app.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. /* ── InkOS TUI — Claude Code-style persistent REPL ── */
  2. import { basename } from "node:path";
  3. import readline from "node:readline/promises";
  4. import {
  5. appendInteractionMessage,
  6. processProjectInteractionInput,
  7. type InteractionRuntimeTools,
  8. } from "@actalk/inkos-core";
  9. import {
  10. loadProjectSession,
  11. persistProjectSession,
  12. resolveSessionActiveBook,
  13. } from "./session-store.js";
  14. import { createInteractionTools } from "./tools.js";
  15. import { formatTuiResult } from "./output.js";
  16. import { ensureProject, interactiveLlmSetup } from "./setup.js";
  17. import {
  18. c,
  19. bold,
  20. dim,
  21. cyan,
  22. green,
  23. yellow,
  24. gray,
  25. magenta,
  26. red,
  27. blue,
  28. brightCyan,
  29. brightWhite,
  30. reset,
  31. box,
  32. hr,
  33. clearLine,
  34. hideCursor,
  35. showCursor,
  36. } from "./ansi.js";
  37. /* ── Version ── */
  38. async function readVersion(): Promise<string> {
  39. try {
  40. const { readFile } = await import("node:fs/promises");
  41. const { join, dirname } = await import("node:path");
  42. const { fileURLToPath } = await import("node:url");
  43. const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
  44. const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
  45. return pkg.version ?? "dev";
  46. } catch {
  47. return "dev";
  48. }
  49. }
  50. /* ── Welcome screen ── */
  51. function printWelcome(version: string, projectName: string, bookTitle?: string): void {
  52. console.log();
  53. console.log(
  54. box([
  55. ` ${c("InkOS", bold, brightCyan)}${c(` v${version}`, dim)}`,
  56. ` ${c("Autonomous Novel Writing AI Agent", dim)}`,
  57. ]),
  58. );
  59. console.log();
  60. const info = [
  61. ` ${c("Project", gray)} ${c(projectName, brightWhite)}`,
  62. ` ${c("Book", gray)} ${bookTitle ? c(bookTitle, brightWhite) : c("none — run /books or create one", dim)}`,
  63. ];
  64. console.log(info.join("\n"));
  65. console.log();
  66. console.log(c(" Type a command or describe what you want to do.", dim));
  67. console.log(c(" /help for commands, /quit to exit.", dim));
  68. console.log();
  69. }
  70. /* ── Help ── */
  71. function printHelp(): void {
  72. const commands = [
  73. ["/write", "Write the next chapter (full pipeline)"],
  74. ["/books", "List all books"],
  75. ["/open <book>", "Select active book"],
  76. ["/mode <auto|semi|manual>", "Switch automation mode"],
  77. ["/focus <text>", "Update current focus for next chapters"],
  78. ["/rewrite <n>", "Rewrite chapter N"],
  79. ["/status", "Show current status"],
  80. ["/help", "Show this help"],
  81. ["/quit", "Exit InkOS TUI"],
  82. ];
  83. console.log();
  84. console.log(c(" Commands", bold, cyan));
  85. console.log();
  86. for (const [cmd, desc] of commands) {
  87. console.log(` ${c(cmd!, green)} ${c(desc!, dim)}`);
  88. }
  89. console.log();
  90. console.log(c(" You can also type in natural language:", dim));
  91. console.log(c(' "继续写" / "写下一章" / "暂停" / "把林烬改成张三"', dim));
  92. console.log();
  93. }
  94. /* ── Spinner ── */
  95. class Spinner {
  96. private interval: ReturnType<typeof setInterval> | undefined;
  97. private frame = 0;
  98. private readonly frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  99. start(message: string): void {
  100. this.frame = 0;
  101. process.stdout.write(hideCursor);
  102. this.interval = setInterval(() => {
  103. const f = this.frames[this.frame % this.frames.length];
  104. process.stdout.write(`${clearLine} ${c(f!, cyan)} ${c(message, dim)}`);
  105. this.frame++;
  106. }, 80);
  107. }
  108. stop(message?: string): void {
  109. if (this.interval) {
  110. clearInterval(this.interval);
  111. this.interval = undefined;
  112. }
  113. process.stdout.write(`${clearLine}${showCursor}`);
  114. if (message) {
  115. console.log(` ${c("✓", green)} ${c(message, dim)}`);
  116. }
  117. }
  118. }
  119. /* ── Process input ── */
  120. async function processInput(
  121. projectRoot: string,
  122. input: string,
  123. tools: InteractionRuntimeTools,
  124. spinner: Spinner,
  125. ): Promise<string | undefined> {
  126. spinner.start("Processing...");
  127. try {
  128. const result = await processProjectInteractionInput({
  129. projectRoot,
  130. input,
  131. tools,
  132. });
  133. const summary = formatTuiResult({
  134. intent: result.request.intent,
  135. status: result.session.currentExecution?.status ?? "completed",
  136. bookId: result.session.activeBookId,
  137. mode: result.request.mode,
  138. responseText: result.responseText,
  139. });
  140. const nextSession = appendInteractionMessage(result.session, {
  141. role: "assistant",
  142. content: summary,
  143. timestamp: Date.now(),
  144. });
  145. await persistProjectSession(projectRoot, nextSession);
  146. spinner.stop();
  147. return summary;
  148. } catch (err) {
  149. spinner.stop();
  150. const msg = err instanceof Error ? err.message : String(err);
  151. return `Error: ${msg}`;
  152. }
  153. }
  154. /* ── Status command ── */
  155. async function printStatus(projectRoot: string): Promise<void> {
  156. try {
  157. const session = await loadProjectSession(projectRoot);
  158. const bookId = await resolveSessionActiveBook(projectRoot, session);
  159. console.log();
  160. console.log(` ${c("Mode", gray)} ${c(session.automationMode, yellow)}`);
  161. console.log(` ${c("Book", gray)} ${bookId ? c(bookId, brightWhite) : c("none", dim)}`);
  162. console.log(` ${c("Status", gray)} ${c(session.currentExecution?.status ?? "idle", green)}`);
  163. if (session.events.length > 0) {
  164. console.log(` ${c("Events", gray)} ${c(String(session.events.length), brightWhite)}`);
  165. const recent = session.events.slice(-3);
  166. for (const ev of recent) {
  167. console.log(` ${c(`${ev.kind}: ${ev.detail ?? ev.status}`, dim)}`);
  168. }
  169. }
  170. console.log();
  171. } catch {
  172. console.log(c(" Could not load session.", dim));
  173. console.log();
  174. }
  175. }
  176. /* ── Legacy exports for tests ── */
  177. export interface TuiFrameState {
  178. readonly projectName: string;
  179. readonly activeBookTitle?: string;
  180. readonly automationMode: string;
  181. readonly status: string;
  182. readonly messages?: ReadonlyArray<string>;
  183. readonly events?: ReadonlyArray<string>;
  184. }
  185. export function renderTuiFrame(state: TuiFrameState): string {
  186. const lines = [
  187. `Project: ${state.projectName}`,
  188. `Book: ${state.activeBookTitle ?? "none"}`,
  189. `Mode: ${state.automationMode}`,
  190. `Stage: ${state.status}`,
  191. "",
  192. "Messages:",
  193. ...(state.messages?.length
  194. ? state.messages.slice(-3).map((message) => `- ${message}`)
  195. : ["- (empty)"]),
  196. "",
  197. "Events:",
  198. ...(state.events?.length
  199. ? state.events.slice(-3).map((event) => `- ${event}`)
  200. : ["- (empty)"]),
  201. "",
  202. "> ",
  203. ];
  204. return lines.join("\n");
  205. }
  206. export async function processTuiInput(
  207. projectRoot: string,
  208. input: string,
  209. tools: InteractionRuntimeTools,
  210. ) {
  211. const result = await processProjectInteractionInput({
  212. projectRoot,
  213. input,
  214. tools,
  215. });
  216. const summary = formatTuiResult({
  217. intent: result.request.intent,
  218. status: result.session.currentExecution?.status ?? "completed",
  219. bookId: result.session.activeBookId,
  220. mode: result.request.mode,
  221. responseText: result.responseText,
  222. });
  223. const nextSession = appendInteractionMessage(result.session, {
  224. role: "assistant",
  225. content: summary,
  226. timestamp: Date.now(),
  227. });
  228. await persistProjectSession(projectRoot, nextSession);
  229. return { ...result, session: nextSession };
  230. }
  231. /* ── Main REPL ── */
  232. export async function launchTui(
  233. projectRoot: string,
  234. toolsOverride?: InteractionRuntimeTools,
  235. ): Promise<void> {
  236. // 1. Auto-setup
  237. const { hasLlmConfig } = await ensureProject(projectRoot);
  238. // 2. LLM config if missing
  239. if (!hasLlmConfig) {
  240. console.log();
  241. console.log(c(" No LLM configuration found.", yellow));
  242. console.log(c(" Let's set up your API provider first.", dim));
  243. await interactiveLlmSetup(projectRoot);
  244. }
  245. // 3. Load session
  246. const session = await loadProjectSession(projectRoot);
  247. const activeBookId = await resolveSessionActiveBook(projectRoot, session);
  248. const version = await readVersion();
  249. // 4. Welcome
  250. printWelcome(version, basename(projectRoot), activeBookId);
  251. // 5. Bail if not interactive
  252. if (!process.stdin.isTTY || !process.stdout.isTTY) {
  253. return;
  254. }
  255. // 6. Build tools
  256. let tools: InteractionRuntimeTools;
  257. try {
  258. tools = toolsOverride ?? (await createInteractionTools(projectRoot));
  259. } catch (err) {
  260. const msg = err instanceof Error ? err.message : String(err);
  261. console.log(c(` Failed to initialize: ${msg}`, red));
  262. console.log(c(" Check your .env or run: inkos config set-global", dim));
  263. console.log();
  264. return;
  265. }
  266. // 7. REPL loop
  267. const rl = readline.createInterface({
  268. input: process.stdin,
  269. output: process.stdout,
  270. prompt: `${c("❯", cyan)} `,
  271. });
  272. const spinner = new Spinner();
  273. const cleanup = () => {
  274. process.stdout.write(showCursor);
  275. rl.close();
  276. };
  277. process.on("SIGINT", () => {
  278. spinner.stop();
  279. console.log();
  280. console.log(c(" Bye!", dim));
  281. cleanup();
  282. process.exit(0);
  283. });
  284. rl.prompt();
  285. for await (const line of rl) {
  286. const input = line.trim();
  287. if (!input) {
  288. rl.prompt();
  289. continue;
  290. }
  291. // Built-in TUI commands
  292. if (/^\/quit$/i.test(input) || /^\/exit$/i.test(input) || /^(quit|exit|bye)$/i.test(input)) {
  293. console.log(c(" Bye!", dim));
  294. break;
  295. }
  296. if (/^\/help$/i.test(input) || /^(help|帮助)$/i.test(input)) {
  297. printHelp();
  298. rl.prompt();
  299. continue;
  300. }
  301. if (/^\/status$/i.test(input) || /^(status|状态)$/i.test(input)) {
  302. await printStatus(projectRoot);
  303. rl.prompt();
  304. continue;
  305. }
  306. if (/^\/clear$/i.test(input)) {
  307. console.clear();
  308. rl.prompt();
  309. continue;
  310. }
  311. // Delegate to interaction layer
  312. const result = await processInput(projectRoot, input, tools, spinner);
  313. if (result) {
  314. console.log();
  315. // Indent each line of result
  316. for (const resultLine of result.split("\n")) {
  317. console.log(` ${resultLine}`);
  318. }
  319. console.log();
  320. }
  321. rl.prompt();
  322. }
  323. cleanup();
  324. }