Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/session/compactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,19 +206,19 @@ 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
// governor's arming floor — derive it from this value instead of carrying
// 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 {
Expand Down
74 changes: 74 additions & 0 deletions src/tui/copy-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
formatCopyText,
pickCopyRow,
streamLogMarkdown,
writeClipboard,
} from "./copy-path"
import type { StreamRow } from "./stream"

Expand All @@ -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<void>((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()
Expand Down
36 changes: 35 additions & 1 deletion src/tui/copy-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,37 @@ export type ClipboardPort = {
readonly writeText: (text: string) => void | Promise<void>
}

/**
* 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<void>).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[]
Expand Down Expand Up @@ -134,14 +165,17 @@ 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,
port: ClipboardPort,
): CopyPayload | null {
if (!row) return null
const payload = formatCopyText(row)
void port.writeText(payload.text)
writeClipboard(port, payload.text, {
onSuccess: () => {},
})
return payload
}

Expand Down
5 changes: 2 additions & 3 deletions src/tui/demo.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
90 changes: 90 additions & 0 deletions src/tui/selection-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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"])
})
})

22 changes: 17 additions & 5 deletions src/tui/selection-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand All @@ -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
}
Loading
Loading