Pārlūkot izejas kodu

refactor(tui): natural scroll flow, no cursor gymnastics

- Remove cursor lift/settle — content flows top-to-bottom naturally
- Spinner + response appear above next input (like Claude Code)
- Input box: top border + hint drawn before prompt, bottom border
  printed after submit, then response, then new input box
- Simplify InputChrome to drawInputTop/drawInputBottom
Ma 7 mēneši atpakaļ
vecāks
revīzija
c8cdddae85

+ 8 - 21
packages/cli/src/__tests__/tui-input-chrome.test.ts

@@ -1,29 +1,16 @@
 import { describe, expect, it } from "vitest";
-import { buildInputChrome } from "../tui/effects.js";
+import { inputPromptPrefix, drawInputTop, drawInputBottom } from "../tui/effects.js";
 import { stripAnsi } from "../tui/ansi.js";
 
 describe("tui input chrome", () => {
-  it("builds a floating prompt shell with inner padding and bottom gap", () => {
-    const chrome = buildInputChrome(100);
-    const topBorder = stripAnsi(chrome.topBorder);
-    const bottomBorder = stripAnsi(chrome.bottomBorder);
-    const promptPrefix = stripAnsi(chrome.promptPrefix);
-
-    expect(topBorder.startsWith("  ╭")).toBe(true);
-    expect(topBorder.endsWith("╮")).toBe(true);
-    expect(bottomBorder.startsWith("  ╰")).toBe(true);
-    expect(bottomBorder.endsWith("╯")).toBe(true);
-    expect(promptPrefix).toContain("│");
-    expect(promptPrefix).toContain("❯");
-    expect(chrome.outerGapLines).toBeGreaterThan(0);
-    expect(chrome.promptLiftRows).toBeGreaterThan(0);
-    expect(chrome.settleRowsAfterSubmit).toBeGreaterThan(0);
+  it("produces a prompt prefix with the ❯ indicator", () => {
+    const prefix = stripAnsi(inputPromptPrefix());
+    expect(prefix).toContain("❯");
   });
 
-  it("clamps the shell width instead of spanning edge-to-edge", () => {
-    const chrome = buildInputChrome(72);
-
-    expect(chrome.width).toBeLessThan(72);
-    expect(chrome.width).toBeGreaterThanOrEqual(48);
+  it("drawInputTop and drawInputBottom are callable", () => {
+    // Smoke test — these write to stdout, just verify no throw
+    expect(typeof drawInputTop).toBe("function");
+    expect(typeof drawInputBottom).toBe("function");
   });
 });

+ 8 - 9
packages/cli/src/tui/app.ts

@@ -28,8 +28,9 @@ import {
   printStyledHelp,
   printStyledStatus,
   printInputSeparator,
-  drawInputArea,
-  buildInputChrome,
+  inputPromptPrefix,
+  drawInputTop,
+  drawInputBottom,
 } from "./effects.js";
 
 /* ── Version ── */
@@ -234,10 +235,11 @@ export async function launchTui(
   };
 
   // 9. REPL loop
+  const prompt = inputPromptPrefix();
   const rl = readline.createInterface({
     input: process.stdin,
     output: process.stdout,
-    prompt: buildInputChrome().promptPrefix,
+    prompt,
     completer,
   });
 
@@ -249,15 +251,13 @@ export async function launchTui(
   };
 
   const promptInput = () => {
-    const chrome = buildInputChrome();
-    rl.setPrompt(chrome.promptPrefix);
-    drawInputArea();
-    process.stdout.write(`\x1b[${chrome.promptLiftRows}A\r`);
+    drawInputTop();
     rl.prompt();
   };
 
   process.on("SIGINT", () => {
     console.log();
+    drawInputBottom();
     console.log(c("  ◇ goodbye", dim));
     console.log();
     cleanup();
@@ -267,9 +267,8 @@ export async function launchTui(
   promptInput();
 
   for await (const line of rl) {
+    drawInputBottom();
     const input = line.trim();
-    const chrome = buildInputChrome();
-    process.stdout.write(`\x1b[${chrome.settleRowsAfterSubmit}B\r`);
 
     if (!input) {
       promptInput();

+ 15 - 39
packages/cli/src/tui/effects.ts

@@ -163,49 +163,25 @@ const ASCII_LOGO = [
 
 /* ── Input area ── */
 
-export interface InputChrome {
-  readonly width: number;
-  readonly topBorder: string;
-  readonly helperLine: string;
-  readonly promptLine: string;
-  readonly bottomBorder: string;
-  readonly promptPrefix: string;
-  readonly outerGapLines: number;
-  readonly promptLiftRows: number;
-  readonly settleRowsAfterSubmit: number;
-}
+/* ── Input chrome ── */
 
-export function buildInputChrome(columns = process.stdout.columns ?? 80): InputChrome {
-  const width = Math.max(48, Math.min(columns - 6, 84));
-  const innerWidth = Math.max(24, width - 4);
-  const helperText = stripAnsi(c("Ask InkOS to write, revise, or explain…", dim));
-  const helper = helperText.length > innerWidth
-    ? helperText.slice(0, innerWidth)
-    : helperText.padEnd(innerWidth, " ");
-
-  return {
-    width,
-    topBorder: `  ${c(`╭${"─".repeat(width - 2)}╮`, gray)}`,
-    helperLine: `  ${c("│", gray)} ${c(helper, dim)} ${c("│", gray)}`,
-    promptLine: `  ${c("│", gray)} ${" ".repeat(innerWidth)} ${c("│", gray)}`,
-    bottomBorder: `  ${c(`╰${"─".repeat(width - 2)}╯`, gray)}`,
-    promptPrefix: `  ${c("│", gray)} ${c("❯", gray)} `,
-    outerGapLines: 1,
-    promptLiftRows: 3,
-    settleRowsAfterSubmit: 2,
-  };
+export function inputPromptPrefix(): string {
+  return `  ${c("❯", gray)} `;
 }
 
-export function drawInputArea(): void {
-  const chrome = buildInputChrome();
-  for (let i = 0; i < chrome.outerGapLines; i += 1) {
-    console.log();
-  }
-  console.log(chrome.topBorder);
-  console.log(chrome.helperLine);
-  console.log(chrome.promptLine);
-  console.log(chrome.bottomBorder);
+export function drawInputTop(): void {
+  const w = Math.max(48, Math.min((process.stdout.columns ?? 80) - 6, 84));
   console.log();
+  console.log(`  ${c(`╭${"─".repeat(w - 2)}╮`, gray)}`);
+  const hint = "Ask InkOS to write, revise, or explain…";
+  const inner = w - 4;
+  const padded = hint.length > inner ? hint.slice(0, inner) : hint.padEnd(inner);
+  console.log(`  ${c("│", gray)} ${c(padded, dim)} ${c("│", gray)}`);
+}
+
+export function drawInputBottom(): void {
+  const w = Math.max(48, Math.min((process.stdout.columns ?? 80) - 6, 84));
+  console.log(`  ${c(`╰${"─".repeat(w - 2)}╯`, gray)}`);
 }
 
 export function printInputSeparator(): void {