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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/claude-sdk-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ref and PreviewEdit state is now persisted to disk
- Register TypeScript language tools (TsDiagnostics, TsHover, TsReferences, TsDefinition) in the CLI
- Render assistant responses as styled markdown in the terminal
- Render markdown tables in a response, honouring column alignment
- Retry on internal server error
- Retry transient API errors with exponential backoff and jitter before surfacing the error
- Scroll the conversation transcript back with the mouse wheel or PageUp/PageDown to read earlier output; the editor and status bar stay pinned
Expand Down Expand Up @@ -149,6 +150,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix `gatherGitSnapshot` crashing when any git command fails (e.g. `rev-parse HEAD` in a repo with no commits)
- Fix `GitStateMonitor` reporting the agent's own file edits and commits as human activity between turns
- Fix a denied tool's status glyph being overwritten by failed once its rejection tool_result arrived, reading a user denial as an execution failure
- Fix a fenced code block drawing its border in the wrong place when it holds a link or a wide character
- Fix AgentMessageHandler re-rendering every tool in a batch on every single tool's own state change (each streamed input-JSON delta, resolve, approve/deny, or result), when the Anthropic API only ever streams one tool at a time; ToolObject.render() now caches its own output, invalidated only by its own mutators
- Fix batch tool approvals: a local Y/N keypress now settles the tool you have selected by its request id, instead of the head of an anonymous queue. Previously one keypress could approve or deny a different tool in the same batch (or two at once)
- Fix colour loss when syntax-highlighted code scrolls off screen
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 @@ -173,3 +173,5 @@
{"description":"The scratchpad is unavailable on platforms with no user id to separate one user's files from another's","category":"changed"}
{"description":"A tool call refused without a prompt now says what refused it: the permission setting that decided, the operation it judged, and the paths that selected that setting. It previously reported only that the tool 'is configured to be denied automatically', which was untrue of every case and left both Claude and the operator guessing at a decision the CLI had already made","category":"fixed"}
{"description":"Deleting a symlink inside the scratchpad is now approved. Removing a link never touches what it points at, so judging the delete by its destination made any link Claude created in its own scratchpad permanently undeletable. Writes still follow a link to where they land, and a delete whose parent directory resolves outside the scratchpad is still refused","category":"fixed"}
{"description":"Render markdown tables in a response, honouring column alignment","category":"added"}
{"description":"Fix a fenced code block drawing its border in the wrong place when it holds a link or a wide character","category":"fixed"}
15 changes: 13 additions & 2 deletions apps/claude-sdk-cli/src/model/markdown/markdownLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { wrapLine } from '@shellicar/claude-core/reflow';
import { marked, type Token, type Tokens } from 'marked';
import type { CodeDecorator } from '../blockLayout.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 } from './palette.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';

/**
* Render an assistant `response` block as styled ANSI: parse with `marked`, walk
Expand All @@ -13,7 +13,7 @@ import { ACCENT, BOLD, BOLD_END, BULLET, box, CODE_FG, DIM, FG, HEADING, ITALIC,
* A token walk (not `marked`'s string renderer) so output stays a line array the
* wrapper can measure. `decorate` is the same count-preserving contract
* blockContentLines uses — one line out per code line — so the rendered height is
* predictable. Out-of-scope constructs (tables, task lists) fall through to raw
* predictable. A construct the walk has no case for falls through to raw
* passthrough, untouched.
*/

