SLOPSHOPPER

storyteller

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

newpanespinnerrowsguardprompt
v0.1.0no licenseupdated 2026-10-06aeriondyseti/storyteller/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · storyteller
│ ┃ Scene ✕ › fix the failing auth test and add an audit log call │ ┃ Reading the story… │ ● storyteller: stage: could not read the story: SyntaxError: JSON Par │ ● storyteller: stage: could not read the story: SyntaxError: JSON Par │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Scene
Reading the story…
Pane · directives
No story directives to show here. refresh
README

claude-roleplay

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.

Requirements

Install

bun 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.

Play

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.

Where things live

  • Stories: ~/.claude-roleplay/stories/<name>/, one folder per story. Set RP_STORIES to use another folder.
  • Shared characters, lore and directives: ~/.claude-roleplay/library/ (characters/, lore/, directives/). Set RP_LIBRARY to use another folder.
  • Your own skills for every story: ~/.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.
  • Each story folder holds story.md, characters/, lore/, directives/ and scenes/. The layout is in spec section 5.

Configure

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.

Development

bun run verify            # typecheck, lint, tests
bun scripts/smoke.ts      # opt-in: one real model call against the example story
Source 11 files
mod/register.tsx 13 lines
1import 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};
13
mod/notes.ts 515 lines
1import 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}
515
mod/stage.tsx 23 lines
1import 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};
23
mod/stage/commands.ts 107 lines
1import 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}
107
mod/stage/directives.tsx 450 lines
1import 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}
450
mod/stage/quiet.ts 17 lines
1import 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};
17
mod/stage/scene.tsx 287 lines
1import 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}
287
mod/stage/voice.tsx 148 lines
1import 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}
148
mod/stage/text.ts 281 lines
1import 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}
281
mod/stage/layout.ts 559 lines
1import 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}
559
mod/types/index.d.ts 157 lines
1// 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