Explorar o código

feat(tui): rebuild the interface around an Ink dashboard

Ma hai 7 meses
pai
achega
a78b958e12
Modificáronse 39 ficheiros con 3413 adicións e 501 borrados
  1. 76 0
      docs/plans/2026-04-11-ink-tui-dashboard-design.md
  2. 153 0
      docs/plans/2026-04-11-ink-tui-dashboard-implementation.md
  3. 8 3
      packages/cli/package.json
  4. 20 0
      packages/cli/src/__tests__/tui-activity-state.test.ts
  5. 25 0
      packages/cli/src/__tests__/tui-chat-depth.test.ts
  6. 43 0
      packages/cli/src/__tests__/tui-chat-draft.test.ts
  7. 18 0
      packages/cli/src/__tests__/tui-composer-display.test.ts
  8. 24 0
      packages/cli/src/__tests__/tui-control-flow.test.ts
  9. 140 0
      packages/cli/src/__tests__/tui-dashboard.test.tsx
  10. 29 0
      packages/cli/src/__tests__/tui-effects-i18n.test.ts
  11. 22 0
      packages/cli/src/__tests__/tui-i18n.test.ts
  12. 40 0
      packages/cli/src/__tests__/tui-input-history.test.ts
  13. 30 13
      packages/cli/src/__tests__/tui-layout.test.ts
  14. 47 0
      packages/cli/src/__tests__/tui-local-commands.test.ts
  15. 10 0
      packages/cli/src/__tests__/tui-output.test.ts
  16. 18 0
      packages/cli/src/__tests__/tui-setup-i18n.test.ts
  17. 31 0
      packages/cli/src/__tests__/tui-slash-autocomplete.test.ts
  18. 41 0
      packages/cli/src/tui/activity-state.ts
  19. 83 250
      packages/cli/src/tui/app.ts
  20. 20 0
      packages/cli/src/tui/chat-depth.ts
  21. 42 0
      packages/cli/src/tui/chat-draft.ts
  22. 16 0
      packages/cli/src/tui/composer-display.ts
  23. 145 0
      packages/cli/src/tui/dashboard-model.ts
  24. 447 0
      packages/cli/src/tui/dashboard.tsx
  25. 195 86
      packages/cli/src/tui/effects.ts
  26. 295 0
      packages/cli/src/tui/i18n.ts
  27. 69 0
      packages/cli/src/tui/input-history.ts
  28. 55 0
      packages/cli/src/tui/local-commands.ts
  29. 11 19
      packages/cli/src/tui/output.ts
  30. 143 19
      packages/cli/src/tui/setup.ts
  31. 44 0
      packages/cli/src/tui/slash-autocomplete.ts
  32. 4 0
      packages/cli/src/tui/theme.ts
  33. 16 4
      packages/cli/src/tui/tools.ts
  34. 1 0
      packages/cli/tsconfig.json
  35. 36 7
      packages/core/src/interaction/project-control.ts
  36. 13 1
      packages/core/src/interaction/project-tools.ts
  37. 353 88
      packages/core/src/interaction/runtime.ts
  38. 20 6
      packages/core/src/llm/provider.ts
  39. 630 5
      pnpm-lock.yaml

+ 76 - 0
docs/plans/2026-04-11-ink-tui-dashboard-design.md

@@ -0,0 +1,76 @@
+# Ink TUI Dashboard Design
+
+**Goal:** Replace the current readline-driven TUI shell with a fixed-layout Ink dashboard so conversation, execution status, and the composer remain visible at the same time.
+
+## Context
+
+The current TUI is a linear REPL layered on top of `readline` and ANSI helpers. That shape creates the exact UX problems reported by users:
+
+- the input area is only a prompt prefix, not a persistent composer
+- task status is printed inline below the conversation instead of being anchored near the composer
+- output pushes the input area downward, so the screen feels unstable
+- the shell looks like logs, not a workspace
+
+The interaction core itself is not the problem. Session state, routing, execution, and persistence already exist in `@actalk/inkos-core`.
+
+## Proposed Architecture
+
+Keep the existing interaction core and replace only the CLI shell:
+
+- `packages/cli/src/tui/app.ts`
+  becomes a thin launcher that prepares project/model/tools and mounts an Ink app
+- new Ink UI modules in `packages/cli/src/tui/`
+  own layout, input, and rendering
+- existing session persistence stays in `session-store.ts`
+- existing command execution still flows through `processProjectInteractionInput`
+
+## Layout
+
+The dashboard is a fixed vertical stack:
+
+1. `Header`
+   project, active book, automation mode, model, and a compact execution badge
+2. `Conversation`
+   recent user / assistant / system messages with stable visual separation
+3. `Status Rail`
+   current stage, pending decision, and recent events
+4. `Composer`
+   highlighted input box, helper text, and submit state
+
+The composer stays visually anchored at the bottom of the dashboard. Execution state sits directly above it.
+
+## Rendering Strategy
+
+Use Ink components instead of manual `console.log` output:
+
+- Ink layout primitives for fixed regions
+- Ink input handling for keyboard events
+- an Ink text input component for the composer
+- small view-model helpers that derive display data from the persisted interaction session
+
+No screen-level ANSI animation survives unless it can be expressed as component state. The redesign favors a stable, readable shell over decorative startup animation.
+
+## Interaction Model
+
+- user submits text in the composer
+- shell enters `submitting` state
+- `processProjectInteractionInput` runs with existing tools
+- returned session replaces local session state
+- conversation and status panes refresh from the updated session
+
+Errors are shown in the status rail and remain visible until the next successful interaction.
+
+## Non-Goals
+
+- changing the interaction runtime or natural-language router
+- changing daemon behavior
+- adding a full message history scroller in this pass
+- introducing Studio-style navigation into CLI
+
+## Success Criteria
+
+- the composer is always visually distinct and highlighted
+- the current execution state is visible without running `/status`
+- recent events appear above the composer instead of below it
+- new output no longer pushes the prompt into a visually unstable position
+- the CLI package still builds and TUI-focused tests cover the new layout contract

+ 153 - 0
docs/plans/2026-04-11-ink-tui-dashboard-implementation.md

@@ -0,0 +1,153 @@
+# Ink TUI Dashboard Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Rebuild the InkOS CLI TUI as a fixed-layout Ink dashboard with a persistent highlighted composer and a status rail directly above it.
+
+**Architecture:** Keep `@actalk/inkos-core` unchanged and swap the CLI shell from `readline + ANSI` to `Ink + React`. `app.ts` becomes the launcher; new dashboard components render header, conversation, status, and composer from persisted interaction session data.
+
+**Tech Stack:** TypeScript, React, Ink, Vitest
+
+---
+
+### Task 1: Add the Ink runtime dependencies
+
+**Files:**
+- Modify: `packages/cli/package.json`
+- Modify: `pnpm-lock.yaml`
+
+**Step 1: Add CLI dependencies**
+
+Add the runtime and test dependencies needed for the new shell.
+
+**Step 2: Verify installation**
+
+Run: `pnpm --dir packages/cli install`
+
+Expected: dependencies resolve and lockfile updates cleanly.
+
+### Task 2: Add failing tests for the dashboard contract
+
+**Files:**
+- Modify: `packages/cli/src/__tests__/tui-layout.test.ts`
+- Create: `packages/cli/src/__tests__/tui-dashboard.test.tsx`
+
+**Step 1: Write failing tests**
+
+Cover:
+- header displays project, book, mode, and model
+- status rail appears above the composer
+- composer placeholder / highlight shell renders
+- conversation pane renders user and assistant messages separately
+
+**Step 2: Run tests to verify they fail**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
+
+Expected: FAIL because the Ink dashboard components do not exist yet.
+
+### Task 3: Build dashboard view-model helpers
+
+**Files:**
+- Create: `packages/cli/src/tui/dashboard-model.ts`
+- Create: `packages/cli/src/tui/dashboard-model.test.ts`
+
+**Step 1: Write failing tests**
+
+Verify session data is mapped into:
+- header badge data
+- conversation rows
+- recent event rows
+- pending decision summary
+
+**Step 2: Run tests to verify they fail**
+
+Run: `pnpm --dir packages/cli exec vitest run src/tui/dashboard-model.test.ts`
+
+Expected: FAIL because the helpers do not exist yet.
+
+**Step 3: Write minimal implementation**
+
+Create pure helpers so the Ink components can stay thin.
+
+**Step 4: Run tests to verify they pass**
+
+Run: `pnpm --dir packages/cli exec vitest run src/tui/dashboard-model.test.ts`
+
+Expected: PASS
+
+### Task 4: Implement the Ink dashboard shell
+
+**Files:**
+- Create: `packages/cli/src/tui/dashboard.tsx`
+- Modify: `packages/cli/src/tui/app.ts`
+- Optionally modify: `packages/cli/src/tui/output.ts`
+
+**Step 1: Write or extend a failing dashboard render test**
+
+Assert that the mounted dashboard contains:
+- a fixed header
+- conversation content
+- status rail
+- highlighted composer
+
+**Step 2: Run tests to verify they fail**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-dashboard.test.tsx`
+
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+Implement:
+- `InkTuiDashboard` component
+- local submit state
+- composer submission through `processProjectInteractionInput`
+- session-driven refresh after each interaction
+
+**Step 4: Run tests to verify they pass**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-dashboard.test.tsx src/__tests__/tui-layout.test.ts`
+
+Expected: PASS
+
+### Task 5: Reconcile launcher behavior and remove obsolete readline assumptions
+
+**Files:**
+- Modify: `packages/cli/src/tui/app.ts`
+- Modify: `packages/cli/src/tui/effects.ts`
+- Modify or remove: old prompt-related tests as needed
+
+**Step 1: Replace the old REPL entry**
+
+Make `launchTui()` mount Ink instead of starting a `readline` loop.
+
+**Step 2: Verify startup / setup flow**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-command.test.ts src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
+
+Expected: PASS
+
+### Task 6: Build verification
+
+**Files:**
+- Modify: any files touched above
+
+**Step 1: Run focused tests**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
+
+Expected: PASS
+
+**Step 2: Run CLI build**
+
+Run: `pnpm --dir packages/cli run build`
+
+Expected: PASS
+
+**Step 3: Commit**
+
+```bash
+git add docs/plans/2026-04-11-ink-tui-dashboard-design.md docs/plans/2026-04-11-ink-tui-dashboard-implementation.md packages/cli/package.json pnpm-lock.yaml packages/cli/src/tui/app.ts packages/cli/src/tui/dashboard.tsx packages/cli/src/tui/dashboard-model.ts packages/cli/src/__tests__/tui-layout.test.ts packages/cli/src/__tests__/tui-dashboard.test.tsx
+git commit -m "feat(tui): rebuild CLI shell with Ink dashboard"
+```

+ 8 - 3
packages/cli/package.json

@@ -48,11 +48,16 @@
     "commander": "^13.0.0",
     "dotenv": "^16.4.0",
     "epub-gen-memory": "^1.0.10",
-    "marked": "^15.0.0"
+    "ink": "^7.0.0",
+    "ink-text-input": "^6.0.0",
+    "marked": "^15.0.0",
+    "react": "^19.2.4"
   },
   "devDependencies": {
+    "@types/react": "^19.2.14",
+    "@types/node": "^22.0.0",
+    "ink-testing-library": "^4.0.0",
     "typescript": "^5.8.0",
-    "vitest": "^3.0.0",
-    "@types/node": "^22.0.0"
+    "vitest": "^3.0.0"
   }
 }

+ 20 - 0
packages/cli/src/__tests__/tui-activity-state.test.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { describeActivityState } from "../tui/activity-state.js";
+import { getTuiCopy } from "../tui/i18n.js";
+import { WARM_ACCENT } from "../tui/theme.js";
+
+describe("tui activity state", () => {
+  it("maps chat-like intents to thinking", () => {
+    const copy = getTuiCopy("en");
+    expect(describeActivityState("chat", copy)).toMatchObject({ label: "thinking", accent: WARM_ACCENT });
+    expect(describeActivityState("explain_status", copy)).toMatchObject({ label: "checking", accent: WARM_ACCENT });
+  });
+
+  it("maps writing and review intents to task-specific labels", () => {
+    const zhCopy = getTuiCopy("zh-CN");
+    expect(describeActivityState("write_next", zhCopy)).toMatchObject({ label: "写作中", accent: WARM_ACCENT });
+    expect(describeActivityState("revise_chapter", zhCopy)).toMatchObject({ label: "审阅中", accent: WARM_ACCENT });
+    expect(describeActivityState("rewrite_chapter", zhCopy)).toMatchObject({ label: "审阅中", accent: WARM_ACCENT });
+    expect(describeActivityState("chat", zhCopy).intervalMs).toBeGreaterThanOrEqual(180);
+  });
+});

+ 25 - 0
packages/cli/src/__tests__/tui-chat-depth.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { resolveChatDepthProfile } from "../tui/chat-depth.js";
+
+describe("tui chat depth", () => {
+  it("maps light/normal/deep to stable chat options", () => {
+    expect(resolveChatDepthProfile("light")).toEqual({
+      depth: "light",
+      temperature: 0.3,
+      maxTokens: 160,
+      label: "light",
+    });
+    expect(resolveChatDepthProfile("normal")).toEqual({
+      depth: "normal",
+      temperature: 0.4,
+      maxTokens: 240,
+      label: "normal",
+    });
+    expect(resolveChatDepthProfile("deep")).toEqual({
+      depth: "deep",
+      temperature: 0.45,
+      maxTokens: 420,
+      label: "deep",
+    });
+  });
+});

+ 43 - 0
packages/cli/src/__tests__/tui-chat-draft.test.ts

@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import type { InteractionSession } from "@actalk/inkos-core";
+import {
+  appendStreamingAssistantChunk,
+  createOptimisticUserMessageSession,
+} from "../tui/chat-draft.js";
+
+function createSession(): InteractionSession {
+  return {
+    sessionId: "session-1",
+    projectRoot: "/tmp/inkos-demo",
+    activeBookId: "harbor",
+    automationMode: "semi",
+    messages: [],
+    events: [],
+  };
+}
+
+describe("tui chat draft", () => {
+  it("optimistically appends the user message before the model returns", () => {
+    const next = createOptimisticUserMessageSession(createSession(), "continue current book", 100);
+    expect(next.messages).toEqual([
+      {
+        role: "user",
+        content: "continue current book",
+        timestamp: 100,
+      },
+    ]);
+  });
+
+  it("creates and extends a streaming assistant draft message", () => {
+    const afterUser = createOptimisticUserMessageSession(createSession(), "hi", 100);
+    const afterFirstChunk = appendStreamingAssistantChunk(afterUser, "hello", 101);
+    expect(afterFirstChunk.messages.at(-1)).toEqual({
+      role: "assistant",
+      content: "hello",
+      timestamp: 101,
+    });
+
+    const afterSecondChunk = appendStreamingAssistantChunk(afterFirstChunk, " world", 101);
+    expect(afterSecondChunk.messages.at(-1)?.content).toBe("hello world");
+  });
+});

+ 18 - 0
packages/cli/src/__tests__/tui-composer-display.test.ts

@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { renderComposerDisplay } from "../tui/composer-display.js";
+
+describe("tui composer display", () => {
+  it("renders placeholder when empty", () => {
+    expect(renderComposerDisplay("", "Ask InkOS")).toEqual({
+      text: "Ask InkOS",
+      isPlaceholder: true,
+    });
+  });
+
+  it("renders plain input text with a block cursor when typing", () => {
+    expect(renderComposerDisplay("continue", "Ask InkOS")).toEqual({
+      text: "continue▌",
+      isPlaceholder: false,
+    });
+  });
+});

+ 24 - 0
packages/cli/src/__tests__/tui-control-flow.test.ts

@@ -12,6 +12,7 @@ describe("tui control flow", () => {
     projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-control-"));
     await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
     await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
+    await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
   });
 
   afterAll(async () => {
@@ -41,6 +42,7 @@ describe("tui control flow", () => {
     expect(result.session.activeBookId).toBe("harbor");
     expect(result.session.currentExecution?.status).toBe("waiting_human");
     expect(result.session.pendingDecision?.kind).toBe("review-next-step");
+    expect(result.session.pendingDecision?.summary).toContain("等待");
     expect(result.session.messages.at(0)?.role).toBe("user");
     expect(result.session.messages.at(-1)?.role).toBe("assistant");
   });
@@ -136,4 +138,26 @@ describe("tui control flow", () => {
     expect(result.session.activeBookId).toBe("beta");
     expect(result.session.messages.at(-1)?.content).toContain("beta");
   });
+
+  it("formats interactive summaries in English when the project language is en", async () => {
+    await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "en" }), "utf-8");
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor", "beta"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processTuiInput(projectRoot, "/open beta", tools);
+
+    expect(result.session.messages.at(-1)?.content).toBe("Active book: beta");
+
+    await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
+  });
 });

+ 140 - 0
packages/cli/src/__tests__/tui-dashboard.test.tsx

