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 }
}
}