diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index 03e093fe..722d6900 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -391,9 +391,9 @@ describe('createCopyOnSelectReporter', () => { // paste that silently came up empty. const report = createCopyOnSelectReporter() - expect(report(42, 'osc52', 's1')).toBe(copyResultNotice(42, 'osc52')) - expect(report(42, 'osc52', 's1')).toBe('sent 42 characters') - expect(report(42, 'osc52', 's2')).toBe(copyResultNotice(42, 'osc52')) + expect(report(42, 'osc52', 's1')).toEqual({ text: copyResultNotice(42, 'osc52'), firstOfSession: true }) + expect(report(42, 'osc52', 's1')).toEqual({ text: 'sent 42 characters', firstOfSession: false }) + expect(report(42, 'osc52', 's2')).toEqual({ text: copyResultNotice(42, 'osc52'), firstOfSession: true }) }) it('does not repeat the caveat when a session is returned to', () => { @@ -404,13 +404,19 @@ describe('createCopyOnSelectReporter', () => { report(42, 'osc52', 's1') report(42, 'osc52', 's2') - expect(report(42, 'osc52', 's1')).toBe('sent 42 characters') + expect(report(42, 'osc52', 's1')).toEqual({ text: 'sent 42 characters', firstOfSession: false }) }) it('keeps its own tally per reporter', () => { // Two TUI processes must not share the fact that one of them has reported. - expect(createCopyOnSelectReporter()(42, 'osc52', 's1')).toBe(copyResultNotice(42, 'osc52')) - expect(createCopyOnSelectReporter()(42, 'osc52', 's1')).toBe(copyResultNotice(42, 'osc52')) + expect(createCopyOnSelectReporter()(42, 'osc52', 's1')).toEqual({ + text: copyResultNotice(42, 'osc52'), + firstOfSession: true + }) + expect(createCopyOnSelectReporter()(42, 'osc52', 's1')).toEqual({ + text: copyResultNotice(42, 'osc52'), + firstOfSession: true + }) }) }) diff --git a/ui-tui/src/__tests__/copyNotice.test.tsx b/ui-tui/src/__tests__/copyNotice.test.tsx new file mode 100644 index 00000000..017dc0a0 --- /dev/null +++ b/ui-tui/src/__tests__/copyNotice.test.tsx @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EverMind. +// See NOTICES.md. +// +// The copy-on-select confirmation: a transient line above the composer that +// replaces itself on every new copy and clears after its duration, instead of +// stacking permanent transcript lines. + +import { renderSync } from '@hermes/ink' +import React from 'react' +import { PassThrough } from 'stream' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { + AppLayoutActions, + AppLayoutComposerProps, + AppLayoutProps, + AppLayoutStatusProps, + GatewayServices +} from '../app/interfaces.js' +import type { Msg } from '../types.js' + +import { $copyNotice, dismissCopyNotice, showCopyNotice } from '../app/copyNoticeStore.js' +import { GatewayProvider } from '../app/gatewayContext.js' +import { patchUiState, resetUiState } from '../app/uiStore.js' +import { AppLayout } from '../components/appLayout.js' +import { DEFAULT_VOICE_RECORD_KEY } from '../lib/platform.js' +import { stripAnsi } from '../lib/text.js' + +const HISTORY: Msg[] = Array.from({ length: 12 }, (_, i) => ({ + role: i % 2 === 0 ? 'user' : 'assistant', + text: `transcript line ${i} lorem ipsum` +})) + +const actions: AppLayoutActions = { + answerApproval: () => {}, + answerClarify: () => {}, + answerConfirm: () => {}, + answerSecret: () => {}, + answerSudo: () => {}, + clearSelection: () => {}, + deleteSessionWithFallback: async () => false, + onModelSelect: () => {}, + resumeById: () => {}, + setStickyPrompt: () => {} +} + +const status: AppLayoutStatusProps = { + cwdLabel: '~/repo', + goodVibesTick: 0, + sessionStartedAt: null, + showStickyPrompt: false, + statusColor: 'green', + stickyPrompt: '', + turnStartedAt: null, + voiceLabel: '' +} + +const makeComposer = (): AppLayoutComposerProps => ({ + cols: 80, + compIdx: 0, + completions: [], + empty: true, + handleTextPaste: async () => null, + input: '', + inputBuf: [], + pagerPageSize: 10, + queueEditIdx: null, + queuedDisplay: [], + submit: () => {}, + updateInput: () => {}, + voiceRecordKey: DEFAULT_VOICE_RECORD_KEY +}) + +const gwServices = { gw: {}, rpc: async () => null } as unknown as GatewayServices + +const makeProps = (): AppLayoutProps => ({ + actions, + composer: makeComposer(), + mouseTracking: false, + progress: { showProgressArea: false }, + status, + transcript: { + historyItems: HISTORY, + scrollRef: { current: null }, + virtualHistory: { + bottomSpacer: 0, + end: HISTORY.length, + measureRef: () => () => {}, + offsets: HISTORY.map((_, i) => i), + start: 0, + topSpacer: 0 + }, + virtualRows: HISTORY.map((msg, index) => ({ index, key: `r${index}`, msg })) + } +}) + +const App = () => ( + + + +) + +// The renderer skips blank cells by moving the cursor with CSI sequences +// instead of writing spaces, so 'copied 7 characters' arrives as +// 'copied[1C7[1Ccharacters'. Turn those into spaces before +// stripping the rest of the ANSI, so plain-text assertions see real gaps. +const cursorForward = new RegExp(`${String.fromCharCode(27)}\\[(\\d+)?C`, 'g') + +const toPlainFrame = (raw: string): string => + stripAnsi(raw.replace(cursorForward, (_, n) => ' '.repeat(n ? parseInt(n, 10) : 1))) + +const renderFrame = ({ setup }: { setup?: () => void } = {}): string => { + resetUiState() + patchUiState({ statusBar: 'bottom' }) + setup?.() + + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 80, isTTY: true, rows: 24 }) + Object.assign(stdin, { isTTY: true, ref: () => {}, setRawMode: () => {}, unref: () => {} }) + Object.assign(stderr, { isTTY: true }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync(, { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + }) + + instance.unmount() + instance.cleanup() + + return toPlainFrame(output) +} + +describe('copy notice store', () => { + afterEach(() => { + vi.useRealTimers() + dismissCopyNotice() + }) + + it('clears itself after its duration', () => { + vi.useFakeTimers() + + showCopyNotice('copied 7 characters', 3000) + + expect($copyNotice.get()).toBe('copied 7 characters') + + vi.advanceTimersByTime(2999) + + expect($copyNotice.get()).toBe('copied 7 characters') + + vi.advanceTimersByTime(1) + + expect($copyNotice.get()).toBeNull() + }) + + it('replaces the previous notice instead of stacking', () => { + vi.useFakeTimers() + + showCopyNotice('copied 7 characters', 3000) + showCopyNotice('sent 42 characters', 3000) + + expect($copyNotice.get()).toBe('sent 42 characters') + }) + + it("lets a re-shown notice outlive the previous one's deadline", () => { + vi.useFakeTimers() + + showCopyNotice('copied 7 characters', 3000) + vi.advanceTimersByTime(2900) + showCopyNotice('sent 42 characters', 5000) + + // The first notice's deadline passes; the newer one must survive it. + vi.advanceTimersByTime(100) + + expect($copyNotice.get()).toBe('sent 42 characters') + + vi.advanceTimersByTime(4900) + + expect($copyNotice.get()).toBeNull() + }) + + it('honours a per-notice duration', () => { + vi.useFakeTimers() + + showCopyNotice('sent 42 characters', 5000) + + vi.advanceTimersByTime(3000) + + expect($copyNotice.get()).toBe('sent 42 characters') + + vi.advanceTimersByTime(2000) + + expect($copyNotice.get()).toBeNull() + }) + + it('dismissing clears the notice and leaves a later one to its own timer', () => { + vi.useFakeTimers() + + showCopyNotice('copied 7 characters', 3000) + dismissCopyNotice() + + expect($copyNotice.get()).toBeNull() + + showCopyNotice('sent 42 characters', 3000) + vi.advanceTimersByTime(2999) + + expect($copyNotice.get()).toBe('sent 42 characters') + + vi.advanceTimersByTime(1) + + expect($copyNotice.get()).toBeNull() + }) +}) + +describe('copy notice rendering', () => { + afterEach(() => { + dismissCopyNotice() + }) + + it('shows the notice above the composer while one is set', () => { + const frame = renderFrame({ setup: () => showCopyNotice('copied 7 characters', 3000) }) + + expect(frame).toContain('copied 7 characters') + + // At statusBar='bottom' the cwd label sits below the input box, so the + // notice must render somewhere above it. + expect(frame.indexOf('copied 7 characters')).toBeLessThan(frame.indexOf('~/repo')) + }) + + it('renders nothing for the notice once it has been dismissed', () => { + const frame = renderFrame({ + setup: () => { + showCopyNotice('copied 7 characters', 3000) + dismissCopyNotice() + } + }) + + expect(frame).not.toContain('copied 7 characters') + }) +}) diff --git a/ui-tui/src/app/copyNoticeStore.ts b/ui-tui/src/app/copyNoticeStore.ts new file mode 100644 index 00000000..54fb051a --- /dev/null +++ b/ui-tui/src/app/copyNoticeStore.ts @@ -0,0 +1,35 @@ +/** + * One transient notice above the composer. + * + * Copy-on-select writes the clipboard on every settled drag; reporting each + * one as a transcript line stacks permanent rows. This holds a single notice + * that replaces itself and clears after its duration, so the confirmation is + * visible for a moment and then gone. + */ + +import { atom } from 'nanostores' + +export const $copyNotice = atom(null) + +let dismissTimer: null | ReturnType = null + +export function showCopyNotice(text: string, ms: number): void { + if (dismissTimer !== null) { + clearTimeout(dismissTimer) + } + + $copyNotice.set(text) + dismissTimer = setTimeout(() => { + dismissTimer = null + $copyNotice.set(null) + }, ms) +} + +export function dismissCopyNotice(): void { + if (dismissTimer !== null) { + clearTimeout(dismissTimer) + dismissTimer = null + } + + $copyNotice.set(null) +} diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 7f8e43ca..3955f777 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -36,6 +36,7 @@ import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js' import { createChatStream, type ChatStreamHandle, type ChatStreamRpcClient } from './chatStream.js' +import { showCopyNotice } from './copyNoticeStore.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' import { getInputSelection } from './inputSelectionStore.js' @@ -326,10 +327,13 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { // mouse tracking, so copy-on-select is what makes a transcript selection // copyable at all. That holds on every platform, not just macOS. // - // Nothing on screen changes when a drag ends, so the transcript line is the - // only confirmation the clipboard was written. Lives below `sys` because the - // dependency array is evaluated during render, while `sys` is still in its - // temporal dead zone further up. + // Nothing on screen changes when a drag ends, so the report is the only + // confirmation the clipboard was written. The first copy of a session + // carries the path caveat and stays in the transcript: it is the answer a + // user comes looking for after a paste comes up empty minutes later, and it + // cannot pile up because it is once per session by construction. The terse + // repeats, which are unbounded, show as a transient notice above the + // composer instead of stacking rows. // // The path caveat is per session while this hook outlives any one session: // `newSession()` and `resumeById()` replace `ui.sid` without remounting it, @@ -342,7 +346,13 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { useEffect( () => subscribeCopyOnSelect(selection, (text, path) => { - sys(reportCopyOnSelect.current(graphemeCount(text), path, getUiState().sid ?? 'draft')) + const report = reportCopyOnSelect.current(graphemeCount(text), path, getUiState().sid ?? 'draft') + + if (report.firstOfSession) { + sys(report.text) + } else { + showCopyNotice(report.text, 3000) + } }), [selection, sys] ) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 11902667..9cff4aa6 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -9,6 +9,7 @@ import { Fragment, memo, useMemo, useRef } from 'react' import type { AppLayoutProps } from '../app/interfaces.js' +import { $copyNotice } from '../app/copyNoticeStore.js' import { useGateway } from '../app/gatewayContext.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' @@ -178,6 +179,7 @@ const ComposerPane = memo(function ComposerPane({ }: Pick) { const ui = useStore($uiState) const isBlocked = useStore($isBlocked) + const copyNotice = useStore($copyNotice) const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') const promptText = sh ? '$' : ui.theme.brand.prompt const promptWidth = composerPromptWidth(promptText) @@ -257,6 +259,8 @@ const ComposerPane = memo(function ComposerPane({ + {copyNotice && {copyNotice}} + {/* When a blocking overlay opens the input rows unmount, collapsing this box to height 0. At statusBar='bottom' the StatusRule sibling then shares its computed top, tripping the renderer's height-0 skip diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 7382aaec..998b45ae 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -239,6 +239,13 @@ export function copyOnSelectNotice(charCount: number, path: ClipboardPath, first return firstOfSession ? copyResultNotice(charCount, path) : counted(verbFor(path), charCount) } +/** The notice for one copy-on-select write, plus whether it carried the path + * caveat -- the caveat takes longer to read, so the caller shows it longer. */ +export type CopyOnSelectReport = { + firstOfSession: boolean + text: string +} + /** * Report copies for a TUI process, spending the path caveat once per session. * @@ -248,7 +255,11 @@ export function copyOnSelectNotice(charCount: number, path: ClipboardPath, first * is whatever identifies the current session to the caller; a resumed session * reaching the same key has already had its caveat and does not repeat it. */ -export function createCopyOnSelectReporter(): (charCount: number, path: ClipboardPath, sessionKey: string) => string { +export function createCopyOnSelectReporter(): ( + charCount: number, + path: ClipboardPath, + sessionKey: string +) => CopyOnSelectReport { const told = new Set() return (charCount, path, sessionKey) => { @@ -256,6 +267,6 @@ export function createCopyOnSelectReporter(): (charCount: number, path: Clipboar told.add(sessionKey) - return copyOnSelectNotice(charCount, path, firstOfSession) + return { text: copyOnSelectNotice(charCount, path, firstOfSession), firstOfSession } } }