@@ -0,0 +1,140 @@
+import React from "react";
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import type { InteractionSession } from "@actalk/inkos-core";
+
+function createSession(): InteractionSession {
+  return {
+    sessionId: "session-1",
+    projectRoot: "/tmp/inkos-demo",
+    activeBookId: "harbor",
+    activeChapterNumber: 12,
+    automationMode: "semi",
+    currentExecution: {
+      status: "writing",
+      bookId: "harbor",
+      chapterNumber: 12,
+      stageLabel: "writing chapter",
+    },
+    pendingDecision: {
+      kind: "review",
+      bookId: "harbor",
+      chapterNumber: 12,
+      summary: "Review chapter 12 before publishing.",
+    },
+    messages: [
+      { role: "user", content: "continue current book", timestamp: 1 },
+      { role: "assistant", content: "Working on chapter 12.", timestamp: 2 },
+    ],
+    events: [
+      {
+        kind: "task.started",
+        timestamp: 3,
+        status: "writing",
+        bookId: "harbor",
+        chapterNumber: 12,
+        detail: "Preparing chapter 12.",
+      },
+    ],
+  };
+}
+
+describe("ink dashboard", () => {
+  it("renders a codex-like single column with compact status and composer", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue=""
+        isSubmitting={false}
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("inkos-demo");
+    expect(frame).toContain("Night Harbor Echo");
+    expect(frame).toContain("gpt-5.4 (openai)");
+    expect(frame).toContain("writing chapter");
+    expect(frame).toContain("Review chapter 12 before publishing.");
+    expect(frame).toContain("Ask InkOS to write, revise, or explain");
+    expect(frame).toContain("│ continue current book");
+    expect(frame).not.toContain("You  continue current book");
+    expect(frame).not.toContain("Header");
+    expect(frame).not.toContain("Conversation");
+    expect(frame).not.toContain("Status");
+    expect(frame).not.toContain("Composer");
+  });
+
+  it("renders the compact status strip directly above the composer", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue="continue"
+        isSubmitting
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("Preparing chapter 12.");
+    expect(frame).toContain("continue");
+    expect(frame.indexOf("Preparing chapter 12.")).toBeLessThan(frame.indexOf("› continue"));
+    expect(frame.indexOf("writing chapter")).toBeLessThan(frame.indexOf("› continue"));
+  });
+
+  it("renders a slash autocomplete dropdown under the composer", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue="/c"
+        isSubmitting={false}
+        slashSuggestions={["/clear", "/config"]}
+        selectedSlashIndex={1}
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("/clear");
+    expect(frame).toContain("/config");
+    expect(frame.indexOf("/clear")).toBeGreaterThan(frame.indexOf("› /c"));
+  });
+
+  it("defaults dashboard chrome to Chinese when locale is zh-CN", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="zh-CN"
+        projectName="inkos-demo"
+        activeBookTitle="夜港回声"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue=""
+        isSubmitting={false}
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("项目 inkos-demo");
+    expect(frame).toContain("作品 夜港回声");
+    expect(frame).toContain("深度 标准");
+    expect(frame).toContain("告诉 InkOS 要写什么、修改什么,或解释什么");
+    expect(frame).toContain("回车发送");
+  });
+});

+ 29 - 0
packages/cli/src/__tests__/tui-effects-i18n.test.ts

@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { stripAnsi } from "../tui/ansi.js";
+import { buildStyledHelpSections, formatStyledStatusLines, intentToBadge } from "../tui/effects.js";
+
+describe("tui effects i18n", () => {
+  it("builds localized help sections", () => {
+    const zhSections = buildStyledHelpSections("zh-CN");
+    const enSections = buildStyledHelpSections("en");
+
+    expect(zhSections[0]?.title).toBe("写作");
+    expect(zhSections[1]?.commands[0]?.[1]).toContain("列出");
+    expect(enSections[0]?.title).toBe("Writing");
+  });
+
+  it("localizes intent badges and status labels", () => {
+    expect(stripAnsi(intentToBadge("write_next", "zh-CN"))).toContain("写作");
+
+    const zhLines = formatStyledStatusLines("zh-CN", {
+      mode: "semi",
+      bookId: "harbor",
+      status: "writing",
+      events: [{ kind: "task.started", detail: "Preparing chapter 3.", status: "running" }],
+    });
+
+    expect(zhLines.join("\n")).toContain("模式");
+    expect(zhLines.join("\n")).toContain("半自动");
+    expect(zhLines.join("\n")).toContain("作品");
+  });
+});

+ 22 - 0
packages/cli/src/__tests__/tui-i18n.test.ts

@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale } from "../tui/i18n.js";
+
+describe("tui i18n", () => {
+  it("defaults to Chinese and supports explicit English override", () => {
+    expect(resolveTuiLocale({})).toBe("zh-CN");
+    expect(resolveTuiLocale({ INKOS_TUI_LOCALE: "en" })).toBe("en");
+    expect(resolveTuiLocale({ LANG: "en_US.UTF-8" })).toBe("en");
+    expect(resolveTuiLocale({}, "en")).toBe("en");
+  });
+
+  it("normalizes common activity labels for Chinese chrome", () => {
+    const copy = getTuiCopy("zh-CN");
+    expect(normalizeStageLabel("writing chapter", copy)).toBe("写作中");
+    expect(normalizeStageLabel("thinking ...", copy)).toBe("思考中");
+    expect(normalizeStageLabel("idle", copy)).toBe("就绪");
+    expect(normalizeStageLabel("waiting_human", copy)).toBe("等待你的决定");
+    expect(normalizeStageLabel("completed", copy)).toBe("已完成");
+    expect(formatModeLabel("semi", copy)).toBe("半自动");
+    expect(formatModeLabel("auto", copy)).toBe("自动");
+  });
+});

+ 40 - 0
packages/cli/src/__tests__/tui-input-history.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import {
+  buildInputHistory,
+  moveHistoryCursor,
+  type InputHistoryState,
+} from "../tui/input-history.js";
+
+describe("tui input history", () => {
+  it("builds unique history from user messages only", () => {
+    const history = buildInputHistory([
+      { role: "user", content: "first", timestamp: 1 },
+      { role: "assistant", content: "reply", timestamp: 2 },
+      { role: "user", content: "second", timestamp: 3 },
+      { role: "user", content: "second", timestamp: 4 },
+      { role: "user", content: "third", timestamp: 5 },
+    ]);
+
+    expect(history).toEqual(["first", "second", "third"]);
+  });
+
+  it("moves up through history and restores draft when moving back down", () => {
+    const entries = ["first", "second", "third"];
+    const initial: InputHistoryState = { cursor: null, draft: "" };
+
+    const firstUp = moveHistoryCursor(entries, initial, "drafting", "up");
+    expect(firstUp.value).toBe("third");
+    expect(firstUp.state).toEqual({ cursor: 2, draft: "drafting" });
+
+    const secondUp = moveHistoryCursor(entries, firstUp.state, firstUp.value, "up");
+    expect(secondUp.value).toBe("second");
+    expect(secondUp.state.cursor).toBe(1);
+
+    const down = moveHistoryCursor(entries, secondUp.state, secondUp.value, "down");
+    expect(down.value).toBe("third");
+
+    const finalDown = moveHistoryCursor(entries, down.state, down.value, "down");
+    expect(finalDown.value).toBe("drafting");
+    expect(finalDown.state.cursor).toBeNull();
+  });
+});

+ 30 - 13
packages/cli/src/__tests__/tui-layout.test.ts

@@ -1,26 +1,34 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 import { renderTuiFrame } from "../tui/app.js";
+import { drawInputHint } from "../tui/effects.js";
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
 
 describe("tui layout", () => {
-  it("renders a project-scoped idle workspace frame", () => {
+  it("renders a codex-like single-column workspace preview", () => {
     const frame = renderTuiFrame({
+      locale: "zh-CN",
       projectName: "inkos-demo",
       activeBookTitle: undefined,
       automationMode: "semi",
       status: "idle",
     });
 
-    expect(frame).toContain("Project: inkos-demo");
-    expect(frame).toContain("Book: none");
-    expect(frame).toContain("Mode: semi");
-    expect(frame).toContain("Stage: idle");
-    expect(frame).toContain("Messages:");
-    expect(frame).toContain("Events:");
-    expect(frame).toContain(">");
+    expect(frame).toContain("项目 inkos-demo");
+    expect(frame).toContain("阶段 就绪");
+    expect(frame).toContain("模式 半自动");
+    expect(frame).not.toContain("Header");
+    expect(frame).not.toContain("Conversation");
+    expect(frame).not.toContain("Status");
+    expect(frame).not.toContain("Composer");
+    expect(frame).toContain("告诉 InkOS");
   });
 
-  it("renders an active book and stage when one is bound", () => {
+  it("keeps the two-line status strip above the composer preview", () => {
     const frame = renderTuiFrame({
+      locale: "en",
       projectName: "inkos-demo",
       activeBookTitle: "Night Harbor Echo",
       automationMode: "auto",
@@ -29,10 +37,19 @@ describe("tui layout", () => {
       events: ["task.completed: Completed write_next for harbor."],
     });
 
-    expect(frame).toContain("Book: Night Harbor Echo");
-    expect(frame).toContain("Mode: auto");
-    expect(frame).toContain("Stage: writing");
+    expect(frame).toContain("Night Harbor Echo");
+    expect(frame).toContain("writing");
     expect(frame).toContain("user: continue");
     expect(frame).toContain("task.completed: Completed write_next for harbor.");
+    expect(frame.indexOf("task.completed: Completed write_next for harbor.")).toBeLessThan(frame.indexOf("Ask InkOS"));
+    expect(frame.indexOf("Mode auto")).toBeLessThan(frame.indexOf("Ask InkOS"));
+  });
+
+  it("does not add blank lines before the readline prompt", () => {
+    const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+    drawInputHint();
+
+    expect(logSpy).not.toHaveBeenCalled();
   });
 });

+ 47 - 0
packages/cli/src/__tests__/tui-local-commands.test.ts

@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { classifyLocalTuiCommand, parseDepthCommand } from "../tui/local-commands.js";
+
+describe("tui local commands", () => {
+  it("recognizes help aliases", () => {
+    expect(classifyLocalTuiCommand("/help")).toBe("help");
+    expect(classifyLocalTuiCommand("help")).toBe("help");
+    expect(classifyLocalTuiCommand("帮助")).toBe("help");
+  });
+
+  it("recognizes status aliases", () => {
+    expect(classifyLocalTuiCommand("/status")).toBe("status");
+    expect(classifyLocalTuiCommand("status")).toBe("status");
+    expect(classifyLocalTuiCommand("状态")).toBe("status");
+  });
+
+  it("recognizes quit aliases", () => {
+    expect(classifyLocalTuiCommand("/quit")).toBe("quit");
+    expect(classifyLocalTuiCommand("/exit")).toBe("quit");
+    expect(classifyLocalTuiCommand("quit")).toBe("quit");
+    expect(classifyLocalTuiCommand("exit")).toBe("quit");
+    expect(classifyLocalTuiCommand("bye")).toBe("quit");
+    expect(classifyLocalTuiCommand("退出")).toBe("quit");
+  });
+
+  it("recognizes config and clear aliases", () => {
+    expect(classifyLocalTuiCommand("/config")).toBe("config");
+    expect(classifyLocalTuiCommand("配置")).toBe("config");
+    expect(classifyLocalTuiCommand("/clear")).toBe("clear");
+    expect(classifyLocalTuiCommand("清屏")).toBe("clear");
+  });
+
+  it("returns undefined for normal chat input", () => {
+    expect(classifyLocalTuiCommand("hi")).toBeUndefined();
+    expect(classifyLocalTuiCommand("continue current book")).toBeUndefined();
+  });
+
+  it("parses depth commands", () => {
+    expect(parseDepthCommand("/depth deep")).toBe("deep");
+    expect(parseDepthCommand("depth light")).toBe("light");
+    expect(parseDepthCommand("/depth normal")).toBe("normal");
+    expect(parseDepthCommand("深度 轻量")).toBe("light");
+    expect(parseDepthCommand("/深度 标准")).toBe("normal");
+    expect(parseDepthCommand("深度 深入")).toBe("deep");
+    expect(parseDepthCommand("/depth weird")).toBeUndefined();
+  });
+});

+ 10 - 0
packages/cli/src/__tests__/tui-output.test.ts

@@ -15,6 +15,7 @@ describe("tui output", () => {
       intent: "switch_mode",
       status: "completed",
       mode: "auto",
+      locale: "en",
     })).toContain("auto");
   });
 
@@ -25,4 +26,13 @@ describe("tui output", () => {
       responseText: "Current status: harbor is at repairing chapter 3.",
     })).toBe("Current status: harbor is at repairing chapter 3.");
   });
+
+  it("renders Chinese summaries when locale is zh-CN", () => {
+    expect(formatTuiResult({
+      intent: "select_book",
+      status: "completed",
+      bookId: "harbor",
+      locale: "zh-CN",
+    })).toBe("当前作品:harbor");
+  });
 });

+ 18 - 0
packages/cli/src/__tests__/tui-setup-i18n.test.ts

@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { buildAutoInitMessages, buildInteractiveSetupCopy } from "../tui/setup.js";
+
+describe("tui setup i18n", () => {
+  it("builds Chinese setup copy by default", () => {
+    const copy = buildInteractiveSetupCopy("zh-CN");
+    expect(copy.title).toBe("模型配置");
+    expect(copy.subtitle).toContain("配置模型服务");
+    expect(copy.steps.provider).toBe("服务提供方");
+    expect(copy.steps.scope).toBe("保存范围");
+    expect(copy.scopeChoices.project).toBe("当前目录");
+  });
+
+  it("builds localized auto-init messages", () => {
+    expect(buildAutoInitMessages("山海", "zh-CN").initializing).toContain("正在初始化项目:山海");
+    expect(buildAutoInitMessages("harbor", "en").initialized).toContain("Project initialized");
+  });
+});

+ 31 - 0
packages/cli/src/__tests__/tui-slash-autocomplete.test.ts

@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import {
+  applySlashSuggestion,
+  getSlashSuggestions,
+  getNextSlashSelection,
+  SLASH_COMMANDS,
+} from "../tui/slash-autocomplete.js";
+
+describe("tui slash autocomplete", () => {
+  it("filters slash commands by prefix", () => {
+    expect(getSlashSuggestions("/st", SLASH_COMMANDS)).toEqual(["/status"]);
+    expect(getSlashSuggestions("/c", SLASH_COMMANDS)).toEqual(["/clear", "/config"]);
+    expect(getSlashSuggestions("/d", SLASH_COMMANDS)).toEqual(["/depth"]);
+  });
+
+  it("does not suggest anything for non-slash input", () => {
+    expect(getSlashSuggestions("status", SLASH_COMMANDS)).toEqual([]);
+    expect(getSlashSuggestions("", SLASH_COMMANDS)).toEqual([]);
+  });
+
+  it("cycles the active suggestion index", () => {
+    expect(getNextSlashSelection(0, 3, "down")).toBe(1);
+    expect(getNextSlashSelection(2, 3, "down")).toBe(0);
+    expect(getNextSlashSelection(0, 3, "up")).toBe(2);
+  });
+
+  it("applies the selected suggestion to the composer input", () => {
+    expect(applySlashSuggestion("/st", ["/status"], 0)).toBe("/status");
+    expect(applySlashSuggestion("/c", ["/clear", "/config"], 1)).toBe("/config");
+  });
+});

+ 41 - 0
packages/cli/src/tui/activity-state.ts

@@ -0,0 +1,41 @@
+import type { InteractionIntentType } from "@actalk/inkos-core";
+import type { TuiCopy } from "./i18n.js";
+
+export interface ActivityState {
+  readonly label: string;
+  readonly frames: readonly string[];
+  readonly accent: string;
+  readonly intervalMs: number;
+}
+
+import { WARM_ACCENT } from "./theme.js";
+
+const DOTS = ["·  ", "·· ", "···", " ··", "  ·"] as const;
+const WAVE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴"] as const;
+const PULSE = ["◜", "◠", "◝", "◞", "◡", "◟"] as const;
+
+export function describeActivityState(
+  intent: InteractionIntentType | "unknown",
+  copy: Pick<TuiCopy, "activity">,
+): ActivityState {
+  switch (intent) {
+    case "write_next":
+    case "continue_book":
+      return { label: copy.activity.writing, frames: WAVE, accent: WARM_ACCENT, intervalMs: 180 };
+    case "revise_chapter":
+    case "rewrite_chapter":
+      return { label: copy.activity.reviewing, frames: WAVE, accent: WARM_ACCENT, intervalMs: 180 };
+    case "update_focus":
+    case "update_author_intent":
+    case "edit_truth":
+      return { label: copy.activity.updating, frames: PULSE, accent: WARM_ACCENT, intervalMs: 220 };
+    case "list_books":
+    case "select_book":
+    case "switch_mode":
+    case "explain_status":
+      return { label: copy.activity.checking, frames: DOTS, accent: WARM_ACCENT, intervalMs: 220 };
+    case "chat":
+    default:
+      return { label: copy.activity.thinking, frames: DOTS, accent: WARM_ACCENT, intervalMs: 220 };
+  }
+}

+ 83 - 250
packages/cli/src/tui/app.ts

@@ -1,114 +1,21 @@
-/* ── InkOS TUI — persistent REPL with themed animations ── */
-
 import { basename } from "node:path";
-import readline from "node:readline/promises";
 import {
   appendInteractionMessage,
   processProjectInteractionInput,
-  routeNaturalLanguageIntent,
   type InteractionRuntimeTools,
 } from "@actalk/inkos-core";
-import {
-  loadProjectSession,
-  persistProjectSession,
-  resolveSessionActiveBook,
-} from "./session-store.js";
-import { createInteractionTools } from "./tools.js";
+import { render } from "ink";
+import React from "react";
+import { InkTuiApp } from "./dashboard.js";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale, type TuiLocale } from "./i18n.js";
 import { formatTuiResult } from "./output.js";