Expand Down Expand Up @@ -144,6 +144,17 @@ function blocks(tokens: Token[], cols: number, decorate: CodeDecorator): string[
case 'blockquote':
out.push(...quote(t as Tokens.Blockquote, cols, decorate));
break;
case 'table': {
const tb = t as Tokens.Table;
out.push(
...table(
[tb.header, ...tb.rows].map((r) => r.map((cell) => inline(cell.tokens))),
tb.align,
cols,
),
);
break;
}
case 'hr':
out.push(DIM + '\u2500'.repeat(HR_WIDTH) + R);
break;
Expand Down
94 changes: 85 additions & 9 deletions apps/claude-sdk-cli/src/model/markdown/palette.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* The markdown renderer's ANSI palette and the two primitives that need exact
* byte sequences: the OSC 8 hyperlink and the boxed code block. Codes are copied
* The markdown renderer's ANSI palette and the primitives that need exact byte
* sequences: the OSC 8 hyperlink, the boxed code block, and the table. Codes are copied
* verbatim from the mission's visual spec (spec/spec.mjs) — that rendered output
* is the contract, so this owns its palette rather than reaching for claude-core's
* ansi constants (whose DIM is `\x1b[2m`, not the spec's bright-black `\x1b[90m`).
Expand All @@ -11,6 +11,7 @@
*/

import { wrapLine } from '@shellicar/claude-core/reflow';
import stringWidth from 'string-width';

const e = (s: string | number): string => `\x1b[${s}m`;

Expand Down Expand Up @@ -39,10 +40,9 @@ export const SUB_BULLET = '\u25e6';
const ST = '\x1b\\';
export const osc8 = (url: string, label: string): string => `\x1b]8;;${url}${ST}${label}\x1b]8;;${ST}`;

// biome-ignore lint/suspicious/noControlCharactersInRegex: matching SGR escape sequences requires \x1b
const STRIP_ANSI = /\x1b\[[0-9;]*m/g;
/** Visible width: strip SGR codes, count code units. Mirrors the spec's measure. */
export const visLen = (s: string): number => s.replace(STRIP_ANSI, '').length;
// Width is measured with `string-width`, the same authority the wrap and paint paths
// use. Counting code units after stripping SGR misses an OSC 8 hyperlink's hidden url
// entirely and mis-sizes every wide or combining glyph (#391).

/** An underlined, link-coloured OSC 8 hyperlink (underline is the fallback when the terminal ignores OSC 8). */
export function link(href: string, label: string): string {
Expand All @@ -66,13 +66,89 @@ export function box(bodyLines: string[], lang: string, termWidth = 80): string[]
for (const l of bodyLines) {
wrapped.push(...wrapLine(l, maxInner));
}
const labelWidth = visLen(lang);
const innerW = Math.min(maxInner, Math.max(labelWidth + 1, ...wrapped.map(visLen)));
const labelWidth = stringWidth(lang);
const innerW = Math.min(maxInner, Math.max(labelWidth + 1, ...wrapped.map((l) => stringWidth(l))));
const out: string[] = [];
out.push(DIM + '\u250c\u2500 ' + ACCENT + lang + FG + DIM + ' ' + '\u2500'.repeat(Math.max(0, innerW - 1 - labelWidth)) + '\u2510' + R);
for (const l of wrapped) {
out.push(DIM + '\u2502' + FG + ' ' + l + ' '.repeat(Math.max(0, innerW - visLen(l))) + ' ' + DIM + '\u2502' + R);
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;
}

// The table's whole visual vocabulary. Style is a change to these three and the
// header emphasis in table(), so a different look never touches the layout walk.
const TABLE_SEP = ` ${DIM}\u2502${R} `;
const TABLE_RULE = '\u2500';
const TABLE_RULE_JOIN = '\u2500\u253c\u2500';

/** Which side a column's cells sit against. Matches the vocabulary `marked` reports. */
export type ColumnAlign = 'left' | 'center' | 'right' | null;

/** Pad a cell to its column width, putting the space on the side its alignment calls for. */
function padCell(cell: string, width: number, align: ColumnAlign): string {
const gap = Math.max(0, width - stringWidth(cell));
if (align === 'right') {
return ' '.repeat(gap) + cell;
}
const before = align === 'center' ? gap >> 1 : 0;
return ' '.repeat(before) + cell + ' '.repeat(gap - before);
}

const MIN_COLUMN = 3;
const SEP_WIDTH = 3;
// Held back on the right so a capped table sits in the same margin the content indent
// gives it on the left, rather than running flush against the terminal edge.
const RIGHT_MARGIN = 3;

/**
* Shrink the widest column a cell at a time until the set fits `available`, so the
* columns costing the most give up the most and a narrow one is never squeezed to
* nothing. Gives up at MIN_COLUMN: on a terminal too narrow to hold the table at all,
* an over-wide table beats an unreadable one.
*/
function fitColumns(natural: number[], available: number): number[] {
const widths = [...natural];
let over = widths.reduce((sum, w) => sum + w, 0) - available;
while (over > 0) {
const widest = Math.max(...widths);
if (widest <= MIN_COLUMN) {
break;
}
widths[widths.indexOf(widest)] = widest - 1;
over--;
}
return widths;
}

/**
* Draw a table: a bold header over a dimmed rule, dimmed separators between columns,
* short rows padded out so a ragged table still lines up. `rows[0]` is the header and
* `align` runs parallel to the columns.
*
* Snug to the content when it fits, capped and wrapped when it does not, the same rule
* box follows. A column that gives up width wraps its cells over several rows, so the
* content is still read rather than running off the right edge where nothing can reach
* it: the CLI scrolls vertically only, and the two output surfaces clip and soft-wrap
* differently, so an over-wide line has no single meaning.
*/
export function table(rows: string[][], align: ColumnAlign[], termWidth = 80): string[] {
const [header, ...body] = rows;
if (!header) {
return [];
}
const columns = Math.max(...rows.map((r) => r.length));
const natural = Array.from({ length: columns }, (_, i) => Math.max(...rows.map((r) => stringWidth(r[i] ?? ''))));
const widths = fitColumns(natural, Math.max(columns * MIN_COLUMN, termWidth - SEP_WIDTH * (columns - 1) - RIGHT_MARGIN));
// TABLE_SEP ends in a space and a padded cell can too, so a row whose last cell wraps
// short or renders empty would otherwise carry invisible whitespace out of the terminal.
const join = (cells: string[]): string => cells.join(TABLE_SEP).replace(/\s+$/, '');
const rowLines = (cells: string[], open: string, close: string): string[] => {
const wrapped = widths.map((w, i) => wrapLine(open + (cells[i] ?? '') + close, w));
const height = Math.max(...wrapped.map((w) => w.length));
return Array.from({ length: height }, (_, r) => join(widths.map((w, i) => padCell(wrapped[i]?.[r] ?? '', w, align[i] ?? null))));
};

return [...rowLines(header, BOLD, BOLD_END), DIM + widths.map((w) => TABLE_RULE.repeat(w)).join(TABLE_RULE_JOIN) + R, ...body.flatMap((cells) => rowLines(cells, '', ''))];
}
142 changes: 137 additions & 5 deletions apps/claude-sdk-cli/test/markdownLayout.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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 } from '../src/model/markdown/palette.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 { getHighlighted } from '../src/view/renderConversation.js';

// The source-to-rendered pairs come from the mission's visual spec (spec/spec.mjs):
Expand Down Expand Up @@ -156,11 +157,142 @@ describe('box — cap, wrap, and label-aware border', () => {
});
});

describe('markdownContentLines — out of scope', () => {
it('passes a table through verbatim', () => {
const expected = ['| Name | Role |', '| --- | --- |', '| Stephen | SC |'];
describe('markdownContentLines — tables', () => {
it('resolves the markdown inside each cell', () => {
const expected = table(
[
['Package', 'Role'],
[`${CODE_FG}claude-sdk${FG}`, `${BOLD}wrapper${BOLD_END}`],
],
[null, null],
);

const actual = render(['| Name | Role |', '| --- | --- |', '| Stephen | SC |']);
const actual = render(['| Package | Role |', '| --- | --- |', '| `claude-sdk` | **wrapper** |']);

expect(actual).toEqual(expected);
});

it('caps every line of a table too wide for the terminal', () => {
const expected: number[] = [];
const source = ['| Mission | State |', '| --- | --- |', '| a mission with a name far past the width | still on one line |'].join('\n');

const actual = markdownContentLines(source, 40, '', getHighlighted)
.map((l) => stringWidth(l))
.filter((w) => w > 40);

expect(actual).toEqual(expected);
});
});

describe('table — columns hug their widest cell', () => {
it('pads each column to its widest cell under a ruled bold header', () => {
const sep = ` ${DIM}\u2502${R} `;
const expected = [`${BOLD}left${BOLD_END}${sep}${BOLD}b${BOLD_END}`, `${DIM}${'\u2500'.repeat(4)}\u2500\u253c\u2500${'\u2500'.repeat(2)}${R}`, `1 ${sep}22`];

const actual = table(
[
['left', 'b'],
['1', '22'],
],
[null, null],
);

expect(actual).toEqual(expected);
});

it('pads a short row out to the full column count', () => {
const sep = ` ${DIM}\u2502${R} `;
const expected = [`${BOLD}a${BOLD_END}${sep}${BOLD}b${BOLD_END}`, `${DIM}\u2500\u2500\u253c\u2500\u2500${R}`, `1 ${DIM}\u2502${R}`];

const actual = table([['a', 'b'], ['1']], [null, null]);

expect(actual).toEqual(expected);
});
});

describe('table — column alignment', () => {
const sep = ` ${DIM}\u2502${R} `;

it('pushes a right-aligned column against its right edge', () => {
const expected = [` ${BOLD}Bytes${BOLD_END}${sep} ${BOLD}Pct${BOLD_END}`, `${DIM}${'\u2500'.repeat(7)}\u2500\u253c\u2500${'\u2500'.repeat(5)}${R}`, `975,950${sep}29.7%`];

const actual = table(
[
['Bytes', 'Pct'],
['975,950', '29.7%'],
],
['right', 'right'],
);

expect(actual).toEqual(expected);
});

it('splits the gap either side of a centred column', () => {
const expected = [`${BOLD}physical${BOLD_END}${sep}${BOLD}note${BOLD_END}`, `${DIM}${'\u2500'.repeat(8)}\u2500\u253c\u2500${'\u2500'.repeat(4)}${R}`, ` \u2713 ${sep}n`];

const actual = table(
[
['physical', 'note'],
['\u2713', 'n'],
],
['center', null],
);

expect(actual).toEqual(expected);
});

it('keeps the padding on a right-aligned last column, where it does not trail', () => {
const expected = [`${BOLD}Type${BOLD_END}${sep}${BOLD}Count${BOLD_END}`, `${DIM}${'\u2500'.repeat(4)}\u2500\u253c\u2500${'\u2500'.repeat(5)}${R}`, `trap${sep} 116`];

const actual = table(
[
['Type', 'Count'],
['trap', '116'],
],
[null, 'right'],
);

expect(actual).toEqual(expected);
});

it('carries the delimiter row alignment through to the rendered columns', () => {
const expected = table(
[
['Type', 'Count'],
['trap', '116'],
],
[null, 'right'],
);

const actual = render(['| Type | Count |', '|---|---:|', '| trap | 116 |']);

expect(actual).toEqual(expected);
});
});

describe('table — measuring a cell', () => {
const sep = ` ${DIM}\u2502${R} `;

it('measures a linked cell by its visible label, not the url hidden in the escape', () => {
const expected = [`${BOLD}Docs${BOLD_END}${sep}${BOLD}Name${BOLD_END}`, `${DIM}${'\u2500'.repeat(4)}\u2500\u253c\u2500${'\u2500'.repeat(4)}${R}`, `${link('https://example.com', 'x')} ${sep}a`];

const actual = render(['| Docs | Name |', '| --- | --- |', '| [x](https://example.com) | a |']);

expect(actual).toEqual(expected);
});

it('measures a wide character by the cells it occupies on screen', () => {
const expected = [`${BOLD}a${BOLD_END} ${sep}${BOLD}b${BOLD_END}`, `${DIM}${'\u2500'.repeat(6)}\u2500\u253c\u2500\u2500${R}`, `\u65e5\u672c\u8a9e${sep}x`, `yyyy ${sep}z`];

const actual = render(['| a | b |', '| --- | --- |', '| \u65e5\u672c\u8a9e | x |', '| yyyy | z |']);

expect(actual).toEqual(expected);
});

it('leaves no trailing whitespace on a row whose last cell is empty', () => {
const expected: string[] = [];

const actual = render(['| a | b |', '| --- | --- |', '| 1 | |']).filter((l) => /\s$/.test(l));

expect(actual).toEqual(expected);
});
Expand Down
Loading