effects.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. /* ── TUI animation effects ── */
  2. import {
  3. c, bold, dim, italic,
  4. cyan, green, yellow, blue, magenta, red, gray, white,
  5. brightCyan, brightGreen, brightYellow, brightBlue, brightMagenta, brightWhite,
  6. bgCyan, bgBlue, bgMagenta, bgGreen, bgYellow, bgRed, bgGray,
  7. clearLine, hideCursor, showCursor, reset,
  8. badge, sleep, stripAnsi, box,
  9. } from "./ansi.js";
  10. /* ── Operation themes ── */
  11. export interface OperationTheme {
  12. readonly icon: string;
  13. readonly color: string;
  14. readonly brightColor: string;
  15. readonly bg: string;
  16. readonly label: string;
  17. readonly frames: ReadonlyArray<string>;
  18. }
  19. const WAVE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  20. const PULSE_FRAMES = ["◜", "◠", "◝", "◞", "◡", "◟"];
  21. const DOTS_FRAMES = ["· ", "·· ", "···", " ··", " ·", " "];
  22. const SCAN_FRAMES = ["▱▱▱▱▱", "▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▰▰▰▰▱", "▰▰▰▰▰", "▱▰▰▰▰", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰"];
  23. const WRITE_FRAMES = ["✎", "✎·", "✎··", "✎···", "✎····", "✎···", "✎··", "✎·"];
  24. export const THEMES: Record<string, OperationTheme> = {
  25. thinking: {
  26. icon: "◇",
  27. color: cyan,
  28. brightColor: brightCyan,
  29. bg: bgCyan,
  30. label: "thinking",
  31. frames: DOTS_FRAMES,
  32. },
  33. writing: {
  34. icon: "✎",
  35. color: magenta,
  36. brightColor: brightMagenta,
  37. bg: bgMagenta,
  38. label: "writing",
  39. frames: WRITE_FRAMES,
  40. },
  41. auditing: {
  42. icon: "◉",
  43. color: yellow,
  44. brightColor: brightYellow,
  45. bg: bgYellow,
  46. label: "auditing",
  47. frames: SCAN_FRAMES,
  48. },
  49. revising: {
  50. icon: "✂",
  51. color: blue,
  52. brightColor: brightBlue,
  53. bg: bgBlue,
  54. label: "revising",
  55. frames: WAVE_FRAMES,
  56. },
  57. planning: {
  58. icon: "◈",
  59. color: cyan,
  60. brightColor: brightCyan,
  61. bg: bgCyan,
  62. label: "planning",
  63. frames: PULSE_FRAMES,
  64. },
  65. composing: {
  66. icon: "❖",
  67. color: green,
  68. brightColor: brightGreen,
  69. bg: bgGreen,
  70. label: "composing",
  71. frames: PULSE_FRAMES,
  72. },
  73. loading: {
  74. icon: "◌",
  75. color: gray,
  76. brightColor: white,
  77. bg: bgGray,
  78. label: "loading",
  79. frames: WAVE_FRAMES,
  80. },
  81. };
  82. /* ── Animated spinner with themed operations ── */
  83. export class ThemedSpinner {
  84. private interval: ReturnType<typeof setInterval> | undefined;
  85. private frame = 0;
  86. private elapsed = 0;
  87. private theme: OperationTheme;
  88. constructor(themeName = "thinking") {
  89. this.theme = THEMES[themeName] ?? THEMES["thinking"]!;
  90. }
  91. start(label?: string): void {
  92. const displayLabel = label ?? this.theme.label;
  93. this.frame = 0;
  94. this.elapsed = 0;
  95. process.stdout.write(hideCursor);
  96. this.interval = setInterval(() => {
  97. this.elapsed += 120;
  98. const f = this.theme.frames[this.frame % this.theme.frames.length]!;
  99. const icon = c(this.theme.icon, this.theme.color);
  100. const anim = c(f, this.theme.brightColor);
  101. const text = c(displayLabel, dim);
  102. const time = this.elapsed >= 3000
  103. ? c(` ${formatElapsed(this.elapsed)}`, gray)
  104. : "";
  105. process.stdout.write(`${clearLine} ${icon} ${text} ${anim}${time}`);
  106. this.frame++;
  107. }, 120);
  108. }
  109. update(label: string): void {
  110. if (!this.interval) return;
  111. this.stop();
  112. this.start(label);
  113. }
  114. succeed(message?: string): void {
  115. this.clear();
  116. if (message) {
  117. console.log(` ${c("✓", brightGreen, bold)} ${message}`);
  118. }
  119. }
  120. fail(message?: string): void {
  121. this.clear();
  122. if (message) {
  123. console.log(` ${c("✗", red, bold)} ${message}`);
  124. }
  125. }
  126. stop(): void {
  127. this.clear();
  128. }
  129. private clear(): void {
  130. if (this.interval) {
  131. clearInterval(this.interval);
  132. this.interval = undefined;
  133. }
  134. process.stdout.write(`${clearLine}${showCursor}`);
  135. }
  136. }
  137. /* ── ASCII Art Logo ── */
  138. const ASCII_LOGO = [
  139. " ██╗███╗ ██╗██╗ ██╗ ██████╗ ███████╗",
  140. " ██║████╗ ██║██║ ██╔╝██╔═══██╗██╔════╝",
  141. " ██║██╔██╗ ██║█████╔╝ ██║ ██║███████╗",
  142. " ██║██║╚██╗██║██╔═██╗ ██║ ██║╚════██║",
  143. " ██║██║ ╚████║██║ ██╗╚██████╔╝███████║",
  144. " ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
  145. ];
  146. /* ── Input area ── */
  147. export interface InputChrome {
  148. readonly width: number;
  149. readonly topBorder: string;
  150. readonly helperLine: string;
  151. readonly promptLine: string;
  152. readonly bottomBorder: string;
  153. readonly promptPrefix: string;
  154. readonly outerGapLines: number;
  155. readonly promptLiftRows: number;
  156. readonly settleRowsAfterSubmit: number;
  157. }
  158. export function buildInputChrome(columns = process.stdout.columns ?? 80): InputChrome {
  159. const width = Math.max(48, Math.min(columns - 6, 84));
  160. const innerWidth = Math.max(24, width - 4);
  161. const helperText = stripAnsi(c("Ask InkOS to write, revise, or explain…", dim));
  162. const helper = helperText.length > innerWidth
  163. ? helperText.slice(0, innerWidth)
  164. : helperText.padEnd(innerWidth, " ");
  165. return {
  166. width,
  167. topBorder: ` ${c(`╭${"─".repeat(width - 2)}╮`, gray)}`,
  168. helperLine: ` ${c("│", gray)} ${c(helper, dim)} ${c("│", gray)}`,
  169. promptLine: ` ${c("│", gray)} ${" ".repeat(innerWidth)} ${c("│", gray)}`,
  170. bottomBorder: ` ${c(`╰${"─".repeat(width - 2)}╯`, gray)}`,
  171. promptPrefix: ` ${c("│", gray)} ${c("❯", gray)} `,
  172. outerGapLines: 1,
  173. promptLiftRows: 3,
  174. settleRowsAfterSubmit: 2,
  175. };
  176. }
  177. export function drawInputArea(): void {
  178. const chrome = buildInputChrome();
  179. for (let i = 0; i < chrome.outerGapLines; i += 1) {
  180. console.log();
  181. }
  182. console.log(chrome.topBorder);
  183. console.log(chrome.helperLine);
  184. console.log(chrome.promptLine);
  185. console.log(chrome.bottomBorder);
  186. console.log();
  187. }
  188. export function printInputSeparator(): void {
  189. const w = Math.min(process.stdout.columns ?? 60, 60);
  190. console.log(c(" " + "─".repeat(w - 4), gray));
  191. }
  192. /* ── Startup animation ── */
  193. export interface StartupModelInfo {
  194. readonly provider: string;
  195. readonly model: string;
  196. }
  197. export async function animateStartup(version: string, projectName: string, bookTitle?: string, modelInfo?: StartupModelInfo): Promise<void> {
  198. const isTTY = process.stdout.isTTY;
  199. if (isTTY) {
  200. console.log();
  201. process.stdout.write(hideCursor);
  202. // Animate ASCII logo line by line
  203. for (let i = 0; i < ASCII_LOGO.length; i++) {
  204. const line = ASCII_LOGO[i]!;
  205. // Gradient: top lines brighter, bottom lines dimmer
  206. const shade = i < 2 ? brightCyan : i < 4 ? cyan : dim + cyan;
  207. process.stdout.write(` ${c(line, shade)}\n`);
  208. await sleep(40);
  209. }
  210. // Version + tagline below logo
  211. console.log(c(` v${version}`, dim));
  212. await sleep(150);
  213. process.stdout.write(showCursor);
  214. } else {
  215. console.log();
  216. for (const line of ASCII_LOGO) {
  217. console.log(` ${c(line, brightCyan)}`);
  218. }
  219. console.log(c(` v${version}`, dim));
  220. }
  221. // Project info
  222. console.log();
  223. const bookDisplay = bookTitle
  224. ? c(bookTitle, brightWhite)
  225. : c("no book yet", dim);
  226. const modelDisplay = modelInfo
  227. ? `${c(modelInfo.model, brightWhite)} ${c(`(${modelInfo.provider})`, dim)}`
  228. : c("not configured — type /config to set up", yellow);
  229. if (isTTY) {
  230. await typewrite(` ${c("◇", cyan)} ${c("Project", gray)} ${c(projectName, brightWhite)}`, 8);
  231. await sleep(60);
  232. await typewrite(` ${c("◇", cyan)} ${c("Book", gray)} ${bookDisplay}`, 8);
  233. await sleep(60);
  234. await typewrite(` ${c("◇", cyan)} ${c("Model", gray)} ${modelDisplay}`, 8);
  235. await sleep(60);
  236. } else {
  237. console.log(` ${c("◇", cyan)} ${c("Project", gray)} ${c(projectName, brightWhite)}`);
  238. console.log(` ${c("◇", cyan)} ${c("Book", gray)} ${bookDisplay}`);
  239. console.log(` ${c("◇", cyan)} ${c("Model", gray)} ${modelDisplay}`);
  240. }
  241. console.log();
  242. console.log(c(" /help for commands · Tab to autocomplete", dim));
  243. console.log();
  244. }
  245. /* ── Typewriter effect ── */
  246. async function typewrite(text: string, charDelay = 12): Promise<void> {
  247. const chars = text.split("");
  248. let i = 0;
  249. let insideEscape = false;
  250. for (const ch of chars) {
  251. process.stdout.write(ch);
  252. if (ch === "\x1b") insideEscape = true;
  253. if (insideEscape) {
  254. if (ch === "m") insideEscape = false;
  255. continue;
  256. }
  257. i++;
  258. if (i % 2 === 0) await sleep(charDelay);
  259. }
  260. process.stdout.write("\n");
  261. }
  262. /* ── Result display ── */
  263. export function formatResultCard(content: string, intent?: string): string {
  264. const lines: string[] = [];
  265. if (intent) {
  266. const intentBadge = intentToBadge(intent);
  267. lines.push(` ${intentBadge}`);
  268. lines.push("");
  269. }
  270. for (const line of content.split("\n")) {
  271. lines.push(` ${line}`);
  272. }
  273. return lines.join("\n");
  274. }
  275. function intentToBadge(intent: string): string {
  276. const badges: Record<string, [string, string]> = {
  277. write_next: [" WRITE ", bgMagenta],
  278. revise_chapter: [" REVISE ", bgBlue],
  279. rewrite_chapter: [" REWRITE ", bgBlue],
  280. update_focus: [" FOCUS ", bgCyan],
  281. explain_status: [" STATUS ", bgGray],
  282. explain_failure: [" DEBUG ", bgRed],
  283. pause_book: [" PAUSE ", bgYellow],
  284. list_books: [" BOOKS ", bgGray],
  285. select_book: [" SELECT ", bgGreen],
  286. switch_mode: [" MODE ", bgCyan],
  287. rename_entity: [" RENAME ", bgYellow],
  288. patch_chapter_text: [" PATCH ", bgBlue],
  289. edit_truth: [" TRUTH ", bgGreen],
  290. };
  291. const [label, bg] = badges[intent] ?? [` ${intent.toUpperCase()} `, bgGray];
  292. return badge(label!, bg!);
  293. }
  294. /* ── Intent to spinner theme ── */
  295. export function intentToTheme(intent: string): string {
  296. const map: Record<string, string> = {
  297. write_next: "writing",
  298. revise_chapter: "revising",
  299. rewrite_chapter: "revising",
  300. update_focus: "composing",
  301. explain_status: "loading",
  302. explain_failure: "thinking",
  303. pause_book: "loading",
  304. list_books: "loading",
  305. select_book: "loading",
  306. switch_mode: "loading",
  307. rename_entity: "composing",
  308. patch_chapter_text: "revising",
  309. edit_truth: "composing",
  310. };
  311. return map[intent] ?? "thinking";
  312. }
  313. /* ── Help display ── */
  314. export function printStyledHelp(): void {
  315. const sections = [
  316. {
  317. title: "Writing",
  318. commands: [
  319. ["/write", "Write the next chapter (full pipeline)"],
  320. ["/rewrite <n>", "Rewrite chapter N from scratch"],
  321. ],
  322. },
  323. {
  324. title: "Navigation",
  325. commands: [
  326. ["/books", "List all books"],
  327. ["/open <book>", "Select active book"],
  328. ["/status", "Show current status"],
  329. ],
  330. },
  331. {
  332. title: "Control",
  333. commands: [
  334. ["/mode <auto|semi|manual>", "Switch automation mode"],
  335. ["/focus <text>", "Update current focus"],
  336. ],
  337. },
  338. {
  339. title: "Session",
  340. commands: [
  341. ["/clear", "Clear screen"],
  342. ["/help", "Show this help"],
  343. ["/quit", "Exit InkOS TUI"],
  344. ],
  345. },
  346. ];
  347. console.log();
  348. for (const section of sections) {
  349. console.log(` ${c(section.title, bold, cyan)}`);
  350. for (const [cmd, desc] of section.commands) {
  351. const cmdStr = c(cmd!, green);
  352. const descStr = c(desc!, dim);
  353. const padding = " ".repeat(Math.max(1, 24 - stripAnsi(cmd!).length));
  354. console.log(` ${cmdStr}${padding}${descStr}`);
  355. }
  356. console.log();
  357. }
  358. console.log(c(" Natural language also works:", dim));
  359. console.log(c(' "继续写" "写下一章" "暂停" "把林烬改成张三"', dim, italic));
  360. console.log();
  361. }
  362. /* ── Status display ── */
  363. export function printStyledStatus(params: {
  364. readonly mode: string;
  365. readonly bookId?: string;
  366. readonly status: string;
  367. readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
  368. }): void {
  369. const modeColors: Record<string, string> = {
  370. auto: green,
  371. semi: yellow,
  372. manual: blue,
  373. };
  374. const modeColor = modeColors[params.mode] ?? gray;
  375. const statusColors: Record<string, string> = {
  376. idle: gray,
  377. running: cyan,
  378. writing: magenta,
  379. auditing: yellow,
  380. completed: green,
  381. failed: red,
  382. waiting_human: brightYellow,
  383. };
  384. const statusColor = statusColors[params.status] ?? gray;
  385. console.log();
  386. console.log(` ${c("◇", cyan)} ${c("Mode", gray)} ${c(params.mode, modeColor, bold)}`);
  387. console.log(` ${c("◇", cyan)} ${c("Book", gray)} ${params.bookId ? c(params.bookId, brightWhite) : c("none", dim)}`);
  388. console.log(` ${c("◇", cyan)} ${c("Status", gray)} ${c(params.status, statusColor)}`);
  389. if (params.events.length > 0) {
  390. console.log(` ${c("◇", cyan)} ${c("Recent", gray)}`);
  391. for (const ev of params.events.slice(-3)) {
  392. const icon = ev.status === "completed" ? c("✓", green) : c("·", gray);
  393. console.log(` ${icon} ${c(`${ev.kind}`, dim)} ${c(ev.detail ?? "", gray)}`);
  394. }
  395. }
  396. console.log();
  397. }
  398. /* ── Utilities ── */
  399. function formatElapsed(ms: number): string {
  400. const s = Math.floor(ms / 1000);
  401. if (s < 60) return `${s}s`;
  402. const m = Math.floor(s / 60);
  403. return `${m}m${s % 60}s`;
  404. }