-import { ensureProject, interactiveLlmSetup, detectModelInfo } from "./setup.js";
-import {
-  c, bold, dim, cyan, green, yellow, gray, red, brightCyan, brightWhite,
-  showCursor, reset, box,
-} from "./ansi.js";
-import {
-  ThemedSpinner,
-  animateStartup,
-  formatResultCard,
-  intentToTheme,
-  printStyledHelp,
-  printStyledStatus,
-  printInputSeparator,
-  inputPromptPrefix,
-  drawInputHint,
-} from "./effects.js";
-
-/* ── Version ── */
-
-async function readVersion(): Promise<string> {
-  try {
-    const { readFile } = await import("node:fs/promises");
-    const { join, dirname } = await import("node:path");
-    const { fileURLToPath } = await import("node:url");
-    const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
-    const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
-    return pkg.version ?? "dev";
-  } catch {
-    return "dev";
-  }
-}
-
-/* ── Process input with themed spinner ── */
-
-async function processInput(
-  projectRoot: string,
-  input: string,
-  tools: InteractionRuntimeTools,
-): 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 {
-    const result = await processProjectInteractionInput({
-      projectRoot,
-      input,
-      tools,
-    });
-    const summary = formatTuiResult({
-      intent: result.request.intent,
-      status: result.session.currentExecution?.status ?? "completed",
-      bookId: result.session.activeBookId,
-      mode: result.request.mode,
-      responseText: result.responseText,
-    });
-    const nextSession = appendInteractionMessage(result.session, {
-      role: "assistant",
-      content: summary,
-      timestamp: Date.now(),
-    });
-    await persistProjectSession(projectRoot, nextSession);
-    spinner.succeed(c(summary, dim));
-    return { summary, intent: result.request.intent };
-  } catch (err) {
-    const msg = err instanceof Error ? err.message : String(err);
-    spinner.fail(c(msg, red));
-    return undefined;
-  }
-}
-
-/* ── Legacy exports for tests ── */
+import { loadProjectSession, persistProjectSession } from "./session-store.js";
+import { detectModelInfo, detectProjectLanguage, ensureProject, interactiveLlmSetup } from "./setup.js";
+import { createInteractionTools } from "./tools.js";
+import { animateStartup } from "./effects.js";
 
 export interface TuiFrameState {
+  readonly locale?: TuiLocale;
   readonly projectName: string;
   readonly activeBookTitle?: string;
   readonly automationMode: string;
@@ -118,32 +25,48 @@ export interface TuiFrameState {
 }
 
 export function renderTuiFrame(state: TuiFrameState): string {
+  const locale = state.locale ?? resolveTuiLocale();
+  const copy = getTuiCopy(locale);
   const lines = [
-    `Project: ${state.projectName}`,
-    `Book: ${state.activeBookTitle ?? "none"}`,
-    `Mode: ${state.automationMode}`,
-    `Stage: ${state.status}`,
+    `${copy.labels.project} ${state.projectName}`,
+    `${copy.labels.stage} ${normalizeStageLabel(state.status, copy)}`,
+    `${copy.labels.mode} ${formatModeLabel(state.automationMode, copy)}`,
+    `${copy.labels.book} ${state.activeBookTitle ?? copy.labels.none}`,
     "",
-    "Messages:",
     ...(state.messages?.length
-      ? state.messages.slice(-3).map((message) => `- ${message}`)
-      : ["- (empty)"]),
+      ? state.messages.slice(-6).map((message) => `- ${message}`)
+      : [`- (${copy.labels.none})`]),
     "",
-    "Events:",
-    ...(state.events?.length
-      ? state.events.slice(-3).map((event) => `- ${event}`)
-      : ["- (empty)"]),
+    state.events?.length
+      ? state.events.slice(-1).map((event) => `${copy.labels.recent} ${event}`)[0]!
+      : `${copy.labels.recent} (${copy.labels.none})`,
     "",
+    copy.composer.placeholder,
     "> ",
   ];
   return lines.join("\n");
 }
 
