app.ts 7.9 KB

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