Просмотр исходного кода

feat(tui): themed animations and operation-aware effects

- Animated startup: typewriter logo reveal + fade-in project info
- ThemedSpinner: different animations per operation type
  - writing: pen/dots pattern (✎·····)
  - auditing: scan bar (▰▰▱▱▱)
  - revising: braille wave (⠋⠙⠹...)
  - planning/composing: pulse (◜◠◝◞◡◟)
  - thinking: wave dots (···  ·· ·)
- Intent-aware labels: "writing chapter", "revising chapter", etc.
- Elapsed time display after 3 seconds
- Styled help with categorized sections
- Styled status with color-coded mode/status badges
- Result cards with intent badges (WRITE, REVISE, FOCUS, etc.)
- Graceful goodbye animation on /quit and Ctrl+C
Ma 7 месяцев назад
Родитель
Сommit
e79cbf2b75
3 измененных файлов с 495 добавлено и 152 удалено
  1. 25 2
      packages/cli/src/tui/ansi.ts
  2. 66 150
      packages/cli/src/tui/app.ts
  3. 404 0
      packages/cli/src/tui/effects.ts

+ 25 - 2
packages/cli/src/tui/ansi.ts

@@ -13,13 +13,28 @@ export const magenta = "\x1b[35m";
 export const cyan = "\x1b[36m";
 export const cyan = "\x1b[36m";
 export const white = "\x1b[37m";
 export const white = "\x1b[37m";
 export const gray = "\x1b[90m";
 export const gray = "\x1b[90m";
+export const brightRed = "\x1b[91m";
+export const brightGreen = "\x1b[92m";
+export const brightYellow = "\x1b[93m";
+export const brightBlue = "\x1b[94m";
+export const brightMagenta = "\x1b[95m";
 export const brightCyan = "\x1b[96m";
 export const brightCyan = "\x1b[96m";
 export const brightWhite = "\x1b[97m";
 export const brightWhite = "\x1b[97m";
 
 
+export const bgCyan = "\x1b[46m";
+export const bgBlue = "\x1b[44m";
+export const bgMagenta = "\x1b[45m";
+export const bgGreen = "\x1b[42m";
+export const bgYellow = "\x1b[43m";
+export const bgRed = "\x1b[41m";
+export const bgGray = "\x1b[100m";
+
 export const clearScreen = "\x1b[2J\x1b[H";
 export const clearScreen = "\x1b[2J\x1b[H";
 export const showCursor = "\x1b[?25h";
 export const showCursor = "\x1b[?25h";
 export const hideCursor = "\x1b[?25l";
 export const hideCursor = "\x1b[?25l";
 export const clearLine = "\x1b[2K\r";
 export const clearLine = "\x1b[2K\r";
