Roleplay and collaborative storytelling: a Storyteller agent, world tools, lorebook hooks and a scene pane for Claude Code

Roleplay and collaborative storytelling inside the Claude Code TUI. A Storyteller (Vex, by default) narrates, plays the cast and keeps the world straight; the story lives as plain Markdown files you can read and edit.
What the system does and how it behaves is in docs/spec.md.
PATH as claude, signed inbun install
bun link # puts `rp` on your PATH
rp install # creates ~/.claude-roleplay/{stories,library,.claude/skills}
Or skip the link and run it from the repo with bun run rp <args>. rp install only creates missing folders and never writes content, so it is safe to run again; it prints each folder and whether it existed. rp new does the same on its way.
rp # pick a story from a list, or start a new one
rp new saltmere # a blank story; the Storyteller interviews you, then opens scene one
rp saltmere # later: continue the last session for that story
rp saltmere --new # start a fresh session (the story itself carries over)
rp list # your stories as plain text, last played first
rp prompt saltmere # print the story bible the Storyteller sees
rp on its own lists your stories, the last played first, each with its current scene and when you last played it. Move with the arrow keys or j/k, press Enter to play (the same as rp <story>), Esc or q to quit. The last row, + new story, asks for a folder name and goes on as rp new <name>; with no stories yet, rp asks for one straight away. To switch stories, leave the session and run rp again.
rp <story> also takes a folder path, so you can try the bundled example without copying it: rp stories/the-hollow-crown.
Other options: --model <m> for one session, and anything after -- goes straight to claude, e.g. rp saltmere -- --verbose.
Talk in character by default. Prefix a message with (( to step out and talk to the Storyteller about the story itself.
Our commands all start with storyteller:. Type /storyteller:recap for a short recap of the story so far and what is still unresolved, /storyteller:scene for the scene pane, and /storyteller:directives to switch, edit, add or delete directives.
~/.claude-roleplay/stories/<name>/, one folder per story. Set RP_STORIES to use another folder.~/.claude-roleplay/library/ (characters/, lore/, directives/). Set RP_LIBRARY to use another folder.~/.claude-roleplay/.claude/skills/ (empty to begin with). Claude Code reads it because the stories folder sits inside it; a story's own .claude/skills/ works too.story.md, characters/, lore/, directives/ and scenes/. The layout is in spec section 5.Inside a session, open /config and find the storyteller plugin settings: notes frequency and model, narrator model and effort, embeddings, the scene pane, and the context budget. They are stored in your Claude Code settings.json and apply from the next launch. --model on the command line wins for one session.
The status line under the prompt is always on. By default it shows two lines: the model with its effort, the Storyteller with the register, your character, the story and the scene (Model: Opus 5.5 (medium) | Narrator: Vex (copilot) | Persona: asset1 | Story: Build Failed Successfully | Scene: Scene 1: Spawn Point); then context used, plan usage (five-hour and weekly, on Pro and Max plans), turns since the notes were updated and whether the log saved. Labels are dark and values bold; each bar is green under 60%, yellow to 80% and red above. To lay out your own, edit ~/.claude-roleplay/statusline.json by hand and check it with bun plugin/statusline.ts --check. The format, the default layout as a file and the list of sources are in spec section 19.5.
bun run verify # typecheck, lint, tests
bun scripts/smoke.ts # opt-in: one real model call against the example storymod/register.tsx 13 lines1import type { Register } from "claude-code";
2import { registerNotes } from "./notes.ts";
3import { registerStage } from "./stage.tsx";
4
5// The plugin's function-hooks module (a Claude Code "mod"). Two concerns, two
6// files: notes.ts keeps the scene notes current in the background (spec 7.2);
7// stage.tsx draws the pane, labels and quiet lines (spec 10). This file only
8// wires them so each can be owned and tested on its own.
9export const register: Register = (on, options) => {
10 registerNotes(on, options);
11 registerStage(on, options);
12};
13mod/notes.ts 515 lines1import type { EngineInterface, PluginOptions, Register } from "claude-code";
2
3// The background notes job (spec 7.2). After a completed narrator turn, every
4// `notesEvery` turns, ask the notes model to rewrite the current scene's
5// `## Now` and `## Notes` from the previous notes and the newest log turns
6// (shape and rules: plugin/prompts/notes.md), write them into scene.md, and
7// re-embed the scene for recall.
8//
9// The mod runs in its own environment with no Node and cannot import from
10// src/ or server/, so the little it needs (frontmatter fields, log lines,
11// the section rule of server/edit.ts setSection) is restated here, and every
12// file access goes through $.fs. The job never throws out of the hook: any
13// failure is one dim transcript line.
14//
15// It runs on a timer started from turn.complete rather than inside the hook,
16// so the turn ends at once, and it waits for the Stop hook (a settings hook,
17// which may finish before or after turn.complete) to append the turn to
18// log.jsonl. No id ties the two together: turn.complete's turnId is minted by
19// the engine at turn.start, while the Stop hook keys on the transcript row
20// uuid of the prompt (state.json lastLogged), which no mod event carries. So
21// the job marks what it saw at turn.complete and accepts any of three signs
22// that this turn was logged (waitForLoggedTurn).
23
24type Engine = EngineInterface;
25
26export type NotesSettings = { every: number; model: string };
27
28// state.json keys this job owns; the hooks keep theirs (turn, injected, ...).
29export type NotesState = { turn?: number; notesTurn?: number; notesUpdatedAt?: number };
30
31const logWaitMs = 500;
32const logWaitTries = 40;
33const reindexTimeoutMs = 120_000;
34const modelTimeoutMs = 180_000;
35
36export function notesSettings(options: PluginOptions): NotesSettings {
37 const every =
38 typeof options.notesEvery === "number" ? Math.max(1, Math.floor(options.notesEvery)) : 1;
39 const model =
40 typeof options.notesModel === "string" && options.notesModel.trim()
41 ? options.notesModel.trim()
42 : "haiku";
43 return { every, model };
44}
45
46export const registerNotes: Register = (on, options) => {
47 const settings = notesSettings(options);
48 // One job at a time: a quick second turn queues behind the first, so two
49 // jobs never read the same notes and overwrite each other.
50 let queue: Promise<void> = Promise.resolve();
51 // The prompt of the turn in flight, so a turn that is talk about the story
52 // (copilot) or a slash command is skipped without waiting on the log: the
53 // Stop hook never logs a command at all.
54 let started: { turnId: string; text: string } | undefined;
55 on("turn.start", (_$, e, next) => {
56 started = { turnId: e.turnId, text: e.text };
57 return next(e);
58 });
59 // The matcher also keeps this registration distinct from the stage's own
60 // turn.complete hook: the engine refuses two unmatched hooks on one event.
61 on("turn.complete", { reason: "answer" }, async ($, e, next) => {
62 const result = await next(e);
63 const prompt = started?.turnId === e.turnId ? started.text : undefined;
64 if (e.agentId === undefined && e.answer.trim() && !isOutOfStory(prompt)) {
65 $.clock.after(0, () => {
66 // Marked before queueing: a job still running from the last turn
67 // must not delay what "before this turn was logged" means.
68 const mark = markTurn($, e.durationMs).catch(() => undefined);
69 queue = queue.then(async () =>
70 runNotesJob($, settings, e.answer, await mark).catch((error: unknown) => {
71 $.ui.log(`notes not updated (${errorText(error)})`);
72 }),
73 );
74 });
75 }
76 return result;
77 });
78};
79
80// A copilot prompt (the rule of src/log.ts registerOf) or a slash command.
81export function isOutOfStory(prompt: string | undefined): boolean {
82 if (prompt === undefined) return false;
83 const text = prompt.trim();
84 return text.startsWith("((") || text.startsWith("/") || text.includes("[register: copilot]");
85}
86
87export async function runNotesJob(
88 $: Engine,
89 settings: NotesSettings,
90 answer: string,
91 mark?: TurnMark,
92): Promise<void> {
93 const storyDir = slash(await $.session.cwd());
94 if (!(await $.fs.exists(`${storyDir}/story.md`))) return;
95 const scene = await currentScene($, storyDir);
96 if (!scene || scene.status === "closed") return;
97
98 const wait = await waitForLoggedTurn($, storyDir, scene.logPath, answer, mark);
99 if (wait.kind === "unlogged") return;
100 if (wait.kind === "timeout") {
101 await appendHookError($, storyDir, waitFailure(wait, answer, mark, await $.clock.now()));
102 throw new Error(
103 `turn not logged within ${(logWaitMs * logWaitTries) / 1000}s; see .rp/hook-errors.log`,
104 );
105 }
106 const turns = wait.turns;
107 // Talk about the story is not story: no notes, and not counted.
108 if (turns.at(-1)?.register === "copilot") return;
109
110 const state = await readState($, storyDir);
111 const notesTurn = (state.notesTurn ?? 0) + 1;
112 await mergeState($, storyDir, { notesTurn });
113 if (notesTurn % settings.every !== 0) return;
114
115 const storyText = await $.fs.read(`${storyDir}/story.md`);
116 const sceneText = await $.fs.read(scene.path);
117 const persona =
118 field(frontmatter(sceneText), "persona") ?? field(frontmatter(storyText), "persona");
119 const cast = (await characterStems($, storyDir, storyText)).filter((s) => s !== persona);
120 const prompt = notesPrompt({
121 persona: persona ?? "",
122 cast,
123 previous: previousNotes(bodyOf(sceneText)),
124 turns: renderTurns(lastExchanges(turns, 2 * settings.every + 2)),
125 });
126
127 const reply = await $.model.complete({
128 model: settings.model,
129 system: await $.fs.read(`${slash($.plugin.root)}/prompts/notes.md`),
130 prompt,
131 maxTokens: 4096,
132 timeoutMs: modelTimeoutMs,
133 });
134 if (!reply.isAnswered) throw new Error(`the notes model gave no reply (${reply.reason})`);
135 const notes = parseNotes(reply.text);
136 if (!notes) throw new Error("the notes reply did not have ## Now and ## Notes");
137
138 // Re-read: the player or a world tool may have changed scene.md meanwhile.
139 const current = await $.fs.read(scene.path);
140 await $.fs.write(scene.path, withNotes(current, notes));
141 await mergeState($, storyDir, { notesUpdatedAt: (await readState($, storyDir)).turn ?? 0 });
142
143 const script = `${slash($.plugin.root)}/../scripts/reindex.ts`;
144 const run = await $.process.run(["bun", script, storyDir, "--incremental"], {
145 cwd: storyDir,
146 timeoutMs: reindexTimeoutMs,
147 });
148 if (run.exitCode !== 0) {
149 $.ui.log(
150 `notes updated, re-index failed (${run.stderr.trim().split("\n")[0] ?? run.exitCode})`,
151 );
152 }
153}
154
155// --- scenes, frontmatter, log ---
156
157export type SceneRef = {
158 number: number;
159 path: string;
160 logPath: string;
161 status: string | undefined;
162};
163
164// The latest open scene; if every scene is closed, the latest one (the same
165// rule as src/story.ts currentScene).
166async function currentScene($: Engine, storyDir: string): Promise<SceneRef | undefined> {
167 const dir = `${storyDir}/scenes`;
168 if (!(await $.fs.exists(dir))) return undefined;
169 const scenes: SceneRef[] = [];
170 for (const entry of await $.fs.list(dir)) {
171 const match = /^(\d+)/.exec(entry.name);
172 if (entry.kind !== "dir" || !match) continue;
173 const path = `${dir}/${entry.name}/scene.md`;
174 if (!(await $.fs.exists(path))) continue;
175 const fm = frontmatter(await $.fs.read(path));
176 const n = Number.parseInt(field(fm, "number") ?? match[1] ?? "0", 10);
177 scenes.push({
178 number: n,
179 path,
180 logPath: `${dir}/${entry.name}/log.jsonl`,
181 status: field(fm, "status"),
182 });
183 }
184 scenes.sort((a, b) => b.number - a.number);
185 return scenes.find((s) => s.status !== "closed") ?? scenes[0];
186}
187
188// One line of log.jsonl, one half of an exchange (the shape src/log.ts Turn
189// writes; the fields the job needs).
190export type LoggedTurn = {
191 n: number;
192 speaker: "player" | "storyteller";
193 name: string;
194 register: "narrator" | "copilot";
195 // ISO time and uuid of the transcript row the half came from; "" when
196 // unknown. A player half's uuid is the Stop hook's lastLogged for it.
197 at: string;
198 uuid: string;
199 text: string;
200};
201
202// A torn last line (the Stop hook mid-write) and anything not a turn are skipped.
203export function parseLogTurns(text: string): LoggedTurn[] {
204 const turns: LoggedTurn[] = [];
205 for (const line of text.split(/\r?\n/)) {
206 if (!line.trim()) continue;
207 let v: Record<string, unknown>;
208 try {
209 const parsed: unknown = JSON.parse(line);
210 if (typeof parsed !== "object" || parsed === null) continue;
211 v = parsed as Record<string, unknown>;
212 } catch {
213 continue;
214 }
215 if (typeof v.n !== "number" || typeof v.text !== "string") continue;
216 if (v.speaker !== "player" && v.speaker !== "storyteller") continue;
217 turns.push({
218 n: v.n,
219 speaker: v.speaker,
220 name: typeof v.name === "string" ? v.name : "",
221 register: v.register === "copilot" ? "copilot" : "narrator",
222 at: typeof v.at === "string" ? v.at : "",
223 uuid: typeof v.uuid === "string" ? v.uuid : "",
224 text: v.text,
225 });
226 }
227 return turns;
228}
229
230// Both halves of each of the last `count` exchanges.
231export function lastExchanges(turns: LoggedTurn[], count: number): LoggedTurn[] {
232 const numbers = [...new Set(turns.map((t) => t.n))].slice(-count);
233 return turns.filter((t) => numbers.includes(t.n));
234}
235
236// What the notes model reads in <turns> (plugin/prompts/notes.md): each half
237// headed "### <n> · Player" or "### <n> · <Storyteller name>".
238export function renderTurns(turns: LoggedTurn[]): string {
239 return turns
240 .map((t) => `### ${t.n} · ${t.speaker === "player" ? "Player" : t.name}\n\n${t.text.trim()}`)
241 .join("\n\n");
242}
243
244// What the job saw at turn.complete: when the turn began (by its duration),
245// the Stop hook's lastLogged, and how many lines the log had.
246export type TurnMark = { startedAt: number; lastLogged: string | undefined; lines: number };
247
248async function markTurn($: Engine, durationMs: number): Promise<TurnMark> {
249 const now = await $.clock.now();
250 const storyDir = slash(await $.session.cwd());
251 const scene = await currentScene($, storyDir);
252 const lines = scene ? (await readLog($, scene.logPath)).lines : 0;
253 return { startedAt: now - durationMs, lastLogged: await lastLogged($, storyDir), lines };
254}
255
256type LogRead = { exists: boolean; lines: number; turns: LoggedTurn[] };
257
258async function readLog($: Engine, logPath: string): Promise<LogRead> {
259 if (!(await $.fs.exists(logPath))) return { exists: false, lines: 0, turns: [] };
260 const text = await $.fs.read(logPath);
261 const lines = text.split(/\r?\n/).filter((l) => l.trim()).length;
262 return { exists: true, lines, turns: parseLogTurns(text) };
263}
264
265async function lastLogged($: Engine, storyDir: string): Promise<string | undefined> {
266 const value = (await readState($, storyDir)).lastLogged;
267 return typeof value === "string" ? value : undefined;
268}
269
270export type WaitResult =
271 | { kind: "logged"; turns: LoggedTurn[] }
272 // The Stop hook handled the turn and chose not to log it.
273 | { kind: "unlogged" }
274 | { kind: "timeout"; logPath: string; log: LogRead; lastLogged: string | undefined };
275
276// The Stop hook appends the exchange to log.jsonl, then sets state.json
277// lastLogged; it may finish before or after turn.complete. Any one of these
278// says this turn was handled:
279// - lastLogged moved since the mark: the turn is logged when a player half
280// carries that uuid or the log grew (an opening cue logs no player half),
281// and otherwise the hook chose not to log it;
282// - the log's last half is the Storyteller's and ends the way the answer ends;
283// - the log's last half is a Storyteller reply written after the turn began.
284async function waitForLoggedTurn(
285 $: Engine,
286 storyDir: string,
287 logPath: string,
288 answer: string,
289 mark: TurnMark | undefined,
290): Promise<WaitResult> {
291 const tail = squash(answer).slice(-80);
292 let log: LogRead = { exists: false, lines: 0, turns: [] };
293 let logged: string | undefined;
294 for (let i = 0; i < logWaitTries; i++) {
295 // State before log: the hook writes the log first, so a moved lastLogged
296 // read here means the log read after it is complete.
297 logged = await lastLogged($, storyDir);
298 log = await readLog($, logPath);
299 const last = log.turns.at(-1);
300 if (mark && logged !== mark.lastLogged) {
301 const isLogged =
302 log.lines > mark.lines ||
303 log.turns.some((t) => t.speaker === "player" && t.uuid === logged);
304 return isLogged ? { kind: "logged", turns: log.turns } : { kind: "unlogged" };
305 }
306 if (last?.speaker === "storyteller") {
307 if (squash(last.text).endsWith(tail)) return { kind: "logged", turns: log.turns };
308 if (mark && Date.parse(last.at) >= mark.startedAt) {
309 return { kind: "logged", turns: log.turns };
310 }
311 }
312 await new Promise<void>((resolve) => $.clock.after(logWaitMs, resolve));
313 }
314 return { kind: "timeout", logPath, log, lastLogged: logged };
315}
316
317// One line for .rp/hook-errors.log, where the settings hooks leave theirs.
318export function waitFailure(
319 wait: Extract<WaitResult, { kind: "timeout" }>,
320 answer: string,
321 mark: TurnMark | undefined,
322 now: number,
323): string {
324 const last = wait.log.turns.at(-1);
325 const fields = [
326 `log=${wait.logPath}`,
327 `exists=${wait.log.exists}`,
328 `lines=${mark?.lines ?? "?"}->${wait.log.lines}`,
329 `last=${last?.speaker ?? "none"}`,
330 `logged=${JSON.stringify(squash(last?.text ?? "").slice(-60))}`,
331 `answer=${JSON.stringify(squash(answer).slice(-60))}`,
332 `lastLogged=${mark?.lastLogged ?? "none"}->${wait.lastLogged ?? "none"}`,
333 ];
334 return `${new Date(now).toISOString()} notes: turn not logged ${fields.join(" ")}`;
335}
336
337async function appendHookError($: Engine, storyDir: string, line: string): Promise<void> {
338 const path = `${storyDir}/.rp/hook-errors.log`;
339 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : "";
340 const sep = before && !before.endsWith("\n") ? "\n" : "";
341 await $.fs.write(path, `${before}${sep}${line}\n`);
342}
343
344const frontmatterBlock = /^---\r?\n([\s\S]*?)\r?\n?---[ \t]*(?:\r?\n|$)/;
345
346export function frontmatter(text: string): string {
347 return frontmatterBlock.exec(text)?.[1] ?? "";
348}
349
350export function bodyOf(text: string): string {
351 const match = frontmatterBlock.exec(text);
352 return (match ? text.slice(match[0].length) : text).trim();
353}
354
355// A top-level scalar field of a YAML block, unquoted.
356export function field(yaml: string, key: string): string | undefined {
357 const match = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(yaml);
358 const value = unquote(match?.[1]?.trim() ?? "");
359 return value || undefined;
360}
361
362// A top-level list field, inline (`[a, b]`) or as a block of `- item` lines.
363export function listField(yaml: string, key: string): string[] {
364 const lines = yaml.split(/\r?\n/);
365 const start = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
366 if (start === -1) return [];
367 const inline = (lines[start] ?? "").slice(key.length + 1).trim();
368 if (inline.startsWith("[")) {
369 return inline
370 .replace(/^\[|\]$/g, "")
371 .split(",")
372 .map((s) => unquote(s.trim()))
373 .filter(Boolean);
374 }
375 const items: string[] = [];
376 for (const line of lines.slice(start + 1)) {
377 const item = /^\s*-\s+(.*)$/.exec(line);
378 if (!item) break;
379 items.push(unquote((item[1] ?? "").trim()));
380 }
381 return items.filter(Boolean);
382}
383
384function unquote(value: string): string {
385 return value.replace(/^(["'])(.*)\1$/, "$2");
386}
387
388// Card stems: the story's characters/ folder plus `uses: [characters/...]`.
389async function characterStems($: Engine, storyDir: string, storyText: string): Promise<string[]> {
390 const stems = new Set<string>();
391 const dir = `${storyDir}/characters`;
392 if (await $.fs.exists(dir)) {
393 for (const entry of await $.fs.list(dir)) {
394 if (entry.kind === "file" && entry.name.endsWith(".md")) stems.add(entry.name.slice(0, -3));
395 }
396 }
397 for (const ref of listField(frontmatter(storyText), "uses")) {
398 if (ref.startsWith("characters/")) stems.add(ref.slice("characters/".length));
399 }
400 return [...stems].sort();
401}
402
403// --- notes in and out ---
404
405export function notesPrompt(input: {
406 persona: string;
407 cast: string[];
408 previous: string;
409 turns: string;
410}): string {
411 return [
412 `<persona>\n${input.persona}\n</persona>`,
413 `<cast>\n${input.cast.join("\n")}\n</cast>`,
414 `<previous_notes>\n${input.previous}\n</previous_notes>`,
415 `<turns>\n${input.turns}\n</turns>`,
416 ].join("\n\n");
417}
418
419export function previousNotes(body: string): string {
420 const now = section(body, "Now");
421 const notes = section(body, "Notes");
422 return [now ? `## Now\n\n${now}` : "", notes ? `## Notes\n\n${notes}` : ""]
423 .filter(Boolean)
424 .join("\n\n");
425}
426
427export type Notes = { now: string; notes: string };
428
429// The reply should be exactly the two sections; tolerate a code fence or a
430// stray line around them, but not a missing section.
431export function parseNotes(reply: string): Notes | undefined {
432 const text = reply.replace(/^\s*```[a-z]*\s*\n/i, "").replace(/\n```\s*$/, "");
433 const now = section(text, "Now");
434 const notes = section(text, "Notes");
435 if (!now || !notes) return undefined;
436 return { now, notes };
437}
438
439// scene.md with its Now and Notes replaced; frontmatter kept byte for byte.
440export function withNotes(sceneText: string, notes: Notes): string {
441 const match = frontmatterBlock.exec(sceneText);
442 const head = match ? match[0].replace(/\r?\n?$/, "\n") : "";
443 const body = setSection(setSection(bodyOf(sceneText), "Now", notes.now), "Notes", notes.notes);
444 return `${head}${head ? "\n" : ""}${body}\n`;
445}
446
447// The text under a "## <heading>" line, up to the next "## " heading (as
448// src/story.ts section).
449export function section(markdown: string, heading: string): string | undefined {
450 const lines = markdown.split(/\r?\n/);
451 const wanted = heading.trim().toLowerCase();
452 const start = lines.findIndex(
453 (l) => /^##\s/.test(l) && l.slice(2).trim().toLowerCase() === wanted,
454 );
455 if (start === -1) return undefined;
456 const rest = lines.slice(start + 1);
457 const end = rest.findIndex((l) => /^##\s/.test(l));
458 return (end === -1 ? rest : rest.slice(0, end)).join("\n").trim();
459}
460
461// Replaces the text under "## <heading>" (up to the next "## " heading), or
462// appends the section when the body has none (as server/edit.ts setSection).
463export function setSection(body: string, heading: string, text: string): string {
464 const lines = body.split(/\r?\n/);
465 const wanted = heading.toLowerCase();
466 const start = lines.findIndex(
467 (l) => /^##\s/.test(l) && l.slice(2).trim().toLowerCase() === wanted,
468 );
469 const block = [`## ${heading}`, "", text.trim(), ""];
470 if (start === -1) return [body.trim(), "", ...block].join("\n").trim();
471 const rest = lines.slice(start + 1);
472 const next = rest.findIndex((l) => /^##\s/.test(l));
473 const after = next === -1 ? [] : rest.slice(next);
474 return [...lines.slice(0, start), ...block, ...after].join("\n").trim();
475}
476
477// --- state.json ---
478
479async function readState(
480 $: Engine,
481 storyDir: string,
482): Promise<NotesState & Record<string, unknown>> {
483 const path = `${storyDir}/.rp/state.json`;
484 try {
485 if (!(await $.fs.exists(path))) return {};
486 const parsed: unknown = JSON.parse(await $.fs.read(path));
487 return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
488 ? (parsed as Record<string, unknown>)
489 : {};
490 } catch {
491 return {};
492 }
493}
494
495// Read-modify-write so the hooks' keys survive (they keep ours the same way).
496async function mergeState($: Engine, storyDir: string, changes: NotesState): Promise<void> {
497 const state = await readState($, storyDir);
498 await $.fs.write(
499 `${storyDir}/.rp/state.json`,
500 `${JSON.stringify({ ...state, ...changes }, null, 2)}\n`,
501 );
502}
503
504function squash(text: string): string {
505 return text.replace(/\s+/g, " ").trim();
506}
507
508function slash(path: string): string {
509 return path.replaceAll("\\", "/");
510}
511
512function errorText(error: unknown): string {
513 return error instanceof Error ? error.message : String(error);
514}
515mod/stage.tsx 23 lines1import type { Register } from "claude-code";
2import { registerCommands } from "./stage/commands.ts";
3import { registerDirectives } from "./stage/directives.tsx";
4import { registerQuiet } from "./stage/quiet.ts";
5import { registerScene } from "./stage/scene.tsx";
6import { registerVoice } from "./stage/voice.tsx";
7
8// The stage (spec 10): the scene pane and what keeps it
9// current (stage/scene.tsx); the quiet line, spinner and reply styling
10// (stage/voice.tsx); quieting of coding reminders (stage/quiet.ts) and of the
11// slash command menu (stage/commands.ts); and the directives pane
12// (stage/directives.tsx, spec 12). The status line is a settings
13// command, plugin/statusline.ts, wired by the launcher.
14// Every drawing hook falls back to the engine's own when it has nothing better,
15// and the engine skips a hook that throws and draws its own.
16export const registerStage: Register = (on, options) => {
17 registerScene(on, options);
18 registerVoice(on, options);
19 registerQuiet(on, options);
20 registerCommands(on, options);
21 registerDirectives(on, options);
22};
23mod/stage/commands.ts 107 lines1import type { EngineInterface, Register } from "claude-code";
2import { keepSkillEntries } from "./text.ts";
3
4// Quieting (spec 10). The launcher already leaves the player's user layer
5// (~/.claude: settings, plugins, skills, agents) out of a story session with
6// --setting-sources project,local, so no user-level or third-party command or
7// skill loads. What is left that is not ours is the engine's own: built-in
8// commands, bundled skills and the built-in plugins' skills, mostly for
9// coding. This hides those from the menu, except the short list below.
10// Kept as well: what our plugin ships and what the project layer brings
11// (~/.claude-roleplay/.claude or the story's own .claude).
12// The same rule trims the skill listing the model reads, so the Skill tool
13// offers the Storyteller its own procedures (plugin/skills) and project
14// skills, never a coding one. Agent types need no hook: the Storyteller has
15// no Agent tool.
16//
17// Naming: everything of ours is reached under `storyteller:`
18// (/storyteller:recap), and a built-in's name is never shadowed. Skills get
19// the prefix from the plugin. The stage's own commands, /storyteller:scene
20// and /storyteller:directives, cannot come from $.command.register: it takes
21// letters, digits, _ and - only, no colon (and refuses a built-in's name).
22// So each is a plugin command file (plugin/commands/*.md), prefixed by the
23// plugin like a skill, and a command.run hook in the stage answers it before
24// its text would reach the model; that text is only a fallback for when the
25// mod is not loaded.
26
27// Built-in commands a story session keeps in the menu: session housekeeping
28// (clear, compact, resume, rename, rewind, exit, quit), settings (config,
29// model, effort), and help when something is wrong (help, status, cost,
30// doctor). memory is not here: auto memory is off for stories.
31export const shownBuiltins: readonly string[] = [
32 "clear",
33 "compact",
34 "config",
35 "cost",
36 "doctor",
37 "effort",
38 "exit",
39 "help",
40 "model",
41 "quit",
42 "rename",
43 "resume",
44 "rewind",
45 "status",
46];
47
48const ours = "storyteller";
49
50// `provider` is the engine's name for who provides a command: "engine" for a
51// built-in, "project" for the project layer, a plugin's name otherwise (ours
52// arrives as "storyteller@inline" from --plugin-dir).
53export function isShown(name: string, provider: string): boolean {
54 if (provider === ours || provider.startsWith(`${ours}@`)) return true;
55 if (provider === "project") return true;
56 return provider === "engine" && shownBuiltins.includes(name);
57}
58
59// What the Storyteller reads in place of a hidden skill a player types in full.
60export function hiddenSkillText(skill: string): string {
61 return `The player typed /${skill}, which is not part of a story session. Say so in one short out-of-character line and carry on with the story.`;
62}
63
64export const registerCommands: Register = (on) => {
65 // Hidden from the typeahead and /help. A hidden command still runs when
66 // typed in full, which is why skills are answered again below.
67 on("command.describe", (_$, e, next) =>
68 isShown(e.command, e.provider.plugin) ? next(e) : next({ ...e, isHidden: true }),
69 );
70
71 // A bundled skill typed in full (/simplify) would hand the Storyteller a
72 // coding task. This event names no provider, so the command list tells it.
73 // This fires for the Skill tool too, so a coding skill the model names
74 // anyway gets the same answer.
75 on("skill.prompt", async ($, e, next) => {
76 const providers = await skillProviders($);
77 return isShown(e.skill, providers(e.skill)) ? next(e) : { text: hiddenSkillText(e.skill) };
78 });
79
80 // The listing of skills the Skill tool offers, which spec 10 used to drop
81 // whole; now it keeps what isShown keeps.
82 on(
83 "prompt.attachment",
84 { type: "skill_listing", origin: { kind: "engine" } },
85 async ($, e, next) => {
86 const providers = await skillProviders($);
87 const text = keepSkillEntries(e.text, (name) => isShown(name, providers(name)));
88 return text === null ? { text: null } : next({ ...e, text });
89 },
90 );
91};
92
93// Who provides each skill, by name. The command list says `user` for a skill
94// from a settings folder; with the user layer left out, that folder is the
95// project layer. Ours go by their `storyteller:` prefix, because a skill the
96// player cannot invoke (user-invocable: false) is not in the command list.
97async function skillProviders($: EngineInterface): Promise<(skill: string) => string> {
98 const commands = await $.command.list();
99 return (skill) => {
100 if (skill.startsWith(`${ours}:`)) return ours;
101 const command = commands.find((c) => c.name === skill);
102 if (command?.source === "user") return "project";
103 if (command?.source === "plugin") return command.plugin ?? "plugin";
104 return "engine";
105 };
106}
107mod/stage/directives.tsx 450 lines1import type { EngineInterface, Register, RenderInput } from "claude-code";
2import { atom, read, update } from "claude-code";
3import type {
4 DirectiveDraft,
5 DirectiveList,
6 DirectiveMode,
7 DirectiveReply,
8 DirectiveRow,
9 DirectivesPane,
10 DirectiveWrite,
11} from "../types";
12
13// The directives pane (spec 12): `/storyteller:directives` opens a dialog listing the
14// story's directives, each with a toggle, an inline editor, open-in-editor
15// and delete. Every read and write goes through plugin/directives.ts, which
16// owns the files and the library override rule; this file only draws and
17// sends. Opened with focus: Tab and the arrows walk the controls, Esc closes.
18
19export const PANE = "directives";
20const TITLE = "Directives";
21
22// Beyond this, or over several lines, a body is not edited in a one-line
23// Input (the engine's Input has no multi-line mode): open the file, or ask Vex.
24export const INLINE_BODY = 300;
25
26const modes: readonly DirectiveMode[] = ["always", "keyed", "manual"];
27const idle: DirectivesPane = { draft: null, confirm: null, notice: null };
28
29const listAtom = atom({ plugin: "storyteller", key: "directives" } as const, null);
30const paneAtom = atom({ plugin: "storyteller", key: "directivesPane" } as const, idle);
31
32let watching = false;
33
34export const registerDirectives: Register = (on) => {
35 // /storyteller:directives is plugin/commands/directives.md, answered here
36 // before its text reaches the model (stage/commands.ts says why).
37 on("command.run", { command: "storyteller:directives" }, async ($) => {
38 await update($, paneAtom, () => idle);
39 await refresh($);
40 const count = (await read($, listAtom))?.directives.length ?? 0;
41 await $.ui.open({
42 id: PANE,
43 title: TITLE,
44 focus: true,
45 closeOnEscape: true,
46 rows: Math.min(30, count * 2 + 5),
47 });
48 if (!watching) {
49 watching = true;
50 watchFiles($);
51 }
52 return {};
53 });
54
55 on("ui.render", { component: "Pane", requestId: PANE }, drawPane);
56};
57
58// ---- talking to plugin/directives.ts
59
60async function script($: EngineInterface, args: string[], stdin?: string): Promise<string | null> {
61 const { exitCode, stdout, stderr } = await $.process.run(
62 ["bun", `${$.plugin.root}/directives.ts`, ...args],
63 { timeoutMs: 15_000, ...(stdin === undefined ? {} : { stdin }) },
64 );
65 if (exitCode !== 0) {
66 $.ui.log(`stage: directives.ts exited ${exitCode}: ${stderr.slice(0, 300)}`, { to: "debug" });
67 return null;
68 }
69 return stdout?.trim() || null;
70}
71
72async function refresh($: EngineInterface): Promise<void> {
73 try {
74 const out = await script($, ["list"]);
75 if (!out) return;
76 const list: DirectiveList | null = JSON.parse(out);
77 await update($, listAtom, () => list);
78 } catch (error) {
79 $.ui.log(`stage: could not read the directives: ${String(error)}`, { to: "debug" });
80 }
81}
82
83// Sends one change; the reply carries the list as it now stands.
84async function send($: EngineInterface, args: string[], stdin?: string): Promise<boolean> {
85 let reply: DirectiveReply;
86 try {
87 const out = await script($, args, stdin);
88 reply = out
89 ? JSON.parse(out)
90 : { message: null, error: "directives.ts printed nothing", list: null };
91 } catch (error) {
92 reply = { message: null, error: String(error), list: null };
93 }
94 const { list } = reply;
95 if (list) await update($, listAtom, () => list);
96 const text = reply.error ?? reply.message;
97 await update($, paneAtom, (p) => ({
98 ...p,
99 confirm: null,
100 notice: text ? { text, isError: reply.error !== null } : null,
101 }));
102 return reply.error === null;
103}
104
105function write($: EngineInterface, change: DirectiveWrite): Promise<boolean> {
106 return send($, ["write"], JSON.stringify(change));
107}
108
109// Edits made outside (the player's editor, Vex's tools) show up while the
110// pane is open: a cheap mtime check of the files listed, as the scene pane does.
111function watchFiles($: EngineInterface): void {
112 let seen = "";
113 $.clock.every(4_000, () => {
114 void (async () => {
115 if (!(await $.ui.panes()).some((p) => p.id === PANE)) return;
116 const list = await read($, listAtom);
117 if (!list) return;
118 const paths = [`${list.dir}/directives`, ...list.directives.map((d) => d.path)];
119 const stamps = await Promise.all(
120 paths.map((p) =>
121 $.fs.stat(p).then(
122 (s) => s.mtimeMs,
123 () => -1,
124 ),
125 ),
126 );
127 const now = stamps.join(",");
128 if (seen && now !== seen) await refresh($);
129 seen = now;
130 })().catch(() => {});
131 });
132}
133
134// ---- the editor's working copy
135
136export function draftOf(d: DirectiveRow): DirectiveDraft {
137 return {
138 stem: d.stem,
139 title: d.title,
140 keys: d.keys.join(", "),
141 mode: d.mode,
142 body: d.body,
143 on: d.on,
144 source: d.source,
145 path: d.path,
146 };
147}
148
149const blank: DirectiveDraft = {
150 stem: null,
151 title: "",
152 keys: "",
153 mode: "manual",
154 body: "",
155 on: true,
156 source: "story",
157 path: null,
158};
159
160export function bodyFitsInline(body: string): boolean {
161 return !body.includes("\n") && body.length <= INLINE_BODY;
162}
163
164export function parseKeys(text: string): string[] {
165 return text
166 .split(",")
167 .map((k) => k.trim())
168 .filter(Boolean);
169}
170
171// The one write a Save sends. The body goes along only when it was editable
172// here, so a long body is never cut down to what an Input showed.
173export function saveOf(draft: DirectiveDraft, original: DirectiveRow | undefined): DirectiveWrite {
174 const keys = parseKeys(draft.keys);
175 if (draft.stem === null) {
176 return {
177 op: "create",
178 title: draft.title,
179 mode: draft.mode,
180 keys,
181 on: draft.on,
182 body: draft.body,
183 };
184 }
185 const change: DirectiveWrite = {
186 op: "update",
187 stem: draft.stem,
188 title: draft.title,
189 mode: draft.mode,
190 keys,
191 };
192 return original && !bodyFitsInline(original.body) ? change : { ...change, body: draft.body };
193}
194
195async function setDraft($: EngineInterface, fields: Partial<DirectiveDraft>): Promise<void> {
196 await update($, paneAtom, (p) => (p.draft ? { ...p, draft: { ...p.draft, ...fields } } : p));
197}
198
199async function startEditing($: EngineInterface, draft: DirectiveDraft): Promise<void> {
200 await update($, paneAtom, () => ({ draft, confirm: null, notice: null }));
201 void $.ui.focus({ requestId: PANE, key: "title" }).catch(() => {});
202}
203
204async function save($: EngineInterface): Promise<void> {
205 const { draft } = await read($, paneAtom);
206 if (!draft) return;
207 const original = (await read($, listAtom))?.directives.find((d) => d.stem === draft.stem);
208 if (await write($, saveOf(draft, original))) {
209 await update($, paneAtom, (p) => ({ ...p, draft: null }));
210 }
211}
212
213// Read at the press, not from the drawing: two quick presses flip it twice.
214async function toggle($: EngineInterface, stem: string): Promise<void> {
215 const current = (await read($, listAtom))?.directives.find((d) => d.stem === stem);
216 if (current) await write($, { op: "toggle", stem, on: !current.on });
217}
218
219// ---- drawing
220
221async function drawPane($: EngineInterface, e: RenderInput<"Pane">) {
222 const { Box, Text, Button } = $.ui.resolve(e);
223 const list = await read($, listAtom);
224 const pane = await read($, paneAtom);
225 if (!list) {
226 return (
227 <Box flexDirection="row" gap={2}>
228 <Text dimColor>No story directives to show here.</Text>
229 <Button key="refresh" plain dimColor onPress={() => refresh($)}>
230 refresh
231 </Button>
232 </Box>
233 );
234 }
235 const rows = list.directives.map((d, i) =>
236 pane.draft?.stem === d.stem
237 ? editor($, e, pane.draft)
238 : row($, e, d, pane.confirm === d.stem, pane.draft === null && i < 9 ? String(i + 1) : null),
239 );
240 const hasLibrary = list.directives.some((d) => d.source === "library");
241 return (
242 <Box flexDirection="column">
243 {list.directives.length === 0 ? <Text dimColor>No directives yet.</Text> : null}
244 {rows}
245 <Box flexDirection="row" gap={2} marginTop={1}>
246 {pane.draft?.stem === null ? null : (
247 <Button key="new" plain onPress={() => startEditing($, { ...blank })}>
248 new directive
249 </Button>
250 )}
251 <Button key="refresh" plain dimColor onPress={() => refresh($)}>
252 refresh
253 </Button>
254 </Box>
255 {pane.draft?.stem === null ? editor($, e, pane.draft) : null}
256 {hasLibrary ? (
257 <Text dimColor wrap="wrap">
258 (library) directives cannot be deleted here: switch them off, or edit them to give the
259 story its own copy.
260 </Text>
261 ) : null}
262 {pane.notice ? (
263 <Text
264 wrap="truncate-end"
265 {...(pane.notice.isError ? { color: "red" } : { dimColor: true })}
266 >
267 {pane.notice.text}
268 </Text>
269 ) : null}
270 </Box>
271 );
272}
273
274function row(
275 $: EngineInterface,
276 e: RenderInput<"Pane">,
277 d: DirectiveRow,
278 confirming: boolean,
279 // A digit while only listing; none while the editor is open, so typing in
280 // a field never switches a directive.
281 hotkey: string | null,
282) {
283 const { Box, Text, Button } = $.ui.resolve(e);
284 const meta = [
285 d.mode,
286 d.keys.length > 0 ? d.keys.join(", ") : null,
287 d.source === "library" ? "(library)" : null,
288 ]
289 .filter(Boolean)
290 .join(" · ");
291 const actions = confirming ? (
292 <Box flexDirection="row" gap={1}>
293 <Text color="red">really delete?</Text>
294 <Button key={`yes:${d.stem}`} onPress={() => write($, { op: "delete", stem: d.stem })}>
295 yes
296 </Button>
297 <Button
298 key={`no:${d.stem}`}
299 onPress={() => update($, paneAtom, (p) => ({ ...p, confirm: null }))}
300 >
301 no
302 </Button>
303 </Box>
304 ) : (
305 <Box flexDirection="row" gap={1}>
306 <Button key={`open:${d.stem}`} plain dimColor onPress={() => openFile($, d.stem)}>
307 open
308 </Button>
309 <Button key={`edit:${d.stem}`} plain dimColor onPress={() => startEditing($, draftOf(d))}>
310 edit
311 </Button>
312 {d.source === "library" ? null : (
313 <Button
314 key={`delete:${d.stem}`}
315 plain
316 dimColor
317 onPress={() => update($, paneAtom, (p) => ({ ...p, confirm: d.stem, notice: null }))}
318 >
319 delete
320 </Button>
321 )}
322 </Box>
323 );
324 return (
325 <Box flexDirection="column">
326 <Box flexDirection="row" gap={1}>
327 <Button
328 key={`toggle:${d.stem}`}
329 plain
330 {...(hotkey ? { hotkey } : {})}
331 onPress={() => toggle($, d.stem)}
332 >
333 {d.on ? "[on] " : "[off]"}
334 </Button>
335 <Box flexDirection="row" flexGrow={1} flexShrink={1} gap={1}>
336 <Text wrap="truncate-end" {...(d.on ? { bold: true } : { dimColor: true })}>
337 {d.title}
338 </Text>
339 <Text dimColor wrap="truncate-end">
340 {meta}
341 </Text>
342 </Box>
343 {actions}
344 </Box>
345 <Text dimColor wrap="truncate-end">
346 {` ${d.body.split("\n")[0] ?? ""}`}
347 </Text>
348 </Box>
349 );
350}
351
352function editor($: EngineInterface, e: RenderInput<"Pane">, draft: DirectiveDraft) {
353 const ui = $.ui.resolve(e);
354 const { Box, Text, Button } = ui;
355 // Mobile draws no text fields: there the file and Vex are the way to edit.
356 if (!("Input" in ui && "Select" in ui)) {
357 return <Text dimColor>Editing needs the terminal or the desktop app.</Text>;
358 }
359 const { Input, Select } = ui;
360 const inline = bodyFitsInline(draft.body);
361 const heading =
362 draft.stem === null
363 ? "New directive"
364 : `Editing ${draft.stem}${draft.source === "library" ? " (library: saving gives the story its own copy)" : ""}`;
365 const ask = draft.stem === null ? "((new directive: " : `((edit the directive "${draft.title}": `;
366 return (
367 <Box flexDirection="column" borderStyle="round" paddingX={1}>
368 <Text dimColor>{heading}</Text>
369 <Input
370 key="title"
371 label="title "
372 value={draft.title}
373 placeholder="what it is called"
374 submitLabel="next"
375 onInput={(v) => setDraft($, { title: v })}
376 onSubmit={(v) => setDraft($, { title: v })}
377 />
378 <Select
379 key="mode"
380 label="mode "
381 value={draft.mode}
382 options={modes.map((m) => ({ value: m }))}
383 onSelect={(v) => {
384 const mode = modes.find((m) => m === v);
385 if (mode) void setDraft($, { mode });
386 }}
387 />
388 <Input
389 key="keys"
390 label="keys "
391 value={draft.keys}
392 placeholder="comma list, for keyed"
393 submitLabel="next"
394 onInput={(v) => setDraft($, { keys: v })}
395 onSubmit={(v) => setDraft($, { keys: v })}
396 />
397 {inline ? (
398 <Input
399 key="body"
400 label="body "
401 value={draft.body}
402 placeholder="the instruction, one line"
403 submitLabel="save"
404 onInput={(v) => setDraft($, { body: v })}
405 onSubmit={async (v) => {
406 await setDraft($, { body: v });
407 await save($);
408 }}
409 />
410 ) : (
411 <Text dimColor wrap="wrap">
412 {`body ${draft.body.split("\n").length} lines, too long to edit here: open the file, or ask Vex.`}
413 </Text>
414 )}
415 <Box flexDirection="row" gap={1}>
416 <Button key="save" variant="primary" onPress={() => save($)}>
417 Save
418 </Button>
419 <Button
420 key="cancel"
421 onPress={() => update($, paneAtom, (p) => ({ ...p, draft: null, notice: null }))}
422 >
423 Cancel
424 </Button>
425 {draft.stem === null ? null : (
426 <Button key="editor-open" plain dimColor onPress={() => openFile($, draft.stem ?? "")}>
427 open
428 </Button>
429 )}
430 <Button key="ask" plain dimColor onPress={() => askVex($, ask)}>
431 … ask Vex
432 </Button>
433 </Box>
434 </Box>
435 );
436}
437
438function openFile($: EngineInterface, stem: string): Promise<boolean> {
439 return send($, ["open", stem]);
440}
441
442// Longer prose is better written in conversation: the prompt gets the
443// out-of-character opening and the pane steps aside so the player can type.
444async function askVex($: EngineInterface, text: string): Promise<void> {
445 // Closed first: the prompt box may refuse a fill while something holds the keys.
446 await update($, paneAtom, () => idle);
447 await $.ui.close({ id: PANE });
448 await $.prompt.fill({ text });
449}
450mod/stage/quiet.ts 17 lines1import type { Register } from "claude-code";
2import { codingAttachments, withoutUserInstructions } from "./text.ts";
3
4// Quieting (spec 10): engine reminders aimed at a coding session are left out
5// of the Storyteller's requests. Only the engine's own text is touched; a
6// settings hook's additional context (origin `hook`) always goes through.
7
8export const registerQuiet: Register = (on) => {
9 on("prompt.attachment", { type: codingAttachments, origin: { kind: "engine" } }, () => ({
10 text: null,
11 }));
12
13 on("prompt.attachment", { type: "instructions", origin: { kind: "engine" } }, (_$, e, next) =>
14 next({ ...e, text: withoutUserInstructions(e.text) }),
15 );
16};
17mod/stage/scene.tsx 287 lines1import type { EngineInterface, Register, RenderChildren, RenderInput } from "claude-code";
2import { atom, read, update } from "claude-code";
3import type { StageSnapshot } from "../types";
4import {
5 BLANK,
6 besidePortrait,
7 FRAME,
8 frameBottom,
9 framed,
10 frameInner,
11 frameTop,
12 groupLines,
13 type Line,
14 namedPanes,
15 PORTRAIT,
16 type PresentRow,
17 paneChanges,
18 paneWidgets,
19 planPane,
20 presentLines,
21 type Run,
22 WIDGET_PANE_PREFIX,
23 widgetGroups,
24} from "./layout.ts";
25import { cardRead, colorFor, findSpeaker } from "./text.ts";
26
27// The scene pane (spec 10), read-only: title and number; a Now frame with
28// where, when, mood and the notes' "now"; who is on stage, with portraits where the terminal draws
29// images; widgets (spec 19.4). Widgets naming a pane of their own are drawn
30// there instead, in a pane opened when the first appears and closed when the
31// last is retired. Also what keeps it current: the story is read through
32// plugin/scene.ts (the mod has no Node and no YAML parser; the Bun script
33// reuses src/story.ts, so pane and hooks never disagree), again after every
34// turn, every world tool call, and whenever the notes job rewrites scene.md.
35
36export const PANE = "scene";
37const TITLE = "Scene";
38
39// State declared in ../types. The engine reads each hooks file on its own
40// (a `$` or a state reference never crosses an import), so voice.tsx spells
41// the atoms it reads again; same plugin and key, same value.
42const stage = atom({ plugin: "storyteller", key: "stage" } as const, null);
43const paneAsked = atom({ plugin: "storyteller", key: "paneAsked" } as const, false);
44const voicing = atom({ plugin: "storyteller", key: "voicing" } as const, null);
45const closedPanes = atom({ plugin: "storyteller", key: "closedPanes" } as const, []);
46const widgetPane = new RegExp(`^${WIDGET_PANE_PREFIX}`);
47
48export const registerScene: Register = (on, options) => {
49 on("session.start", async ($, e, next) => {
50 const started = await next(e);
51 void refresh($);
52 watchScene($);
53 // Unasked, the engine seats a pane only on a wide terminal, and the Pane
54 // hook closes it again if it lands inline instead of docked: spec 10 says
55 // it opens on start "when the layout docks a pane".
56 if (options.paneOnStart && e.isInteractive) void $.ui.open({ id: PANE, title: TITLE });
57 return started;
58 });
59
60 // /storyteller:scene is plugin/commands/scene.md, answered here before its
61 // text reaches the model (stage/commands.ts says why not register()).
62 on("command.run", { command: "storyteller:scene" }, async ($) => {
63 await update($, paneAsked, () => true);
64 void refresh($);
65 await $.ui.open({ id: PANE, title: TITLE });
66 return {};
67 });
68
69 on("turn.complete", async ($, e, next) => {
70 const done = await next(e);
71 void refresh($);
72 return done;
73 });
74
75 // prompt.submit and tool.call gate the turn: should the stage's part fail,
76 // `.catch` lets the prompt or the call go on untouched.
77 on("prompt.submit", async ($, e, next) => {
78 await update($, voicing, () => null);
79 return next(e);
80 }).catch((_$, e, next) => next(e));
81
82 // A card read for a character on stage means the Storyteller is about to
83 // speak as them: the spinner names them (spec 10).
84 on("tool.call", async ($, e, next) => {
85 const card = cardRead(e.tool, e);
86 if (card) {
87 const who = findSpeaker((await read($, stage))?.speakers ?? [], card);
88 if (who) await update($, voicing, () => who.name);
89 }
90 const result = await next(e);
91 if (e.tool.startsWith("mcp__world__")) void refresh($);
92 return result;
93 }).catch((_$, e, next) => next(e));
94
95 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
96 if (e.props.placement === "inline" && !(await read($, paneAsked))) {
97 $.clock.after(0, () => void $.ui.close({ id: PANE }).catch(() => {}));
98 const { Text } = $.ui.resolve(e);
99 return <Text dimColor>/storyteller:scene shows the scene pane</Text>;
100 }
101 return drawPane($, e);
102 });
103
104 on("ui.render", { component: "Pane", requestId: widgetPane }, async ($, e) => {
105 const { Box, Text } = $.ui.resolve(e);
106 const widgets = paneWidgets((await read($, stage))?.scene?.widgets ?? [], e.requestId ?? "");
107 if (widgets.length === 0) return <Text dimColor>Nothing here now.</Text>;
108 // The pane's own frame holds these: groups get their headings, no box.
109 const columns = e.props.bodyColumns || 40;
110 return (
111 <Box flexDirection="column">
112 {groupLines(widgetGroups(widgets, columns), columns).map((l) => drawLine($, e, l))}
113 </Box>
114 );
115 });
116
117 // A widget pane the person closes stays closed while its widgets last;
118 // otherwise the next read of the story would open it again.
119 on("ui.close", { id: widgetPane, origin: { kind: "person" } }, async ($, e, next) => {
120 await update($, closedPanes, (ids) => [...new Set([...(ids ?? []), e.id])]);
121 return next(e);
122 }).catch((_$, e, next) => next(e));
123};
124
125// Opens a pane for each `pane:` name the widgets carry and closes the ones no
126// widget names any more. $.ui.panes() is the engine's record, so this holds
127// across a module reload, when the module's own variables start over.
128async function syncPanes($: EngineInterface, snapshot: StageSnapshot | null): Promise<void> {
129 const wanted = namedPanes(snapshot?.scene?.widgets ?? []);
130 const open = (await $.ui.panes()).map((p) => p.id);
131 const closed = (await read($, closedPanes)) ?? [];
132 const changes = paneChanges(wanted, open, closed);
133 for (const pane of changes.open) await $.ui.open(pane);
134 for (const id of changes.close) await $.ui.close({ id });
135 // A pane whose widgets are gone may open again when they come back.
136 const stillWanted = closed.filter((id) => wanted.some((p) => p.id === id));
137 if (stillWanted.length !== closed.length) await update($, closedPanes, () => stillWanted);
138}
139
140async function refresh($: EngineInterface): Promise<void> {
141 try {
142 const { exitCode, stdout, stderr } = await $.process.run(["bun", `${$.plugin.root}/scene.ts`], {
143 timeoutMs: 15_000,
144 });
145 if (exitCode !== 0) {
146 $.ui.log(`stage: scene.ts exited ${exitCode}: ${stderr.slice(0, 300)}`, { to: "debug" });
147 return;
148 }
149 // Nothing printed is nothing to show, not an error worth a line.
150 if (!stdout?.trim()) return;
151 const snapshot: StageSnapshot | null = JSON.parse(stdout);
152 await update($, stage, () => snapshot);
153 await syncPanes($, snapshot);
154 } catch (error) {
155 $.ui.log(`stage: could not read the story: ${String(error)}`, { to: "debug" });
156 }
157}
158
159// The notes job rewrites scene.md in the background after a turn; a cheap
160// mtime check brings its "now" into the pane without waiting for a turn.
161function watchScene($: EngineInterface): void {
162 let seen = 0;
163 $.clock.every(4_000, () => {
164 void (async () => {
165 const path = (await read($, stage))?.scene?.path;
166 if (!path) return;
167 const { mtimeMs } = await $.fs.stat(path);
168 if (seen !== 0 && mtimeMs !== seen) await refresh($);
169 seen = mtimeMs;
170 })().catch(() => {});
171 });
172}
173
174async function drawPane($: EngineInterface, e: RenderInput<"Pane">) {
175 const { Box, Text } = $.ui.resolve(e);
176 const snapshot = await read($, stage);
177 if (!snapshot?.scene) {
178 return (
179 <Box flexDirection="column">
180 <Text dimColor>{snapshot ? "No scene is open yet." : "Reading the story…"}</Text>
181 </Box>
182 );
183 }
184 const columns = e.props.bodyColumns || 40;
185 // Docked, the body's own rows; inline the pane grows to its content, so its
186 // body says nothing about the room there is and the viewport is the bound.
187 const rows =
188 (e.props.placement === "dock" ? e.props.scroll?.bodyRows : 0) || e.viewport?.rows || 40;
189 const plan = planPane(
190 snapshot.scene,
191 { columns, rows, portraits: e.surface === "terminal" },
192 colorFor(snapshot.storyteller),
193 colorFor,
194 );
195 const inner = frameInner(columns);
196 const line = (runs: Line) => drawLine($, e, runs);
197 const inside = (runs: Line) => line(framed(runs, columns));
198 // Each section a frame drawn as text lines (layout.ts), so the pane's
199 // width math stays exact and a resize just lays the lines out again.
200 const section = (title: string, body: RenderChildren[]) =>
201 body.length > 0 ? (
202 <Box flexDirection="column">
203 {line(frameTop(title, columns))}
204 {inside(BLANK)}
205 {body}
206 {inside(BLANK)}
207 {line(frameBottom(columns))}
208 </Box>
209 ) : null;
210 return (
211 <Box flexDirection="column">
212 {plan.header.map(line)}
213 {section("Now", plan.now.map(inside))}
214 {section(
215 "Present",
216 plan.present.map((row) => presentRow($, e, row, columns)),
217 )}
218 {section("Widgets", groupLines(plan.widgets, inner).map(inside))}
219 </Box>
220 );
221}
222
223// A planned line is already wrapped and cut to the pane, so each is one Text
224// that truncates rather than wraps: a miscounted cell never adds a row.
225function drawLine($: EngineInterface, e: RenderInput<"Pane">, runs: Line) {
226 const { Text } = $.ui.resolve(e);
227 const [only] = runs;
228 if (runs.length === 1 && only) {
229 return (
230 <Text wrap="truncate-end" {...style(only)}>
231 {only.text}
232 </Text>
233 );
234 }
235 return (
236 <Text wrap="truncate-end">
237 {runs.map((run) => (
238 <Text {...style(run)}>{run.text}</Text>
239 ))}
240 </Text>
241 );
242}
243
244function style(run: Run): {
245 color?: string;
246 dimColor?: boolean;
247 bold?: boolean;
248} {
249 return {
250 ...(run.color ? { color: run.color } : {}),
251 ...(run.dim ? { dimColor: true } : {}),
252 ...(run.bold ? { bold: true } : {}),
253 };
254}
255
256// Image is the terminal's element alone, and only a PNG file is offered; the
257// terminal draws `alt` where it cannot show pictures (most besides kitty and
258// Ghostty). The plan offers a portrait only on a wide pane; elsewhere the
259// name stands by itself.
260// Inside the Present frame: the frame's left edge, the picture, then the name
261// block padded so the right edge lines up with the text rows around it.
262function presentRow($: EngineInterface, e: RenderInput<"Pane">, row: PresentRow, columns: number) {
263 const inner = frameInner(columns);
264 const lines = presentLines(row, inner);
265 if (e.surface !== "terminal" || !row.portrait)
266 return drawLine($, e, framed(lines[0] ?? [], columns));
267 const { Box, Image } = $.ui.resolve(e);
268 const rows = Array.from({ length: PORTRAIT.rows }, (_, i) => i);
269 const textW = inner - PORTRAIT.columns - 1;
270 return (
271 <Box flexDirection="row">
272 <Box flexDirection="column">
273 {rows.map(() => drawLine($, e, [{ text: `${FRAME.v} `, dim: true }]))}
274 </Box>
275 <Image
276 source={{ file: row.portrait, format: "png" }}
277 columns={PORTRAIT.columns}
278 rows={PORTRAIT.rows}
279 alt={`portrait of ${row.name}`}
280 />
281 <Box flexDirection="column">
282 {rows.map((i) => drawLine($, e, besidePortrait(lines[i] ?? [], textW)))}
283 </Box>
284 </Box>
285 );
286}
287mod/stage/voice.tsx 148 lines1import type { EngineInterface, Register, RenderInput } from "claude-code";
2import { atom, read } from "claude-code";
3import type { StageSnapshot } from "../types";
4import {
5 type Block,
6 colorFor,
7 emphasisRuns,
8 quietPhrase,
9 quietTool,
10 quoteSpans,
11 replyBlocks,
12 spinnerWord,
13} from "./text.ts";
14
15// How the Storyteller reads on screen (spec 10): tool rows as one dim line in
16// voice, the spinner in its name, and reply blocks labelled and styled. Each
17// hook passes to the engine's drawing (`next(e)`) until the story has been
18// read, and wherever it has nothing better to draw.
19
20const FALLBACK_NAME = "The Storyteller";
21
22// Written by scene.tsx; spelled again here because a state reference never
23// crosses an import in a hooks module.
24const stage = atom({ plugin: "storyteller", key: "stage" } as const, null);
25const voicing = atom({ plugin: "storyteller", key: "voicing" } as const, null);
26
27export const registerVoice: Register = (on) => {
28 // The quiet line. A ToolUse row carries no ctrl+o flag (only ToolGroup
29 // does), so these rows stay quiet in the expanded transcript too; an
30 // errored call always shows the engine's own row.
31 on("ui.render", { component: "ToolUse", props: { tool: quietTool } }, async ($, e, next) => {
32 if (e.props.isErrored) return next(e);
33 const { Text } = $.ui.resolve(e);
34 const name = (await read($, stage))?.storyteller ?? FALLBACK_NAME;
35 const phrase = quietPhrase(e.props.tool, e.props.input, e.props.output);
36 return (
37 <Text dimColor italic>
38 {`${name} ${phrase}${e.props.isRunning ? "…" : "."}`}
39 </Text>
40 );
41 });
42
43 // The result block under a quiet row would undo the quiet.
44 on("ui.render", { component: "ToolResult", props: { tool: quietTool } }, ($, e, next) => {
45 if (e.props.isErrored) return next(e);
46 const { Box } = $.ui.resolve(e);
47 return <Box />;
48 });
49
50 on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
51 const calls = e.props.calls;
52 const isQuiet = calls.every((c) => quietTool.test(c.tool) && !c.isErrored);
53 if (e.props.isExpanded || !isQuiet) return next(e);
54 const { Text } = $.ui.resolve(e);
55 const name = (await read($, stage))?.storyteller ?? FALLBACK_NAME;
56 const phrases = [...new Set(calls.map((c) => quietPhrase(c.tool, c.input, c.output)))];
57 return (
58 <Text dimColor italic>
59 {`${name} ${phrases.join(", ")}${e.props.isActive ? "…" : "."}`}
60 </Text>
61 );
62 });
63
64 on("ui.render", { component: "Spinner" }, async ($, e, next) => {
65 const snapshot = await read($, stage);
66 if (!snapshot) return next(e);
67 const word = spinnerWord(snapshot.storyteller, e.props.mode, await read($, voicing));
68 return next({ ...e, props: { ...e.props, word } });
69 });
70
71 // Markdown and Text are bounded at 10000 characters; a longer block keeps
72 // the engine's drawing.
73 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
74 const snapshot = await read($, stage);
75 if (!snapshot || e.props.text.length > 9_000) return next(e);
76 return drawReply($, e, snapshot);
77 });
78};
79
80// One text block of a reply: the Storyteller's name in its colour at the
81// start of a reply; asides in (( )) dim; a lone italic phrase as a
82// scene-setting rule; quoted lines tinted for the one character a paragraph
83// names; everything else drawn as the engine draws markdown.
84function drawReply(
85 $: EngineInterface,
86 e: RenderInput<"AssistantMessage">,
87 snapshot: StageSnapshot,
88) {
89 const { Box, Text } = $.ui.resolve(e);
90 const blocks = replyBlocks(e.props.text, snapshot.speakers);
91 const columns = e.viewport?.columns ?? 80;
92 return (
93 <Box flexDirection="column">
94 {e.props.isFirstOfReply ? (
95 <Text bold color={colorFor(snapshot.storyteller)}>
96 {snapshot.storyteller}
97 </Text>
98 ) : null}
99 <Box flexDirection="column" gap={1}>
100 {blocks.map((block) => drawBlock($, e, block, columns))}
101 </Box>
102 </Box>
103 );
104}
105
106function drawBlock(
107 $: EngineInterface,
108 e: RenderInput<"AssistantMessage">,
109 block: Block,
110 columns: number,
111) {
112 const { Text, Markdown } = $.ui.resolve(e);
113 switch (block.kind) {
114 case "prose":
115 return <Markdown text={block.text} />;
116 case "ooc":
117 return <Markdown text={block.text} dimColor />;
118 case "setting": {
119 const rule = "─".repeat(Math.max(3, Math.min(40, columns - block.text.length - 8)));
120 return (
121 <Text wrap="wrap">
122 <Text dimColor>{"── "}</Text>
123 <Text italic>{block.text}</Text>
124 <Text dimColor>{` ${rule}`}</Text>
125 </Text>
126 );
127 }
128 case "dialogue": {
129 const color = colorFor(block.speaker.stem);
130 return (
131 <Text wrap="wrap">
132 {quoteSpans(block.text).flatMap((span) =>
133 emphasisRuns(span.text).map((run) => (
134 <Text
135 {...(span.quoted ? { color } : {})}
136 {...(run.italic ? { italic: true } : {})}
137 {...(run.bold ? { bold: true } : {})}
138 >
139 {run.text}
140 </Text>
141 )),
142 )}
143 </Text>
144 );
145 }
146 }
147}
148mod/stage/text.ts 281 lines1import type { StageCharacter } from "../types";
2
3// Pure text work behind the stage's drawings: colours, the quiet-line phrases,
4// and how a reply splits into what gets drawn differently. No `$` here, so the
5// tests exercise it directly.
6
7// Readable on dark and light terminals alike; a name always lands on the same one.
8const palette = ["#d19a66", "#61afef", "#c678dd", "#98c379", "#e06c75", "#56b6c2", "#e5c07b"];
9
10export function colorFor(key: string): string {
11 let hash = 0;
12 for (const ch of key) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
13 return palette[hash % palette.length] ?? "#d19a66";
14}
15
16// --- Quiet line (spec 10): a tool row becomes one phrase in voice ---
17
18export const quietTool = /^(mcp__world__.+|Read|Glob|Grep|Skill)$/;
19
20const phrases: Record<string, string> = {
21 set_widget: "updates the board",
22 remove_widget: "wipes a mark from the board",
23 roll: "rolls",
24 upsert_character: "writes a card",
25 update_sheet: "writes a card",
26 upsert_lore: "writes into the lore",
27 upsert_directive: "amends the rules",
28 set_directive: "amends the rules",
29 update_story: "amends the story",
30 open_scene: "opens a scene",
31 close_scene: "closes the scene",
32 set_persona: "changes your part",
33 // A procedure from plugin/skills (the interview, closing a scene, ...).
34 Skill: "considers the craft",
35};
36
37export function quietPhrase(tool: string, input: unknown, output: unknown): string {
38 const name = tool.replace(/^mcp__world__/, "");
39 if (name === "set_scene_state")
40 return hasKey(input, "time") ? "notes the time" : "sets the scene";
41 if (name === "roll") {
42 const expr = field(input, "expr");
43 const result = firstLine(textOf(output));
44 return ["rolls", expr, result ? `→ ${result}` : ""].filter(Boolean).join(" ");
45 }
46 return phrases[name] ?? "consults the archive";
47}
48
49// The text an MCP tool answered: a string, or its text content blocks.
50function textOf(output: unknown): string {
51 if (typeof output === "string") return output;
52 const blocks = Array.isArray(output)
53 ? output
54 : isRecord(output) && Array.isArray(output.content)
55 ? output.content
56 : [];
57 return blocks
58 .map((b: unknown) => (isRecord(b) && typeof b.text === "string" ? b.text : ""))
59 .join("\n");
60}
61
62function firstLine(text: string): string {
63 const line = text.trim().split(/\r?\n/)[0] ?? "";
64 return line.length > 40 ? `${line.slice(0, 39)}…` : line;
65}
66
67function field(input: unknown, key: string): string {
68 return isRecord(input) && typeof input[key] === "string" ? input[key] : "";
69}
70
71function hasKey(input: unknown, key: string): boolean {
72 return isRecord(input) && input[key] !== undefined && input[key] !== null;
73}
74
75function isRecord(value: unknown): value is Record<string, unknown> {
76 return typeof value === "object" && value !== null && !Array.isArray(value);
77}
78
79// The character a card read is about: a Read of characters/<stem>.md, or the
80// world server's get_character. Undefined for any other call.
81export function cardRead(tool: string, input: unknown): string | undefined {
82 if (tool === "Read") {
83 return /[\\/]characters[\\/]([^\\/]+)\.md$/.exec(field(input, "file_path"))?.[1];
84 }
85 if (tool === "mcp__world__get_character") return field(input, "name") || undefined;
86 return undefined;
87}
88
89export function findSpeaker(
90 speakers: readonly StageCharacter[],
91 nameOrStem: string,
92): StageCharacter | undefined {
93 const wanted = nameOrStem.trim().toLowerCase();
94 return speakers.find((s) => s.stem === wanted || s.name.toLowerCase() === wanted);
95}
96
97// --- Reply blocks ---
98
99export type Block =
100 | { kind: "prose"; text: string }
101 // Copilot register: an aside wholly inside (( )).
102 | { kind: "ooc"; text: string }
103 // A paragraph that is exactly one short italic phrase (spec 16).
104 | { kind: "setting"; text: string }
105 // Prose with quoted dialogue that belongs to one character.
106 | { kind: "dialogue"; text: string; speaker: StageCharacter };
107
108// Code fences are left whole: a reply holding one is drawn as plain markdown.
109export function replyBlocks(text: string, speakers: readonly StageCharacter[]): Block[] {
110 if (text.includes("```")) return [{ kind: "prose", text }];
111 const blocks: Block[] = [];
112 let ooc: string[] | undefined;
113 for (const para of text
114 .split(/\n\s*\n/)
115 .map((p) => p.trim())
116 .filter(Boolean)) {
117 if (ooc || para.startsWith("((")) {
118 ooc = [...(ooc ?? []), para];
119 if (para.endsWith("))")) {
120 blocks.push({ kind: "ooc", text: ooc.join("\n\n") });
121 ooc = undefined;
122 }
123 continue;
124 }
125 const setting = /^\*([^*\n]{1,100})\*$/.exec(para)?.[1];
126 if (setting) {
127 blocks.push({ kind: "setting", text: setting.trim() });
128 continue;
129 }
130 const speaker = speakerOf(para, speakers);
131 if (speaker) {
132 blocks.push({ kind: "dialogue", text: para, speaker });
133 continue;
134 }
135 const last = blocks.at(-1);
136 if (last?.kind === "prose") last.text += `\n\n${para}`;
137 else blocks.push({ kind: "prose", text: para });
138 }
139 // An aside that never closed is still an aside.
140 if (ooc) blocks.push({ kind: "ooc", text: ooc.join("\n\n") });
141 return blocks;
142}
143
144// Markdown beyond *emphasis* (headings, lists, quotes, code, links) would be
145// lost in a tinted drawing, so such a paragraph stays plain.
146const richMarkdown = /^(#|[-+] |\d+\. |>)|`|\]\(/m;
147
148// The one character a paragraph's quoted lines belong to: exactly one speaker
149// named outside the quotes (full name or first name). None or several named:
150// no tint, rather than a wrong one.
151export function speakerOf(
152 para: string,
153 speakers: readonly StageCharacter[],
154): StageCharacter | undefined {
155 if (richMarkdown.test(para)) return undefined;
156 const spans = quoteSpans(para);
157 if (!spans.some((s) => s.quoted)) return undefined;
158 const narration = spans
159 .filter((s) => !s.quoted)
160 .map((s) => s.text)
161 .join(" ");
162 const named = speakers.filter((s) => namesOf(s).some((n) => wordIn(narration, n)));
163 return named.length === 1 ? named[0] : undefined;
164}
165
166function namesOf(s: StageCharacter): string[] {
167 const first = s.name.split(/\s+/)[0] ?? "";
168 return first.length >= 3 && first !== s.name ? [s.name, first] : [s.name];
169}
170
171function wordIn(text: string, word: string): boolean {
172 const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
173 return new RegExp(`(^|[^\\p{L}])${escaped}(?![\\p{L}])`, "u").test(text);
174}
175
176export type Span = { text: string; quoted: boolean };
177
178// Splits on double quotes, straight or curly; the quote marks stay in the
179// quoted span.
180export function quoteSpans(para: string): Span[] {
181 const spans: Span[] = [];
182 let current = "";
183 let quoted = false;
184 const flush = () => {
185 if (current) spans.push({ text: current, quoted });
186 current = "";
187 };
188 for (const ch of para) {
189 const opens = !quoted && (ch === '"' || ch === "“");
190 const closes = quoted && (ch === '"' || ch === "”");
191 if (opens) {
192 flush();
193 quoted = true;
194 current = ch;
195 } else if (closes) {
196 current += ch;
197 flush();
198 quoted = false;
199 } else {
200 current += ch;
201 }
202 }
203 flush();
204 return spans;
205}
206
207export type Run = { text: string; italic: boolean; bold: boolean };
208
209// *italic* and **bold** inside a tinted paragraph, the only markdown it keeps.
210export function emphasisRuns(text: string): Run[] {
211 const runs: Run[] = [];
212 const pattern = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
213 let at = 0;
214 for (const m of text.matchAll(pattern)) {
215 const index = m.index ?? 0;
216 if (index > at) runs.push({ text: text.slice(at, index), italic: false, bold: false });
217 if (m[1] !== undefined) runs.push({ text: m[1], italic: false, bold: true });
218 else runs.push({ text: m[2] ?? "", italic: true, bold: false });
219 at = index + m[0].length;
220 }
221 if (at < text.length) runs.push({ text: text.slice(at), italic: false, bold: false });
222 return runs;
223}
224
225// --- Spinner ---
226
227export function spinnerWord(storyteller: string, mode: string, voicing: string | null): string {
228 return mode === "thinking"
229 ? `${voicing ?? storyteller} is thinking`
230 : `${storyteller} is writing`;
231}
232
233// --- Quieting (spec 10) ---
234
235// Engine reminders aimed at a coding session: todo lists and task tools, plan
236// and auto modes (their Bash and file-editing steers), memory files, listings
237// of skills and agents the Storyteller has no tool to call, and nudges whose
238// answer would leak into narration. Hook context, the date, the model, the
239// token budget and tool listings stay.
240export const codingAttachments = [
241 "todo_reminder",
242 "task_reminder",
243 "plan_mode",
244 "plan_mode_reentry",
245 "plan_mode_exit",
246 "auto_mode",
247 "auto_mode_exit",
248 "nested_memory",
249 "agent_listing_delta",
250 "silent_turn_reminder",
251 "bash_output_audience_note",
252 "remote_session_change",
253];
254
255// The skill listing names every skill the Skill tool can load, one entry per
256// "- name: description" line (a description may run onto further lines).
257// Only the entries `keep` accepts stay; null when none does, so the
258// attachment is dropped.
259export function keepSkillEntries(text: string, keep: (name: string) => boolean): string | null {
260 const [head = "", ...entries] = text.split(/^(?=- [^\s:]+:)/m);
261 const kept = entries.filter((entry) => keep(/^- ([^\s:]+(?::[^\s:]+)?):/.exec(entry)?.[1] ?? ""));
262 return kept.length ? `${head}${kept.join("")}`.trimEnd() : null;
263}
264
265// The instructions attachment carries every CLAUDE.md in reach. The story's
266// own (generated: index and current scene) stays; the player's global one is
267// their coding notebook and goes. Text in any other shape passes untouched.
268export function withoutUserInstructions(text: string): string {
269 const header = /^Contents of .+ \((.+)\):$/gm;
270 const starts = [...text.matchAll(header)].map((m) => ({
271 at: m.index ?? 0,
272 isUser: (m[1] ?? "").startsWith("user's private global instructions"),
273 }));
274 if (!starts.some((s) => s.isUser)) return text;
275 let out = text.slice(0, starts[0]?.at ?? 0);
276 starts.forEach((s, i) => {
277 if (!s.isUser) out += text.slice(s.at, starts[i + 1]?.at ?? text.length);
278 });
279 return out.trimEnd();
280}
281mod/stage/layout.ts 559 lines1import type { StagePresent, StageScene, StageWidget } from "../types";
2
3// Pure layout behind the scene pane (spec 10): the scene laid out as lines of
4// styled runs, already wrapped to the pane's width (or a frame's inside), so
5// the drawing only maps lines to Text and the terminal never re-wraps a value
6// into one long run. No `$` here, so the tests exercise it directly.
7
8export type Run = { text: string; color?: string; dim?: true; bold?: true };
9export type Line = Run[];
10
11export type PresentRow = {
12 stem: string;
13 name: string;
14 color: string;
15 // Dim tags, one short line; empty when the card has none.
16 tags: string;
17 // A portrait drawn left of the name block: only on a wide pane, set by the caller.
18 portrait: string | null;
19};
20
21// One widget (spec 19.4): its row, which may take several lines (a list, tags
22// or text that wrap, a name stacked over its value on a narrow pane), and its
23// note, dim, whole, under it.
24export type WidgetRow = { lines: Line[]; note: Line[] };
25// Widgets sharing a `group`, under its heading; the ungrouped have none.
26export type WidgetGroup = { heading: string | null; rows: WidgetRow[] };
27
28// The header spans the pane; Now, Present and Widgets are laid out for the
29// inside of a section frame (frameInner), which the caller draws.
30export type PanePlan = {
31 header: Line[];
32 // The Now frame's body: where, when and mood, a blank row, then the prose.
33 now: Line[];
34 present: PresentRow[];
35 // Only the widgets with no pane of their own.
36 widgets: WidgetGroup[];
37};
38
39export type PaneSize = {
40 columns: number;
41 rows: number;
42 // True where a portrait Image can be drawn beside the name (terminal, wide pane).
43 portraits: boolean;
44};
45
46export const RULE = "─";
47export const ELLIPSIS = "…";
48// Image cells beside a name; the rows are also what such a row costs.
49export const PORTRAIT = { columns: 6, rows: 3 } as const;
50export const WIDE = 60;
51const LABEL = 6;
52
53// Code points, not UTF-16 units: an em dash or a curly quote is one cell.
54export function width(text: string): number {
55 return [...text].length;
56}
57
58export function truncate(text: string, w: number): string {
59 if (w <= 0) return "";
60 const chars = [...text];
61 if (chars.length <= w) return text;
62 return `${chars
63 .slice(0, w - 1)
64 .join("")
65 .trimEnd()}${ELLIPSIS}`;
66}
67
68export function pad(text: string, w: number): string {
69 const cut = truncate(text, w);
70 return cut + " ".repeat(Math.max(0, w - width(cut)));
71}
72
73export function padStart(text: string, w: number): string {
74 const cut = truncate(text, w);
75 return " ".repeat(Math.max(0, w - width(cut))) + cut;
76}
77
78// Greedy word wrap, whitespace (newlines included) collapsed; a word longer
79// than the width is split across lines rather than allowed to overflow.
80export function wrap(text: string, w: number): string[] {
81 if (w < 1) return [];
82 const words = text.split(/\s+/).filter(Boolean);
83 const lines: string[] = [];
84 let line = "";
85 for (let word of words) {
86 while (width(word) > w) {
87 const room = line ? w - width(line) - 1 : w;
88 if (room <= 0) {
89 lines.push(line);
90 line = "";
91 continue;
92 }
93 const chars = [...word];
94 const head = chars.slice(0, room).join("");
95 lines.push(line ? `${line} ${head}` : head);
96 line = "";
97 word = chars.slice(room).join("");
98 }
99 if (!word) continue;
100 if (!line) line = word;
101 else if (width(line) + 1 + width(word) <= w) line = `${line} ${word}`;
102 else {
103 lines.push(line);
104 line = word;
105 }
106 }
107 if (line) lines.push(line);
108 return lines;
109}
110
111// At most `max` lines; when some are cut, the last kept one is shortened so a
112// dim ellipsis fits after it on the same row.
113export function clip(lines: string[], max: number, w: number): { lines: string[]; cut: boolean } {
114 if (lines.length <= max) return { lines, cut: false };
115 if (max <= 0) return { lines: [], cut: true };
116 const kept = lines.slice(0, max);
117 const last = kept[max - 1] ?? "";
118 kept[max - 1] =
119 width(last) < w
120 ? last
121 : [...last]
122 .slice(0, w - 1)
123 .join("")
124 .trimEnd();
125 return { lines: kept, cut: true };
126}
127
128// A line of runs cut to `w` cells, the last run that fits ending in an ellipsis.
129export function fit(line: Line, w: number): Line {
130 const total = line.reduce((n, r) => n + width(r.text), 0);
131 if (total <= w) return line;
132 const out: Line = [];
133 let room = w;
134 for (const run of line) {
135 if (room <= 0) break;
136 const n = width(run.text);
137 if (n < room) {
138 out.push(run);
139 room -= n;
140 } else {
141 out.push({ ...run, text: truncate(run.text, room) });
142 room = 0;
143 }
144 }
145 return out;
146}
147
148// A labelled value: the label dim in a fixed column, the value wrapped beside
149// it with a hanging indent so every continuation lines up under the first.
150export function labelled(label: string, value: string, w: number): Line[] {
151 const lines = wrap(value, Math.max(1, w - LABEL));
152 return lines.map((text, i) => [
153 i === 0 ? { text: pad(label, LABEL), dim: true } : { text: " ".repeat(LABEL) },
154 { text },
155 ]);
156}
157
158function prose(lines: string[], cut: boolean, style: Omit<Run, "text">, indent = ""): Line[] {
159 return lines.map((text, i) => {
160 const line: Line = [{ text: indent + text, ...style }];
161 if (cut && i === lines.length - 1) line.push({ text: ELLIPSIS, dim: true });
162 return line;
163 });
164}
165
166export function planPane(
167 scene: StageScene,
168 size: PaneSize,
169 color: string,
170 colorOf: (stem: string) => string,
171): PanePlan {
172 const w = Math.max(16, size.columns);
173 const header: Line[] = [
174 ...wrap(`Scene ${scene.number} · ${scene.title}`, w).map(
175 (text): Line => [{ text, color, bold: true }],
176 ),
177 [{ text: RULE.repeat(w), dim: true }],
178 ];
179 const inner = frameInner(w);
180 const facts = [
181 ["Where", scene.location],
182 ["When", scene.time],
183 ["Mood", scene.mood],
184 ].flatMap(([label, value]) => (label && value ? labelled(label, value, inner) : []));
185 const nowAll = scene.now ? wrap(scene.now, inner) : [];
186 const portraits = size.portraits && w >= WIDE;
187 const present = scene.present.map((who) => presentRow(who, colorOf, portraits));
188
189 // Nothing shrinks to fit the height: the pane scrolls, and the player reads
190 // Now and the widget notes whole. Only names and values truncate.
191 const now = clip(nowAll, nowAll.length, inner);
192 // Dim like a widget's note: the facts above read first, the prose under them.
193 const nowProse = prose(now.lines, now.cut, { dim: true });
194 // A snapshot kept in $.state from before a reload may predate widgets.
195 const widgets = (scene.widgets ?? []).filter((x) => x.pane === null);
196 return {
197 header,
198 // A blank framed row between the facts and the prose, only with both.
199 now: [...facts, ...(facts.length > 0 && nowProse.length > 0 ? [[]] : []), ...nowProse],
200 present,
201 widgets: widgetGroups(widgets, inner),
202 };
203}
204
205// --- Frames ---
206
207// Sections are boxes with the title set into the top edge, drawn as text so
208// every line is exactly the pane's width and nothing rests on how the engine
209// lays out borders. Frame and title are dim; the content keeps its styles.
210export const FRAME = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" } as const;
211// The narrowest pane laid out, as in planPane.
212const MIN_FRAME = 16;
213
214// Content width inside a frame: a border and a space on each side.
215export function frameInner(columns: number): number {
216 return Math.max(MIN_FRAME, columns) - 4;
217}
218
219export function frameTop(title: string, columns: number): Line {
220 const w = Math.max(MIN_FRAME, columns);
221 const label = truncate(title, w - 6);
222 const fill = w - width(label) - 5;
223 return [{ text: `${FRAME.tl}${FRAME.h} ${label} ${FRAME.h.repeat(fill)}${FRAME.tr}`, dim: true }];
224}
225
226export function frameBottom(columns: number): Line {
227 const w = Math.max(MIN_FRAME, columns);
228 return [{ text: `${FRAME.bl}${FRAME.h.repeat(w - 2)}${FRAME.br}`, dim: true }];
229}
230
231// One content line between the side borders, cut or padded to the inside.
232export function framed(line: Line, columns: number): Line {
233 const inner = frameInner(columns);
234 const body = fit(line, inner);
235 const used = body.reduce((n, r) => n + width(r.text), 0);
236 const fill: Line = used < inner ? [{ text: " ".repeat(inner - used) }] : [];
237 return [{ text: `${FRAME.v} `, dim: true }, ...body, ...fill, { text: ` ${FRAME.v}`, dim: true }];
238}
239
240// A line beside a portrait inside a frame: a space after the picture, the
241// text cut or padded to `w`, then the right edge.
242export function besidePortrait(line: Line, w: number): Line {
243 const body = fit(line, w);
244 const used = body.reduce((n, r) => n + width(r.text), 0);
245 const fill: Line = used < w ? [{ text: " ".repeat(w - used) }] : [];
246 return [{ text: " " }, ...body, ...fill, { text: ` ${FRAME.v}`, dim: true }];
247}
248
249// A blank row. A space, not "": an empty Text may take no row at all.
250export const BLANK: Line = [{ text: " " }];
251
252// A whole section as lines, a blank framed row of padding inside each edge;
253// none when it is empty, so it is left out.
254export function frame(title: string, body: readonly Line[], columns: number): Line[] {
255 if (body.length === 0) return [];
256 return [
257 frameTop(title, columns),
258 ...[BLANK, ...body, BLANK].map((l) => framed(l, columns)),
259 frameBottom(columns),
260 ];
261}
262
263// --- Widgets (spec 19.4) ---
264
265// A meter is ten cells of these, a clock one segment per step; the filled part
266// takes the widget's colour, the rest the same colour dimmed.
267export const BAR = { full: "▰", empty: "▱", cells: 10 } as const;
268export const SEGMENT = { full: "◆", empty: "◇" } as const;
269export const SEPARATOR = " · ";
270const INDENT = " ";
271// Fewer cells than this beside the names and each widget stacks instead:
272// name on its own line, value and note under it.
273const MIN_VALUE = 16;
274const STACK = 4;
275// The name column never takes more than this share of the width.
276const NAME_SHARE = 0.4;
277
278// Where a widget sits: `indent` cells in (a group's two), its value starting
279// at column `valueAt` from the left edge, or stacked under the name (null).
280export type Placement = { indent: number; valueAt: number | null };
281
282// One name column for every widget laid out together (a section, a named
283// pane), so every value starts at the same column: the longest name plus two,
284// capped, after the group indent when any widget has a group.
285export function placement(widgets: readonly StageWidget[], w: number): number | null {
286 const longest = Math.max(0, ...widgets.map((x) => width(x.name)));
287 const nameW = Math.min(longest + INDENT.length, Math.floor(w * NAME_SHARE));
288 const valueAt = (widgets.some((x) => x.group !== null) ? INDENT.length : 0) + nameW;
289 return w - valueAt < MIN_VALUE ? null : valueAt;
290}
291
292// Groups in order of first appearance, the ungrouped first; widgets in file
293// order inside each. A group's widgets sit two cells in from its heading.
294export function widgetGroups(widgets: readonly StageWidget[], columns: number): WidgetGroup[] {
295 const w = Math.max(16, columns);
296 const valueAt = placement(widgets, w);
297 const order: (string | null)[] = [null];
298 for (const x of widgets) if (!order.includes(x.group)) order.push(x.group);
299 return order
300 .map((heading) => {
301 const indent = heading === null ? 0 : INDENT.length;
302 const rows = widgets
303 .filter((x) => x.group === heading)
304 .map((x) => widgetRow(x, w, { indent, valueAt }));
305 return { heading, rows };
306 })
307 .filter((g) => g.rows.length > 0);
308}
309
310// The name in the body colour, the value beside it (or under it, stacked) in
311// the widget's colour, then the note, dim, two cells in from the name (four
312// when stacked), wrapped whole.
313export function widgetRow(
314 widget: StageWidget,
315 w: number,
316 at: Placement = { indent: 0, valueAt: placement([widget], w) },
317): WidgetRow {
318 const lead: Line = at.indent > 0 ? [{ text: " ".repeat(at.indent) }] : [];
319 const stacked = at.valueAt === null;
320 const valueAt = at.valueAt ?? at.indent + STACK;
321 const values = valueLines(widget, w - valueAt).map((l) => fit(l, w - valueAt));
322 const gutter: Line = [{ text: " ".repeat(valueAt) }];
323 let lines: Line[];
324 if (stacked) {
325 const name: Line = [...lead, { text: truncate(widget.name, w - at.indent) }];
326 lines = [name, ...values.map((l): Line => [...gutter, ...l])];
327 } else {
328 const name = truncate(widget.name, valueAt - at.indent - INDENT.length);
329 const first: Line = [
330 ...lead,
331 { text: name },
332 { text: " ".repeat(valueAt - at.indent - width(name)) },
333 ...(values[0] ?? []),
334 ];
335 lines = [first, ...values.slice(1).map((l): Line => [...gutter, ...l])];
336 }
337 const noteAt = at.indent + (stacked ? STACK : INDENT.length);
338 const note = widget.note
339 ? wrap(widget.note, w - noteAt).map(
340 (text): Line => [{ text: " ".repeat(noteAt) + text, dim: true }],
341 )
342 : [];
343 return { lines, note };
344}
345
346// The value part alone, `w` wide, every run in the widget's colour.
347function valueLines(widget: StageWidget, w: number): Line[] {
348 const tint: Omit<Run, "text"> = widget.color ? { color: widget.color } : {};
349 const none: Line[] = [[{ text: "none", dim: true }]];
350 switch (widget.type) {
351 case "text": {
352 const lines = wrap(widget.value, w);
353 if (lines.length === 0) return none;
354 return lines.map((text): Line => [{ text, ...tint, bold: true }]);
355 }
356 case "counter":
357 return [[{ text: String(widget.value), ...tint, bold: true }]];
358 case "meter": {
359 const count = `${widget.value}/${widget.max}`;
360 const cells = Math.max(1, Math.min(BAR.cells, w - width(count) - 1));
361 return [[...bar(widget, cells, tint), { text: ` ${count}`, ...tint, bold: true }]];
362 }
363 case "clock": {
364 const line: Line = [];
365 const filled = SEGMENT.full.repeat(Math.max(0, widget.value));
366 const empty = SEGMENT.empty.repeat(Math.max(0, widget.of - widget.value));
367 if (filled) line.push({ text: filled, ...tint });
368 if (empty) line.push({ text: empty, ...tint, dim: true });
369 line.push({ text: ` ${widget.value}/${widget.of}`, ...tint, bold: true });
370 return [line];
371 }
372 case "list": {
373 if (widget.value.length === 0) return none;
374 return widget.value.flatMap((item) =>
375 wrap(item, Math.max(1, w - 2)).map(
376 (text, i): Line => [{ text: `${i === 0 ? "• " : " "}${text}`, ...tint }],
377 ),
378 );
379 }
380 case "tags": {
381 const lines = joinWrap(widget.value, SEPARATOR, w);
382 if (lines.length === 0) return none;
383 return lines.map((text): Line => [{ text, ...tint }]);
384 }
385 }
386}
387
388function bar(widget: { value: number; max: number }, cells: number, tint: Omit<Run, "text">): Line {
389 const share = widget.max > 0 ? widget.value / widget.max : 0;
390 const full = Math.max(0, Math.min(cells, Math.round(share * cells)));
391 const line: Line = [];
392 if (full > 0) line.push({ text: BAR.full.repeat(full), ...tint });
393 if (full < cells) line.push({ text: BAR.empty.repeat(cells - full), ...tint, dim: true });
394 return line;
395}
396
397// Items joined by a separator, wrapped between items, never inside one unless
398// a single item is wider than the line. A wrapped line opens with the
399// separator, so it reads as more of the same run of tags.
400export function joinWrap(items: readonly string[], separator: string, w: number): string[] {
401 const lead = separator.trimStart();
402 const lines: string[] = [];
403 let line = "";
404 for (const item of items) {
405 if (line && width(line) + width(separator) + width(item) <= w) {
406 line += separator + item;
407 continue;
408 }
409 if (line) lines.push(line);
410 const next = lines.length > 0 ? lead + item : item;
411 if (width(next) <= w) line = next;
412 else {
413 const pieces = wrap(next, w);
414 line = pieces.pop() ?? "";
415 lines.push(...pieces);
416 }
417 }
418 if (line) lines.push(line);
419 return lines;
420}
421
422// --- Named panes (spec 19.4) ---
423
424// A pane of the mod's own per `pane:` name; the prefix keeps them apart from
425// the scene and directives panes, and is what their render hook matches.
426export const WIDGET_PANE_PREFIX = "widgets-";
427
428export type NamedPane = { id: string; title: string };
429
430// Ids are 1-64 of letters, digits, `_` and `-`; a name with none of those
431// (all accents, say) falls back to a hash of it.
432export function paneId(name: string): string {
433 const slug = name
434 .normalize("NFKD")
435 .replace(/\p{M}/gu, "")
436 .toLowerCase()
437 .replace(/[^a-z0-9]+/g, "-")
438 .slice(0, 48)
439 .replace(/^-+|-+$/g, "");
440 if (slug) return WIDGET_PANE_PREFIX + slug;
441 let hash = 0;
442 for (const ch of name) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
443 return `${WIDGET_PANE_PREFIX}${hash.toString(36)}`;
444}
445
446// The panes the widgets name, in order of first appearance; two names with
447// one slug share a pane, titled by the first.
448export function namedPanes(widgets: readonly StageWidget[]): NamedPane[] {
449 const panes: NamedPane[] = [];
450 for (const x of widgets) {
451 if (x.pane === null) continue;
452 const id = paneId(x.pane);
453 if (!panes.some((p) => p.id === id)) panes.push({ id, title: x.pane });
454 }
455 return panes;
456}
457
458export function paneWidgets(widgets: readonly StageWidget[], id: string): StageWidget[] {
459 return widgets.filter((x) => x.pane !== null && paneId(x.pane) === id);
460}
461
462// What to open and close so the open panes match the widgets: `open` lists
463// the ids now up (from $.ui.panes(), which survives a module reload), and
464// `closed` the ones the person shut by hand, left shut while their widgets stay.
465export function paneChanges(
466 wanted: readonly NamedPane[],
467 open: readonly string[],
468 closed: readonly string[],
469): { open: NamedPane[]; close: string[] } {
470 return {
471 open: wanted.filter((p) => !open.includes(p.id) && !closed.includes(p.id)),
472 close: open.filter(
473 (id) => id.startsWith(WIDGET_PANE_PREFIX) && !wanted.some((p) => p.id === id),
474 ),
475 };
476}
477
478function presentRow(
479 who: StagePresent,
480 colorOf: (stem: string) => string,
481 portraits: boolean,
482): PresentRow {
483 return {
484 stem: who.stem,
485 name: who.name,
486 color: colorOf(who.stem),
487 // A snapshot kept in $.state from before a reload may predate the field.
488 tags: (who.tags ?? []).join(", "),
489 portrait: portraits ? who.portrait : null,
490 };
491}
492
493// One row of the Present list as runs: a bullet and the name in the
494// character's colour, tags dim after it, cut to the width. Beside a portrait
495// the tags take the second row instead (see presentLines).
496export function presentLine(row: PresentRow, w: number): Line {
497 const line: Line = [
498 { text: "● ", color: row.color },
499 { text: row.name, color: row.color },
500 ];
501 if (row.tags) line.push({ text: ` ${row.tags}`, dim: true });
502 return fit(line, w);
503}
504
505export function presentLines(row: PresentRow, w: number): Line[] {
506 if (!row.portrait) return [presentLine(row, w)];
507 const text = w - PORTRAIT.columns - 1;
508 const name = fit([{ text: row.name, color: row.color, bold: true }], text);
509 return row.tags ? [name, fit([{ text: row.tags, dim: true }], text)] : [name];
510}
511
512const flat = (line: Line) => line.map((r) => r.text).join("");
513
514// The plan as plain text, for tests and for showing a layout outside a
515// terminal: the frames the pane draws, a portrait shown as [img].
516export function planText(plan: PanePlan, columns: number): string {
517 const w = Math.max(MIN_FRAME, columns);
518 const inner = frameInner(w);
519 const present = plan.present.flatMap((row): Line[] => {
520 const lines = presentLines(row, inner).map(flat);
521 if (!row.portrait) return lines.map((text) => [{ text }]);
522 const pic = (i: number) => (i === 0 ? "[img] " : " ");
523 return Array.from({ length: PORTRAIT.rows }, (_, i) => [
524 { text: `${pic(i)} ${lines[i] ?? ""}` },
525 ]);
526 });
527 return [
528 ...plan.header,
529 ...frame("Now", plan.now, w),
530 ...frame("Present", present, w),
531 ...frame("Widgets", groupLines(plan.widgets, inner), w),
532 ]
533 .map((l) => flat(l).trimEnd())
534 .join("\n");
535}
536
537// A group's heading: its name upper-case and bold in the body colour, then on
538// the next line a dim rule the full width. Section titles sit in frame borders
539// in title case, so the two levels never look alike.
540export function groupHeading(heading: string, w: number): Line[] {
541 return [
542 [{ text: truncate(heading.toUpperCase(), w), bold: true }],
543 [{ text: RULE.repeat(w), dim: true }],
544 ];
545}
546
547// Widget groups as lines, `w` wide: a blank line before each group but the first.
548export function groupLines(groups: readonly WidgetGroup[], w: number): Line[] {
549 return groups.flatMap((g, i) => [
550 ...(i > 0 ? [BLANK] : []),
551 ...(g.heading === null ? [] : groupHeading(g.heading, w)),
552 ...g.rows.flatMap((r) => [...r.lines, ...r.note]),
553 ]);
554}
555
556export function widgetText(groups: readonly WidgetGroup[], w: number): string[] {
557 return groupLines(groups, w).map((l) => flat(l).trimEnd());
558}
559mod/types/index.d.ts 157 lines1// The storyteller mod's contract: the values it keeps in $.state, and the
2// snapshot plugin/scene.ts prints for the stage to draw from. Plain JSON
3// throughout (null, never undefined), because it crosses a process boundary
4// and the host's state store.
5
6export type StageCharacter = { stem: string; name: string };
7
8export type StagePresent = StageCharacter & {
9 // Absolute path of a PNG portrait under the story's assets/, when there is one.
10 portrait: string | null;
11 // The card's tags, drawn dim beside the name; empty when it has none.
12 tags: string[];
13};
14
15// One widget as the pane draws it (spec 19), mirroring PlainWidget in
16// src/widgets.ts, which the mod cannot import: the name inside, null for an
17// unset field. `color` is a hex the server validated; `pane` names a pane of
18// its own (null: the scene pane); `group` draws a dim heading.
19export type StageWidget = {
20 name: string;
21 note: string | null;
22 color: string | null;
23 pane: string | null;
24 group: string | null;
25} & (
26 | { type: "text"; value: string }
27 | { type: "counter"; value: number }
28 | { type: "meter"; value: number; max: number }
29 | { type: "clock"; value: number; of: number }
30 | { type: "list"; value: string[] }
31 | { type: "tags"; value: string[] }
32);
33
34export type StageScene = {
35 number: number;
36 title: string;
37 location: string | null;
38 time: string | null;
39 mood: string | null;
40 // The scene's "## Now" section, kept current by the notes job.
41 now: string | null;
42 // scene.md, watched for the notes job's writes.
43 path: string;
44 // On stage, the player's own character left out.
45 present: StagePresent[];
46 // In the scene file's order, which is draw order.
47 widgets: StageWidget[];
48};
49
50export type StageSnapshot = {
51 storyteller: string;
52 persona: string | null;
53 scene: StageScene | null;
54 // Characters a quoted line in a reply may belong to.
55 speakers: StageCharacter[];
56};
57
58// The directives pane (spec 12): what plugin/directives.ts prints and takes.
59
60export type DirectiveMode = "always" | "keyed" | "manual";
61
62export type DirectiveRow = {
63 stem: string;
64 title: string;
65 mode: DirectiveMode;
66 keys: string[];
67 on: boolean;
68 // The instruction itself.
69 body: string;
70 // "library" when the story uses the library's file and has none of its own.
71 source: "story" | "library";
72 // The file in force: the story's own, or the library's.
73 path: string;
74};
75
76export type DirectiveList = {
77 // The story folder.
78 dir: string;
79 directives: DirectiveRow[];
80};
81
82// One change, sent as JSON on the script's stdin. `update` changes only the
83// fields it names; an empty `keys` removes them.
84export type DirectiveWrite =
85 | { op: "toggle"; stem: string; on: boolean }
86 | {
87 op: "update";
88 stem: string;
89 title?: string;
90 mode?: DirectiveMode;
91 keys?: string[];
92 body?: string;
93 }
94 | {
95 op: "create";
96 title: string;
97 mode: DirectiveMode;
98 keys: string[];
99 on: boolean;
100 body: string;
101 }
102 | { op: "delete"; stem: string };
103
104// What a write or an open answers: a one-line confirmation or an error, and
105// after a write the list as it now stands.
106export type DirectiveReply = {
107 message: string | null;
108 error: string | null;
109 list: DirectiveList | null;
110};
111
112// The inline editor's working copy. `stem` is null for a new directive;
113// `keys` is the comma list as typed.
114export type DirectiveDraft = {
115 stem: string | null;
116 title: string;
117 keys: string;
118 mode: DirectiveMode;
119 body: string;
120 on: boolean;
121 source: "story" | "library";
122 path: string | null;
123};
124
125export type DirectivesPane = {
126 // The row being edited, or a new directive; null while only listing.
127 draft: DirectiveDraft | null;
128 // The stem whose delete waits for "yes".
129 confirm: string | null;
130 // The one dim line under the list: the last confirmation or error.
131 notice: { text: string; isError: boolean } | null;
132};
133
134declare module "claude-code" {
135 interface PluginState {
136 storyteller: {
137 // The story as last read from disk; null until the first read, or
138 // outside a story.
139 stage: StageSnapshot | null;
140 // True once the player asked for the pane (/scene): it may then sit
141 // inline above the prompt; opened unasked it only docks as a sidebar.
142 paneAsked: boolean;
143 // Display name of the character whose card was read this turn; the
144 // spinner says they are thinking.
145 voicing: string | null;
146 // The story's directives as last read; null until /directives first
147 // reads them, or outside a story.
148 directives: DirectiveList | null;
149 // The directives pane's own state: editor, delete confirm, notice.
150 directivesPane: DirectivesPane;
151 // Ids of widget panes the person closed by hand: not reopened while
152 // their widgets stay, so a closed pane stays closed.
153 closedPanes: string[];
154 };
155 }
156}
157