diff --git a/apps/claude-sdk-cli/CHANGELOG.md b/apps/claude-sdk-cli/CHANGELOG.md index b7606a4b..a2076745 100644 --- a/apps/claude-sdk-cli/CHANGELOG.md +++ b/apps/claude-sdk-cli/CHANGELOG.md @@ -50,6 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Claude now gets a scratchpad directory of its own, one per conversation, under the operating system's temp directory. Its path is stated at the start of each conversation, and reads, writes and deletes inside it are approved without prompting, so working files no longer have to land in your project or be cleaned up afterwards. Opt out with workspace.enabled, which takes effect on the next tool call rather than at a restart - Configurable system prompts via SYSTEM.md, --system, and sdk-config - Configure tool approval permissions via a permissions block in sdk-config.json +- Copy a code block by clicking the icon in its top border. The block's source is put on the system clipboard, unwrapped and undecorated, and the status bar confirms how many lines went +- Copy a whole block by clicking the icon on its header. The icon appears once the block is sealed, since copying one still being written gives you less than it will hold - Customize which commands ExecV3 will run or refuse, without a mistake in that customization ever disabling safety or breaking the rest of your settings - Decode escape sequences in --prompt values: \n, \r, \t, \\ - Display server tool use as its own block in the conversation diff --git a/apps/claude-sdk-cli/changes.jsonl b/apps/claude-sdk-cli/changes.jsonl index 474c35d3..5dd54e37 100644 --- a/apps/claude-sdk-cli/changes.jsonl +++ b/apps/claude-sdk-cli/changes.jsonl @@ -178,3 +178,5 @@ {"description":"New http.allowH2 setting, off by default, so API requests negotiate HTTP/1.1","category":"added"} {"description":"An error written to the log keeps its name, message, stack and cause instead of rendering as an empty object","category":"fixed"} {"description":"Each connection logs the HTTP protocol it negotiated","category":"added"} +{"description":"Copy a code block by clicking the icon in its top border. The block's source is put on the system clipboard, unwrapped and undecorated, and the status bar confirms how many lines went","category":"added"} +{"description":"Copy a whole block by clicking the icon on its header. The icon appears once the block is sealed, since copying one still being written gives you less than it will hold","category":"added"} diff --git a/apps/claude-sdk-cli/src/app/ViewHost.ts b/apps/claude-sdk-cli/src/app/ViewHost.ts index c794df0c..7bfc90d5 100644 --- a/apps/claude-sdk-cli/src/app/ViewHost.ts +++ b/apps/claude-sdk-cli/src/app/ViewHost.ts @@ -1,5 +1,6 @@ import type { KeyAction } from '@shellicar/claude-core/input'; import type { AppModeKey, IAppModeState } from '../model/AppModeState.js'; +import type { IFrameRegions } from '../model/FrameRegions.js'; import type { TerminalRenderer } from '../view/TerminalRenderer.js'; import type { ViewModel } from '../view/View.js'; import type { Presentation } from './Presentation.js'; @@ -24,15 +25,17 @@ export class ViewHost implements Disposable { readonly #model: ViewModel; readonly #presentations: ReadonlyMap; readonly #appModeState: IAppModeState; + readonly #frameRegions: IFrameRegions; readonly #onChange: () => void; #renderPending = false; #disposed = false; - public constructor(renderer: TerminalRenderer, model: ViewModel, presentations: ReadonlyMap, appModeState: IAppModeState) { + public constructor(renderer: TerminalRenderer, model: ViewModel, presentations: ReadonlyMap, appModeState: IAppModeState, frameRegions: IFrameRegions) { this.#renderer = renderer; this.#model = model; this.#presentations = presentations; this.#appModeState = appModeState; + this.#frameRegions = frameRegions; this.#onChange = () => this.scheduleRender(); model.conversationState.on('change', this.#onChange); @@ -83,8 +86,9 @@ export class ViewHost implements Disposable { if (this.#disposed) { return; } - const rows = this.#activePresentation().view.render(this.#model); - this.#renderer.paint(rows); + const frame = this.#activePresentation().view.render(this.#model); + this.#frameRegions.set(frame.regions); + this.#renderer.paint(frame.rows); } public scheduleRender(): void { diff --git a/apps/claude-sdk-cli/src/controller/ClickHandler.ts b/apps/claude-sdk-cli/src/controller/ClickHandler.ts new file mode 100644 index 00000000..2be2d7c0 --- /dev/null +++ b/apps/claude-sdk-cli/src/controller/ClickHandler.ts @@ -0,0 +1,43 @@ +import { Clock } from '@js-joda/core'; +import type { KeyAction } from '@shellicar/claude-core/input'; +import { dependsOn } from '@shellicar/core-di'; +import { hitTest } from '../model/ClickRegion.js'; +import { IClickTracker } from '../model/ClickTracker.js'; +import { IClipboard } from '../model/Clipboard.js'; +import { IFrameRegions } from '../model/FrameRegions.js'; +import { StatusState } from '../model/StatusState.js'; +import type { InputHandler } from './InputHandler.js'; + +/** + * Turns a click on the frame into a copy. A press remembers the target under the pointer; + * a release copies when it lands on that same target, and does nothing otherwise. That is + * the whole rule, and nothing else abandons a held press: a release always resolves + * against the frame on screen at that moment, so a scroll or a repaint in between simply + * means the release finds a different target, or none. + * + * Claims both mouse events whether or not they hit anything, so a click on empty space + * never travels on to the editor as input. + */ +export class ClickHandler implements InputHandler { + @dependsOn(IFrameRegions) private readonly frameRegions!: IFrameRegions; + @dependsOn(IClickTracker) private readonly tracker!: IClickTracker; + @dependsOn(IClipboard) private readonly clipboard!: IClipboard; + @dependsOn(StatusState) private readonly statusState!: StatusState; + @dependsOn(Clock) private readonly clock!: Clock; + + public handleKey(key: KeyAction): boolean { + if (key.type === 'mouse_down') { + this.tracker.press(hitTest(this.frameRegions.current, key.col, key.row)); + return true; + } + if (key.type === 'mouse_up') { + const target = this.tracker.release(hitTest(this.frameRegions.current, key.col, key.row)); + if (target !== null) { + this.clipboard.write(target.text); + this.statusState.markCopied(this.clock.instant(), target.text.split('\n').length); + } + return true; + } + return false; + } +} diff --git a/apps/claude-sdk-cli/src/model/ClickRegion.ts b/apps/claude-sdk-cli/src/model/ClickRegion.ts new file mode 100644 index 00000000..cf91aae9 --- /dev/null +++ b/apps/claude-sdk-cli/src/model/ClickRegion.ts @@ -0,0 +1,47 @@ +/** + * An interactive span of a single rendered row, produced by the view that drew it + * and resolved back to its payload when a click lands on it. + * + * Coordinates are zero-based and in the same space as the screen grid, not the + * terminal's one-based mouse report; the input edge converts once. + * + * `id` is the identity and `text` only the payload. A frame is rebuilt whole on every + * paint, so regions cannot be compared by reference across a repaint; the id comes from + * the model instead, where it outlives any frame, and two regions are the same target + * only when they name the same thing. + */ +export type ClickRegion = { + id: string; + row: number; + startCol: number; + endCol: number; + text: string; +}; + +/** The region covering a point, or null when the point lands on none. */ +export function hitTest(regions: readonly ClickRegion[], col: number, row: number): ClickRegion | null { + return regions.find((region) => region.row === row && col >= region.startCol && col <= region.endCol) ?? null; +} + +/** + * Move transcript-relative regions onto the window the primary view paints, dropping + * the ones it no longer shows. Mirrors windowTranscript's geometry: a transcript + * shorter than the window is padded above, a longer one is sliced to its tail less the + * scroll offset, and a scrolled window gives its final row to the position indicator. + */ +export function windowRegions(regions: readonly ClickRegion[], total: number, scrollRows: number, offset: number): ClickRegion[] { + if (total <= scrollRows) { + const padding = scrollRows - total; + return regions.map((region) => ({ ...region, row: region.row + padding })); + } + const top = total - offset - scrollRows; + const belowLast = offset > 0 ? scrollRows - 1 : scrollRows; + const visible: ClickRegion[] = []; + for (const region of regions) { + const row = region.row - top; + if (row >= 0 && row < belowLast) { + visible.push({ ...region, row }); + } + } + return visible; +} diff --git a/apps/claude-sdk-cli/src/model/ClickTracker.ts b/apps/claude-sdk-cli/src/model/ClickTracker.ts new file mode 100644 index 00000000..ef46a152 --- /dev/null +++ b/apps/claude-sdk-cli/src/model/ClickTracker.ts @@ -0,0 +1,38 @@ +import type { ClickRegion } from './ClickRegion.js'; + +/** + * Pairs a mouse press with its release so a click fires only when both land on the + * same target. A press remembers the region it landed on; a release resolves its own + * coordinate and returns the pressed region when the two are the same target. + * + * Holding the press against the target rather than the coordinate is what makes this + * self-correcting. Both ends resolve against whatever frame is on screen at the time, so + * a scroll or a streaming repaint between them needs no special handling: either the + * release lands on the same target, or it does not. A release tmux swallowed during a + * drag simply never fires. + * + * Targets are compared by id rather than by what they copy, because two of them can hold + * identical text: the same prompt sent twice. A drag between those two has to cancel. + */ +/** The state's contract; register abstract→concrete and depend on the abstract (DI rule). */ +export abstract class IClickTracker { + public abstract press(region: ClickRegion | null): void; + public abstract release(region: ClickRegion | null): ClickRegion | null; +} + +export class ClickTracker extends IClickTracker { + #pressed: ClickRegion | null = null; + + public press(region: ClickRegion | null): void { + this.#pressed = region; + } + + public release(region: ClickRegion | null): ClickRegion | null { + const pressed = this.#pressed; + this.#pressed = null; + if (pressed === null || region === null || pressed.id !== region.id) { + return null; + } + return pressed; + } +} diff --git a/apps/claude-sdk-cli/src/model/Clipboard.ts b/apps/claude-sdk-cli/src/model/Clipboard.ts new file mode 100644 index 00000000..174a4d13 --- /dev/null +++ b/apps/claude-sdk-cli/src/model/Clipboard.ts @@ -0,0 +1,23 @@ +import { osc52 } from '@shellicar/claude-core/ansi'; +import { Screen } from '@shellicar/claude-core/screen'; +import { dependsOn } from '@shellicar/core-di'; + +/** The state's contract; register abstract→concrete and depend on the abstract (DI rule). */ +export abstract class IClipboard { + public abstract write(text: string): void; +} + +/** + * Puts text on the system clipboard by asking the terminal to do it, which works whether + * the CLI is on the same machine as the clipboard or at the far end of a connection. + * + * The sequence is a one-shot command, not content: it occupies no cell, so it goes + * straight out through the screen and never enters a rendered row or the frame diff. + */ +export class Osc52Clipboard extends IClipboard { + @dependsOn(Screen) private readonly screen!: Screen; + + public write(text: string): void { + this.screen.write(osc52(text)); + } +} diff --git a/apps/claude-sdk-cli/src/model/ConversationState.ts b/apps/claude-sdk-cli/src/model/ConversationState.ts index 50ed3257..767ae6b9 100644 --- a/apps/claude-sdk-cli/src/model/ConversationState.ts +++ b/apps/claude-sdk-cli/src/model/ConversationState.ts @@ -1,8 +1,10 @@ +import { randomUUID } from 'node:crypto'; import EventEmitter from 'node:events'; import { Clock, Instant } from '@js-joda/core'; import { ILogger } from '@shellicar/claude-core/logging/ILogger'; import { sanitiseLoneSurrogates } from '@shellicar/claude-core/sanitise'; import { dependsOn } from '@shellicar/core-di'; +import { settledCodeTexts } from './markdown/markdownLayout.js'; import type { ToolEntry } from './ToolObject.js'; type ConversationStateEvents = { @@ -11,9 +13,42 @@ type ConversationStateEvents = { export type BlockType = 'prompt' | 'thinking' | 'response' | 'tools' | 'execution' | 'compaction' | 'meta' | 'notice'; +/** + * How often a still-streaming block is re-parsed to look for code blocks that have settled. + * + * Without a period this runs once per delta, and each run parses the whole block, so the cost + * of a long answer grows with the square of its length. Nothing needs the fence noticed on the + * delta that closed it: the affordance cannot appear before the next paint anyway, and the seal + * settles everything unconditionally. The value only has to be short enough that the latency it + * adds before an icon appears goes unnoticed. + */ +const FENCE_SETTLE_MS = 120; + +/** + * A code block whose content has settled, and the identity a click on its copy affordance + * resolves to. Minted once and never reissued, so the same fence is the same target in + * every frame that draws it. + */ +export type FenceRecord = { + id: string; + text: string; +}; + export type Block = { + /** + * Minted when the block is created and carried through the seal. A frame is rebuilt whole + * on every paint, so this is what lets a press and a release two frames apart agree on + * what was clicked. + */ + id: string; type: BlockType; content: string; + /** + * The settled code blocks within `content`, in the order a render draws them. Grows as + * fences close and never shrinks. Only `response` blocks are drawn as markdown, so only + * they have any. + */ + fences?: FenceRecord[]; /** Structured tool entries for a `tools` block; undefined for every other type. The history view reads this; the Primary view renders `content`. */ tools?: ToolEntry[]; /** @@ -24,6 +59,9 @@ export type Block = { exitedAt?: Instant; }; +/** A block as a caller supplies it: the store mints the id and discovers the fences. */ +export type NewBlock = Omit; + export type TransitionResult = { noop: boolean; from: BlockType | null; @@ -45,7 +83,7 @@ export abstract class IConversationState { public abstract get flushedCount(): number; public abstract get activeBlock(): Block | null; public abstract get promptStartedAt(): Instant | null; - public abstract addBlocks(blocks: ReadonlyArray): void; + public abstract addBlocks(blocks: ReadonlyArray): void; public abstract markPromptStart(): void; public abstract transitionBlock(type: BlockType): TransitionResult; public abstract appendToActive(text: string): void; @@ -68,6 +106,7 @@ export class ConversationState extends IConversationState { #activeBlock: Block | null = null; @dependsOn(Clock) private readonly clock!: Clock; #promptStartedAt: Instant | null = null; + readonly #lastSettleMillis = new WeakMap(); readonly #emitter = new EventEmitter(); public on(event: K, listener: (...args: ConversationStateEvents[K]) => void): void { @@ -104,9 +143,11 @@ export class ConversationState extends IConversationState { * already flushed: these are re-displays of past content or boot-time notices, not new turn * content, so they must not be re-written to scrollback. */ - public addBlocks(blocks: ReadonlyArray): void { + public addBlocks(blocks: ReadonlyArray): void { for (const block of blocks) { - this.#sealedBlocks.push(block); + const added: Block = { ...block, id: randomUUID() }; + this.#settleFences(added, true); + this.#sealedBlocks.push(added); } this.#flushedCount = this.#sealedBlocks.length; this.#emitter.emit('change'); @@ -136,12 +177,11 @@ export class ConversationState extends IConversationState { const from = this.#activeBlock?.type ?? null; const sealed = !!this.#activeBlock?.content.trim(); if (this.#activeBlock?.content.trim()) { - const sealing = this.#activeBlock; - this.#sealedBlocks.push({ ...sealing, exitedAt: Instant.now(this.clock) }); + this.#seal(this.#activeBlock); } const createdAt = type === 'prompt' && this.#promptStartedAt !== null ? this.#promptStartedAt : Instant.now(this.clock); this.#promptStartedAt = null; - this.#activeBlock = { type, content: '', createdAt }; + this.#activeBlock = { id: randomUUID(), type, content: '', createdAt }; this.#emitter.emit('change'); return { noop: false, from, sealed }; } @@ -150,6 +190,7 @@ export class ConversationState extends IConversationState { public appendToActive(text: string): void { if (this.#activeBlock) { this.#activeBlock.content += text; + this.#settleFences(this.#activeBlock, false); this.#emitter.emit('change'); } } @@ -162,9 +203,10 @@ export class ConversationState extends IConversationState { */ public appendStreaming(text: string): void { if (!this.#activeBlock) { - this.#activeBlock = { type: 'notice', content: '', createdAt: Instant.now(this.clock) }; + this.#activeBlock = { id: randomUUID(), type: 'notice', content: '', createdAt: Instant.now(this.clock) }; } this.#activeBlock.content += sanitiseLoneSurrogates(text); + this.#settleFences(this.#activeBlock, false); this.#emitter.emit('change'); } @@ -176,6 +218,7 @@ export class ConversationState extends IConversationState { public replaceActiveFromOffset(offset: number, text: string): void { if (this.#activeBlock) { this.#activeBlock.content = this.#activeBlock.content.slice(0, offset) + text; + this.#settleFences(this.#activeBlock, false); } } @@ -188,6 +231,7 @@ export class ConversationState extends IConversationState { public setActiveBlockContent(text: string): void { if (this.#activeBlock) { this.#activeBlock.content = sanitiseLoneSurrogates(text); + this.#settleFences(this.#activeBlock, false); this.#emitter.emit('change'); } } @@ -205,7 +249,7 @@ export class ConversationState extends IConversationState { public spliceNotice(text: string): void { const sanitised = sanitiseLoneSurrogates(text); if (!this.#activeBlock) { - this.#activeBlock = { type: 'notice', content: `${sanitised}\n`, createdAt: Instant.now(this.clock) }; + this.#activeBlock = { id: randomUUID(), type: 'notice', content: `${sanitised}\n`, createdAt: Instant.now(this.clock) }; this.#emitter.emit('change'); return; } @@ -216,6 +260,7 @@ export class ConversationState extends IConversationState { } else { this.#activeBlock.content = `${content.slice(0, pos + 1)}${sanitised}\n${content.slice(pos + 1)}`; } + this.#settleFences(this.#activeBlock, false); this.#emitter.emit('change'); } @@ -228,6 +273,7 @@ export class ConversationState extends IConversationState { const sanitised = sanitiseLoneSurrogates(text); if (this.#activeBlock?.type === type) { this.#activeBlock.content = sanitised; + this.#settleFences(this.#activeBlock, false); this.#emitter.emit('change'); return; } @@ -251,11 +297,54 @@ export class ConversationState extends IConversationState { this.logger.warn('setLastTools: no active block of matching type; sealed blocks are never modified', { type }); } + /** + * Close a block off and file it. Sealing is the last thing that happens to its content, so + * every code block in it has settled by definition, including a fence the response left + * unclosed: nothing more is coming to close it. + */ + #seal(block: Block): void { + const sealed: Block = { ...block, exitedAt: Instant.now(this.clock) }; + this.#settleFences(sealed, true); + this.#sealedBlocks.push(sealed); + } + + /** + * Give an identity to any code block that has settled since the last look. + * + * Ids are minted once and never revisited, so the records already held have to still line up + * with the order a render walks them in. That holds while content only grows, since a fence + * that has closed cannot reopen. `replaceActiveFromOffset` and `setActiveBlockContent` can + * shorten or replace it and would break the alignment; neither has a caller today. + * + * The period is held per block, so a block that has just opened is looked at straight away + * rather than waiting out the one before it. Sealing always looks, whatever the period: it is + * the last chance, and what it finds is final. The streaming path is the only one that reads + * the clock. + */ + #settleFences(block: Block, final: boolean): void { + if (block.type !== 'response') { + return; + } + if (!final) { + const now = this.clock.millis(); + const last = this.#lastSettleMillis.get(block); + if (last !== undefined && now - last < FENCE_SETTLE_MS) { + return; + } + this.#lastSettleMillis.set(block, now); + } + const texts = settledCodeTexts(block.content, final); + const held = block.fences ?? []; + if (texts.length <= held.length) { + return; + } + block.fences = [...held, ...texts.slice(held.length).map((text) => ({ id: randomUUID(), text }))]; + } + /** Seal the active block if it has content, then clear it. */ public completeActive(): void { if (this.#activeBlock?.content.trim()) { - const sealing = this.#activeBlock; - this.#sealedBlocks.push({ ...sealing, exitedAt: Instant.now(this.clock) }); + this.#seal(this.#activeBlock); } this.#activeBlock = null; this.#emitter.emit('change'); @@ -272,6 +361,7 @@ export class ConversationState extends IConversationState { public appendToLastSealed(type: BlockType, text: string): 'active' | 'miss' { if (this.#activeBlock?.type === type) { this.#activeBlock.content += text; + this.#settleFences(this.#activeBlock, false); this.#emitter.emit('change'); return 'active'; } diff --git a/apps/claude-sdk-cli/src/model/FrameRegions.ts b/apps/claude-sdk-cli/src/model/FrameRegions.ts new file mode 100644 index 00000000..4dcf7290 --- /dev/null +++ b/apps/claude-sdk-cli/src/model/FrameRegions.ts @@ -0,0 +1,27 @@ +import type { ClickRegion } from './ClickRegion.js'; + +/** + * The clickable spans of the frame currently on screen. Written by the render + * coordinator each paint and read by the input chain when a click arrives, which is the + * seam between the two: the view is the only thing that knows where it drew anything, + * and the input chain is the only thing that knows a click happened. + * + * Replaced wholesale rather than accumulated, because a frame is rebuilt whole. + */ +/** The state's contract; register abstract→concrete and depend on the abstract (DI rule). */ +export abstract class IFrameRegions { + public abstract get current(): readonly ClickRegion[]; + public abstract set(regions: readonly ClickRegion[]): void; +} + +export class FrameRegions extends IFrameRegions { + #regions: readonly ClickRegion[] = []; + + public get current(): readonly ClickRegion[] { + return this.#regions; + } + + public set(regions: readonly ClickRegion[]): void { + this.#regions = regions; + } +} diff --git a/apps/claude-sdk-cli/src/model/StatusState.ts b/apps/claude-sdk-cli/src/model/StatusState.ts index 9bab6414..72a4a4ee 100644 --- a/apps/claude-sdk-cli/src/model/StatusState.ts +++ b/apps/claude-sdk-cli/src/model/StatusState.ts @@ -1,4 +1,5 @@ import EventEmitter from 'node:events'; +import type { Instant } from '@js-joda/core'; import type { SdkMessageUsage, ThinkingEffort } from '@shellicar/claude-sdk'; type StatusStateEvents = { @@ -37,6 +38,8 @@ export class StatusState { #thinkingOverride: 'on' | 'off' | null = null; #effortOverride: ThinkingEffort | null = null; #cwdBasename: string; + #copiedAt: Instant | null = null; + #copiedLines = 0; readonly #emitter = new EventEmitter(); public get totalInputTokens(): number { @@ -84,6 +87,12 @@ export class StatusState { public get cwdBasename(): string { return this.#cwdBasename; } + public get copiedAt(): Instant | null { + return this.#copiedAt; + } + public get copiedLines(): number { + return this.#copiedLines; + } public constructor(cwdBasename: string) { this.#cwdBasename = cwdBasename; @@ -104,6 +113,17 @@ export class StatusState { this.#emitter.emit('change'); } + /** + * Record that a copy just landed, so the status line can say so. The instant comes + * from the caller rather than a clock read here: the view compares it against its own + * injected clock to decide whether the notice has expired. + */ + public markCopied(at: Instant, lines: number): void { + this.#copiedAt = at; + this.#copiedLines = lines; + this.#emitter.emit('change'); + } + public setModel(name: string, overridden = false): void { this.#model = name; this.#modelOverridden = overridden; diff --git a/apps/claude-sdk-cli/src/model/markdown/markdownLayout.ts b/apps/claude-sdk-cli/src/model/markdown/markdownLayout.ts index ddbdef97..d00b371a 100644 --- a/apps/claude-sdk-cli/src/model/markdown/markdownLayout.ts +++ b/apps/claude-sdk-cli/src/model/markdown/markdownLayout.ts @@ -1,6 +1,7 @@ import { wrapLine } from '@shellicar/claude-core/reflow'; import { marked, type Token, type Tokens } from 'marked'; import type { CodeDecorator } from '../blockLayout.js'; +import type { ClickRegion } from '../ClickRegion.js'; import { HR_WIDTH } from '../dividerWidths.js'; import { ACCENT, BOLD, BOLD_END, BULLET, box, CODE_FG, DIM, FG, HEADING, ITALIC, ITALIC_END, link, R, STRIKE, STRIKE_END, SUB_BULLET, table } from './palette.js'; @@ -113,14 +114,44 @@ function list(token: Tokens.List, cols: number, decorate: CodeDecorator, depth: return out; } +/** + * Lines, and the clickable spans within them, addressed relative to this array's own + * first line. A caller splicing these into a larger array offsets the rows by where it + * put them; one adding a prefix offsets the columns by the prefix's width. + */ +export type Laid = { + lines: string[]; + regions: ClickRegion[]; +}; + +const shift = (regions: readonly ClickRegion[], rows: number, columns: number): ClickRegion[] => regions.map((r) => ({ ...r, row: r.row + rows, startCol: r.startCol + columns, endCol: r.endCol + columns })); + +/** + * Hands out the identity of each code box in the order the walk draws them, which is the + * same order the model recorded them in. Running out means the model holds no record for + * this one, so it is drawn without an affordance rather than with a dead one. + */ +export type FenceIds = () => string | undefined; + +/** A cursor over recorded fence ids, optionally resuming partway in when an earlier slice was drawn from cache. */ +export function fenceCursor(ids: readonly string[], from = 0): FenceIds { + let next = from; + return () => ids[next++]; +} + +/** For the render paths that produce no clickable spans at all: scrollback, the history view. */ +export const noFences: FenceIds = () => undefined; + /** Render a blockquote: each produced line gets a dimmed `│` gutter and italic body. */ -function quote(token: Tokens.Blockquote, cols: number, decorate: CodeDecorator): string[] { - return blocks(token.tokens, cols, decorate).map((l) => `${DIM}\u2502${R} ${ITALIC}${l}${ITALIC_END}`); +function quote(token: Tokens.Blockquote, cols: number, decorate: CodeDecorator, fenceIds: FenceIds): Laid { + const inner = blocks(token.tokens, cols, decorate, fenceIds); + return { lines: inner.lines.map((l) => `${DIM}\u2502${R} ${ITALIC}${l}${ITALIC_END}`), regions: shift(inner.regions, 0, 2) }; } /** Render block-level tokens to display lines (no outer indent; the caller adds it). */ -function blocks(tokens: Token[], cols: number, decorate: CodeDecorator): string[] { +function blocks(tokens: Token[], cols: number, decorate: CodeDecorator, fenceIds: FenceIds): Laid { const out: string[] = []; + const regions: ClickRegion[] = []; for (const t of tokens) { switch (t.type) { case 'heading': { @@ -135,15 +166,25 @@ function blocks(tokens: Token[], cols: number, decorate: CodeDecorator): string[ case 'code': { const c = t as Tokens.Code; const lang = (c.lang ? c.lang.trim().split(/\s+/)[0] : '') || 'plaintext'; - out.push(...box(decorate(c.text, lang), lang, cols)); + // Drawn for every code box so the cursor stays in step with the walk, even where the + // box turns out too narrow to carry the icon. + const id = fenceIds(); + const drawn = box(decorate(c.text, lang), lang, cols, id !== undefined); + if (id !== undefined && drawn.iconCol >= 0) { + regions.push({ id, row: out.length, startCol: drawn.iconCol, endCol: drawn.iconCol, text: c.text }); + } + out.push(...drawn.lines); break; } case 'list': out.push(...list(t as Tokens.List, cols, decorate, 0)); break; - case 'blockquote': - out.push(...quote(t as Tokens.Blockquote, cols, decorate)); + case 'blockquote': { + const quoted = quote(t as Tokens.Blockquote, cols, decorate, fenceIds); + regions.push(...shift(quoted.regions, out.length, 0)); + out.push(...quoted.lines); break; + } case 'table': { const tb = t as Tokens.Table; out.push( @@ -166,7 +207,7 @@ function blocks(tokens: Token[], cols: number, decorate: CodeDecorator): string[ break; } } - return out; + return { lines: out, regions }; } /** @@ -174,9 +215,10 @@ function blocks(tokens: Token[], cols: number, decorate: CodeDecorator): string[ * raw path. Mirrors blockContentLines' signature so the view and any measurement * share one walker, with `decorate` injected for code-body colour. */ -export function markdownContentLines(content: string, cols: number, indent: string, decorate: CodeDecorator): string[] { +export function markdownContent(content: string, cols: number, indent: string, decorate: CodeDecorator, fenceIds: FenceIds = noFences): Laid { const inner = Math.max(1, cols - indent.length); - return blocks(marked.lexer(content), inner, decorate).map((l) => indent + l); + const laid = blocks(marked.lexer(content), inner, decorate, fenceIds); + return { lines: laid.lines.map((l) => indent + l), regions: shift(laid.regions, 0, indent.length) }; } /** @@ -204,7 +246,62 @@ export function splitSealedTokens(content: string): { sealed: Token[]; tail: Tok } /** Render an already-split token slice (see splitSealedTokens) to indented display lines. */ -export function renderTokenLines(tokens: Token[], cols: number, indent: string, decorate: CodeDecorator): string[] { +export function renderTokens(tokens: Token[], cols: number, indent: string, decorate: CodeDecorator, fenceIds: FenceIds = noFences): Laid { const inner = Math.max(1, cols - indent.length); - return blocks(tokens, inner, decorate).map((l) => indent + l); + const laid = blocks(tokens, inner, decorate, fenceIds); + return { lines: laid.lines.map((l) => indent + l), regions: shift(laid.regions, 0, indent.length) }; +} + +/** + * The code bodies a render draws a box for, in that order. Mirrors the walk in `blocks`: top + * level and inside a blockquote, but not inside a list item, where a fence is flattened to + * inline text and never boxed. + */ +function codeTexts(tokens: readonly Token[]): string[] { + const out: string[] = []; + for (const t of tokens) { + if (t.type === 'code') { + out.push((t as Tokens.Code).text); + } else if (t.type === 'blockquote') { + out.push(...codeTexts((t as Tokens.Blockquote).tokens)); + } + } + return out; +} + +/** How many code boxes a token slice draws, so a cursor can resume past a slice rendered from cache. */ +export function codeBoxCount(tokens: readonly Token[]): number { + return codeTexts(tokens).length; +} + +/** + * Whether the last thing in the document is a code block. marked's fence rule consumes to end + * of input when a fence never closes, so an unclosed one is always in this position. A code + * block anywhere else has closed. + */ +function endsInCode(tokens: readonly Token[]): boolean { + const last = tokens[tokens.length - 1]; + if (!last) { + return false; + } + if (last.type === 'code') { + return true; + } + return last.type === 'blockquote' ? endsInCode((last as Tokens.Blockquote).tokens) : false; +} + +/** + * The code bodies in `content` that can no longer change, in the order a render draws them. + * + * `final` is a block that has sealed: nothing more is coming, so a fence left unclosed has + * settled too. While a block is still being written the last code box is excluded when it is + * also the document's last token, since that is the only place an open fence can be. + */ +export function settledCodeTexts(content: string, final: boolean): string[] { + const tokens = marked.lexer(content); + const texts = codeTexts(tokens); + if (!final && endsInCode(tokens)) { + texts.pop(); + } + return texts; } diff --git a/apps/claude-sdk-cli/src/model/markdown/palette.ts b/apps/claude-sdk-cli/src/model/markdown/palette.ts index dace2759..4ce34e89 100644 --- a/apps/claude-sdk-cli/src/model/markdown/palette.ts +++ b/apps/claude-sdk-cli/src/model/markdown/palette.ts @@ -33,6 +33,13 @@ export const CODE_FG = e('38;5;180'); /** Heading colour graded by level; h4+ reuse h3 (the spec grades three levels). */ export const HEADING = [e('38;5;39'), e('38;5;74'), e('38;5;110')]; +/** + * The clickable copy affordance drawn in a code box's top border. Deliberately not an + * emoji: a VS16 sequence is measured differently by tmux and by iTerm2, which corrupts + * the row on redraw, and the box's width invariant depends on this being one cell. + */ +export const COPY_ICON = '\u29c9'; + export const BULLET = '\u2022'; export const SUB_BULLET = '\u25e6'; @@ -52,6 +59,9 @@ export function link(href: string, label: string): string { /** * Draw a code body inside a box with a language label in the top border. * + * `withIcon` decides whether the top border carries the copy affordance. Without it the border + * is drawn to the same width and `iconCol` comes back as -1. + * * Capped to `termWidth`: snug to the content when it fits, capped-and-wrapped when * it does not, so a long line is seen instead of clipping off the right edge. The * wrap is ANSI-aware (via `wrapLine`) so a syntax-highlighted line breaks on visible @@ -60,21 +70,38 @@ export function link(href: string, label: string): string { * label-aware (`innerW - 1 - L`), so a label of any length lines up. Structure * matches the spec's box(). */ -export function box(bodyLines: string[], lang: string, termWidth = 80): string[] { +export type CodeBox = { + lines: string[]; + /** Column of the copy icon within the top border, or -1 when the box is too narrow to carry one. */ + iconCol: number; +}; + +export function box(bodyLines: string[], lang: string, termWidth: number, withIcon: boolean): CodeBox { const maxInner = Math.max(1, termWidth - 4); const wrapped: string[] = []; for (const l of bodyLines) { wrapped.push(...wrapLine(l, maxInner)); } const labelWidth = stringWidth(lang); - const innerW = Math.min(maxInner, Math.max(labelWidth + 1, ...wrapped.map((l) => stringWidth(l)))); + // The label and the icon both live in the top border, so the box is never narrower + // than the chrome needs: sizing to the body alone leaves a short snippet under a long + // language label with nowhere to put the icon. + const innerW = Math.min(maxInner, Math.max(labelWidth + 3, ...wrapped.map((l) => stringWidth(l)))); + // On a terminal too narrow even for that, the border is drawn without an icon rather + // than losing its width invariant to make room. + const dashes = innerW - labelWidth - 3; + const iconCol = withIcon && dashes >= 0 ? innerW + 2 : -1; const out: string[] = []; - out.push(DIM + '\u250c\u2500 ' + ACCENT + lang + FG + DIM + ' ' + '\u2500'.repeat(Math.max(0, innerW - 1 - labelWidth)) + '\u2510' + R); + if (iconCol < 0) { + out.push(DIM + '\u250c\u2500 ' + ACCENT + lang + FG + DIM + ' ' + '\u2500'.repeat(Math.max(0, innerW - 1 - labelWidth)) + '\u2510' + R); + } else { + out.push(DIM + '\u250c\u2500 ' + ACCENT + lang + FG + DIM + ' ' + '\u2500'.repeat(dashes) + ' ' + ACCENT + COPY_ICON + FG + DIM + '\u2510' + R); + } for (const l of wrapped) { out.push(DIM + '\u2502' + FG + ' ' + l + ' '.repeat(Math.max(0, innerW - stringWidth(l))) + ' ' + DIM + '\u2502' + R); } out.push(DIM + '\u2514' + '\u2500'.repeat(innerW + 2) + '\u2518' + R); - return out; + return { lines: out, iconCol }; } // The table's whole visual vocabulary. Style is a change to these three and the diff --git a/apps/claude-sdk-cli/src/setup/container.ts b/apps/claude-sdk-cli/src/setup/container.ts index 3fb0a9ce..50d6b4f9 100644 --- a/apps/claude-sdk-cli/src/setup/container.ts +++ b/apps/claude-sdk-cli/src/setup/container.ts @@ -87,6 +87,7 @@ import { ClaudeMdLoader } from '../ClaudeMdLoader.js'; import { AgentMessageHandler } from '../controller/AgentMessageHandler.js'; import { ApprovalHandler } from '../controller/ApprovalHandler.js'; import { CancelHandler } from '../controller/CancelHandler.js'; +import { ClickHandler } from '../controller/ClickHandler.js'; import { CommandIntentExecutor } from '../controller/CommandIntentExecutor.js'; import { CommandKeyHandler } from '../controller/CommandKeyHandler.js'; import { ConversationNavHandler } from '../controller/ConversationNavHandler.js'; @@ -111,6 +112,8 @@ import { AccountLimitNotice } from '../model/AccountLimitNotice.js'; import { type AppModeKey, AppModeState, IAppModeState } from '../model/AppModeState.js'; import { ApprovalNotifier } from '../model/ApprovalNotifier.js'; import { AttachmentSource } from '../model/AttachmentSource.js'; +import { ClickTracker, IClickTracker } from '../model/ClickTracker.js'; +import { IClipboard, Osc52Clipboard } from '../model/Clipboard.js'; import { RequestClockAdapter, ToolsClockAdapter } from '../model/ClockListeners.js'; import { CommandModeState, ICommandModeState } from '../model/CommandModeState.js'; import { ConversationListState, IConversationListState } from '../model/ConversationListState.js'; @@ -118,6 +121,7 @@ import { ConversationSession, IConversationSession } from '../model/Conversation import { ConversationState, IConversationState } from '../model/ConversationState.js'; import { DisabledToolsNoticeGate } from '../model/DisabledToolsNoticeGate.js'; import { EditorBuffer, IEditorBuffer } from '../model/EditorBuffer.js'; +import { FrameRegions, IFrameRegions } from '../model/FrameRegions.js'; import { HistoryViewState, IHistoryViewState } from '../model/HistoryViewState.js'; import { IGraphemeSegmenter } from '../model/IGraphemeSegmenter.js'; import { IntlGraphemeSegmenter } from '../model/IntlGraphemeSegmenter.js'; @@ -468,6 +472,9 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { services.register(TerminalState).as(ITerminalState); services.register(PrimaryViewState).as(IPrimaryViewState); services.register(ScrollState).as(IScrollState); + services.register(FrameRegions).as(IFrameRegions); + services.register(ClickTracker).as(IClickTracker); + services.register(Osc52Clipboard).as(IClipboard); services.register(AppModeState).as(IAppModeState); services.register(HistoryViewState).as(IHistoryViewState); services.register(ConversationListState).as(IConversationListState); @@ -509,6 +516,7 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { services.register(EditorHandler).asSelf(); services.register(ViewSelectHandler).asSelf(); services.register(ScrollHandler).asSelf(); + services.register(ClickHandler).asSelf(); services.register(HistoryNavHandler).asSelf(); services.register(AgentMessageHandler).asSelf(); services.register(SdkEventBridge).as(ISdkEventBridge); @@ -532,9 +540,11 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { .asSelf(); services .register(PrimaryPresentation) - .using([QuitHandler, ViewSelectHandler, ScrollHandler, ApprovalHandler, CommandKeyHandler, EditorHandler, CancelHandler, PrimaryView, IPrimaryViewState], (quit, viewSelect, scroll, approval, commandKey, editor, cancel, primaryView, primaryViewState) => { - const editorChain: readonly InputHandler[] = [quit, viewSelect, scroll, approval, commandKey, editor]; - const streamingChain: readonly InputHandler[] = [quit, viewSelect, scroll, approval, commandKey, cancel]; + .using([QuitHandler, ViewSelectHandler, ScrollHandler, ClickHandler, ApprovalHandler, CommandKeyHandler, EditorHandler, CancelHandler, PrimaryView, IPrimaryViewState], (quit, viewSelect, scroll, click, approval, commandKey, editor, cancel, primaryView, primaryViewState) => { + // Click sits after scroll (which claims the wheel) and before everything that reads + // the keyboard, so a click never reaches the composer as input. + const editorChain: readonly InputHandler[] = [quit, viewSelect, scroll, click, approval, commandKey, editor]; + const streamingChain: readonly InputHandler[] = [quit, viewSelect, scroll, click, approval, commandKey, cancel]; return new PrimaryPresentation(primaryView, primaryViewState, editorChain, streamingChain); }) .asSelf(); @@ -576,6 +586,7 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { PrimaryPresentation, HistoryPresentation, ConversationPresentation, + IFrameRegions, ], ( conversationState, @@ -598,6 +609,7 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { primaryPresentation, historyPresentation, conversationPresentation, + frameRegions, ) => { const model: ViewModel = { conversationState, @@ -622,7 +634,7 @@ export function buildContainer(options: ContainerOptions): IServiceCollection { ['history', historyPresentation], ['conversations', conversationPresentation], ]); - return new ViewHost(terminalRenderer, model, presentations, appModeState); + return new ViewHost(terminalRenderer, model, presentations, appModeState, frameRegions); }, ) .asSelf(); diff --git a/apps/claude-sdk-cli/src/view/ConversationView.ts b/apps/claude-sdk-cli/src/view/ConversationView.ts index 11b74d44..ad933805 100644 --- a/apps/claude-sdk-cli/src/view/ConversationView.ts +++ b/apps/claude-sdk-cli/src/view/ConversationView.ts @@ -5,7 +5,7 @@ import stringWidth from 'string-width'; import type { ConversationEntry } from '../model/ConversationListState.js'; import { formatAge, formatContext, formatCost, formatModel, formatSpan, formatTimeOfDay, oneLine, PENDING } from './formatConversationEntry.js'; import { renderViewBar } from './renderViewBar.js'; -import type { View, ViewModel } from './View.js'; +import type { Frame, View, ViewModel } from './View.js'; /** Marks the conversation this process is currently on. */ const CURRENT = '●'; @@ -51,7 +51,7 @@ const fit = (text: string, width: number): string => { * Render-only: the loader fills summaries, the state owns selection and the peek flag. */ export class ConversationView implements View { - public render(model: ViewModel): string[] { + public render(model: ViewModel): Frame { const { conversationListState, terminalState, appModeState, session, statusState } = model; const cols = terminalState.cols; const rows = terminalState.rows; @@ -69,7 +69,7 @@ export class ConversationView implements View { const body = blocks.length === 0 ? [`${DIM} no conversations recorded in this directory${RESET}`] : this.#windowed(blocks, conversationListState.selected, bodyHeight); - return [header, '', ...body.slice(0, bodyHeight), ...Array(Math.max(0, bodyHeight - body.length)).fill(''), hints, bar]; + return { rows: [header, '', ...body.slice(0, bodyHeight), ...Array(Math.max(0, bodyHeight - body.length)).fill(''), hints, bar], regions: [] }; } /** What the keys do: the key itself accented, its effect beside it. A key that would be refused is diff --git a/apps/claude-sdk-cli/src/view/HistoryView.ts b/apps/claude-sdk-cli/src/view/HistoryView.ts index af15d616..b9b62447 100644 --- a/apps/claude-sdk-cli/src/view/HistoryView.ts +++ b/apps/claude-sdk-cli/src/view/HistoryView.ts @@ -1,9 +1,9 @@ import { HISTORY_CONTENT_INDENT, historyContentBudget, historyOpenLines } from '../model/blockLayout.js'; import type { Block } from '../model/ConversationState.js'; import type { IHistoryViewState } from '../model/HistoryViewState.js'; -import { buildDivider, getHighlighted, renderBlockContentCached } from './renderConversation.js'; +import { buildDivider, getHighlighted, renderBlockFrameCached } from './renderConversation.js'; import { renderViewBar } from './renderViewBar.js'; -import type { View, ViewModel } from './View.js'; +import type { Frame, View, ViewModel } from './View.js'; const LABEL: Record = { prompt: 'prompt', @@ -36,7 +36,7 @@ const ELLIPSIS = `${HISTORY_CONTENT_INDENT}...`; * the box edge. Tools blocks nest the same box model. Render-only. */ export class HistoryView implements View { - public render(model: ViewModel): string[] { + public render(model: ViewModel): Frame { const { conversationState, historyViewState, terminalState, appModeState } = model; const cols = terminalState.cols; const rows = terminalState.rows; @@ -63,7 +63,7 @@ export class HistoryView implements View { const body = this.#centre(stack, focusedStart, focusedLen, bodyHeight); body.push(renderViewBar(appModeState.active)); - return body; + return { rows: body, regions: [] }; } /** @@ -107,12 +107,12 @@ export class HistoryView implements View { // Focused but collapsed: gutter every line, cap the content. if (focused) { const inner = cols - GUTTER.length; - const capped = this.#cap(renderBlockContentCached(block, block.content, inner, false)); + const capped = this.#cap(renderBlockFrameCached(block, block.content, inner, false).lines); return [`${GUTTER}${buildDivider(`${label} (focused)`, inner)}`, ...capped.map((l) => `${GUTTER}${l}`)]; } // Unfocused: flush, collapsed. - return [buildDivider(label, cols), ...this.#cap(renderBlockContentCached(block, block.content, cols, false))]; + return [buildDivider(label, cols), ...this.#cap(renderBlockFrameCached(block, block.content, cols, false).lines)]; } #toolsCard(block: Block, focused: boolean, hv: IHistoryViewState, cols: number, rows: number): string[] { @@ -145,10 +145,10 @@ export class HistoryView implements View { const names = tools.map((tool) => tool.name).join(' . '); if (focused) { const inner = cols - GUTTER.length; - const preview = this.#cap(renderBlockContentCached(block, names, inner, false)); + const preview = this.#cap(renderBlockFrameCached(block, names, inner, false).lines); return [`${GUTTER}${buildDivider(`${label} (${n}) (focused)`, inner)}`, ...preview.map((l) => `${GUTTER}${l}`)]; } - return [buildDivider(`${label} (${n})`, cols), ...this.#cap(renderBlockContentCached(block, names, cols, false))]; + return [buildDivider(`${label} (${n})`, cols), ...this.#cap(renderBlockFrameCached(block, names, cols, false).lines)]; } /** Cap a collapsed box's content: keep the first lines, mark more with a `...` line — only when there is more. */ diff --git a/apps/claude-sdk-cli/src/view/PrimaryView.ts b/apps/claude-sdk-cli/src/view/PrimaryView.ts index e28459e5..e900d097 100644 --- a/apps/claude-sdk-cli/src/view/PrimaryView.ts +++ b/apps/claude-sdk-cli/src/view/PrimaryView.ts @@ -1,11 +1,12 @@ +import { windowRegions } from '../model/ClickRegion.js'; import type { IScrollState } from '../model/ScrollState.js'; import { renderCommandMode } from './renderCommandMode.js'; -import { blockTimestamps, buildDivider, renderConversation } from './renderConversation.js'; +import { blockTimestamps, buildDivider, renderConversationFrame } from './renderConversation.js'; import { renderEditor } from './renderEditor.js'; -import { renderClock, renderModel, renderStatus } from './renderStatus.js'; +import { copyNotice, renderClock, renderModel, renderStatus } from './renderStatus.js'; import { renderToolApproval } from './renderToolApproval.js'; import { renderViewBar } from './renderViewBar.js'; -import type { View, ViewModel } from './View.js'; +import type { Frame, View, ViewModel } from './View.js'; /** * Window the transcript into the scroll region for this frame. Reconciles the @@ -42,7 +43,7 @@ function windowTranscript(transcript: readonly string[], scrollRows: number, col * the chrome fixed. */ export class PrimaryView implements View { - public render(model: ViewModel): string[] { + public render(model: ViewModel): Frame { const { conversationState, editorBuffer, segmenter, toolApprovalState, commandModeState, statusState, turnClock, terminalState, primaryViewState, scrollState, appModeState, session, configLoader } = model; const cols = terminalState.cols; const rows = terminalState.rows; @@ -65,18 +66,24 @@ export class PrimaryView implements View { editorRegion.push(...renderEditor(segmenter, editorBuffer.content, cols)); } - const transcript = renderConversation(conversationState, cols, configLoader.config.markdown); + const transcript = renderConversationFrame(conversationState, cols, configLoader.config.markdown); const scrollRows = Math.max(2, rows - statusBarHeight - editorRegion.length); - const visibleRows = windowTranscript(transcript, scrollRows, cols, scrollState); + const visibleRows = windowTranscript(transcript.lines, scrollRows, cols, scrollState); + // The transcript window is the first thing in the frame, so a windowed region's row + // is already its screen row with nothing further to add. + const regions = windowRegions(transcript.regions, transcript.lines.length, scrollRows, scrollState.offset); const separator = buildDivider(null, cols); const modelLine = renderModel(statusState, cols, session.id); const statusLine = renderStatus(statusState, cols, session.turnCount); const clockLine = renderClock(turnClock.snapshot()); const viewBar = renderViewBar(appModeState.active); + // The approval row is blank whenever nothing is pending, so the copy notice borrows + // it rather than costing a row of its own. A pending approval outranks it. + const noticeRow = approvalRow || copyNotice(statusState, model.clock.instant()); // The view bar shares the command-mode row (existing footer chrome, not a // new row): it fills the row when no command hint is present. How the two // share the row when both are present is the deferred layout call. - return [...visibleRows, ...editorRegion, separator, modelLine, statusLine, clockLine, approvalRow, ...editorRows, commandRow || viewBar, ...expandedRows]; + return { rows: [...visibleRows, ...editorRegion, separator, modelLine, statusLine, clockLine, noticeRow, ...editorRows, commandRow || viewBar, ...expandedRows], regions }; } } diff --git a/apps/claude-sdk-cli/src/view/View.ts b/apps/claude-sdk-cli/src/view/View.ts index 57419d33..25d1664c 100644 --- a/apps/claude-sdk-cli/src/view/View.ts +++ b/apps/claude-sdk-cli/src/view/View.ts @@ -2,6 +2,7 @@ import type { Clock } from '@js-joda/core'; import type { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader'; import type { sdkConfigSchema } from '../cli-config/schema.js'; import type { IAppModeState } from '../model/AppModeState.js'; +import type { ClickRegion } from '../model/ClickRegion.js'; import type { ICommandModeState } from '../model/CommandModeState.js'; import type { IConversationListState } from '../model/ConversationListState.js'; import type { IConversationSession } from '../model/ConversationSession.js'; @@ -50,11 +51,24 @@ export type ViewModel = { }; /** - * A presentation's render surface. It renders the model to a full frame of rows - * and does nothing else: no key handling, no I/O, no store mutation. Input - * handling is a separate concern (see InputHandler) that meets presentation - * only at the stores in ViewModel. + * One painted screen: the rows, and the spans within them a click can act on. + * + * The regions come back with the rows because the view is the only thing that knows + * where it put anything. Wrapping, indenting and scroll windowing all happen inside + * the render, and nothing downstream can recover them from strings. A frame is rebuilt + * whole every paint, so its regions are replaced wholesale rather than accumulated. + */ +export type Frame = { + rows: string[]; + regions: ClickRegion[]; +}; + +/** + * A presentation's render surface. It renders the model to a full frame and does + * nothing else: no key handling, no I/O, no store mutation. Returning a region is not + * acting on one: the frame describes what a click would hit, and the input chain is + * what invokes it. */ export interface View { - render(model: ViewModel): string[]; + render(model: ViewModel): Frame; } diff --git a/apps/claude-sdk-cli/src/view/renderConversation.ts b/apps/claude-sdk-cli/src/view/renderConversation.ts index dab8fad8..9f606046 100644 --- a/apps/claude-sdk-cli/src/view/renderConversation.ts +++ b/apps/claude-sdk-cli/src/view/renderConversation.ts @@ -5,9 +5,11 @@ import { highlight, supportsLanguage } from 'cli-highlight'; import stringWidth from 'string-width'; import type { MarkdownConfig } from '../cli-config/types.js'; import { blockContentLines, CONTENT_INDENT } from '../model/blockLayout.js'; +import type { ClickRegion } from '../model/ClickRegion.js'; import type { Block, IConversationState } from '../model/ConversationState.js'; import { MIN_DIVIDER_WIDTH } from '../model/dividerWidths.js'; -import { markdownContentLines, renderTokenLines, splitSealedTokens } from '../model/markdown/markdownLayout.js'; +import { codeBoxCount, type FenceIds, fenceCursor, type Laid, markdownContent, noFences, renderTokens, splitSealedTokens } from '../model/markdown/markdownLayout.js'; +import { ACCENT, COPY_ICON } from '../model/markdown/palette.js'; import { formatDuration } from './formatDuration.js'; const FILL = '\u2500'; @@ -56,8 +58,13 @@ export function getHighlighted(code: string, lang: string): string[] { * code fences with getHighlighted — layout is shared (model/blockLayout), the * cli-highlight decoration stays here in the view. */ -export function renderBlockContent(content: string, cols: number, indent: string = CONTENT_INDENT, markdown = false): string[] { - return markdown ? markdownContentLines(content, cols, indent, getHighlighted) : blockContentLines(content, cols, indent, getHighlighted); +export function renderBlockFrame(content: string, cols: number, indent: string = CONTENT_INDENT, markdown = false, fenceIds: FenceIds = noFences): Laid { + return markdown ? markdownContent(content, cols, indent, getHighlighted, fenceIds) : { lines: blockContentLines(content, cols, indent, getHighlighted), regions: [] }; +} + +/** The identities the model has recorded for a block's code blocks, in the order a render draws them. */ +function recordedFenceIds(block: Block): string[] { + return (block.fences ?? []).map((fence) => fence.id); } /** Whether a block renders as markdown: `response` blocks, when the flag is on. */ @@ -88,7 +95,7 @@ export function blockTimestamps(createdAt: Instant | undefined, exitedAt: Instan }; } -type SealedRender = { cols: number; content: string; markdown: boolean; lines: string[] }; +type SealedRender = { cols: number; content: string; markdown: boolean; lines: string[]; regions: ClickRegion[] }; const sealedContentCache = new WeakMap(); /** @@ -104,9 +111,11 @@ type StreamingMarkdownCache = { cols: number; sealedRaw: string; sealedLines: string[]; + sealedRegions: ClickRegion[]; lastRunAt: number; decoratedContent: string; decoratedLines: string[]; + decoratedRegions: ClickRegion[]; // True when decoratedLines' last entry is the still-open tail's last wrapped row — i.e. safe to // continue appending raw text onto. False when the tail was empty at decoration time (content ended // exactly at a sealed boundary), in which case the last entry is sealed content (e.g. a closing fence @@ -135,14 +144,16 @@ const streamingMarkdownCache = new WeakMap(); * Keyed by block identity, like sealedContentCache; a fresh WeakMap entry per block means a new * response starts with no stale state from a previous one. */ -function renderStreamingMarkdown(block: Block, cols: number, indent: string, now: number): string[] { +function renderStreamingMarkdown(block: Block, cols: number, indent: string, now: number): Laid { const hit = streamingMarkdownCache.get(block); const dueForRefresh = !hit || hit.cols !== cols || now - hit.lastRunAt >= MARKDOWN_REFRESH_MS || !block.content.startsWith(hit.decoratedContent); if (!dueForRefresh && hit) { const rawTail = block.content.slice(hit.decoratedContent.length); if (rawTail.length === 0) { - return hit.decoratedLines; + // A copy: the caller swaps the first line's indent for the block emoji, and the + // cached array would carry that swap into the next frame and slice it again. + return { lines: [...hit.decoratedLines], regions: hit.decoratedRegions }; } // The raw tail's first fragment (up to its first \n, or all of it if there's none) continues // whatever was already on the last decorated line — it must be concatenated and rewrapped, not @@ -160,17 +171,24 @@ function renderStreamingMarkdown(block: Block, cols: number, indent: string, now for (const fragment of fragments) { lines.push(...wrapLine(indent + fragment, cols)); } - return lines; + // Raw appended text carries no code fence, so it adds no clickable span, and every + // span the decorated prefix holds keeps its row: only the last line is ever rewrapped. + return { lines, regions: hit.decoratedRegions }; } const { sealed, tail } = splitSealedTokens(block.content); const sealedRaw = sealed.map((t) => t.raw ?? '').join(''); - const sealedLines = hit && hit.cols === cols && hit.sealedRaw === sealedRaw ? hit.sealedLines : renderTokenLines(sealed, cols, indent, getHighlighted); - const tailLines = renderTokenLines(tail, cols, indent, getHighlighted); - const lines = [...sealedLines, ...tailLines]; + const cached = hit && hit.cols === cols && hit.sealedRaw === sealedRaw; + // One cursor's worth of ids spans both slices, so the tail resumes where the sealed part + // stopped even on the frames where the sealed part came back from cache without being walked. + const ids = recordedFenceIds(block); + const sealedLaid: Laid = cached ? { lines: hit.sealedLines, regions: hit.sealedRegions } : renderTokens(sealed, cols, indent, getHighlighted, fenceCursor(ids)); + const tailLaid = renderTokens(tail, cols, indent, getHighlighted, fenceCursor(ids, codeBoxCount(sealed))); + const lines = [...sealedLaid.lines, ...tailLaid.lines]; + const regions = [...sealedLaid.regions, ...tailLaid.regions.map((region) => ({ ...region, row: region.row + sealedLaid.lines.length }))]; - streamingMarkdownCache.set(block, { cols, sealedRaw, sealedLines, lastRunAt: now, decoratedContent: block.content, decoratedLines: lines, hasOpenLine: tailLines.length > 0 }); - return lines; + streamingMarkdownCache.set(block, { cols, sealedRaw, sealedLines: sealedLaid.lines, sealedRegions: sealedLaid.regions, lastRunAt: now, decoratedContent: block.content, decoratedLines: lines, decoratedRegions: regions, hasOpenLine: tailLaid.lines.length > 0 }); + return { lines, regions }; } /** @@ -186,15 +204,15 @@ function renderStreamingMarkdown(block: Block, cols: number, indent: string, now * block identity; the WeakMap drops entries when a block is gc'd (e.g. * ConversationState.clear()). The active streaming block is never cached. */ -export function renderBlockContentCached(block: Block, content: string, cols: number, markdown: boolean): string[] { +export function renderBlockFrameCached(block: Block, content: string, cols: number, markdown: boolean): Laid { const indent = block.type === 'notice' ? '' : CONTENT_INDENT; const hit = sealedContentCache.get(block); if (hit && hit.cols === cols && hit.content === content && hit.markdown === markdown) { - return hit.lines; + return { lines: hit.lines, regions: hit.regions }; } - const lines = renderBlockContent(content, cols, indent, markdown); - sealedContentCache.set(block, { cols, content, markdown, lines }); - return lines; + const laid = renderBlockFrame(content, cols, indent, markdown, fenceCursor(recordedFenceIds(block))); + sealedContentCache.set(block, { cols, content, markdown, lines: laid.lines, regions: laid.regions }); + return laid; } /** @@ -226,14 +244,73 @@ export function buildDivider(displayLabel: string | null, cols: number, timestam return DIM + line + FILL.repeat(remaining) + RESET; } +/** + * A block's header divider, with the copy affordance that puts the whole block on the + * clipboard. Only sealed blocks get one: until a block closes there is less to copy than + * there will be. Same rule as the code box, whose icon waits for its fence to close. + */ +export function buildBlockDivider(displayLabel: string, cols: number, timestamps?: DividerTimestamps): { line: string; iconCol: number } { + const bare = buildDivider(displayLabel, cols, timestamps); + return { line: `${bare} ${ACCENT}${COPY_ICON}${RESET}`, iconCol: stringWidth(bare) + 1 }; +} + +/** + * The rows a run of blocks draws, as text. blockContentLines drops one trailing newline + * when it lays a block out, so joining the raw contents would paste a blank line the + * transcript never drew. + */ +function runContent(run: readonly Block[]): string { + return run.map((block) => (block.content.endsWith('\n') ? block.content.slice(0, -1) : block.content)).join('\n'); +} + +/** + * What a block's copy affordance puts on the clipboard. + * + * Consecutive blocks of one type are drawn as a single block under one header, so the + * affordance on that header answers for every block beneath it. A run of one is the + * ordinary case. + * + * A tools or execution block shows a one-line summary of the calls it made, which is + * useless pasted anywhere, so it copies the calls themselves as JSON: a tools block is + * the invocation side and carries each call's name and input, an execution block is the + * result side and carries each call's name and output. Every other block copies its own + * content, which is what it displays. + */ +function copyPayload(run: readonly [Block, ...Block[]]): string { + const first = run[0]; + if (first.type !== 'tools' && first.type !== 'execution') { + return runContent(run); + } + const entries = run.flatMap((block) => block.tools ?? []); + if (entries.length === 0) { + return runContent(run); + } + const calls = first.type === 'tools' ? entries.map((entry) => ({ name: entry.name, input: entry.input })) : entries.map((entry) => ({ name: entry.name, output: entry.output })); + return JSON.stringify(calls, null, 2); +} + +/** The block and every sealed block drawn beneath its header, which is the run of one type it starts. */ +function runFrom(sealedBlocks: ReadonlyArray, start: number, block: Block): [Block, ...Block[]] { + const run: [Block, ...Block[]] = [block]; + for (let i = start + 1; i < sealedBlocks.length; i++) { + const next = sealedBlocks[i]; + if (next?.type !== block.type) { + break; + } + run.push(next); + } + return run; +} + /** * Render conversation blocks into an array of display lines for the alt-buffer viewport. * * Returns sealed blocks + active streaming block. The caller (AppLayout) appends the * editor divider and editor lines when in editor mode, then slices to contentRows. */ -export function renderConversation(state: IConversationState, cols: number, markdown?: MarkdownConfig): string[] { +export function renderConversationFrame(state: IConversationState, cols: number, markdown?: MarkdownConfig): Laid { const allContent: string[] = []; + const regions: ClickRegion[] = []; const sealedBlocks = state.sealedBlocks; for (let i = 0; i < sealedBlocks.length; i++) { @@ -248,12 +325,26 @@ export function renderConversation(state: IConversationState, cols: number, mark const hasNextContinuation = nextBlock?.type === block.type; if (!isContinuation && block.type !== 'notice') { - const emoji = BLOCK_EMOJI[block.type] ?? ''; - const plain = BLOCK_PLAIN[block.type] ?? block.type; - allContent.push(buildDivider(`${emoji}${plain}`, cols, blockTimestamps(block.createdAt, block.exitedAt))); + const label = `${BLOCK_EMOJI[block.type] ?? ''}${BLOCK_PLAIN[block.type] ?? block.type}`; + const timestamps = blockTimestamps(block.createdAt, block.exitedAt); + const run = runFrom(sealedBlocks, i, block); + // The run reaches into the block still being written, so what this header answers for is + // not finished. Same rule as an open fence: draw the header, offer nothing on it. + const stillBeingWritten = i + run.length === sealedBlocks.length && state.activeBlock?.type === block.type; + if (stillBeingWritten) { + allContent.push(buildDivider(label, cols, timestamps)); + } else { + const header = buildBlockDivider(label, cols, timestamps); + regions.push({ id: block.id, row: allContent.length, startCol: header.iconCol, endCol: header.iconCol, text: copyPayload(run) }); + allContent.push(header.line); + } allContent.push(''); } - allContent.push(...renderBlockContentCached(block, block.content, cols, blockRendersMarkdown(block, markdown))); + const laid = renderBlockFrameCached(block, block.content, cols, blockRendersMarkdown(block, markdown)); + for (const region of laid.regions) { + regions.push({ ...region, row: region.row + allContent.length }); + } + allContent.push(...laid.lines); if (!hasNextContinuation) { allContent.push(''); } @@ -278,10 +369,14 @@ export function renderConversation(state: IConversationState, cols: number, mark if (streamingMarkdown) { // markdownContentLines indents every line; swap the first line's indent for // the block emoji so the active block keeps its leading marker. - const mdLines = renderStreamingMarkdown(state.activeBlock, cols, activeIndent, Date.now()); + const streamed = renderStreamingMarkdown(state.activeBlock, cols, activeIndent, Date.now()); + const mdLines = streamed.lines; if (mdLines.length > 0) { mdLines[0] = activeEmoji + mdLines[0].slice(activeIndent.length); } + for (const region of streamed.regions) { + regions.push({ ...region, row: region.row + allContent.length }); + } allContent.push(...mdLines); } else { const activeLines = state.activeBlock.content.split('\n'); @@ -292,7 +387,7 @@ export function renderConversation(state: IConversationState, cols: number, mark } } - return allContent; + return { lines: allContent, regions }; } /** @@ -317,7 +412,7 @@ export function renderBlocksToString(allBlocks: ReadonlyArray, startIndex out += `${buildDivider(`${emoji}${plain}`, cols, blockTimestamps(block.createdAt, block.exitedAt))}\n\n`; } const blockIndent = block.type === 'notice' ? '' : CONTENT_INDENT; - for (const line of renderBlockContent(block.content, cols, blockIndent, blockRendersMarkdown(block, markdown))) { + for (const line of renderBlockFrame(block.content, cols, blockIndent, blockRendersMarkdown(block, markdown)).lines) { out += `${line}\n`; } if (!hasNextContinuation) { diff --git a/apps/claude-sdk-cli/src/view/renderStatus.ts b/apps/claude-sdk-cli/src/view/renderStatus.ts index 4d75a6c6..0b656232 100644 --- a/apps/claude-sdk-cli/src/view/renderStatus.ts +++ b/apps/claude-sdk-cli/src/view/renderStatus.ts @@ -1,6 +1,6 @@ -import type { Duration } from '@js-joda/core'; +import { Duration, type Instant } from '@js-joda/core'; import versionInfo from '@shellicar/build-version/version'; -import { BOLD_WHITE, CYAN, DIM, RESET, YELLOW } from '@shellicar/claude-core/ansi'; +import { BOLD_WHITE, CYAN, DIM, GREEN, RESET, YELLOW } from '@shellicar/claude-core/ansi'; import { StatusLineBuilder } from '@shellicar/claude-core/status-line'; import type { ClockRole, ClockSnapshot } from '../model/ITurnClock.js'; import type { StatusState } from '../model/StatusState.js'; @@ -22,6 +22,26 @@ import { parseModelName } from './parseModelName.js'; * than competing with the model/session segments. Shown in both branches * (model set or not) since it identifies the running build regardless. */ +/** How long a copy stays announced. Long enough to notice, short enough not to linger. */ +const COPY_NOTICE_DURATION = Duration.ofSeconds(2); + +/** + * The transient acknowledgement that a click put something on the clipboard. Expiry is + * decided against the caller's clock, so the notice clears on the next repaint after its + * window closes rather than needing a timer of its own. + * + * A whole row, not a segment: it shares the row the tool-approval prompt uses, which is + * empty whenever nothing is pending, so the notice never pushes another line sideways. + */ +export function copyNotice(state: StatusState, now: Instant): string { + const copiedAt = state.copiedAt; + if (copiedAt === null || !now.isBefore(copiedAt.plus(COPY_NOTICE_DURATION))) { + return ''; + } + const lines = state.copiedLines; + return ` ${GREEN}\u2713 copied ${lines} ${lines === 1 ? 'line' : 'lines'}${RESET}`; +} + export function renderModel(state: StatusState, _cols: number, conversationId: string): string { const label = state.sessionName != null ? `${BOLD_WHITE}*${state.sessionName}${RESET}` : state.cwdBasename; const model = state.model; diff --git a/apps/claude-sdk-cli/test/ClickHandler.spec.ts b/apps/claude-sdk-cli/test/ClickHandler.spec.ts new file mode 100644 index 00000000..b52a59f8 --- /dev/null +++ b/apps/claude-sdk-cli/test/ClickHandler.spec.ts @@ -0,0 +1,135 @@ +import { Clock, Instant, ZoneId } from '@js-joda/core'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { ClickHandler } from '../src/controller/ClickHandler.js'; +import type { ClickRegion } from '../src/model/ClickRegion.js'; +import { ClickTracker, IClickTracker } from '../src/model/ClickTracker.js'; +import { IClipboard } from '../src/model/Clipboard.js'; +import { FrameRegions, IFrameRegions } from '../src/model/FrameRegions.js'; +import { StatusState } from '../src/model/StatusState.js'; + +const NOW = Instant.parse('2026-08-11T00:00:00Z'); + +/** Records what reached the clipboard, rather than writing an escape sequence to a screen. */ +class RecordingClipboard extends IClipboard { + public readonly written: string[] = []; + public write(text: string): void { + this.written.push(text); + } +} + +const region = (id: string, text: string, row: number): ClickRegion => ({ id, row, startCol: 10, endCol: 12, text }); + +// ClickHandler injects the regions, the tracker, the clipboard, the status line and a +// clock, so build it through a container holding the ones a test needs to inspect. +function build(regions: readonly ClickRegion[]): { handler: ClickHandler; clipboard: RecordingClipboard; statusState: StatusState } { + const clipboard = new RecordingClipboard(); + const statusState = new StatusState('test'); + const frameRegions = new FrameRegions(); + frameRegions.set(regions); + + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(Clock) + .using(() => Clock.fixed(NOW, ZoneId.UTC)) + .asSelf(); + services + .register(IFrameRegions) + .using(() => frameRegions) + .asSelf(); + services + .register(IClickTracker) + .using(() => new ClickTracker()) + .asSelf(); + services + .register(IClipboard) + .using(() => clipboard) + .asSelf(); + services + .register(StatusState) + .using(() => statusState) + .asSelf(); + services.register(ClickHandler).asSelf(); + return { handler: services.buildProvider().resolve(ClickHandler), clipboard, statusState }; +} + +describe('ClickHandler — a completed click', () => { + it('puts the target text on the clipboard', () => { + const { handler, clipboard } = build([region('fence-1', 'const a = 1;', 3)]); + handler.handleKey({ type: 'mouse_down', col: 11, row: 3 }); + handler.handleKey({ type: 'mouse_up', col: 11, row: 3 }); + const expected = ['const a = 1;']; + const actual = clipboard.written; + expect(actual).toEqual(expected); + }); + + it('reports how many lines it copied', () => { + const { handler, statusState } = build([region('fence-1', 'one\ntwo\nthree', 3)]); + handler.handleKey({ type: 'mouse_down', col: 11, row: 3 }); + handler.handleKey({ type: 'mouse_up', col: 11, row: 3 }); + const expected = 3; + const actual = statusState.copiedLines; + expect(actual).toBe(expected); + }); + + it('records when the copy happened', () => { + const { handler, statusState } = build([region('fence-1', 'const a = 1;', 3)]); + handler.handleKey({ type: 'mouse_down', col: 11, row: 3 }); + handler.handleKey({ type: 'mouse_up', col: 11, row: 3 }); + const expected = NOW; + const actual = statusState.copiedAt; + expect(actual).toEqual(expected); + }); +}); + +describe('ClickHandler — a click that did not complete', () => { + it('copies nothing when the release lands on another target', () => { + const { handler, clipboard } = build([region('fence-1', 'const a = 1;', 3), region('fence-2', 'const b = 2;', 5)]); + handler.handleKey({ type: 'mouse_down', col: 11, row: 3 }); + handler.handleKey({ type: 'mouse_up', col: 11, row: 5 }); + const expected = 0; + const actual = clipboard.written.length; + expect(actual).toBe(expected); + }); + + it('copies nothing when the release lands on empty space', () => { + const { handler, clipboard } = build([region('fence-1', 'const a = 1;', 3)]); + handler.handleKey({ type: 'mouse_down', col: 11, row: 3 }); + handler.handleKey({ type: 'mouse_up', col: 40, row: 9 }); + const expected = 0; + const actual = clipboard.written.length; + expect(actual).toBe(expected); + }); + + it('copies nothing when the release lands on another block holding identical text', () => { + const { handler, clipboard } = build([region('block-1', 'continue', 3), region('block-2', 'continue', 5)]); + handler.handleKey({ type: 'mouse_down', col: 11, row: 3 }); + handler.handleKey({ type: 'mouse_up', col: 11, row: 5 }); + const expected = 0; + const actual = clipboard.written.length; + expect(actual).toBe(expected); + }); +}); + +describe('ClickHandler — what it claims', () => { + it('claims a press that landed on nothing, so it never reaches the editor', () => { + const { handler } = build([]); + const expected = true; + const actual = handler.handleKey({ type: 'mouse_down', col: 1, row: 1 }); + expect(actual).toBe(expected); + }); + + it('claims a release that landed on nothing', () => { + const { handler } = build([]); + const expected = true; + const actual = handler.handleKey({ type: 'mouse_up', col: 1, row: 1 }); + expect(actual).toBe(expected); + }); + + it('passes a key that is not a mouse event through', () => { + const { handler } = build([region('fence-1', 'const a = 1;', 3)]); + const expected = false; + const actual = handler.handleKey({ type: 'char', value: 'x' }); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/ClickTracker.spec.ts b/apps/claude-sdk-cli/test/ClickTracker.spec.ts new file mode 100644 index 00000000..740ad4fa --- /dev/null +++ b/apps/claude-sdk-cli/test/ClickTracker.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import type { ClickRegion } from '../src/model/ClickRegion.js'; +import { ClickTracker } from '../src/model/ClickTracker.js'; + +// Each call builds a fresh object, so a test that presses and releases "the same" +// region is passing two distinct objects naming one target. That is the repaint case: +// the frame the release resolves against is not the frame the press did. +const region = (id: string, text = 'const a = 1;', row = 4): ClickRegion => ({ id, row, startCol: 10, endCol: 12, text }); + +describe('ClickTracker', () => { + it('returns the pressed region when the release lands on the same target', () => { + const expected = region('fence-1'); + const tracker = new ClickTracker(); + tracker.press(expected); + const actual = tracker.release(region('fence-1')); + expect(actual).toBe(expected); + }); + + it('returns the pressed region when a repaint moved the target to another row', () => { + const expected = region('fence-1', 'const a = 1;', 4); + const tracker = new ClickTracker(); + tracker.press(expected); + const actual = tracker.release(region('fence-1', 'const a = 1;', 3)); + expect(actual).toBe(expected); + }); + + it('returns null when the release lands on a different target', () => { + const tracker = new ClickTracker(); + tracker.press(region('fence-1')); + const actual = tracker.release(region('fence-2', 'const b = 2;')); + expect(actual).toBeNull(); + }); + + it('returns null when the release lands on a different target carrying identical text', () => { + const tracker = new ClickTracker(); + tracker.press(region('block-1', 'continue')); + const actual = tracker.release(region('block-2', 'continue')); + expect(actual).toBeNull(); + }); + + it('returns null when the release lands on no target', () => { + const tracker = new ClickTracker(); + tracker.press(region('fence-1')); + const actual = tracker.release(null); + expect(actual).toBeNull(); + }); + + it('returns null when a press landed on no target', () => { + const tracker = new ClickTracker(); + tracker.press(null); + const actual = tracker.release(region('fence-1')); + expect(actual).toBeNull(); + }); + + it('returns null for a release with no press before it', () => { + const tracker = new ClickTracker(); + const actual = tracker.release(region('fence-1')); + expect(actual).toBeNull(); + }); + + it('returns null for a second release once the first consumed the press', () => { + const tracker = new ClickTracker(); + tracker.press(region('fence-1')); + tracker.release(region('fence-1')); + const actual = tracker.release(region('fence-1')); + expect(actual).toBeNull(); + }); + + it('matches the most recent press when two arrive without a release', () => { + const expected = region('fence-2', 'const b = 2;'); + const tracker = new ClickTracker(); + tracker.press(region('fence-1')); + tracker.press(expected); + const actual = tracker.release(region('fence-2', 'const b = 2;')); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/ConversationState.spec.ts b/apps/claude-sdk-cli/test/ConversationState.spec.ts index d48712ee..6f527ec5 100644 --- a/apps/claude-sdk-cli/test/ConversationState.spec.ts +++ b/apps/claude-sdk-cli/test/ConversationState.spec.ts @@ -630,3 +630,142 @@ describe('ConversationState — setLastTools', () => { expect(actual).toBe(expected); }); }); + +describe('ConversationState — block identity', () => { + it('gives each block an id of its own', () => { + const state = buildConversationState(); + state.addBlocks([ + { type: 'meta', content: 'one' }, + { type: 'meta', content: 'two' }, + ]); + const expected = 2; + const actual = new Set(state.sealedBlocks.map((block) => block.id)).size; + expect(actual).toBe(expected); + }); + + it('keeps the active block id when it seals', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendStreaming('hello'); + const expected = state.activeBlock?.id; + state.completeActive(); + const actual = state.sealedBlocks[0]?.id; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — settled code blocks', () => { + const FENCE = '```ts\nconst a = 1;\n```\n\n'; + + it('records a code block once its fence closes', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendStreaming(`intro\n\n${FENCE}after`); + const expected = ['const a = 1;']; + const actual = state.activeBlock?.fences?.map((fence) => fence.text); + expect(actual).toEqual(expected); + }); + + it('records nothing while the fence is still open', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendStreaming('intro\n\n```ts\nconst a = 1;'); + const actual = state.activeBlock?.fences; + expect(actual).toBeUndefined(); + }); + + it('gives two code blocks holding identical source different ids', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendStreaming(`${FENCE}${FENCE}after`); + const expected = 2; + const actual = new Set(state.activeBlock?.fences?.map((fence) => fence.id)).size; + expect(actual).toBe(expected); + }); + + it('keeps a code block id as more content arrives after it', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendStreaming(`${FENCE}after`); + const expected = state.activeBlock?.fences?.[0]?.id; + state.appendStreaming('\n\nand more still\n\n'); + const actual = state.activeBlock?.fences?.[0]?.id; + expect(actual).toBe(expected); + }); + + it('records a fence the response never closed once the block seals', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendStreaming('intro\n\n```ts\nconst a = 1;'); + state.completeActive(); + const expected = 1; + const actual = state.sealedBlocks[0]?.fences?.length; + expect(actual).toBe(expected); + }); + + it('records nothing for a block that is never drawn as markdown', () => { + const state = buildConversationState(); + state.transitionBlock('meta'); + state.appendStreaming(`intro\n\n${FENCE}after`); + const actual = state.activeBlock?.fences; + expect(actual).toBeUndefined(); + }); +}); + +describe('ConversationState — how often it looks for settled code blocks', () => { + const FENCE = '```ts\nconst a = 1;\n```\n\n'; + + it('does not look again within the period', () => { + const clock = new FakeClock(Instant.ofEpochMilli(0)); + const state = buildConversationState(clock); + state.transitionBlock('response'); + state.appendStreaming(`${FENCE}after`); + clock.advanceTo(Instant.ofEpochMilli(119)); + state.appendStreaming(`\n\n${FENCE}more`); + const expected = 1; + const actual = state.activeBlock?.fences?.length; + expect(actual).toBe(expected); + }); + + it('looks again once the period has passed', () => { + const clock = new FakeClock(Instant.ofEpochMilli(0)); + const state = buildConversationState(clock); + state.transitionBlock('response'); + state.appendStreaming(`${FENCE}after`); + clock.advanceTo(Instant.ofEpochMilli(120)); + state.appendStreaming(`\n\n${FENCE}more`); + const expected = 2; + const actual = state.activeBlock?.fences?.length; + expect(actual).toBe(expected); + }); + + it('looks when the block seals, however recently it last looked', () => { + const clock = new FakeClock(Instant.ofEpochMilli(0)); + const state = buildConversationState(clock); + state.transitionBlock('response'); + state.appendStreaming(`${FENCE}after`); + clock.advanceTo(Instant.ofEpochMilli(1)); + state.appendStreaming(`\n\n${FENCE}more`); + state.completeActive(); + const expected = 2; + const actual = state.sealedBlocks[0]?.fences?.length; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — one period per block', () => { + const FENCE = '```ts\nconst a = 1;\n```\n\n'; + + it('looks at a new block straight away, however recently the one before it looked', () => { + const clock = new FakeClock(Instant.ofEpochMilli(0)); + const state = buildConversationState(clock); + state.transitionBlock('response'); + state.appendStreaming(`${FENCE}after`); + state.transitionBlock('prompt'); + state.transitionBlock('response'); + state.appendStreaming(`${FENCE}after`); + const expected = 1; + const actual = state.activeBlock?.fences?.length; + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/ConversationView.spec.ts b/apps/claude-sdk-cli/test/ConversationView.spec.ts index 5b45502d..3b63de16 100644 --- a/apps/claude-sdk-cli/test/ConversationView.spec.ts +++ b/apps/claude-sdk-cli/test/ConversationView.spec.ts @@ -42,7 +42,7 @@ function render(listState: ConversationListState, sessionId = CURRENT_ID, phase: statusState: { cwdBasename: 'claude-cli' }, clock: Clock.fixed(NOW, ZoneId.UTC), } as unknown as ViewModel; - return plain(new ConversationView().render(model)); + return plain(new ConversationView().render(model).rows); } const listWith = (...entries: Array<{ id: string; summary?: AuditSummary }>): ConversationListState => { diff --git a/apps/claude-sdk-cli/test/HistoryNavHandler.spec.ts b/apps/claude-sdk-cli/test/HistoryNavHandler.spec.ts index 1ab09125..5436e0bc 100644 --- a/apps/claude-sdk-cli/test/HistoryNavHandler.spec.ts +++ b/apps/claude-sdk-cli/test/HistoryNavHandler.spec.ts @@ -6,6 +6,7 @@ import { HistoryNavHandler } from '../src/controller/HistoryNavHandler.js'; import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; import { HistoryViewState, IHistoryViewState } from '../src/model/HistoryViewState.js'; import { ITerminalState, TerminalState } from '../src/model/TerminalState.js'; +import { buildConversationState } from './buildConversationState.js'; class NoopLogger extends ILogger { public trace(): void {} @@ -49,7 +50,7 @@ function buildHistoryNavHandler(state: HistoryViewState, conversation: Conversat } function setup() { - const conversation = new ConversationState(); + const conversation = buildConversationState(); conversation.addBlocks([ { type: 'prompt', content: 'ask' }, { diff --git a/apps/claude-sdk-cli/test/HistoryView.spec.ts b/apps/claude-sdk-cli/test/HistoryView.spec.ts index 6b671523..a239f19a 100644 --- a/apps/claude-sdk-cli/test/HistoryView.spec.ts +++ b/apps/claude-sdk-cli/test/HistoryView.spec.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest'; import { AppModeState } from '../src/model/AppModeState.js'; import { ConversationListState } from '../src/model/ConversationListState.js'; import type { ConversationSession } from '../src/model/ConversationSession.js'; -import { ConversationState } from '../src/model/ConversationState.js'; import { HistoryViewState } from '../src/model/HistoryViewState.js'; import { IntlGraphemeSegmenter } from '../src/model/IntlGraphemeSegmenter.js'; import { ITurnClock } from '../src/model/ITurnClock.js'; @@ -18,6 +17,7 @@ import { HistoryView } from '../src/view/HistoryView.js'; import { renderViewBar } from '../src/view/renderViewBar.js'; import type { ViewModel } from '../src/view/View.js'; import { buildCommandModeState } from './buildCommandModeState.js'; +import { buildConversationState } from './buildConversationState.js'; import { buildEditorBuffer } from './buildEditorBuffer.js'; const CONTENT_INDENT = ' '; @@ -38,7 +38,7 @@ function makeTurnClock(): ITurnClock { function makeModel(firstContent = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8'): ViewModel { const terminalState = new TerminalState(); terminalState.setSize(80, 24); - const conversationState = new ConversationState(); + const conversationState = buildConversationState(); conversationState.addBlocks([ { type: 'response', content: firstContent }, { @@ -74,19 +74,19 @@ function makeModel(firstContent = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8'): ViewModel { describe('HistoryView — box model', () => { it('gutters the focused block content', () => { const expected = `> ${CONTENT_INDENT}l1`; - const actual = new HistoryView().render(makeModel()); + const actual = new HistoryView().render(makeModel()).rows; expect(actual).toContain(expected); }); it('caps a long collapsed block with an ellipsis line', () => { const expected = `> ${CONTENT_INDENT}...`; - const actual = new HistoryView().render(makeModel()); + const actual = new HistoryView().render(makeModel()).rows; expect(actual).toContain(expected); }); it('renders an unfocused block flush, without a gutter', () => { const expected = `${CONTENT_INDENT}reply`; - const actual = new HistoryView().render(makeModel()); + const actual = new HistoryView().render(makeModel()).rows; expect(actual).toContain(expected); }); @@ -94,7 +94,7 @@ describe('HistoryView — box model', () => { const model = makeModel(); model.historyViewState.apply('open', model.conversationState.sealedBlocks); const expected = `${CONTENT_INDENT}l8`; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); @@ -104,7 +104,7 @@ describe('HistoryView — box model', () => { model.historyViewState.apply('next', bs); // focus the tools block model.historyViewState.apply('open', bs); // descend to tool 0 const expected = '> ReadFile'; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); @@ -115,7 +115,7 @@ describe('HistoryView — box model', () => { model.historyViewState.apply('open', bs); // descend to tool 0 model.historyViewState.apply('open', bs); // open tool 0 const expected = `${CONTENT_INDENT} {"path":"a.ts"}`; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); @@ -126,19 +126,19 @@ describe('HistoryView — box model', () => { model.historyViewState.apply('open', bs); // descend to tool 0 model.historyViewState.apply('open', bs); // open tool 0 const expected = `${CONTENT_INDENT} file contents`; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); it('renders the view bar as the footer row', () => { const expected = renderViewBar('primary'); - const actual = new HistoryView().render(makeModel()).at(-1); + const actual = new HistoryView().render(makeModel()).rows.at(-1); expect(actual).toBe(expected); }); it('fills the screen height', () => { const expected = 24; - const actual = new HistoryView().render(makeModel()).length; + const actual = new HistoryView().render(makeModel()).rows.length; expect(actual).toBe(expected); }); @@ -147,7 +147,7 @@ describe('HistoryView — box model', () => { const model = makeModel(tall); model.historyViewState.apply('open', model.conversationState.sealedBlocks); const expected = `${CONTENT_INDENT}...`; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); @@ -155,7 +155,7 @@ describe('HistoryView — box model', () => { const tall = Array.from({ length: 30 }, (_, i) => `line${i + 1}`).join('\n'); const model = makeModel(tall); model.historyViewState.apply('open', model.conversationState.sealedBlocks); - const actual = new HistoryView().render(model).find((row) => row.includes('~')); + const actual = new HistoryView().render(model).rows.find((row) => row.includes('~')); expect(actual).toBeUndefined(); }); }); @@ -167,7 +167,7 @@ describe('HistoryView — frames against the illustrations', () => { // Every block's content fits the cap, and the short stack is not clipped, // so no `...` marker (collapsed cap or centre clip) should appear anywhere. const model = makeModel('only one line'); - const actual = new HistoryView().render(model).find((row) => row.includes('...')); + const actual = new HistoryView().render(model).rows.find((row) => row.includes('...')); expect(actual).toBeUndefined(); }); @@ -176,23 +176,23 @@ describe('HistoryView — frames against the illustrations', () => { // of the focused box, so it carries the gutter. const middleRow = Math.floor((24 - 1) / 2); const expected = '> '; - const actual = new HistoryView().render(makeModel())[middleRow]?.slice(0, 2); + const actual = new HistoryView().render(makeModel()).rows[middleRow]?.slice(0, 2); expect(actual).toBe(expected); }); it('marks the opened block with an (open) header', () => { const model = makeModel(); model.historyViewState.apply('open', model.conversationState.sealedBlocks); - const actual = new HistoryView().render(model).find((row) => row.includes('(open)')); + const actual = new HistoryView().render(model).rows.find((row) => row.includes('(open)')); expect(actual).toBeDefined(); }); it('leaves the frame unchanged when moving up at the first block', () => { const model = makeModel(); const view = new HistoryView(); - const expected = view.render(model); + const expected = view.render(model).rows; model.historyViewState.apply('prev', model.conversationState.sealedBlocks); - const actual = view.render(model); + const actual = view.render(model).rows; expect(actual).toEqual(expected); }); @@ -201,9 +201,9 @@ describe('HistoryView — frames against the illustrations', () => { const bs = model.conversationState.sealedBlocks; model.historyViewState.apply('end', bs); const view = new HistoryView(); - const expected = view.render(model); + const expected = view.render(model).rows; model.historyViewState.apply('next', bs); - const actual = view.render(model); + const actual = view.render(model).rows; expect(actual).toEqual(expected); }); @@ -212,9 +212,9 @@ describe('HistoryView — frames against the illustrations', () => { const bs = model.conversationState.sealedBlocks; model.historyViewState.apply('end', bs); const view = new HistoryView(); - const atEnd = view.render(model); + const atEnd = view.render(model).rows; model.historyViewState.apply('prev', bs); - const actual = view.render(model); + const actual = view.render(model).rows; expect(actual).not.toEqual(atEnd); }); @@ -225,7 +225,7 @@ describe('HistoryView — frames against the illustrations', () => { model.historyViewState.apply('open', bs); model.historyViewState.apply('end', bs, 100); const expected = `${CONTENT_INDENT}line30`; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); @@ -235,9 +235,9 @@ describe('HistoryView — frames against the illustrations', () => { const bs = model.conversationState.sealedBlocks; model.historyViewState.apply('open', bs); const view = new HistoryView(); - const expected = view.render(model).find((row) => row.includes('(open)')); + const expected = view.render(model).rows.find((row) => row.includes('(open)')); model.historyViewState.apply('end', bs, 100); - const actual = view.render(model).find((row) => row.includes('(open)')); + const actual = view.render(model).rows.find((row) => row.includes('(open)')); expect(actual).toBe(expected); }); }); @@ -266,7 +266,7 @@ describe('HistoryView — execution block', () => { const bs = model.conversationState.sealedBlocks; model.historyViewState.apply('open', bs); // descend to tool 0 const expected = '> DeleteFile'; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); @@ -276,7 +276,7 @@ describe('HistoryView — execution block', () => { model.historyViewState.apply('open', bs); // descend to tool 0 model.historyViewState.apply('open', bs); // open tool 0 const expected = `${CONTENT_INDENT} deleted`; - const actual = new HistoryView().render(model); + const actual = new HistoryView().render(model).rows; expect(actual).toContain(expected); }); }); diff --git a/apps/claude-sdk-cli/test/HistoryViewState.spec.ts b/apps/claude-sdk-cli/test/HistoryViewState.spec.ts index 812a5d13..d78d4054 100644 --- a/apps/claude-sdk-cli/test/HistoryViewState.spec.ts +++ b/apps/claude-sdk-cli/test/HistoryViewState.spec.ts @@ -4,8 +4,9 @@ import { HistoryViewState } from '../src/model/HistoryViewState.js'; function blocks(): Block[] { return [ - { type: 'prompt', content: 'ask' }, + { id: 'block-prompt', type: 'prompt', content: 'ask' }, { + id: 'block-tools', type: 'tools', content: 'tool lines', tools: [ @@ -13,7 +14,7 @@ function blocks(): Block[] { { name: 'Exec', kind: 'client', input: { cmd: 'ls' }, output: 'list', phase: 'done' }, ], }, - { type: 'response', content: 'reply' }, + { id: 'block-response', type: 'response', content: 'reply' }, ]; } diff --git a/apps/claude-sdk-cli/test/PermissionsNoticeGate.spec.ts b/apps/claude-sdk-cli/test/PermissionsNoticeGate.spec.ts index 9633ee2a..008f48a5 100644 --- a/apps/claude-sdk-cli/test/PermissionsNoticeGate.spec.ts +++ b/apps/claude-sdk-cli/test/PermissionsNoticeGate.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import type { PermissionsConfigInput } from '../src/cli-config/formatPermissionsDisplay.js'; import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; import { PermissionsNoticeGate } from '../src/model/PermissionsNoticeGate.js'; -import { renderConversation } from '../src/view/renderConversation.js'; +import { renderConversationFrame } from '../src/view/renderConversation.js'; class NoopLogger extends ILogger { public trace(): void {} @@ -53,7 +53,7 @@ function applyConfigChange(gate: PermissionsNoticeGate, state: ConversationState } function permissionsNoticeRendered(state: ConversationState): boolean { - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); return lines.some((line) => line.includes('Permissions')); } diff --git a/apps/claude-sdk-cli/test/PrimaryPresentation.spec.ts b/apps/claude-sdk-cli/test/PrimaryPresentation.spec.ts index 2ab1f16c..cc21c6e3 100644 --- a/apps/claude-sdk-cli/test/PrimaryPresentation.spec.ts +++ b/apps/claude-sdk-cli/test/PrimaryPresentation.spec.ts @@ -4,7 +4,7 @@ import type { InputHandler } from '../src/controller/InputHandler.js'; import { PrimaryViewState } from '../src/model/PrimaryViewState.js'; import type { View } from '../src/view/View.js'; -const stubView: View = { render: () => [] }; +const stubView: View = { render: () => ({ rows: [], regions: [] }) }; const editorChain: readonly InputHandler[] = [{ handleKey: () => false }]; const streamingChain: readonly InputHandler[] = [{ handleKey: () => false }]; diff --git a/apps/claude-sdk-cli/test/PrimaryView.spec.ts b/apps/claude-sdk-cli/test/PrimaryView.spec.ts index edb47e66..f4340fb2 100644 --- a/apps/claude-sdk-cli/test/PrimaryView.spec.ts +++ b/apps/claude-sdk-cli/test/PrimaryView.spec.ts @@ -56,7 +56,7 @@ function makeModel(): ViewModel { const terminalState = new TerminalState(); terminalState.setSize(80, 24); return { - conversationState: new ConversationState(), + conversationState: makeConversationState(Clock.fixed(Instant.ofEpochMilli(0), ZoneId.UTC)), editorBuffer: buildEditorBuffer(), segmenter: new IntlGraphemeSegmenter(), toolApprovalState: new ToolApprovalState(), @@ -78,7 +78,7 @@ function makeModel(): ViewModel { describe('PrimaryView — editor region', () => { it('includes the prompt divider in editor phase', () => { const model = makeModel(); - const rows = new PrimaryView().render(model); + const rows = new PrimaryView().render(model).rows; const expected = true; const actual = rows.join('\n').includes('prompt'); expect(actual).toBe(expected); @@ -90,7 +90,7 @@ describe('PrimaryView — editor region', () => { model.conversationState = makeConversationState(clock); model.conversationState.markPromptStart(); - const rows = new PrimaryView().render(model); + const rows = new PrimaryView().render(model).rows; const promptRow = rows.find((row) => row.includes('prompt')); const expected = true; const actual = /\d{2}:\d{2}:\d{2}/.test(promptRow ?? ''); @@ -101,7 +101,7 @@ describe('PrimaryView — editor region', () => { it('omits the prompt divider in streaming phase', () => { const model = makeModel(); model.primaryViewState.setPhase('streaming'); - const rows = new PrimaryView().render(model); + const rows = new PrimaryView().render(model).rows; const expected = false; const actual = rows.join('\n').includes('prompt'); expect(actual).toBe(expected); @@ -113,7 +113,7 @@ describe('PrimaryView — status', () => { const model = makeModel(); model.statusState.setModel('claude-x'); model.statusState.setShowConversationId(true); - const rows = new PrimaryView().render(model); + const rows = new PrimaryView().render(model).rows; const expected = true; const actual = rows.join('\n').includes('sess-123'); expect(actual).toBe(expected); @@ -130,7 +130,7 @@ describe('PrimaryView — scroll', () => { it('pins to the bottom with no indicator by default', () => { const model = tallStreamingModel(); - const rows = new PrimaryView().render(model); + const rows = new PrimaryView().render(model).rows; const expected = false; const actual = rows.join('\n').includes('scroll down to resume'); expect(actual).toBe(expected); @@ -141,7 +141,7 @@ describe('PrimaryView — scroll', () => { const view = new PrimaryView(); view.render(model); // measure geometry model.scrollState.lineUp(); - const rows = view.render(model); + const rows = view.render(model).rows; const expected = true; const actual = rows.join('\n').includes('scroll down to resume'); expect(actual).toBe(expected); @@ -153,7 +153,7 @@ describe('PrimaryView — scroll', () => { const view = new PrimaryView(); view.render(model); model.scrollState.lineUp(); - const rows = view.render(model); + const rows = view.render(model).rows; const indicatorRow = rows.findIndex((row) => row.includes('scroll down to resume')); const promptRow = rows.findIndex((row) => row.includes('prompt')); const expected = true; diff --git a/apps/claude-sdk-cli/test/ViewHost.spec.ts b/apps/claude-sdk-cli/test/ViewHost.spec.ts index 21300c9e..d1ba1afa 100644 --- a/apps/claude-sdk-cli/test/ViewHost.spec.ts +++ b/apps/claude-sdk-cli/test/ViewHost.spec.ts @@ -25,8 +25,9 @@ import { AttachmentSource } from '../src/model/AttachmentSource.js'; import { ICommandModeState } from '../src/model/CommandModeState.js'; import { ConversationListState } from '../src/model/ConversationListState.js'; import { IConversationSession } from '../src/model/ConversationSession.js'; -import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; +import { IConversationState } from '../src/model/ConversationState.js'; import { IEditorBuffer } from '../src/model/EditorBuffer.js'; +import { FrameRegions } from '../src/model/FrameRegions.js'; import { HistoryViewState } from '../src/model/HistoryViewState.js'; import { IntlGraphemeSegmenter } from '../src/model/IntlGraphemeSegmenter.js'; import { ISystemIdentity } from '../src/model/ISystemIdentity.js'; @@ -45,9 +46,10 @@ import { ConsumerChannel } from '../src/setup/ConsumerChannel.js'; import { ConversationSwitcher, IConversationSwitcher } from '../src/setup/ConversationSwitcher.js'; import { PrimaryView } from '../src/view/PrimaryView.js'; import type { TerminalRenderer } from '../src/view/TerminalRenderer.js'; -import type { ViewModel } from '../src/view/View.js'; +import type { Frame, ViewModel } from '../src/view/View.js'; import { IWorkspace } from '../src/workspace/Workspace.js'; import { buildCommandModeState } from './buildCommandModeState.js'; +import { buildConversationState } from './buildConversationState.js'; import { buildEditorBuffer } from './buildEditorBuffer.js'; import { FakeAttachmentSource } from './FakeAttachmentSource.js'; import { FakeWorkspace } from './FakeWorkspace.js'; @@ -91,7 +93,7 @@ function makeModel(): ViewModel { const terminalState = new TerminalState(); terminalState.setSize(80, 24); return { - conversationState: new ConversationState(), + conversationState: buildConversationState(), editorBuffer: buildEditorBuffer(), segmenter: new IntlGraphemeSegmenter(), toolApprovalState: new ToolApprovalState(), @@ -119,9 +121,35 @@ function fakeRenderer(paints: Array): TerminalRenderer { } function singlePresentation(activeChain: () => readonly InputHandler[]): ReadonlyMap { - return new Map([['primary', { view: { render: () => [] }, activeChain }]]); + return new Map([['primary', { view: { render: () => ({ rows: [], regions: [] }) }, activeChain }]]); } +describe('ViewHost — the regions it publishes', () => { + it('publishes the frame regions so a click has something to resolve against', () => { + const model = makeModel(); + const expected = [{ id: 'fence-1', row: 1, startCol: 2, endCol: 2, text: 'const a = 1;' }]; + const frameRegions = new FrameRegions(); + const presentations = new Map([['primary', { view: { render: () => ({ rows: [], regions: expected }) }, activeChain: () => [] }]]); + new ViewHost(fakeRenderer([]), model, presentations, new AppModeState(), frameRegions).renderNow(); + const actual = frameRegions.current; + expect(actual).toEqual(expected); + }); + + it('replaces the regions of the previous frame rather than accumulating them', () => { + const model = makeModel(); + const frameRegions = new FrameRegions(); + let frame: Frame = { rows: [], regions: [{ id: 'fence-1', row: 1, startCol: 2, endCol: 2, text: 'const a = 1;' }] }; + const presentations = new Map([['primary', { view: { render: () => frame }, activeChain: () => [] }]]); + const host = new ViewHost(fakeRenderer([]), model, presentations, new AppModeState(), frameRegions); + host.renderNow(); + frame = { rows: [], regions: [] }; + host.renderNow(); + const expected = 0; + const actual = frameRegions.current.length; + expect(actual).toBe(expected); + }); +}); + describe('ViewHost — render coalescing', () => { it('paints once after a single emission', async () => { const model = makeModel(); @@ -131,6 +159,7 @@ describe('ViewHost — render coalescing', () => { model, singlePresentation(() => []), new AppModeState(), + new FrameRegions(), ); model.conversationState.addBlocks([{ type: 'meta', content: 'x' }]); await flush(); @@ -147,6 +176,7 @@ describe('ViewHost — render coalescing', () => { model, singlePresentation(() => []), new AppModeState(), + new FrameRegions(), ); model.conversationState.addBlocks([{ type: 'meta', content: 'x' }]); model.editorBuffer.reset(); @@ -174,6 +204,7 @@ describe('ViewHost — key dispatch', () => { model, singlePresentation(() => chain), new AppModeState(), + new FrameRegions(), ); host.dispatchKey({ type: 'char', value: 'x' }); const expected = ['a', 'b']; @@ -190,6 +221,7 @@ describe('ViewHost — key dispatch', () => { model, singlePresentation(() => chain), new AppModeState(), + new FrameRegions(), ); host.dispatchKey({ type: 'escape' }); const expected = 0; @@ -216,8 +248,8 @@ describe('ViewHost — key dispatch', () => { }, }, ]; - const presentation = new PrimaryPresentation({ render: () => [] }, model.primaryViewState, editorChain, streamingChain); - const host = new ViewHost(fakeRenderer([]), model, new Map([['primary', presentation]]), new AppModeState()); + const presentation = new PrimaryPresentation({ render: () => ({ rows: [], regions: [] }) }, model.primaryViewState, editorChain, streamingChain); + const host = new ViewHost(fakeRenderer([]), model, new Map([['primary', presentation]]), new AppModeState(), new FrameRegions()); host.dispatchKey({ type: 'char', value: 'x' }); model.primaryViewState.setPhase('streaming'); host.dispatchKey({ type: 'char', value: 'x' }); @@ -337,7 +369,7 @@ describe('ViewHost — escape routing through the primary chains', () => { const editorChain: readonly InputHandler[] = [provider.resolve(ApprovalHandler), provider.resolve(CommandKeyHandler), provider.resolve(EditorHandler)]; const streamingChain: readonly InputHandler[] = [provider.resolve(ApprovalHandler), provider.resolve(CancelHandler)]; const presentation = new PrimaryPresentation(new PrimaryView(), model.primaryViewState, editorChain, streamingChain); - const host = new ViewHost(fakeRenderer([]), model, new Map([['primary', presentation]]), new AppModeState()); + const host = new ViewHost(fakeRenderer([]), model, new Map([['primary', presentation]]), new AppModeState(), new FrameRegions()); return { host, model, cancelLog }; } @@ -362,7 +394,7 @@ describe('ViewHost — escape routing through the primary chains', () => { describe('ViewHost — presentation switching', () => { function twoPresentations(log: string[]): ReadonlyMap { const primary: Presentation = { - view: { render: () => [] }, + view: { render: () => ({ rows: [], regions: [] }) }, activeChain: () => [ { handleKey: () => { @@ -373,7 +405,7 @@ describe('ViewHost — presentation switching', () => { ], }; const history: Presentation = { - view: { render: () => [] }, + view: { render: () => ({ rows: [], regions: [] }) }, activeChain: () => [ { handleKey: () => { @@ -393,7 +425,7 @@ describe('ViewHost — presentation switching', () => { const model = makeModel(); const log: string[] = []; const appModeState = new AppModeState(); - const host = new ViewHost(fakeRenderer([]), model, twoPresentations(log), appModeState); + const host = new ViewHost(fakeRenderer([]), model, twoPresentations(log), appModeState, new FrameRegions()); host.dispatchKey({ type: 'char', value: 'x' }); appModeState.setActive('history'); host.dispatchKey({ type: 'char', value: 'x' }); @@ -407,7 +439,7 @@ describe('ViewHost — presentation switching', () => { const paints: Array = []; const appModeState = new AppModeState(); appModeState.setActive('history'); - new ViewHost(fakeRenderer(paints), model, twoPresentations([]), appModeState); + new ViewHost(fakeRenderer(paints), model, twoPresentations([]), appModeState, new FrameRegions()); model.historyViewState.reset(); await flush(); const expected = 1; diff --git a/apps/claude-sdk-cli/test/ViewSelectHandler.spec.ts b/apps/claude-sdk-cli/test/ViewSelectHandler.spec.ts index bea36bc3..0e875a87 100644 --- a/apps/claude-sdk-cli/test/ViewSelectHandler.spec.ts +++ b/apps/claude-sdk-cli/test/ViewSelectHandler.spec.ts @@ -9,6 +9,7 @@ import { ConversationListState, IConversationListState } from '../src/model/Conv import { IConversationSession } from '../src/model/ConversationSession.js'; import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; import { HistoryViewState, IHistoryViewState } from '../src/model/HistoryViewState.js'; +import { buildConversationState } from './buildConversationState.js'; /** The conversation this process is on, which entry to the view should land on. */ const LIVE_ID = 'conv-live'; @@ -70,7 +71,7 @@ function buildViewSelectHandler(appModeState: AppModeState, historyViewState: Hi function setup() { const appModeState = new AppModeState(); const historyViewState = new HistoryViewState(); - const conversation = new ConversationState(); + const conversation = buildConversationState(); conversation.addBlocks([ { type: 'prompt', content: 'a' }, { type: 'response', content: 'b' }, diff --git a/apps/claude-sdk-cli/test/blockCopyIcon.spec.ts b/apps/claude-sdk-cli/test/blockCopyIcon.spec.ts new file mode 100644 index 00000000..415496d4 --- /dev/null +++ b/apps/claude-sdk-cli/test/blockCopyIcon.spec.ts @@ -0,0 +1,228 @@ +import { Clock, Instant, ZoneId } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import stringWidth from 'string-width'; +import { describe, expect, it } from 'vitest'; +import { ConversationState, IConversationState, type NewBlock } from '../src/model/ConversationState.js'; +import { COPY_ICON } from '../src/model/markdown/palette.js'; +import { renderConversationFrame } from '../src/view/renderConversation.js'; +import { glyphAtColumn } from './glyphAtColumn.js'; + +const NOW = Instant.parse('2026-08-11T00:00:00Z'); + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +function buildConversationState(): IConversationState { + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services.register(NoopLogger).as(ILogger); + services + .register(Clock) + .using(() => Clock.fixed(NOW, ZoneId.UTC)) + .asSelf(); + services.register(ConversationState).as(IConversationState); + return services.buildProvider().resolve(IConversationState); +} + +function strip(s: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI for test assertions + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** The visible cell a region addresses, as the operator sees it. */ +function cellUnder(frame: { lines: string[]; regions: Array<{ row: number; startCol: number }> }, index: number): string | undefined { + const region = frame.regions[index]; + if (!region) { + return undefined; + } + return glyphAtColumn(frame.lines[region.row] ?? '', region.startCol); +} + +const sealed = (...blocks: NewBlock[]): IConversationState => { + const state = buildConversationState(); + state.addBlocks(blocks); + return state; +}; + +describe('a sealed block carries a copy affordance on its header', () => { + it('addresses the cell the icon was drawn in', () => { + const expected = COPY_ICON; + const actual = cellUnder(renderConversationFrame(sealed({ type: 'response', content: 'hello' }), 80), 0); + expect(actual).toBe(expected); + }); + + it('copies the block content', () => { + const expected = 'hello'; + const actual = renderConversationFrame(sealed({ type: 'response', content: 'hello' }), 80).regions[0]?.text; + expect(actual).toBe(expected); + }); + + it('addresses the cell even when the label carries a variation selector', () => { + const expected = COPY_ICON; + const actual = cellUnder(renderConversationFrame(sealed({ type: 'execution', content: 'done' }), 80), 0); + expect(actual).toBe(expected); + }); + + it('addresses the cell on a meta block, whose label is a variation selector too', () => { + const expected = COPY_ICON; + const actual = cellUnder(renderConversationFrame(sealed({ type: 'meta', content: 'notice' }), 80), 0); + expect(actual).toBe(expected); + }); + + it('gives a block of each type its own affordance', () => { + const expected = 2; + const actual = renderConversationFrame(sealed({ type: 'thinking', content: 'pondering' }, { type: 'response', content: 'hello' }), 80).regions.length; + expect(actual).toBe(expected); + }); +}); + +describe('a block of tool calls copies the calls, not its summary', () => { + it('copies each invocation with its input', () => { + const expected = JSON.stringify([{ name: 'ExecV3', input: { intent: 'look' } }], null, 2); + const state = buildConversationState(); + state.transitionBlock('tools'); + state.appendToActive('\u2192 ExecV3'); + state.setLastTools('tools', '\u2192 ExecV3', [{ name: 'ExecV3', input: { intent: 'look' }, output: null, kind: 'client', phase: 'ok' }]); + state.transitionBlock('response'); + const actual = renderConversationFrame(state, 80).regions[0]?.text; + expect(actual).toBe(expected); + }); + + it('copies each result with its output', () => { + const expected = JSON.stringify([{ name: 'ExecV3', output: 'done' }], null, 2); + const state = buildConversationState(); + state.transitionBlock('execution'); + state.appendToActive('\u21a9 1 result'); + state.setLastTools('execution', '\u21a9 1 result', [{ name: 'ExecV3', input: { intent: 'look' }, output: 'done', kind: 'client', phase: 'ok' }]); + state.transitionBlock('response'); + const actual = renderConversationFrame(state, 80).regions[0]?.text; + expect(actual).toBe(expected); + }); + + it('falls back to the summary when the block carries no calls', () => { + const expected = '\u2192 ExecV3'; + const state = buildConversationState(); + state.transitionBlock('tools'); + state.appendToActive('\u2192 ExecV3'); + state.transitionBlock('response'); + const actual = renderConversationFrame(state, 80).regions[0]?.text; + expect(actual).toBe(expected); + }); +}); + +describe('a block still being written carries none', () => { + it('offers nothing to copy while the block is open', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendToActive('half a thou'); + const expected = 0; + const actual = renderConversationFrame(state, 80).regions.length; + expect(actual).toBe(expected); + }); + + it('draws no icon while the block is open', () => { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendToActive('half a thou'); + const expected = false; + const actual = renderConversationFrame(state, 80).lines.some((row) => row.includes(COPY_ICON)); + expect(actual).toBe(expected); + }); +}); + +describe('the header keeps its shape', () => { + it('still shows the block label', () => { + const expected = true; + const actual = strip(renderConversationFrame(sealed({ type: 'response', content: 'hello' }), 80).lines[0] ?? '').includes('response'); + expect(actual).toBe(expected); + }); + + it('puts the icon at the end of the divider', () => { + const frame = renderConversationFrame(sealed({ type: 'response', content: 'hello' }), 80); + const expected = stringWidth(strip(frame.lines[0] ?? '')) - 1; + const actual = frame.regions[0]?.startCol; + expect(actual).toBe(expected); + }); +}); + +describe('blocks drawn as one carry one affordance for all of them', () => { + it('draws a single affordance over a run of the same type', () => { + const expected = 1; + const actual = renderConversationFrame(sealed({ type: 'response', content: 'First part.' }, { type: 'response', content: 'Second part.' }), 80).regions.length; + expect(actual).toBe(expected); + }); + + it('copies every block under the header, not just the first', () => { + const expected = 'First part.\nSecond part.'; + const actual = renderConversationFrame(sealed({ type: 'response', content: 'First part.' }, { type: 'response', content: 'Second part.' }), 80).regions[0]?.text; + expect(actual).toBe(expected); + }); + + it('joins without the blank line the transcript never drew', () => { + const expected = 'First part.\nSecond part.'; + const actual = renderConversationFrame(sealed({ type: 'response', content: 'First part.\n' }, { type: 'response', content: 'Second part.' }), 80).regions[0]?.text; + expect(actual).toBe(expected); + }); + + it('stops at the end of the run rather than running into the next type', () => { + const expected = 'First part.\nSecond part.'; + const actual = renderConversationFrame(sealed({ type: 'response', content: 'First part.' }, { type: 'response', content: 'Second part.' }, { type: 'thinking', content: 'pondering' }), 80).regions[0]?.text; + expect(actual).toBe(expected); + }); + + it('copies every call across a run of tool blocks', () => { + const expected = JSON.stringify( + [ + { name: 'ReadFile', input: { path: 'a.ts' } }, + { name: 'ExecV3', input: { intent: 'look' } }, + ], + null, + 2, + ); + const state = buildConversationState(); + state.addBlocks([ + { type: 'tools', content: '\u2192 ReadFile', tools: [{ name: 'ReadFile', input: { path: 'a.ts' }, output: null, kind: 'client', phase: 'ok' }] }, + { type: 'tools', content: '\u2192 ExecV3', tools: [{ name: 'ExecV3', input: { intent: 'look' }, output: null, kind: 'client', phase: 'ok' }] }, + ]); + const actual = renderConversationFrame(state, 80).regions[0]?.text; + expect(actual).toBe(expected); + }); +}); + +// A response, then a tool block that never gets content, then a response again. The empty +// middle block seals nothing, so the sealed run and the active block are both `response` and +// the active one is drawn under the sealed one's header. +function runStillBeingWritten(): IConversationState { + const state = buildConversationState(); + state.transitionBlock('response'); + state.appendStreaming('first half'); + state.transitionBlock('tools'); + state.transitionBlock('response'); + state.appendStreaming('second half'); + return state; +} + +describe('a header whose run reaches into the block still being written', () => { + it('offers nothing to copy', () => { + const expected = 0; + const actual = renderConversationFrame(runStillBeingWritten(), 80).regions.length; + expect(actual).toBe(expected); + }); + + it('draws no icon on it', () => { + const expected = false; + const actual = renderConversationFrame(runStillBeingWritten(), 80).lines.some((row) => row.includes(COPY_ICON)); + expect(actual).toBe(expected); + }); + + it('still draws the header', () => { + const expected = true; + const actual = strip(renderConversationFrame(runStillBeingWritten(), 80).lines[0] ?? '').includes('response'); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/boxCopyIcon.spec.ts b/apps/claude-sdk-cli/test/boxCopyIcon.spec.ts new file mode 100644 index 00000000..8eed8665 --- /dev/null +++ b/apps/claude-sdk-cli/test/boxCopyIcon.spec.ts @@ -0,0 +1,76 @@ +import stringWidth from 'string-width'; +import { describe, expect, it } from 'vitest'; +import { box, COPY_ICON } from '../src/model/markdown/palette.js'; + +const COLS = 80; +const drawn = (): string[] => box(['const a = 1;'], 'typescript', COLS, true).lines; +const bare = (): string[] => box(['const a = 1;'], 'typescript', COLS, false).lines; + +describe('COPY_ICON', () => { + it('occupies exactly one cell', () => { + const expected = 1; + const actual = stringWidth(COPY_ICON); + expect(actual).toBe(expected); + }); + + it('carries no variation selector, which tmux and iTerm2 measure differently', () => { + const expected = false; + const actual = COPY_ICON.includes('\ufe0f'); + expect(actual).toBe(expected); + }); +}); + +describe('box — copy affordance', () => { + it('draws the copy icon in the top border', () => { + const expected = true; + const actual = drawn()[0]?.includes(COPY_ICON); + expect(actual).toBe(expected); + }); + + it('still shows the language label alongside it', () => { + const expected = true; + const actual = drawn()[0]?.includes('typescript'); + expect(actual).toBe(expected); + }); + + it('keeps the top border as wide as a body row', () => { + const lines = drawn(); + const expected = stringWidth(lines[1] ?? ''); + const actual = stringWidth(lines[0] ?? ''); + expect(actual).toBe(expected); + }); + + it('keeps the top border as wide as the bottom border', () => { + const lines = drawn(); + const expected = stringWidth(lines[lines.length - 1] ?? ''); + const actual = stringWidth(lines[0] ?? ''); + expect(actual).toBe(expected); + }); + + it('adds no row to the box', () => { + const expected = 3; + const actual = drawn().length; + expect(actual).toBe(expected); + }); +}); + +describe('box — drawn without an affordance', () => { + it('shows no copy icon', () => { + const expected = false; + const actual = bare()[0]?.includes(COPY_ICON); + expect(actual).toBe(expected); + }); + + it('reports no icon column', () => { + const expected = -1; + const actual = box(['const a = 1;'], 'typescript', COLS, false).iconCol; + expect(actual).toBe(expected); + }); + + it('keeps the top border as wide as the bottom border', () => { + const lines = bare(); + const expected = stringWidth(lines[lines.length - 1] ?? ''); + const actual = stringWidth(lines[0] ?? ''); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/buildConversationState.ts b/apps/claude-sdk-cli/test/buildConversationState.ts new file mode 100644 index 00000000..f6690086 --- /dev/null +++ b/apps/claude-sdk-cli/test/buildConversationState.ts @@ -0,0 +1,27 @@ +import { Clock, Instant, ZoneId } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +/** ConversationState injects Clock and ILogger; built with `new` it has neither, and the streaming paths read both. */ +export function buildConversationState(clock: Clock = Clock.fixed(Instant.ofEpochMilli(0), ZoneId.UTC)): ConversationState { + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(Clock) + .using(() => clock) + .asSelf(); + services + .register(ILogger) + .using(() => new NoopLogger()) + .asSelf(); + services.register(ConversationState).asSelf().as(IConversationState); + return services.buildProvider().resolve(ConversationState); +} diff --git a/apps/claude-sdk-cli/test/copyNotice.spec.ts b/apps/claude-sdk-cli/test/copyNotice.spec.ts new file mode 100644 index 00000000..a9389133 --- /dev/null +++ b/apps/claude-sdk-cli/test/copyNotice.spec.ts @@ -0,0 +1,58 @@ +import { Instant } from '@js-joda/core'; +import { describe, expect, it } from 'vitest'; +import { StatusState } from '../src/model/StatusState.js'; +import { copyNotice } from '../src/view/renderStatus.js'; + +const AT = Instant.parse('2026-08-09T00:00:00Z'); +const secondsLater = (seconds: number): Instant => AT.plusSeconds(seconds); + +const copied = (lines: number): StatusState => { + const state = new StatusState('repo'); + state.markCopied(AT, lines); + return state; +}; + +function plain(s: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI for test assertions + return s.replace(/\x1b\[[^m]*m/g, ''); +} + +describe('copyNotice', () => { + it('says nothing before anything has been copied', () => { + const expected = ''; + const actual = copyNotice(new StatusState('repo'), AT); + expect(actual).toBe(expected); + }); + + it('announces the copy at the moment it lands', () => { + const expected = ' ✓ copied 3 lines'; + const actual = plain(copyNotice(copied(3), AT)); + expect(actual).toBe(expected); + }); + + it('says line rather than lines for a single line', () => { + const expected = ' ✓ copied 1 line'; + const actual = plain(copyNotice(copied(1), AT)); + expect(actual).toBe(expected); + }); + + it('is still showing part way through its window', () => { + const expected = ' ✓ copied 3 lines'; + const actual = plain(copyNotice(copied(3), secondsLater(1))); + expect(actual).toBe(expected); + }); + + it('has gone once the window has passed', () => { + const expected = ''; + const actual = copyNotice(copied(3), secondsLater(2)); + expect(actual).toBe(expected); + }); + + it('starts its window again when a second copy lands', () => { + const expected = ' ✓ copied 9 lines'; + const state = copied(3); + state.markCopied(secondsLater(5), 9); + const actual = plain(copyNotice(state, secondsLater(6))); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/flushSealedToScroll.spec.ts b/apps/claude-sdk-cli/test/flushSealedToScroll.spec.ts new file mode 100644 index 00000000..67a76c47 --- /dev/null +++ b/apps/claude-sdk-cli/test/flushSealedToScroll.spec.ts @@ -0,0 +1,156 @@ +import { Clock, Instant, ZoneId } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; +import { COPY_ICON } from '../src/model/markdown/palette.js'; +import { TerminalState } from '../src/model/TerminalState.js'; +import { flushSealedToScroll } from '../src/view/flushSealedToScroll.js'; +import { renderBlocksToString } from '../src/view/renderConversation.js'; +import type { TerminalRenderer } from '../src/view/TerminalRenderer.js'; + +const NOW = Instant.parse('2026-08-11T00:00:00Z'); +const COLS = 80; + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +/** Collects what would have gone to the terminal's scroll buffer. */ +class RecordingRenderer { + public readonly scroll: string[] = []; + public writeToScroll(text: string): void { + this.scroll.push(text); + } +} + +function buildConversationState(): ConversationState { + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(Clock) + .using(() => Clock.fixed(NOW, ZoneId.UTC)) + .asSelf(); + services + .register(ILogger) + .using(() => new NoopLogger()) + .asSelf(); + services.register(ConversationState).asSelf().as(IConversationState); + return services.buildProvider().resolve(ConversationState); +} + +/** A sealed block with content, which addBlocks cannot produce: it marks what it adds as already flushed. */ +function sealedResponse(state: ConversationState, content: string): void { + state.transitionBlock('response'); + state.appendStreaming(content); + state.completeActive(); +} + +function terminal(): TerminalState { + const terminalState = new TerminalState(); + terminalState.setSize(COLS, 24); + return terminalState; +} + +describe('renderBlocksToString', () => { + it('heads the first block of a type with its label', () => { + const expected = true; + const actual = renderBlocksToString([{ id: 'b1', type: 'response', content: 'hello' }], 0, COLS).includes('response'); + expect(actual).toBe(expected); + }); + + it('draws no copy affordance, since scrollback is the terminal\u2019s and not addressable', () => { + const expected = false; + const actual = renderBlocksToString([{ id: 'b1', type: 'response', content: 'hello' }], 0, COLS).includes(COPY_ICON); + expect(actual).toBe(expected); + }); + + it('heads a run of one type once', () => { + const out = renderBlocksToString( + [ + { id: 'b1', type: 'response', content: 'first' }, + { id: 'b2', type: 'response', content: 'second' }, + ], + 0, + COLS, + ); + const expected = 1; + const actual = out.split('response').length - 1; + expect(actual).toBe(expected); + }); + + it('writes only from the index it was given', () => { + const expected = false; + const actual = renderBlocksToString( + [ + { id: 'b1', type: 'response', content: 'already gone' }, + { id: 'b2', type: 'prompt', content: 'still here' }, + ], + 1, + COLS, + ).includes('already gone'); + expect(actual).toBe(expected); + }); + + it('suppresses the header of a block continuing one already written', () => { + const expected = false; + const actual = renderBlocksToString( + [ + { id: 'b1', type: 'response', content: 'already gone' }, + { id: 'b2', type: 'response', content: 'still here' }, + ], + 1, + COLS, + ).includes('response'); + expect(actual).toBe(expected); + }); +}); + +describe('flushSealedToScroll', () => { + it('writes a newly sealed block to scrollback', () => { + const state = buildConversationState(); + sealedResponse(state, 'hello'); + const renderer = new RecordingRenderer(); + flushSealedToScroll(state, terminal(), renderer as unknown as TerminalRenderer); + const expected = true; + const actual = renderer.scroll[0]?.includes('hello'); + expect(actual).toBe(expected); + }); + + it('advances the flush boundary past what it wrote', () => { + const state = buildConversationState(); + sealedResponse(state, 'hello'); + flushSealedToScroll(state, terminal(), new RecordingRenderer() as unknown as TerminalRenderer); + const expected = 1; + const actual = state.flushedCount; + expect(actual).toBe(expected); + }); + + it('writes nothing when every sealed block has already gone', () => { + const state = buildConversationState(); + sealedResponse(state, 'hello'); + const renderer = new RecordingRenderer(); + flushSealedToScroll(state, terminal(), renderer as unknown as TerminalRenderer); + flushSealedToScroll(state, terminal(), renderer as unknown as TerminalRenderer); + const expected = 1; + const actual = renderer.scroll.length; + expect(actual).toBe(expected); + }); + + it('writes only the block sealed since the last flush', () => { + const state = buildConversationState(); + sealedResponse(state, 'first'); + const renderer = new RecordingRenderer(); + flushSealedToScroll(state, terminal(), renderer as unknown as TerminalRenderer); + state.transitionBlock('prompt'); + state.appendStreaming('second'); + state.completeActive(); + flushSealedToScroll(state, terminal(), renderer as unknown as TerminalRenderer); + const expected = false; + const actual = renderer.scroll[1]?.includes('first'); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/glyphAtColumn.ts b/apps/claude-sdk-cli/test/glyphAtColumn.ts new file mode 100644 index 00000000..4c606ac5 --- /dev/null +++ b/apps/claude-sdk-cli/test/glyphAtColumn.ts @@ -0,0 +1,14 @@ +import ansiRegex from 'ansi-regex'; +import { layoutRow } from '../src/view/ScreenBuffer.js'; + +/** + * The glyph occupying a column of a row, measured the way the paint path measures it. + * + * Delegates to layoutRow rather than walking the string, because the two disagree wherever + * a grapheme spans several code points: `⚙️` is one two-column grapheme and 1 + 0 as code + * points, so counting code points puts every later column out by the difference. A cell + * carries the styling that travels with it, which is not part of what occupies the column. + */ +export function glyphAtColumn(row: string, column: number): string | undefined { + return layoutRow(row, column + 1)[column]?.replace(ansiRegex(), ''); +} diff --git a/apps/claude-sdk-cli/test/historyKeyMap.spec.ts b/apps/claude-sdk-cli/test/historyKeyMap.spec.ts index 16431bfd..c356d6cc 100644 --- a/apps/claude-sdk-cli/test/historyKeyMap.spec.ts +++ b/apps/claude-sdk-cli/test/historyKeyMap.spec.ts @@ -4,7 +4,7 @@ import type { Block } from '../src/model/ConversationState.js'; import { HistoryViewState } from '../src/model/HistoryViewState.js'; function nonTools(): Block[] { - return [{ type: 'response', content: 'reply' }]; + return [{ id: 'block-response', type: 'response', content: 'reply' }]; } describe('historyKeyMap — on a list', () => { diff --git a/apps/claude-sdk-cli/test/hitTest.spec.ts b/apps/claude-sdk-cli/test/hitTest.spec.ts new file mode 100644 index 00000000..f3e00500 --- /dev/null +++ b/apps/claude-sdk-cli/test/hitTest.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { type ClickRegion, hitTest } from '../src/model/ClickRegion.js'; + +const region = (overrides: Partial = {}): ClickRegion => ({ id: 'fence-1', row: 4, startCol: 10, endCol: 12, text: 'const a = 1;', ...overrides }); + +describe('hitTest', () => { + it('finds the region a point falls inside', () => { + const expected = region(); + const actual = hitTest([expected], 11, 4); + expect(actual).toBe(expected); + }); + + it('finds a region at its first column', () => { + const expected = region(); + const actual = hitTest([expected], 10, 4); + expect(actual).toBe(expected); + }); + + it('finds a region at its last column', () => { + const expected = region(); + const actual = hitTest([expected], 12, 4); + expect(actual).toBe(expected); + }); + + it('returns null for a column before the region', () => { + const actual = hitTest([region()], 9, 4); + expect(actual).toBeNull(); + }); + + it('returns null for a column past the region', () => { + const actual = hitTest([region()], 13, 4); + expect(actual).toBeNull(); + }); + + it('returns null for the right column on the wrong row', () => { + const actual = hitTest([region()], 11, 5); + expect(actual).toBeNull(); + }); + + it('returns null when there are no regions', () => { + const actual = hitTest([], 11, 4); + expect(actual).toBeNull(); + }); + + it('picks the region on the clicked row when several rows carry one', () => { + const expected = region({ row: 7, text: 'const b = 2;' }); + const actual = hitTest([region(), expected], 11, 7); + expect(actual).toBe(expected); + }); + + it('picks the region in the clicked column span when a row carries several', () => { + const expected = region({ startCol: 20, endCol: 22, text: 'const b = 2;' }); + const actual = hitTest([region(), expected], 21, 4); + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/markdownLayout.perf.spec.ts b/apps/claude-sdk-cli/test/markdownLayout.perf.spec.ts index 31d56ec6..bfe45cbc 100644 --- a/apps/claude-sdk-cli/test/markdownLayout.perf.spec.ts +++ b/apps/claude-sdk-cli/test/markdownLayout.perf.spec.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { markdownContentLines } from '../src/model/markdown/markdownLayout.js'; +import type { CodeDecorator } from '../src/model/blockLayout.js'; +import { markdownContent } from '../src/model/markdown/markdownLayout.js'; + +const markdownContentLines = (content: string, cols: number, indent: string, decorate: CodeDecorator): string[] => markdownContent(content, cols, indent, decorate).lines; /** * Instrumentation, not a stopwatch: renderConversation's active (streaming) block is never cached diff --git a/apps/claude-sdk-cli/test/markdownLayout.sealedCache.spec.ts b/apps/claude-sdk-cli/test/markdownLayout.sealedCache.spec.ts index e89b2dea..0977c2c9 100644 --- a/apps/claude-sdk-cli/test/markdownLayout.sealedCache.spec.ts +++ b/apps/claude-sdk-cli/test/markdownLayout.sealedCache.spec.ts @@ -1,5 +1,9 @@ +import type { Token } from 'marked'; import { describe, expect, it } from 'vitest'; -import { renderTokenLines, splitSealedTokens } from '../src/model/markdown/markdownLayout.js'; +import type { CodeDecorator } from '../src/model/blockLayout.js'; +import { renderTokens, splitSealedTokens } from '../src/model/markdown/markdownLayout.js'; + +const renderTokenLines = (tokens: Token[], cols: number, indent: string, decorate: CodeDecorator): string[] => renderTokens(tokens, cols, indent, decorate).lines; /** * Proves the fix behind renderStreamingMarkdown (renderConversation.ts): splitting lexed tokens at the diff --git a/apps/claude-sdk-cli/test/markdownLayout.spec.ts b/apps/claude-sdk-cli/test/markdownLayout.spec.ts index b68c0026..51b4a667 100644 --- a/apps/claude-sdk-cli/test/markdownLayout.spec.ts +++ b/apps/claude-sdk-cli/test/markdownLayout.spec.ts @@ -1,7 +1,12 @@ +import { marked } from 'marked'; import stringWidth from 'string-width'; import { describe, expect, it } from 'vitest'; -import { markdownContentLines } from '../src/model/markdown/markdownLayout.js'; -import { ACCENT, BOLD, BOLD_END, box, CODE_FG, DIM, FG, HEADING, ITALIC, ITALIC_END, link, R, STRIKE, STRIKE_END, SUB_BULLET, table } from '../src/model/markdown/palette.js'; +import type { CodeDecorator } from '../src/model/blockLayout.js'; +import { codeBoxCount, markdownContent } from '../src/model/markdown/markdownLayout.js'; + +const markdownContentLines = (content: string, cols: number, indent: string, decorate: CodeDecorator): string[] => markdownContent(content, cols, indent, decorate).lines; + +import { ACCENT, BOLD, BOLD_END, box, CODE_FG, COPY_ICON, DIM, FG, HEADING, ITALIC, ITALIC_END, link, R, STRIKE, STRIKE_END, SUB_BULLET, table } from '../src/model/markdown/palette.js'; import { getHighlighted } from '../src/view/renderConversation.js'; // The source-to-rendered pairs come from the mission's visual spec (spec/spec.mjs): @@ -13,7 +18,7 @@ const render = (src: string[]): string[] => markdownContentLines(src.join('\n'), describe('markdownContentLines — the fence boundary', () => { it('renders prose markdown but leaves fenced markdown literal', () => { - const expected = [`${BOLD}${HEADING[0]}Hello${FG}${BOLD_END}`, '', ...box(getHighlighted('# Hello', 'md'), 'md')]; + const expected = [`${BOLD}${HEADING[0]}Hello${FG}${BOLD_END}`, '', ...box(getHighlighted('# Hello', 'md'), 'md', COLS, false).lines]; const actual = render(['# Hello', '', '```md', '# Hello', '```']); @@ -130,7 +135,7 @@ describe('markdownContentLines — inline and block constructs', () => { describe('markdownContentLines — fenced code', () => { it('boxes fenced code with its language label, content highlighted', () => { const code = ['const main = () => {', " console.log('Hello Warble');", '};'].join('\n'); - const expected = box(getHighlighted(code, 'ts'), 'ts'); + const expected = box(getHighlighted(code, 'ts'), 'ts', COLS, false).lines; const actual = render(['```ts', code, '```']); @@ -139,7 +144,7 @@ describe('markdownContentLines — fenced code', () => { it('wraps a long code line inside the box instead of clipping it', () => { const line = 'a very long line that runs well past the box edge and wraps inside it instead of disappearing'; - const expected = box(getHighlighted(line, 'plaintext'), 'plaintext', 56); + const expected = box(getHighlighted(line, 'plaintext'), 'plaintext', 56, false).lines; const actual = markdownContentLines(['```plaintext', line, '```'].join('\n'), 56, '', getHighlighted); @@ -149,9 +154,9 @@ describe('markdownContentLines — fenced code', () => { describe('box — cap, wrap, and label-aware border', () => { it('caps to the width, wraps the over-long line, and sizes the border to the label', () => { - const expected = [`${DIM}\u250c\u2500 ${ACCENT}ts${FG}${DIM} ${'\u2500'.repeat(3)}\u2510${R}`, `${DIM}\u2502${FG} abcdef ${DIM}\u2502${R}`, `${DIM}\u2502${FG} gh${' '.repeat(4)} ${DIM}\u2502${R}`, `${DIM}\u2514${'\u2500'.repeat(8)}\u2518${R}`]; + const expected = [`${DIM}\u250c\u2500 ${ACCENT}ts${FG}${DIM} ${'\u2500'.repeat(1)} ${ACCENT}${COPY_ICON}${FG}${DIM}\u2510${R}`, `${DIM}\u2502${FG} abcdef ${DIM}\u2502${R}`, `${DIM}\u2502${FG} gh${' '.repeat(4)} ${DIM}\u2502${R}`, `${DIM}\u2514${'\u2500'.repeat(8)}\u2518${R}`]; - const actual = box(['abcdefgh'], 'ts', 10); + const actual = box(['abcdefgh'], 'ts', 10, true).lines; expect(actual).toEqual(expected); }); @@ -297,3 +302,55 @@ describe('table — measuring a cell', () => { expect(actual).toEqual(expected); }); }); + +// codeBoxCount tells the store how many code boxes a render will draw, and the ids it hands +// out are matched to them by order, so the two walks have to agree on every construct. They +// are separate implementations, and this is what holds them together: a document carrying +// every construct the walk has a case for, counted both ways. A construct added to one walk +// and not the other still needs adding here. +const EVERY_CONSTRUCT = [ + '# Heading', + '', + 'A paragraph with **bold** and `a codespan`.', + '', + '```ts', + 'const top = 1;', + '```', + '', + '- a list item', + '- one with a fence inside it:', + '', + ' ```ts', + ' const inAList = 2;', + ' ```', + '', + '> a quote', + '>', + '> ```ts', + '> const inAQuote = 3;', + '> ```', + '', + '| a | b |', + '| - | - |', + '| 1 | 2 |', + '', + '---', + '', + ' const indented = 4;', + '', +].join('\n'); + +describe('codeBoxCount', () => { + it('counts exactly the boxes the walk draws', () => { + const drawn = markdownContent(EVERY_CONSTRUCT, COLS, '', getHighlighted).lines; + const expected = drawn.filter((line) => line.includes('\u250c')).length; + const actual = codeBoxCount(marked.lexer(EVERY_CONSTRUCT)); + expect(actual).toBe(expected); + }); + + it('counts more than one, so the document is actually exercising it', () => { + const expected = true; + const actual = codeBoxCount(marked.lexer(EVERY_CONSTRUCT)) > 1; + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/markdownLayout.streaming-corruption.spec.ts b/apps/claude-sdk-cli/test/markdownLayout.streaming-corruption.spec.ts index 0742cf65..7462c647 100644 --- a/apps/claude-sdk-cli/test/markdownLayout.streaming-corruption.spec.ts +++ b/apps/claude-sdk-cli/test/markdownLayout.streaming-corruption.spec.ts @@ -1,6 +1,10 @@ import { marked } from 'marked'; import { describe, expect, it } from 'vitest'; -import { markdownContentLines } from '../src/model/markdown/markdownLayout.js'; +import type { CodeDecorator } from '../src/model/blockLayout.js'; +import { markdownContent } from '../src/model/markdown/markdownLayout.js'; + +const markdownContentLines = (content: string, cols: number, indent: string, decorate: CodeDecorator): string[] => markdownContent(content, cols, indent, decorate).lines; + import { BULLET } from '../src/model/markdown/palette.js'; // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI for test assertions diff --git a/apps/claude-sdk-cli/test/primaryViewRegions.spec.ts b/apps/claude-sdk-cli/test/primaryViewRegions.spec.ts new file mode 100644 index 00000000..fda7f258 --- /dev/null +++ b/apps/claude-sdk-cli/test/primaryViewRegions.spec.ts @@ -0,0 +1,175 @@ +import { Clock, Instant, ZoneId } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { AppModeState } from '../src/model/AppModeState.js'; +import { hitTest } from '../src/model/ClickRegion.js'; +import { ConversationListState } from '../src/model/ConversationListState.js'; +import type { ConversationSession } from '../src/model/ConversationSession.js'; +import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; +import { HistoryViewState } from '../src/model/HistoryViewState.js'; +import { IntlGraphemeSegmenter } from '../src/model/IntlGraphemeSegmenter.js'; +import { ITurnClock } from '../src/model/ITurnClock.js'; +import { COPY_ICON } from '../src/model/markdown/palette.js'; +import { PrimaryViewState } from '../src/model/PrimaryViewState.js'; +import { ScrollState } from '../src/model/ScrollState.js'; +import { StatusState } from '../src/model/StatusState.js'; +import { TerminalState } from '../src/model/TerminalState.js'; +import { ToolApprovalState } from '../src/model/ToolApprovalState.js'; +import { TurnClock } from '../src/model/TurnClock.js'; +import { PrimaryView } from '../src/view/PrimaryView.js'; +import type { Frame, ViewModel } from '../src/view/View.js'; +import { buildCommandModeState } from './buildCommandModeState.js'; +import { buildEditorBuffer } from './buildEditorBuffer.js'; +import { glyphAtColumn } from './glyphAtColumn.js'; + +const NOW = Instant.parse('2026-08-11T00:00:00Z'); +const CODE = 'const a = 1;\nconst b = 2;'; +const RESPONSE = ['before', '', '```ts', CODE, '```', '', 'after'].join('\n'); + +/** + * The cell the code block's own affordance addresses, as the operator sees it. Found by + * what it copies, not by its place in the array: a block header carries an affordance + * too, so position says nothing about which one this is. + */ +function cellUnderCodeRegion(frame: Frame): string | undefined { + const region = frame.regions.find((candidate) => candidate.text === CODE); + if (!region) { + return undefined; + } + return glyphAtColumn(frame.rows[region.row] ?? '', region.startCol); +} + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +// ConversationState injects Clock and ILogger; constructing it bare leaves the streaming +// path without a clock to rate-limit against. +function makeConversationState(): ConversationState { + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(Clock) + .using(() => Clock.fixed(NOW, ZoneId.UTC)) + .asSelf(); + services + .register(ILogger) + .using(() => new NoopLogger()) + .asSelf(); + services.register(ConversationState).asSelf().as(IConversationState); + return services.buildProvider().resolve(ConversationState); +} + +function makeTurnClock(): ITurnClock { + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services + .register(Clock) + .using(() => Clock.fixed(NOW, ZoneId.UTC)) + .asSelf(); + services.register(TurnClock).as(ITurnClock); + return services.buildProvider().resolve(ITurnClock); +} + +function makeModel(): ViewModel { + const terminalState = new TerminalState(); + terminalState.setSize(80, 24); + return { + conversationState: makeConversationState(), + editorBuffer: buildEditorBuffer(), + segmenter: new IntlGraphemeSegmenter(), + toolApprovalState: new ToolApprovalState(), + commandModeState: buildCommandModeState(), + statusState: new StatusState('test'), + turnClock: makeTurnClock(), + terminalState, + primaryViewState: new PrimaryViewState(), + scrollState: new ScrollState(), + historyViewState: new HistoryViewState(), + conversationListState: new ConversationListState(), + clock: Clock.fixed(NOW, ZoneId.UTC), + appModeState: new AppModeState(), + session: { id: 'sess-123', turnCount: 0 } as unknown as ConversationSession, + configLoader: { config: { markdown: { enabled: true, streaming: true } } } as unknown as ViewModel['configLoader'], + }; +} + +function sealedCodeBlock(): ViewModel { + const model = makeModel(); + model.conversationState.addBlocks([{ type: 'response', content: RESPONSE }]); + return model; +} + +function streamingCodeBlock(): ViewModel { + const model = makeModel(); + model.conversationState.transitionBlock('response'); + model.conversationState.appendStreaming(`${RESPONSE}\n`); + return model; +} + +describe('PrimaryView — regions against the frame it painted', () => { + it('addresses the cell the copy icon was drawn in', () => { + const expected = COPY_ICON; + const actual = cellUnderCodeRegion(new PrimaryView().render(sealedCodeBlock())); + expect(actual).toBe(expected); + }); + + it('resolves a click on that cell back to the block source', () => { + const frame = new PrimaryView().render(sealedCodeBlock()); + const region = frame.regions.find((candidate) => candidate.text === CODE); + const expected = CODE; + const actual = hitTest(frame.regions, region?.startCol ?? -1, region?.row ?? -1)?.text; + expect(actual).toBe(expected); + }); + + it('still addresses the icon after the transcript is scrolled back', () => { + const model = sealedCodeBlock(); + model.conversationState.addBlocks(Array.from({ length: 40 }, (_, i) => ({ type: 'meta' as const, content: `line ${i}` }))); + const view = new PrimaryView(); + view.render(model); + for (let i = 0; i < 30; i++) { + model.scrollState.lineUp(); + } + const expected = COPY_ICON; + const actual = cellUnderCodeRegion(view.render(model)); + expect(actual).toBe(expected); + }); +}); + +function openCodeBlock(): ViewModel { + const model = makeModel(); + model.conversationState.transitionBlock('response'); + model.conversationState.appendStreaming(['before', '', '```ts', CODE].join('\n')); + return model; +} + +describe('PrimaryView — a code block whose fence has not closed', () => { + it('draws no copy icon on it', () => { + const expected = false; + const actual = new PrimaryView().render(openCodeBlock()).rows.some((row) => row.includes(COPY_ICON)); + expect(actual).toBe(expected); + }); + + it('offers nothing to click', () => { + const expected = 0; + const actual = new PrimaryView().render(openCodeBlock()).regions.length; + expect(actual).toBe(expected); + }); +}); + +describe('PrimaryView — regions while a response is still streaming', () => { + it('draws the copy icon on a code block in the streaming response', () => { + const expected = true; + const actual = new PrimaryView().render(streamingCodeBlock()).rows.some((row) => row.includes(COPY_ICON)); + expect(actual).toBe(expected); + }); + + it('makes the icon it drew clickable', () => { + const expected = true; + const actual = new PrimaryView().render(streamingCodeBlock()).regions.length > 0; + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/renderConversation.spec.ts b/apps/claude-sdk-cli/test/renderConversation.spec.ts index 3f1d0dd9..0b2c3431 100644 --- a/apps/claude-sdk-cli/test/renderConversation.spec.ts +++ b/apps/claude-sdk-cli/test/renderConversation.spec.ts @@ -4,7 +4,7 @@ import { createServiceCollection, Lifetime } from '@shellicar/core-di'; import stringWidth from 'string-width'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; -import { buildDivider, type DividerTimestamps, renderBlockContentCached, renderConversation } from '../src/view/renderConversation.js'; +import { buildDivider, type DividerTimestamps, renderBlockFrameCached, renderConversationFrame } from '../src/view/renderConversation.js'; class NoopLogger extends ILogger { public trace(): void {} @@ -39,7 +39,7 @@ describe('renderConversation — empty state', () => { it('returns an empty array when no blocks exist', () => { const state = buildConversationState(); const expected = 0; - const actual = renderConversation(state, 80).length; + const actual = renderConversationFrame(state, 80).lines.length; expect(actual).toBe(expected); }); }); @@ -48,7 +48,7 @@ describe('renderConversation — single sealed block', () => { it('includes a divider line for the block', () => { const state = buildConversationState(); state.addBlocks([{ type: 'response', content: 'hello' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const actual = lines.some((l) => l.includes('response')); expect(actual).toBe(true); }); @@ -56,7 +56,7 @@ describe('renderConversation — single sealed block', () => { it('includes a blank line after the divider', () => { const state = buildConversationState(); state.addBlocks([{ type: 'response', content: 'hello' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const dividerIdx = lines.findIndex((l) => l.includes('response')); const actual = lines[dividerIdx + 1]; expect(actual).toBe(''); @@ -65,7 +65,7 @@ describe('renderConversation — single sealed block', () => { it('includes the block content', () => { const state = buildConversationState(); state.addBlocks([{ type: 'response', content: 'hello world' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const actual = lines.some((l) => l.includes('hello world')); expect(actual).toBe(true); }); @@ -73,7 +73,7 @@ describe('renderConversation — single sealed block', () => { it('includes a trailing blank line after the content', () => { const state = buildConversationState(); state.addBlocks([{ type: 'response', content: 'hello' }]); - const lines = renderConversation(state, 80); + const lines = renderConversationFrame(state, 80).lines; const actual = lines[lines.length - 1]; expect(actual).toBe(''); }); @@ -86,7 +86,7 @@ describe('renderConversation — continuation suppression', () => { { type: 'tools', content: 'tool A' }, { type: 'tools', content: 'tool B' }, ]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const dividerCount = lines.filter((l) => l.includes('tool use')).length; // Only the first block gets a divider; the second is a continuation const expected = 1; @@ -100,7 +100,7 @@ describe('renderConversation — continuation suppression', () => { { type: 'tools', content: 'tool A' }, { type: 'tools', content: 'tool B' }, ]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); // Find the line with "tool A"; the line after should NOT be blank (continuation) const aIdx = lines.findIndex((l) => l.includes('tool A')); const actual = lines[aIdx + 1]; @@ -115,7 +115,7 @@ describe('renderConversation — active block', () => { state.addBlocks([{ type: 'prompt', content: 'user prompt' }]); state.transitionBlock('response'); state.appendToActive('streaming...'); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const actual = lines.some((l) => l.includes('response')); expect(actual).toBe(true); }); @@ -125,7 +125,7 @@ describe('renderConversation — active block', () => { state.addBlocks([{ type: 'tools', content: 'tool A' }]); state.transitionBlock('tools'); state.appendToActive('tool B'); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const dividerCount = lines.filter((l) => l.includes('tool use')).length; // Only the sealed block gets a divider; active continuation does not const expected = 1; @@ -137,7 +137,7 @@ describe('renderConversation — active block', () => { const state = buildConversationState(); state.transitionBlock('response'); state.appendToActive('live content'); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const actual = lines.some((l) => l.includes('live content')); expect(actual).toBe(true); }); @@ -146,7 +146,7 @@ describe('renderConversation — active block', () => { const state = buildConversationState(); state.transitionBlock('response'); state.appendToActive('streaming'); - const lines = renderConversation(state, 80); + const lines = renderConversationFrame(state, 80).lines; // Last line is the content, not a blank const actual = lines[lines.length - 1]; expect(actual).not.toBe(''); @@ -168,10 +168,10 @@ describe('renderConversation — streaming markdown line continuation', () => { state.transitionBlock('response'); state.appendToActive('hello wor'); // First render: full decoration pass, caches the wrapped lines. - renderConversation(state, 80, markdown); + renderConversationFrame(state, 80, markdown).lines; // Second delta arrives with no newline, inside the same refresh window (fake timers, time frozen). state.appendToActive('ld more text'); - const lines = renderConversation(state, 80, markdown).map(stripAnsi); + const lines = renderConversationFrame(state, 80, markdown).lines.map(stripAnsi); const actual = lines.some((l) => l.trim().endsWith('hello world more text')); expect(actual).toBe(true); }); @@ -181,9 +181,9 @@ describe('renderConversation — streaming markdown line continuation', () => { const markdown = { enabled: true, streaming: true }; state.transitionBlock('response'); state.appendToActive('hello wor'); - renderConversation(state, 80, markdown); + renderConversationFrame(state, 80, markdown).lines; state.appendToActive('ld more text'); - const lines = renderConversation(state, 80, markdown).map(stripAnsi); + const lines = renderConversationFrame(state, 80, markdown).lines.map(stripAnsi); const actual = lines.some((l) => l.trim().endsWith('hello wor')); expect(actual).toBe(false); }); @@ -193,9 +193,9 @@ describe('renderConversation — streaming markdown line continuation', () => { const markdown = { enabled: true, streaming: true }; state.transitionBlock('response'); state.appendToActive('hello\n\n'); - renderConversation(state, 80, markdown); + renderConversationFrame(state, 80, markdown).lines; state.appendToActive('World'); - const lines = renderConversation(state, 80, markdown).map(stripAnsi); + const lines = renderConversationFrame(state, 80, markdown).lines.map(stripAnsi); const helloLine = lines.find((l) => l.includes('hello')); const actual = helloLine?.includes('World'); expect(actual).toBe(false); @@ -206,9 +206,9 @@ describe('renderConversation — streaming markdown line continuation', () => { const markdown = { enabled: true, streaming: true }; state.transitionBlock('response'); state.appendToActive('hello\n\n'); - renderConversation(state, 80, markdown); + renderConversationFrame(state, 80, markdown).lines; state.appendToActive('World'); - const lines = renderConversation(state, 80, markdown).map(stripAnsi); + const lines = renderConversationFrame(state, 80, markdown).lines.map(stripAnsi); const actual = lines.some((l) => l.trim() === 'World'); expect(actual).toBe(true); }); @@ -219,10 +219,10 @@ describe('renderConversation — streaming markdown line continuation', () => { const cols = 20; state.transitionBlock('response'); state.appendToActive('a'.repeat(15)); - renderConversation(state, cols, markdown); + renderConversationFrame(state, cols, markdown).lines; // Appending past the column width must reflow onto a new row, still as one continuous word. state.appendToActive('b'.repeat(10)); - const lines = renderConversation(state, cols, markdown).map(stripAnsi); + const lines = renderConversationFrame(state, cols, markdown).lines.map(stripAnsi); const joined = lines.map((l) => l.trim()).join(''); const actual = joined.includes(`${'a'.repeat(15)}${'b'.repeat(10)}`); expect(actual).toBe(true); @@ -264,7 +264,7 @@ describe('renderConversation — notice block', () => { it('sealed notice block renders without a divider', () => { const state = buildConversationState(); state.addBlocks([{ type: 'notice', content: 'some warning' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const expected = false; const actual = lines.some((l) => l.includes('notice')); expect(actual).toBe(expected); @@ -273,7 +273,7 @@ describe('renderConversation — notice block', () => { it('sealed notice block includes the content', () => { const state = buildConversationState(); state.addBlocks([{ type: 'notice', content: 'some warning' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const expected = true; const actual = lines.some((l) => l.includes('some warning')); expect(actual).toBe(expected); @@ -283,7 +283,7 @@ describe('renderConversation — notice block', () => { const state = buildConversationState(); state.transitionBlock('notice'); state.appendToActive('[stop: max_tokens]'); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const expected = false; const actual = lines.some((l) => l.includes('notice')); expect(actual).toBe(expected); @@ -293,7 +293,7 @@ describe('renderConversation — notice block', () => { const state = buildConversationState(); state.transitionBlock('notice'); state.appendToActive('[stop: max_tokens]'); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const expected = true; const actual = lines.some((l) => l.includes('[stop: max_tokens]')); expect(actual).toBe(expected); @@ -302,7 +302,7 @@ describe('renderConversation — notice block', () => { it('notice block content is not indented', () => { const state = buildConversationState(); state.addBlocks([{ type: 'notice', content: 'some warning' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const noticeLine = lines.find((l) => l.includes('some warning')); const expected = 'some warning'; const actual = noticeLine; @@ -314,7 +314,7 @@ describe('renderConversation — code fence highlighting', () => { it('renders code from an unknown language without warning (plain fallback)', () => { const state = buildConversationState(); state.addBlocks([{ type: 'response', content: '```unknownxyz\nfoo bar\n```' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const actual = lines.some((l) => l.includes('foo bar')); expect(actual).toBe(true); }); @@ -322,7 +322,7 @@ describe('renderConversation — code fence highlighting', () => { it('preserves the original fence label even when an alias is used for highlighting', () => { const state = buildConversationState(); state.addBlocks([{ type: 'response', content: '```jsonl\n{"key": 1}\n```' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); // Fence header should show the original language name, not the alias const actual = lines.some((l) => l.includes('```jsonl')); expect(actual).toBe(true); @@ -331,7 +331,7 @@ describe('renderConversation — code fence highlighting', () => { it('renders jsonl code content', () => { const state = buildConversationState(); state.addBlocks([{ type: 'response', content: '```jsonl\n{"key": 1}\n```' }]); - const lines = renderConversation(state, 80).map(stripAnsi); + const lines = renderConversationFrame(state, 80).lines.map(stripAnsi); const actual = lines.some((l) => l.includes('"key"')); expect(actual).toBe(true); }); @@ -345,8 +345,8 @@ describe('renderBlockContentCached — HistoryView shares this cache', () => { if (!block) { throw new Error('expected a sealed block'); } - const first = renderBlockContentCached(block, block.content, 80, false); - const second = renderBlockContentCached(block, block.content, 80, false); + const first = renderBlockFrameCached(block, block.content, 80, false).lines; + const second = renderBlockFrameCached(block, block.content, 80, false).lines; const actual = second === first; expect(actual).toBe(true); }); @@ -358,8 +358,8 @@ describe('renderBlockContentCached — HistoryView shares this cache', () => { if (!block) { throw new Error('expected a sealed block'); } - renderBlockContentCached(block, block.content, 80, false); - const second = renderBlockContentCached(block, block.content, 78, false); + renderBlockFrameCached(block, block.content, 80, false).lines; + const second = renderBlockFrameCached(block, block.content, 78, false).lines; const actual = second.some((l) => l.includes('hello')); expect(actual).toBe(true); }); @@ -372,8 +372,8 @@ describe('renderBlockContentCached — HistoryView shares this cache', () => { throw new Error('expected a sealed block'); } const namesPreview = 'ReadFile . Exec'; - const first = renderBlockContentCached(block, namesPreview, 80, false); - const second = renderBlockContentCached(block, namesPreview, 80, false); + const first = renderBlockFrameCached(block, namesPreview, 80, false).lines; + const second = renderBlockFrameCached(block, namesPreview, 80, false).lines; const actual = second === first && second.some((l) => l.includes('ReadFile')); expect(actual).toBe(true); }); diff --git a/apps/claude-sdk-cli/test/toolBlockCopy.spec.ts b/apps/claude-sdk-cli/test/toolBlockCopy.spec.ts new file mode 100644 index 00000000..336a1b7b --- /dev/null +++ b/apps/claude-sdk-cli/test/toolBlockCopy.spec.ts @@ -0,0 +1,88 @@ +import { Clock, Instant, ZoneId } from '@js-joda/core'; +import { ILogger } from '@shellicar/claude-core/logging/ILogger'; +import { createServiceCollection, Lifetime } from '@shellicar/core-di'; +import { describe, expect, it } from 'vitest'; +import { hitTest } from '../src/model/ClickRegion.js'; +import { ConversationState, IConversationState } from '../src/model/ConversationState.js'; +import type { ToolEntry } from '../src/model/ToolObject.js'; +import { renderConversationFrame } from '../src/view/renderConversation.js'; + +const NOW = Instant.parse('2026-08-11T00:00:00Z'); + +class NoopLogger extends ILogger { + public trace(): void {} + public debug(): void {} + public info(): void {} + public warn(): void {} + public error(): void {} +} + +function buildConversationState(): IConversationState { + const services = createServiceCollection({ defaultLifetime: Lifetime.Singleton }); + services.register(NoopLogger).as(ILogger); + services + .register(Clock) + .using(() => Clock.fixed(NOW, ZoneId.UTC)) + .asSelf(); + services.register(ConversationState).as(IConversationState); + return services.buildProvider().resolve(IConversationState); +} + +const entry = (output: string | null): ToolEntry => ({ name: 'ExecV3', kind: 'client', input: { intent: 'look' }, output, phase: output === null ? 'running' : 'ok' }); + +/** + * The order AgentMessageHandler drives: the use block is opened and filled while the + * call is generated, sealed when the message completes, then the execution block is + * opened and refilled from the same tool objects as each call settles. + */ +function afterOneToolRoundTrip(): IConversationState { + const state = buildConversationState(); + state.transitionBlock('tools'); + state.setLastTools('tools', '\u2192 ExecV3', [entry(null)]); + state.completeActive(); + state.transitionBlock('execution'); + state.setLastTools('execution', '\u2192 ExecV3', [entry('done')]); + state.completeActive(); + return state; +} + +describe('the two sides of a tool round trip copy different things', () => { + it('gives the use block and the execution block one affordance each', () => { + const expected = 2; + const actual = renderConversationFrame(afterOneToolRoundTrip(), 80).regions.length; + expect(actual).toBe(expected); + }); + + it('copies the invocation from the use block', () => { + const expected = JSON.stringify([{ name: 'ExecV3', input: { intent: 'look' } }], null, 2); + const actual = renderConversationFrame(afterOneToolRoundTrip(), 80).regions[0]?.text; + expect(actual).toBe(expected); + }); + + it('copies the result from the execution block', () => { + const expected = JSON.stringify([{ name: 'ExecV3', output: 'done' }], null, 2); + const actual = renderConversationFrame(afterOneToolRoundTrip(), 80).regions[1]?.text; + expect(actual).toBe(expected); + }); + + it('puts the two affordances on different rows', () => { + const frame = renderConversationFrame(afterOneToolRoundTrip(), 80); + const expected = true; + const actual = frame.regions[0]?.row !== frame.regions[1]?.row; + expect(actual).toBe(expected); + }); + + it('puts each affordance on the row of the header it belongs to', () => { + const frame = renderConversationFrame(afterOneToolRoundTrip(), 80); + const expected = [true, true]; + const actual = frame.regions.map((region) => (frame.lines[region.row] ?? '').includes('\u29c9')); + expect(actual).toEqual(expected); + }); + + it('resolves a click on each affordance to that block', () => { + const frame = renderConversationFrame(afterOneToolRoundTrip(), 80); + const expected = frame.regions.map((region) => region.text); + const actual = frame.regions.map((region) => hitTest(frame.regions, region.startCol, region.row)?.text); + expect(actual).toEqual(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/windowRegions.spec.ts b/apps/claude-sdk-cli/test/windowRegions.spec.ts new file mode 100644 index 00000000..8276d68d --- /dev/null +++ b/apps/claude-sdk-cli/test/windowRegions.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { type ClickRegion, windowRegions } from '../src/model/ClickRegion.js'; + +const region = (row: number, text = 'const a = 1;'): ClickRegion => ({ id: `fence-${row}`, row, startCol: 10, endCol: 12, text }); + +describe('windowRegions — transcript shorter than the window', () => { + it('shifts a region down by the blank rows padded above the transcript', () => { + const expected = 12; + const actual = windowRegions([region(2)], 5, 15, 0)[0]?.row; + expect(actual).toBe(expected); + }); + + it('shows every region, because the whole transcript fits', () => { + const expected = 2; + const actual = windowRegions([region(0), region(4)], 5, 15, 0).length; + expect(actual).toBe(expected); + }); +}); + +describe('windowRegions — transcript longer than the window', () => { + it('shifts a region up by the lines scrolled off the top', () => { + const expected = 0; + const actual = windowRegions([region(90)], 100, 10, 0)[0]?.row; + expect(actual).toBe(expected); + }); + + it('keeps a region on the last visible row while pinned to the bottom', () => { + const expected = 9; + const actual = windowRegions([region(99)], 100, 10, 0)[0]?.row; + expect(actual).toBe(expected); + }); + + it('drops a region that sits above the window', () => { + const expected = 0; + const actual = windowRegions([region(89)], 100, 10, 0).length; + expect(actual).toBe(expected); + }); + + it('moves a region down the screen as the transcript is scrolled back', () => { + const expected = 5; + const actual = windowRegions([region(90)], 100, 10, 5)[0]?.row; + expect(actual).toBe(expected); + }); + + it('drops a region once scrolling has pushed it off the bottom', () => { + const expected = 0; + const actual = windowRegions([region(99)], 100, 10, 5).length; + expect(actual).toBe(expected); + }); + + it('drops a region on the row the scroll indicator takes over', () => { + const expected = 0; + const actual = windowRegions([region(94)], 100, 10, 5).length; + expect(actual).toBe(expected); + }); +}); + +describe('windowRegions — what it carries through', () => { + it('leaves the first column of the span alone', () => { + const expected = 10; + const actual = windowRegions([region(90)], 100, 10, 0)[0]?.startCol; + expect(actual).toBe(expected); + }); + + it('leaves the last column of the span alone', () => { + const expected = 12; + const actual = windowRegions([region(90)], 100, 10, 0)[0]?.endCol; + expect(actual).toBe(expected); + }); + + it('carries the payload through unchanged', () => { + const expected = 'const a = 1;'; + const actual = windowRegions([region(90)], 100, 10, 0)[0]?.text; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-core/CHANGELOG.md b/packages/claude-core/CHANGELOG.md index 087d6e1c..7cba78dc 100644 --- a/packages/claude-core/CHANGELOG.md +++ b/packages/claude-core/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A left mouse press and release are now reported as key actions carrying the cell under the pointer, in the grid's own coordinates. Every other mouse event is still swallowed, so a drag or a modified click stays with the terminal - Add a BLUE foreground ANSI colour constant - Add a chdir operation to the IFileSystem contract - Add a README describing the package and pointing to the main documentation @@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - F3 is now recognised as a key action - IFileSystem gains readFileBytes, for a reader that scans a file's bytes rather than decoding it to a string - IFileSystem gains tmpdir, uid, mkdir with an explicit mode, lstat, and a synchronous readlinkSync, so code that needs a temporary directory, or has to create one and check who owns it, can reach all of it through the filesystem seam instead of node:os and node:fs directly +- osc52 builds the escape that asks the terminal to put text on the system clipboard, which works whether the program is local or at the far end of a connection - Parse mouse-wheel events from stdin into scroll_up/scroll_down key actions; add enableMouse/disableMouse escape sequences - StatResult now carries uid and mode, so a caller can tell who owns a path and who else can reach it - Support binary file reads through encoding parameter on IFileSystem.readFile diff --git a/packages/claude-core/changes.jsonl b/packages/claude-core/changes.jsonl index d46051a4..0cd4064b 100644 --- a/packages/claude-core/changes.jsonl +++ b/packages/claude-core/changes.jsonl @@ -29,3 +29,5 @@ {"description":"StatResult now carries uid and mode, so a caller can tell who owns a path and who else can reach it","category":"added"} {"description":"canonicalisePath resolves a path to where it actually lands, following symlinks even when the target does not exist yet, for callers that must decide on the destination rather than the string they were handed","category":"added"} {"description":"Preserve image format when downscaling","category":"fixed"} +{"description":"A left mouse press and release are now reported as key actions carrying the cell under the pointer, in the grid's own coordinates. Every other mouse event is still swallowed, so a drag or a modified click stays with the terminal","category":"added"} +{"description":"osc52 builds the escape that asks the terminal to put text on the system clipboard, which works whether the program is local or at the far end of a connection","category":"added"} diff --git a/packages/claude-core/src/ansi.ts b/packages/claude-core/src/ansi.ts index cb4f59a7..2bd9d180 100644 --- a/packages/claude-core/src/ansi.ts +++ b/packages/claude-core/src/ansi.ts @@ -39,5 +39,16 @@ export const BOLD_WHITE = '\x1B[1;97m'; export const enableMouse = `${ESC}?1000h${ESC}?1006h`; export const disableMouse = `${ESC}?1000l${ESC}?1006l`; +// String terminator, ESC backslash, as the OSC spec defines it. +const ST = '\x1b\\'; + +// Clipboard (OSC 52). The terminal base64-decodes the payload and sets the system +// clipboard; the `c` selection targets the clipboard rather than the primary selection. +// One-shot command, written straight to the screen: it occupies no cell and never +// enters a rendered row. +export function osc52(text: string): string { + return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}${ST}`; +} + // Misc export const BEL = '\x07'; diff --git a/packages/claude-core/src/input.ts b/packages/claude-core/src/input.ts index 22fde0b1..4773c6f9 100644 --- a/packages/claude-core/src/input.ts +++ b/packages/claude-core/src/input.ts @@ -47,6 +47,9 @@ export type KeyAction = | { type: 'shift+down' } | { type: 'scroll_up' } | { type: 'scroll_down' } + /** Left button, zero-based screen coordinates: the terminal reports one-based, and this is the one place that converts. */ + | { type: 'mouse_down'; col: number; row: number } + | { type: 'mouse_up'; col: number; row: number } | { type: 'unknown'; raw: string }; export interface NodeKey { @@ -268,7 +271,8 @@ function parseMouseAt(buf: Buffer, start: number): MouseParse { return { length: k - start, action: null }; } k++; - if (digits() === null) { + const column = digits(); + if (column === null) { return k >= buf.length ? 'incomplete' : { length: k - start, action: null }; } if (k >= buf.length) { @@ -278,7 +282,8 @@ function parseMouseAt(buf: Buffer, start: number): MouseParse { return { length: k - start, action: null }; } k++; - if (digits() === null) { + const line = digits(); + if (line === null) { return k >= buf.length ? 'incomplete' : { length: k - start, action: null }; } if (k >= buf.length) { @@ -289,10 +294,32 @@ function parseMouseAt(buf: Buffer, start: number): MouseParse { return { length: k - start + 1, action: null }; } k++; - const action: KeyAction | null = final === 0x4d && button === 64 ? { type: 'scroll_up' } : final === 0x4d && button === 65 ? { type: 'scroll_down' } : null; + const action: KeyAction | null = leftClickAction(button, final, column - 1, line - 1); return { length: k - start, action }; } +/** + * The action one parsed mouse event maps to, or null to swallow it. Wheel up and down + * are buttons 64 and 65 and only ever report a press. A plain left button (0, so no + * modifier bits and no motion bit) reports both, and its coordinates are converted from + * the terminal's one-based report to the grid's zero-based one here, the single place + * that knows the difference. Everything else is swallowed so its bytes never surface as + * stray keypresses: a modified left click is the terminal's own selection gesture, and a + * drag reports motion rather than a click. + */ +function leftClickAction(button: number, final: number, col: number, row: number): KeyAction | null { + if (final === 0x4d && button === 64) { + return { type: 'scroll_up' }; + } + if (final === 0x4d && button === 65) { + return { type: 'scroll_down' }; + } + if (button !== 0) { + return null; + } + return final === 0x4d ? { type: 'mouse_down', col, row } : { type: 'mouse_up', col, row }; +} + /** * Pull complete SGR mouse sequences out of a raw stdin buffer. readline shreds * mouse sequences into per-character keypress events, so they must be removed diff --git a/packages/claude-core/test/extractMouseSequences.spec.ts b/packages/claude-core/test/extractMouseSequences.spec.ts index 52a8f995..51a7bbfb 100644 --- a/packages/claude-core/test/extractMouseSequences.spec.ts +++ b/packages/claude-core/test/extractMouseSequences.spec.ts @@ -32,13 +32,7 @@ describe('extractMouseSequences — wheel', () => { }); describe('extractMouseSequences — non-wheel mouse', () => { - it('swallows a left-button press (button 0) with no action', () => { - const expected = 0; - const actual = extractMouseSequences(buf('\x1b[<0;3;4M')).actions.length; - expect(actual).toBe(expected); - }); - - it('swallows a button release (m) and passes nothing through', () => { + it('keeps a button release out of the passthrough, so its bytes never reach readline', () => { const expected = ''; const actual = extractMouseSequences(buf('\x1b[<0;3;4m')).passthrough.toString('latin1'); expect(actual).toBe(expected); diff --git a/packages/claude-core/test/mouseClicks.spec.ts b/packages/claude-core/test/mouseClicks.spec.ts new file mode 100644 index 00000000..afe8ef76 --- /dev/null +++ b/packages/claude-core/test/mouseClicks.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { extractMouseSequences, type KeyAction } from '../src/input'; + +// SGR reports one-based coordinates, so `<0;3;4M` is column 3, row 4 on screen and +// column 2, row 3 in the grid the renderer addresses. +const buf = (s: string): Buffer => Buffer.from(s, 'latin1'); +const leftPress = '\x1b[<0;3;4M'; +const leftRelease = '\x1b[<0;3;4m'; + +describe('extractMouseSequences — left button', () => { + it('reports a press with the grid coordinates under the pointer', () => { + const expected: KeyAction[] = [{ type: 'mouse_down', col: 2, row: 3 }]; + const actual = extractMouseSequences(buf(leftPress)).actions; + expect(actual).toEqual(expected); + }); + + it('reports a release with the grid coordinates under the pointer', () => { + const expected: KeyAction[] = [{ type: 'mouse_up', col: 2, row: 3 }]; + const actual = extractMouseSequences(buf(leftRelease)).actions; + expect(actual).toEqual(expected); + }); + + it('reports a press and its release as two actions in order', () => { + const expected: KeyAction[] = [ + { type: 'mouse_down', col: 2, row: 3 }, + { type: 'mouse_up', col: 2, row: 3 }, + ]; + const actual = extractMouseSequences(buf(leftPress + leftRelease)).actions; + expect(actual).toEqual(expected); + }); + + it('reports the release position when the pointer moved between the two', () => { + const expected: KeyAction[] = [{ type: 'mouse_up', col: 40, row: 11 }]; + const actual = extractMouseSequences(buf('\x1b[<0;41;12m')).actions; + expect(actual).toEqual(expected); + }); + + it('removes the click bytes from the passthrough', () => { + const expected = ''; + const actual = extractMouseSequences(buf(leftPress)).passthrough.toString('latin1'); + expect(actual).toBe(expected); + }); + + it('holds a click split across two chunks until the rest arrives', () => { + const expected = 0; + const actual = extractMouseSequences(buf('\x1b[<0;3;')).actions.length; + expect(actual).toBe(expected); + }); +}); + +describe('extractMouseSequences — buttons that are not a plain left click', () => { + it('swallows a middle-button press', () => { + const expected = 0; + const actual = extractMouseSequences(buf('\x1b[<1;3;4M')).actions.length; + expect(actual).toBe(expected); + }); + + it('swallows a right-button press', () => { + const expected = 0; + const actual = extractMouseSequences(buf('\x1b[<2;3;4M')).actions.length; + expect(actual).toBe(expected); + }); + + it('swallows a left press held with shift, which is the terminal-selection gesture', () => { + const expected = 0; + const actual = extractMouseSequences(buf('\x1b[<4;3;4M')).actions.length; + expect(actual).toBe(expected); + }); + + it('swallows a left drag, which reports motion rather than a click', () => { + const expected = 0; + const actual = extractMouseSequences(buf('\x1b[<32;3;4M')).actions.length; + expect(actual).toBe(expected); + }); +}); diff --git a/packages/claude-core/test/osc52.spec.ts b/packages/claude-core/test/osc52.spec.ts new file mode 100644 index 00000000..ef7eb69f --- /dev/null +++ b/packages/claude-core/test/osc52.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { osc52 } from '../src/ansi'; + +describe('osc52', () => { + it('wraps a base64 payload in a clipboard-targeted sequence', () => { + const expected = '\x1b]52;c;aGk=\x1b\\'; + const actual = osc52('hi'); + expect(actual).toBe(expected); + }); + + it('encodes multi-byte text as UTF-8 before base64', () => { + const expected = '\x1b]52;c;4oKs\x1b\\'; + const actual = osc52('\u20ac'); + expect(actual).toBe(expected); + }); + + it('sends an empty payload for empty text', () => { + const expected = '\x1b]52;c;\x1b\\'; + const actual = osc52(''); + expect(actual).toBe(expected); + }); + + it('encodes a newline rather than emitting it raw', () => { + const expected = '\x1b]52;c;YQpi\x1b\\'; + const actual = osc52('a\nb'); + expect(actual).toBe(expected); + }); +});