app.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. /* ── InkOS TUI — persistent REPL with themed animations ── */
  2. import { basename } from "node:path";
  3. import readline from "node:readline/promises";
  4. import {
  5. appendInteractionMessage,
  6. processProjectInteractionInput,
  7. routeNaturalLanguageIntent,
  8. type InteractionRuntimeTools,
  9. } from "@actalk/inkos-core";
  10. import {
  11. loadProjectSession,
  12. persistProjectSession,
  13. resolveSessionActiveBook,
  14. } from "./session-store.js";
  15. import { createInteractionTools } from "./tools.js";
  16. import { formatTuiResult } from "./output.js";
  17. import { ensureProject, interactiveLlmSetup, detectModelInfo } from "./setup.js";
  18. import {
  19. c, bold, dim, cyan, green, yellow, gray, red, brightCyan, brightWhite,
  20. showCursor, reset, box,
  21. } from "./ansi.js";
  22. import {
  23. ThemedSpinner,
  24. animateStartup,
  25. formatResultCard,
  26. intentToTheme,
  27. printStyledHelp,
  28. printStyledStatus,
  29. printInputSeparator,
  30. inputPromptPrefix,
  31. drawInputHint,
  32. } from "./effects.js";
  33. /* ── Version ── */
  34. async function readVersion(): Promise<string> {
  35. try {
  36. const { readFile } = await import("node:fs/promises");
  37. const { join, dirname } = await import("node:path");
  38. const { fileURLToPath } = await import("node:url");
  39. const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
  40. const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
  41. return pkg.version ?? "dev";
  42. } catch {
  43. return "dev";
  44. }
  45. }
  46. /* ── Process input with themed spinner ── */
  47. async function processInput(
  48. projectRoot: string,
  49. input: string,
  50. tools: InteractionRuntimeTools,
  51. ): Promise<{ summary: string; intent: string } | undefined> {
  52. // Detect intent for themed spinner
  53. const session = await loadProjectSession(projectRoot);
  54. const activeBookId = await resolveSessionActiveBook(projectRoot, session);
  55. const routed = routeNaturalLanguageIntent(input, { activeBookId });
  56. const themeName = intentToTheme(routed.intent);
  57. const spinner = new ThemedSpinner(themeName);
  58. const intentLabels: Record<string, string> = {
  59. write_next: "writing chapter",
  60. revise_chapter: "revising chapter",
  61. rewrite_chapter: "rewriting chapter",
  62. update_focus: "updating focus",
  63. explain_status: "checking status",
  64. explain_failure: "investigating",
  65. pause_book: "pausing book",
  66. list_books: "listing books",
  67. select_book: "selecting book",
  68. switch_mode: "switching mode",
  69. rename_entity: "renaming entity",
  70. patch_chapter_text: "patching text",
  71. edit_truth: "editing truth file",
  72. };
  73. spinner.start(intentLabels[routed.intent] ?? "processing");
  74. try {
  75. const result = await processProjectInteractionInput({
  76. projectRoot,
  77. input,
  78. tools,
  79. });
  80. const summary = formatTuiResult({
  81. intent: result.request.intent,
  82. status: result.session.currentExecution?.status ?? "completed",
  83. bookId: result.session.activeBookId,
  84. mode: result.request.mode,
  85. responseText: result.responseText,
  86. });
  87. const nextSession = appendInteractionMessage(result.session, {
  88. role: "assistant",
  89. content: summary,
  90. timestamp: Date.now(),
  91. });
  92. await persistProjectSession(projectRoot, nextSession);
  93. spinner.succeed(c(summary, dim));
  94. return { summary, intent: result.request.intent };
  95. } catch (err) {
  96. const msg = err instanceof Error ? err.message : String(err);
  97. spinner.fail(c(msg, red));
  98. return undefined;
  99. }
  100. }
  101. /* ── Legacy exports for tests ── */
  102. export interface TuiFrameState {
  103. readonly projectName: string;
  104. readonly activeBookTitle?: string;
  105. readonly automationMode: string;
  106. readonly status: string;
  107. readonly messages?: ReadonlyArray<string>;
  108. readonly events?: ReadonlyArray<string>;
  109. }
  110. export function renderTuiFrame(state: TuiFrameState): string {
  111. const lines = [
  112. `Project: ${state.projectName}`,
  113. `Book: ${state.activeBookTitle ?? "none"}`,
  114. `Mode: ${state.automationMode}`,
  115. `Stage: ${state.status}`,
  116. "",
  117. "Messages:",
  118. ...(state.messages?.length
  119. ? state.messages.slice(-3).map((message) => `- ${message}`)
  120. : ["- (empty)"]),
  121. "",
  122. "Events:",
  123. ...(state.events?.length
  124. ? state.events.slice(-3).map((event) => `- ${event}`)
  125. : ["- (empty)"]),
  126. "",
  127. "> ",
  128. ];
  129. return lines.join("\n");
  130. }
  131. export async function processTuiInput(
  132. projectRoot: string,
  133. input: string,
  134. tools: InteractionRuntimeTools,
  135. ) {
  136. const result = await processProjectInteractionInput({
  137. projectRoot,
  138. input,
  139. tools,
  140. });
  141. const summary = formatTuiResult({
  142. intent: result.request.intent,
  143. status: result.session.currentExecution?.status ?? "completed",
  144. bookId: result.session.activeBookId,
  145. mode: result.request.mode,
  146. responseText: result.responseText,
  147. });
  148. const nextSession = appendInteractionMessage(result.session, {
  149. role: "assistant",
  150. content: summary,
  151. timestamp: Date.now(),
  152. });
  153. await persistProjectSession(projectRoot, nextSession);
  154. return { ...result, session: nextSession };
  155. }
  156. /* ── Main REPL ── */
  157. export async function launchTui(
  158. projectRoot: string,
  159. toolsOverride?: InteractionRuntimeTools,
  160. ): Promise<void> {
  161. // 1. Auto-setup
  162. const { hasLlmConfig } = await ensureProject(projectRoot);
  163. // 2. LLM config if missing
  164. if (!hasLlmConfig) {
  165. console.log();
  166. console.log(c(" No LLM configuration found.", yellow));
  167. console.log(c(" Let's set up your API provider first.", dim));
  168. await interactiveLlmSetup(projectRoot);
  169. }
  170. // 3. Load session
  171. const session = await loadProjectSession(projectRoot);
  172. const activeBookId = await resolveSessionActiveBook(projectRoot, session);
  173. const version = await readVersion();
  174. // 4. Detect model + animated welcome
  175. const modelInfo = await detectModelInfo(projectRoot);
  176. await animateStartup(version, basename(projectRoot), activeBookId, modelInfo ?? undefined);
  177. // 5. Bail if not interactive
  178. if (!process.stdin.isTTY || !process.stdout.isTTY) {
  179. return;
  180. }
  181. // 6. Build tools
  182. let tools: InteractionRuntimeTools;
  183. try {
  184. tools = toolsOverride ?? (await createInteractionTools(projectRoot));
  185. } catch (err) {
  186. const msg = err instanceof Error ? err.message : String(err);
  187. console.log(c(` ${c("✗", red, bold)} Failed to initialize: ${msg}`, red));
  188. console.log(c(" Check your .env or run: inkos config set-global", dim));
  189. console.log();
  190. return;
  191. }
  192. // 7. Suppress noisy Node warnings (e.g. SQLite experimental)
  193. const origEmitWarning = process.emitWarning;
  194. process.emitWarning = (() => {}) as typeof process.emitWarning;
  195. const origStderrWrite = process.stderr.write.bind(process.stderr);
  196. process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
  197. const s = typeof chunk === "string" ? chunk : chunk.toString();
  198. if (s.includes("ExperimentalWarning") || s.includes("--trace-warnings")) {
  199. return true;
  200. }
  201. return (origStderrWrite as Function)(chunk, ...args);
  202. };
  203. // 8. Slash command completer
  204. const SLASH_COMMANDS = [
  205. "/write", "/rewrite", "/books", "/open", "/status",
  206. "/mode", "/focus", "/config", "/clear", "/help", "/quit",
  207. ];
  208. const completer = (line: string): [string[], string] => {
  209. if (line.startsWith("/")) {
  210. const hits = SLASH_COMMANDS.filter((cmd) => cmd.startsWith(line));
  211. return [hits.length > 0 ? hits : SLASH_COMMANDS, line];
  212. }
  213. return [[], line];
  214. };
  215. // 9. REPL loop
  216. const prompt = inputPromptPrefix();
  217. const rl = readline.createInterface({
  218. input: process.stdin,
  219. output: process.stdout,
  220. prompt,
  221. completer,
  222. });
  223. const cleanup = () => {
  224. process.emitWarning = origEmitWarning;
  225. process.stderr.write = origStderrWrite;
  226. process.stdout.write(showCursor);
  227. rl.close();
  228. };
  229. const promptInput = () => {
  230. drawInputHint();
  231. rl.prompt();
  232. };
  233. process.on("SIGINT", () => {
  234. console.log();
  235. console.log(c(" ◇ goodbye", dim));
  236. console.log();
  237. cleanup();
  238. process.exit(0);
  239. });
  240. promptInput();
  241. for await (const line of rl) {
  242. const input = line.trim();
  243. if (!input) {
  244. promptInput();
  245. continue;
  246. }
  247. console.log();
  248. // Built-in TUI commands
  249. if (/^\/quit$/i.test(input) || /^\/exit$/i.test(input) || /^(quit|exit|bye)$/i.test(input)) {
  250. console.log(c(" ◇ goodbye", dim));
  251. console.log();
  252. break;
  253. }
  254. if (/^\/help$/i.test(input) || /^(help|帮助)$/i.test(input)) {
  255. printStyledHelp();
  256. promptInput();
  257. continue;
  258. }
  259. if (/^\/status$/i.test(input) || /^(status|状态)$/i.test(input)) {
  260. try {
  261. const s = await loadProjectSession(projectRoot);
  262. const bId = await resolveSessionActiveBook(projectRoot, s);
  263. printStyledStatus({
  264. mode: s.automationMode,
  265. bookId: bId,
  266. status: s.currentExecution?.status ?? "idle",
  267. events: s.events,
  268. });
  269. } catch {
  270. console.log(c(" Could not load session.", dim));
  271. }
  272. promptInput();
  273. continue;
  274. }
  275. if (/^\/config$/i.test(input)) {
  276. await interactiveLlmSetup(projectRoot);
  277. const newModel = await detectModelInfo(projectRoot);
  278. if (newModel) {
  279. console.log(` ${c("◇", cyan)} ${c("Model", gray)} ${c(newModel.model, brightWhite)} ${c(`(${newModel.provider})`, dim)}`);
  280. }
  281. console.log();
  282. promptInput();
  283. continue;
  284. }
  285. if (/^\/clear$/i.test(input)) {
  286. console.clear();
  287. promptInput();
  288. continue;
  289. }
  290. // Delegate to interaction layer with themed animation
  291. await processInput(projectRoot, input, tools);
  292. console.log();
  293. promptInput();
  294. }
  295. cleanup();
  296. }