mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
0ab71d61d1
commit
2653db10b5
27 files changed
+5746
-6
No files matched your search
+1186
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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`).
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user