+export const saveCursor = "\x1b[s";
+export const restoreCursor = "\x1b[u";
 
 
 export function c(text: string, ...codes: string[]): string {
 export function c(text: string, ...codes: string[]): string {
   return `${codes.join("")}${text}${reset}`;
   return `${codes.join("")}${text}${reset}`;
@@ -29,6 +44,10 @@ export function termWidth(): number {
   return process.stdout.columns ?? 80;
   return process.stdout.columns ?? 80;
 }
 }
 
 
+export function stripAnsi(s: string): string {
+  return s.replace(/\x1b\[[0-9;]*m/g, "");
+}
+
 export function hr(char = "─"): string {
 export function hr(char = "─"): string {
   return char.repeat(Math.min(termWidth(), 60));
   return char.repeat(Math.min(termWidth(), 60));
 }
 }
@@ -44,6 +63,10 @@ export function box(lines: string[], width = 56): string {
   return [top, ...rows, bot].join("\n");
   return [top, ...rows, bot].join("\n");
 }
 }
 
 
-function stripAnsi(s: string): string {
-  return s.replace(/\x1b\[[0-9;]*m/g, "");
+export function badge(text: string, bg: string, fg: string = brightWhite): string {
+  return `${bg}${fg}${bold} ${text} ${reset}`;
+}
+
+export function sleep(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
 }
 }

+ 66 - 150
packages/cli/src/tui/app.ts

@@ -1,10 +1,11 @@
-/* ── InkOS TUI — Claude Code-style persistent REPL ── */
+/* ── InkOS TUI — persistent REPL with themed animations ── */
 
 
 import { basename } from "node:path";
 import { basename } from "node:path";
 import readline from "node:readline/promises";
 import readline from "node:readline/promises";
 import {
 import {
   appendInteractionMessage,
   appendInteractionMessage,
   processProjectInteractionInput,
   processProjectInteractionInput,
+  routeNaturalLanguageIntent,
   type InteractionRuntimeTools,
   type InteractionRuntimeTools,
 } from "@actalk/inkos-core";
 } from "@actalk/inkos-core";
 import {
 import {
@@ -16,25 +17,17 @@ import { createInteractionTools } from "./tools.js";
 import { formatTuiResult } from "./output.js";
 import { formatTuiResult } from "./output.js";
 import { ensureProject, interactiveLlmSetup } from "./setup.js";
 import { ensureProject, interactiveLlmSetup } from "./setup.js";
 import {
 import {
-  c,
-  bold,
-  dim,
-  cyan,
-  green,
-  yellow,
-  gray,
-  magenta,
-  red,
-  blue,
-  brightCyan,
-  brightWhite,
-  reset,
-  box,
-  hr,
-  clearLine,
-  hideCursor,
-  showCursor,
+  c, bold, dim, cyan, green, yellow, gray, red, brightCyan, brightWhite,
+  showCursor, reset, box,
 } from "./ansi.js";
 } from "./ansi.js";
+import {
+  ThemedSpinner,
+  animateStartup,
+  formatResultCard,
+  intentToTheme,
+  printStyledHelp,
+  printStyledStatus,
+} from "./effects.js";
 
 
 /* ── Version ── */
 /* ── Version ── */
 
 
@@ -51,94 +44,37 @@ async function readVersion(): Promise<string> {
   }
   }
 }
 }
 
 
-/* ── Welcome screen ── */
-
-function printWelcome(version: string, projectName: string, bookTitle?: string): void {
-  console.log();
-  console.log(
-    box([
-      `  ${c("InkOS", bold, brightCyan)}${c(` v${version}`, dim)}`,
-      `  ${c("Autonomous Novel Writing AI Agent", dim)}`,
-    ]),
-  );
-  console.log();
-
-  const info = [
-    `  ${c("Project", gray)}  ${c(projectName, brightWhite)}`,
-    `  ${c("Book", gray)}     ${bookTitle ? c(bookTitle, brightWhite) : c("none — run /books or create one", dim)}`,
-  ];
-  console.log(info.join("\n"));
-  console.log();
-  console.log(c("  Type a command or describe what you want to do.", dim));
-  console.log(c("  /help for commands, /quit to exit.", dim));
-  console.log();
-}
-
-/* ── Help ── */
-
-function printHelp(): void {
-  const commands = [
-    ["/write", "Write the next chapter (full pipeline)"],
-    ["/books", "List all books"],
-    ["/open <book>", "Select active book"],
-    ["/mode <auto|semi|manual>", "Switch automation mode"],
-    ["/focus <text>", "Update current focus for next chapters"],
-    ["/rewrite <n>", "Rewrite chapter N"],
-    ["/status", "Show current status"],
-    ["/help", "Show this help"],
-    ["/quit", "Exit InkOS TUI"],
-  ];
-
-  console.log();
-  console.log(c("  Commands", bold, cyan));
-  console.log();
-  for (const [cmd, desc] of commands) {
-    console.log(`  ${c(cmd!, green)}  ${c(desc!, dim)}`);
-  }
-  console.log();
-  console.log(c("  You can also type in natural language:", dim));
-  console.log(c('  "继续写" / "写下一章" / "暂停" / "把林烬改成张三"', dim));
-  console.log();
-}
-
-/* ── Spinner ── */
-
-class Spinner {
-  private interval: ReturnType<typeof setInterval> | undefined;
-  private frame = 0;
-  private readonly frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
-
-  start(message: string): void {
-    this.frame = 0;
-    process.stdout.write(hideCursor);
-    this.interval = setInterval(() => {
-      const f = this.frames[this.frame % this.frames.length];
-      process.stdout.write(`${clearLine}  ${c(f!, cyan)} ${c(message, dim)}`);
-      this.frame++;
-    }, 80);
-  }
-
-  stop(message?: string): void {
-    if (this.interval) {
-      clearInterval(this.interval);
-      this.interval = undefined;
-    }
-    process.stdout.write(`${clearLine}${showCursor}`);
-    if (message) {
-      console.log(`  ${c("✓", green)} ${c(message, dim)}`);
-    }
-  }
-}
-
-/* ── Process input ── */
+/* ── Process input with themed spinner ── */
 
 
 async function processInput(
 async function processInput(
   projectRoot: string,
   projectRoot: string,
   input: string,
   input: string,
   tools: InteractionRuntimeTools,
   tools: InteractionRuntimeTools,
-  spinner: Spinner,
-): Promise<string | undefined> {
-  spinner.start("Processing...");
+): Promise<{ summary: string; intent: string } | undefined> {
+  // Detect intent for themed spinner
+  const session = await loadProjectSession(projectRoot);
+  const activeBookId = await resolveSessionActiveBook(projectRoot, session);
+  const routed = routeNaturalLanguageIntent(input, { activeBookId });
+  const themeName = intentToTheme(routed.intent);
+
+  const spinner = new ThemedSpinner(themeName);
+  const intentLabels: Record<string, string> = {
+    write_next: "writing chapter",
+    revise_chapter: "revising chapter",
+    rewrite_chapter: "rewriting chapter",
+    update_focus: "updating focus",
+    explain_status: "checking status",
+    explain_failure: "investigating",
+    pause_book: "pausing book",
+    list_books: "listing books",
+    select_book: "selecting book",
+    switch_mode: "switching mode",
+    rename_entity: "renaming entity",
+    patch_chapter_text: "patching text",
+    edit_truth: "editing truth file",
+  };
+  spinner.start(intentLabels[routed.intent] ?? "processing");
+
   try {
   try {
     const result = await processProjectInteractionInput({
     const result = await processProjectInteractionInput({
       projectRoot,
       projectRoot,
@@ -158,36 +94,12 @@ async function processInput(
       timestamp: Date.now(),
       timestamp: Date.now(),
     });
     });
     await persistProjectSession(projectRoot, nextSession);
     await persistProjectSession(projectRoot, nextSession);
-    spinner.stop();
-    return summary;
+    spinner.succeed(c(summary, dim));
+    return { summary, intent: result.request.intent };
   } catch (err) {
   } catch (err) {
-    spinner.stop();
     const msg = err instanceof Error ? err.message : String(err);
     const msg = err instanceof Error ? err.message : String(err);
-    return `Error: ${msg}`;
-  }
-}
-
-/* ── Status command ── */
-
-async function printStatus(projectRoot: string): Promise<void> {
-  try {
-    const session = await loadProjectSession(projectRoot);
-    const bookId = await resolveSessionActiveBook(projectRoot, session);
-    console.log();
-    console.log(`  ${c("Mode", gray)}     ${c(session.automationMode, yellow)}`);
-    console.log(`  ${c("Book", gray)}     ${bookId ? c(bookId, brightWhite) : c("none", dim)}`);
-    console.log(`  ${c("Status", gray)}   ${c(session.currentExecution?.status ?? "idle", green)}`);
-    if (session.events.length > 0) {
-      console.log(`  ${c("Events", gray)}   ${c(String(session.events.length), brightWhite)}`);
-      const recent = session.events.slice(-3);
-      for (const ev of recent) {
-        console.log(`           ${c(`${ev.kind}: ${ev.detail ?? ev.status}`, dim)}`);
-      }
-    }
-    console.log();
-  } catch {
-    console.log(c("  Could not load session.", dim));
-    console.log();
+    spinner.fail(c(msg, red));
+    return undefined;
   }
   }
 }
 }
 
 
@@ -272,8 +184,8 @@ export async function launchTui(
   const activeBookId = await resolveSessionActiveBook(projectRoot, session);
   const activeBookId = await resolveSessionActiveBook(projectRoot, session);
   const version = await readVersion();
   const version = await readVersion();
 
 
-  // 4. Welcome
-  printWelcome(version, basename(projectRoot), activeBookId);
+  // 4. Animated welcome
+  await animateStartup(version, basename(projectRoot), activeBookId);
 
 
   // 5. Bail if not interactive
   // 5. Bail if not interactive
   if (!process.stdin.isTTY || !process.stdout.isTTY) {
   if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -286,7 +198,7 @@ export async function launchTui(
     tools = toolsOverride ?? (await createInteractionTools(projectRoot));
     tools = toolsOverride ?? (await createInteractionTools(projectRoot));
   } catch (err) {
   } catch (err) {
     const msg = err instanceof Error ? err.message : String(err);
     const msg = err instanceof Error ? err.message : String(err);
-    console.log(c(`  Failed to initialize: ${msg}`, red));
+    console.log(c(`  ${c("✗", red, bold)} Failed to initialize: ${msg}`, red));
     console.log(c("  Check your .env or run: inkos config set-global", dim));
     console.log(c("  Check your .env or run: inkos config set-global", dim));
     console.log();
     console.log();
     return;
     return;
@@ -296,9 +208,8 @@ export async function launchTui(
   const rl = readline.createInterface({
   const rl = readline.createInterface({
     input: process.stdin,
     input: process.stdin,
     output: process.stdout,
     output: process.stdout,
-    prompt: `${c("❯", cyan)} `,
+    prompt: `  ${c("❯", brightCyan)} `,
   });
   });
-  const spinner = new Spinner();
 
 
   const cleanup = () => {
   const cleanup = () => {
     process.stdout.write(showCursor);
     process.stdout.write(showCursor);
@@ -306,9 +217,9 @@ export async function launchTui(
   };
   };
 
 
   process.on("SIGINT", () => {
   process.on("SIGINT", () => {
-    spinner.stop();
     console.log();
     console.log();
-    console.log(c("  Bye!", dim));
+    console.log(c("  ◇ goodbye", dim));
+    console.log();
     cleanup();
     cleanup();
     process.exit(0);
     process.exit(0);
   });
   });
@@ -325,18 +236,30 @@ export async function launchTui(
 
 
     // Built-in TUI commands
     // Built-in TUI commands
     if (/^\/quit$/i.test(input) || /^\/exit$/i.test(input) || /^(quit|exit|bye)$/i.test(input)) {
     if (/^\/quit$/i.test(input) || /^\/exit$/i.test(input) || /^(quit|exit|bye)$/i.test(input)) {
-      console.log(c("  Bye!", dim));
+      console.log(c("  ◇ goodbye", dim));
+      console.log();
       break;
       break;
     }
     }
 
 
     if (/^\/help$/i.test(input) || /^(help|帮助)$/i.test(input)) {
     if (/^\/help$/i.test(input) || /^(help|帮助)$/i.test(input)) {
-      printHelp();
+      printStyledHelp();
       rl.prompt();
       rl.prompt();
       continue;
       continue;
     }
     }
 
 
     if (/^\/status$/i.test(input) || /^(status|状态)$/i.test(input)) {
     if (/^\/status$/i.test(input) || /^(status|状态)$/i.test(input)) {
-      await printStatus(projectRoot);
+      try {
+        const s = await loadProjectSession(projectRoot);
+        const bId = await resolveSessionActiveBook(projectRoot, s);
+        printStyledStatus({
+          mode: s.automationMode,
+          bookId: bId,
+          status: s.currentExecution?.status ?? "idle",
+          events: s.events,
+        });
+      } catch {
+        console.log(c("  Could not load session.", dim));
+      }
       rl.prompt();
       rl.prompt();
       continue;
       continue;
     }
     }
@@ -347,16 +270,9 @@ export async function launchTui(
       continue;
       continue;
     }
     }
 
 
-    // Delegate to interaction layer
-    const result = await processInput(projectRoot, input, tools, spinner);
-    if (result) {
-      console.log();
-      // Indent each line of result
-      for (const resultLine of result.split("\n")) {
-        console.log(`  ${resultLine}`);
-      }
-      console.log();
-    }
+    // Delegate to interaction layer with themed animation
+    await processInput(projectRoot, input, tools);
+    console.log();
 
 
     rl.prompt();
     rl.prompt();
   }
   }

+ 404 - 0
packages/cli/src/tui/effects.ts

@@ -0,0 +1,404 @@
+/* ── TUI animation effects ── */
+
+import {
+  c, bold, dim, italic,
+  cyan, green, yellow, blue, magenta, red, gray, white,
+  brightCyan, brightGreen, brightYellow, brightBlue, brightMagenta, brightWhite,
+  bgCyan, bgBlue, bgMagenta, bgGreen, bgYellow, bgRed, bgGray,
+  clearLine, hideCursor, showCursor, reset,
+  badge, sleep, stripAnsi, box,
+} from "./ansi.js";
+
+/* ── Operation themes ── */
+
+export interface OperationTheme {
+  readonly icon: string;
+  readonly color: string;
+  readonly brightColor: string;
+  readonly bg: string;
+  readonly label: string;
+  readonly frames: ReadonlyArray<string>;
+}
+
+const WAVE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+const PULSE_FRAMES = ["◜", "◠", "◝", "◞", "◡", "◟"];
+const DOTS_FRAMES = ["·  ", "·· ", "···", " ··", "  ·", "   "];
+const SCAN_FRAMES = ["▱▱▱▱▱", "▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▰▰▰▰▱", "▰▰▰▰▰", "▱▰▰▰▰", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰"];
+const WRITE_FRAMES = ["✎", "✎·", "✎··", "✎···", "✎····", "✎···", "✎··", "✎·"];
+
+export const THEMES: Record<string, OperationTheme> = {
+  thinking: {
+    icon: "◇",
+    color: cyan,
+    brightColor: brightCyan,
+    bg: bgCyan,
+    label: "thinking",
+    frames: DOTS_FRAMES,
+  },
+  writing: {
+    icon: "✎",
+    color: magenta,
+    brightColor: brightMagenta,
+    bg: bgMagenta,
+    label: "writing",
+    frames: WRITE_FRAMES,
+  },
+  auditing: {
+    icon: "◉",
+    color: yellow,
+    brightColor: brightYellow,
+    bg: bgYellow,
+    label: "auditing",
+    frames: SCAN_FRAMES,
+  },
+  revising: {
+    icon: "✂",
+    color: blue,
+    brightColor: brightBlue,
+    bg: bgBlue,
+    label: "revising",
+    frames: WAVE_FRAMES,
+  },
+  planning: {
+    icon: "◈",
+    color: cyan,
+    brightColor: brightCyan,
+    bg: bgCyan,
+    label: "planning",
+    frames: PULSE_FRAMES,
+  },
+  composing: {
+    icon: "❖",
+    color: green,
+    brightColor: brightGreen,
+    bg: bgGreen,
+    label: "composing",
+    frames: PULSE_FRAMES,
+  },
+  loading: {
+    icon: "◌",
+    color: gray,
+    brightColor: white,
+    bg: bgGray,
+    label: "loading",
+    frames: WAVE_FRAMES,
+  },
+};
+
+/* ── Animated spinner with themed operations ── */
+
+export class ThemedSpinner {
+  private interval: ReturnType<typeof setInterval> | undefined;
+  private frame = 0;
+  private elapsed = 0;
+  private theme: OperationTheme;
+
+  constructor(themeName = "thinking") {
+    this.theme = THEMES[themeName] ?? THEMES["thinking"]!;
+  }
+
+  start(label?: string): void {
+    const displayLabel = label ?? this.theme.label;
+    this.frame = 0;
+    this.elapsed = 0;
+    process.stdout.write(hideCursor);
+
+    this.interval = setInterval(() => {
+      this.elapsed += 120;
+      const f = this.theme.frames[this.frame % this.theme.frames.length]!;
+      const icon = c(this.theme.icon, this.theme.color);
+      const anim = c(f, this.theme.brightColor);
+      const text = c(displayLabel, dim);
+      const time = this.elapsed >= 3000
+        ? c(` ${formatElapsed(this.elapsed)}`, gray)
+        : "";
+      process.stdout.write(`${clearLine}  ${icon} ${text} ${anim}${time}`);
+      this.frame++;
+    }, 120);
+  }
+
+  update(label: string): void {
+    if (!this.interval) return;
+    this.stop();
+    this.start(label);
+  }
+
+  succeed(message?: string): void {
+    this.clear();
+    if (message) {
+      console.log(`  ${c("✓", brightGreen, bold)} ${message}`);
+    }
+  }
+
+  fail(message?: string): void {
+    this.clear();
+    if (message) {
+      console.log(`  ${c("✗", red, bold)} ${message}`);
+    }
+  }
+
+  stop(): void {
+    this.clear();
+  }
+
+  private clear(): void {
+    if (this.interval) {
+      clearInterval(this.interval);
+      this.interval = undefined;
+    }
+    process.stdout.write(`${clearLine}${showCursor}`);
+  }
+}
+
+/* ── Startup animation ── */
+
+export async function animateStartup(version: string, projectName: string, bookTitle?: string): Promise<void> {
+  const isTTY = process.stdout.isTTY;
+
+  // Typing effect for logo
+  const logoText = "InkOS";
+  const tagline = "Autonomous Novel Writing AI Agent";
+
+  if (isTTY) {
+    console.log();
+    process.stdout.write(hideCursor);
+
+    // Draw box top
+    process.stdout.write("  ╭" + "─".repeat(52) + "╮\n");
+
+    // Animate logo typing
+    process.stdout.write("  │  ");
+    for (const char of logoText) {
+      process.stdout.write(c(char, bold, brightCyan));
+      await sleep(60);
+    }
+    process.stdout.write(c(` v${version}`, dim));
+    const logoPad = 52 - 2 - logoText.length - ` v${version}`.length;
+    process.stdout.write(" ".repeat(Math.max(0, logoPad)) + "│\n");
+
+    // Animate tagline
+    process.stdout.write("  │  ");
+    for (let i = 0; i < tagline.length; i++) {
+      process.stdout.write(c(tagline[i]!, dim));
+      if (i % 3 === 0) await sleep(15);
+    }
+    const tagPad = 52 - 2 - tagline.length;
+    process.stdout.write(" ".repeat(Math.max(0, tagPad)) + "│\n");
+
+    // Box bottom
+    process.stdout.write("  ╰" + "─".repeat(52) + "╯\n");
+    process.stdout.write(showCursor);
+
+    await sleep(200);
+  } else {
+    // Non-TTY fallback
+    console.log();
+    console.log(
+      box([
+        `  ${c("InkOS", bold, brightCyan)}${c(` v${version}`, dim)}`,
+        `  ${c(tagline, dim)}`,
+      ]),
+    );
+  }
+
+  // Project info with fade-in effect
+  console.log();
+  if (isTTY) {
+    await typewrite(`  ${c("◇", cyan)} ${c("Project", gray)}  ${c(projectName, brightWhite)}`, 8);
+    await sleep(100);
+    const bookDisplay = bookTitle
+      ? c(bookTitle, brightWhite)
+      : c("no book yet", dim);
+    await typewrite(`  ${c("◇", cyan)} ${c("Book", gray)}     ${bookDisplay}`, 8);
+    await sleep(100);
+  } else {
+    console.log(`  ${c("◇", cyan)} ${c("Project", gray)}  ${c(projectName, brightWhite)}`);
+    const bookDisplay = bookTitle
+      ? c(bookTitle, brightWhite)
+      : c("no book yet", dim);
+    console.log(`  ${c("◇", cyan)} ${c("Book", gray)}     ${bookDisplay}`);
+  }
+
+  console.log();
+  console.log(c("  Type anything to start. /help for commands.", dim));
+  console.log();
+}
+
+/* ── Typewriter effect ── */
+
+async function typewrite(text: string, charDelay = 12): Promise<void> {
+  const chars = text.split("");
+  let i = 0;
+  let insideEscape = false;
+
+  for (const ch of chars) {
+    process.stdout.write(ch);
+    if (ch === "\x1b") insideEscape = true;
+    if (insideEscape) {
+      if (ch === "m") insideEscape = false;
+      continue;
+    }
+    i++;
+    if (i % 2 === 0) await sleep(charDelay);
+  }
+  process.stdout.write("\n");
+}
+
+/* ── Result display ── */
+
+export function formatResultCard(content: string, intent?: string): string {
+  const lines: string[] = [];
+
+  if (intent) {
+    const intentBadge = intentToBadge(intent);
+    lines.push(`  ${intentBadge}`);
+    lines.push("");
+  }
+
+  for (const line of content.split("\n")) {
+    lines.push(`  ${line}`);
+  }
+
+  return lines.join("\n");
+}
+
+function intentToBadge(intent: string): string {
+  const badges: Record<string, [string, string]> = {
+    write_next: [" WRITE ", bgMagenta],
+    revise_chapter: [" REVISE ", bgBlue],
+    rewrite_chapter: [" REWRITE ", bgBlue],
+    update_focus: [" FOCUS ", bgCyan],
+    explain_status: [" STATUS ", bgGray],
+    explain_failure: [" DEBUG ", bgRed],
+    pause_book: [" PAUSE ", bgYellow],
+    list_books: [" BOOKS ", bgGray],
+    select_book: [" SELECT ", bgGreen],
+    switch_mode: [" MODE ", bgCyan],
+    rename_entity: [" RENAME ", bgYellow],
+    patch_chapter_text: [" PATCH ", bgBlue],
+    edit_truth: [" TRUTH ", bgGreen],
+  };
+  const [label, bg] = badges[intent] ?? [` ${intent.toUpperCase()} `, bgGray];
+  return badge(label!, bg!);
+}
+
+/* ── Intent to spinner theme ── */
+
+export function intentToTheme(intent: string): string {
+  const map: Record<string, string> = {
+    write_next: "writing",
+    revise_chapter: "revising",
+    rewrite_chapter: "revising",
+    update_focus: "composing",
+    explain_status: "loading",
+    explain_failure: "thinking",
+    pause_book: "loading",
+    list_books: "loading",
+    select_book: "loading",
+    switch_mode: "loading",
+    rename_entity: "composing",
+    patch_chapter_text: "revising",
+    edit_truth: "composing",
+  };
+  return map[intent] ?? "thinking";
+}
+
+/* ── Help display ── */
+
+export function printStyledHelp(): void {
+  const sections = [
+    {
+      title: "Writing",
+      commands: [
+        ["/write", "Write the next chapter (full pipeline)"],
+        ["/rewrite <n>", "Rewrite chapter N from scratch"],
+      ],
+    },
+    {
+      title: "Navigation",
+      commands: [
+        ["/books", "List all books"],
+        ["/open <book>", "Select active book"],
+        ["/status", "Show current status"],
+      ],
+    },
+    {
+      title: "Control",
+      commands: [
+        ["/mode <auto|semi|manual>", "Switch automation mode"],
+        ["/focus <text>", "Update current focus"],
+      ],
+    },
+    {
+      title: "Session",
+      commands: [
+        ["/clear", "Clear screen"],
+        ["/help", "Show this help"],
+        ["/quit", "Exit InkOS TUI"],
+      ],
+    },
+  ];
+
+  console.log();
+  for (const section of sections) {
+    console.log(`  ${c(section.title, bold, cyan)}`);
+    for (const [cmd, desc] of section.commands) {
+      const cmdStr = c(cmd!, green);
+      const descStr = c(desc!, dim);
+      const padding = " ".repeat(Math.max(1, 24 - stripAnsi(cmd!).length));
+      console.log(`    ${cmdStr}${padding}${descStr}`);
+    }
+    console.log();
+  }
+  console.log(c("  Natural language also works:", dim));
+  console.log(c('  "继续写" "写下一章" "暂停" "把林烬改成张三"', dim, italic));
+  console.log();
+}
+
+/* ── Status display ── */
+
+export function printStyledStatus(params: {
+  readonly mode: string;
+  readonly bookId?: string;
+  readonly status: string;
+  readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
+}): void {
+  const modeColors: Record<string, string> = {
+    auto: green,
+    semi: yellow,
+    manual: blue,
+  };
+  const modeColor = modeColors[params.mode] ?? gray;
+  const statusColors: Record<string, string> = {
+    idle: gray,
+    running: cyan,
+    writing: magenta,
+    auditing: yellow,
+    completed: green,
+    failed: red,
+    waiting_human: brightYellow,
+  };
+  const statusColor = statusColors[params.status] ?? gray;
+
+  console.log();
+  console.log(`  ${c("◇", cyan)} ${c("Mode", gray)}     ${c(params.mode, modeColor, bold)}`);
+  console.log(`  ${c("◇", cyan)} ${c("Book", gray)}     ${params.bookId ? c(params.bookId, brightWhite) : c("none", dim)}`);
+  console.log(`  ${c("◇", cyan)} ${c("Status", gray)}   ${c(params.status, statusColor)}`);
+  if (params.events.length > 0) {
+    console.log(`  ${c("◇", cyan)} ${c("Recent", gray)}`);
+    for (const ev of params.events.slice(-3)) {
+      const icon = ev.status === "completed" ? c("✓", green) : c("·", gray);
+      console.log(`        ${icon} ${c(`${ev.kind}`, dim)} ${c(ev.detail ?? "", gray)}`);
+    }
+  }
+  console.log();
+}
+
+/* ── Utilities ── */
+
+function formatElapsed(ms: number): string {
+  const s = Math.floor(ms / 1000);
+  if (s < 60) return `${s}s`;
+  const m = Math.floor(s / 60);
+  return `${m}m${s % 60}s`;
+}