diff --git a/CHANGELOG.md b/CHANGELOG.md index b8bb68f76..7b06076d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,10 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions drag selection in the transcript writes the selected text to the system clipboard on mouse-up and flashes a short status line. Alt+M still hands the mouse back for native terminal selection; Alt+C remains the keyboard copy - path for whole messages, tool outputs, and diffs. + path for whole messages, tool outputs, and diffs. Highlight clears + immediately; status flash only after the clipboard write settles — success + shows the preview, throw/reject shows `Copy failed` (same honesty on Alt+C + structured copy). - **Install-aware upgrade notice.** When a newer GitHub release exists, a non-blocking startup notice names the running and latest versions and the right upgrade step for Homebrew, source/Bun, deb, release binary, or diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 4198023d8..df056c33d 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -206,12 +206,6 @@ export type CompactorConfig = { maxAnchorTurns: number; }; -const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { - keepRecentTurns: 6, - summaryMaxChars: 2000, - maxAnchorTurns: 8, -}; - // Recent turns kept verbatim by both real pruning-compactor registrations // (the main session and sub-agents). Exported so callers that need to know // in advance whether a compaction would do anything — the compaction @@ -219,6 +213,12 @@ const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { // an independent literal that can silently drift out of sync. export const COMPACTOR_KEEP_RECENT_TURNS = 6; +const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { + keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, + summaryMaxChars: 2000, + maxAnchorTurns: 8, +}; + // `apply` below no-ops at or below this turn count: keeping `keepRecentTurns` // turns plus at least one more is what makes pruning worth doing at all. export function compactorNoOpFloor(keepRecentTurns: number): number { diff --git a/src/tui/copy-path.test.ts b/src/tui/copy-path.test.ts index cf153248f..86f467674 100644 --- a/src/tui/copy-path.test.ts +++ b/src/tui/copy-path.test.ts @@ -7,6 +7,7 @@ import { formatCopyText, pickCopyRow, streamLogMarkdown, + writeClipboard, } from "./copy-path" import type { StreamRow } from "./stream" @@ -30,6 +31,79 @@ describe("classifyCopy", () => { }) }) +describe("writeClipboard", () => { + test("sync success runs onSuccess", () => { + const events: string[] = [] + writeClipboard( + { + writeText: (text) => { + events.push(`write:${text}`) + }, + }, + "hi", + { + onSuccess: () => events.push("ok"), + onFailure: () => events.push("fail"), + }, + ) + expect(events).toEqual(["write:hi", "ok"]) + }) + + test("sync throw runs onFailure", () => { + const events: string[] = [] + writeClipboard( + { + writeText: () => { + throw new Error("nope") + }, + }, + "hi", + { + onSuccess: () => events.push("ok"), + onFailure: () => events.push("fail"), + }, + ) + expect(events).toEqual(["fail"]) + }) + + test("async resolve defers onSuccess", async () => { + let resolveWrite!: () => void + const writeP = new Promise((r) => { + resolveWrite = r + }) + const events: string[] = [] + writeClipboard( + { writeText: () => writeP }, + "hi", + { + onSuccess: () => events.push("ok"), + onFailure: () => events.push("fail"), + }, + ) + expect(events).toEqual([]) + resolveWrite() + await writeP + await Promise.resolve() + expect(events).toEqual(["ok"]) + }) + + test("async reject runs onFailure", async () => { + const events: string[] = [] + writeClipboard( + { writeText: () => Promise.reject(new Error("nope")) }, + "hi", + { + onSuccess: () => events.push("ok"), + onFailure: () => events.push("fail"), + }, + ) + expect(events).toEqual([]) + await Promise.resolve() + await Promise.resolve() + expect(events).toEqual(["fail"]) + }) +}) + describe("formatCopyText / copyStreamRow", () => { test("writes plain text and summary", () => { const port = createRecordingClipboard() diff --git a/src/tui/copy-path.ts b/src/tui/copy-path.ts index 81c1948bf..457b6c766 100644 --- a/src/tui/copy-path.ts +++ b/src/tui/copy-path.ts @@ -28,6 +28,37 @@ export type ClipboardPort = { readonly writeText: (text: string) => void | Promise } +/** + * Write to the clipboard, then run success/failure handlers. + * Never throws: sync throws and promise rejections both hit onFailure. + * Flash "Copied …" only from onSuccess so a failed write never lies. + */ +export function writeClipboard( + port: ClipboardPort, + text: string, + handlers: { + readonly onSuccess: () => void + readonly onFailure?: () => void + }, +): void { + const fail = () => { + handlers.onFailure?.() + } + try { + const result = port.writeText(text) + if ( + result != null + && typeof (result as PromiseLike).then === "function" + ) { + void Promise.resolve(result).then(handlers.onSuccess, fail) + return + } + handlers.onSuccess() + } catch { + fail() + } +} + /** Recording port for headless tests. */ export function createRecordingClipboard(): ClipboardPort & { readonly writes: string[] @@ -134,6 +165,7 @@ export function streamLogMarkdown(targets: readonly CopyTarget[]): string { /** * Copy the active (or last) stream row via the clipboard port. * Returns the payload, or null when there is nothing to copy. + * Write errors are swallowed (no flash here — callers own chrome). */ export function copyStreamRow( row: StreamRow | undefined | null, @@ -141,7 +173,9 @@ export function copyStreamRow( ): CopyPayload | null { if (!row) return null const payload = formatCopyText(row) - void port.writeText(payload.text) + writeClipboard(port, payload.text, { + onSuccess: () => {}, + }) return payload } diff --git a/src/tui/demo.ts b/src/tui/demo.ts index 495280baf..a3a4c5081 100644 --- a/src/tui/demo.ts +++ b/src/tui/demo.ts @@ -1,9 +1,8 @@ /** * Interactive OpenTUI product-skin demo (real TTY only). - * Run: bun src/tui/demo.ts + * Run: `bun src/tui/demo.ts` * - * Wave 7: residual surfaces + observe on shared kit. - * Not production CLI. Ink remains production. + * Not the production CLI (`src/index.ts` → OpenTUI shell). Playground only. * * Keys: * Enter=queue · Alt+Enter=steer · Ctrl+C=stop diff --git a/src/tui/selection-copy.test.ts b/src/tui/selection-copy.test.ts index feca02007..e8d6f84c1 100644 --- a/src/tui/selection-copy.test.ts +++ b/src/tui/selection-copy.test.ts @@ -88,5 +88,95 @@ describe("copyFinishedSelection", () => { expect(h.flashes[0]).toContain("ok go") expect(h.flashes[0]).not.toContain("\n") }) + + test("clears highlight immediately while write is still pending", async () => { + let resolveWrite!: () => void + const writeP = new Promise((r) => { + resolveWrite = r + }) + const flashes: string[] = [] + let cleared = 0 + const ok = copyFinishedSelection( + { + clipboard: { + writeText: () => writeP, + }, + flash: (text: string) => { + flashes.push(text) + }, + clearSelection: () => { + cleared += 1 + }, + }, + { + isDragging: false, + getSelectedText: () => "pending", + }, + ) + expect(ok).toBe(true) + expect(cleared).toBe(1) + expect(flashes).toEqual([]) + resolveWrite() + await writeP + await Promise.resolve() + expect(flashes[0]).toContain("Copied 7 chars") + expect(cleared).toBe(1) + }) + + test("flashes Copy failed after clear when write rejects", async () => { + const flashes: string[] = [] + let cleared = 0 + const ok = copyFinishedSelection( + { + clipboard: { + writeText: () => Promise.reject(new Error("no clipboard")), + }, + flash: (text: string) => { + flashes.push(text) + }, + clearSelection: () => { + cleared += 1 + }, + }, + { + isDragging: false, + getSelectedText: () => "secret", + }, + ) + expect(ok).toBe(true) + expect(cleared).toBe(1) + expect(flashes).toEqual([]) + await Promise.resolve() + await Promise.resolve() + expect(flashes).toEqual(["Copy failed"]) + expect(cleared).toBe(1) + }) + + test("flashes Copy failed when write throws synchronously", () => { + const flashes: string[] = [] + let cleared = 0 + const ok = copyFinishedSelection( + { + clipboard: { + writeText: () => { + throw new Error("no clipboard") + }, + }, + flash: (text: string) => { + flashes.push(text) + }, + clearSelection: () => { + cleared += 1 + }, + }, + { + isDragging: false, + getSelectedText: () => "secret", + }, + ) + expect(ok).toBe(true) + expect(cleared).toBe(1) + expect(flashes).toEqual(["Copy failed"]) + }) }) diff --git a/src/tui/selection-copy.ts b/src/tui/selection-copy.ts index 037af09bb..1483782a6 100644 --- a/src/tui/selection-copy.ts +++ b/src/tui/selection-copy.ts @@ -7,7 +7,7 @@ */ import type { Selection } from "@opentui/core" -import type { ClipboardPort } from "./copy-path.js" +import { writeClipboard, type ClipboardPort } from "./copy-path.js" /** Minimal deps so unit tests do not need a full AppShell. */ export type SelectionCopyHost = { @@ -23,8 +23,12 @@ export type FinishedSelection = { } /** - * Copy a finished (non-dragging) selection. Returns true when text was - * written. Empty selections and still-dragging states are no-ops. + * Copy a finished (non-dragging) selection. Returns true when a write was + * attempted. Empty selections and still-dragging states are no-ops. + * + * Clears the highlight immediately so a slow or hung clipboard helper cannot + * leave the selection stuck. Status flash waits for write settlement: + * `Copied …` on success, `Copy failed` on throw/reject. */ export function copyFinishedSelection( host: SelectionCopyHost, @@ -34,13 +38,21 @@ export function copyFinishedSelection( const text = selection.getSelectedText() if (text.length === 0) return false - void host.clipboard.writeText(text) // Notice row is one line; always collapse whitespace so multi-line // drag-selects do not inject raw newlines into chrome. const oneLine = text.replace(/\s+/g, " ").trim() const preview = oneLine.length > 48 ? `${oneLine.slice(0, 45)}…` : oneLine - host.flash(`Copied ${text.length} chars: ${preview}`) + + // Clear before the write settles — honesty only gates the flash message. host.clearSelection() + writeClipboard(host.clipboard, text, { + onSuccess: () => { + host.flash(`Copied ${text.length} chars: ${preview}`) + }, + onFailure: () => { + host.flash("Copy failed") + }, + }) return true } diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 6fbc982ad..04bb1ccc9 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -149,6 +149,7 @@ import { buildCopyTargets, createRecordingClipboard, streamLogMarkdown, + writeClipboard, type ClipboardPort, type CopyTarget, } from "./copy-path.js" @@ -3411,7 +3412,8 @@ export type OpenListOverlayOpts = { readonly echoChoice?: boolean /** * Claim printable keys for a `>` filter row so the list narrows as you type. - * Opt-in per open (model picker); other overlays keep j/k navigation. + * Opt-in per open (model picker, palette). Overlays without it keep j/k + * navigation; with it, j/k type into the filter and arrows still navigate. */ readonly typeToFilter?: boolean } @@ -3684,13 +3686,14 @@ function repaintPalette(shell: AppShell): void { } /** - * Keys the palette claims while it is open, so the `>` row filters as you type. + * Keys a type-to-filter list claims while it is open, so the `>` row filters + * as you type. * - * Opt-in per open rather than a property of the shared list overlay: every other - * picker (permissions, model, resume, workers, copy) keeps j/k navigation, which - * only the palette has to give up to get its printable keys back. Arrow and page - * keys are never claimed here, so they keep working in every overlay including - * this one. + * Opt-in per open (`typeToFilter`): palette and the flat model picker give up + * j/k navigation so printable keys feed the filter. Overlays without + * type-to-filter (permissions, resume, workers, copy, …) keep j/k. Arrow and + * page keys are never claimed here, so they keep working in every overlay + * including type-to-filter ones. */ export function handlePaletteFilterKey( shell: AppShell, @@ -4566,15 +4569,21 @@ export function confirmCopySelection(shell: AppShell): boolean { closeInsetOverlay(shell) return false } - void shell.clipboard.writeText(target.text) const preview = target.text.length > 48 ? `${target.text.slice(0, 45).replace(/\s+/g, " ")}…` : target.text - setStatusFlash( - shell, - `Copied ${target.label} (${target.text.length} chars): ${preview}`, - ) + writeClipboard(shell.clipboard, target.text, { + onSuccess: () => { + setStatusFlash( + shell, + `Copied ${target.label} (${target.text.length} chars): ${preview}`, + ) + }, + onFailure: () => { + setStatusFlash(shell, "Copy failed") + }, + }) closeInsetOverlay(shell) return true } @@ -4588,11 +4597,17 @@ export function copyAllTargets(shell: AppShell): boolean { return false } const text = streamLogMarkdown(targets) - void shell.clipboard.writeText(text) - setStatusFlash( - shell, - `Copied all (${targets.length} items, ${text.length} chars)`, - ) + writeClipboard(shell.clipboard, text, { + onSuccess: () => { + setStatusFlash( + shell, + `Copied all (${targets.length} items, ${text.length} chars)`, + ) + }, + onFailure: () => { + setStatusFlash(shell, "Copy failed") + }, + }) closeInsetOverlay(shell) return true } @@ -5466,8 +5481,8 @@ export function createAppShell( key.preventDefault() return } - // The palette filters as you type, so it claims printable keys — including - // the j/k every other overlay still uses to navigate. + // Type-to-filter overlays (palette, model picker) claim printables — + // including j/k that non-filter overlays still use to navigate. if (handlePaletteFilterKey(shell, key)) { key.preventDefault() return diff --git a/src/upgrade/index.test.ts b/src/upgrade/index.test.ts index dea41da2d..433c6077c 100644 --- a/src/upgrade/index.test.ts +++ b/src/upgrade/index.test.ts @@ -8,6 +8,7 @@ import { compareVersionStrings, detectInstallMethod, formatUpgradeMessage, + scheduleUpgradeNotice, type InstallProbe, } from "./index.js"; @@ -253,3 +254,88 @@ describe("checkForUpgrade", () => { expect(result.notice.message).toContain("bun install"); }); }); + +describe("scheduleUpgradeNotice", () => { + test("notifies only when an upgrade is available", async () => { + const notices: string[] = []; + let resolveDone!: () => void; + const done = new Promise((r) => { + resolveDone = r; + }); + scheduleUpgradeNotice({ + notify: (text) => { + notices.push(text); + resolveDone(); + }, + options: { + currentVersion: "0.1.0", + fetchLatest: async () => "0.2.0", + method: "homebrew", + }, + }); + await done; + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("0.2.0"); + expect(notices[0]).toContain("brew upgrade"); + }); + + test("stays quiet when current or skipped", async () => { + const notices: string[] = []; + const fetches: Promise[] = []; + const track = (value: string | null) => { + const p = Promise.resolve(value); + fetches.push(p); + return p; + }; + scheduleUpgradeNotice({ + notify: (text) => notices.push(text), + options: { + currentVersion: "0.2.0", + fetchLatest: () => track("0.2.0"), + }, + }); + scheduleUpgradeNotice({ + notify: (text) => notices.push(text), + options: { + currentVersion: "0.1.0", + fetchLatest: () => track(null), + }, + }); + await Promise.all(fetches); + await Promise.resolve(); + await Promise.resolve(); + expect(notices).toEqual([]); + }); + + test("swallows notify throws without unhandled rejection", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + try { + let resolveFetch!: (v: string) => void; + const fetchP = new Promise((r) => { + resolveFetch = r; + }); + scheduleUpgradeNotice({ + notify: () => { + throw new Error("flash failed"); + }, + options: { + currentVersion: "0.1.0", + fetchLatest: () => fetchP, + method: "unknown", + }, + }); + resolveFetch!("0.2.0"); + await fetchP; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +});