فهرست منبع

feat(tui): Claude Code-style persistent REPL with auto-init

- Auto-detect inkos.json on launch, auto-init if missing
- Interactive LLM config wizard when no API key found
- Persistent REPL loop with colored output and box-drawn header
- Spinner animation during async operations
- /help, /status, /quit, /clear built-in commands
- Natural language and slash commands delegated to interaction layer
- Zero new dependencies (pure ANSI escape codes)
Ma 7 ماه پیش
والد
کامیت
ec72e18bf4
5فایلهای تغییر یافته به همراه537 افزوده شده و 28 حذف شده
  1. 2 2
      packages/cli/src/__tests__/tui-output.test.ts
  2. 49 0
      packages/cli/src/tui/ansi.ts
  3. 276 23
      packages/cli/src/tui/app.ts
  4. 27 3
      packages/cli/src/tui/output.ts
  5. 183 0
      packages/cli/src/tui/setup.ts

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

@@ -7,7 +7,7 @@ describe("tui output", () => {
       intent: "write_next",
       intent: "write_next",
       status: "completed",
       status: "completed",
       bookId: "harbor",
       bookId: "harbor",
-    })).toContain("Completed write_next for harbor");
+    })).toContain("harbor");
   });
   });
 
 
   it("summarizes a mode switch", () => {
   it("summarizes a mode switch", () => {
@@ -15,7 +15,7 @@ describe("tui output", () => {
       intent: "switch_mode",
       intent: "switch_mode",
       status: "completed",
       status: "completed",
       mode: "auto",
       mode: "auto",
-    })).toContain("Switched mode to auto");
+    })).toContain("auto");
   });
   });
 
 
   it("prefers explicit response text when provided", () => {
   it("prefers explicit response text when provided", () => {

+ 49 - 0
packages/cli/src/tui/ansi.ts

@@ -0,0 +1,49 @@
+/* ── ANSI terminal helpers ── zero dependencies ── */
+
+export const reset = "\x1b[0m";
+export const bold = "\x1b[1m";
+export const dim = "\x1b[2m";
+export const italic = "\x1b[3m";
+
+export const red = "\x1b[31m";
+export const green = "\x1b[32m";
+export const yellow = "\x1b[33m";
+export const blue = "\x1b[34m";
+export const magenta = "\x1b[35m";
+export const cyan = "\x1b[36m";
+export const white = "\x1b[37m";
+export const gray = "\x1b[90m";
+export const brightCyan = "\x1b[96m";
+export const brightWhite = "\x1b[97m";
+
+export const clearScreen = "\x1b[2J\x1b[H";
+export const showCursor = "\x1b[?25h";
+export const hideCursor = "\x1b[?25l";
+export const clearLine = "\x1b[2K\r";
+
+export function c(text: string, ...codes: string[]): string {
+  return `${codes.join("")}${text}${reset}`;
+}
+
+export function termWidth(): number {
+  return process.stdout.columns ?? 80;
+}
+
+export function hr(char = "─"): string {
+  return char.repeat(Math.min(termWidth(), 60));
+}
+
+export function box(lines: string[], width = 56): string {
+  const top = `╭${"─".repeat(width - 2)}╮`;
+  const bot = `╰${"─".repeat(width - 2)}╯`;
+  const rows = lines.map((line) => {
+    const visible = stripAnsi(line);
+    const pad = Math.max(0, width - 2 - visible.length);
+    return `│${line}${" ".repeat(pad)}│`;
+  });
+  return [top, ...rows, bot].join("\n");
+}
+
+function stripAnsi(s: string): string {
+  return s.replace(/\x1b\[[0-9;]*m/g, "");
+}

+ 276 - 23
packages/cli/src/tui/app.ts

@@ -1,10 +1,10 @@
+/* ── InkOS TUI — Claude Code-style persistent REPL ── */
+
 import { basename } from "node:path";
 import { basename } from "node:path";
 import readline from "node:readline/promises";
 import readline from "node:readline/promises";
 import {
 import {
   appendInteractionMessage,
   appendInteractionMessage,
   processProjectInteractionInput,
   processProjectInteractionInput,
-  type AutomationMode,
-  type ExecutionStatus,
   type InteractionRuntimeTools,
   type InteractionRuntimeTools,
 } from "@actalk/inkos-core";
 } from "@actalk/inkos-core";
 import {
 import {
@@ -14,12 +14,190 @@ import {
 } from "./session-store.js";
 } from "./session-store.js";
 import { createInteractionTools } from "./tools.js";
 import { createInteractionTools } from "./tools.js";
 import { formatTuiResult } from "./output.js";
 import { formatTuiResult } from "./output.js";
+import { ensureProject, interactiveLlmSetup } from "./setup.js";
+import {
+  c,
+  bold,
+  dim,
+  cyan,
+  green,
+  yellow,
+  gray,
+  magenta,
+  red,
+  blue,
+  brightCyan,
+  brightWhite,
+  reset,
+  box,
+  hr,
+  clearLine,
+  hideCursor,
+  showCursor,
+} from "./ansi.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";
+  }
+}
+
+/* ── Welcome screen ── */
+
+function printWelcome(version: string, projectName: string, bookTitle?: string): void {
+  console.log();
+  console.log(
+    box([
+      `  ${c("InkOS", bold, brightCyan)}${c(` v${version}`, dim)}`,
+      `  ${c("Autonomous Novel Writing AI Agent", dim)}`,
+    ]),
+  );
+  console.log();
+
+  const info = [
+    `  ${c("Project", gray)}  ${c(projectName, brightWhite)}`,
+    `  ${c("Book", gray)}     ${bookTitle ? c(bookTitle, brightWhite) : c("none — run /books or create one", dim)}`,
+  ];
+  console.log(info.join("\n"));
+  console.log();
+  console.log(c("  Type a command or describe what you want to do.", dim));
+  console.log(c("  /help for commands, /quit to exit.", dim));
+  console.log();
+}
+
+/* ── Help ── */
+
+function printHelp(): void {
+  const commands = [
+    ["/write", "Write the next chapter (full pipeline)"],
+    ["/books", "List all books"],
+    ["/open <book>", "Select active book"],
+    ["/mode <auto|semi|manual>", "Switch automation mode"],
+    ["/focus <text>", "Update current focus for next chapters"],
+    ["/rewrite <n>", "Rewrite chapter N"],
+    ["/status", "Show current status"],
+    ["/help", "Show this help"],
+    ["/quit", "Exit InkOS TUI"],
+  ];
+
+  console.log();
+  console.log(c("  Commands", bold, cyan));
+  console.log();
+  for (const [cmd, desc] of commands) {
+    console.log(`  ${c(cmd!, green)}  ${c(desc!, dim)}`);
+  }
+  console.log();
+  console.log(c("  You can also type in natural language:", dim));
+  console.log(c('  "继续写" / "写下一章" / "暂停" / "把林烬改成张三"', dim));
+  console.log();
+}
+
+/* ── Spinner ── */
+
+class Spinner {
+  private interval: ReturnType<typeof setInterval> | undefined;
+  private frame = 0;
+  private readonly frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+
+  start(message: string): void {
+    this.frame = 0;
+    process.stdout.write(hideCursor);
+    this.interval = setInterval(() => {
+      const f = this.frames[this.frame % this.frames.length];
+      process.stdout.write(`${clearLine}  ${c(f!, cyan)} ${c(message, dim)}`);
+      this.frame++;
+    }, 80);
+  }
+
+  stop(message?: string): void {
+    if (this.interval) {
+      clearInterval(this.interval);
+      this.interval = undefined;
+    }
+    process.stdout.write(`${clearLine}${showCursor}`);
+    if (message) {
+      console.log(`  ${c("✓", green)} ${c(message, dim)}`);
+    }
+  }
+}
+
+/* ── Process input ── */
+
+async function processInput(
+  projectRoot: string,
+  input: string,
+  tools: InteractionRuntimeTools,
+  spinner: Spinner,
+): Promise<string | undefined> {
+  spinner.start("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.stop();
+    return summary;
+  } catch (err) {
+    spinner.stop();
+    const msg = err instanceof Error ? err.message : String(err);
+    return `Error: ${msg}`;
+  }
+}
+
+/* ── Status command ── */
+
+async function printStatus(projectRoot: string): Promise<void> {
+  try {
+    const session = await loadProjectSession(projectRoot);
+    const bookId = await resolveSessionActiveBook(projectRoot, session);
+    console.log();
+    console.log(`  ${c("Mode", gray)}     ${c(session.automationMode, yellow)}`);
+    console.log(`  ${c("Book", gray)}     ${bookId ? c(bookId, brightWhite) : c("none", dim)}`);
+    console.log(`  ${c("Status", gray)}   ${c(session.currentExecution?.status ?? "idle", green)}`);
+    if (session.events.length > 0) {
+      console.log(`  ${c("Events", gray)}   ${c(String(session.events.length), brightWhite)}`);
+      const recent = session.events.slice(-3);
+      for (const ev of recent) {
+        console.log(`           ${c(`${ev.kind}: ${ev.detail ?? ev.status}`, dim)}`);
+      }
+    }
+    console.log();
+  } catch {
+    console.log(c("  Could not load session.", dim));
+    console.log();
+  }
+}
+
+/* ── Legacy exports for tests ── */
 
 
 export interface TuiFrameState {
 export interface TuiFrameState {
   readonly projectName: string;
   readonly projectName: string;
   readonly activeBookTitle?: string;
   readonly activeBookTitle?: string;
-  readonly automationMode: AutomationMode;
-  readonly status: ExecutionStatus;
+  readonly automationMode: string;
+  readonly status: string;
   readonly messages?: ReadonlyArray<string>;
   readonly messages?: ReadonlyArray<string>;
   readonly events?: ReadonlyArray<string>;
   readonly events?: ReadonlyArray<string>;
 }
 }
@@ -43,7 +221,6 @@ export function renderTuiFrame(state: TuiFrameState): string {
     "",
     "",
     "> ",
     "> ",
   ];
   ];
-
   return lines.join("\n");
   return lines.join("\n");
 }
 }
 
 
@@ -73,40 +250,116 @@ export async function processTuiInput(
   return { ...result, session: nextSession };
   return { ...result, session: nextSession };
 }
 }
 
 
+/* ── Main REPL ── */
+
 export async function launchTui(
 export async function launchTui(
   projectRoot: string,
   projectRoot: string,
-  tools?: InteractionRuntimeTools,
+  toolsOverride?: InteractionRuntimeTools,
 ): Promise<void> {
 ): Promise<void> {
+  // 1. Auto-setup
+  const { hasLlmConfig } = await ensureProject(projectRoot);
+
+  // 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));
+    await interactiveLlmSetup(projectRoot);
+  }
+
+  // 3. Load session
   const session = await loadProjectSession(projectRoot);
   const session = await loadProjectSession(projectRoot);
   const activeBookId = await resolveSessionActiveBook(projectRoot, session);
   const activeBookId = await resolveSessionActiveBook(projectRoot, session);
-  const frame = renderTuiFrame({
-    projectName: basename(projectRoot),
-    activeBookTitle: activeBookId,
-    automationMode: session.automationMode,
-    status: session.currentExecution?.status ?? "idle",
-    messages: session.messages.map((message) => `${message.role}: ${message.content}`),
-    events: session.events.map((event) => `${event.kind}: ${event.detail ?? event.status}`),
-  });
+  const version = await readVersion();
 
 
-  process.stdout.write(frame);
+  // 4. Welcome
+  printWelcome(version, basename(projectRoot), activeBookId);
 
 
+  // 5. Bail if not interactive
   if (!process.stdin.isTTY || !process.stdout.isTTY) {
   if (!process.stdin.isTTY || !process.stdout.isTTY) {
     return;
     return;
   }
   }
 
 
+  // 6. Build tools
+  let tools: InteractionRuntimeTools;
+  try {
+    tools = toolsOverride ?? (await createInteractionTools(projectRoot));
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    console.log(c(`  Failed to initialize: ${msg}`, red));
+    console.log(c("  Check your .env or run: inkos config set-global", dim));
+    console.log();
+    return;
+  }
+
+  // 7. REPL loop
   const rl = readline.createInterface({
   const rl = readline.createInterface({
     input: process.stdin,
     input: process.stdin,
     output: process.stdout,
     output: process.stdout,
+    prompt: `${c("❯", cyan)} `,
   });
   });
+  const spinner = new Spinner();
 
 
-  try {
-    const input = await rl.question("");
-    if (!input.trim()) {
-      return;
-    }
-    const result = await processTuiInput(projectRoot, input, tools ?? await createInteractionTools(projectRoot));
-    process.stdout.write(`\n${result.session.messages.at(-1)?.content ?? ""}\n`);
-  } finally {
+  const cleanup = () => {
+    process.stdout.write(showCursor);
     rl.close();
     rl.close();
+  };
+
+  process.on("SIGINT", () => {
+    spinner.stop();
+    console.log();
+    console.log(c("  Bye!", dim));
+    cleanup();
+    process.exit(0);
+  });
+
+  rl.prompt();
+
+  for await (const line of rl) {
+    const input = line.trim();
+
+    if (!input) {
+      rl.prompt();
+      continue;
+    }
+
+    // Built-in TUI commands
+    if (/^\/quit$/i.test(input) || /^\/exit$/i.test(input) || /^(quit|exit|bye)$/i.test(input)) {
+      console.log(c("  Bye!", dim));
+      break;
+    }
+
+    if (/^\/help$/i.test(input) || /^(help|帮助)$/i.test(input)) {
+      printHelp();
+      rl.prompt();
+      continue;
+    }
+
+    if (/^\/status$/i.test(input) || /^(status|状态)$/i.test(input)) {
+      await printStatus(projectRoot);
+      rl.prompt();
+      continue;
+    }
+
+    if (/^\/clear$/i.test(input)) {
+      console.clear();
+      rl.prompt();
+      continue;
+    }
+
+    // Delegate to interaction layer
+    const result = await processInput(projectRoot, input, tools, spinner);
+    if (result) {
+      console.log();
+      // Indent each line of result
+      for (const resultLine of result.split("\n")) {
+        console.log(`  ${resultLine}`);
+      }
+      console.log();
+    }
+
+    rl.prompt();
   }
   }
+
+  cleanup();
 }
 }

