feat(story-mode): draw a Claude session's turns as comic pages, live

Story Mode is an opt-in header button (showStoryButton, per device, default
off, kept off phones) that opens the active Claude Code session as comic pages:
one page per prompt, panels for reading code, planning, helpers, edits, tests
(POW/KRAK), questions and saving, each with a plain story caption and a
Director's cut caption naming the tools. Files open in the existing file
preview, the code page opens the exact diff, a test board opens the output,
and a running turn redraws as Claude works.

Server: GET /api/sessions/:id/story (experimental) reads a bounded tail of the
session's own transcript. A pure core (src/story-mode.ts) reduces lines,
joins tool calls with their results, splits turns at prompts and
turn_duration, and groups events into at most 12 panels; Bash calls are
sorted by what the command does. The reader (src/story-reader.ts) caches
reduced entries and reads only appended bytes while the file grows.

Frontend: story-mode.js (canvas frame engine, live refresh from transcript and
hook SSE events plus a poll while a turn runs, details drawer, the wire) and
story-mode.css, with two self-hosted OFL comic fonts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:22:06 +02:00
1 parent 0ab71d61d1
commit 2653db10b5
27 files changed
+5746 -6

No files matched your search

+1186
View File
File diff suppressed because it is too large. Load diff
+159
View File
@@ -0,0 +1,159 @@
/**
* @fileoverview Story Mode transcript reader: the IO half of Story Mode (the pure half is story-mode.ts).
*
* Reads the TAIL of a Claude Code transcript, never the whole file: transcripts reach tens of MB
* (Write/Edit results carry whole files). The first read takes the last `STORY_TAIL_BYTES` and drops
* the partial first line; later reads, while the file only grows, read just the appended bytes. A line
* that is still being written (no newline yet) is left for the next read.
*
* Each transcript's reduced entries are cached (`createStoryReducer()` already dropped file contents,
* images and long outputs), bounded per transcript by entry count and character budget, and across
* transcripts by an LRU, so a live view refetching every second costs one small append read.
* Concurrent reads of one path share a single in-flight promise.
*
* All IO is async (`fs/promises`); nothing here runs on a path the browser supplied. The route decides
* which file is read (story-routes.ts).
*/
import { open, stat } from 'node:fs/promises';
import { LRUMap } from './utils/lru-map.js';
import { createStoryReducer, type StoryEntry, type StoryReducer } from './story-mode.js';
/** How much of the file's end a first read takes. */
export const STORY_TAIL_BYTES = 8 * 1024 * 1024;
/** Entries kept per transcript (the oldest go first). */
const MAX_ENTRIES = 20_000;
/** Approximate characters kept per transcript across all entries. */
const MAX_CHARS = 6_000_000;
/** Transcripts cached at once. */
const MAX_TRANSCRIPTS = 6;
interface CacheEntry {
size: number;
mtimeMs: number;
/** Byte offset up to which complete lines were consumed. */
offset: number;
entries: StoryEntry[];
chars: number;
reducer: StoryReducer;
/** The read began mid-file, so the oldest turn may be cut. */
partial: boolean;
}
export interface StoryEntriesResult {
entries: StoryEntry[];
partial: boolean;
}
const cache = new LRUMap<string, CacheEntry>({ maxSize: MAX_TRANSCRIPTS });
const inFlight = new Map<string, Promise<StoryEntriesResult>>();
/** For tests: forget every cached transcript. */
export function clearStoryReaderCache(): void {
cache.clear();
inFlight.clear();
}
async function readRange(path: string, start: number, end: number): Promise<Buffer> {
const length = Math.max(0, end - start);
const buf = Buffer.alloc(length);
const handle = await open(path, 'r');
try {
let read = 0;
while (read < length) {
const { bytesRead } = await handle.read(buf, read, length - read, start + read);
if (bytesRead === 0) break;
read += bytesRead;
}
return read === length ? buf : buf.subarray(0, read);
} finally {
await handle.close();
}
}
const entryChars = (e: StoryEntry): number => JSON.stringify(e).length;
function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): void {
let begin = 0;
if (startsMidLine) {
const firstNewline = buf.indexOf(0x0a);
if (firstNewline < 0) return; // one huge partial line: nothing complete yet
begin = firstNewline + 1;
}
const lastNewline = buf.lastIndexOf(0x0a);
if (lastNewline < begin) {
// No complete line in what was read; consume nothing beyond a dropped partial head.
entry.offset += begin;
return;
}
const text = buf.subarray(begin, lastNewline).toString('utf8');
entry.offset += lastNewline + 1;
for (const line of text.split('\n')) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue; // a malformed line is skipped, never fatal
}
for (const reduced of entry.reducer.reduce(parsed)) {
entry.entries.push(reduced);
entry.chars += entryChars(reduced);
}
}
while (entry.entries.length > MAX_ENTRIES || (entry.chars > MAX_CHARS && entry.entries.length > 1)) {
const dropped = entry.entries.shift();
if (dropped) entry.chars -= entryChars(dropped);
entry.partial = true;
}
}
async function load(path: string): Promise<StoryEntriesResult> {
const info = await stat(path);
if (!info.isFile()) throw new Error('Transcript is not a regular file');
const cached = cache.get(path);
if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
return { entries: cached.entries, partial: cached.partial };
}
if (
cached &&
info.size > cached.offset &&
info.size - cached.offset <= STORY_TAIL_BYTES &&
info.size >= cached.size
) {
// The file only grew: read what was appended since the last complete line.
const buf = await readRange(path, cached.offset, info.size);
ingest(cached, buf, false);
cached.size = info.size;
cached.mtimeMs = info.mtimeMs;
cache.set(path, cached);
return { entries: cached.entries, partial: cached.partial };
}
// First read, a rewrite, or more appended than a tail holds: start over from the tail.
const start = Math.max(0, info.size - STORY_TAIL_BYTES);
const fresh: CacheEntry = {
size: info.size,
mtimeMs: info.mtimeMs,
offset: start,
entries: [],
chars: 0,
reducer: createStoryReducer(),
partial: start > 0,
};
const buf = await readRange(path, start, info.size);
ingest(fresh, buf, start > 0);
cache.set(path, fresh);
return { entries: fresh.entries, partial: fresh.partial };
}
/**
* Reduced entries for the transcript at `path`, newest last. Throws when the file cannot be read;
* the caller turns that into an error response.
*/
export function readStoryEntries(path: string): Promise<StoryEntriesResult> {
const running = inFlight.get(path);
if (running) return running;
const promise = load(path).finally(() => inFlight.delete(path));
inFlight.set(path, promise);
return promise;
}
+1
View File
@@ -74,3 +74,4 @@ export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './mcp-sync.js';
export * from './story.js';
+169
View File
@@ -0,0 +1,169 @@
/**
* @fileoverview Story Mode types: one Claude Code turn drawn as a comic page.
*
* Produced by the pure core in `src/story-mode.ts` and served by
* `GET /api/sessions/:id/story` (`src/web/routes/story-routes.ts`). The shape is
* EXPERIMENTAL (docs/api-reference.md), not part of the stable `/api/v1` contract yet.
*
* Every string in here comes from a transcript the agent wrote, so it is untrusted
* text: the browser renders it with `textContent` only, and a file path in here is
* opened through the existing file preview, never fetched directly.
*/
/** What one transcript event means, read from the tool name and, for Bash, the command. */
export type StoryIntent =
| 'prompt'
| 'read'
| 'search'
| 'look'
| 'web'
| 'plan'
| 'say'
| 'helper'
| 'message'
| 'edit'
| 'test'
| 'build'
| 'run'
| 'save'
| 'ask'
| 'compact'
| 'interrupt'
| 'tool';
/** The comic panel a run of events is drawn as. */
export type StoryPanelKind =
| 'mission'
| 'investigate'
| 'research'
| 'plan'
| 'backup'
| 'fix'
| 'check'
| 'loop'
| 'run'
| 'save'
| 'ask'
| 'compact'
| 'montage'
| 'complete';
export type StoryActor = 'you' | 'claude' | 'helper' | 'system';
/** Who started a turn: you, a background task reporting back, another session, or a schedule/SDK. */
export type StoryTurnOrigin = 'human' | 'task' | 'peer' | 'system';
/** One hunk of an edit, in the shape of Claude Code's `structuredPatch` (lines keep their ` `/`+`/`-` prefix). */
export interface StoryHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
/** What one Edit/Write call changed. Counts are exact even when `hunks` was cut to size. */
export interface StoryEdit {
file: string;
added: number;
removed: number;
created: boolean;
hunks: StoryHunk[];
truncated: boolean;
}
/** A helper (Agent/Task/Workflow call): what it was asked and what it answered. */
export interface StoryHelper {
name: string;
type: string;
/** Claude's short description of the job ("Write a phone-size test"). */
task?: string;
prompt: string;
result?: string;
toolUses?: number;
durationMs?: number;
}
export interface StoryTests {
passed: number;
failed: number;
}
export interface StoryTodo {
text: string;
done: boolean;
}
/** One row of "the wire": a prompt, a sentence Claude said, or one tool call with its result. */
export interface StoryEvent {
id: string;
at: string;
actor: StoryActor;
tool: string;
label: string;
intent: StoryIntent;
/** False when the tool result was flagged as an error; absent while the call is still running. */
ok?: boolean;
pending?: boolean;
/** The one file this event concerns (Read, Edit, Write), as the transcript gave it. */
file?: string;
line?: number;
/** Claude's own short description of a Bash call ("Run the test suite"). */
description?: string;
output?: string;
outputTruncated?: boolean;
edit?: StoryEdit;
helper?: StoryHelper;
tests?: StoryTests;
todos?: StoryTodo[];
question?: { text: string; options: string[]; answer?: string };
}
export interface StoryPanel {
kind: StoryPanelKind;
/** Indices into the page's `events`. */
events: number[];
caption: { story: string; director: string };
balloon?: { who: 'you' | 'claude'; text: string };
files?: string[];
notes?: string[];
helpers?: Array<{ name: string; type: string; task?: string; event: number }>;
added?: number;
removed?: number;
ok?: boolean;
rounds?: number;
tests?: StoryTests;
todos?: StoryTodo[];
question?: { text: string; answer?: string };
/** The turn is still running and this is its newest panel. */
live?: boolean;
}
export interface StoryTurnSummary {
id: string;
at: string;
prompt: string;
origin: StoryTurnOrigin;
complete: boolean;
interrupted: boolean;
toolCalls: number;
}
export interface StoryPage extends StoryTurnSummary {
endedAt?: string;
durationMs?: number;
/** The turn began before the part of the transcript that was read. */
partial: boolean;
/** Older events, outputs or hunks were dropped to keep the page small. */
truncated: boolean;
panels: StoryPanel[];
events: StoryEvent[];
filesChanged: number;
added: number;
removed: number;
helpers: number;
}
export type StoryResponse =
| { supported: false; reason: string }
| { supported: true; turns: StoryTurnSummary[]; page: StoryPage | null };
+26 -1
View File
@@ -22,7 +22,7 @@
* recoverable pane into the collision this module exists to prevent.
*
* @dependencies none
* @consumedby session (relaunch resume pinning)
* @consumedby session (relaunch resume pinning), web/routes/story-routes (Story Mode transcript lookup)
*
* @module utils/claude-transcript
*/
@@ -47,6 +47,31 @@ export function claudeProjectsDir(configDir?: string): string {
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
}
/**
* Path of the transcript for `conversationId` under any project directory, or null. Unlike
* {@link claudeTranscriptExists} it answers with the file (Story Mode reads it), so the id is
* checked against a strict charset before it is joined into a path, and only a regular file counts.
*/
export async function findClaudeTranscriptPath(conversationId: string, configDir?: string): Promise<string | null> {
if (!/^[a-zA-Z0-9._-]+$/.test(conversationId) || conversationId.startsWith('.')) return null;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return null;
}
for (const projectDir of projectDirs) {
const candidate = join(projectsDir, projectDir, `${conversationId}.jsonl`);
try {
if ((await stat(candidate)).isFile()) return candidate;
} catch {
// Not in this project directory; keep looking.
}
}
return null;
}
/**
* True when a transcript for `conversationId` exists under any project
* directory. Returns false for a missing projects dir or an unreadable one,
+3 -1
View File
@@ -54,6 +54,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): drawer first, then the overlay, and nothing while the file preview or folder grid is open on top. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. Tests: `test/story-mode.test.ts`, `test/routes/story-routes.test.ts`.
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
@@ -86,6 +88,6 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), Story Mode overlay (2400, below the connection-loss overlay so a lost server still shows), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
+12
View File
@@ -2197,6 +2197,18 @@ class CodemanApp {
addListener(event, () => this._onSessionListMaybeChanged());
}
// Story Mode (story-mode.js): any sign of transcript activity refreshes an open story for that
// session. Extra listeners like the ones above, so the handler map stays one method per event.
// No transcript `complete` event: interactive transcripts carry no `result` entry to raise it.
for (const event of [
SSE_EVENTS.TRANSCRIPT_TOOL_START,
SSE_EVENTS.TRANSCRIPT_TOOL_END,
SSE_EVENTS.HOOK_STOP,
SSE_EVENTS.HOOK_PROMPT_SUBMITTED,
]) {
addListener(event, (e) => this._storyOnActivity?.(e));
}
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => {
try {
@@ -0,0 +1,193 @@
Story Mode comic fonts (src/web/public/fonts/bowlby-one-sc-latin.woff2, src/web/public/fonts/patrick-hand-sc-latin.woff2).
Both are the Latin subsets served by Google Fonts, unmodified, under the SIL Open Font License 1.1.
=== Bowlby One SC ===
Copyright (c) 2011 by vernon adams (vern@newtypography.co.uk),
with Reserved Font Names "Bowlby" "Bowlby One" and "Bowlby One SC"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
=== Patrick Hand SC ===
Copyright 2012 The Patrick Hand Authors (mail@patrickwagesreiter.at)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -17,6 +17,7 @@
<title>Codeman</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="story-mode.css">
<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
@@ -181,6 +182,7 @@
</div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-story-header btn-story-header--hidden" onclick="app.toggleStoryMode()" title="Story Mode: this session as a comic" aria-label="Story Mode"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="18" height="8" rx="1"/></svg></button>
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
<button class="btn-icon-header btn-approvals btn-approvals--hidden" id="approvalsBtn" onclick="app.toggleApprovalsInbox()" title="Approvals waiting on you" aria-label="Open approvals inbox" aria-expanded="false">
@@ -2067,11 +2069,12 @@
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
<div class="set-group-body">
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response story file viewer attachments monitor session away cron redraw split tiles">
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
<label class="set-chip" data-preview="header" data-preview-order="4"><input type="checkbox" id="appSettingsShowResponseViewer"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Response Viewer</span></label>
<label class="set-chip" data-search="story mode comic panels" data-preview="header" data-preview-order="4.5"><input type="checkbox" id="appSettingsShowStoryButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="18" height="8" rx="1"/></svg><span>Story Mode</span></label>
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
<label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label>
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
@@ -4272,6 +4275,7 @@
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="story-mode.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+4 -1
View File
@@ -589,7 +589,10 @@ html.mobile-init .file-browser-panel {
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
(inline approve/deny in mobile-overview.js); the bell would only crowd the
header it was designed to stay out of. */
.btn-icon-header.btn-approvals {
.btn-icon-header.btn-approvals,
/* Story Mode: opt-in and desktop-first for now; the overlay itself is responsive, a phone
entry point is a follow-up rather than one more icon in this header. */
.btn-icon-header.btn-story-header {
display: none !important;
}
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
+6 -1
View File
@@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowStoryButton').checked = settings.showStoryButton ?? defaults.showStoryButton ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
@@ -2634,6 +2635,7 @@ Object.assign(CodemanApp.prototype, {
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showStoryButton: document.getElementById('appSettingsShowStoryButton').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
@@ -4137,6 +4139,9 @@ Object.assign(CodemanApp.prototype, {
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
}
// Story Mode (story-mode.js): opt-in, default OFF; turning it off also closes an open story.
this.applyStoryButtonVisibility?.();
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
// marker class, base is display:inline-flex !important.
const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
@@ -4600,7 +4605,7 @@ Object.assign(CodemanApp.prototype, {
_displaySettingKeySet() {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showLifecycleLog', 'showResponseViewer', 'showStoryButton', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
+848
View File
@@ -0,0 +1,848 @@
/* Story Mode (story-mode.js): a session's turns as comic pages.
Layout: a full-screen overlay (bar on top, the comic sheet below, "the wire" beside it in the
Director's cut) with a details drawer on the right. The comic keeps its own palette, the four
process inks, so it reads the same on every Codeman skin; a light skin gets the paper variant.
Fonts are self-hosted (CSP font-src 'self'); licenses in fonts/LICENSE-story-fonts.txt. */
@font-face {
font-family: 'Codeman Story Display';
src: url('fonts/bowlby-one-sc-latin.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Codeman Story Letter';
src: url('fonts/patrick-hand-sc-latin.woff2') format('woff2');
font-display: swap;
}
.story-overlay {
/* Night edition: dark panels, light ink for marks drawn on them, dark line art on objects. */
--story-bg: #0d0d12;
--story-sheet: #15151c;
--story-panel: #1c1c25;
--story-ink: #edeae4;
--story-line: #121016;
--story-muted: #a49fae;
--story-rule: #2e2d38;
--story-cyan: #33b7ea;
--story-magenta: #ff4e97;
--story-yellow: #ffd43d;
--story-go: #3ec27c;
--story-pencil: #4f86b8;
--story-balloon: #f3f1ec;
--story-balloon-ink: #121016;
--story-cap: #ffd43d;
--story-cap-ink: #121016;
--story-display: 'Codeman Story Display', Impact, 'Arial Black', sans-serif;
--story-letter: 'Codeman Story Letter', 'Comic Sans MS', 'Chalkboard SE', cursive;
--story-body: 'Manrope', system-ui, sans-serif;
--story-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--story-drawer-w: min(560px, 44vw);
position: fixed;
inset: 0;
z-index: 2400;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
background: var(--story-bg);
color: var(--story-ink);
font-family: var(--story-body);
color-scheme: dark;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.story-overlay.story-paper {
--story-bg: #ecede9;
--story-sheet: #fbfbf9;
--story-panel: #ffffff;
--story-ink: #18151d;
--story-line: #18151d;
--story-muted: #5f5a68;
--story-rule: #d5d3da;
--story-cyan: #0e9fd6;
--story-magenta: #e0237a;
--story-yellow: #ffd019;
--story-go: #1a9a55;
--story-pencil: #5fa6df;
--story-balloon: #ffffff;
--story-balloon-ink: #18151d;
--story-cap: #ffd019;
--story-cap-ink: #18151d;
color-scheme: light;
}
.story-overlay[hidden] {
display: none;
}
body.story-open {
overflow: hidden;
}
.story-overlay button {
font: inherit;
color: inherit;
cursor: pointer;
}
.story-overlay :focus-visible {
outline: 3px solid var(--story-cyan);
outline-offset: 2px;
}
/* Bar */
.story-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 10px max(16px, env(safe-area-inset-right, 0px)) 10px max(16px, env(safe-area-inset-left, 0px));
border-bottom: 2px solid var(--story-ink);
background: var(--story-sheet);
}
.story-brand {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
flex: 1 1 180px;
}
.story-title {
font: 28px/1 var(--story-display);
color: var(--story-yellow);
-webkit-text-stroke: 1.5px var(--story-line);
paint-order: stroke fill;
text-shadow: 3px 3px 0 var(--story-magenta);
}
.story-session {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 500 12.5px/1.2 var(--story-mono);
color: var(--story-muted);
}
.story-nav {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1 1 200px;
}
.story-turns {
min-width: 0;
width: 100%;
flex: 1;
max-width: 420px;
padding: 6px 8px;
font: 500 12.5px/1.2 var(--story-mono);
color: var(--story-ink);
background: var(--story-panel);
border: 2px solid var(--story-ink);
border-radius: 0;
}
.story-btn {
display: inline-grid;
place-items: center;
min-width: 34px;
height: 34px;
padding: 0 8px;
font: 20px/1 var(--story-body) !important;
background: var(--story-panel);
border: 2px solid var(--story-ink);
border-radius: 0;
}
.story-btn:disabled {
opacity: 0.35;
cursor: default;
}
.story-seg {
display: inline-flex;
border: 2.5px solid var(--story-ink);
}
.story-seg-btn {
padding: 6px 12px 5px;
font: 18px/1 var(--story-letter) !important;
background: transparent;
border: 0;
}
.story-seg-btn[aria-pressed='true'] {
background: var(--story-ink);
color: var(--story-sheet) !important;
}
.story-live {
display: inline-flex;
align-items: center;
gap: 7px;
font: 600 12px/1 var(--story-mono);
letter-spacing: 0.1em;
color: var(--story-magenta);
}
.story-live[hidden] {
display: none;
}
.story-live i {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--story-magenta);
animation: story-blink 1s steps(2, start) infinite;
}
@keyframes story-blink {
to {
visibility: hidden;
}
}
/* Main: the sheet and the wire */
.story-main {
overflow: auto;
padding: 16px max(16px, env(safe-area-inset-right, 0px)) 40px max(16px, env(safe-area-inset-left, 0px));
}
.story-overlay.has-drawer .story-main {
padding-right: calc(var(--story-drawer-w) + 16px);
}
.story-note {
max-width: 1240px;
margin: 0 auto 14px;
padding: 8px 12px;
font-size: 14px;
line-height: 1.45;
color: var(--story-ink);
border: 2px dashed var(--story-pencil);
}
.story-note[hidden] {
display: none;
}
.story-stage {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
align-items: start;
max-width: 1240px;
margin: 0 auto;
}
.story-overlay.is-director .story-stage {
grid-template-columns: minmax(0, 1fr) 320px;
}
@media (max-width: 1100px) {
.story-overlay.is-director .story-stage {
grid-template-columns: minmax(0, 1fr);
}
}
.story-sheet {
container: story-sheet / inline-size;
background: var(--story-sheet);
}
.story-page {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 270px;
gap: 14px;
padding: 14px;
}
/* Panels */
.story-panel {
position: relative;
margin: 0;
overflow: hidden;
grid-column: span var(--story-span6, 2);
background: var(--story-panel);
border: 3px solid var(--story-ink);
}
.story-panel canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.story-panel.is-live {
border-color: var(--story-magenta);
}
.story-hit {
position: absolute;
z-index: 1;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
}
.story-hit:hover,
.story-hit:focus-visible {
outline: 3px dashed var(--story-magenta) !important;
outline-offset: 2px;
}
.story-cap {
position: absolute;
z-index: 2;
left: 10px;
top: 10px;
margin: 0;
max-width: min(64%, 34ch);
padding: 5px 9px 6px;
background: var(--story-cap);
color: var(--story-cap-ink);
border: 2.5px solid var(--story-cap-ink);
font: 17px/1.13 var(--story-letter);
text-wrap: pretty;
}
.story-cap-dir {
display: none;
}
.story-overlay.is-director .story-cap {
max-width: min(74%, 46ch);
font: 500 11px/1.45 var(--story-mono);
}
.story-overlay.is-director .story-cap-story {
display: none;
}
.story-overlay.is-director .story-cap-dir {
display: inline;
}
.story-balloon {
position: absolute;
z-index: 3;
margin: 0;
max-width: 42%;
padding: 9px 15px 10px;
border-radius: 22px;
background: var(--story-balloon);
color: var(--story-balloon-ink);
border: 2.5px solid var(--story-balloon-ink);
font: 17px/1.12 var(--story-letter);
text-wrap: balance;
overflow-wrap: anywhere;
}
.story-balloon[hidden] {
display: none;
}
.story-balloon::before,
.story-balloon::after {
content: '';
position: absolute;
}
.story-balloon--claude {
right: 18%;
top: 28%;
}
.story-balloon--claude::before {
right: 22px;
bottom: -19px;
width: 22px;
height: 19px;
background: var(--story-balloon-ink);
clip-path: polygon(0 0, 100% 0, 85% 100%);
}
.story-balloon--claude::after {
right: 25.5px;
bottom: -14px;
width: 15px;
height: 16.5px;
background: var(--story-balloon);
clip-path: polygon(0 0, 100% 0, 84% 100%);
}
.story-balloon--you {
left: 16px;
top: 74px;
max-width: 54%;
}
.story-balloon--you::before {
left: -22px;
top: 34%;
width: 22px;
height: 22px;
background: var(--story-balloon-ink);
clip-path: polygon(100% 0, 100% 100%, 0 75%);
}
.story-balloon--you::after {
left: -15px;
top: calc(34% + 3.5px);
width: 17.5px;
height: 15px;
background: var(--story-balloon);
clip-path: polygon(100% 0, 100% 100%, 0 76%);
}
.story-panel[data-kind='investigate'] .story-balloon--claude {
right: auto;
left: 30%;
top: 30%;
max-width: 38%;
}
.story-panel[data-kind='backup'] .story-balloon--claude {
right: auto;
left: 56%;
top: 25%;
max-width: 40%;
}
.story-panel[data-kind='ask'] .story-balloon--claude {
right: auto;
left: 14px;
top: 30%;
max-width: 46%;
}
.story-answer {
position: absolute;
z-index: 3;
left: 14px;
bottom: 14px;
margin: 0;
max-width: 50%;
padding: 5px 9px 4px;
font: 13px/1.2 var(--story-display);
letter-spacing: 0.03em;
color: var(--story-go);
background: var(--story-panel);
border: 3px solid var(--story-go);
transform: rotate(-4deg);
overflow-wrap: anywhere;
}
.story-answer[hidden],
.story-live-tag[hidden] {
display: none;
}
.story-live-tag {
position: absolute;
z-index: 4;
right: 8px;
top: 8px;
padding: 3px 7px 2px;
font: 600 10.5px/1 var(--story-mono);
letter-spacing: 0.1em;
color: #fff;
background: var(--story-magenta);
}
.story-splash {
position: absolute;
z-index: 2;
left: 12px;
top: 12px;
bottom: 12px;
width: 46%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
}
.story-splash .story-cap,
.story-splash .story-balloon {
position: static;
align-self: flex-start;
max-width: 100%;
}
.story-splash .story-balloon::before,
.story-splash .story-balloon::after {
display: none;
}
.story-splash-title {
margin: 0;
font: 400 clamp(28px, 4.4cqi, 44px) / 0.92 var(--story-display);
color: var(--story-yellow);
-webkit-text-stroke: 2.5px var(--story-line);
paint-order: stroke fill;
text-shadow: 4px 4px 0 var(--story-line);
transform: rotate(-3deg);
transform-origin: left bottom;
}
.story-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.story-chips li > * {
display: block;
padding: 4px 8px;
font: 500 12px/1.2 var(--story-mono) !important;
background: var(--story-balloon);
color: var(--story-balloon-ink) !important;
border: 2px solid var(--story-line);
}
.story-chips button {
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.story-chips button:hover {
background: var(--story-yellow);
}
.story-pencils {
grid-column: span var(--story-span6, 2);
display: grid;
place-content: center;
gap: 4px;
text-align: center;
color: var(--story-pencil);
border: 2.5px dashed var(--story-pencil);
}
.story-pencils span {
font: 26px/1 var(--story-letter);
letter-spacing: 0.08em;
}
.story-pencils small {
font: 12px/1.3 var(--story-mono);
}
.story-fresh .story-cap,
.story-fresh .story-splash-title {
animation: story-pop 0.45s cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
.story-fresh .story-balloon,
.story-fresh .story-chips {
animation: story-pop 0.45s 0.35s cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
@keyframes story-pop {
from {
opacity: 0;
transform: scale(0.6);
}
to {
opacity: 1;
}
}
@container story-sheet (max-width: 1000px) {
.story-page {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.story-panel {
grid-column: span var(--story-span2, 1);
}
.story-pencils {
grid-column: span var(--story-span2, 1);
}
}
@container story-sheet (max-width: 600px) {
.story-page {
grid-template-columns: minmax(0, 1fr);
grid-auto-rows: auto;
gap: 10px;
padding: 10px;
}
.story-panel,
.story-pencils {
grid-column: auto;
height: 300px;
}
.story-panel[data-kind='complete'] {
height: 430px;
}
.story-splash {
right: 12px;
bottom: auto;
width: auto;
}
.story-splash-title {
font-size: 36px;
}
.story-balloon {
max-width: 52%;
}
}
/* The wire */
.story-wire {
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 120px);
position: sticky;
top: 0;
background: var(--story-sheet);
border: 2.5px solid var(--story-ink);
}
.story-wire[hidden] {
display: none;
}
@media (max-width: 1100px) {
.story-wire {
position: static;
max-height: 460px;
}
}
.story-wire-head {
padding: 11px 13px 10px;
border-bottom: 2px solid var(--story-ink);
}
.story-wire-title {
margin: 0;
font: 22px/1 var(--story-letter);
}
.story-wire-sub {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.4;
color: var(--story-muted);
}
.story-wire-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 4px 0 10px;
list-style: none;
overflow: auto;
}
.story-wire-group {
padding: 12px 13px 3px;
font: 16px/1.1 var(--story-letter);
color: var(--story-muted);
}
.story-ev {
display: grid;
grid-template-columns: 62px minmax(0, 1fr);
gap: 8px;
padding: 4px 13px;
font: 11.5px/1.45 var(--story-mono);
font-variant-numeric: tabular-nums;
}
.story-ev-time {
color: var(--story-muted);
}
.story-ev-body {
overflow-wrap: anywhere;
}
.story-ev-who {
color: var(--story-muted);
}
.story-link {
padding: 0;
border: 0;
background: none;
text-align: left;
font: inherit !important;
text-decoration: underline dotted;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
overflow-wrap: anywhere;
}
.story-link:hover {
color: var(--story-yellow) !important;
}
.story-tag {
display: inline-block;
padding: 0 5px;
font-style: normal;
font-size: 10.5px;
color: var(--story-muted);
border: 1.5px solid currentColor;
border-radius: 3px;
}
.story-tag--bad {
color: var(--story-magenta);
}
.story-tag--ok {
color: var(--story-go);
}
.story-tag--pending {
color: var(--story-yellow);
}
/* Drawer */
.story-drawer {
position: absolute;
z-index: 5;
top: 0;
right: 0;
bottom: 0;
width: var(--story-drawer-w);
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
background: var(--story-sheet);
border-left: 3px solid var(--story-ink);
}
.story-drawer[hidden] {
display: none;
}
@media (max-width: 900px) {
.story-overlay {
--story-drawer-w: 100vw;
}
.story-overlay.has-drawer .story-main {
padding-right: max(16px, env(safe-area-inset-right, 0px));
}
.story-drawer {
top: auto;
left: 0;
width: auto;
height: 82vh;
border-left: 0;
border-top: 3px solid var(--story-ink);
padding-left: env(safe-area-inset-left, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
.story-drawer-head {
position: relative;
display: grid;
gap: 6px;
padding: 14px 60px 12px 18px;
border-bottom: 2px solid var(--story-ink);
}
.story-drawer-meta {
display: flex;
align-items: center;
gap: 10px;
}
.story-drawer-kind {
padding: 3px 8px 2px;
font: 16px/1 var(--story-letter);
background: var(--story-cap);
color: var(--story-cap-ink);
border: 2px solid var(--story-cap-ink);
}
.story-drawer-title {
margin: 0;
font: 500 13.5px/1.35 var(--story-mono);
overflow-wrap: anywhere;
}
.story-drawer-sub {
margin: 0;
font-size: 13.5px;
line-height: 1.4;
color: var(--story-muted);
}
.story-drawer-close {
position: absolute;
right: 12px;
top: 12px;
}
.story-drawer-body {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 0 28px;
}
.story-btn-s {
padding: 4px 10px 3px;
font: 15px/1 var(--story-letter) !important;
background: var(--story-panel);
border: 2px solid var(--story-ink);
}
.story-df-head,
.story-out-head {
position: sticky;
top: -4px;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
padding: 12px 18px 8px;
background: var(--story-sheet);
border-bottom: 1.5px solid var(--story-rule);
}
.story-df + .story-df .story-df-head,
.story-out + .story-out .story-out-head {
border-top: 2px solid var(--story-ink);
}
.story-df-path,
.story-out-cmd {
flex: 1 1 220px;
font: 500 12.5px/1.35 var(--story-mono);
overflow-wrap: anywhere;
}
.story-cnt {
font: 500 12.5px/1 var(--story-mono);
}
.story-cnt-a {
color: var(--story-go);
}
.story-cnt-d {
color: var(--story-magenta);
}
.story-code {
font: 12px/1.65 var(--story-mono);
}
.story-ln {
display: grid;
grid-template-columns: 3.2em 3.2em 1.6em minmax(0, 1fr);
padding-right: 14px;
}
.story-ln-no {
padding-right: 8px;
text-align: right;
color: var(--story-muted);
user-select: none;
font-variant-numeric: tabular-nums;
}
.story-ln-mk {
text-align: center;
color: var(--story-muted);
user-select: none;
}
.story-ln-tx {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.story-ln--add {
background: color-mix(in srgb, var(--story-go) 17%, transparent);
}
.story-ln--add .story-ln-mk {
color: var(--story-go);
}
.story-ln--del {
background: color-mix(in srgb, var(--story-magenta) 15%, transparent);
}
.story-ln--del .story-ln-mk {
color: var(--story-magenta);
}
.story-ln--hunk {
display: block;
padding: 2px 18px;
color: var(--story-muted);
background: color-mix(in srgb, var(--story-cyan) 12%, transparent);
}
.story-arrive {
animation: story-arrive 1.1s ease-out both;
}
@keyframes story-arrive {
0% {
clip-path: inset(0 100% 0 0);
background: color-mix(in srgb, var(--story-yellow) 45%, transparent);
}
45% {
clip-path: inset(0 0 0 0);
background: color-mix(in srgb, var(--story-yellow) 45%, transparent);
}
}
.story-out-desc,
.story-empty,
.story-answer-line {
margin: 0;
padding: 8px 18px 0;
font-size: 13.5px;
color: var(--story-muted);
}
.story-pre {
margin: 0;
padding: 8px 18px;
font: 12px/1.6 var(--story-mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.story-h {
margin: 16px 18px 0;
font: 17px/1.1 var(--story-letter);
}
.story-todos {
margin: 8px 0 0;
padding: 0 18px 0 40px;
font-size: 14.5px;
line-height: 1.6;
}
.story-todos .is-done {
color: var(--story-go);
text-decoration: line-through;
}
@media (prefers-reduced-motion: reduce) {
.story-fresh *,
.story-arrive,
.story-live i {
animation: none !important;
}
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -13257,6 +13257,15 @@ kbd {
display: none !important;
}
/* "Story Mode" header toggle (story-mode.js, App Settings → Header buttons), opt-in. Same marker
pattern as the Response Viewer; mobile.css keeps it off phones. */
.btn-story-header {
display: inline-flex !important;
}
.btn-story-header.btn-story-header--hidden {
display: none !important;
}
/* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles
the marker class on count changes), so it ships hidden and stays out of the
default header. Same marker pattern as the attachments button. */
+1
View File
@@ -15,6 +15,7 @@ export { registerApprovalRoutes } from './approval-routes.js';
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerStoryRoutes } from './story-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerWorktreeRoutes } from './worktree-routes.js';
+76
View File
@@ -0,0 +1,76 @@
/**
* @fileoverview `GET /api/sessions/:id/story`: one turn of a Claude Code session as a comic page
* (Story Mode, `story-mode.js` in the browser). EXPERIMENTAL: the payload shape is not frozen.
*
* Query: `turn=<id>` picks a turn from the `turns` list; without it the newest turn is drawn.
* Answers `{ supported: false, reason }` for a session it cannot draw, decided from the CLI's
* registry capability (`capabilities.transcript === 'claude-jsonl'`, never the mode id) and from
* where the transcript lives: a remote or Docker session's transcript is not on this host.
*
* Read-only: unlike `last-response` it never adopts a conversation id or persists anything. The file
* read is the session's own transcript, chosen here and never named by the request: the path the
* Claude hook reported for this session, else `<conversation id>.jsonl` under the projects directory.
* Reading is bounded and async (story-reader.ts); what comes back is untrusted text the browser
* renders with `textContent`, and file paths in it open through the existing file preview.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, type ApiResponse, type StoryResponse } from '../../types.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
import { readStoryEntries } from '../../story-reader.js';
import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js';
import { findSessionOrFail } from '../route-helpers.js';
import type { ConfigPort, SessionPort } from '../ports/index.js';
/** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */
async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise<string | null> {
if (hookPath && hookPath.startsWith('/') && hookPath.endsWith('.jsonl')) return hookPath;
return findClaudeTranscriptPath(conversationId);
}
export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & ConfigPort): void {
app.get('/api/sessions/:id/story', async (req, reply): Promise<ApiResponse<StoryResponse>> => {
const { id } = req.params as { id: string };
const { turn } = req.query as { turn?: unknown };
const session = findSessionOrFail(ctx, id, req);
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') {
return {
success: true,
data: { supported: false, reason: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' },
};
}
if (session.remote || session.docker) {
return {
success: true,
data: {
supported: false,
reason:
'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.',
},
};
}
const transcriptPath = await resolveTranscriptPath(
ctx.getTranscriptPath(id),
session.claudeSessionId || session.id
);
if (!transcriptPath) return { success: true, data: { supported: true, turns: [], page: null } };
let entries;
try {
({ entries } = await readStoryEntries(transcriptPath));
} catch {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript');
}
const turns = buildStoryTurns(entries);
const wanted = typeof turn === 'string' && turn ? turns.find((t) => t.id === turn) : turns[turns.length - 1];
return {
success: true,
data: { supported: true, turns: summarizeTurns(turns), page: wanted ? buildStoryPage(wanted) : null },
};
});
}
+1
View File
@@ -1584,6 +1584,7 @@ export const SettingsUpdateSchema = z
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
showResponseViewer: z.boolean().optional(),
showStoryButton: z.boolean().optional(),
showMonitor: z.boolean().optional(),
showProjectInsights: z.boolean().optional(),
showFileBrowser: z.boolean().optional(),
+2
View File
@@ -189,6 +189,7 @@ import {
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
registerStoryRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -1152,6 +1153,7 @@ export class WebServer extends EventEmitter {
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
registerStoryRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);