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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/claude-sdk-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/claude-sdk-cli/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
10 changes: 7 additions & 3 deletions apps/claude-sdk-cli/src/app/ViewHost.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,15 +25,17 @@ export class ViewHost implements Disposable {
readonly #model: ViewModel;
readonly #presentations: ReadonlyMap<AppModeKey, Presentation>;
readonly #appModeState: IAppModeState;
readonly #frameRegions: IFrameRegions;
readonly #onChange: () => void;
#renderPending = false;
#disposed = false;

public constructor(renderer: TerminalRenderer, model: ViewModel, presentations: ReadonlyMap<AppModeKey, Presentation>, appModeState: IAppModeState) {
public constructor(renderer: TerminalRenderer, model: ViewModel, presentations: ReadonlyMap<AppModeKey, Presentation>, 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);
Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions apps/claude-sdk-cli/src/controller/ClickHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
45 changes: 45 additions & 0 deletions apps/claude-sdk-cli/src/model/ClickRegion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* 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.
*
* `text` is both the payload and the identity. A frame is rebuilt whole on every
* paint, so regions cannot be compared by reference across a repaint, and two
* regions carrying the same payload are indistinguishable in effect.
*/
export type ClickRegion = {
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;
}
35 changes: 35 additions & 0 deletions apps/claude-sdk-cli/src/model/ClickTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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.
*/
/** 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.text !== region.text) {
return null;
}
return pressed;
}
}
23 changes: 23 additions & 0 deletions apps/claude-sdk-cli/src/model/Clipboard.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
27 changes: 27 additions & 0 deletions apps/claude-sdk-cli/src/model/FrameRegions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
20 changes: 20 additions & 0 deletions apps/claude-sdk-cli/src/model/StatusState.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<StatusStateEvents>();

public get totalInputTokens(): number {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
46 changes: 35 additions & 11 deletions apps/claude-sdk-cli/src/model/markdown/markdownLayout.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -113,14 +114,28 @@ 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 }));

/** 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): Laid {
const inner = blocks(token.tokens, cols, decorate);
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): Laid {
const out: string[] = [];
const regions: ClickRegion[] = [];
for (const t of tokens) {
switch (t.type) {
case 'heading': {
Expand All @@ -135,15 +150,22 @@ 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));
const drawn = box(decorate(c.text, lang), lang, cols);
if (drawn.iconCol >= 0) {
regions.push({ 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);
regions.push(...shift(quoted.regions, out.length, 0));
out.push(...quoted.lines);
break;
}
case 'table': {
const tb = t as Tokens.Table;
out.push(
Expand All @@ -166,17 +188,18 @@ function blocks(tokens: Token[], cols: number, decorate: CodeDecorator): string[
break;
}
}
return out;
return { lines: out, regions };
}

/**
* Lay a `response` block's markdown out into display rows, indented to match the
* 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): 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);
return { lines: laid.lines.map((l) => indent + l), regions: shift(laid.regions, 0, indent.length) };
}

/**
Expand Down Expand Up @@ -204,7 +227,8 @@ 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): Laid {
const inner = Math.max(1, cols - indent.length);
return blocks(tokens, inner, decorate).map((l) => indent + l);
const laid = blocks(tokens, inner, decorate);
return { lines: laid.lines.map((l) => indent + l), regions: shift(laid.regions, 0, indent.length) };
}
Loading
Loading