+ 27 - 3
packages/cli/src/tui/output.ts

@@ -12,12 +12,36 @@ export function formatTuiResult(params: {
   }
   }
 
 
   if (params.intent === "switch_mode" && params.mode) {
   if (params.intent === "switch_mode" && params.mode) {
-    return `Switched mode to ${params.mode}.`;
+    return `Mode switched to ${params.mode}.`;
+  }
+
+  if (params.intent === "list_books") {
+    return "Books listed.";
+  }
+
+  if (params.intent === "select_book" && params.bookId) {
+    return `Active book: ${params.bookId}`;
   }
   }
 
 
   if (params.bookId) {
   if (params.bookId) {
-    return `Completed ${params.intent} for ${params.bookId}.`;
+    return `${intentLabel(params.intent)} — ${params.bookId}`;
   }
   }
 
 
-  return `Completed ${params.intent}.`;
+  return intentLabel(params.intent);
+}
+
+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}`;
 }
 }

+ 183 - 0
packages/cli/src/tui/setup.ts

@@ -0,0 +1,183 @@
+/* ── Auto-init & environment detection for TUI ── */
+
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { join, basename } from "node:path";
+import readline from "node:readline/promises";
+import { c, bold, cyan, green, yellow, gray, dim, red, reset } from "./ansi.js";
+import { GLOBAL_ENV_PATH } from "../utils.js";
+
+const PROVIDERS = ["openai", "anthropic", "custom"] as const;
+
+interface SetupResult {
+  readonly projectRoot: string;
+  readonly hasLlmConfig: boolean;
+}
+
+export async function ensureProject(cwd: string): Promise<SetupResult> {
+  const configPath = join(cwd, "inkos.json");
+  const hasConfig = await fileExists(configPath);
+
+  if (!hasConfig) {
+    await autoInit(cwd);
+  }
+
+  const hasLlm = await hasLlmConfig(cwd);
+  return { projectRoot: cwd, hasLlmConfig: hasLlm };
+}
+
+export async function interactiveLlmSetup(
+  projectRoot: string,
+): Promise<void> {
+  const rl = readline.createInterface({
+    input: process.stdin,
+    output: process.stdout,
+  });
+
+  try {
+    console.log();
+    console.log(c("  LLM 配置向导", bold, cyan));
+    console.log(c("  Configure your LLM provider to start writing.", dim));
+    console.log();
+
+    const providerInput = await rl.question(
+      c("  Provider ", gray) +
+        c("(openai/anthropic/custom)", dim) +
+        c(": ", gray),
+    );
+    const provider = PROVIDERS.includes(providerInput.trim() as typeof PROVIDERS[number])
+      ? providerInput.trim()
+      : "openai";
+
+    const baseUrl = await rl.question(
+      c("  Base URL ", gray) +
+        c("(API endpoint)", dim) +
+        c(": ", gray),
+    );
+
+    const apiKey = await rl.question(
+      c("  API Key ", gray) +
+        c(": ", gray),
+    );
+
+    const model = await rl.question(
+      c("  Model ", gray) +
+        c("(e.g. gpt-4o, claude-sonnet-4-20250514)", dim) +
+        c(": ", gray),
+    );
+
+    const scope = await rl.question(
+      c("  Save to ", gray) +
+        c("(global/project)", dim) +
+        c(" [global]: ", gray),
+    );
+
+    const useGlobal = scope.trim().toLowerCase() !== "project";
+
+    const envContent = [
+      `INKOS_LLM_PROVIDER=${provider}`,
+      `INKOS_LLM_BASE_URL=${baseUrl.trim()}`,
+      `INKOS_LLM_API_KEY=${apiKey.trim()}`,
+      `INKOS_LLM_MODEL=${model.trim()}`,
+    ].join("\n");
+
+    if (useGlobal) {
+      const globalDir = join(GLOBAL_ENV_PATH, "..");
+      await mkdir(globalDir, { recursive: true });
+      await writeFile(GLOBAL_ENV_PATH, envContent + "\n", "utf-8");
+      console.log(c(`  ✓ Saved to ${GLOBAL_ENV_PATH}`, green));
+    } else {
+      await writeFile(join(projectRoot, ".env"), envContent + "\n", "utf-8");
+      console.log(c("  ✓ Saved to .env", green));
+    }
+    console.log();
+  } finally {
+    rl.close();
+  }
+}
+
+async function autoInit(cwd: string): Promise<void> {
+  const projectName = basename(cwd);
+  console.log(c(`  Auto-initializing project in ${projectName}/ ...`, dim));
+
+  await mkdir(join(cwd, "books"), { recursive: true });
+  await mkdir(join(cwd, "radar"), { recursive: true });
+
+  const config = {
+    name: projectName,
+    version: "0.1.0",
+    language: "zh",
+    llm: {
+      provider: process.env.INKOS_LLM_PROVIDER ?? "openai",
+      baseUrl: process.env.INKOS_LLM_BASE_URL ?? "",
+      model: process.env.INKOS_LLM_MODEL ?? "",
+    },
+    notify: [],
+    daemon: {
+      schedule: {
+        radarCron: "0 */6 * * *",
+        writeCron: "*/15 * * * *",
+      },
+      maxConcurrentBooks: 3,
+    },
+  };
+
+  await writeFile(
+    join(cwd, "inkos.json"),
+    JSON.stringify(config, null, 2),
+    "utf-8",
+  );
+
+  const hasGlobal = await hasGlobalConfig();
+  if (!hasGlobal) {
+    await writeFile(
+      join(cwd, ".env"),
+      [
+        "# LLM Configuration — run inkos tui to configure interactively",
+        "INKOS_LLM_PROVIDER=openai",
+        "INKOS_LLM_BASE_URL=",
+        "INKOS_LLM_API_KEY=",
+        "INKOS_LLM_MODEL=",
+      ].join("\n"),
+      "utf-8",
+    );
+  }
+
+  await writeFile(
+    join(cwd, ".gitignore"),
+    [".env", "node_modules/", ".DS_Store"].join("\n"),
+    "utf-8",
+  );
+
+  console.log(c("  ✓ Project initialized", green));
+}
+
+async function hasLlmConfig(projectRoot: string): Promise<boolean> {
+  // Check project .env first
+  const projectEnv = join(projectRoot, ".env");
+  if (await checkEnvForKey(projectEnv)) return true;
+  // Check global
+  return checkEnvForKey(GLOBAL_ENV_PATH);
+}
+
+async function hasGlobalConfig(): Promise<boolean> {
+  return checkEnvForKey(GLOBAL_ENV_PATH);
+}
+
+async function checkEnvForKey(envPath: string): Promise<boolean> {
+  try {
+    const content = await readFile(envPath, "utf-8");
+    const match = content.match(/INKOS_LLM_API_KEY=(.+)/);
+    return !!match && match[1]!.trim().length > 0 && !match[1]!.includes("your-api-key");
+  } catch {
+    return false;
+  }
+}
+
+async function fileExists(path: string): Promise<boolean> {
+  try {
+    await access(path);
+    return true;
+  } catch {
+    return false;
+  }
+}