diff --git a/src/tui/clipboard/clipboard-context.tsx b/src/tui/clipboard/clipboard-context.tsx new file mode 100644 index 00000000..2bcf83b0 --- /dev/null +++ b/src/tui/clipboard/clipboard-context.tsx @@ -0,0 +1,59 @@ +/** + * React access to the clipboard writer. + * + * Shaped like `mouse-context.tsx` and for the same reason: the chat + * bubbles are presentational and prop-drilling a writer down through + * `ChatLog` → `FinalisedMessage` → every bubble would be a bigger change + * than the feature earns. + * + * Unlike the mouse context there is a **default** when no provider is + * mounted, because a copy button with no clipboard is not a degraded + * button, it is a broken one. The default is created lazily and shared, + * so the common case — the real TUI, which mounts no provider — needs no + * wiring at all. `createClipboardWriter` refuses to act on a non-TTY + * stdout, which is what keeps that default from touching a real human's + * clipboard when a component test happens to render a copy button. + * + * Tests that want to *observe* a copy mount `ClipboardProvider` with a + * fake and get an exact record of what was copied. + */ +import { createContext, useContext, type ReactElement, type ReactNode } from "react"; +import { + createClipboardWriter, + type ClipboardWriter, +} from "./copy-to-clipboard.js"; + +const ClipboardContext = createContext(null); + +let defaultWriter: ClipboardWriter | null = null; + +/** + * The process-wide writer used when no provider is mounted. Lazy so that + * merely importing a chat component does not read `process.platform` or + * capture a `process.stdout` that a harness may still replace. + */ +export function getDefaultClipboardWriter(): ClipboardWriter { + defaultWriter ??= createClipboardWriter(); + return defaultWriter; +} + +export interface ClipboardProviderProps { + readonly writer: ClipboardWriter; + readonly children: ReactNode; +} + +export function ClipboardProvider({ + writer, + children, +}: ClipboardProviderProps): ReactElement { + return ( + + {children} + + ); +} + +/** The active clipboard writer — the provider's, or the shared default. */ +export function useClipboard(): ClipboardWriter { + return useContext(ClipboardContext) ?? getDefaultClipboardWriter(); +} diff --git a/src/tui/clipboard/copy-to-clipboard.test.ts b/src/tui/clipboard/copy-to-clipboard.test.ts new file mode 100644 index 00000000..1c5fa595 --- /dev/null +++ b/src/tui/clipboard/copy-to-clipboard.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { + createClipboardWriter, + createNullClipboardWriter, + fitsInOsc52, + osc52Sequence, + platformClipboardCommand, + OSC52_MAX_BASE64_CHARS, + type ClipboardCommandRunner, +} from "./copy-to-clipboard.js"; + +interface FakeStdout { + isTTY: boolean; + writes: string[]; + write(chunk: string): boolean; +} + +function makeStdout(isTty: boolean): FakeStdout { + const writes: string[] = []; + return { + isTTY: isTty, + writes, + write(chunk: string): boolean { + writes.push(chunk); + return true; + }, + }; +} + +interface RunLog { + runner: ClipboardCommandRunner; + calls: Array<{ command: string; args: readonly string[]; text: string }>; +} + +function makeRunner(result: boolean): RunLog { + const calls: RunLog["calls"] = []; + return { + calls, + runner: async (command, args, text) => { + calls.push({ command, args, text }); + return result; + }, + }; +} + +describe("osc52Sequence", () => { + it("wraps base64 in the OSC 52 clipboard sequence", () => { + expect(osc52Sequence("hi")).toBe("\u001B]52;c;aGk=\u0007"); + }); + + it("encodes non-ASCII as UTF-8 bytes, not UTF-16 units", () => { + // A terminal decodes the payload as bytes; encoding "é" as its + // UTF-16 code unit would paste a replacement character. + expect(osc52Sequence("é")).toBe( + `\u001B]52;c;${Buffer.from("é", "utf8").toString("base64")}\u0007`, + ); + }); + + it("carries newlines through untouched", () => { + const decoded = Buffer.from( + osc52Sequence("a\nb").slice("\u001B]52;c;".length, -1), + "base64", + ).toString("utf8"); + expect(decoded).toBe("a\nb"); + }); +}); + +describe("fitsInOsc52", () => { + it("accepts an ordinary chat message", () => { + expect(fitsInOsc52("a normal reply")).toBe(true); + }); + + it("rejects a payload past the terminal-safe ceiling", () => { + const tooBig = "x".repeat(OSC52_MAX_BASE64_CHARS); + expect(fitsInOsc52(tooBig)).toBe(false); + }); +}); + +describe("platformClipboardCommand", () => { + it("uses pbcopy on macOS", () => { + expect(platformClipboardCommand("darwin", {})).toEqual({ + command: "pbcopy", + args: [], + }); + }); + + it("uses clip on Windows", () => { + expect(platformClipboardCommand("win32", {})?.command).toBe("clip"); + }); + + it("prefers wl-copy over xclip when both sessions advertise themselves", () => { + const command = platformClipboardCommand("linux", { + WAYLAND_DISPLAY: "wayland-0", + DISPLAY: ":0", + }); + expect(command?.command).toBe("wl-copy"); + }); + + it("falls back to xclip under X11", () => { + expect(platformClipboardCommand("linux", { DISPLAY: ":0" })).toEqual({ + command: "xclip", + args: ["-selection", "clipboard"], + }); + }); + + it("has nothing to offer on a headless box", () => { + // Not a failure: OSC 52 is the correct — and only — route back to + // the clipboard of whoever is on the other end of the ssh pipe. + expect(platformClipboardCommand("linux", {})).toBeNull(); + }); +}); + +describe("createClipboardWriter", () => { + it("emits OSC 52 and runs the platform command for one copy", () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + return writer.copy("hello").then((ok) => { + expect(ok).toBe(true); + expect(stdout.writes).toEqual([osc52Sequence("hello")]); + expect(run.calls).toEqual([ + { command: "pbcopy", args: [], text: "hello" }, + ]); + }); + }); + + it("still reports success when the platform command fails but OSC 52 went out", () => { + // The SSH case: pbcopy would target the wrong machine anyway, and a + // terminal that honoured OSC 52 has the text. + const stdout = makeStdout(true); + const run = makeRunner(false); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + return writer.copy("hello").then((ok) => expect(ok).toBe(true)); + }); + + it("reports success from the platform command alone when the payload is too big for OSC 52", async () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + const huge = "x".repeat(OSC52_MAX_BASE64_CHARS); + expect(await writer.copy(huge)).toBe(true); + expect(stdout.writes).toEqual([]); + expect(run.calls[0]?.text).toBe(huge); + }); + + it("reports failure when there is no platform command and the payload is too big", async () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "linux", + env: {}, + }); + expect(await writer.copy("x".repeat(OSC52_MAX_BASE64_CHARS))).toBe(false); + expect(run.calls).toEqual([]); + }); + + it("does nothing at all when stdout is not a TTY", async () => { + // This guard is what keeps `npx vitest` from overwriting the + // clipboard of whoever is running the suite. + const stdout = makeStdout(false); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + expect(await writer.copy("hello")).toBe(false); + expect(stdout.writes).toEqual([]); + expect(run.calls).toEqual([]); + }); + + it("falls back to the platform command when the stdout write throws", async () => { + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout: { + isTTY: true, + write: () => { + throw new Error("EIO"); + }, + }, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + expect(await writer.copy("hello")).toBe(true); + expect(run.calls).toHaveLength(1); + }); +}); + +describe("createNullClipboardWriter", () => { + it("always reports failure", async () => { + expect(await createNullClipboardWriter().copy("hello")).toBe(false); + }); +}); diff --git a/src/tui/clipboard/copy-to-clipboard.ts b/src/tui/clipboard/copy-to-clipboard.ts new file mode 100644 index 00000000..fe2fdd62 --- /dev/null +++ b/src/tui/clipboard/copy-to-clipboard.ts @@ -0,0 +1,211 @@ +/** + * Writing to the *user's* clipboard from a TUI. + * + * There is no single mechanism that works everywhere, and the two that + * exist fail in exactly opposite situations — so this module runs both + * and reports success if either one landed. + * + * - **OSC 52** (`ESC ] 52 ; c ; BEL`) asks the terminal + * emulator itself to set the clipboard. It is the only mechanism + * that survives SSH: the bytes travel back up the same pty the + * frames come down, so the text lands on the machine the human is + * sitting at rather than on the box the agent happens to run on. + * Its weakness is that it is advisory — the terminal may ignore it + * (Apple Terminal does), gate it behind a preference (iTerm2's + * "Applications in terminal may access clipboard"), or swallow it + * in a multiplexer (tmux needs `set -g set-clipboard on`; GNU screen + * needs DCS wrapping we do not emit). Crucially, **there is no + * reply**: a terminal that ignores 52 is indistinguishable from one + * that honoured it, so we can never report "OSC 52 worked". + * - **The platform clipboard command** (`pbcopy`, `wl-copy`, `xclip`, + * `clip.exe`) is authoritative — it either exits 0 or it does not — + * but it writes to the clipboard of the machine the *process* runs + * on, which is the wrong machine over SSH, and it does not exist at + * all on a headless box. + * + * Doing both is not belt-and-braces sloppiness; it is the only way to + * cover Apple Terminal (native only) and a remote session (OSC 52 only) + * with one code path. Writing the same string twice is harmless: the + * clipboard ends up holding that string either way. + * + * Safety of interleaving OSC 52 with Ink's frames: the sequence moves no + * cursor, sets no mode, and paints no cell, so a terminal that + * understands it consumes it invisibly wherever it lands between Ink's + * writes, and one that does not silently drops an unknown OSC. That is + * why it can be written straight to the same stdout Ink is rendering to + * without coordinating with the renderer or leaving the alt screen. + * + * Everything the writer touches — stdout, process spawning, platform, + * env — is injected, so tests exercise the real decision logic without + * going anywhere near the developer's actual clipboard. + */ +import { spawn } from "node:child_process"; + +export interface ClipboardWriter { + /** + * Copies `text`. Resolves `true` when at least one mechanism is + * believed to have worked — see {@link createClipboardWriter} for what + * "believed" can and cannot mean. + */ + copy(text: string): Promise; +} + +/** Minimal shape of the stream OSC 52 is written to. */ +export interface ClipboardStdout { + write(chunk: string): unknown; + readonly isTTY?: boolean; +} + +/** Runs a clipboard command with `text` on stdin; resolves `true` on exit 0. */ +export type ClipboardCommandRunner = ( + command: string, + args: readonly string[], + text: string, +) => Promise; + +export interface ClipboardWriterOptions { + readonly stdout?: ClipboardStdout; + readonly runCommand?: ClipboardCommandRunner; + readonly platform?: NodeJS.Platform; + readonly env?: Readonly>; +} + +export interface ClipboardCommand { + readonly command: string; + readonly args: readonly string[]; +} + +/** + * Terminals differ on how much base64 they will accept in one OSC 52, + * and the ones that dislike a long payload tend to drop it *silently* + * rather than truncate — which would leave the user with a stale + * clipboard and a cheerful "copied!". Past this size we skip OSC 52 and + * let the platform command carry the copy alone; a paste that big is + * overwhelmingly a local one anyway. + */ +export const OSC52_MAX_BASE64_CHARS = 100_000; + +/** BEL terminator: accepted everywhere `ESC \` is, and by a few terminals that mis-parse ST. */ +const BEL = "\u0007"; + +/** The OSC 52 sequence that sets the system clipboard (`c`) to `text`. */ +export function osc52Sequence(text: string): string { + const payload = Buffer.from(text, "utf8").toString("base64"); + return `\u001B]52;c;${payload}${BEL}`; +} + +/** `true` when `text` is small enough to be worth sending as OSC 52. */ +export function fitsInOsc52(text: string): boolean { + // 4 base64 chars per 3 input bytes, rounded up — cheaper than encoding + // a megabyte of transcript just to find out it is too big. + const bytes = Buffer.byteLength(text, "utf8"); + return Math.ceil(bytes / 3) * 4 <= OSC52_MAX_BASE64_CHARS; +} + +/** + * The platform's clipboard command, or `null` when there is none worth + * trying. On Linux the answer depends on the *session*, not the OS: + * `wl-copy` under Wayland, `xclip` under X11, and nothing at all on a + * headless box — where returning `null` is the honest answer and OSC 52 + * is the only route back to the human's clipboard. + */ +export function platformClipboardCommand( + platform: NodeJS.Platform, + env: Readonly>, +): ClipboardCommand | null { + if (platform === "darwin") return { command: "pbcopy", args: [] }; + if (platform === "win32") return { command: "clip", args: [] }; + if (env.WAYLAND_DISPLAY) return { command: "wl-copy", args: [] }; + if (env.DISPLAY) { + return { command: "xclip", args: ["-selection", "clipboard"] }; + } + return null; +} + +/** + * Default runner: spawns the command, feeds `text` on stdin, resolves on + * the exit code. A missing binary surfaces as an `error` event rather + * than a non-zero exit, so both collapse to `false` — the caller only + * ever needs "did the clipboard change". + */ +const spawnClipboardCommand: ClipboardCommandRunner = ( + command, + args, + text, +) => + new Promise((resolve) => { + let settled = false; + const done = (ok: boolean): void => { + if (settled) return; + settled = true; + resolve(ok); + }; + try { + const child = spawn(command, [...args], { + stdio: ["pipe", "ignore", "ignore"], + }); + child.on("error", () => done(false)); + child.on("close", (code) => done(code === 0)); + // EPIPE here means the child died before reading — `close` already + // has that case covered, so the write error is not interesting. + child.stdin?.on("error", () => {}); + child.stdin?.end(text); + } catch { + done(false); + } + }); + +/** + * Builds the clipboard writer used by the TUI. + * + * `copy` resolves `true` if the platform command succeeded, **or** if we + * emitted OSC 52 to a TTY. The second half is optimism, and deliberately + * so: OSC 52 never answers, so the alternative is to report failure on + * every terminal that only supports OSC 52 (i.e. every SSH session), + * which would be wrong far more often than the optimism is. A stale + * clipboard is recoverable; a "copy failed" badge on a copy that worked + * teaches the user the button is broken. + * + * When stdout is not a TTY nothing is attempted at all. There is no + * terminal to talk to, and — the reason this guard matters in practice — + * it keeps every non-interactive run, the test suite included, from + * reaching out and overwriting a real human's clipboard. + */ +export function createClipboardWriter( + options: ClipboardWriterOptions = {}, +): ClipboardWriter { + const stdout = options.stdout ?? process.stdout; + const runCommand = options.runCommand ?? spawnClipboardCommand; + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + return { + async copy(text: string): Promise { + if (stdout.isTTY !== true) return false; + let claimed = false; + if (fitsInOsc52(text)) { + try { + stdout.write(osc52Sequence(text)); + claimed = true; + } catch { + // A stdout that rejects a write is a dead terminal; the + // platform command may still be able to do the job. + } + } + const command = platformClipboardCommand(platform, env); + if (command) { + const ok = await runCommand(command.command, command.args, text); + claimed = claimed || ok; + } + return claimed; + }, + }; +} + +/** + * A writer that does nothing and reports failure. Used where a clipboard + * is structurally unavailable, and as the explicit stand-in in tests + * that must not touch a real one. + */ +export function createNullClipboardWriter(): ClipboardWriter { + return { copy: async () => false }; +} diff --git a/src/tui/clipboard/index.ts b/src/tui/clipboard/index.ts new file mode 100644 index 00000000..cf8f8122 --- /dev/null +++ b/src/tui/clipboard/index.ts @@ -0,0 +1,19 @@ +export { + createClipboardWriter, + createNullClipboardWriter, + fitsInOsc52, + osc52Sequence, + platformClipboardCommand, + OSC52_MAX_BASE64_CHARS, + type ClipboardCommand, + type ClipboardCommandRunner, + type ClipboardStdout, + type ClipboardWriter, + type ClipboardWriterOptions, +} from "./copy-to-clipboard.js"; +export { + ClipboardProvider, + getDefaultClipboardWriter, + useClipboard, + type ClipboardProviderProps, +} from "./clipboard-context.js"; diff --git a/src/tui/components/chat-copy-button.test.tsx b/src/tui/components/chat-copy-button.test.tsx new file mode 100644 index 00000000..cc088a9a --- /dev/null +++ b/src/tui/components/chat-copy-button.test.tsx @@ -0,0 +1,294 @@ +import { render } from "ink-testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactElement, ReactNode } from "react"; +import { ClipboardProvider } from "../clipboard/clipboard-context.js"; +import type { ClipboardWriter } from "../clipboard/copy-to-clipboard.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import { createInitialTuiState, type TuiSessionInfo } from "../tui-state.js"; +import { ChatCopyButton } from "./chat-copy-button.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "copy", + workingDir: "/tmp/copy", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function strip(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + +/** + * Screen position of `needle`. Stripping SGR leaves the visual grid + * intact, so these are the cells a terminal would report for a click — + * the same trick `mouse-app.test.tsx` uses. + */ +function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of frame.split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +/** + * Captured before any `vi.useFakeTimers()` call so the polling below + * keeps running on real time. The fake-timer tests here deliberately + * fake **only** `setTimeout`/`clearTimeout` — the component's badge + * window and nothing else. Faking the whole clock would also freeze + * React's scheduler and Ink's own bookkeeping, and the frame under + * assertion would simply never repaint. + */ +const realSetTimeout = globalThis.setTimeout; + +const delay = (ms: number): Promise => + new Promise((resolve) => realSetTimeout(resolve, ms)); + +/** Fakes the badge window only. See {@link realSetTimeout}. */ +function fakeBadgeTimerOnly(): void { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); +} + +/** + * Ink commits a frame and React flushes the effect that registers the + * click target on its own schedule, so a freshly rendered button is not + * clickable for a tick or two. Everything here polls rather than + * sleeping a fixed interval. + */ +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +interface Harness { + frame: () => string; + clickAt: (needle: string) => void; + clickCell: (x: number, y: number) => void; + unmount: () => void; +} + +function noopCallbacks(): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: () => {}, + }; +} + +function mount( + writer: ClipboardWriter, + children: ReactNode, + { withMouse = true }: { withMouse?: boolean } = {}, +): Harness { + const registry = new MouseTargetRegistry(); + const state = createInitialTuiState(SESSION); + const tree: ReactElement = withMouse ? ( + {}} + callbacks={noopCallbacks()} + getState={() => state} + > + {children} + + ) : ( + <>{children} + ); + const { lastFrame, unmount } = render( + {tree}, + ); + const frame = (): string => strip(lastFrame() ?? ""); + return { + frame, + clickAt: (needle) => { + const at = locate(frame(), needle); + registry.dispatch(click(at.x, at.y)); + }, + clickCell: (x, y) => registry.dispatch(click(x, y)), + unmount, + }; +} + +/** + * Clicks `[copy]` until the click actually lands. The target is + * registered by an effect that runs after the frame the label first + * appears in, so the first click can fall on a cell nothing owns yet — + * the same reason `mouse-app.test.tsx` re-sends its clicks. + */ +async function clickCopy(app: Harness, copied: readonly string[]): Promise { + await waitUntil(() => app.frame().includes("[copy]"), "the idle label"); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (copied.length > 0) return; + app.clickAt("[copy]"); + await delay(25); + } + throw new Error("click never took effect on the copy button"); +} + +function recordingWriter(result = true): { + writer: ClipboardWriter; + copied: string[]; +} { + const copied: string[] = []; + return { + copied, + writer: { + copy: async (text: string) => { + copied.push(text); + return result; + }, + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ChatCopyButton", () => { + it("renders the quiet idle label", () => { + const app = mount(recordingWriter().writer, ); + expect(app.frame()).toContain("[copy]"); + app.unmount(); + }); + + it("still renders without a mouse provider", () => { + // `useMouseCommands()` is null under `--no-mouse` and in every + // component test; the button must degrade to a label, not vanish. + const app = mount(recordingWriter().writer, , { + withMouse: false, + }); + expect(app.frame()).toContain("[copy]"); + app.unmount(); + }); + + it("copies the message text and flips the label when clicked", async () => { + const { writer, copied } = recordingWriter(); + const app = mount(writer, ); + await clickCopy(app, copied); + expect(copied).toEqual(["the exact reply"]); + await waitUntil( + () => app.frame().includes("[copied!]"), + "the copied badge", + ); + app.unmount(); + }); + + it("reports a refused copy instead of claiming success", async () => { + const { writer, copied } = recordingWriter(false); + const app = mount(writer, ); + await clickCopy(app, copied); + await waitUntil( + () => app.frame().includes("[copy failed]"), + "the failure badge", + ); + app.unmount(); + }); + + it("copies the message its own button belongs to, not a neighbour's", async () => { + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + <> + + + , + ); + await waitUntil(() => app.frame().split("[copy]").length === 3, "both buttons"); + // The second button is the second `[copy]` on screen — one row down. + const first = locate(app.frame(), "[copy]"); + for (let attempt = 0; attempt < 40 && copied.length === 0; attempt += 1) { + app.clickCell(first.x, first.y + 1); + await delay(25); + } + expect(copied).toEqual(["second message"]); + app.unmount(); + }); + + it("does not leave a timer behind when unmounted mid-badge", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount(writer, ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + // Only the badge window is faked, so this count is the component's + // pending revert and nothing else. + expect(vi.getTimerCount()).toBe(1); + app.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("ChatCopyButton label timer", () => { + it("reverts to the idle label once the badge window elapses", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + , + ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + vi.advanceTimersByTime(4_999); + expect(app.frame()).toContain("[copied!]"); + vi.advanceTimersByTime(1); + await waitUntil( + () => app.frame().includes("[copy]") && !app.frame().includes("[copied!]"), + "the label reverting on its own", + ); + app.unmount(); + }); + + it("a second click restarts the window instead of letting the first timer clear it", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + , + ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + vi.advanceTimersByTime(4_000); + const seen = copied.length; + app.clickAt("[copied!]"); + await waitUntil(() => copied.length > seen, "the second copy"); + // `copied` grows inside `copy()`; the badge timer is only restarted + // in the `.then` after it. Give that microtask a real tick. + await delay(25); + // The first click's timeout is due 1s from here. If it had not been + // cleared, the badge would blink off a second after the re-click. + vi.advanceTimersByTime(2_000); + expect(vi.getTimerCount()).toBe(1); + expect(app.frame()).toContain("[copied!]"); + app.unmount(); + }); +}); diff --git a/src/tui/components/chat-copy-button.tsx b/src/tui/components/chat-copy-button.tsx new file mode 100644 index 00000000..208ccbb9 --- /dev/null +++ b/src/tui/components/chat-copy-button.tsx @@ -0,0 +1,95 @@ +import { Box, Text } from "ink"; +import { useCallback, type ReactElement } from "react"; +import { useClipboard } from "../clipboard/clipboard-context.js"; +import { useTransientStatus } from "../hooks/use-transient-status.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { theme } from "../theme/theme.js"; + +interface ChatCopyButtonProps { + /** Exactly the text that lands on the clipboard — no markdown, no borders. */ + readonly text: string; + /** How long `copied!` stays up before the label reverts. */ + readonly revertAfterMs?: number; +} + +/** Idle / just-copied / copy-was-refused. Drives the label and nothing else. */ +type CopyStatus = "idle" | "copied" | "failed"; + +const DEFAULT_REVERT_MS = 2_000; + +const LABELS: Readonly> = { + idle: "[copy]", + copied: "[copied!]", + failed: "[copy failed]", +}; + +/** + * The per-message copy affordance in the chat log. + * + * **Why a button at all.** Mouse reporting takes the terminal's own + * drag-to-select away (see `mouse-tracking.ts`), and "I want that reply + * on my clipboard" is overwhelmingly the reason anyone selects text in a + * chat TUI. A button answers that intent directly and, unlike a + * selection, copies the message *source* — the raw text, not the + * markdown-rendered, border-decorated, hard-wrapped thing on screen, + * which is what a drag would have given you. + * + * **Why brackets and no colour.** `[copy]` in the palette's `muted` + * grey, dimmed, is the quietest thing that still reads as a control. + * There is one of these under every message; anything with hue would + * turn the transcript into a column of badges. "Dark grey" is expressed + * as a theme token rather than a literal because the four light palettes + * would swallow a literal `#555` whole — `muted` + `dimColor` is the + * darkest grey each palette actually has. + * + * **Without a mouse provider** (component tests, `--no-mouse`) the + * button still renders — it is a legible hint that the message has a + * copy affordance when the mouse is on — but registers no target. + */ +export function ChatCopyButton({ + text, + revertAfterMs = DEFAULT_REVERT_MS, +}: ChatCopyButtonProps): ReactElement { + const clipboard = useClipboard(); + const mouse = useMouseCommands(); + const [status, flash] = useTransientStatus("idle", revertAfterMs); + + const copy = useCallback(() => { + // Fire-and-forget: the click handler runs outside React's render + // pass and the clipboard write can outlive the frame. `flash` is the + // only thing that touches state, and it no-ops after unmount. + void clipboard + .copy(text) + .then((ok) => flash(ok ? "copied" : "failed")) + .catch(() => flash("failed")); + }, [clipboard, text, flash]); + + const label = ( + + {LABELS[status]} + + ); + + // A row wrapper, not a column child: in a column Yoga stretches the + // target to the full chat width and every click on the line would + // copy. In a row it hugs the six cells the label actually occupies. + return ( + + {mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + copy(); + return true; + }} + > + {label} + + ) : ( + label + )} + + ); +} diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx index bc020054..69cead5c 100644 --- a/src/tui/components/chat-log.tsx +++ b/src/tui/components/chat-log.tsx @@ -6,6 +6,8 @@ import type { TuiAction } from "../tui-action.js"; import type { ChatMessage, TuiState } from "../tui-state.js"; import { theme } from "../theme/theme.js"; import { AssistantBubble } from "./assistant-bubble.js"; +import { ChatCopyButton } from "./chat-copy-button.js"; +import { ChatTryAgainButton } from "./chat-try-again-button.js"; import { estimateMessageHeight, estimateStreamingTailHeight, @@ -170,7 +172,15 @@ function FinalisedMessage({ toolsExpandedById, }: FinalisedMessageProps): ReactElement { if (message.role === "user") { - return ; + return ( + + + + + + + + ); } if (message.role === "assistant") { return ( @@ -202,14 +212,18 @@ function FinalisedMessage({ text={message.text} toolSteps={message.toolSteps ?? 0} /> + ); } return ( - + + + + ); } diff --git a/src/tui/components/chat-message-height.test.ts b/src/tui/components/chat-message-height.test.ts index 218fe73a..1e2b5f0a 100644 --- a/src/tui/components/chat-message-height.test.ts +++ b/src/tui/components/chat-message-height.test.ts @@ -28,8 +28,8 @@ function assistantMsg( describe("estimateMessageHeight", () => { it("counts a single-line user message as body + bubble overhead", () => { const h = estimateMessageHeight(userMsg("u1", "hello")); - // 1 body + 3 overhead (margin + 2 padding) - expect(h).toBe(4); + // 1 body + 3 overhead (margin + 2 padding) + 1 copy-button row + expect(h).toBe(5); }); it("counts assistant footer when toolSteps > 0", () => { @@ -70,9 +70,9 @@ describe("selectVisibleMessages", () => { userMsg("u3", "c"), userMsg("u4", "d"), ]; - // Each 1-line user message costs 4 rows. Budget for 2 messages - // exactly: 8 rows. - const slice = selectVisibleMessages(msgs, 0, 8); + // Each 1-line user message costs 5 rows (4 of bubble + the copy + // button under it). Budget for 2 messages exactly: 10 rows. + const slice = selectVisibleMessages(msgs, 0, 10); expect(slice.visible.map((m) => m.id)).toEqual(["u3", "u4"]); expect(slice.hiddenAbove).toBe(2); }); @@ -92,8 +92,8 @@ describe("selectVisibleMessages", () => { it("respects multiline body length", () => { const longMsg = userMsg("u1", "line1\nline2\nline3\nline4\nline5"); - // 5 body + 3 overhead = 8 rows. - expect(estimateMessageHeight(longMsg)).toBe(8); + // 5 body + 3 overhead + 1 copy button = 9 rows. + expect(estimateMessageHeight(longMsg)).toBe(9); const slice = selectVisibleMessages( [longMsg, userMsg("u2", "tail")], 0, diff --git a/src/tui/components/chat-message-height.ts b/src/tui/components/chat-message-height.ts index d9236814..e837a9c0 100644 --- a/src/tui/components/chat-message-height.ts +++ b/src/tui/components/chat-message-height.ts @@ -25,6 +25,17 @@ const BUBBLE_OVERHEAD_ROWS = 3; // marginTop + paddingTop + paddingBottom const REASONING_BUBBLE_OVERHEAD_ROWS = 5; // marginTop + paddingTop + 1-line header + paddingBottom + safety const TOOL_CARD_BASE_ROWS = 2; const ASSISTANT_FOOTER_ROWS = 1; +/** + * `FinalisedMessage` hangs a button footer under every finalised bubble, + * whatever the role: `[copy]` everywhere, `[try again]` beside it on + * user messages. The two share a row, so this stays one row and + * unconditional — the day a role earns a second footer line this + * estimate has to learn about roles, and an under-count is not cosmetic: + * Ink 7 paints an over-tall frame's later lines over its earlier ones + * instead of clipping. The streaming tail has no footer at all, which is + * why the row is charged here and not in `estimateStreamingTailHeight`. + */ +const MESSAGE_FOOTER_ROWS = 1; function bodyLines(text: string): number { if (text.length === 0) return 1; @@ -33,7 +44,7 @@ function bodyLines(text: string): number { export function estimateMessageHeight(message: ChatMessage): number { const bodyRows = bodyLines(message.text); - let total = bodyRows + BUBBLE_OVERHEAD_ROWS; + let total = bodyRows + BUBBLE_OVERHEAD_ROWS + MESSAGE_FOOTER_ROWS; if (message.role === "assistant") { if (message.reasoningBlocks && message.reasoningBlocks.length > 0) { total += REASONING_BUBBLE_OVERHEAD_ROWS + 1; diff --git a/src/tui/components/chat-try-again-button.test.tsx b/src/tui/components/chat-try-again-button.test.tsx new file mode 100644 index 00000000..3462644a --- /dev/null +++ b/src/tui/components/chat-try-again-button.test.tsx @@ -0,0 +1,237 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import type { ReactElement, ReactNode } from "react"; +import { reduceTuiState } from "../agent-event-reducer.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import { createInitialTuiState, type TuiSessionInfo, type TuiState } from "../tui-state.js"; +import { ChatTryAgainButton } from "./chat-try-again-button.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "again", + workingDir: "/tmp/again", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function strip(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + +/** Screen cell of `needle` — the position a terminal reports for a click. */ +function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of frame.split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Ink commits frames on a throttle and the effect that registers a click + * target runs after the frame the label first appears in, so nothing + * here sleeps a fixed interval — it polls. Same reason + * `chat-copy-button.test.tsx` and `mouse-app.test.tsx` re-send clicks. + */ +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +interface Harness { + frame: () => string; + /** Clicks the label until the registry actually owns those cells. */ + clickUntil: (needle: string, landed: () => boolean) => Promise; + clickOnce: (needle: string) => void; + state: () => TuiState; + actions: TuiAction[]; + submitted: string[]; + steered: string[]; + unmount: () => void; +} + +function mount( + children: ReactNode, + { withMouse = true, initial }: { withMouse?: boolean; initial?: TuiState } = {}, +): Harness { + const registry = new MouseTargetRegistry(); + // A real reducer behind the provider: the point of these tests is what + // the submit path does to `TuiState`, and a stub dispatch would assert + // only that the component called something. + let state = initial ?? createInitialTuiState(SESSION); + const actions: TuiAction[] = []; + const dispatch = (action: TuiAction): void => { + actions.push(action); + state = reduceTuiState(state, action); + }; + const submitted: string[] = []; + const steered: string[] = []; + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: (text) => submitted.push(text), + onMessageSteered: (text) => steered.push(text), + }; + const tree: ReactElement = withMouse ? ( + state} + > + {children} + + ) : ( + <>{children} + ); + const { lastFrame, unmount } = render(tree); + const frame = (): string => strip(lastFrame() ?? ""); + const clickOnce = (needle: string): void => { + const at = locate(frame(), needle); + registry.dispatch(click(at.x, at.y)); + }; + return { + frame, + clickOnce, + clickUntil: async (needle, landed) => { + await waitUntil(() => frame().includes(needle), `the ${needle} label`); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (landed()) return; + clickOnce(needle); + await delay(25); + } + throw new Error(`click never took effect on ${needle}`); + }, + state: () => state, + actions, + submitted, + steered, + unmount, + }; +} + +describe("ChatTryAgainButton", () => { + it("renders the quiet idle label", () => { + const app = mount(); + expect(app.frame()).toContain("[try again]"); + app.unmount(); + }); + + it("still renders without a mouse provider", () => { + const app = mount(, { withMouse: false }); + expect(app.frame()).toContain("[try again]"); + app.unmount(); + }); + + it("re-sends the message through the normal submit path", async () => { + const app = mount(); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.submitted).toEqual(["list the files"]); + // `message_submitted` is what Enter dispatches — the re-run starts a + // real turn rather than poking the orchestrator behind the reducer. + expect(app.actions.map((a) => a.type)).toContain("message_submitted"); + await waitUntil(() => app.frame().includes("[sent]"), "the sent badge"); + app.unmount(); + }); + + it("keeps an unsent draft in the composer", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + inputValue: "half-written thought", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.submitted).toEqual(["run that again"]); + // Submitting blanks `inputValue` (`startNewRun`); the draft is put + // back afterwards, so the re-run costs a turn and not the operator's + // half-typed message. + expect(app.state().inputValue).toBe("half-written thought"); + app.unmount(); + }); + + it("steers into the running turn when that is what Enter would do", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + status: "running", + whileBusyMode: "steer", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.steered.length > 0); + expect(app.steered).toEqual(["try that again"]); + // Not a second turn: the routing is `handleEditorSubmit`'s, not ours. + expect(app.submitted).toEqual([]); + expect(app.state().status).toBe("running"); + app.unmount(); + }); + + it("queues into the running turn when that is what Enter would do", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + status: "running", + whileBusyMode: "queue", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.steered).toEqual([]); + expect(app.state().queuedMessages).toEqual(["and again"]); + app.unmount(); + }); + + it("ignores the second press of a double-click, then re-arms", async () => { + const app = mount( + , + ); + // The first send starts a turn, so the second one steers into it — + // count both landings, since which one fires is the submit path's + // decision and this test is about how many times it was asked. + const sends = (): number => app.submitted.length + app.steered.length; + await app.clickUntil("[try again]", () => sends() > 0); + await waitUntil(() => app.frame().includes("[sent]"), "the sent badge"); + // A terminal reports a double-click as two presses; a turn is not + // free, so the badge window swallows the second one. + app.clickOnce("[sent]"); + await delay(50); + expect(sends()).toBe(1); + // The guard is a window, not a latch. + await waitUntil( + () => app.frame().includes("[try again]"), + "the label re-arming", + ); + await app.clickUntil("[try again]", () => sends() > 1); + expect(app.submitted).toEqual(["expensive turn"]); + expect(app.steered).toEqual(["expensive turn"]); + app.unmount(); + }); +}); diff --git a/src/tui/components/chat-try-again-button.tsx b/src/tui/components/chat-try-again-button.tsx new file mode 100644 index 00000000..20855b96 --- /dev/null +++ b/src/tui/components/chat-try-again-button.tsx @@ -0,0 +1,141 @@ +import { Box, Text } from "ink"; +import { type ReactElement } from "react"; +import { useTransientStatus } from "../hooks/use-transient-status.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { + MouseTarget, + useMouseCommands, + type MouseContextValue, +} from "../mouse/mouse-context.js"; +import { handleEditorSubmit } from "../submit-handler.js"; +import { theme } from "../theme/theme.js"; + +interface ChatTryAgainButtonProps { + /** The message source, resent verbatim — byte for byte what was sent before. */ + readonly text: string; + /** How long `sent` stays up before the label reverts. */ + readonly revertAfterMs?: number; +} + +/** Idle / just-resent. The badge is also the double-click guard. */ +type TryAgainStatus = "idle" | "sent"; + +const DEFAULT_REVERT_MS = 2_000; + +const LABELS: Readonly> = { + idle: "[try again]", + sent: "[sent]", +}; + +/** + * Re-run `text` exactly as if it had been typed into the composer and + * submitted with Enter. + * + * **One submit path.** Everything goes through `handleEditorSubmit`, the + * function Enter calls, so a re-run inherits whatever routing the + * operator has configured instead of inventing a third behaviour: idle + * starts a turn; while a turn is running `tui.whileBusySubmit` (Ctrl+T) + * decides between steering the text into the turn in flight and parking + * it in the queue. The same rule covers the odd cases for free — a + * message that happens to read as a slash command runs as one, because + * that is what typing it would do, and a second interpretation of the + * same text is exactly how two submit paths drift apart. + * + * **The composer draft survives.** Every landing that path dispatches + * blanks `inputValue` — `startNewRun`, `message_queued` and + * `message_steered` all do — which would silently eat a half-written + * message the operator had not sent yet. The draft is snapshotted before + * the submit and written back after it, so a re-run costs a turn and + * nothing else. Restoring the buffer alone is enough: a draft that would + * also need slash-palette state restored cannot reach this handler at + * all, because `TuiApp` raises the mouse floor to `MOUSE_LAYER_MODAL` + * while the palette is open and this button sits on the base layer. + */ +export function resubmitChatMessage( + text: string, + mouse: MouseContextValue, +): void { + // Read state at click time, not render time: the handler fires outside + // React's render pass and the turn may have started or finished since + // the frame that painted the button. + const state = mouse.getState(); + const draft = state.inputValue; + handleEditorSubmit(text, state, mouse.dispatch, mouse.callbacks); + if (draft.length > 0) { + mouse.dispatch({ type: "input_changed", value: draft }); + } +} + +/** + * The per-message "run that again" affordance, beside `[copy]`. + * + * **Only user messages get one**, which is `chat-log.tsx`'s call to + * make, not this component's — but the reasoning belongs next to the + * code it explains. A user message is a command someone gave the agent, + * so re-running it is a real intent: the model wandered off, a file + * changed, a tool was down. An assistant message is the agent's own + * prose; sending it back would open a turn whose prompt is the previous + * answer, which is not "try again" in any sense an operator means. A + * system message is TUI runtime output — queue listings, turn-failed + * lines — and re-sending one as a prompt is worse than nonsense. Asking + * the model to have another go at the *same* question is a different + * feature (it has to drop the last turn, not append one) and it is not + * this button. + * + * **Why a badge when the click already changes the screen.** Often it + * does not. A steered message is not rendered until the loop applies it + * at the next step boundary (`steer_applied`), which can be seconds + * away, so a click with no feedback reads as a dead button and gets + * clicked again. `[sent]` closes that gap and doubles as the guard: + * clicks are ignored while it is up, so the double-click a terminal + * reports as two presses cannot open two turns. + * + * **Without a mouse provider** (component tests, `--no-mouse`) it still + * renders, exactly like `[copy]` — a legible hint that the affordance is + * there when the mouse is on — but registers no target. + */ +export function ChatTryAgainButton({ + text, + revertAfterMs = DEFAULT_REVERT_MS, +}: ChatTryAgainButtonProps): ReactElement { + const mouse = useMouseCommands(); + const [status, flash] = useTransientStatus( + "idle", + revertAfterMs, + ); + + const label = ( + + {LABELS[status]} + + ); + + // One space off `[copy]`, on the same row: the footer stays a single + // line whatever the role, so `estimateMessageHeight` does not have to + // branch — and an under-counted row is not a cosmetic bug in Ink 7, + // which paints an over-tall frame's later lines over its earlier ones + // rather than clipping. + return ( + + {mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + // Claim the press either way — the click landed on this + // button, and letting it fall through would hand it to the + // viewport wheel target behind the chat log. + if (status !== "idle") return true; + resubmitChatMessage(text, mouse); + flash("sent"); + return true; + }} + > + {label} + + ) : ( + label + )} + + ); +} diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index 0945b713..bdb47f34 100644 --- a/src/tui/components/debug-pane.tsx +++ b/src/tui/components/debug-pane.tsx @@ -164,7 +164,8 @@ function buildManageTabs(state: TuiState): SubTab[] { /** * Height consumed by the always-on app frame OUTSIDE the debug pane: * the top `StatusBar` (1 row) + the `PromptShell` (≈6 rows: top margin, - * padding, the editor line, the meta-row, and the `╹` cap) + the + * the rounded frame's two border rows, the editor line and the action + * bar) + the * `HotkeyHint` (1 row). Ink 7 does NOT clip a frame taller than the * terminal — it overlaps/garbles earlier lines instead (verified) — so * the per-tab budget must subtract this accurately and err generous. diff --git a/src/tui/components/logo.tsx b/src/tui/components/logo.tsx index a1aae8d3..5332f1de 100644 --- a/src/tui/components/logo.tsx +++ b/src/tui/components/logo.tsx @@ -84,6 +84,15 @@ export const LOGO_ART: Readonly> = { mini: rasteriseMark(toInkMask(FULL_ART), { columns: 7, rows: 4 }), }; +/** + * The rail's brand mark: the same drawing as the splash, rasterised to a + * 6x4 cell so the rail spends four rows on branding rather than ten. + */ +export const RAIL_MARK: readonly string[] = rasteriseMark( + toInkMask(FULL_ART), + { columns: 6, rows: 4 }, +); + export const WORDMARK_ROWS: readonly string[] = [ "▄▀█ ▀█▀ █▀█ █▀▄▀█ █ █▀▀ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀", "█▀█ █ █▄█ █ ▀ █ █ █▄▄ █▀█ █▄█ ██▄ █ ▀█ █ ", diff --git a/src/tui/components/prompt-meta-bar.test.tsx b/src/tui/components/prompt-meta-bar.test.tsx new file mode 100644 index 00000000..24cd8ba5 --- /dev/null +++ b/src/tui/components/prompt-meta-bar.test.tsx @@ -0,0 +1,188 @@ +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { PromptShell } from "./prompt-shell.js"; + +function strip(value: string): string { + return value + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); +} + +/** + * Screen position of `needle`'s first cell. Stripping SGR codes leaves + * the visual grid intact, so the column/row returned here are the same + * cells a terminal would report for a click on that label. + */ +function locate(frame: string, needle: string): { x: number; y: number } { + const lines = strip(frame).split("\n"); + for (const [y, line] of lines.entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${strip(frame)}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const noopCallbacks = {} as TuiAppCallbacks; + +/** + * `PromptShell` inside a real registry. The buttons only need dispatch + * to exist — they act through their own props — but the registry is the + * real one so the click goes through genuine Yoga hit-testing rather + * than a hand-fed rectangle. + */ +async function mountWithMouse(node: ReactElement): Promise<{ + registry: MouseTargetRegistry; + frame: () => string; + unmount: () => void; +}> { + const registry = new MouseTargetRegistry(); + const { lastFrame, unmount } = render( + {}} + callbacks={noopCallbacks} + getState={() => ({}) as TuiState} + > + {node} + , + ); + // Ink commits on its own throttle and React registers the click + // targets in the effect after that commit, so a freshly mounted + // button is not hit-testable on the very first tick. + await new Promise((resolve) => setTimeout(resolve, 120)); + return { registry, frame: () => lastFrame() ?? "", unmount }; +} + +describe("composer buttons", () => { + it("submits the live buffer when Send is clicked", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(true); + expect(sent).toEqual(["ship it"]); + unmount(); + }); + + it("stays inert while the buffer is blank", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + it("stays inert while the editor is disabled", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + + it("ignores a right-button press on Send", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect( + registry.dispatch({ ...click(x, y), button: "right" }), + ).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + it("renders without a mouse provider at all", () => { + const { lastFrame, unmount } = render( + {}} onSubmit={() => {}} />, + ); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("send"); + unmount(); + }); +}); + +describe("the model label", () => { + const renderModel = (model: string): string => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const frame = strip(lastFrame() ?? ""); + unmount(); + return frame; + }; + + /** + * Fusion names both legs. Spending the whole budget left-to-right ate + * the local half outright — "vendor/some-very-long-name ⇄ q…" — which + * hides the model that actually executes most of the steps. + */ + it("keeps both fusion legs identifiable", () => { + const frame = renderModel( + "vendor/some-very-long-cloud-model ⇄ qwen3-4b-instruct-q4.gguf", + ); + expect(frame).toContain("vendor/some-v…"); + expect(frame).toContain("qwen3-4b-inst…"); + }); + + it("still trims a single long name the way it always did", () => { + expect(renderModel("vendor/an-extremely-long-single-model-name")).toContain( + "vendor/an-extremely-long-single…", + ); + }); +}); diff --git a/src/tui/components/prompt-meta-bar.tsx b/src/tui/components/prompt-meta-bar.tsx new file mode 100644 index 00000000..c5e24ee8 --- /dev/null +++ b/src/tui/components/prompt-meta-bar.tsx @@ -0,0 +1,218 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { theme } from "../theme/theme.js"; + +/** + * The composer's action bar: what the model is on the left, the two + * buttons on the right, drawn on the same inverted ground as the rail. + * + * **Why inverted.** The bar is the composer's chrome, not its content. + * A terminal has no borders-and-shadows to say "this strip is a + * toolbar", so it borrows the one device the rail already established: + * its own ground, per-palette rather than a literal white, because + * `#fff` disappears on the four light themes. Reading the composer as + * "a field with a toolbar under it" instead of "two lines of text" is + * the whole point of the change. + * + * The ground is one `backgroundColor` on the bar container, which Ink 7 + * paints across the empty space between the meta text and the buttons — + * no filler cells, and no risk of the row growing taller than it looks. + * + * **A caveat about the slots.** `leftSlot` / `rightSlot` arrive from the + * chat surface already coloured (the LLM health pill, the context-window + * counter), and those colours were chosen against the *normal* ground. + * On the rail ground they read as low-contrast secondary text — which is + * what they are — but on `github-dark` and `catppuccin-mocha` the muted + * tone is close enough to the light rail ground to be genuinely faint. + * Recolouring them would mean reaching into components outside this + * file; the glyph in each pill carries a saturated status colour and + * stays legible, so the signal survives even where the label dims. + */ +export interface PromptMetaBarProps { + /** Chat-surface content rendered first — normally the LLM health pill. */ + leftSlot: ReactElement | null; + model: string | null; + provider: string | null; + /** Chat-surface content rendered just before the buttons. */ + rightSlot: ReactElement | null; + /** Whether Send has something to send; drives the primary/ghost look. */ + canSend: boolean; + onSend: () => void; +} + +/** Labels carry their own padding so the chip's ground reads as a button. */ +const SEND_LABEL = " send → "; + +const MODEL_LABEL_MAX_LEN = 32; + +/** + * Separator `runModeModelSummary` puts between the two fusion legs. + * Matched here rather than imported as a run-mode concept: this file + * only needs to know that a label can be a pair, so that it can spend + * its budget on both halves instead of on the first one. + */ +const PAIR_SEPARATOR = " ⇄ "; + +export function PromptMetaBar({ + leftSlot, + model, + provider, + rightSlot, + canSend, + onSend, +}: PromptMetaBarProps): ReactElement { + return ( + + {/* + The meta group is the only thing allowed to give up columns: at + 60 the buttons must survive intact, because a half-drawn button + is worse than a truncated model name. + */} + + + + + {rightSlot ? ( + + {rightSlot} + + ) : null} + + + + ); +} + +interface ComposerButtonProps { + label: string; + /** Filled in the accent colour — the bar's one primary action. */ + primary?: boolean; + /** A disabled button still renders: it says the affordance exists. */ + enabled: boolean; + onPress: () => void; +} + +/** + * One button chip. + * + * Every colour here is a *pair* taken from the theme rather than a + * literal, and each pair is one the palette already guarantees to be + * opposite: `border` against `railBackground`, `accent` against + * `railForeground`. That is what keeps the chips legible across all + * eleven palettes without a per-theme table — the tokens flip polarity + * with the theme, so the contrast holds on light and dark alike. + * + * A disabled Send drops its ground entirely and dims to `railMuted`, + * which is the terminal's version of a ghost button: still there, still + * labelled, visibly not pressable. + */ +function ComposerButton({ + label, + primary = false, + enabled, + onPress, +}: ComposerButtonProps): ReactElement { + const background = !enabled + ? theme.colors.railBackground + : primary + ? theme.colors.accent + : theme.colors.border; + const foreground = !enabled + ? theme.colors.railMuted + : primary + ? theme.colors.railForeground + : theme.colors.railBackground; + const chip = ( + + {label} + + ); + const mouse = useMouseCommands(); + // No provider (component tests, the wizard's separate Ink tree) or + // nothing to do: render the label and stop. Registering a target that + // swallows the click without acting would be worse than no target. + if (!mouse || !enabled) return chip; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onPress(); + return true; + }} + > + {chip} + + ); +} + +interface MetaLeftProps { + leftSlot: ReactElement | null; + model: string | null; + provider: string | null; +} + +function MetaLeft({ leftSlot, model, provider }: MetaLeftProps): ReactElement { + if (!leftSlot && !model && !provider) { + return ; + } + const cleanModel = model ? formatModel(model) : null; + // Wrap the optional `leftSlot` in a `` so neighbouring spans + // (a leading dot separator before the model) stay on the same line + // without Yoga inserting an inline break between Box children. + // `truncate` rather than wrap: a second line here would push the + // frame's bottom border down and change the composer's height, which + // is exactly the kind of drift a bounded frame exists to prevent. + return ( + + {leftSlot ? {leftSlot} : null} + {leftSlot && (cleanModel || provider) ? ( + + {" "} + {theme.glyphs.dotSeparator}{" "} + + ) : null} + {cleanModel ? ( + + {cleanModel} + + ) : null} + {cleanModel && provider ? ( + + {" "} + {theme.glyphs.dotSeparator}{" "} + + ) : null} + {provider ? ( + {provider} + ) : null} + + ); +} + +function formatModel(model: string): string { + // Fusion names both legs. Truncating the joined string would eat the + // local half whole and leave "anthropic/claude-sonnet-4.5 ⇄ q…", which + // says less than either name alone would: the reader can no longer + // tell which local model is executing. Each side gets half the budget + // so both stay identifiable at the width the row already had. + const [cloud, local] = model.split(PAIR_SEPARATOR); + if (cloud !== undefined && local !== undefined) { + const half = Math.floor((MODEL_LABEL_MAX_LEN - PAIR_SEPARATOR.length) / 2); + return `${shorten(cloud, half)}${PAIR_SEPARATOR}${shorten(local, half)}`; + } + return shorten(model, MODEL_LABEL_MAX_LEN); +} + +function shorten(label: string, max: number): string { + const stripped = label.replace(/\.gguf$/i, ""); + if (stripped.length <= max) return stripped; + return `${stripped.slice(0, max - 1)}…`; +} diff --git a/src/tui/components/prompt-shell.test.tsx b/src/tui/components/prompt-shell.test.tsx index a354b95f..5efaa1d3 100644 --- a/src/tui/components/prompt-shell.test.tsx +++ b/src/tui/components/prompt-shell.test.tsx @@ -1,3 +1,4 @@ +import { Box, Text } from "ink"; import { render } from "ink-testing-library"; import { describe, expect, it } from "vitest"; import { PromptShell } from "./prompt-shell.js"; @@ -9,7 +10,7 @@ function strip(value: string): string { } describe("PromptShell", () => { - it("renders the left tail cap (╹) below the editor", () => { + it("closes a frame around the editor and the action bar", () => { const { lastFrame, unmount } = render( { />, ); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain("╹"); + expect(frame).toContain("╭"); + expect(frame).toContain("╰"); expect(frame).toContain("hello"); + // The tail cap the frame replaced. + expect(frame).not.toContain("╹"); + unmount(); + }); + + it("shows the send button", () => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("send"); + unmount(); + }); + + /** + * The composer's whole height budget: four rows of chrome plus the + * buffer. If this grows, the chat viewport shrinks — and Ink 7 will + * overlap the lines above rather than clip, so a drift here is not a + * cosmetic one. + */ + it("spends four rows on chrome regardless of the buffer", () => { + const heightOf = (value: string): number => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const rows = strip(lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0).length; + unmount(); + return rows; + }; + expect(heightOf("one")).toBe(4); + expect(heightOf("one\ntwo\nthree")).toBe(6); + }); + + /** + * 60 columns is the narrowest terminal the composer has to survive: + * the chat column is 56 wide once the root padding is taken, and the + * rail is already hidden at that width. The meta group is the only + * thing allowed to give up columns — a clipped button reads as a + * rendering bug, a clipped model name reads as a long model name. + */ + it("keeps the send button whole in a 56-column chat column", () => { + const { lastFrame, unmount } = render( + // A column, like the chat surface: the composer takes the + // column's full width rather than its own intrinsic one. + + {"● healthy"}} + rightSlot={ctx 32768} + onChange={() => {}} + onSubmit={() => {}} + /> + , + ); + const lines = strip(lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0); + expect(lines).toHaveLength(4); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(56); + } + const bar = lines[2] ?? ""; + expect(bar).toContain(" send → "); unmount(); }); @@ -107,7 +188,12 @@ describe("PromptShell", () => { unmount(); }); - it("omits the meta-row when neither model nor right-slot is set", () => { + /** + * The bar is unconditional now — it carries the buttons, so it cannot + * come and go with the model label the way the old meta-row did + * without the composer changing height mid-session. + */ + it("keeps the action bar with no model and no slots", () => { const { lastFrame, unmount } = render( { ); const frame = strip(lastFrame() ?? ""); expect(frame).not.toContain("llama.cpp"); + expect(frame).toContain("send"); unmount(); }); }); diff --git a/src/tui/components/prompt-shell.tsx b/src/tui/components/prompt-shell.tsx index 18a31934..905143df 100644 --- a/src/tui/components/prompt-shell.tsx +++ b/src/tui/components/prompt-shell.tsx @@ -1,21 +1,32 @@ -import { Box, Text } from "ink"; +import { Box } from "ink"; import type { ReactElement } from "react"; import { useRotatingPlaceholder } from "../hooks/use-rotating-placeholder.js"; import { theme } from "../theme/theme.js"; import { MultiLineEditor, type MultiLineEditorProps } from "./multi-line-editor.js"; +import { PromptMetaBar } from "./prompt-meta-bar.js"; /** - * Visual shell around `MultiLineEditor` modelled after the opencode - * prompt: a left "tail" column terminated by a `╹` cap, optional - * rotating placeholder, and a meta-row underneath that surfaces the - * active model. The editor itself runs in `bare` mode so the chrome is - * fully owned here. + * The composer: a framed input field with a toolbar under it. + * + * It used to be an opencode-style left "tail" — a single border column + * down the left of the editor, capped by a `╹`. That reads as a quote + * block, not as a place you type into, and it gave the two things the + * composer needs to advertise (send, reference a file) nowhere to live. + * A closed frame plus an action bar is the shape every operator already + * knows from every other message box they have used, and it costs one + * row *less* than the tail did: border, editor, bar, border — where the + * tail spent a top pad, a blank row above the meta and the cap glyph. + * + * The frame is deliberately the app's only fully-boxed surface besides + * modals. Bounded height matters: Ink 7 does not clip a frame taller + * than the terminal, it overlaps the lines above it (the hazard + * `splash-fit.ts` exists to document), so the composer grows only with + * the buffer the operator typed and never with its own chrome. * * Out-of-scope (deferred for parity with opencode): * - bracketed paste with image bytes (Ink delivers cooked stdin) - * - mouse interactions / hover (Ink has no mouse layer) * - extmark "chips" inside the textarea (e.g. coloured `@file.ts`) - * - alpha / fade-in animations on the meta-row + * - alpha / fade-in animations on the action bar * * The shell does **not** open the autocomplete popup — slash-palette * stays where it lived before, rendered by the parent above the editor. @@ -33,7 +44,7 @@ export interface PromptShellProps rotatingPlaceholders?: readonly string[]; /** Rotation period in milliseconds. Defaults to 4000. */ placeholderRotationMs?: number; - /** Active model alias rendered into the meta-row (e.g. `qwen3-30b`). */ + /** Active model alias rendered into the action bar (e.g. `qwen3-30b`). */ model?: string | null; /** * Optional provider hint shown after the model (e.g. `llama.cpp`). @@ -41,13 +52,13 @@ export interface PromptShellProps */ provider?: string | null; /** - * Optional content rendered at the start of the meta-row, before the + * Optional content rendered at the start of the action bar, before the * model/provider labels. Used by the chat surface to show the live * LLM health pill. Separated by a dot from the model when both are * present. */ leftSlot?: ReactElement | null; - /** Optional content rendered on the right-hand side of the meta-row. */ + /** Optional content rendered just before the buttons on the right. */ rightSlot?: ReactElement | null; } @@ -63,6 +74,8 @@ export function PromptShell(props: PromptShellProps): ReactElement { focus, disabled, value, + onChange, + onSubmit, ...editorProps } = props; const rotated = useRotatingPlaceholder( @@ -72,102 +85,44 @@ export function PromptShell(props: PromptShellProps): ReactElement { const effectivePlaceholder = value.length === 0 ? (rotated ?? placeholder ?? "") : ""; const accent = focus && !disabled ? theme.colors.accent : theme.colors.border; - // Render the meta-row whenever any slot is occupied. With the live - // LLM-health pill being a permanent left-slot tenant, this means the - // row is effectively always rendered after mount — keeping the - // layout stable so the input does not jump up by one cell the moment - // `/props` lands. - const showMeta = - Boolean(model) || - Boolean(provider) || - Boolean(leftSlot) || - Boolean(rightSlot); + // Send is live on exactly the condition Enter is: a non-blank buffer + // in an editor that is accepting input. `handleEditorSubmit` drops a + // blank buffer anyway, but a button that visibly does nothing when + // pressed is a bug report waiting to happen. + const canSend = !disabled && value.trim().length > 0; return ( - - + {/* + Padding lives on the editor row, not on the frame: the action + bar has to reach both borders for its ground to read as a + toolbar rather than as a floating stripe. + */} + + + + onSubmit(value)} /> - {showMeta ? ( - - - {rightSlot ? {rightSlot} : null} - - ) : null} - ); } - -interface MetaLeftProps { - leftSlot: ReactElement | null; - model: string | null; - provider: string | null; -} - -const MODEL_LABEL_MAX_LEN = 32; - -function MetaLeft({ - leftSlot, - model, - provider, -}: MetaLeftProps): ReactElement { - if (!leftSlot && !model && !provider) { - return ; - } - const cleanModel = model ? formatModel(model) : null; - // Wrap the optional `leftSlot` in a `` so neighbouring spans - // (a leading dot separator before the model) stay on the same line - // without Yoga inserting an inline break between Box children. - return ( - - {leftSlot ? {leftSlot} : null} - {leftSlot && (cleanModel || provider) ? ( - - {" "} - {theme.glyphs.dotSeparator}{" "} - - ) : null} - {cleanModel ? ( - - {cleanModel} - - ) : null} - {cleanModel && provider ? ( - - {" "} - {theme.glyphs.dotSeparator}{" "} - - ) : null} - {provider ? ( - {provider} - ) : null} - - ); -} - -function formatModel(model: string): string { - const stripped = model.replace(/\.gguf$/i, ""); - if (stripped.length <= MODEL_LABEL_MAX_LEN) return stripped; - return `${stripped.slice(0, MODEL_LABEL_MAX_LEN - 1)}…`; -} diff --git a/src/tui/components/sidebar.test.tsx b/src/tui/components/sidebar.test.tsx index 2e7a20e6..efa149d9 100644 --- a/src/tui/components/sidebar.test.tsx +++ b/src/tui/components/sidebar.test.tsx @@ -72,8 +72,11 @@ describe("Sidebar", () => { />, ); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Sessions"); - expect(text).toContain("Tasks"); + // Upper-case since the rail became the app frame — it carries the + // brand, the version and the menu button now, so its own headings + // read as labels rather than as content. + expect(text).toContain("SESSIONS"); + expect(text).toContain("TASKS"); expect(text).not.toContain("Workspace"); expect(text).not.toContain("LLM"); }); @@ -178,8 +181,10 @@ describe("Sidebar", () => { // Both panes admit what they are hiding. expect(text).toContain("9 more"); expect(text).toContain("6 more"); - // Two headers + 3 sessions + 2 tasks + 2 "more" rows + blank row. - expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(10); + // Two headers + 3 sessions + 2 tasks + 2 "more" rows + spacers, plus + // the brand block (mark, wordmark, version), the menu button and the + // breadcrumb slot the rail gained when it replaced the top bar. + expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(22); }); it("scrolls the Tasks pane to keep the cursor visible", () => { diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index c82bfce6..41bbdb20 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -1,15 +1,17 @@ import { Box, Text } from "ink"; import type { ReactElement, ReactNode } from "react"; -import { computeRowWindow } from "../row-window.js"; import { MouseTarget, useMouseCommands, useMouseTarget, } from "../mouse/mouse-context.js"; import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { computeRowWindow } from "../row-window.js"; import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; import { theme } from "../theme/theme.js"; import type { SessionPickerEntry } from "../tui-state.js"; +import { getAppVersion } from "../../version.js"; +import { RAIL_MARK } from "./logo.js"; export type SidebarSection = "sessions" | "tasks"; @@ -24,6 +26,8 @@ export interface SidebarProps { activeSection: SidebarSection; /** Whether the sidebar owns keyboard focus right now. */ focused: boolean; + /** Short session id, shown under the wordmark. */ + sessionId?: string | null; /** * Row budget for each pane, normally derived from the terminal height * by `computeSidebarRowBudget` in `../layout.ts`. The defaults keep @@ -46,19 +50,33 @@ const ROW_CHROME_COLUMNS = 7; const MIN_PREVIEW_COLUMNS = 6; /** - * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and - * Tasks (bottom) — both navigable when the sidebar has focus. Tab - * cycles editor → sessions → tasks → editor (handled by - * `app-key-bindings.ts`); the sidebar component itself is purely - * presentational and never measures the terminal directly so the - * same component works under ink-testing-library's static viewport. - * Its width and per-pane row budgets arrive as props from `TuiApp`, - * which owns the terminal measurement. + * The app rail: brand mark, menu button, where you are, then Sessions + * and Tasks. Always on screen, on the **left**, drawn on its own + * inverted ground. + * + * It used to be a plain right-hand list of sessions with the app title + * on a separate bar across the top. That is two pieces of chrome doing + * one job. Everything that says "which app, which version, where am I, + * what else is there" now lives in one column, which is where a reader + * coming from any normal application will look for it — and the top bar + * is gone entirely. + * + * **Why the inverted ground.** A terminal has no borders-and-shadows to + * separate regions, so two columns of the same text on the same ground + * read as one wrapped document. Giving the rail its own ground is the + * cheapest honest way to say "this is chrome, that is content". It is + * per-palette rather than literally white: `#fff` would vanish on the + * four light themes, and the property that has to hold is inversion. + * + * The ground is one `backgroundColor` on the rail container, so it fills + * the column's whole height on its own. Painting it line by line instead + * needs filler rows to reach the bottom, and a rail taller than the + * terminal makes Ink 7 overlap earlier lines rather than clip — the same + * trap `splash-fit.ts` exists to avoid. * - * Focus is layered: `focused` toggles the section header colour for - * the active pane, and `activeSection` decides which pane gets the - * cursor highlight. When `focused` is false, both panes render in - * their muted resting state. + * Purely presentational: it never measures the terminal, so the same + * component works under ink-testing-library's static viewport. Width and + * per-pane row budgets arrive as props from `TuiApp`. */ export function Sidebar(props: SidebarProps): ReactElement { const { @@ -70,11 +88,13 @@ export function Sidebar(props: SidebarProps): ReactElement { tasksCursor, activeSection, focused, + sessionId = null, maxSessionRows = DEFAULT_MAX_SESSION_ROWS, maxTaskRows = DEFAULT_MAX_TASK_ROWS, } = props; const sessionsActive = focused && activeSection === "sessions"; const tasksActive = focused && activeSection === "tasks"; + const inner = Math.max(1, width - 2); const previewWidth = Math.max( MIN_PREVIEW_COLUMNS, width - ROW_CHROME_COLUMNS, @@ -101,16 +121,13 @@ export function Sidebar(props: SidebarProps): ReactElement { width={width} flexShrink={0} flexDirection="column" - borderStyle="single" - borderTop={false} - borderRight={false} - borderBottom={false} - borderLeft - borderColor={theme.colors.border} - paddingLeft={1} - paddingRight={1} + backgroundColor={theme.colors.railBackground} + paddingX={1} > - + + + + - - - + + + {/* + The menu sits at the foot of the rail, the way an application + parks its account or settings control: it is the thing you reach + for occasionally, and the lists above it are what you look at. + The spacer pushes it down however tall the terminal is. + */} + + + + ); +} + +/** Clip to `width` columns; the ground is painted by the container. */ +function clip(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text; +} + +/** + * One rail line. The text is clipped to the rail width but not padded — + * the container's `backgroundColor` paints the rest of the row. + */ +function RailLine({ + inner, + children, + color, + bold, +}: { + inner: number; + children: string; + color?: string; + bold?: boolean; +}): ReactElement { + return ( + + {clip(children, inner)} + + ); +} + +/** + * One row of breathing space. An empty `` collapses to zero height + * in Ink, so the spacer has to be a sized Box. + */ +function RailBlank(): ReactElement { + return ; +} + +/** + * Mark, wordmark, version — the mark on the left with the text beside + * it, the way a product lockup is normally set. Stacked, it spent six of + * the rail's rows on branding before the first useful line. + * + * The session id keeps its own full-width row underneath: it is the one + * piece here that can be long, and squeezing it into the column beside a + * six-column mark would truncate it to nothing. + */ +function RailBrand({ + inner, + sessionId, +}: { + inner: number; + sessionId: string | null; +}): ReactElement { + const art = RAIL_MARK; + const textWidth = Math.max(0, inner - MARK_COLUMNS - 1); + return ( + + + + + {art.map((row, idx) => ( + + {row} + + ))} + + + {/* Blank rows centre the two text lines against the four-row mark. */} + + + {clip("atomic-agent", textWidth)} + + + {clip(`v${getAppVersion()}`, textWidth)} + + + + {sessionId ? ( + + {shortenId(sessionId)} + + ) : null} ); } +/** Width of {@link RAIL_MARK}, kept beside it so the lockup can measure. */ +const MARK_COLUMNS = 6; + +/** + * Starts a fresh thread. It sits at the head of the session list because + * that is the list it adds to — and because `/new` was the only way to + * reach it, which is not a thing a first-time operator knows. + */ +function NewSessionButton({ inner }: { inner: number }): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {" + New session"} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.callbacks.onSessionNewRequested?.(); + return true; + }} + > + {label} + + ); +} + +/** + * The one control on the rail. `ctrl+p` opens the same menu; this is + * what makes it reachable without knowing that, which was the whole + * complaint about the old top bar — nothing on screen said the menu + * existed. + */ +function MenuButton({ inner }: { inner: number }): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {`${theme.glyphs.menuGlyph} Menu${" ".repeat(Math.max(1, inner - 17))}ctrl+p`} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "menu_opened" }); + return true; + }} + > + {label} + + ); +} + +function shortenId(value: string): string { + if (value.length <= 8) return value; + return `${value.slice(0, 8)}…`; +} + interface SectionHeaderProps { title: string; active: boolean; + inner: number; } -function SectionHeader({ title, active }: SectionHeaderProps): ReactElement { +function SectionHeader({ title, active, inner }: SectionHeaderProps): ReactElement { return ( - - {title} - + + {title.toUpperCase()} + ); } @@ -153,6 +337,7 @@ interface SessionsListProps { currentSessionId: string | null; maxRows: number; previewWidth: number; + inner: number; } function SessionsList({ @@ -162,12 +347,13 @@ function SessionsList({ currentSessionId, maxRows, previewWidth, + inner, }: SessionsListProps): ReactElement { if (sessions.length === 0) { return ( - - (no sessions yet) - + + {"(no sessions yet)"} + ); } const window = computeRowWindow(sessions.length, cursor, maxRows); @@ -191,10 +377,11 @@ function SessionsList({ selected={focused && idx === visibleCursor} current={entry.sessionId === currentSessionId} previewWidth={previewWidth} + inner={inner} /> ))} - ); } @@ -204,6 +391,7 @@ interface SessionRowProps { selected: boolean; current: boolean; previewWidth: number; + inner: number; } function SessionRow({ @@ -211,18 +399,19 @@ function SessionRow({ selected, current, previewWidth, + inner, }: SessionRowProps): ReactElement { const preview = truncate(entry.preview, previewWidth); const marker = current ? theme.glyphs.assistantMarker : " "; const chevron = selected ? theme.glyphs.chevronRight : " "; return ( - - {chevron} {marker} {preview} - + {`${chevron} ${marker} ${preview}`} + ); } @@ -232,6 +421,7 @@ interface TasksListProps { focused: boolean; maxRows: number; previewWidth: number; + inner: number; } function TasksList({ @@ -240,12 +430,13 @@ function TasksList({ focused, maxRows, previewWidth, + inner, }: TasksListProps): ReactElement { if (tasks.length === 0) { return ( - - (no active tasks) - + + {"(no active tasks)"} + ); } const window = computeRowWindow(tasks.length, cursor, maxRows); @@ -268,10 +459,11 @@ function TasksList({ row={row} selected={focused && idx === visibleCursor} previewWidth={previewWidth} + inner={inner} /> ))} -