app.ts 8.4 KB

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