From b6e241428a4e05328c3bde13083feb2a3ed18516 Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:46:33 +0000 Subject: [PATCH 1/4] fix(tui): show the copy-on-select confirmation as a transient notice Each settled drag wrote a permanent transcript line, so a reading session piled up "copied N characters" rows under the conversation. The confirmation is now a single notice above the input box that replaces itself and clears: 3s for the terse line, 5s for the first copy's path caveat. Co-authored-by: Claude (deepseek-v4-pro) --- ui-tui/src/__tests__/clipboard.test.ts | 18 +- ui-tui/src/__tests__/copyNotice.test.tsx | 247 +++++++++++++++++++++++ ui-tui/src/app/copyNoticeStore.ts | 35 ++++ ui-tui/src/app/useMainApp.ts | 15 +- ui-tui/src/components/appLayout.tsx | 4 + ui-tui/src/lib/clipboard.ts | 15 +- 6 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 ui-tui/src/__tests__/copyNotice.test.tsx create mode 100644 ui-tui/src/app/copyNoticeStore.ts 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..aa573797 --- /dev/null +++ b/ui-tui/src/__tests__/copyNotice.test.tsx @@ -0,0 +1,247 @@ +// 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, + CompletionItem, + 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 = (completions: CompletionItem[]): AppLayoutComposerProps => ({ + cols: 80, + compIdx: 0, + completions, + empty: completions.length === 0, + handleTextPaste: async () => null, + input: completions.length ? '/comp' : '', + inputBuf: completions.length ? ['/comp'] : [], + pagerPageSize: 10, + queueEditIdx: null, + queuedDisplay: [], + submit: () => {}, + updateInput: () => {}, + voiceRecordKey: DEFAULT_VOICE_RECORD_KEY +}) + +const gwServices = { gw: {}, rpc: async () => null } as unknown as GatewayServices + +const makeProps = (completions: CompletionItem[]): AppLayoutProps => ({ + actions, + composer: makeComposer(completions), + 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 = ({ completions = [] }: { completions?: CompletionItem[] }) => ( + + + +) + +// 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', () => { + dismissCopyNotice() + + const frame = renderFrame() + + 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..ce55fa2b 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,9 @@ 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 notice is the only + // confirmation the clipboard was written -- shown above the composer and + // dismissed, not written into the transcript. // // The path caveat is per session while this hook outlives any one session: // `newSession()` and `resumeById()` replace `ui.sid` without remounting it, @@ -342,9 +342,12 @@ 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') + + // The first copy's caveat is longer than the terse line, so it stays up longer. + showCopyNotice(report.text, report.firstOfSession ? 5000 : 3000) }), - [selection, sys] + [selection] ) const page = useCallback( 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 } } } From 64ed00108af9d261938cc305b7084c4557702ad1 Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:11:49 +0000 Subject: [PATCH 2/4] fix(tui): keep the copy-on-select path caveat in the transcript The caveat explains an empty paste, but the user discovers the empty paste after switching to another app, long after a transient notice is gone. The caveat is once per session by construction, so keeping it in the transcript cannot pile up rows; only the unbounded terse repeats become transient notices. Co-authored-by: Claude (deepseek-v4-pro) --- ui-tui/src/app/useMainApp.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index ce55fa2b..3955f777 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -327,9 +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 notice is the only - // confirmation the clipboard was written -- shown above the composer and - // dismissed, not written into the transcript. + // 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, @@ -344,10 +348,13 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { subscribeCopyOnSelect(selection, (text, path) => { const report = reportCopyOnSelect.current(graphemeCount(text), path, getUiState().sid ?? 'draft') - // The first copy's caveat is longer than the terse line, so it stays up longer. - showCopyNotice(report.text, report.firstOfSession ? 5000 : 3000) + if (report.firstOfSession) { + sys(report.text) + } else { + showCopyNotice(report.text, 3000) + } }), - [selection] + [selection, sys] ) const page = useCallback( From cd04fa01c77cb4f26fcd32f6f13f20f17768ce2a Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:11:55 +0000 Subject: [PATCH 3/4] test(tui): drop the inert completions scaffolding from the copy notice harness The completions plumbing was copied from the status bar harness and is never driven here: renderFrame always renders App without props, so the parameter threading and the /comp branches are dead. Co-authored-by: Claude (deepseek-v4-pro) --- ui-tui/src/__tests__/copyNotice.test.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/ui-tui/src/__tests__/copyNotice.test.tsx b/ui-tui/src/__tests__/copyNotice.test.tsx index aa573797..049cda60 100644 --- a/ui-tui/src/__tests__/copyNotice.test.tsx +++ b/ui-tui/src/__tests__/copyNotice.test.tsx @@ -16,7 +16,6 @@ import type { AppLayoutComposerProps, AppLayoutProps, AppLayoutStatusProps, - CompletionItem, GatewayServices } from '../app/interfaces.js' import type { Msg } from '../types.js' @@ -57,14 +56,14 @@ const status: AppLayoutStatusProps = { voiceLabel: '' } -const makeComposer = (completions: CompletionItem[]): AppLayoutComposerProps => ({ +const makeComposer = (): AppLayoutComposerProps => ({ cols: 80, compIdx: 0, - completions, - empty: completions.length === 0, + completions: [], + empty: true, handleTextPaste: async () => null, - input: completions.length ? '/comp' : '', - inputBuf: completions.length ? ['/comp'] : [], + input: '', + inputBuf: [], pagerPageSize: 10, queueEditIdx: null, queuedDisplay: [], @@ -75,9 +74,9 @@ const makeComposer = (completions: CompletionItem[]): AppLayoutComposerProps => const gwServices = { gw: {}, rpc: async () => null } as unknown as GatewayServices -const makeProps = (completions: CompletionItem[]): AppLayoutProps => ({ +const makeProps = (): AppLayoutProps => ({ actions, - composer: makeComposer(completions), + composer: makeComposer(), mouseTracking: false, progress: { showProgressArea: false }, status, @@ -96,9 +95,9 @@ const makeProps = (completions: CompletionItem[]): AppLayoutProps => ({ } }) -const App = ({ completions = [] }: { completions?: CompletionItem[] }) => ( +const App = () => ( - + ) From 3b0564f7f88f6fb455ecb95faf194cb640e6681b Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:12:00 +0000 Subject: [PATCH 4/4] test(tui): make the dismissed copy-notice case show before dismissing The case dismissed a store that was already null, so it could not fail for the reason its name gives. Show a notice first so the case pins the show-then-dismiss transition. Co-authored-by: Claude (deepseek-v4-pro) --- ui-tui/src/__tests__/copyNotice.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/__tests__/copyNotice.test.tsx b/ui-tui/src/__tests__/copyNotice.test.tsx index 049cda60..017dc0a0 100644 --- a/ui-tui/src/__tests__/copyNotice.test.tsx +++ b/ui-tui/src/__tests__/copyNotice.test.tsx @@ -237,9 +237,12 @@ describe('copy notice rendering', () => { }) it('renders nothing for the notice once it has been dismissed', () => { - dismissCopyNotice() - - const frame = renderFrame() + const frame = renderFrame({ + setup: () => { + showCopyNotice('copied 7 characters', 3000) + dismissCopyNotice() + } + }) expect(frame).not.toContain('copied 7 characters') })