From a41090d698c0483095a7766c77906413580d39d2 Mon Sep 17 00:00:00 2001 From: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:52:31 +0300 Subject: [PATCH 1/4] feat(tui): per-message copy and try-again buttons First slice of the #175 UI round, ported onto current main. Every message carries a [copy] button; user messages also carry [try again], which re-runs the text through the same submit path Enter uses rather than a second code path that could drift from it. The clipboard layer is its own module: copy-to-clipboard owns the platform command (pbcopy / clip.exe / wl-copy / xclip), and clipboard-context exposes it through a provider so a test can observe a copy without touching the real clipboard. useClipboard falls back to the shared default writer, so the buttons work whether or not a provider is mounted. Also lands selection-passthrough (the honest answer to mouse text selection) and tasks-list-fit, both self-contained. Deliberately NOT in this slice: the run-mode / fusion files that ship in the same #175 round, since the fusion stack is still under review; and anything touching the contested files (tui-app, menu-popup, sidebar, theme) where main has moved since #175 was cut. Tests: 4828 total, no new failures. The two LlmHealthPoller cases that appear under full-suite load pass in isolation on this branch and on pristine main. --- src/tui/clipboard/clipboard-context.tsx | 59 +++ src/tui/clipboard/copy-to-clipboard.test.ts | 212 ++++++++++ src/tui/clipboard/copy-to-clipboard.ts | 211 ++++++++++ src/tui/clipboard/index.ts | 19 + src/tui/components/chat-copy-button.test.tsx | 294 ++++++++++++++ src/tui/components/chat-copy-button.tsx | 95 +++++ src/tui/components/chat-log.tsx | 24 +- .../components/chat-message-height.test.ts | 14 +- src/tui/components/chat-message-height.ts | 13 +- .../components/chat-try-again-button.test.tsx | 237 +++++++++++ src/tui/components/chat-try-again-button.tsx | 141 +++++++ src/tui/hooks/use-transient-status.ts | 53 +++ src/tui/mouse/selection-passthrough.test.ts | 151 +++++++ src/tui/mouse/selection-passthrough.ts | 151 +++++++ src/tui/tasks/tasks-list-fit.test.ts | 238 +++++++++++ src/tui/tasks/tasks-list-fit.ts | 380 ++++++++++++++++++ src/tui/theme/github-themes.test.ts | 67 --- 17 files changed, 2279 insertions(+), 80 deletions(-) create mode 100644 src/tui/clipboard/clipboard-context.tsx create mode 100644 src/tui/clipboard/copy-to-clipboard.test.ts create mode 100644 src/tui/clipboard/copy-to-clipboard.ts create mode 100644 src/tui/clipboard/index.ts create mode 100644 src/tui/components/chat-copy-button.test.tsx create mode 100644 src/tui/components/chat-copy-button.tsx create mode 100644 src/tui/components/chat-try-again-button.test.tsx create mode 100644 src/tui/components/chat-try-again-button.tsx create mode 100644 src/tui/hooks/use-transient-status.ts create mode 100644 src/tui/mouse/selection-passthrough.test.ts create mode 100644 src/tui/mouse/selection-passthrough.ts create mode 100644 src/tui/tasks/tasks-list-fit.test.ts create mode 100644 src/tui/tasks/tasks-list-fit.ts delete mode 100644 src/tui/theme/github-themes.test.ts 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/hooks/use-transient-status.ts b/src/tui/hooks/use-transient-status.ts new file mode 100644 index 00000000..2019f29e --- /dev/null +++ b/src/tui/hooks/use-transient-status.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * A status value that falls back to `idle` on its own after + * `revertAfterMs` — the "label flips for two seconds, then reverts" + * pattern the chat-log buttons are built on. + * + * The bookkeeping is fussier than it looks, which is why both buttons + * share it rather than each carrying a copy: + * + * - **The timer id lives in a ref.** The chat log repaints on every + * streamed token, so an id kept in component state is replaced + * mid-flight and the old timeout fires against a stale closure, + * stranding the label on its badge. + * - **A re-flash restarts the window** instead of stacking a second + * timeout behind it — otherwise the older timeout clears the badge + * early and the label blinks mid-feedback. + * - **Unmount clears it.** A message can scroll out of the ring buffer + * while its badge is still up. + */ +export function useTransientStatus( + idle: T, + revertAfterMs: number, +): [T, (next: T) => void] { + const [status, setStatus] = useState(idle); + const timerRef = useRef(null); + const mountedRef = useRef(true); + // Read through a ref so `flash` never has to be re-created when the + // caller passes a fresh object/array as the idle value. + const idleRef = useRef(idle); + idleRef.current = idle; + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + }, []); + const flash = useCallback( + (next: T) => { + if (!mountedRef.current) return; + setStatus(next); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + if (mountedRef.current) setStatus(idleRef.current); + }, revertAfterMs); + }, + [revertAfterMs], + ); + return [status, flash]; +} diff --git a/src/tui/mouse/selection-passthrough.test.ts b/src/tui/mouse/selection-passthrough.test.ts new file mode 100644 index 00000000..6e41badb --- /dev/null +++ b/src/tui/mouse/selection-passthrough.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TuiMouseEvent } from "./mouse-event.js"; +import { + createSelectionPassthrough, + DEFAULT_SELECTION_WINDOW_MS, + type SelectionSuspendable, +} from "./selection-passthrough.js"; + +function press(overrides: Partial = {}): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x: 4, + y: 7, + shift: false, + alt: false, + ctrl: false, + ...overrides, + }; +} + +interface FakeTracking extends SelectionSuspendable { + suspends: number; + resumes: number; +} + +function makeTracking(): FakeTracking { + let suspended = false; + return { + suspends: 0, + resumes: 0, + suspend(): void { + suspended = true; + this.suspends += 1; + }, + resume(): void { + suspended = false; + this.resumes += 1; + }, + isSuspended: () => suspended, + }; +} + +describe("createSelectionPassthrough", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("hands the terminal back its drag on a shift-modified press", () => { + const tracking = makeTracking(); + const messages: string[] = []; + const passthrough = createSelectionPassthrough({ + tracking: () => tracking, + notify: (m) => messages.push(m), + }); + expect(passthrough.observe(press({ shift: true }))).toBe(true); + expect(tracking.isSuspended()).toBe(true); + expect(messages[0]).toContain("drag to select"); + }); + + it("leaves ordinary clicks alone so existing targets keep working", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ tracking: () => tracking }); + expect(passthrough.observe(press())).toBe(false); + expect(passthrough.observe(press({ ctrl: true }))).toBe(false); + expect(passthrough.observe(press({ alt: true }))).toBe(false); + expect( + passthrough.observe({ ...press({ shift: true }), kind: "release" }), + ).toBe(false); + expect( + passthrough.observe({ + ...press({ shift: true }), + kind: "wheel", + wheel: "up", + button: "none", + }), + ).toBe(false); + expect(tracking.suspends).toBe(0); + }); + + it("restores reporting when the window expires", () => { + const tracking = makeTracking(); + const messages: string[] = []; + const passthrough = createSelectionPassthrough({ + tracking: () => tracking, + notify: (m) => messages.push(m), + }); + passthrough.observe(press({ shift: true })); + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS - 1); + expect(tracking.isSuspended()).toBe(true); + vi.advanceTimersByTime(1); + expect(tracking.isSuspended()).toBe(false); + expect(messages[1]).toContain("mouse back on"); + }); + + it("does not extend the window with a report that was already in flight", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ + tracking: () => tracking, + windowMs: 1_000, + }); + passthrough.observe(press({ shift: true })); + vi.advanceTimersByTime(900); + // A press the terminal had already sent before it saw our disable. + expect(passthrough.observe(press({ shift: true }))).toBe(true); + expect(tracking.suspends).toBe(1); + vi.advanceTimersByTime(100); + expect(tracking.isSuspended()).toBe(false); + }); + + it("resumes early on request", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ tracking: () => tracking }); + passthrough.observe(press({ shift: true })); + passthrough.resumeNow(); + expect(tracking.isSuspended()).toBe(false); + // The pending timer must be gone, not merely ineffective. + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS); + expect(tracking.resumes).toBe(1); + }); + + it("does nothing when mouse support is off entirely", () => { + const passthrough = createSelectionPassthrough({ tracking: () => null }); + expect(passthrough.observe(press({ shift: true }))).toBe(false); + }); + + it("never resumes a controller that was switched off mid-window", () => { + // `/mouse off` during the window: the operator asked for reporting to + // stay gone, and the pending timer must not undo that. + const tracking = makeTracking(); + let live: SelectionSuspendable | null = tracking; + const passthrough = createSelectionPassthrough({ tracking: () => live }); + passthrough.observe(press({ shift: true })); + live = null; + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS); + expect(tracking.resumes).toBe(0); + }); + + it("drops the pending resume on dispose", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ tracking: () => tracking }); + passthrough.observe(press({ shift: true })); + passthrough.dispose(); + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS); + expect(tracking.resumes).toBe(0); + }); +}); diff --git a/src/tui/mouse/selection-passthrough.ts b/src/tui/mouse/selection-passthrough.ts new file mode 100644 index 00000000..9ef8036c --- /dev/null +++ b/src/tui/mouse/selection-passthrough.ts @@ -0,0 +1,151 @@ +/** + * Giving the terminal its drag-to-select back, for one selection. + * + * ## Why this exists rather than in-app selection + * + * Selecting text inside the app — track the drag, paint an inverse-video + * span, copy the range — was considered and rejected. It needs three + * things this design does not have and would not cheaply gain: + * + * 1. Motion reports (1002/1003), so the highlight follows the drag. + * Those are off on purpose (see `mouse-tracking.ts`) and would have + * to come back on, at least for the duration of a drag. + * 2. A readback of *what character is painted in each cell*. Ink has + * no framebuffer API — `measureElement` returns sizes, and + * `mouse-registry` deliberately reconstructs geometry from Yoga + * rather than from painted text. There is nothing to slice. + * 3. Every component that could fall under the selection rectangle + * would have to become selection-aware to paint the highlight. + * + * And the result would still be worse than what the terminal already + * does: it could not select the scrollback above the alt screen, it + * could not honour the terminal's own copy-on-select or ⌘C, and it would + * copy the *rendered* text — borders, wrap points and all — where the + * `[copy]` button copies the message source. + * + * ## What this does instead + * + * On the terminals that matter most (iTerm2, kitty, WezTerm, Alacritty, + * foot, Windows Terminal, VS Code) **Shift+drag already works**: the + * terminal keeps the gesture for itself and never reports it, so native + * selection is one modifier away and costs zero code. Apple Terminal has + * no such bypass; there, a shift-modified press is *reported to the app* + * instead. + * + * That asymmetry is the trigger. A shift-modified press arriving here is + * positive evidence that this terminal did not bypass — i.e. exactly the + * terminal that needs help — and that the operator was reaching for a + * selection. So we hand reporting back for a short window and say so. + * The gesture that produced the trigger is lost (the terminal has + * already reported it rather than selected with it), so the operator + * drags a second time; that is the price of not having a bypass, and it + * is still cheaper than discovering `/mouse off`. + * + * On a terminal that *does* bypass, this code never fires. Being inert + * where it is not needed is the point. + * + * Reporting always comes back on its own after `windowMs`. Resuming on + * activity is not possible: while suspended the app receives no mouse + * events at all, which is the whole idea. + */ +import type { TuiMouseEvent } from "./mouse-event.js"; + +/** The part of `MouseTrackingController` this needs. */ +export interface SelectionSuspendable { + suspend(): void; + resume(): void; + isSuspended(): boolean; +} + +export interface SelectionPassthroughOptions { + /** + * Reads the live tracking controller. A getter rather than a value + * because `/mouse on|off` replaces the controller underneath us, and a + * captured one would resume a controller nobody is using any more. + */ + readonly tracking: () => SelectionSuspendable | null; + /** Surfaces the state change to the operator. */ + readonly notify?: (message: string) => void; + readonly windowMs?: number; + /** Injected for fake-timer tests. */ + readonly setTimer?: (fn: () => void, ms: number) => NodeJS.Timeout; + readonly clearTimer?: (handle: NodeJS.Timeout) => void; +} + +export interface SelectionPassthrough { + /** + * Offers a decoded mouse event. Returns `true` when the event was + * consumed as a selection gesture and must **not** reach the hit-test + * registry. + */ + observe(event: TuiMouseEvent): boolean; + /** Ends the window early. */ + resumeNow(): void; + /** Cancels any pending resume. Called during TUI teardown. */ + dispose(): void; +} + +/** + * Long enough to line up a drag on a long reply without rushing, short + * enough that an operator who triggered it by accident does not conclude + * the mouse broke. + */ +export const DEFAULT_SELECTION_WINDOW_MS = 10_000; + +export function createSelectionPassthrough( + options: SelectionPassthroughOptions, +): SelectionPassthrough { + const { + tracking, + notify, + windowMs = DEFAULT_SELECTION_WINDOW_MS, + setTimer = setTimeout, + clearTimer = clearTimeout, + } = options; + let timer: NodeJS.Timeout | null = null; + + const cancelTimer = (): void => { + if (!timer) return; + clearTimer(timer); + timer = null; + }; + + const resumeNow = (): void => { + cancelTimer(); + const controller = tracking(); + // `/mouse off` during the window: reporting is already gone for good + // and there is nothing to restore or announce. + if (!controller || !controller.isSuspended()) return; + controller.resume(); + notify?.("mouse back on — clicks and the wheel work again"); + }; + + return { + observe(event: TuiMouseEvent): boolean { + if (event.kind !== "press" || !event.shift) return false; + const controller = tracking(); + if (!controller) return false; + if (controller.isSuspended()) { + // Cannot happen while reporting is off, but a report already in + // flight when we suspended can still land here. Do not restart + // the window on it — that would be the terminal extending its + // own pause. + return true; + } + controller.suspend(); + cancelTimer(); + timer = setTimer(() => { + timer = null; + resumeNow(); + }, windowMs); + const seconds = Math.round(windowMs / 1000); + notify?.( + `text selection: mouse paused for ${seconds}s — drag to select, ` + + "then copy the way you normally would in this terminal", + ); + return true; + }, + resumeNow, + dispose: cancelTimer, + }; +} diff --git a/src/tui/tasks/tasks-list-fit.test.ts b/src/tui/tasks/tasks-list-fit.test.ts new file mode 100644 index 00000000..79382e66 --- /dev/null +++ b/src/tui/tasks/tasks-list-fit.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { + computeTaskListLayout, + computeTasksListFit, + describeEmptyTaskList, + fitTaskListHints, + formatTaskListHeader, + formatTaskRowCells, + taskRowWidth, + TASK_LIST_HINTS, +} from "./tasks-list-fit.js"; +import type { TaskSummaryRow } from "./tasks-panel-state.js"; + +const NOW = Date.UTC(2026, 7, 19, 12, 0, 0); + +function row(overrides: Partial = {}): TaskSummaryRow { + return { + id: "t-1", + status: "pending", + origin: "cli", + triggerSource: null, + sessionId: "s-114e4b54-aba7-4264-9aff-07f86eebe388", + userMessage: + "task number 1 — do the thing that needs doing regularly, at length", + scheduleKind: "cron", + scheduleLabel: "cron: 0 1 * * * (Europe/Berlin)", + recurring: true, + scheduledFor: NOW + 7 * 3_600_000, + createdAt: NOW, + updatedAt: NOW, + startedAt: null, + completedAt: null, + attempts: 0, + maxAttempts: 3, + lastError: null, + ...overrides, + }; +} + +/** + * The panel widths the left rail actually leaves behind: 88 on a + * 120-column terminal, 73 on a 100-column one, 78 once the rail + * collapses at 80. + */ +const REAL_WIDTHS = [88, 78, 73]; + +describe("task list column layout", () => { + for (const width of REAL_WIDTHS) { + it(`fits a row into ${width} columns`, () => { + const layout = computeTaskListLayout(width); + expect(taskRowWidth(layout)).toBeLessThanOrEqual(width); + // Every column an operator needs to tell two cron jobs apart + // survives at the widths the app is actually used at. + expect(layout.status).toBeGreaterThan(0); + expect(layout.schedule).toBeGreaterThan(0); + expect(layout.nextRun).toBeGreaterThan(0); + expect(layout.message).toBeGreaterThan(0); + }); + } + + it("never plans a row wider than the panel, at any width", () => { + for (let width = 12; width <= 200; width += 1) { + const layout = computeTaskListLayout(width); + expect(taskRowWidth(layout)).toBeLessThanOrEqual(width); + } + }); + + it("spends slack on the message column", () => { + const narrow = computeTaskListLayout(88); + const wide = computeTaskListLayout(140); + expect(wide.message).toBeGreaterThan(narrow.message); + }); + + it("drops the session id before the schedule loses its expression", () => { + const layout = computeTaskListLayout(56); + expect(layout.session).toBe(0); + expect(layout.schedule).toBeGreaterThan(0); + }); +}); + +describe("task row cells", () => { + for (const width of REAL_WIDTHS) { + it(`renders one line of exactly the planned width at ${width}`, () => { + const layout = computeTaskListLayout(width); + const cells = formatTaskRowCells(row(), layout, NOW); + const line = [ + `▸ ${cells.status}`, + cells.schedule, + cells.nextRun, + cells.session, + cells.message, + ] + .filter((cell) => cell.length > 0) + .join(" "); + expect(line.length).toBeLessThanOrEqual(width); + expect(line).not.toContain("\n"); + }); + } + + it("keeps the header aligned with its own columns", () => { + const layout = computeTaskListLayout(88); + const header = formatTaskListHeader(layout); + const cells = formatTaskRowCells(row(), layout, NOW); + expect(header.indexOf("status")).toBe(2); + expect(header.indexOf("schedule")).toBe(2 + cells.status.length + 1); + expect(header.length).toBeLessThanOrEqual(88); + }); + + it("truncates rather than wraps a long message", () => { + const layout = computeTaskListLayout(73); + const cells = formatTaskRowCells( + row({ userMessage: "x".repeat(400) }), + layout, + NOW, + ); + expect(cells.message.length).toBe(layout.message); + expect(cells.message.endsWith("…")).toBe(true); + }); +}); + +describe("footer hints", () => { + for (const width of REAL_WIDTHS) { + it(`fits the hint strip on one line at ${width}`, () => { + const hints = fitTaskListHints(width); + expect(hints.length).toBeLessThanOrEqual(width); + // The keys that let a newcomer act, not just look, always survive. + expect(hints).toContain("Enter detail"); + expect(hints).toContain("n new"); + expect(hints).toContain("c cancel"); + }); + } + + it("keeps every hint when the panel is wide enough", () => { + const hints = fitTaskListHints(200); + for (const hint of TASK_LIST_HINTS) expect(hints).toContain(hint); + }); + + it("still names one key on an absurdly narrow panel", () => { + const hints = fitTaskListHints(6); + expect(hints.length).toBeLessThanOrEqual(6); + expect(hints.length).toBeGreaterThan(0); + }); +}); + +describe("row budget", () => { + /** Rows a fit actually draws, given how many rows the filter matched. */ + function drawnRows(budget: number, totalRows: number): number { + const fit = computeTasksListFit(budget, totalRows); + const window = Math.min(fit.listRows, totalRows); + return ( + (fit.header ? 1 : 0) + + (fit.hints ? 1 : 0) + + (fit.hintsSpacer ? 1 : 0) + + fit.scrollMarkerRows + + window + ); + } + + it("never plans more rows than the budget", () => { + for (let budget = 1; budget <= 40; budget += 1) { + for (const totalRows of [1, 3, 12, 200]) { + expect(drawnRows(budget, totalRows)).toBeLessThanOrEqual(budget); + } + } + }); + + it("reserves both scroll markers once the table cannot show everything", () => { + // Reserving only the marker that happens to be on screen overflows + // by one row as soon as the cursor scrolls the other one into view. + const fit = computeTasksListFit(12, 100); + expect(fit.scrollMarkerRows).toBe(2); + expect(computeTasksListFit(12, 3).scrollMarkerRows).toBe(0); + }); + + it("keeps header and hints while the budget can carry a real list", () => { + const fit = computeTasksListFit(20, 12); + expect(fit.header).toBe(true); + expect(fit.hints).toBe(true); + expect(fit.hintsSpacer).toBe(true); + expect(fit.listRows).toBeGreaterThanOrEqual(12); + }); + + it("sheds the spacer, then the hints, then the header", () => { + expect(computeTasksListFit(5, 3)).toMatchObject({ + header: true, + hints: true, + hintsSpacer: false, + }); + expect(computeTasksListFit(4, 3)).toMatchObject({ + header: true, + hints: false, + hintsSpacer: false, + }); + expect(computeTasksListFit(2, 3)).toMatchObject({ + header: false, + hints: false, + }); + }); + + it("always leaves at least one task row", () => { + for (let budget = 1; budget <= 6; budget += 1) { + expect(computeTasksListFit(budget, 50).listRows).toBeGreaterThanOrEqual(1); + } + }); +}); + +describe("empty table copy", () => { + it("does not blame a filter when the queue is simply empty", () => { + const text = describeEmptyTaskList({ + totalRows: 0, + filterStatus: "all", + searchQuery: "", + }); + expect(text.headline).not.toContain("filter"); + expect(text.headline).toContain("`n`"); + expect(text.detail).toContain("cron"); + }); + + it("names the filter that is hiding the rows", () => { + const text = describeEmptyTaskList({ + totalRows: 12, + filterStatus: "running", + searchQuery: "", + }); + expect(text.headline).toContain("running"); + expect(text.detail).toContain("`f`"); + }); + + it("names the search that is hiding the rows", () => { + const text = describeEmptyTaskList({ + totalRows: 12, + filterStatus: "all", + searchQuery: "digest ", + }); + expect(text.headline).toContain("digest"); + expect(text.detail).toContain("Esc"); + }); +}); diff --git a/src/tui/tasks/tasks-list-fit.ts b/src/tui/tasks/tasks-list-fit.ts new file mode 100644 index 00000000..30c7f103 --- /dev/null +++ b/src/tui/tasks/tasks-list-fit.ts @@ -0,0 +1,380 @@ +import type { TaskSummaryRow } from "./tasks-panel-state.js"; +import { formatRelativeMs } from "./tasks-summary.js"; + +/** + * Fit maths for the Tasks list — how wide each column may be, which + * footer hints survive, and how many rows the table may draw. + * + * The table used to lay out its columns from constants that added up to + * ~123 characters no matter how wide the panel actually was. That was + * survivable while the debug pane owned the full terminal; once the + * left rail became permanent app frame the panel only gets + * `computeChatWidth()` columns (88 on a 120-column terminal, 73 on a + * 100-column one), so every row wrapped onto a second line — which + * collided the columns into each other (`pendincron: 0 1 * * *`) and + * silently doubled the height of the table. Ink 7 does not clip an + * over-tall frame, it paints later lines over earlier ones (see + * `../row-window.ts`), so the doubled height then overwrote the filter + * bar and the header row with task text and diagnostics fragments. + * + * The height side is the same story from the other axis: the list used + * to treat its whole row budget as *table rows* and then draw a header, + * two scroll markers and a hint strip on top of it, so even with + * one-line rows the panel asked for ~5 rows more than the debug pane + * had budgeted — and those 5 rows are what Ink paints over the top of + * the filter bar. + * + * All of that is geometry, not rendering, so it lives here as pure + * functions: the component asks for a layout and renders it, and the + * invariants — a row never exceeds the panel width, a frame never + * exceeds its row budget — can be unit-tested as a table instead of + * through screenshots. + */ + +/** Chevron gutter in front of every row: the glyph plus one space. */ +const CHEVRON_COLUMNS = 2; +/** Single space between two columns. */ +const COLUMN_GAP = 1; +/** + * One spare column held back from the row. Ink wraps a row whose + * content matches the box width exactly as soon as anything (a + * double-width glyph in a message, a padding change upstream) costs one + * more cell than we predicted, and a wrapped row is the very failure + * this module exists to prevent — so we buy the insurance. + */ +const SAFETY_COLUMNS = 1; + +/** Widest `TaskStatus` string (`cancelled`). */ +const STATUS_WIDTH = 9; +/** Narrowest status that still separates `pending` from `completed`. */ +const STATUS_MIN_WIDTH = 4; +const SCHEDULE_WIDTH = 22; +const SCHEDULE_MIN_WIDTH = 12; +const NEXT_RUN_WIDTH = 9; +const NEXT_RUN_MIN_WIDTH = 7; +const SESSION_WIDTH = 10; +/** Below this the message column stops carrying any information. */ +const MESSAGE_MIN_WIDTH = 16; + +/** + * Column widths for one table row, in characters. `0` means the column + * is dropped entirely (no cell, no separating gap). + */ +export interface TaskListLayout { + status: number; + schedule: number; + nextRun: number; + session: number; + message: number; +} + +type MutableLayout = { -readonly [K in keyof TaskListLayout]: number }; + +/** + * Order in which columns give up room when the panel is too narrow for + * all of them. The message is what identifies a task to a human, so it + * is defended to `MESSAGE_MIN_WIDTH` while everything else shrinks; the + * session id goes early because it is only ever a truncated prefix here + * and the detail view prints it in full. + */ +const DEGRADATIONS: ReadonlyArray<(layout: MutableLayout) => void> = [ + (layout) => { + layout.schedule = SCHEDULE_MIN_WIDTH; + }, + (layout) => { + layout.session = 0; + }, + (layout) => { + layout.nextRun = NEXT_RUN_MIN_WIDTH; + }, + (layout) => { + layout.schedule = 0; + }, + (layout) => { + layout.nextRun = 0; + }, + (layout) => { + layout.status = STATUS_MIN_WIDTH; + }, +]; + +/** Width a laid-out row occupies, including the chevron and every gap. */ +export function taskRowWidth(layout: TaskListLayout): number { + return ( + CHEVRON_COLUMNS + + layout.status + + cellWidth(layout.schedule) + + cellWidth(layout.nextRun) + + cellWidth(layout.session) + + cellWidth(layout.message) + ); +} + +function cellWidth(width: number): number { + return width > 0 ? width + COLUMN_GAP : 0; +} + +/** + * Resolve the column widths for a panel `width` columns wide. The + * result always satisfies `taskRowWidth(layout) <= width`, which is + * what keeps a row on one line. + */ +export function computeTaskListLayout(width: number): TaskListLayout { + const usable = Math.max(0, Math.floor(width) - SAFETY_COLUMNS); + const layout: MutableLayout = { + status: STATUS_WIDTH, + schedule: SCHEDULE_WIDTH, + nextRun: NEXT_RUN_WIDTH, + session: SESSION_WIDTH, + message: MESSAGE_MIN_WIDTH, + }; + for (const degrade of DEGRADATIONS) { + if (taskRowWidth(layout) <= usable) break; + degrade(layout); + } + // Whatever survives the degradations goes to the message: a wide + // terminal should spend its extra columns on the prompt text, not on + // padding between fixed-width cells. + const slack = usable - taskRowWidth(layout); + if (slack > 0) layout.message += slack; + if (taskRowWidth(layout) > usable) { + layout.message = Math.max( + 0, + layout.message - (taskRowWidth(layout) - usable), + ); + } + if (taskRowWidth(layout) > usable) { + // Nothing but the status fits. Still better than a wrapped row: + // the operator can read the table's shape and widen the window. + layout.schedule = 0; + layout.nextRun = 0; + layout.session = 0; + layout.message = 0; + layout.status = Math.max(1, usable - CHEVRON_COLUMNS); + } + return layout; +} + +/** Text of one rendered cell, already padded / truncated to its width. */ +export interface TaskRowCells { + status: string; + /** Columns after the chevron+status pair, in render order. Empty when dropped. */ + schedule: string; + nextRun: string; + session: string; + message: string; +} + +/** + * Project a summary row onto a layout. Every cell comes back at exactly + * its column width (the message is only truncated — trailing padding on + * the last column buys nothing), so the header and the rows below it + * can never drift apart. + */ +export function formatTaskRowCells( + row: TaskSummaryRow, + layout: TaskListLayout, + now: number, +): TaskRowCells { + return { + status: padCell(row.status, layout.status), + schedule: padCell(row.scheduleLabel, layout.schedule), + nextRun: padCell(formatRelativeMs(row.scheduledFor, now), layout.nextRun), + session: padCell(row.sessionId ?? "—", layout.session), + message: truncate(row.userMessage, layout.message), + }; +} + +/** + * Header labels for a layout. Built from the same widths as the rows so + * the column titles always sit over their own data — the old header was + * a hand-spaced string literal, which stopped lining up the first time + * anyone touched a column width. + */ +export function formatTaskListHeader(layout: TaskListLayout): string { + const cells = [ + " ".repeat(CHEVRON_COLUMNS) + padCell("status", layout.status), + padCell("schedule", layout.schedule), + padCell("next-run", layout.nextRun), + padCell("session", layout.session), + truncate("message", layout.message), + ].filter((cell) => cell.length > 0); + return cells.join(" ".repeat(COLUMN_GAP)).trimEnd(); +} + +function padCell(text: string, width: number): string { + if (width <= 0) return ""; + return truncate(text, width).padEnd(width); +} + +function truncate(text: string, max: number): string { + if (max <= 0) return ""; + if (text.length <= max) return text; + if (max === 1) return "…"; + return `${text.slice(0, max - 1)}…`; +} + +/** + * Footer hints in priority order. The tail is dropped first when the + * panel is too narrow to spell all of them, so the keys that let a + * newcomer *do* something (move, open, create, cancel, run) have to + * come before the ones that only adjust the view. + */ +export const TASK_LIST_HINTS: readonly string[] = [ + "j/k move", + "Enter detail", + "n new", + "c cancel", + "R run-now", + "f filter", + "/ search", + "r refresh", + "a auto", + "Esc clear search", +]; + +const HINT_SEPARATOR = " · "; + +/** + * Longest prefix of `TASK_LIST_HINTS` that fits on one line. One line + * is the point: the hint strip used to wrap onto a second row that the + * height budget had not reserved, which is one of the rows that pushed + * the panel over its budget and into Ink's overpainting. + */ +export function fitTaskListHints(width: number): string { + const usable = Math.max(0, Math.floor(width) - SAFETY_COLUMNS); + let line = ""; + for (const hint of TASK_LIST_HINTS) { + const next = line.length === 0 ? hint : `${line}${HINT_SEPARATOR}${hint}`; + if (next.length > usable) break; + line = next; + } + // Even a panel too narrow for the first hint gets *something*: a + // truncated `j/k move` still says the list is navigable. + if (line.length === 0) return truncate(TASK_LIST_HINTS[0] ?? "", usable); + return line; +} +/** How the list spends a row budget across its chrome and its rows. */ +export interface TasksListFit { + /** Rows the table body may draw. */ + listRows: number; + /** Whether the column-header row is drawn. */ + header: boolean; + /** Whether the footer hint strip is drawn. */ + hints: boolean; + /** Whether a blank row separates the table from the hints. */ + hintsSpacer: boolean; + /** Rows reserved for the `↑ N above` / `↓ N below` markers. */ + scrollMarkerRows: number; +} + +/** + * Rows the table keeps before it starts shedding chrome. Below three + * the list stops being a list, so the header and then the hints go + * first — a garbled panel helps nobody, but neither does a header with + * a single row under it. + */ +const MIN_LIST_ROWS = 3; + +/** + * Chrome combinations in the order they are given up. Everything is + * kept while it fits; then the blank spacer, then the hints, then the + * header. + */ +const CHROME_LADDER: ReadonlyArray< + Pick +> = [ + { header: true, hints: true, hintsSpacer: true }, + { header: true, hints: true, hintsSpacer: false }, + { header: true, hints: false, hintsSpacer: false }, + { header: false, hints: false, hintsSpacer: false }, +]; + +/** + * Split `budget` rows between the table's chrome and its body so the + * rendered frame is never taller than the budget. `totalRows` is the + * number of rows the filter currently matches — it decides whether the + * scroll markers need reserving at all. + */ +export function computeTasksListFit( + budget: number, + totalRows: number, +): TasksListFit { + const rows = Math.max(1, Math.floor(budget)); + for (const [index, chrome] of CHROME_LADDER.entries()) { + const cost = + (chrome.header ? 1 : 0) + + (chrome.hints ? 1 : 0) + + (chrome.hintsSpacer ? 1 : 0); + const listRows = rows - cost; + const last = index === CHROME_LADDER.length - 1; + if (listRows >= MIN_LIST_ROWS || last) { + return withScrollMarkers(chrome, listRows, totalRows); + } + } + /* c8 ignore next 2 -- the ladder's last entry always returns above */ + return withScrollMarkers( + { header: false, hints: false, hintsSpacer: false }, + rows, + totalRows, + ); +} + +/** + * Reserve both marker rows as soon as the table cannot show everything. + * Reserving lazily (only for the marker that is on screen right now) + * overflows by one row the moment the cursor scrolls far enough for the + * second marker to appear, and one row is all Ink needs to overpaint. + */ +function withScrollMarkers( + chrome: Pick, + listRows: number, + totalRows: number, +): TasksListFit { + const available = Math.max(1, listRows); + const markers = + totalRows > available ? Math.min(2, Math.max(0, available - 1)) : 0; + return { + ...chrome, + scrollMarkerRows: markers, + listRows: Math.max(1, available - markers), + }; +} + +/** Copy for an empty table, split so a narrow panel can drop the detail. */ +export interface EmptyTaskListText { + headline: string; + /** Second line — context, safe to drop when rows are scarce. */ + detail: string | null; +} + +/** + * What to say when the table has nothing to draw. A fresh install hits + * this screen first, and it used to answer with "no tasks match the + * current filter" even when the queue was simply empty — sending a + * first-time operator hunting for a filter that was never set instead + * of telling them what a task is and which key makes one. + */ +export function describeEmptyTaskList(args: { + totalRows: number; + filterStatus: string; + searchQuery: string; +}): EmptyTaskListText { + const query = args.searchQuery.trim(); + if (args.totalRows === 0) { + return { + headline: "no tasks yet — press `n` to create one.", + detail: "tasks fire on a cron / interval schedule, or once at a time.", + }; + } + if (query.length > 0) { + return { + headline: `nothing matches “${query}”.`, + detail: "Esc clears the search · `f` cycles the status filter.", + }; + } + return { + headline: `no ${args.filterStatus} tasks right now.`, + detail: "`f` cycles the status filter · `r` refreshes the list.", + }; +} diff --git a/src/tui/theme/github-themes.test.ts b/src/tui/theme/github-themes.test.ts deleted file mode 100644 index 31fe5e37..00000000 --- a/src/tui/theme/github-themes.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { THEMES, type TuiColors } from "./theme.js"; - -const COLOR_KEYS: (keyof TuiColors)[] = [ - "user", - "assistant", - "system", - "reasoning", - "tool", - "toolOk", - "toolError", - "accent", - "accentSoft", - "border", - "muted", - "error", - "warn", - "warnStrong", - "success", - "info", -]; - -describe("github themes", () => { - it("github-dark defines all 16 colour keys with the documented Primer hexes", () => { - const c = THEMES["github-dark"].colors; - for (const key of COLOR_KEYS) { - expect(c[key]).toMatch(/^#[0-9a-f]{6}$/); - } - expect(c.accent).toBe("#4493f8"); - expect(c.user).toBe("#4493f8"); - expect(c.tool).toBe("#4493f8"); - expect(c.info).toBe("#4493f8"); - expect(c.assistant).toBe("#3fb950"); - expect(c.toolOk).toBe("#3fb950"); - expect(c.success).toBe("#3fb950"); - expect(c.reasoning).toBe("#ab7df8"); - expect(c.toolError).toBe("#f85149"); - expect(c.error).toBe("#f85149"); - expect(c.warn).toBe("#d29922"); - expect(c.warnStrong).toBe("#db6d28"); - expect(c.muted).toBe("#9198a1"); - expect(c.system).toBe("#9198a1"); - expect(c.border).toBe("#3d444d"); - }); - - it("github-light defines all 16 colour keys with the documented Primer hexes", () => { - const c = THEMES["github-light"].colors; - for (const key of COLOR_KEYS) { - expect(c[key]).toMatch(/^#[0-9a-f]{6}$/); - } - expect(c.accent).toBe("#0969da"); - expect(c.user).toBe("#0969da"); - expect(c.tool).toBe("#0969da"); - expect(c.info).toBe("#0969da"); - expect(c.assistant).toBe("#1a7f37"); - expect(c.toolOk).toBe("#1a7f37"); - expect(c.success).toBe("#1a7f37"); - expect(c.reasoning).toBe("#8250df"); - expect(c.toolError).toBe("#d1242f"); - expect(c.error).toBe("#d1242f"); - expect(c.warn).toBe("#9a6700"); - expect(c.warnStrong).toBe("#bc4c00"); - expect(c.muted).toBe("#59636e"); - expect(c.system).toBe("#59636e"); - expect(c.border).toBe("#d1d9e0"); - }); -}); From 37375c8eca50c57c96e0f320337f2dc64c70b919 Mon Sep 17 00:00:00 2001 From: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:12:16 +0300 Subject: [PATCH 2/4] feat(tui): the rail moves left and becomes the app frame Second slice of the #175 UI round. The rail leads the row container, so it renders on the LEFT, and it now carries the brand lockup, the version and a Menu button alongside Sessions and Tasks. It is drawn in every mode, not just chat: switching to a panel used to take all the chrome off screen. Three things came out of porting it rather than from the branch: - The one-row status bar STAYS. #175 removed it on the grounds that the rail carries the same four things, but its rail never grew the breadcrumb, so removing the bar left no indicator of where you are. The bar keeps the breadcrumb and drops its own brand lockup while the rail is up, since two copies read as a rendering bug. - SIDEBAR_CHROME_ROWS goes 5 -> 13. The rail spends those rows on the mark, the version line and the Menu button. Measured off the rendered component, not estimated: at 24 rows the old constant had the rail drawing 28 rows into a 24-row terminal, and Ink 7 overlaps rather than clips, which garbles the whole frame. - main's layout.ts is kept over #175's. It has SIDEBAR_OUTER_ROWS and SIDEBAR_MIN_ROWS, which #175 predates; only the constant is retuned. Theme gains railBackground / railForeground / railMuted (additive; no palette key is removed) and logo.tsx gains RAIL_MARK, the same drawing as the splash rasterised to a 6x4 cell. Tests: 4830 total, no new failures. The layout and smoke expectations that encoded the old chrome are updated, not deleted: the row-budget test still pins the 2:1 ratio, and the Tab-focus test still branches on whether the rail is up. --- src/tui/components/logo.tsx | 9 + src/tui/components/sidebar.test.tsx | 13 +- src/tui/components/sidebar.tsx | 308 +++++++++++++++++++++++----- src/tui/components/status-bar.tsx | 25 ++- src/tui/layout.test.ts | 14 +- src/tui/layout.ts | 10 +- src/tui/theme/github-themes.test.ts | 71 +++++++ src/tui/theme/theme-palettes.ts | 35 +++- src/tui/theme/theme.ts | 18 ++ src/tui/tui-app.test.tsx | 18 +- src/tui/tui-app.tsx | 37 ++-- 11 files changed, 466 insertions(+), 92 deletions(-) create mode 100644 src/tui/theme/github-themes.test.ts 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/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} /> ))} -