+async function readVersion(): Promise<string> {
+  try {
+    const { readFile } = await import("node:fs/promises");
+    const { join, dirname } = await import("node:path");
+    const { fileURLToPath } = await import("node:url");
+    const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+    const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
+    return pkg.version ?? "dev";
+  } catch {
+    return "dev";
+  }
+}
+
 export async function processTuiInput(
   projectRoot: string,
   input: string,
   tools: InteractionRuntimeTools,
 ) {
+  const projectLanguage = await detectProjectLanguage(projectRoot);
+  const locale = resolveTuiLocale(process.env, projectLanguage);
   const result = await processProjectInteractionInput({
     projectRoot,
     input,
@@ -155,6 +78,7 @@ export async function processTuiInput(
     bookId: result.session.activeBookId,
     mode: result.request.mode,
     responseText: result.responseText,
+    locale,
   });
   const nextSession = appendInteractionMessage(result.session, {
     role: "assistant",
@@ -165,168 +89,77 @@ export async function processTuiInput(
   return { ...result, session: nextSession };
 }
 
-/* ── Main REPL ── */
-
 export async function launchTui(
   projectRoot: string,
   toolsOverride?: InteractionRuntimeTools,
 ): Promise<void> {
-  // 1. Auto-setup
   const { hasLlmConfig } = await ensureProject(projectRoot);
+  const projectLanguage = await detectProjectLanguage(projectRoot);
+  const locale = resolveTuiLocale(process.env, projectLanguage);
+  const copy = getTuiCopy(locale);
 
-  // 2. LLM config if missing
   if (!hasLlmConfig) {
     console.log();
-    console.log(c("  No LLM configuration found.", yellow));
-    console.log(c("  Let's set up your API provider first.", dim));
+    console.log(copy.notes.noLlmConfig);
+    console.log(copy.notes.setupProvider);
     await interactiveLlmSetup(projectRoot);
   }
 
-  // 3. Load session
-  const session = await loadProjectSession(projectRoot);
-  const activeBookId = await resolveSessionActiveBook(projectRoot, session);
-  const version = await readVersion();
-
-  // 4. Detect model + animated welcome
-  const modelInfo = await detectModelInfo(projectRoot);
-  await animateStartup(version, basename(projectRoot), activeBookId, modelInfo ?? undefined);
-
-  // 5. Bail if not interactive
   if (!process.stdin.isTTY || !process.stdout.isTTY) {
     return;
   }
 
-  // 6. Build tools
+  const session = await loadProjectSession(projectRoot);
+  const modelInfo = await detectModelInfo(projectRoot);
+  const modelLabel = modelInfo
+    ? `${modelInfo.model && modelInfo.model !== "unknown" ? modelInfo.model : copy.labels.unknown} (${modelInfo.provider})`
+    : copy.labels.notConfigured;
+  const version = await readVersion();
+  const chatStreamBridge: { onTextDelta?: (text: string) => void } = {};
+
   let tools: InteractionRuntimeTools;
   try {
-    tools = toolsOverride ?? (await createInteractionTools(projectRoot));
-  } catch (err) {
-    const msg = err instanceof Error ? err.message : String(err);
-    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();
+    tools = toolsOverride ?? (await createInteractionTools(projectRoot, {
+      onChatTextDelta: (text) => {
+        chatStreamBridge.onTextDelta?.(text);
+      },
+    }));
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    console.error(copy.notes.toolInitFailed(message));
+    console.error(copy.notes.toolInitHint);
     return;
   }
 
-  // 7. Suppress noisy Node warnings (e.g. SQLite experimental)
-  const origEmitWarning = process.emitWarning;
+  const originalEmitWarning = process.emitWarning;
   process.emitWarning = (() => {}) as typeof process.emitWarning;
-  const origStderrWrite = process.stderr.write.bind(process.stderr);
+  const originalStderrWrite = process.stderr.write.bind(process.stderr);
   process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
-    const s = typeof chunk === "string" ? chunk : chunk.toString();
-    if (s.includes("ExperimentalWarning") || s.includes("--trace-warnings")) {
+    const text = typeof chunk === "string" ? chunk : chunk.toString();
+    if (text.includes("ExperimentalWarning") || text.includes("--trace-warnings")) {
       return true;
     }
-    return (origStderrWrite as Function)(chunk, ...args);
+    return (originalStderrWrite as Function)(chunk, ...args);
   };
 
-  // 8. Slash command completer
-  const SLASH_COMMANDS = [
-    "/write", "/rewrite", "/books", "/open", "/status",
-    "/mode", "/focus", "/config", "/clear", "/help", "/quit",
-  ];
-  const completer = (line: string): [string[], string] => {
-    if (line.startsWith("/")) {
-      const hits = SLASH_COMMANDS.filter((cmd) => cmd.startsWith(line));
-      return [hits.length > 0 ? hits : SLASH_COMMANDS, line];
-    }
-    return [[], line];
-  };
-
-  // 9. REPL loop
-  const prompt = inputPromptPrefix();
-  const rl = readline.createInterface({
-    input: process.stdin,
-    output: process.stdout,
-    prompt,
-    completer,
-  });
-
-  const cleanup = () => {
-    process.emitWarning = origEmitWarning;
-    process.stderr.write = origStderrWrite;
-    process.stdout.write(showCursor);
-    rl.close();
-  };
-
-  const promptInput = () => {
-    drawInputHint();
-    rl.prompt();
-  };
-
-  process.on("SIGINT", () => {
-    console.log();
-    console.log(c("  ◇ goodbye", dim));
-    console.log();
-    cleanup();
-    process.exit(0);
-  });
-
-  promptInput();
-
-  for await (const line of rl) {
-    const input = line.trim();
-
-    if (!input) {
-      promptInput();
-      continue;
-    }
-
-    console.log();
-
-    // Built-in TUI commands
-    if (/^\/quit$/i.test(input) || /^\/exit$/i.test(input) || /^(quit|exit|bye)$/i.test(input)) {
-      console.log(c("  ◇ goodbye", dim));
-      console.log();
-      break;
-    }
-
-    if (/^\/help$/i.test(input) || /^(help|帮助)$/i.test(input)) {
-      printStyledHelp();
-      promptInput();
-      continue;
-    }
-
-    if (/^\/status$/i.test(input) || /^(status|状态)$/i.test(input)) {
-      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));
-      }
-      promptInput();
-      continue;
-    }
-
-    if (/^\/config$/i.test(input)) {
-      await interactiveLlmSetup(projectRoot);
-      const newModel = await detectModelInfo(projectRoot);
-      if (newModel) {
-        console.log(`  ${c("◇", cyan)} ${c("Model", gray)}    ${c(newModel.model, brightWhite)} ${c(`(${newModel.provider})`, dim)}`);
-      }
-      console.log();
-      promptInput();
-      continue;
-    }
-
-    if (/^\/clear$/i.test(input)) {
-      console.clear();
-      promptInput();
-      continue;
-    }
-
-    // Delegate to interaction layer with themed animation
-    await processInput(projectRoot, input, tools);
-    console.log();
-
-    promptInput();
+  try {
+    await animateStartup(version, basename(projectRoot), session.activeBookId, modelInfo);
+
+    const app = render(
+      React.createElement(InkTuiApp, {
+        locale,
+        projectRoot,
+        projectName: basename(projectRoot),
+        modelLabel,
+        initialSession: session,
+        tools,
+        chatStreamBridge,
+      }),
+      { exitOnCtrlC: true },
+    );
+    await app.waitUntilExit();
+  } finally {
+    process.emitWarning = originalEmitWarning;
+    process.stderr.write = originalStderrWrite;
   }
-
-  cleanup();
 }

+ 20 - 0
packages/cli/src/tui/chat-depth.ts

@@ -0,0 +1,20 @@
+export type ChatDepth = "light" | "normal" | "deep";
+
+export interface ChatDepthProfile {
+  readonly depth: ChatDepth;
+  readonly temperature: number;
+  readonly maxTokens: number;
+  readonly label: string;
+}
+
+export function resolveChatDepthProfile(depth: ChatDepth): ChatDepthProfile {
+  switch (depth) {
+    case "light":
+      return { depth, temperature: 0.3, maxTokens: 160, label: "light" };
+    case "deep":
+      return { depth, temperature: 0.45, maxTokens: 420, label: "deep" };
+    case "normal":
+    default:
+      return { depth: "normal", temperature: 0.4, maxTokens: 240, label: "normal" };
+  }
+}

+ 42 - 0
packages/cli/src/tui/chat-draft.ts

@@ -0,0 +1,42 @@
+import {
+  appendInteractionMessage,
+  type InteractionSession,
+} from "@actalk/inkos-core";
+
+export function createOptimisticUserMessageSession(
+  session: InteractionSession,
+  input: string,
+  timestamp: number = Date.now(),
+): InteractionSession {
+  return appendInteractionMessage(session, {
+    role: "user",
+    content: input,
+    timestamp,
+  });
+}
+
+export function appendStreamingAssistantChunk(
+  session: InteractionSession,
+  chunk: string,
+  timestamp: number = Date.now(),
+): InteractionSession {
+  if (!chunk) {
+    return session;
+  }
+
+  const lastMessage = session.messages.at(-1);
+  if (lastMessage?.role === "assistant" && lastMessage.timestamp === timestamp) {
+    return {
+      ...session,
+      messages: session.messages.map((message, index) => index === session.messages.length - 1
+        ? { ...message, content: message.content + chunk }
+        : message),
+    };
+  }
+
+  return appendInteractionMessage(session, {
+    role: "assistant",
+    content: chunk,
+    timestamp,
+  });
+}

+ 16 - 0
packages/cli/src/tui/composer-display.ts

@@ -0,0 +1,16 @@
+export function renderComposerDisplay(
+  inputValue: string,
+  placeholder: string,
+): { readonly text: string; readonly isPlaceholder: boolean } {
+  if (!inputValue) {
+    return {
+      text: placeholder,
+      isPlaceholder: true,
+    };
+  }
+
+  return {
+    text: `${inputValue}▌`,
+    isPlaceholder: false,
+  };
+}

+ 145 - 0
packages/cli/src/tui/dashboard-model.ts

@@ -0,0 +1,145 @@
+import type {
+  ExecutionStatus,
+  InteractionEvent,
+  InteractionMessage,
+  InteractionSession,
+} from "@actalk/inkos-core";
+import { formatModeLabel, normalizeStageLabel, type TuiCopy } from "./i18n.js";
+
+export interface DashboardMessageRow {
+  readonly key: string;
+  readonly label: string;
+  readonly role: InteractionMessage["role"];
+  readonly content: string;
+}
+
+export interface DashboardEventRow {
+  readonly key: string;
+  readonly status: ExecutionStatus;
+  readonly summary: string;
+}
+
+export interface DashboardViewModel {
+  readonly projectName: string;
+  readonly activeBookTitle?: string;
+  readonly modelLabel: string;
+  readonly modeLabel: string;
+  readonly executionStatus: ExecutionStatus;
+  readonly executionLabel: string;
+  readonly headerLine: string;
+  readonly statusPrimaryLine: string;
+  readonly statusSecondaryLine: string;
+  readonly messageRows: ReadonlyArray<DashboardMessageRow>;
+  readonly eventRows: ReadonlyArray<DashboardEventRow>;
+  readonly pendingDecisionSummary?: string;
+  readonly composerPlaceholder: string;
+  readonly composerHelper: string;
+  readonly composerStatus: string;
+  readonly errorText?: string;
+}
+
+export interface BuildDashboardViewModelParams {
+  readonly projectName: string;
+  readonly activeBookTitle?: string;
+  readonly modelLabel: string;
+  readonly depthLabel?: string;
+  readonly copy: TuiCopy;
+  readonly session: InteractionSession;
+  readonly isSubmitting: boolean;
+  readonly lastError?: string;
+  readonly sinceTimestamp?: number;
+  readonly terminalRows?: number;
+}
+
+export function buildDashboardViewModel(params: BuildDashboardViewModelParams): DashboardViewModel {
+  const status = params.session.currentExecution?.status ?? "idle";
+  const executionLabel = normalizeStageLabel(params.session.currentExecution?.stageLabel ?? status, params.copy);
+  const modeLabel = formatModeLabel(params.session.automationMode, params.copy);
+  const bookLabel = params.activeBookTitle ?? params.session.activeBookId ?? params.copy.labels.none;
+  const sinceTimestamp = params.sinceTimestamp ?? 0;
+  const terminalRows = params.terminalRows ?? process.stdout.rows ?? 24;
+  const conversationLimit = Math.max(4, terminalRows - 10);
+
+  const messageRows = params.session.messages
+    .filter((message) => message.timestamp >= sinceTimestamp)
+    .slice(-conversationLimit)
+    .map((message, index) => ({
+      key: `${message.timestamp}-${index}`,
+      label: roleLabel(message.role, params.copy),
+      role: message.role,
+      content: message.content,
+    }));
+
+  const eventRows = params.session.events
+    .filter((event) => event.timestamp >= sinceTimestamp)
+    .slice(-3)
+    .map((event, index) => ({
+      key: `${event.timestamp}-${index}`,
+      status: event.status,
+      summary: summarizeEvent(event, params.copy),
+    }));
+
+  const latestEventSummary = eventRows[eventRows.length - 1]?.summary;
+
+  return {
+    projectName: params.projectName,
+    activeBookTitle: params.activeBookTitle ?? params.session.activeBookId,
+    modelLabel: params.modelLabel,
+    modeLabel,
+    executionStatus: status,
+    executionLabel,
+    headerLine: `${params.copy.labels.project} ${params.projectName} · ${params.copy.labels.book} ${bookLabel} · ${params.copy.labels.depth} ${params.depthLabel ?? params.copy.depthLabels.normal} · ${params.copy.labels.session} ${params.session.sessionId.slice(-4)} · ${params.copy.labels.messageCount(params.session.messages.length)}`,
+    statusPrimaryLine: `${params.copy.labels.stage} ${executionLabel} · ${params.copy.labels.mode} ${modeLabel} · ${params.copy.labels.model} ${params.modelLabel}`,
+    statusSecondaryLine: params.lastError
+      ? `${params.copy.labels.error} · ${compactInline(params.lastError)}`
+      : params.isSubmitting && latestEventSummary
+        ? `${params.copy.labels.recent} · ${latestEventSummary}`
+        : params.session.pendingDecision?.summary
+          ? `${params.copy.labels.pending} · ${compactInline(params.session.pendingDecision.summary)}`
+          : latestEventSummary
+            ? `${params.copy.labels.recent} · ${latestEventSummary}`
+            : `${params.copy.labels.ready} · ${bookLabel}`,
+    messageRows,
+    eventRows,
+    pendingDecisionSummary: params.session.pendingDecision?.summary,
+    composerPlaceholder: params.copy.composer.placeholder,
+    composerHelper: params.copy.composer.helper,
+    composerStatus: params.isSubmitting
+      ? params.copy.composer.submitting
+      : params.lastError
+        ? params.copy.composer.failed
+        : params.copy.composer.ready,
+    errorText: params.lastError,
+  };
+}
+
+function roleLabel(role: InteractionMessage["role"], copy: TuiCopy): string {
+  switch (role) {
+    case "user":
+      return copy.roles.user;
+    case "assistant":
+      return copy.roles.assistant;
+    case "system":
+      return copy.roles.system;
+    default:
+      return role;
+  }
+}
+
+function summarizeEvent(event: InteractionEvent, copy: TuiCopy): string {
+  const base = compactInline(event.detail?.trim() || event.kind);
+  if (event.bookId && event.chapterNumber !== undefined) {
+    return copy.locale === "zh-CN"
+      ? `${base}(${event.bookId} 第 ${event.chapterNumber} 章)`
+      : `${base} (${event.bookId} ch.${event.chapterNumber})`;
+  }
+  if (event.bookId) {
+    return `${base} (${event.bookId})`;
+  }
+  return base;
+}
+
+function compactInline(value: string): string {
+  const singleLine = value.replace(/\s+/g, " ").trim();
+  return singleLine.length > 72 ? singleLine.slice(0, 69) + "..." : singleLine;
+}

+ 447 - 0
packages/cli/src/tui/dashboard.tsx

@@ -0,0 +1,447 @@
+import React, { useEffect, useRef, useState } from "react";
+import {
+  appendInteractionMessage,
+  processProjectInteractionInput,
+  routeNaturalLanguageIntent,
+  type InteractionIntentType,
+  type InteractionRuntimeTools,
+  type InteractionSession,
+} from "@actalk/inkos-core";
+import { Box, Text, useApp, useInput } from "ink";
+import { describeActivityState } from "./activity-state.js";
+import { resolveChatDepthProfile, type ChatDepth } from "./chat-depth.js";
+import { appendStreamingAssistantChunk, createOptimisticUserMessageSession } from "./chat-draft.js";
+import { renderComposerDisplay } from "./composer-display.js";
+import { formatTuiResult } from "./output.js";
+import { buildDashboardViewModel, type DashboardMessageRow } from "./dashboard-model.js";
+import { buildInputHistory, moveHistoryCursor } from "./input-history.js";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, type TuiLocale } from "./i18n.js";
+import { loadProjectSession, persistProjectSession, resolveSessionActiveBook } from "./session-store.js";
+import { classifyLocalTuiCommand, parseDepthCommand } from "./local-commands.js";
+import {
+  applySlashSuggestion,
+  getNextSlashSelection,
+  getSlashSuggestions,
+  SLASH_COMMANDS,
+} from "./slash-autocomplete.js";
+import { WARM_ACCENT, WARM_BORDER, WARM_MUTED, WARM_REPLY } from "./theme.js";
+
+export interface InkTuiDashboardProps {
+  readonly locale: TuiLocale;
+  readonly projectName: string;
+  readonly activeBookTitle?: string;
+  readonly modelLabel: string;
+  readonly depthLabel?: string;
+  readonly session: InteractionSession;
+  readonly inputValue: string;
+  readonly isSubmitting: boolean;
+  readonly sinceTimestamp?: number;
+  readonly lastError?: string;
+  readonly slashSuggestions?: ReadonlyArray<string>;
+  readonly selectedSlashIndex?: number;
+  readonly onInputChange?: (value: string) => void;
+  readonly onSubmit?: (value: string) => void;
+}
+
+export interface InkTuiAppProps {
+  readonly locale: TuiLocale;
+  readonly projectRoot: string;
+  readonly projectName: string;
+  readonly modelLabel: string;
+  readonly initialSession: InteractionSession;
+  readonly tools: InteractionRuntimeTools;
+  readonly chatStreamBridge?: {
+    onTextDelta?: (text: string) => void;
+    getChatRequestOptions?: () => {
+      readonly temperature?: number;
+      readonly maxTokens?: number;
+    };
+  };
+}
+
+export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element {
+  const copy = getTuiCopy(props.locale);
+  const model = buildDashboardViewModel({
+    copy,
+    projectName: props.projectName,
+    activeBookTitle: props.activeBookTitle,
+    modelLabel: props.modelLabel,
+    depthLabel: props.depthLabel,
+    session: props.session,
+    isSubmitting: props.isSubmitting,
+    lastError: props.lastError,
+    sinceTimestamp: props.sinceTimestamp,
+  });
+  const activeAccent = props.isSubmitting ? WARM_ACCENT : statusColor(model.executionStatus);
+  const composer = renderComposerDisplay(props.inputValue, model.composerPlaceholder);
+
+  return (
+    <Box flexDirection="column" width="100%" paddingX={2}>
+      <Text color={WARM_MUTED}>{model.headerLine}</Text>
+
+      <Box flexDirection="column" marginTop={1} flexGrow={1}>
+        {model.messageRows.length > 0 ? (
+          model.messageRows.map((row) => <ConversationRow key={row.key} row={row} />)
+        ) : (
+          <MutedText>{copy.composer.emptyConversation}</MutedText>
+        )}
+      </Box>
+
+      <Box flexDirection="column" marginTop={1}>
+        <Text color={activeAccent}>
+          <ExecutionBadge status={model.executionStatus} color={activeAccent} />
+          {" "}
+          {model.statusPrimaryLine}
+        </Text>
+        <Text color={model.errorText ? "red" : props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
+          {model.statusSecondaryLine}
+        </Text>
+
+        <Box
+          marginTop={1}
+          flexDirection="column"
+          width="100%"
+          borderStyle="round"
+          borderColor={props.isSubmitting ? WARM_ACCENT : WARM_BORDER}
+          paddingX={1}
+        >
+          <Box>
+            <Text color={props.isSubmitting ? WARM_ACCENT : WARM_ACCENT} bold>
+              ›{" "}
+            </Text>
+            <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
+              {composer.text}
+            </Text>
+          </Box>
+          <Text color={props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
+            {model.composerStatus} • {model.composerHelper}
+          </Text>
+          {props.slashSuggestions && props.slashSuggestions.length > 0 ? (
+            <Box flexDirection="column" marginTop={1}>
+              {props.slashSuggestions.slice(0, 5).map((suggestion, index) => {
+                const isSelected = index === (props.selectedSlashIndex ?? 0);
+                return (
+                  <Text key={suggestion} color={isSelected ? WARM_ACCENT : WARM_MUTED}>
+                    {isSelected ? "› " : "  "}
+                    {suggestion}
+                  </Text>
+                );
+              })}
+            </Box>
+          ) : null}
+        </Box>
+      </Box>
+    </Box>
+  );
+}
+
+export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
+  const { exit } = useApp();
+  const copy = getTuiCopy(props.locale);
+  const [session, setSession] = useState(props.initialSession);
+  const [inputValue, setInputValue] = useState("");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [lastError, setLastError] = useState<string | undefined>();
+  const [sinceTimestamp, setSinceTimestamp] = useState<number | undefined>();
+  const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
+  const [historyState, setHistoryState] = useState<{ cursor: number | null; draft: string }>({
+    cursor: null,
+    draft: "",
+  });
+  const [activityIntent, setActivityIntent] = useState<InteractionIntentType | "unknown">("unknown");
+  const [activityFrameIndex, setActivityFrameIndex] = useState(0);
+  const [chatDepth, setChatDepth] = useState<ChatDepth>("normal");
+  const assistantDraftTimestampRef = useRef<number | null>(null);
+  const submitLockRef = useRef(false);
+  const slashSuggestions = getSlashSuggestions(inputValue, SLASH_COMMANDS);
+  const inputHistory = buildInputHistory(session.messages);
+  const activity = describeActivityState(activityIntent, copy);
+  const chatDepthProfile = resolveChatDepthProfile(chatDepth);
+
+  useEffect(() => {
+    if (!isSubmitting) {
+      setActivityFrameIndex(0);
+      return;
+    }
+
+    const timer = setInterval(() => {
+      setActivityFrameIndex((current) => (current + 1) % activity.frames.length);
+    }, activity.intervalMs);
+    return () => clearInterval(timer);
+  }, [activity.frames.length, activity.intervalMs, isSubmitting]);
+
+  if (props.chatStreamBridge) {
+    props.chatStreamBridge.getChatRequestOptions = () => ({
+      temperature: chatDepthProfile.temperature,
+      maxTokens: chatDepthProfile.maxTokens,
+    });
+  }
+
+  props.chatStreamBridge && (props.chatStreamBridge.onTextDelta = (text: string) => {
+    const timestamp = assistantDraftTimestampRef.current;
+    if (timestamp === null) {
+      return;
+    }
+
+    setSession((current) => appendStreamingAssistantChunk(current, text, timestamp));
+  });
+
+  useInput((_input, key) => {
+    if (key.escape) {
+      exit();
+      return;
+    }
+
+    if (slashSuggestions.length > 0 && key.tab) {
+      setInputValue(applySlashSuggestion(inputValue, slashSuggestions, selectedSlashIndex));
+      setSelectedSlashIndex(0);
+      return;
+    }
+
+    if (key.backspace || key.delete) {
+      setInputValue((current) => current.slice(0, -1));
+      setSelectedSlashIndex(0);
+      return;
+    }
+
+    if (slashSuggestions.length > 0 && key.downArrow) {
+      setSelectedSlashIndex((current) => getNextSlashSelection(current, slashSuggestions.length, "down"));
+      return;
+    }
+
+    if (slashSuggestions.length > 0 && key.upArrow) {
+      setSelectedSlashIndex((current) => getNextSlashSelection(current, slashSuggestions.length, "up"));
+      return;
+    }
+
+    if (key.downArrow) {
+      const next = moveHistoryCursor(inputHistory, historyState, inputValue, "down");
+      setHistoryState(next.state);
+      setInputValue(next.value);
+      return;
+    }
+
+    if (key.upArrow) {
+      const next = moveHistoryCursor(inputHistory, historyState, inputValue, "up");
+      setHistoryState(next.state);
+      setInputValue(next.value);
+      return;
+    }
+
+    if (key.return) {
+      void handleSubmit(inputValue);
+      return;
+    }
+
+    if (_input && !_input.includes("\r") && !_input.includes("\n") && !key.ctrl && !key.meta) {
+      setInputValue((current) => current + _input);
+      setSelectedSlashIndex(0);
+    }
+  });
+
+  const appendSystemNote = (content: string) => {
+    setLastError(undefined);
+    setSession((current) => appendInteractionMessage(current, {
+      role: "system",
+      content,
+      timestamp: Date.now(),
+    }));
+  };
+
+  const handleSubmit = async (rawValue: string) => {
+    const input = rawValue.trim();
+    if (!input || isSubmitting || submitLockRef.current) {
+      return;
+    }
+    submitLockRef.current = true;
+
+    try {
+      const localCommand = classifyLocalTuiCommand(input);
+      const depthCommand = parseDepthCommand(input);
+      if (localCommand) {
+        setInputValue("");
+
+        if (localCommand === "quit") {
+          exit();
+          return;
+        }
+
+        if (localCommand === "help") {
+          appendSystemNote(copy.notes.help);
+          return;
+        }
+
+        if (localCommand === "status") {
+          const stage = normalizeStageLabel(
+            session.currentExecution?.stageLabel ?? session.currentExecution?.status ?? "idle",
+            copy,
+          );
+          appendSystemNote(copy.notes.status(stage, formatModeLabel(session.automationMode, copy)));
+          return;
+        }
+
+        if (localCommand === "clear") {
+          setLastError(undefined);
+          setSinceTimestamp(Date.now());
+          return;
+        }
+
+        if (localCommand === "config") {
+          appendSystemNote(copy.notes.config);
+          return;
+        }
+      }
+
+      if (depthCommand) {
+        setInputValue("");
+        setChatDepth(depthCommand);
+        appendSystemNote(copy.notes.depthSet(copy.depthLabels[depthCommand]));
+        return;
+      }
+
+      const activeBookId = await resolveSessionActiveBook(props.projectRoot, session);
+      const routed = routeNaturalLanguageIntent(input, { activeBookId });
+      const userTimestamp = Date.now();
+      const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
+      assistantDraftTimestampRef.current = assistantDraftTimestamp;
+      setActivityIntent(routed.intent);
+      setIsSubmitting(true);
+      setLastError(undefined);
+      setInputValue("");
+      setHistoryState({ cursor: null, draft: "" });
+      setSession((current) => createOptimisticUserMessageSession(current, input, userTimestamp));
+
+      const result = await processProjectInteractionInput({
+        projectRoot: props.projectRoot,
+        input,
+        tools: props.tools,
+        activeBookId,
+      });
+      const summary = formatTuiResult({
+        intent: result.request.intent,
+        status: result.session.currentExecution?.status ?? "completed",
+        bookId: result.session.activeBookId,
+        mode: result.request.mode,
+        responseText: result.responseText,
+        locale: props.locale,
+      });
+      const nextSession = appendInteractionMessage(result.session, {
+        role: "assistant",
+        content: summary,
+        timestamp: Date.now(),
+      });
+      await persistProjectSession(props.projectRoot, nextSession);
+      setSession(nextSession);
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      const failedSession = await loadProjectSession(props.projectRoot);
+      setSession(failedSession);
+      setLastError(message);
+    } finally {
+      assistantDraftTimestampRef.current = null;
+      setIsSubmitting(false);
+      setActivityIntent("unknown");
+      submitLockRef.current = false;
+    }
+  };
+
+  const activitySession = isSubmitting
+    ? {
+        ...session,
+        currentExecution: {
+          status: "planning" as const,
+          bookId: session.activeBookId,
+          chapterNumber: session.activeChapterNumber,
+          stageLabel: `${activity.label} ${activity.frames[activityFrameIndex] ?? ""}`.trim(),
+        },
+      }
+    : session;
+
+  return (
+    <InkTuiDashboard
+      locale={props.locale}
+      projectName={props.projectName}
+      activeBookTitle={activitySession.activeBookId}
+      modelLabel={props.modelLabel}
+      depthLabel={copy.depthLabels[chatDepth]}
+      session={activitySession}
+      inputValue={inputValue}
+      isSubmitting={isSubmitting}
+      sinceTimestamp={sinceTimestamp}
+      lastError={lastError}
+      slashSuggestions={slashSuggestions}
+      selectedSlashIndex={selectedSlashIndex}
+      onInputChange={(value) => {
+        setInputValue(value);
+        setSelectedSlashIndex(0);
+        setHistoryState((current) => current.cursor === null ? current : { cursor: null, draft: value });
+      }}
+      onSubmit={(value) => {
+        void handleSubmit(value);
+      }}
+    />
+  );
+}
+
+function ConversationRow(props: { readonly row: DashboardMessageRow }): React.JSX.Element {
+  if (props.row.role === "user") {
+    return (
+      <Box marginBottom={1}>
+        <Text color="gray">│ {props.row.content}</Text>
+      </Box>
+    );
+  }
+
+  return (
+    <Box marginBottom={1}>
+      <Text color={messageColor(props.row.role)}>
+        {props.row.role === "assistant" ? props.row.content : `${props.row.label}  ${props.row.content}`}
+      </Text>
+    </Box>
+  );
+}
+
+function ExecutionBadge(props: { readonly status: string; readonly color?: string }): React.JSX.Element {
+  return (
+    <Text color={props.color ?? statusColor(props.status)} bold>
+      ●
+    </Text>
+  );
+}
+
+function MutedText(props: { readonly children: React.ReactNode }): React.JSX.Element {
+  return <Text color={WARM_MUTED}>{props.children}</Text>;
+}
+
+function messageColor(role: DashboardMessageRow["role"]): string {
+  switch (role) {
+    case "user":
+      return WARM_MUTED;
+    case "assistant":
+      return WARM_REPLY;
+    case "system":
+      return WARM_ACCENT;
+    default:
+      return WARM_REPLY;
+  }
+}
+
+function statusColor(status: string): string {
+  switch (status) {
+    case "completed":
+      return WARM_REPLY;
+    case "failed":
+      return "red";
+    case "blocked":
+    case "waiting_human":
+      return WARM_ACCENT;
+    case "writing":
+    case "repairing":
+    case "planning":
+    case "composing":
+    case "persisting":
+      return WARM_ACCENT;
+    default:
+      return WARM_MUTED;
+  }
+}

+ 195 - 86
packages/cli/src/tui/effects.ts

@@ -8,6 +8,7 @@ import {
   clearLine, hideCursor, showCursor, reset,
   badge, sleep, stripAnsi, box,
 } from "./ansi.js";
+import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale, type TuiLocale } from "./i18n.js";
 
 /* ── Operation themes ── */
 
@@ -20,6 +21,11 @@ export interface OperationTheme {
   readonly frames: ReadonlyArray<string>;
 }
 
+export interface StyledHelpSection {
+  readonly title: string;
+  readonly commands: ReadonlyArray<readonly [string, string]>;
+}
+
 const WAVE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
 const PULSE_FRAMES = ["◜", "◠", "◝", "◞", "◡", "◟"];
 const DOTS_FRAMES = ["·  ", "·· ", "···", " ··", "  ·", "   "];
@@ -98,7 +104,7 @@ export class ThemedSpinner {
   }
 
   start(label?: string): void {
-    const displayLabel = label ?? this.theme.label;
+    const displayLabel = label ?? localizeThemeLabel(this.theme.label, resolveTuiLocale());
     this.frame = 0;
     this.elapsed = 0;
     process.stdout.write(hideCursor);
@@ -170,8 +176,8 @@ export function inputPromptPrefix(): string {
 }
 
 export function drawInputHint(): void {
-  console.log();
-  console.log();
+  // Keep the prompt anchored to the next line; extra blank lines confuse
+  // terminal UIs that render readline prompts inside a framed input block.
 }
 
 export function printInputSeparator(): void {
@@ -214,31 +220,6 @@ export async function animateStartup(version: string, projectName: string, bookT
     }
     console.log(c(`  v${version}`, dim));
   }
-
-  // Project info
-  console.log();
-  const bookDisplay = bookTitle
-    ? c(bookTitle, brightWhite)
-    : c("no book yet", dim);
-  const modelDisplay = modelInfo
-    ? `${c(modelInfo.model, brightWhite)} ${c(`(${modelInfo.provider})`, dim)}`
-    : c("not configured — type /config to set up", yellow);
-
-  if (isTTY) {
-    await typewrite(`  ${c("◇", cyan)} ${c("Project", gray)}  ${c(projectName, brightWhite)}`, 8);
-    await sleep(60);
-    await typewrite(`  ${c("◇", cyan)} ${c("Book", gray)}     ${bookDisplay}`, 8);
-    await sleep(60);
-    await typewrite(`  ${c("◇", cyan)} ${c("Model", gray)}    ${modelDisplay}`, 8);
-    await sleep(60);
-  } else {
-    console.log(`  ${c("◇", cyan)} ${c("Project", gray)}  ${c(projectName, brightWhite)}`);
-    console.log(`  ${c("◇", cyan)} ${c("Book", gray)}     ${bookDisplay}`);
-    console.log(`  ${c("◇", cyan)} ${c("Model", gray)}    ${modelDisplay}`);
-  }
-
-  console.log();
-  console.log(c("  /help for commands · Tab to autocomplete", dim));
   console.log();
 }
 
@@ -280,23 +261,55 @@ export function formatResultCard(content: string, intent?: string): string {
   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],
+export function intentToBadge(intent: string, locale: TuiLocale = resolveTuiLocale()): string {
+  const labels = locale === "en"
+    ? {
+        write_next: " WRITE ",
+        revise_chapter: " REVISE ",
+        rewrite_chapter: " REWRITE ",
+        update_focus: " FOCUS ",
+        explain_status: " STATUS ",
+        explain_failure: " DEBUG ",
+        pause_book: " PAUSE ",
+        list_books: " BOOKS ",
+        select_book: " SELECT ",
+        switch_mode: " MODE ",
+        rename_entity: " RENAME ",
+        patch_chapter_text: " PATCH ",
+        edit_truth: " TRUTH ",
+      }
+    : {
+        write_next: " 写作 ",
+        revise_chapter: " 修订 ",
+        rewrite_chapter: " 重写 ",
+        update_focus: " 焦点 ",
+        explain_status: " 状态 ",
+        explain_failure: " 调试 ",
+        pause_book: " 暂停 ",
+        list_books: " 作品 ",
+        select_book: " 选择 ",
+        switch_mode: " 模式 ",
+        rename_entity: " 改名 ",
+        patch_chapter_text: " 修补 ",
+        edit_truth: " 真相 ",
+      };
+  const backgrounds: Record<string, string> = {
+    write_next: bgMagenta,
+    revise_chapter: bgBlue,
+    rewrite_chapter: bgBlue,
+    update_focus: bgCyan,
+    explain_status: bgGray,
+    explain_failure: bgRed,
+    pause_book: bgYellow,
+    list_books: bgGray,
+    select_book: bgGreen,
+    switch_mode: bgCyan,
+    rename_entity: bgYellow,
+    patch_chapter_text: bgBlue,
+    edit_truth: bgGreen,
   };
-  const [label, bg] = badges[intent] ?? [` ${intent.toUpperCase()} `, bgGray];
+  const label = labels[intent as keyof typeof labels] ?? ` ${intent} `;
+  const bg = backgrounds[intent] ?? bgGray;
   return badge(label!, bg!);
 }
 
@@ -324,38 +337,9 @@ export function intentToTheme(intent: string): string {
 /* ── 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"],
-      ],
-    },
-  ];
+  const locale = resolveTuiLocale();
+  const sections = buildStyledHelpSections(locale);
+  const footer = buildHelpFooter(locale);
 
   console.log();
   for (const section of sections) {
@@ -368,8 +352,10 @@ export function printStyledHelp(): void {
     }
     console.log();
   }
-  console.log(c("  Natural language also works:", dim));
-  console.log(c('  "继续写" "写下一章" "暂停" "把林烬改成张三"', dim, italic));
+  console.log(c(`  ${footer.title}`, dim));
+  for (const example of footer.examples) {
+    console.log(c(`  ${example}`, dim, italic));
+  }
   console.log();
 }
 
@@ -381,6 +367,24 @@ export function printStyledStatus(params: {
   readonly status: string;
   readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
 }): void {
+  const locale = resolveTuiLocale();
+  console.log();
+  for (const line of formatStyledStatusLines(locale, params)) {
+    console.log(line);
+  }
+  console.log();
+}
+
+export function formatStyledStatusLines(
+  locale: TuiLocale,
+  params: {
+    readonly mode: string;
+    readonly bookId?: string;
+    readonly status: string;
+    readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
+  },
+): string[] {
+  const copy = getTuiCopy(locale);
   const modeColors: Record<string, string> = {
     auto: green,
     semi: yellow,
@@ -397,19 +401,23 @@ export function printStyledStatus(params: {
     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)}`);
+  const modeLabel = locale === "en" ? "Mode" : "模式";
+  const bookLabel = locale === "en" ? "Book" : "作品";
+  const statusLabel = locale === "en" ? "Status" : "状态";
+  const recentLabel = locale === "en" ? "Recent" : "最近";
+  const lines = [
+    `  ${c("◇", cyan)} ${c(modeLabel, gray)}     ${c(formatModeLabel(params.mode, copy), modeColor, bold)}`,
+    `  ${c("◇", cyan)} ${c(bookLabel, gray)}     ${params.bookId ? c(params.bookId, brightWhite) : c(copy.labels.none, dim)}`,
+    `  ${c("◇", cyan)} ${c(statusLabel, gray)}   ${c(normalizeStageLabel(params.status, copy), statusColor)}`,
+  ];
   if (params.events.length > 0) {
-    console.log(`  ${c("◇", cyan)} ${c("Recent", gray)}`);
+    lines.push(`  ${c("◇", cyan)} ${c(recentLabel, 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)}`);
+      lines.push(`        ${icon} ${c(`${ev.kind}`, dim)} ${c(ev.detail ?? "", gray)}`);
     }
   }
-  console.log();
+  return lines;
 }
 
 /* ── Utilities ── */
@@ -420,3 +428,104 @@ function formatElapsed(ms: number): string {
   const m = Math.floor(s / 60);
   return `${m}m${s % 60}s`;
 }
+
+export function buildStyledHelpSections(locale: TuiLocale = resolveTuiLocale()): StyledHelpSection[] {
+  if (locale === "en") {
+    return [
+      {
+        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"],
+        ],
+      },
+    ];
+  }
+
+  return [
+    {
+      title: "写作",
+      commands: [
+        ["/write", "完整跑一轮下一章写作"],
+        ["/rewrite <n>", "从头重写第 N 章"],
+      ],
+    },
+    {
+      title: "导航",
+      commands: [
+        ["/books", "列出全部作品"],
+        ["/open <book>", "切换当前作品"],
+        ["/status", "查看当前状态"],
+      ],
+    },
+    {
+      title: "控制",
+      commands: [
+        ["/mode <auto|semi|manual>", "切换自动化模式"],
+        ["/focus <text>", "更新当前焦点"],
+      ],
+    },
+    {
+      title: "会话",
+      commands: [
+        ["/clear", "清空当前屏幕"],
+        ["/help", "显示帮助"],
+        ["/quit", "退出 InkOS TUI"],
+      ],
+    },
+  ];
+}
+
+function buildHelpFooter(locale: TuiLocale): { readonly title: string; readonly examples: readonly string[] } {
+  if (locale === "en") {
+    return {
+      title: "Natural language also works:",
+      examples: ['"continue writing" "write next chapter" "pause" "rename Lin Jin to Zhang San"'],
+    };
+  }
+
+  return {
+    title: "自然语言同样可用:",
+    examples: ['"继续写" "写下一章" "暂停" "把林烬改成张三"'],
+  };
+}
+
+function localizeThemeLabel(label: string, locale: TuiLocale): string {
+  if (locale === "en") {
+    return label;
+  }
+
+  const labels: Record<string, string> = {
+    thinking: "思考中",
+    writing: "写作中",
+    auditing: "审计中",
+    revising: "修订中",
+    planning: "规划中",
+    composing: "生成中",
+    loading: "加载中",
+  };
+  return labels[label] ?? label;
+}

+ 295 - 0
packages/cli/src/tui/i18n.ts

@@ -0,0 +1,295 @@
+import type { ChatDepth } from "./chat-depth.js";
+
+export type TuiLocale = "zh-CN" | "en";
+
+export interface TuiCopy {
+  readonly locale: TuiLocale;
+  readonly labels: {
+    readonly project: string;
+    readonly book: string;
+    readonly depth: string;
+    readonly session: string;
+    readonly messageCount: (count: number) => string;
+    readonly stage: string;
+    readonly mode: string;
+    readonly model: string;
+    readonly error: string;
+    readonly recent: string;
+    readonly pending: string;
+    readonly ready: string;
+    readonly none: string;
+    readonly notConfigured: string;
+    readonly unknown: string;
+  };
+  readonly modeLabels: Record<string, string>;
+  readonly composer: {
+    readonly placeholder: string;
+    readonly emptyConversation: string;
+    readonly helper: string;
+    readonly submitting: string;
+    readonly failed: string;
+    readonly ready: string;
+  };
+  readonly notes: {
+    readonly help: string;
+    readonly status: (stage: string, mode: string) => string;
+    readonly config: string;
+    readonly depthSet: (depthLabel: string) => string;
+    readonly noLlmConfig: string;
+    readonly setupProvider: string;
+    readonly toolInitFailed: (message: string) => string;
+    readonly toolInitHint: string;
+  };
+  readonly roles: {
+    readonly user: string;
+    readonly assistant: string;
+    readonly system: string;
+  };
+  readonly activity: Record<"thinking" | "checking" | "writing" | "reviewing" | "updating", string>;
+  readonly depthLabels: Record<ChatDepth, string>;
+  readonly results: {
+    readonly modeSwitched: (mode: string) => string;
+    readonly booksListed: string;
+    readonly activeBook: (bookId: string) => string;
+    readonly completed: (intent: string) => string;
+    readonly intentLabels: Partial<Record<string, string>>;
+  };
+}
+
+const ZH_CN: TuiCopy = {
+  locale: "zh-CN",
+  labels: {
+    project: "项目",
+    book: "作品",
+    depth: "深度",
+    session: "会话",
+    messageCount: (count) => `${count} 条消息`,
+    stage: "阶段",
+    mode: "模式",
+    model: "模型",
+    error: "错误",
+    recent: "最近",
+    pending: "待确认",
+    ready: "就绪",
+    none: "无",
+    notConfigured: "未配置",
+    unknown: "未知",
+  },
+  modeLabels: {
+    auto: "自动",
+    semi: "半自动",
+    manual: "手动",
+  },
+  composer: {
+    placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
+    emptyConversation: "先告诉 InkOS 你要做什么。",
+    helper: "回车发送 • /help • /status • /clear • /config • /depth • /quit",
+    submitting: "处理中…",
+    failed: "上次请求失败",
+    ready: "就绪",
+  },
+  notes: {
+    help: "可用命令:/help、/status、/clear、/config、/depth、/quit。也支持直接输入自然语言。",
+    status: (stage, mode) => `当前状态:${stage}(${mode})。`,
+    config: "当前 Ink 仪表盘里还不支持交互式 /config。请使用 inkos config set-global。",
+    depthSet: (depthLabel) => `思考深度已切换为 ${depthLabel}。`,
+    noLlmConfig: "未发现 LLM 配置。",
+    setupProvider: "先配置 API 提供方。",
+    toolInitFailed: (message) => `初始化 TUI 工具失败:${message}`,
+    toolInitHint: "请检查 .env,或运行:inkos config set-global",
+  },
+  roles: {
+    user: "你",
+    assistant: "InkOS",
+    system: "系统",
+  },
+  activity: {
+    thinking: "思考中",
+    checking: "检查中",
+    writing: "写作中",
+    reviewing: "审阅中",
+    updating: "更新中",
+  },
+  depthLabels: {
+    light: "轻量",
+    normal: "标准",
+    deep: "深入",
+  },
+  results: {
+    modeSwitched: (mode) => `已切换到 ${mode} 模式。`,
+    booksListed: "已列出作品。",
+    activeBook: (bookId) => `当前作品:${bookId}`,
+    completed: (intent) => `已完成 ${intent}`,
+    intentLabels: {
+      write_next: "已写完下一章",
+      revise_chapter: "已修订章节",
+      rewrite_chapter: "已重写章节",
+      update_focus: "已更新焦点",
+      explain_status: "状态说明",
+      explain_failure: "失败说明",
+      pause_book: "已暂停作品",
+      rename_entity: "已重命名实体",
+      patch_chapter_text: "已修补正文",
+      edit_truth: "已更新真相文件",
+    },
+  },
+};
+
+const EN: TuiCopy = {
+  locale: "en",
+  labels: {
+    project: "Project",
+    book: "Book",
+    depth: "Depth",
+    session: "Session",
+    messageCount: (count) => `${count} msgs`,
+    stage: "Stage",
+    mode: "Mode",
+    model: "Model",
+    error: "Error",
+    recent: "Recent",
+    pending: "Pending",
+    ready: "Ready",
+    none: "none",
+    notConfigured: "not configured",
+    unknown: "unknown",
+  },
+  modeLabels: {
+    auto: "auto",
+    semi: "semi",
+    manual: "manual",
+  },
+  composer: {
+    placeholder: "Ask InkOS to write, revise, or explain…",
+    emptyConversation: "Start by asking InkOS what to do.",
+    helper: "Enter to send • /help • /status • /clear • /config • /depth • /quit",
+    submitting: "Submitting…",
+    failed: "Last request failed",
+    ready: "Ready",
+  },
+  notes: {
+    help: "Commands: /help, /status, /clear, /config, /depth, /quit. Natural language still works.",
+    status: (stage, mode) => `Status: ${stage} (${mode}).`,
+    config: "Interactive /config is not available inside the Ink dashboard yet. Use inkos config set-global.",
+    depthSet: (depthLabel) => `Thinking depth set to ${depthLabel}.`,
+    noLlmConfig: "No LLM configuration found.",
+    setupProvider: "Let's set up your API provider first.",
+    toolInitFailed: (message) => `Failed to initialize TUI tools: ${message}`,
+    toolInitHint: "Check your .env or run: inkos config set-global",
+  },
+  roles: {
+    user: "You",
+    assistant: "InkOS",
+    system: "System",
+  },
+  activity: {
+    thinking: "thinking",
+    checking: "checking",
+    writing: "writing",
+    reviewing: "reviewing",
+    updating: "updating",
+  },
+  depthLabels: {
+    light: "light",
+    normal: "normal",
+    deep: "deep",
+  },
+  results: {
+    modeSwitched: (mode) => `Mode switched to ${mode}.`,
+    booksListed: "Books listed.",
+    activeBook: (bookId) => `Active book: ${bookId}`,
+    completed: (intent) => `Completed ${intent}`,
+    intentLabels: {
+      write_next: "Chapter written",
+      revise_chapter: "Chapter revised",
+      rewrite_chapter: "Chapter rewritten",
+      update_focus: "Focus updated",
+      explain_status: "Status",
+      explain_failure: "Explanation",
+      pause_book: "Book paused",
+      rename_entity: "Entity renamed",
+      patch_chapter_text: "Text patched",
+      edit_truth: "Truth file updated",
+    },
+  },
+};
+
+export function resolveTuiLocale(
+  env: NodeJS.ProcessEnv = process.env,
+  preferredLanguage?: string,
+): TuiLocale {
+  const requested = normalizeLocale(env.INKOS_TUI_LOCALE ?? env.INKOS_LOCALE);
+  if (requested) {
+    return requested;
+  }
+
+  const preferred = normalizeLocale(preferredLanguage);
+  if (preferred) {
+    return preferred;
+  }
+
+  const detected = normalizeLocale(env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG);
+  return detected ?? "zh-CN";
+}
+
+export function getTuiCopy(locale: TuiLocale): TuiCopy {
+  return locale === "en" ? EN : ZH_CN;
+}
+
+export function normalizeStageLabel(label: string, copy: TuiCopy): string {
+  const normalized = label.trim().toLowerCase();
+  if (!normalized) {
+    return label;
+  }
+
+  const replacements: Array<[RegExp, string]> = [
+    [/^thinking\b/i, copy.activity.thinking],
+    [/^checking\b/i, copy.activity.checking],
+    [/^writing\b/i, copy.activity.writing],
+    [/^reviewing\b/i, copy.activity.reviewing],
+    [/^updating\b/i, copy.activity.updating],
+    [/^completed\b/i, copy.locale === "en" ? "completed" : "已完成"],
+    [/^failed\b/i, copy.locale === "en" ? "failed" : "失败"],
+    [/^blocked\b/i, copy.locale === "en" ? "blocked" : "已阻塞"],
+    [/^waiting_human\b/i, copy.locale === "en" ? "waiting for your decision" : "等待你的决定"],
+    [/^paused by user\b/i, copy.locale === "en" ? "paused by user" : "已由用户暂停"],
+    [/^ready to continue\b/i, copy.locale === "en" ? "ready to continue" : "可继续执行"],
+  ];
+
+  for (const [pattern, value] of replacements) {
+    if (pattern.test(label)) {
+      return copy.locale === "en" ? label : value;
+    }
+  }
+
+  if (normalized === "idle") {
+    return copy.labels.ready;
+  }
+
+  return label;
+}
+
+export function formatModeLabel(mode: string, copy: TuiCopy): string {
+  return copy.modeLabels[mode] ?? mode;
+}
+
+function normalizeLocale(value: string | undefined): TuiLocale | undefined {
+  if (!value) {
+    return undefined;
+  }
+
+  const normalized = value.trim().toLowerCase();
+  if (!normalized || normalized === "auto") {
+    return undefined;
+  }
+
+  if (normalized.startsWith("zh")) {
+    return "zh-CN";
+  }
+
+  if (normalized.startsWith("en")) {
+    return "en";
+  }
+
+  return undefined;
+}

+ 69 - 0
packages/cli/src/tui/input-history.ts

@@ -0,0 +1,69 @@
+import type { InteractionMessage } from "@actalk/inkos-core";
+
+export interface InputHistoryState {
+  readonly cursor: number | null;
+  readonly draft: string;
+}
+
+export type InputHistoryDirection = "up" | "down";
+
+export function buildInputHistory(messages: ReadonlyArray<InteractionMessage>): string[] {
+  const result: string[] = [];
+
+  for (const message of messages) {
+    if (message.role !== "user") {
+      continue;
+    }
+
+    const value = message.content.trim();
+    if (!value || result[result.length - 1] === value) {
+      continue;
+    }
+    result.push(value);
+  }
+
+  return result;
+}
+
+export function moveHistoryCursor(
+  entries: ReadonlyArray<string>,
+  state: InputHistoryState,
+  currentValue: string,
+  direction: InputHistoryDirection,
+): { state: InputHistoryState; value: string } {
+  if (entries.length === 0) {
+    return { state, value: currentValue };
+  }
+
+  if (direction === "up") {
+    if (state.cursor === null) {
+      return {
+        state: { cursor: entries.length - 1, draft: currentValue },
+        value: entries[entries.length - 1]!,
+      };
+    }
+
+    const nextCursor = Math.max(0, state.cursor - 1);
+    return {
+      state: { ...state, cursor: nextCursor },
+      value: entries[nextCursor]!,
+    };
+  }
+
+  if (state.cursor === null) {
+    return { state, value: currentValue };
+  }
+
+  if (state.cursor >= entries.length - 1) {
+    return {
+      state: { cursor: null, draft: state.draft },
+      value: state.draft,
+    };
+  }
+
+  const nextCursor = state.cursor + 1;
+  return {
+    state: { ...state, cursor: nextCursor },
+    value: entries[nextCursor]!,
+  };
+}

+ 55 - 0
packages/cli/src/tui/local-commands.ts

@@ -0,0 +1,55 @@
+import type { ChatDepth } from "./chat-depth.js";
+
+export type LocalTuiCommand = "help" | "status" | "quit" | "clear" | "config";
+
+export function classifyLocalTuiCommand(input: string): LocalTuiCommand | undefined {
+  const value = input.trim();
+
+  if (/^\/help$/i.test(value) || /^(help|帮助)$/i.test(value)) {
+    return "help";
+  }
+
+  if (/^\/status$/i.test(value) || /^(status|状态)$/i.test(value)) {
+    return "status";
+  }
+
+  if (/^\/clear$/i.test(value) || /^清屏$/i.test(value)) {
+    return "clear";
+  }
+
+  if (/^\/config$/i.test(value) || /^(config|配置)$/i.test(value)) {
+    return "config";
+  }
+
+  if (/^\/quit$/i.test(value) || /^\/exit$/i.test(value) || /^(quit|exit|bye|退出)$/i.test(value)) {
+    return "quit";
+  }
+
+  return undefined;
+}
+
+export function parseDepthCommand(input: string): ChatDepth | undefined {
+  const value = input.trim().toLowerCase();
+  const englishMatch = value.match(/^\/?depth\s+(light|normal|deep)$/);
+  if (englishMatch?.[1]) {
+    return englishMatch[1] as ChatDepth;
+  }
+
+  const chineseMatch = input.trim().match(/^\/?深度\s+(浅|轻量|标准|普通|深|深入)$/);
+  if (!chineseMatch?.[1]) {
+    return undefined;
+  }
+
+  switch (chineseMatch[1]) {
+    case "浅":
+    case "轻量":
+      return "light";
+    case "深":
+    case "深入":
+      return "deep";
+    case "标准":
+    case "普通":
+    default:
+      return "normal";
+  }
+}

+ 11 - 19
packages/cli/src/tui/output.ts

@@ -1,4 +1,5 @@
 import type { AutomationMode, ExecutionStatus, InteractionIntentType } from "@actalk/inkos-core";
+import { formatModeLabel, getTuiCopy, resolveTuiLocale, type TuiLocale } from "./i18n.js";
 
 export function formatTuiResult(params: {
   readonly intent: InteractionIntentType;
@@ -6,42 +7,33 @@ export function formatTuiResult(params: {
   readonly bookId?: string;
   readonly mode?: AutomationMode;
   readonly responseText?: string;
+  readonly locale?: TuiLocale;
 }): string {
+  const copy = getTuiCopy(params.locale ?? resolveTuiLocale());
+
   if (params.responseText?.trim()) {
     return params.responseText.trim();
   }
 
   if (params.intent === "switch_mode" && params.mode) {
-    return `Mode switched to ${params.mode}.`;
+    return copy.results.modeSwitched(formatModeLabel(params.mode, copy));
   }
 
   if (params.intent === "list_books") {
-    return "Books listed.";
+    return copy.results.booksListed;
   }
 
   if (params.intent === "select_book" && params.bookId) {
-    return `Active book: ${params.bookId}`;
+    return copy.results.activeBook(params.bookId);
   }
 
   if (params.bookId) {
-    return `${intentLabel(params.intent)} — ${params.bookId}`;
+    return `${intentLabel(params.intent, copy)} — ${params.bookId}`;
   }
 
-  return intentLabel(params.intent);
+  return intentLabel(params.intent, copy);
 }
 
-function intentLabel(intent: InteractionIntentType): string {
-  const labels: Partial<Record<InteractionIntentType, string>> = {
-    write_next: "Chapter written",
-    revise_chapter: "Chapter revised",
-    rewrite_chapter: "Chapter rewritten",
-    update_focus: "Focus updated",
-    explain_status: "Status",
-    explain_failure: "Explanation",
-    pause_book: "Book paused",
-    rename_entity: "Entity renamed",
-    patch_chapter_text: "Text patched",
-    edit_truth: "Truth file updated",
-  };
-  return labels[intent] ?? `Completed ${intent}`;
+function intentLabel(intent: InteractionIntentType, copy: ReturnType<typeof getTuiCopy>): string {
+  return copy.results.intentLabels[intent] ?? copy.results.completed(intent);
 }

+ 143 - 19
packages/cli/src/tui/setup.ts

@@ -8,6 +8,7 @@ import {
   cyan, green, yellow, gray, red,
   brightCyan, brightGreen, brightWhite,
 } from "./ansi.js";
+import { resolveTuiLocale, type TuiLocale } from "./i18n.js";
 import { GLOBAL_ENV_PATH } from "../utils.js";
 
 const PROVIDERS = ["openai", "anthropic", "custom"] as const;
@@ -17,6 +18,114 @@ interface SetupResult {
   readonly hasLlmConfig: boolean;
 }
 
+export interface InteractiveSetupCopy {
+  readonly title: string;
+  readonly subtitle: string;
+  readonly steps: {
+    readonly provider: string;
+    readonly baseUrl: string;
+    readonly apiKey: string;
+    readonly model: string;
+    readonly scope: string;
+  };
+  readonly hints: {
+    readonly provider: string;
+    readonly baseUrl: string;
+    readonly model: string;
+    readonly scope: string;
+  };
+  readonly defaults: {
+    readonly provider: string;
+    readonly baseUrl: string;
+    readonly scope: string;
+  };
+  readonly scopeChoices: {
+    readonly global: string;
+    readonly project: string;
+  };
+  readonly savedTo: string;
+}
+
+export function buildInteractiveSetupCopy(locale: TuiLocale): InteractiveSetupCopy {
+  if (locale === "en") {
+    return {
+      title: "LLM Setup",
+      subtitle: "Configure your model provider to start writing.",
+      steps: {
+        provider: "Provider",
+        baseUrl: "Base URL",
+        apiKey: "API Key",
+        model: "Model",
+        scope: "Save scope",
+      },
+      hints: {
+        provider: "openai / anthropic / custom (OpenAI-compatible proxy)",
+        baseUrl: "Your API endpoint",
+        model: "e.g. gpt-4o, claude-sonnet-4-20250514, deepseek-chat",
+        scope: "global = all projects, project = this directory only",
+      },
+      defaults: {
+        provider: "openai",
+        baseUrl: "(default)",
+        scope: "[global]",
+      },
+      scopeChoices: {
+        global: "all projects",
+        project: "this directory",
+      },
+      savedTo: "Saved to",
+    };
+  }
+
+  return {
+    title: "模型配置",
+    subtitle: "配置模型服务后即可开始使用。",
+    steps: {
+      provider: "服务提供方",
+      baseUrl: "接口地址",
+      apiKey: "API 密钥",
+      model: "模型",
+      scope: "保存范围",
+    },
+    hints: {
+      provider: "openai / anthropic / custom(兼容 OpenAI 的代理)",
+      baseUrl: "你的 API 入口地址",
+      model: "例如 gpt-5.4、claude-sonnet-4-20250514、deepseek-chat",
+      scope: "global = 所有项目,project = 仅当前目录",
+    },
+    defaults: {
+      provider: "openai",
+      baseUrl: "(默认)",
+      scope: "[global]",
+    },
+    scopeChoices: {
+      global: "所有项目",
+      project: "当前目录",
+    },
+    savedTo: "已保存到",
+  };
+}
+
+export function buildAutoInitMessages(projectName: string, locale: TuiLocale): {
+  readonly initializing: string;
+  readonly initialized: string;
+  readonly envTemplateHeader: string;
+} {
+  if (locale === "en") {
+    return {
+      initializing: `Initializing project in ${projectName}/ ...`,
+      initialized: "Project initialized",
+      envTemplateHeader: "# LLM Configuration — run inkos tui to configure interactively",
+    };
+  }
+
+  return {
+    initializing: `正在初始化项目:${projectName}/ ...`,
+    initialized: "项目已初始化",
+    envTemplateHeader: "# LLM 配置 —— 运行 inkos tui 进行交互式配置",
+  };
+}
+
 export async function ensureProject(cwd: string): Promise<SetupResult> {
   const configPath = join(cwd, "inkos.json");
   const hasConfig = await fileExists(configPath);
@@ -32,6 +141,9 @@ export async function ensureProject(cwd: string): Promise<SetupResult> {
 export async function interactiveLlmSetup(
   projectRoot: string,
 ): Promise<void> {
+  const projectLanguage = await detectProjectLanguage(projectRoot);
+  const locale = resolveTuiLocale(process.env, projectLanguage);
+  const copy = buildInteractiveSetupCopy(locale);
   const rl = readline.createInterface({
     input: process.stdin,
     output: process.stdout,
@@ -39,29 +151,29 @@ export async function interactiveLlmSetup(
 
   try {
     console.log();
-    console.log(`  ${c("◈", brightCyan)} ${c("LLM Setup", bold, brightWhite)}`);
-    console.log(c("  Configure your model provider to start writing.", dim));
+    console.log(`  ${c("◈", brightCyan)} ${c(copy.title, bold, brightWhite)}`);
+    console.log(c(`  ${copy.subtitle}`, dim));
     console.log();
 
     // Provider
-    console.log(`  ${c("1", cyan)}  ${c("Provider", gray)}`);
-    console.log(c("     openai / anthropic / custom (OpenAI-compatible proxy)", dim));
+    console.log(`  ${c("1", cyan)}  ${c(copy.steps.provider, gray)}`);
+    console.log(c(`     ${copy.hints.provider}`, dim));
     const providerInput = await rl.question(`     ${c("❯", cyan)} `);
     const provider = PROVIDERS.includes(providerInput.trim() as typeof PROVIDERS[number])
       ? providerInput.trim()
-      : "openai";
+      : copy.defaults.provider;
     console.log(`     ${c("✓", brightGreen)} ${provider}`);
     console.log();
 
     // Base URL
-    console.log(`  ${c("2", cyan)}  ${c("Base URL", gray)}`);
-    console.log(c("     Your API endpoint", dim));
+    console.log(`  ${c("2", cyan)}  ${c(copy.steps.baseUrl, gray)}`);
+    console.log(c(`     ${copy.hints.baseUrl}`, dim));
     const baseUrl = await rl.question(`     ${c("❯", cyan)} `);
-    console.log(`     ${c("✓", brightGreen)} ${baseUrl.trim() || "(default)"}`);
+    console.log(`     ${c("✓", brightGreen)} ${baseUrl.trim() || copy.defaults.baseUrl}`);
     console.log();
 
     // API Key
-    console.log(`  ${c("3", cyan)}  ${c("API Key", gray)}`);
+    console.log(`  ${c("3", cyan)}  ${c(copy.steps.apiKey, gray)}`);
     const apiKey = await rl.question(`     ${c("❯", cyan)} `);
     const maskedKey = apiKey.trim().length > 8
       ? apiKey.trim().slice(0, 4) + "···" + apiKey.trim().slice(-4)
@@ -70,16 +182,16 @@ export async function interactiveLlmSetup(
     console.log();
 
     // Model
-    console.log(`  ${c("4", cyan)}  ${c("Model", gray)}`);
-    console.log(c("     e.g. gpt-4o, claude-sonnet-4-20250514, deepseek-chat", dim));
+    console.log(`  ${c("4", cyan)}  ${c(copy.steps.model, gray)}`);
+    console.log(c(`     ${copy.hints.model}`, dim));
     const model = await rl.question(`     ${c("❯", cyan)} `);
     console.log(`     ${c("✓", brightGreen)} ${model.trim()}`);
     console.log();
 
     // Scope
-    console.log(`  ${c("5", cyan)}  ${c("Save scope", gray)}`);
-    console.log(c("     global = all projects, project = this directory only", dim));
-    const scope = await rl.question(`     ${c("❯", cyan)} ${c("[global]", dim)} `);
+    console.log(`  ${c("5", cyan)}  ${c(copy.steps.scope, gray)}`);
+    console.log(c(`     ${copy.hints.scope}`, dim));
+    const scope = await rl.question(`     ${c("❯", cyan)} ${c(copy.defaults.scope, dim)} `);
     const useGlobal = scope.trim().toLowerCase() !== "project";
 
     const envContent = [
@@ -94,11 +206,11 @@ export async function interactiveLlmSetup(
       await mkdir(globalDir, { recursive: true });
       await writeFile(GLOBAL_ENV_PATH, envContent + "\n", "utf-8");
       console.log();
-      console.log(`  ${c("✓", brightGreen, bold)} ${c("Saved to", dim)} ${c(GLOBAL_ENV_PATH, gray)}`);
+      console.log(`  ${c("✓", brightGreen, bold)} ${c(copy.savedTo, dim)} ${c(GLOBAL_ENV_PATH, gray)}`);
     } else {
       await writeFile(join(projectRoot, ".env"), envContent + "\n", "utf-8");
       console.log();
-      console.log(`  ${c("✓", brightGreen, bold)} ${c("Saved to", dim)} ${c(".env", gray)}`);
+      console.log(`  ${c("✓", brightGreen, bold)} ${c(copy.savedTo, dim)} ${c(".env", gray)}`);
     }
     console.log();
   } finally {
@@ -108,8 +220,10 @@ export async function interactiveLlmSetup(
 
 async function autoInit(cwd: string): Promise<void> {
   const projectName = basename(cwd);
+  const locale = resolveTuiLocale();
+  const messages = buildAutoInitMessages(projectName, locale);
   console.log();
-  console.log(`  ${c("◌", cyan)} ${c(`Initializing project in ${projectName}/ ...`, dim)}`);
+  console.log(`  ${c("◌", cyan)} ${c(messages.initializing, dim)}`);
 
   await mkdir(join(cwd, "books"), { recursive: true });
   await mkdir(join(cwd, "radar"), { recursive: true });
@@ -144,7 +258,7 @@ async function autoInit(cwd: string): Promise<void> {
     await writeFile(
       join(cwd, ".env"),
       [
-        "# LLM Configuration — run inkos tui to configure interactively",
+        messages.envTemplateHeader,
         "INKOS_LLM_PROVIDER=openai",
         "INKOS_LLM_BASE_URL=",
         "INKOS_LLM_API_KEY=",
@@ -160,7 +274,7 @@ async function autoInit(cwd: string): Promise<void> {
     "utf-8",
   );
 
-  console.log(`  ${c("✓", brightGreen, bold)} ${c("Project initialized", dim)}`);
+  console.log(`  ${c("✓", brightGreen, bold)} ${c(messages.initialized, dim)}`);
 }
 
 async function hasLlmConfig(projectRoot: string): Promise<boolean> {
@@ -198,6 +312,16 @@ export async function detectModelInfo(projectRoot: string): Promise<ModelInfo |
   return undefined;
 }
 
+export async function detectProjectLanguage(projectRoot: string): Promise<string | undefined> {
+  try {
+    const raw = await readFile(join(projectRoot, "inkos.json"), "utf-8");
+    const parsed = JSON.parse(raw) as { language?: string };
+    return parsed.language;
+  } catch {
+    return undefined;
+  }
+}
+
 async function parseEnvModel(envPath: string): Promise<ModelInfo | undefined> {
   try {
     const content = await readFile(envPath, "utf-8");

+ 44 - 0
packages/cli/src/tui/slash-autocomplete.ts

@@ -0,0 +1,44 @@
+export const SLASH_COMMANDS = [
+  "/help",
+  "/status",
+  "/clear",
+  "/config",
+  "/depth",
+  "/quit",
+  "/exit",
+] as const;
+
+export type SlashNavigationDirection = "up" | "down";
+
+export function getSlashSuggestions(input: string, commands: readonly string[]): string[] {
+  const value = input.trim();
+  if (!value.startsWith("/")) {
+    return [];
+  }
+
+  return commands.filter((command) => command.startsWith(value));
+}
+
+export function getNextSlashSelection(
+  currentIndex: number,
+  suggestionCount: number,
+  direction: SlashNavigationDirection,
+): number {
+  if (suggestionCount <= 0) {
+    return 0;
+  }
+
+  if (direction === "down") {
+    return (currentIndex + 1) % suggestionCount;
+  }
+
+  return (currentIndex - 1 + suggestionCount) % suggestionCount;
+}
+
+export function applySlashSuggestion(
+  _input: string,
+  suggestions: readonly string[],
+  selectedIndex: number,
+): string {
+  return suggestions[selectedIndex] ?? "";
+}

+ 4 - 0
packages/cli/src/tui/theme.ts

@@ -0,0 +1,4 @@
+export const WARM_ACCENT = "#c88a56";
+export const WARM_MUTED = "#8f8374";
+export const WARM_REPLY = "#f0e6d8";
+export const WARM_BORDER = "#6b6156";

+ 16 - 4
packages/cli/src/tui/tools.ts

@@ -8,12 +8,20 @@ import { buildPipelineConfig, loadConfig } from "../utils.js";
 
 type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
 type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
+type CliInteractionToolHooks = {
+  readonly onChatTextDelta?: (text: string) => void;
+  readonly getChatRequestOptions?: () => {
+    readonly temperature?: number;
+    readonly maxTokens?: number;
+  };
+};
 
 export function createCliInteractionToolsFromDeps(
   pipeline: CliPipelineLike,
   state: CliStateLike,
+  hooks?: CliInteractionToolHooks,
 ): InteractionRuntimeTools {
-  return createInteractionToolsFromDeps(pipeline, state);
+  return createInteractionToolsFromDeps(pipeline, state, hooks);
 }
 
 // Backward-compatible export for the current CLI tests during the extraction phase.
@@ -21,15 +29,19 @@ export function createInteractionToolsFromDepsCompat(
   _projectRoot: string,
   pipeline: CliPipelineLike,
   state: CliStateLike,
+  hooks?: CliInteractionToolHooks,
 ): InteractionRuntimeTools {
-  return createInteractionToolsFromDeps(pipeline, state);
+  return createInteractionToolsFromDeps(pipeline, state, hooks);
 }
 
 export { createInteractionToolsFromDepsCompat as createInteractionToolsFromDeps };
 
-export async function createInteractionTools(projectRoot: string): Promise<InteractionRuntimeTools> {
+export async function createInteractionTools(
+  projectRoot: string,
+  hooks?: CliInteractionToolHooks,
+): Promise<InteractionRuntimeTools> {
   const config = await loadConfig();
   const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
   const state = new StateManager(projectRoot);
-  return createInteractionToolsFromDeps(pipeline, state);
+  return createInteractionToolsFromDeps(pipeline, state, hooks);
 }

+ 1 - 0
packages/cli/tsconfig.json

@@ -1,6 +1,7 @@
 {
   "extends": "../../tsconfig.json",
   "compilerOptions": {
+    "jsx": "react-jsx",
     "outDir": "dist",
     "rootDir": "src"
   },

+ 36 - 7
packages/core/src/interaction/project-control.ts

@@ -1,3 +1,5 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
 import { appendInteractionEvent, appendInteractionMessage } from "./session.js";
 import { routeNaturalLanguageIntent } from "./nl-router.js";
 import type { InteractionRequest } from "./intents.js";
@@ -15,9 +17,11 @@ async function processProjectInteractionRequestInternal(params: {
   readonly tools: InteractionRuntimeTools;
   readonly activeBookId?: string;
 }) {
+  const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
+  const localizedRequest = attachRequestLanguage(params.request, requestLanguage);
   const session = await loadProjectSession(params.projectRoot);
   const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
-  const resolvedBookId = params.activeBookId ?? params.request.bookId ?? restoredBookId;
+  const resolvedBookId = params.activeBookId ?? localizedRequest.bookId ?? restoredBookId;
   const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
     ? { ...session, activeBookId: resolvedBookId }
     : session;
@@ -25,13 +29,13 @@ async function processProjectInteractionRequestInternal(params: {
   try {
     const result = await runInteractionRequest({
       session: sessionWithBook,
-      request: params.request,
+      request: localizedRequest,
       tools: params.tools,
     });
     await persistProjectSession(params.projectRoot, result.session);
     return {
       ...result,
-      request: params.request,
+      request: localizedRequest,
     };
   } catch (error) {
     const detail = error instanceof Error ? error.message : String(error);
@@ -41,7 +45,7 @@ async function processProjectInteractionRequestInternal(params: {
         status: "failed",
         bookId: sessionWithBook.activeBookId,
         chapterNumber: sessionWithBook.activeChapterNumber,
-        stageLabel: `failed ${params.request.intent}`,
+        stageLabel: localizedRequest.language === "en" ? `failed ${localizedRequest.intent}` : `执行失败:${localizedRequest.intent}`,
       },
     }, {
       kind: "task.failed",
@@ -62,6 +66,7 @@ export async function processProjectInteractionInput(params: {
   readonly tools: InteractionRuntimeTools;
   readonly activeBookId?: string;
 }) {
+  const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
   const session = await loadProjectSession(params.projectRoot);
   const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
   const resolvedBookId = params.activeBookId ?? restoredBookId;
@@ -73,9 +78,9 @@ export async function processProjectInteractionInput(params: {
     content: params.input,
     timestamp: Date.now(),
   });
-  const request = routeNaturalLanguageIntent(params.input, {
+  const request = attachRequestLanguage(routeNaturalLanguageIntent(params.input, {
     activeBookId: userSession.activeBookId,
-  });
+  }), requestLanguage);
   try {
     const result = await runInteractionRequest({
       session: userSession,
@@ -95,7 +100,7 @@ export async function processProjectInteractionInput(params: {
         status: "failed",
         bookId: userSession.activeBookId,
         chapterNumber: userSession.activeChapterNumber,
-        stageLabel: `failed ${request.intent}`,
+        stageLabel: request.language === "en" ? `failed ${request.intent}` : `执行失败:${request.intent}`,
       },
     }, {
       kind: "task.failed",
@@ -118,3 +123,27 @@ export async function processProjectInteractionRequest(params: {
 }) {
   return processProjectInteractionRequestInternal(params);
 }
+
+function attachRequestLanguage(
+  request: InteractionRequest,
+  language: "zh" | "en" | undefined,
+): InteractionRequest {
+  if (request.language || !language) {
+    return request;
+  }
+
+  return {
+    ...request,
+    language,
+  };
+}
+
+async function detectProjectInteractionLanguage(projectRoot: string): Promise<"zh" | "en" | undefined> {
+  try {
+    const raw = await readFile(join(projectRoot, "inkos.json"), "utf-8");
+    const parsed = JSON.parse(raw) as { language?: string };
+    return parsed.language === "en" ? "en" : parsed.language === "zh" ? "zh" : undefined;
+  } catch {
+    return undefined;
+  }
+}

+ 13 - 1
packages/core/src/interaction/project-tools.ts

@@ -315,6 +315,13 @@ async function withPipelineInteractionTelemetry<T extends { chapterNumber?: numb
 export function createInteractionToolsFromDeps(
   pipeline: PipelineLike,
   state: StateLike,
+  hooks?: {
+    readonly onChatTextDelta?: (text: string) => void;
+    readonly getChatRequestOptions?: () => {
+      readonly temperature?: number;
+      readonly maxTokens?: number;
+    };
+  },
 ): InteractionRuntimeTools {
   const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
 
@@ -355,6 +362,7 @@ export function createInteractionToolsFromDeps(
     },
     chat: async (input, options) => {
       const bookLabel = options.bookId ?? "none";
+      const chatRequestOptions = hooks?.getChatRequestOptions?.() ?? {};
       const response = instrumentedPipeline.config?.client && instrumentedPipeline.config?.model
         ? await chatCompletion(
           instrumentedPipeline.config.client,
@@ -374,7 +382,11 @@ export function createInteractionToolsFromDeps(
               content: `activeBook=${bookLabel}\nautomationMode=${options.automationMode}\nmessage=${input}`,
             },
           ],
-          { temperature: 0.4, maxTokens: 240 },
+          {
+            temperature: chatRequestOptions.temperature ?? 0.4,
+            maxTokens: chatRequestOptions.maxTokens ?? 240,
+            onTextDelta: hooks?.onChatTextDelta,
+          },
         )
         : undefined;
 

+ 353 - 88
packages/core/src/interaction/runtime.ts

@@ -11,6 +11,7 @@ import {
 } from "./session.js";
 
 type ReviseMode = "local-fix" | "rewrite";
+type RuntimeLanguage = "zh" | "en";
 
 export interface InteractionRuntimeTools {
   readonly listBooks: () => Promise<ReadonlyArray<string>>;
@@ -91,9 +92,30 @@ function extractToolMetadata(value: unknown): InteractionToolMetadata {
   };
 }
 
+function resolveRuntimeLanguage(request: InteractionRequest): RuntimeLanguage {
+  return request.language === "en" ? "en" : "zh";
+}
+
+function localize<T>(language: RuntimeLanguage, messages: { zh: T; en: T }): T {
+  return language === "en" ? messages.en : messages.zh;
+}
+
+function localizeMode(mode: AutomationMode, language: RuntimeLanguage): string {
+  if (language === "en") {
+    return mode;
+  }
+
+  return {
+    auto: "自动",
+    semi: "半自动",
+    manual: "手动",
+  }[mode] ?? mode;
+}
+
 function buildTaskStartedState(
   session: InteractionSession,
   request: InteractionRequest,
+  language: RuntimeLanguage,
 ): ExecutionState {
   switch (request.intent) {
     case "write_next":
@@ -102,20 +124,29 @@ function buildTaskStartedState(
         status: "planning",
         bookId: request.bookId ?? session.activeBookId,
         chapterNumber: session.activeChapterNumber,
-        stageLabel: "preparing chapter inputs",
+        stageLabel: localize(language, {
+          zh: "准备章节输入",
+          en: "preparing chapter inputs",
+        }),
       };
     case "create_book":
       return {
         status: "planning",
         bookId: request.bookId ?? session.activeBookId,
-        stageLabel: "creating book foundation",
+        stageLabel: localize(language, {
+          zh: "创建作品基础",
+          en: "creating book foundation",
+        }),
       };
     case "export_book":
       return {
         status: "persisting",
         bookId: request.bookId ?? session.activeBookId,
         chapterNumber: session.activeChapterNumber,
-        stageLabel: "exporting book artifacts",
+        stageLabel: localize(language, {
+          zh: "导出作品文件",
+          en: "exporting book artifacts",
+        }),
       };
     case "revise_chapter":
     case "rewrite_chapter":
@@ -123,7 +154,9 @@ function buildTaskStartedState(
         status: "repairing",
         bookId: request.bookId ?? session.activeBookId,
         chapterNumber: request.chapterNumber ?? session.activeChapterNumber,
-        stageLabel: request.intent === "rewrite_chapter" ? "rewriting chapter" : "revising chapter",
+        stageLabel: request.intent === "rewrite_chapter"
+          ? localize(language, { zh: "重写章节", en: "rewriting chapter" })
+          : localize(language, { zh: "修订章节", en: "revising chapter" }),
       };
     case "update_focus":
     case "update_author_intent":
@@ -132,21 +165,30 @@ function buildTaskStartedState(
         status: "persisting",
         bookId: request.bookId ?? session.activeBookId,
         chapterNumber: session.activeChapterNumber,
-        stageLabel: "applying project edit",
+        stageLabel: localize(language, {
+          zh: "应用项目修改",
+          en: "applying project edit",
+        }),
       };
     case "pause_book":
       return {
         status: "blocked",
         bookId: request.bookId ?? session.activeBookId,
         chapterNumber: session.activeChapterNumber,
-        stageLabel: "paused by user",
+        stageLabel: localize(language, {
+          zh: "已由用户暂停",
+          en: "paused by user",
+        }),
       };
     default:
       return {
         status: "planning",
         bookId: request.bookId ?? session.activeBookId,
         chapterNumber: session.activeChapterNumber,
-        stageLabel: `handling ${request.intent}`,
+        stageLabel: localize(language, {
+          zh: `处理中:${request.intent}`,
+          en: `handling ${request.intent}`,
+        }),
       };
   }
 }
@@ -177,6 +219,7 @@ function shouldWaitForHuman(
 function buildPendingDecision(
   session: InteractionSession,
   request: InteractionRequest,
+  language: RuntimeLanguage,
   chapterNumber?: number,
 ): PendingDecision | undefined {
   if (!shouldWaitForHuman(session.automationMode, request)) {
@@ -193,21 +236,31 @@ function buildPendingDecision(
     bookId,
     ...(chapterNumber !== undefined ? { chapterNumber } : {}),
     summary: session.automationMode === "manual"
-      ? "Execution finished. Choose the next action explicitly."
-      : "Execution finished. Waiting for your next decision.",
+      ? localize(language, {
+          zh: "执行已完成。请明确选择下一步操作。",
+          en: "Execution finished. Choose the next action explicitly.",
+        })
+      : localize(language, {
+          zh: "执行已完成,等待你的下一步决定。",
+          en: "Execution finished. Waiting for your next decision.",
+        }),
   };
 }
 
 function buildWaitingExecution(
   session: InteractionSession,
   request: InteractionRequest,
+  language: RuntimeLanguage,
   chapterNumber?: number,
 ): ExecutionState {
   return {
     status: "waiting_human",
     bookId: request.bookId ?? session.activeBookId,
     ...(chapterNumber !== undefined ? { chapterNumber } : {}),
-    stageLabel: "waiting for your next decision",
+    stageLabel: localize(language, {
+      zh: "等待你的下一步决定",
+      en: "waiting for your next decision",
+    }),
   };
 }
 
@@ -232,6 +285,7 @@ export async function runInteractionRequest(params: {
   readonly tools: InteractionRuntimeTools;
 }): Promise<InteractionRuntimeResult> {
   const request = routeInteractionRequest(params.request);
+  const language = resolveRuntimeLanguage(request);
   let session = params.session;
   const addEvent = (
     nextSession: InteractionSession,
@@ -253,9 +307,12 @@ export async function runInteractionRequest(params: {
 
   session = clearPendingDecision({
     ...session,
-    currentExecution: buildTaskStartedState(session, request),
+    currentExecution: buildTaskStartedState(session, request, language),
   });
-  session = addEvent(session, "task.started", session.currentExecution!.status, `Started ${request.intent}.`);
+  session = addEvent(session, "task.started", session.currentExecution!.status, localize(language, {
+    zh: `开始执行 ${request.intent}。`,
+    en: `Started ${request.intent}.`,
+  }));
 
   const markCompleted = (nextSession: InteractionSession): InteractionSession => ({
     ...nextSession,
@@ -263,17 +320,26 @@ export async function runInteractionRequest(params: {
       status: "completed",
       bookId: nextSession.activeBookId,
       chapterNumber: nextSession.activeChapterNumber,
-      stageLabel: "completed",
+      stageLabel: localize(language, {
+        zh: "已完成",
+        en: "completed",
+      }),
     },
   });
 
   switch (request.intent) {
     case "create_book": {
       if (!params.tools.createBook) {
-        throw new Error("Book creation is not implemented in the interaction runtime yet.");
+        throw new Error(localize(language, {
+          zh: "交互运行时暂未实现创建作品。",
+          en: "Book creation is not implemented in the interaction runtime yet.",
+        }));
       }
       if (!request.title) {
-        throw new Error("Book creation requires a title.");
+        throw new Error(localize(language, {
+          zh: "创建作品需要标题。",
+          en: "Book creation requires a title.",
+        }));
       }
       const toolResult = await params.tools.createBook({
         title: request.title,
@@ -289,7 +355,10 @@ export async function runInteractionRequest(params: {
         ? (toolResult as { bookId: string }).bookId
         : undefined;
       if (!createdBookId) {
-        throw new Error("Create-book tool did not return a book id.");
+        throw new Error(localize(language, {
+          zh: "创建作品工具没有返回作品 ID。",
+          en: "Create-book tool did not return a book id.",
+        }));
       }
       session = bindActiveBook(session, createdBookId);
       session = appendToolEvents(session, metadata.events);
@@ -298,8 +367,14 @@ export async function runInteractionRequest(params: {
         currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
       };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Created ${createdBookId}.`),
-        responseText: metadata.responseText ?? `Created ${createdBookId}.`,
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已创建作品 ${createdBookId}。`,
+          en: `Created ${createdBookId}.`,
+        })),
+        responseText: metadata.responseText ?? localize(language, {
+          zh: `已创建作品 ${createdBookId}。`,
+          en: `Created ${createdBookId}.`,
+        }),
         details: metadata.details,
       };
     }
@@ -307,7 +382,10 @@ export async function runInteractionRequest(params: {
     case "continue_book": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       const toolResult = await params.tools.writeNextChapter(bookId);
       const metadata = extractToolMetadata(toolResult);
@@ -316,24 +394,34 @@ export async function runInteractionRequest(params: {
       const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
         session,
         request,
+        language,
         metadata.activeChapterNumber,
       );
       const completed = pendingDecision
         ? {
             ...session,
             pendingDecision,
-            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, metadata.activeChapterNumber),
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, metadata.activeChapterNumber),
           }
         : {
             ...markCompleted(session),
             currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
           };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Completed write_next for ${bookId}.`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已为 ${bookId} 完成下一章写作。`,
+          en: `Completed write_next for ${bookId}.`,
+        })),
         responseText: metadata.responseText ?? (
           pendingDecision
-            ? `Completed write_next for ${bookId}; waiting for your next decision.`
-            : `Completed write_next for ${bookId}.`
+            ? localize(language, {
+                zh: `已为 ${bookId} 完成下一章写作,等待你的下一步决定。`,
+                en: `Completed write_next for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已为 ${bookId} 完成下一章写作。`,
+                en: `Completed write_next for ${bookId}.`,
+              })
         ),
       };
     }
@@ -341,34 +429,61 @@ export async function runInteractionRequest(params: {
       const books = await params.tools.listBooks();
       const completed = markCompleted(session);
       return {
-        session: addEvent(completed, "task.completed", "completed", `Listed ${books.length} book(s).`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已列出 ${books.length} 本作品。`,
+          en: `Listed ${books.length} book(s).`,
+        })),
         responseText: books.length > 0
-          ? `Books: ${books.join(", ")}`
-          : "No books found in this project.",
+          ? localize(language, {
+              zh: `作品列表:${books.join("、")}`,
+              en: `Books: ${books.join(", ")}`,
+            })
+          : localize(language, {
+              zh: "当前项目下没有作品。",
+              en: "No books found in this project.",
+            }),
       };
     }
     case "select_book": {
       if (!request.bookId) {
-        throw new Error("Book selection requires a book id.");
+        throw new Error(localize(language, {
+          zh: "切换作品需要提供作品 ID。",
+          en: "Book selection requires a book id.",
+        }));
       }
       const books = await params.tools.listBooks();
       if (!books.includes(request.bookId)) {
-        throw new Error(`Book "${request.bookId}" not found in this project.`);
+        throw new Error(localize(language, {
+          zh: `当前项目中找不到作品「${request.bookId}」。`,
+          en: `Book "${request.bookId}" not found in this project.`,
+        }));
       }
       const completed = markCompleted(bindActiveBook(session, request.bookId));
       return {
-        session: addEvent(completed, "task.completed", "completed", `Bound active book to ${request.bookId}.`),
-        responseText: `Opened ${request.bookId}.`,
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已切换当前作品到 ${request.bookId}。`,
+          en: `Bound active book to ${request.bookId}.`,
+        })),
+        responseText: localize(language, {
+          zh: `当前作品:${request.bookId}`,
+          en: `Active book: ${request.bookId}`,
+        }),
       };
     }
     case "revise_chapter":
     case "rewrite_chapter": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       if (!request.chapterNumber) {
-        throw new Error("Chapter number is required for chapter revision.");
+        throw new Error(localize(language, {
+          zh: "修订章节需要章节号。",
+          en: "Chapter number is required for chapter revision.",
+        }));
       }
       const mode: ReviseMode = request.intent === "rewrite_chapter" ? "rewrite" : "local-fix";
       const toolResult = await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
@@ -379,34 +494,56 @@ export async function runInteractionRequest(params: {
       const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
         session,
         request,
+        language,
         chapterNumber,
       );
       const completed = pendingDecision
         ? {
             ...session,
             pendingDecision,
-            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, chapterNumber),
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, chapterNumber),
           }
         : {
             ...markCompleted(session),
             currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
           };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Completed ${request.intent} for ${bookId}.`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: request.intent === "rewrite_chapter"
+            ? `已为 ${bookId} 完成章节重写。`
+            : `已为 ${bookId} 完成章节修订。`,
+          en: `Completed ${request.intent} for ${bookId}.`,
+        })),
         responseText: metadata.responseText ?? (
           pendingDecision
-            ? `Completed ${request.intent} for ${bookId}; waiting for your next decision.`
-            : `Completed ${request.intent} for ${bookId}.`
+            ? localize(language, {
+                zh: request.intent === "rewrite_chapter"
+                  ? `已为 ${bookId} 完成章节重写,等待你的下一步决定。`
+                  : `已为 ${bookId} 完成章节修订,等待你的下一步决定。`,
+                en: `Completed ${request.intent} for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: request.intent === "rewrite_chapter"
+                  ? `已为 ${bookId} 完成章节重写。`
+                  : `已为 ${bookId} 完成章节修订。`,
+                en: `Completed ${request.intent} for ${bookId}.`,
+              })
         ),
       };
     }
     case "patch_chapter_text": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       if (!request.chapterNumber || !request.targetText || !request.replacementText) {
-        throw new Error("Chapter patch requires chapter number, target text, and replacement text.");
+        throw new Error(localize(language, {
+          zh: "正文修补需要章节号、目标文本和替换文本。",
+          en: "Chapter patch requires chapter number, target text, and replacement text.",
+        }));
       }
       const toolResult = await params.tools.patchChapterText(
         bookId,
@@ -421,34 +558,50 @@ export async function runInteractionRequest(params: {
       const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
         session,
         request,
+        language,
         chapterNumber,
       );
       const completed = pendingDecision
         ? {
             ...session,
             pendingDecision,
-            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, chapterNumber),
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, chapterNumber),
           }
         : {
             ...markCompleted(session),
             currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
           };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Patched chapter ${chapterNumber} for ${bookId}.`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已修补 ${bookId} 的第 ${chapterNumber} 章。`,
+          en: `Patched chapter ${chapterNumber} for ${bookId}.`,
+        })),
         responseText: metadata.responseText ?? (
           pendingDecision
-            ? `Patched chapter ${chapterNumber} for ${bookId}; waiting for your next decision.`
-            : `Patched chapter ${chapterNumber} for ${bookId}.`
+            ? localize(language, {
+                zh: `已修补 ${bookId} 的第 ${chapterNumber} 章,等待你的下一步决定。`,
+                en: `Patched chapter ${chapterNumber} for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已修补 ${bookId} 的第 ${chapterNumber} 章。`,
+                en: `Patched chapter ${chapterNumber} for ${bookId}.`,
+              })
         ),
       };
     }
     case "rename_entity": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       if (!request.oldValue || !request.newValue) {
-        throw new Error("Entity rename requires old and new values.");
+        throw new Error(localize(language, {
+          zh: "实体改名需要旧值和新值。",
+          en: "Entity rename requires old and new values.",
+        }));
       }
       const toolResult = await params.tools.renameEntity(bookId, request.oldValue, request.newValue);
       const metadata = extractToolMetadata(toolResult);
@@ -457,130 +610,191 @@ export async function runInteractionRequest(params: {
       const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
         session,
         request,
+        language,
         metadata.activeChapterNumber,
       );
       const completed = pendingDecision
         ? {
             ...session,
             pendingDecision,
-            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, metadata.activeChapterNumber),
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, metadata.activeChapterNumber),
           }
         : {
             ...markCompleted(session),
             currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
           };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue}。`,
+          en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`,
+        })),
         responseText: metadata.responseText ?? (
           pendingDecision
-            ? `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}; waiting for your next decision.`
-            : `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`
+            ? localize(language, {
+                zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue},等待你的下一步决定。`,
+                en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue}。`,
+                en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`,
+              })
         ),
       };
     }
     case "update_focus": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       if (!request.instruction) {
-        throw new Error("Focus update requires instruction content.");
+        throw new Error(localize(language, {
+          zh: "更新焦点需要提供内容。",
+          en: "Focus update requires instruction content.",
+        }));
       }
       const toolResult = await params.tools.updateCurrentFocus(bookId, request.instruction);
       const metadata = extractToolMetadata(toolResult);
       session = bindActiveBook(session, bookId);
       session = appendToolEvents(session, metadata.events);
-      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
       const completed = pendingDecision
         ? {
             ...session,
             pendingDecision,
-            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
           }
         : {
             ...markCompleted(session),
             currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
           };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Updated current focus for ${bookId}.`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已更新 ${bookId} 的当前焦点。`,
+          en: `Updated current focus for ${bookId}.`,
+        })),
         responseText: metadata.responseText ?? (
           pendingDecision
-            ? `Updated current focus for ${bookId}; waiting for your next decision.`
-            : `Updated current focus for ${bookId}.`
+            ? localize(language, {
+                zh: `已更新 ${bookId} 的当前焦点,等待你的下一步决定。`,
+                en: `Updated current focus for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已更新 ${bookId} 的当前焦点。`,
+                en: `Updated current focus for ${bookId}.`,
+              })
         ),
       };
     }
     case "update_author_intent": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       if (!request.instruction) {
-        throw new Error("Author intent update requires instruction content.");
+        throw new Error(localize(language, {
+          zh: "更新作者意图需要提供内容。",
+          en: "Author intent update requires instruction content.",
+        }));
       }
       const toolResult = await params.tools.updateAuthorIntent(bookId, request.instruction);
       const metadata = extractToolMetadata(toolResult);
       session = bindActiveBook(session, bookId);
       session = appendToolEvents(session, metadata.events);
-      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
       const completed = pendingDecision
         ? {
             ...session,
             pendingDecision,
-            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
           }
         : {
             ...markCompleted(session),
             currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
           };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Updated author intent for ${bookId}.`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已更新 ${bookId} 的作者意图。`,
+          en: `Updated author intent for ${bookId}.`,
+        })),
         responseText: metadata.responseText ?? (
           pendingDecision
-            ? `Updated author intent for ${bookId}; waiting for your next decision.`
-            : `Updated author intent for ${bookId}.`
+            ? localize(language, {
+                zh: `已更新 ${bookId} 的作者意图,等待你的下一步决定。`,
+                en: `Updated author intent for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已更新 ${bookId} 的作者意图。`,
+                en: `Updated author intent for ${bookId}.`,
+              })
         ),
       };
     }
     case "edit_truth": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       if (!request.fileName || !request.instruction) {
-        throw new Error("Truth-file edit requires a file name and content.");
+        throw new Error(localize(language, {
+          zh: "编辑真相文件需要文件名和内容。",
+          en: "Truth-file edit requires a file name and content.",
+        }));
       }
       const toolResult = await params.tools.writeTruthFile(bookId, request.fileName, request.instruction);
       const metadata = extractToolMetadata(toolResult);
       session = bindActiveBook(session, bookId);
       session = appendToolEvents(session, metadata.events);
-      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
       const completed = pendingDecision
         ? {
             ...session,
             pendingDecision,
-            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
           }
         : {
             ...markCompleted(session),
             currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
           };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Updated ${request.fileName} for ${bookId}.`),
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已更新 ${bookId} 的 ${request.fileName}。`,
+          en: `Updated ${request.fileName} for ${bookId}.`,
+        })),
         responseText: metadata.responseText ?? (
           pendingDecision
-            ? `Updated ${request.fileName} for ${bookId}; waiting for your next decision.`
-            : `Updated ${request.fileName} for ${bookId}.`
+            ? localize(language, {
+                zh: `已更新 ${bookId} 的 ${request.fileName},等待你的下一步决定。`,
+                en: `Updated ${request.fileName} for ${bookId}; waiting for your next decision.`,
+              })
+            : localize(language, {
+                zh: `已更新 ${bookId} 的 ${request.fileName}。`,
+                en: `Updated ${request.fileName} for ${bookId}.`,
+              })
         ),
       };
     }
     case "export_book": {
       const bookId = request.bookId ?? session.activeBookId;
       if (!params.tools.exportBook) {
-        throw new Error("Book export is not implemented in the interaction runtime yet.");
+        throw new Error(localize(language, {
+          zh: "交互运行时暂未实现导出作品。",
+          en: "Book export is not implemented in the interaction runtime yet.",
+        }));
       }
       if (!bookId) {
-        throw new Error("No active book is bound to the interaction session.");
+        throw new Error(localize(language, {
+          zh: "当前交互会话还没有绑定作品。",
+          en: "No active book is bound to the interaction session.",
+        }));
       }
       const toolResult = await params.tools.exportBook(bookId, {
         format: request.format,
@@ -595,16 +809,28 @@ export async function runInteractionRequest(params: {
         currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
       };
       return {
-        session: addEvent(completed, "task.completed", "completed", `Exported ${bookId}.`),
-        responseText: metadata.responseText ?? `Exported ${bookId}.`,
+        session: addEvent(completed, "task.completed", "completed", localize(language, {
+          zh: `已导出 ${bookId}。`,
+          en: `Exported ${bookId}.`,
+        })),
+        responseText: metadata.responseText ?? localize(language, {
+          zh: `已导出 ${bookId}。`,
+          en: `Exported ${bookId}.`,
+        }),
         details: metadata.details,
       };
     }
     case "switch_mode":
       session = markCompleted(session);
       return {
-        session: addEvent(session, "task.completed", "completed", `Switched mode to ${session.automationMode}.`),
-        responseText: `Switched mode to ${session.automationMode}.`,
+        session: addEvent(session, "task.completed", "completed", localize(language, {
+          zh: `已切换到${localizeMode(session.automationMode, language)}模式。`,
+          en: `Switched mode to ${session.automationMode}.`,
+        })),
+        responseText: localize(language, {
+          zh: `已切换到${localizeMode(session.automationMode, language)}模式。`,
+          en: `Switched mode to ${session.automationMode}.`,
+        }),
       };
     case "pause_book": {
       const bookId = request.bookId ?? session.activeBookId;
@@ -614,12 +840,21 @@ export async function runInteractionRequest(params: {
           status: "blocked" as const,
           bookId,
           chapterNumber: session.activeChapterNumber,
-          stageLabel: "paused by user",
+          stageLabel: localize(language, {
+            zh: "已由用户暂停",
+            en: "paused by user",
+          }),
         },
       };
       return {
-        session: addEvent(paused, "task.completed", "blocked", `Paused ${bookId ?? "current book"}.`),
-        responseText: `Paused ${bookId ?? "current book"}.`,
+        session: addEvent(paused, "task.completed", "blocked", localize(language, {
+          zh: `已暂停${bookId ?? "当前作品"}。`,
+          en: `Paused ${bookId ?? "current book"}.`,
+        })),
+        responseText: localize(language, {
+          zh: `已暂停${bookId ?? "当前作品"}。`,
+          en: `Paused ${bookId ?? "current book"}.`,
+        }),
       };
     }
     case "resume_book": {
@@ -630,12 +865,21 @@ export async function runInteractionRequest(params: {
           status: "completed" as const,
           bookId,
           chapterNumber: session.activeChapterNumber,
-          stageLabel: "ready to continue",
+          stageLabel: localize(language, {
+            zh: "可继续执行",
+            en: "ready to continue",
+          }),
         },
       };
       return {
-        session: addEvent(resumed, "task.completed", "completed", `Resumed ${bookId ?? "current book"}.`),
-        responseText: `Resumed ${bookId ?? "current book"}.`,
+        session: addEvent(resumed, "task.completed", "completed", localize(language, {
+          zh: `已恢复${bookId ?? "当前作品"}。`,
+          en: `Resumed ${bookId ?? "current book"}.`,
+        })),
+        responseText: localize(language, {
+          zh: `已恢复${bookId ?? "当前作品"}。`,
+          en: `Resumed ${bookId ?? "current book"}.`,
+        }),
       };
     }
     case "chat": {
@@ -651,11 +895,23 @@ export async function runInteractionRequest(params: {
       const responseText = metadata.responseText ?? (
         /^(hi|hello|hey|你好|嗨|哈喽)$/i.test(prompt)
           ? (bookId
-              ? `Hi. Active book is ${bookId}. Ask me to continue, revise a chapter, or explain what is blocked.`
-              : "Hi. No active book yet. Open a book, list books, or tell me what you want to write.")
+              ? localize(language, {
+                  zh: `你好。当前作品是 ${bookId}。你可以让我继续写、修订章节,或者解释当前卡住的原因。`,
+                  en: `Hi. Active book is ${bookId}. Ask me to continue, revise a chapter, or explain what is blocked.`,
+                })
+              : localize(language, {
+                  zh: "你好。当前还没有激活作品。你可以先打开作品、列出作品,或者直接告诉我你要写什么。",
+                  en: "Hi. No active book yet. Open a book, list books, or tell me what you want to write.",
+                }))
           : (bookId
-              ? `I’m here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`
-              : "I’m here. No active book is bound yet. Open a book, list books, or describe what you want to write.")
+              ? localize(language, {
+                  zh: `我在。当前作品是 ${bookId}。你可以让我继续写、修订章节、重写、调整焦点,或者查看流水线为何停止。`,
+                  en: `I’m here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`,
+                })
+              : localize(language, {
+                  zh: "我在。当前还没有绑定作品。先打开作品、列出作品,或者直接描述你要写什么。",
+                  en: "I’m here. No active book is bound yet. Open a book, list books, or describe what you want to write.",
+                }))
       );
       const completed = markCompleted(session);
       return {
@@ -669,8 +925,14 @@ export async function runInteractionRequest(params: {
       const baselineExecution = params.session.currentExecution;
       const stage = baselineExecution?.stageLabel ?? baselineExecution?.status ?? "idle";
       const summary = request.intent === "explain_failure"
-        ? `Current failure context: ${bookId ?? "no active book"} is at ${stage}.`
-        : `Current status: ${bookId ?? "no active book"} is at ${stage}.`;
+        ? localize(language, {
+            zh: `当前失败上下文:${bookId ?? "当前无激活作品"} 处于 ${stage}。`,
+            en: `Current failure context: ${bookId ?? "no active book"} is at ${stage}.`,
+          })
+        : localize(language, {
+            zh: `当前状态:${bookId ?? "当前无激活作品"} 处于 ${stage}。`,
+            en: `Current status: ${bookId ?? "no active book"} is at ${stage}.`,
+          });
       const completed = markCompleted(session);
       return {
         session: addEvent(completed, "task.completed", "completed", summary),
@@ -678,6 +940,9 @@ export async function runInteractionRequest(params: {
       };
     }
     default:
-      throw new Error(`Intent "${request.intent}" is not implemented in the interaction runtime yet.`);
+      throw new Error(localize(language, {
+        zh: `交互运行时暂未实现意图「${request.intent}」。`,
+        en: `Intent "${request.intent}" is not implemented in the interaction runtime yet.`,
+      }));
   }
 }

+ 20 - 6
packages/core/src/llm/provider.ts

@@ -301,6 +301,7 @@ export async function chatCompletion(
     readonly maxTokens?: number;
     readonly webSearch?: boolean;
     readonly onStreamProgress?: OnStreamProgress;
+    readonly onTextDelta?: (text: string) => void;
   },
 ): Promise<LLMResponse> {
   const perCallMax = options?.maxTokens ?? client.defaults.maxTokens;
@@ -314,22 +315,23 @@ export async function chatCompletion(
     extra: client.defaults.extra,
   };
   const onStreamProgress = options?.onStreamProgress;
+  const onTextDelta = options?.onTextDelta;
   const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
 
   try {
     if (client.provider === "anthropic") {
       return client.stream
-        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress)
-        : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
+        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress, onTextDelta)
+        : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onTextDelta);
     }
     if (client.apiFormat === "responses") {
       return client.stream
-        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
-        : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
+        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
+        : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
     }
     return client.stream
-      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
-      : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
+      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
+      : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
   } catch (error) {
     // Stream interrupted but partial content is usable — return truncated response
     if (error instanceof PartialResponseError) {
@@ -433,6 +435,7 @@ async function chatCompletionOpenAIChat(
   options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   webSearch?: boolean,
   onStreamProgress?: OnStreamProgress,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const createParams: any = {
@@ -458,6 +461,7 @@ async function chatCompletionOpenAIChat(
       if (delta) {
         chunks.push(delta);
         monitor.onChunk(delta);
+        onTextDelta?.(delta);
       }
       if (chunk.usage) {
         inputTokens = chunk.usage.prompt_tokens ?? 0;
@@ -494,6 +498,7 @@ async function chatCompletionOpenAIChatSync(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   _webSearch?: boolean,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const syncParams: any = {
@@ -508,6 +513,7 @@ async function chatCompletionOpenAIChatSync(
 
   const content = response.choices[0]?.message?.content ?? "";
   if (!content) throw new Error("LLM returned empty response");
+  onTextDelta?.(content);
 
   return {
     content,
@@ -621,6 +627,7 @@ async function chatCompletionOpenAIResponses(
   options: { readonly temperature: number; readonly maxTokens: number },
   webSearch?: boolean,
   onStreamProgress?: OnStreamProgress,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
     role: m.role as "system" | "user" | "assistant",
@@ -650,6 +657,7 @@ async function chatCompletionOpenAIResponses(
       if (event.type === "response.output_text.delta") {
         chunks.push(event.delta);
         monitor.onChunk(event.delta);
+        onTextDelta?.(event.delta);
       }
       if (event.type === "response.completed") {
         inputTokens = event.response.usage?.input_tokens ?? 0;
@@ -686,6 +694,7 @@ async function chatCompletionOpenAIResponsesSync(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
   _webSearch?: boolean,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
     role: m.role as "system" | "user" | "assistant",
@@ -708,6 +717,7 @@ async function chatCompletionOpenAIResponsesSync(
     .join("");
 
   if (!content) throw new Error("LLM returned empty response");
+  onTextDelta?.(content);
 
   return {
     content,
@@ -814,6 +824,7 @@ async function chatCompletionAnthropic(
   options: { readonly temperature: number; readonly maxTokens: number },
   thinkingBudget: number = 0,
   onStreamProgress?: OnStreamProgress,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const systemText = messages
     .filter((m) => m.role === "system")
@@ -845,6 +856,7 @@ async function chatCompletionAnthropic(
       if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
         chunks.push(event.delta.text);
         monitor.onChunk(event.delta.text);
+        onTextDelta?.(event.delta.text);
       }
       if (event.type === "message_start") {
         inputTokens = event.message.usage?.input_tokens ?? 0;
@@ -883,6 +895,7 @@ async function chatCompletionAnthropicSync(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
   thinkingBudget: number = 0,
+  onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
   const systemText = messages
     .filter((m) => m.role === "system")
@@ -909,6 +922,7 @@ async function chatCompletionAnthropicSync(
     .join("");
 
   if (!content) throw new Error("LLM returned empty response");
+  onTextDelta?.(content);
 
   return {
     content,

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 630 - 5
pnpm-lock.yaml


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio