Просмотр исходного кода

feat(tui): float input chrome above terminal edge

Ma 7 месяцев назад
Родитель
Сommit
8018ac74ed

+ 89 - 0
docs/plans/2026-04-09-tui-floating-input-implementation.md

@@ -0,0 +1,89 @@
+# TUI Floating Input Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Make the TUI input area feel closer to Codex by giving it a floating input-shell look with clearer bottom spacing.
+
+**Architecture:** Keep the existing REPL loop and natural-language flow intact. Only reshape the prompt chrome: extract a small render helper in the TUI effects layer, use it from the app loop, and lock the visual contract with a focused CLI test.
+
+**Tech Stack:** TypeScript, Node readline, Vitest
+
+---
+
+### Task 1: Add a failing test for floating input chrome
+
+**Files:**
+- Create: `packages/cli/src/__tests__/tui-input-chrome.test.ts`
+- Reference: `packages/cli/src/tui/effects.ts`
+
+**Step 1: Write the failing test**
+
+Add tests that expect:
+- a bounded/floating top border line
+- a prompt prefix that includes inner padding
+- bottom spacing metadata greater than zero
+
+**Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts`
+
+Expected: FAIL because the helper does not exist yet.
+
+**Step 3: Write minimal implementation**
+
+Add a small helper in `effects.ts` that builds the input chrome description from terminal width.
+
+**Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts`
+
+Expected: PASS
+
+### Task 2: Wire the floating chrome into the TUI prompt loop
+
+**Files:**
+- Modify: `packages/cli/src/tui/app.ts`
+- Modify: `packages/cli/src/tui/effects.ts`
+
+**Step 1: Write the failing test**
+
+Extend the input-chrome test (or add a small app-facing assertion) so the exported prompt prefix and spacing are stable enough to drive the REPL loop.
+
+**Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts src/__tests__/tui-layout.test.ts`
+
+Expected: FAIL until the app loop uses the new chrome contract.
+
+**Step 3: Write minimal implementation**
+
+Update `app.ts` so:
+- the prompt string comes from the chrome helper
+- the input area prints a floating top border and helper line
+- the prompt is not visually glued to the terminal bottom
+
+**Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-input-chrome.test.ts src/__tests__/tui-layout.test.ts`
+
+Expected: PASS
+
+### Task 3: Verify CLI package build
+
+**Files:**
+- Modify: `packages/cli/src/tui/app.ts`
+- Modify: `packages/cli/src/tui/effects.ts`
+- Test: `packages/cli/src/__tests__/tui-input-chrome.test.ts`
+
+**Step 1: Run package build**
+
+Run: `pnpm --dir packages/cli run build`
+
+Expected: PASS
+
+**Step 2: Commit**
+
+```bash
+git add docs/plans/2026-04-09-tui-floating-input-implementation.md packages/cli/src/tui/app.ts packages/cli/src/tui/effects.ts packages/cli/src/__tests__/tui-input-chrome.test.ts
+git commit -m "feat(tui): float input chrome above terminal edge"
+```

+ 27 - 0
packages/cli/src/__tests__/tui-input-chrome.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { buildInputChrome } 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);
+  });
+
+  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);
+  });
+});

+ 7 - 2
packages/cli/src/tui/app.ts

@@ -29,6 +29,7 @@ import {
   printStyledStatus,
   printInputSeparator,
   drawInputArea,
+  buildInputChrome,
 } from "./effects.js";
 
 /* ── Version ── */
@@ -222,7 +223,7 @@ export async function launchTui(
   const rl = readline.createInterface({
     input: process.stdin,
     output: process.stdout,
-    prompt: `  ${c("❯", gray)} `,
+    prompt: buildInputChrome().promptPrefix,
   });
 
   const cleanup = () => {
@@ -233,8 +234,9 @@ export async function launchTui(
   };
 
   const promptInput = () => {
+    const chrome = buildInputChrome();
+    rl.setPrompt(chrome.promptPrefix);
     drawInputArea();
-    console.log();
     rl.prompt();
   };
 
@@ -250,12 +252,15 @@ export async function launchTui(
 
   for await (const line of rl) {
     const input = line.trim();
+    const chrome = buildInputChrome();
 
     if (!input) {
+      console.log(chrome.bottomBorder);
       promptInput();
       continue;
     }
 
+    console.log(chrome.bottomBorder);
     console.log();
 
     // Built-in TUI commands

+ 33 - 4
packages/cli/src/tui/effects.ts

@@ -163,11 +163,40 @@ const ASCII_LOGO = [
 
 /* ── Input area ── */
 
+export interface InputChrome {
+  readonly width: number;
+  readonly topBorder: string;
+  readonly helperLine: string;
+  readonly bottomBorder: string;
+  readonly promptPrefix: string;
+  readonly outerGapLines: number;
+}
+
+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)}`,
+    bottomBorder: `  ${c(`╰${"─".repeat(width - 2)}╯`, gray)}`,
+    promptPrefix: `  ${c("│", gray)} ${c("❯", gray)} `,
+    outerGapLines: 1,
+  };
+}
+
 export function drawInputArea(): void {
-  const w = process.stdout.columns ?? 80;
-  console.log();
-  console.log();
-  console.log(c("─".repeat(w), gray));
+  const chrome = buildInputChrome();
+  for (let i = 0; i < chrome.outerGapLines; i += 1) {
+    console.log();
+  }
+  console.log(chrome.topBorder);
+  console.log(chrome.helperLine);
 }
 
 export function printInputSeparator(): void {