@aragon-agent/cli 0.6.27 → 0.6.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -2
  3. package/dist/agent/reducer.js +47 -2
  4. package/dist/input/control-sequences.d.ts +71 -0
  5. package/dist/input/control-sequences.js +222 -0
  6. package/dist/input/limits.d.ts +20 -0
  7. package/dist/input/limits.js +20 -0
  8. package/dist/input/paste-parse.d.ts +21 -9
  9. package/dist/input/paste-parse.js +23 -11
  10. package/dist/input/stdin-filter.js +123 -9
  11. package/dist/tools/terminal-output.d.ts +8 -0
  12. package/dist/tools/terminal-output.js +10 -7
  13. package/dist/ui/App.js +34 -6
  14. package/dist/ui/AutocompletePopup.js +1 -1
  15. package/dist/ui/Header.js +7 -2
  16. package/dist/ui/Markdown.js +30 -3
  17. package/dist/ui/PromptInput.js +41 -3
  18. package/dist/ui/Transcript.js +7 -0
  19. package/dist/ui/display-tab.d.ts +51 -0
  20. package/dist/ui/display-tab.js +82 -0
  21. package/dist/ui/editor-navigation.d.ts +11 -1
  22. package/dist/ui/editor-navigation.js +14 -3
  23. package/dist/ui/entries/QueuedEntry.js +4 -1
  24. package/dist/ui/entries/UserEntry.js +8 -1
  25. package/dist/ui/layout/Gutter.js +1 -1
  26. package/dist/ui/layout/OverlayFrame.d.ts +15 -0
  27. package/dist/ui/layout/OverlayFrame.js +18 -0
  28. package/dist/ui/layout/ScrollIndicator.js +10 -3
  29. package/dist/ui/layout/budget.d.ts +15 -0
  30. package/dist/ui/layout/budget.js +15 -0
  31. package/dist/ui/layout/header-hints.d.ts +9 -0
  32. package/dist/ui/layout/header-hints.js +14 -1
  33. package/dist/ui/layout/status-layout.js +63 -2
  34. package/dist/ui/layout/virtual-window.js +44 -11
  35. package/dist/ui/markdown-blocks.d.ts +26 -5
  36. package/dist/ui/markdown-blocks.js +110 -33
  37. package/dist/ui/paste-frames.d.ts +5 -1
  38. package/dist/ui/paste-frames.js +7 -2
  39. package/dist/ui/transcript-text.js +11 -2
  40. package/dist/ui/use-wheel-routing.d.ts +10 -7
  41. package/dist/ui/use-wheel-routing.js +11 -6
  42. package/package.json +2 -2
@@ -0,0 +1,51 @@
1
+ /**
2
+ * TAB expansion that agrees with the TERMINAL, not with UTF-16 (fix A'-2 of
3
+ * paste-control-sequence-residue).
4
+ *
5
+ * WHY THIS IS A MODULE OF ITS OWN. A TAB has no width; it advances the pen to
6
+ * the next tab stop, so what it occupies depends on where the line currently
7
+ * is. That makes "one TAB" a property of a CURSOR, and the same character was
8
+ * being read three different ways in this package:
9
+ *
10
+ * - `ui/editor-navigation.ts` painted it at FOUR-column stops in the composer,
11
+ * - `entries/UserEntry.tsx` handed the raw byte to `<Text>`, so the terminal
12
+ * painted it at EIGHT-column stops in the transcript,
13
+ * - `tools/terminal-output.ts` expanded it by UTF-16 code-unit count, so a
14
+ * pasted `中文<TAB>X` table came out two columns short.
15
+ *
16
+ * A user who pastes a TAB-indented block or an Excel/Word table therefore saw
17
+ * the draft and the sent message disagree about their own text. One function,
18
+ * used by the composer AND by every renderer of stored text, is what makes the
19
+ * "before send" and "after send" rows character-identical.
20
+ *
21
+ * `input/` is the wrong home for it: `cli/input` may depend on `node:stream`
22
+ * and nothing else (`.claude-index/index.md`), and column arithmetic needs the
23
+ * width table. `ui/` already owns that (`selection/selection.ts` uses
24
+ * `string-width` for exactly this reason).
25
+ *
26
+ * Pure, React-free, Ink-free.
27
+ */
28
+ /**
29
+ * Tab stop width, matching every terminal's default.
30
+ *
31
+ * A COPY OF `tools/terminal-output.ts`'s number, on purpose and in the same
32
+ * shape `composer-limits.ts` copies `budget.ts::HINT_MIN_ROWS`: importing the
33
+ * tools module here would point an arrow from `ui/` into `tools/` for one
34
+ * integer, and the two are asserted equal by a test instead. (`tools/` keeps
35
+ * its own expander because its rows are clipped to 200 columns before anyone
36
+ * looks at them, so a code-unit approximation there cannot reach a user.)
37
+ */
38
+ export declare const TAB_WIDTH = 8;
39
+ /**
40
+ * Expand every TAB in `text` to spaces up to the next `TAB_WIDTH` stop, given
41
+ * the display column the first character will be painted at.
42
+ *
43
+ * `fromColumn` is a DISPLAY column, not an offset: a caller that has already
44
+ * painted `中文` must pass 4. Lines are independent -- a `\n` resets the column
45
+ * to zero, because the terminal's pen returns to column 0.
46
+ *
47
+ * Non-ASCII text is measured with `string-width`, which is the whole point: a
48
+ * CJK glyph occupies two cells, and counting it as one space is what left
49
+ * CJK-plus-TAB tables misaligned.
50
+ */
51
+ export declare function expandTabs(text: string, fromColumn?: number): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * TAB expansion that agrees with the TERMINAL, not with UTF-16 (fix A'-2 of
3
+ * paste-control-sequence-residue).
4
+ *
5
+ * WHY THIS IS A MODULE OF ITS OWN. A TAB has no width; it advances the pen to
6
+ * the next tab stop, so what it occupies depends on where the line currently
7
+ * is. That makes "one TAB" a property of a CURSOR, and the same character was
8
+ * being read three different ways in this package:
9
+ *
10
+ * - `ui/editor-navigation.ts` painted it at FOUR-column stops in the composer,
11
+ * - `entries/UserEntry.tsx` handed the raw byte to `<Text>`, so the terminal
12
+ * painted it at EIGHT-column stops in the transcript,
13
+ * - `tools/terminal-output.ts` expanded it by UTF-16 code-unit count, so a
14
+ * pasted `中文<TAB>X` table came out two columns short.
15
+ *
16
+ * A user who pastes a TAB-indented block or an Excel/Word table therefore saw
17
+ * the draft and the sent message disagree about their own text. One function,
18
+ * used by the composer AND by every renderer of stored text, is what makes the
19
+ * "before send" and "after send" rows character-identical.
20
+ *
21
+ * `input/` is the wrong home for it: `cli/input` may depend on `node:stream`
22
+ * and nothing else (`.claude-index/index.md`), and column arithmetic needs the
23
+ * width table. `ui/` already owns that (`selection/selection.ts` uses
24
+ * `string-width` for exactly this reason).
25
+ *
26
+ * Pure, React-free, Ink-free.
27
+ */
28
+ import stringWidth from 'string-width';
29
+ /**
30
+ * Tab stop width, matching every terminal's default.
31
+ *
32
+ * A COPY OF `tools/terminal-output.ts`'s number, on purpose and in the same
33
+ * shape `composer-limits.ts` copies `budget.ts::HINT_MIN_ROWS`: importing the
34
+ * tools module here would point an arrow from `ui/` into `tools/` for one
35
+ * integer, and the two are asserted equal by a test instead. (`tools/` keeps
36
+ * its own expander because its rows are clipped to 200 columns before anyone
37
+ * looks at them, so a code-unit approximation there cannot reach a user.)
38
+ */
39
+ export const TAB_WIDTH = 8;
40
+ /**
41
+ * Expand every TAB in `text` to spaces up to the next `TAB_WIDTH` stop, given
42
+ * the display column the first character will be painted at.
43
+ *
44
+ * `fromColumn` is a DISPLAY column, not an offset: a caller that has already
45
+ * painted `中文` must pass 4. Lines are independent -- a `\n` resets the column
46
+ * to zero, because the terminal's pen returns to column 0.
47
+ *
48
+ * Non-ASCII text is measured with `string-width`, which is the whole point: a
49
+ * CJK glyph occupies two cells, and counting it as one space is what left
50
+ * CJK-plus-TAB tables misaligned.
51
+ */
52
+ export function expandTabs(text, fromColumn = 0) {
53
+ if (text.indexOf('\t') === -1)
54
+ return text;
55
+ let out = '';
56
+ let column = Math.max(0, Math.floor(fromColumn));
57
+ let run = '';
58
+ for (const part of graphemesOf(text)) {
59
+ if (part === '\t') {
60
+ out += run;
61
+ run = '';
62
+ const width = TAB_WIDTH - (column % TAB_WIDTH);
63
+ out += ' '.repeat(width);
64
+ column += width;
65
+ continue;
66
+ }
67
+ run += part;
68
+ if (part === '\n')
69
+ column = 0;
70
+ else
71
+ column += stringWidth(part);
72
+ }
73
+ return out + run;
74
+ }
75
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
76
+ /**
77
+ * Grapheme clusters rather than code units, so a combining mark or an emoji ZWJ
78
+ * sequence advances the column once, exactly as the terminal draws it.
79
+ */
80
+ function graphemesOf(text) {
81
+ return Array.from(segmenter.segment(text), ({ segment }) => segment);
82
+ }
@@ -25,7 +25,17 @@ export interface EditorVisualMap {
25
25
  readonly positions: VisualPosition[];
26
26
  readonly cursor: VisualPosition;
27
27
  }
28
- /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
28
+ /**
29
+ * Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation.
30
+ *
31
+ * THE TAB STOP IS THE TERMINAL'S (`TAB_WIDTH`), NOT AN EDITOR-SIZED FOUR. It
32
+ * used to be four, while the transcript handed the raw `\t` to `<Text>` and
33
+ * the terminal painted it at eight: the same pasted table sat one column off
34
+ * from itself before and after sending. A tab that would cross the right edge
35
+ * is still clipped to the row (the caret cannot point past the last cell), so
36
+ * a tab STRADDLING the wrap is the one shape where the draft and the stored
37
+ * text can differ by the remainder of the advance.
38
+ */
29
39
  export declare function buildEditorVisualMap(input: {
30
40
  buffer: string;
31
41
  cols: number;
@@ -1,5 +1,6 @@
1
1
  /** Shared UTF-16 grapheme boundaries and terminal-cell layout for the editor. */
2
2
  import stringWidth from 'string-width';
3
+ import { TAB_WIDTH } from './display-tab.js';
3
4
  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
4
5
  /** Segment text without changing its UTF-16 offsets or stored whitespace. */
5
6
  export function graphemes(text) {
@@ -31,7 +32,17 @@ export function graphemeWidth(text) {
31
32
  return 1;
32
33
  return stringWidth(text);
33
34
  }
34
- /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
35
+ /**
36
+ * Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation.
37
+ *
38
+ * THE TAB STOP IS THE TERMINAL'S (`TAB_WIDTH`), NOT AN EDITOR-SIZED FOUR. It
39
+ * used to be four, while the transcript handed the raw `\t` to `<Text>` and
40
+ * the terminal painted it at eight: the same pasted table sat one column off
41
+ * from itself before and after sending. A tab that would cross the right edge
42
+ * is still clipped to the row (the caret cannot point past the last cell), so
43
+ * a tab STRADDLING the wrap is the one shape where the draft and the stored
44
+ * text can differ by the remainder of the advance.
45
+ */
35
46
  export function buildEditorVisualMap(input) {
36
47
  const cols = Math.max(1, Math.floor(input.cols));
37
48
  const cursor = snapGrapheme(input.buffer, input.cursor);
@@ -50,12 +61,12 @@ export function buildEditorVisualMap(input) {
50
61
  breakRow();
51
62
  continue;
52
63
  }
53
- let width = part.text === '\t' ? Math.min(cols, 4 - column % 4)
64
+ let width = part.text === '\t' ? Math.min(cols, TAB_WIDTH - column % TAB_WIDTH)
54
65
  : graphemeWidth(part.text);
55
66
  if (column > 0 && column + width > cols) {
56
67
  breakRow();
57
68
  if (part.text === '\t')
58
- width = Math.min(cols, 4);
69
+ width = Math.min(cols, TAB_WIDTH);
59
70
  }
60
71
  locate(part.index);
61
72
  rows[rows.length - 1].push({ ...part,
@@ -13,10 +13,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
13
13
  import React from 'react';
14
14
  import { Text } from 'ink';
15
15
  import { pickGlyphs } from '../glyphs.js';
16
+ import { expandTabs } from '../display-tab.js';
16
17
  function QueuedEntryImpl({ text, theme, caps, }) {
17
18
  const glyphs = pickGlyphs(caps);
18
19
  const lines = text.split('\n');
19
- const first = lines[0] ?? '';
20
+ // Same reason as `UserEntry`: a queued draft is a draft, so its TABs must
21
+ // occupy the columns the composer showed them at.
22
+ const first = expandTabs(lines[0] ?? '');
20
23
  const extra = lines.length - 1;
21
24
  const suffix = extra > 0 ? ` ${glyphs.ellipsis}+${extra} line${extra === 1 ? '' : 's'}` : '';
22
25
  return (_jsx(Text, { wrap: "truncate", color: theme.muted, children: `Queue: ${first}${suffix}` }));
@@ -14,9 +14,16 @@ import React from 'react';
14
14
  import { Box, Text } from 'ink';
15
15
  import { pickGlyphs } from '../glyphs.js';
16
16
  import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
17
+ import { expandTabs } from '../display-tab.js';
17
18
  function UserEntryImpl({ text, theme, caps, }) {
18
19
  const glyphs = pickGlyphs(caps);
19
- const all = text.split('\n');
20
+ // TAB IS EXPANDED HERE, AT THE RENDER BOUNDARY, AND NOWHERE ABOVE IT.
21
+ // The payload keeps its `\t` (the model must receive the original
22
+ // indentation), so `<Text>` used to hand the raw byte to the terminal and
23
+ // the terminal painted an EIGHT-column stop while the composer painted a
24
+ // FOUR-column one. Splitting first and expanding per line keeps this row
25
+ // identical to the row the user saw in the draft.
26
+ const all = text.split('\n').map((line) => expandTabs(line));
20
27
  // A submitted 5 000-line paste otherwise asks Yoga to lay out 5 000 nodes
21
28
  // inside a measured entry -- the hazard `render-cache.ts:37` already records
22
29
  // in the same words ("a multi-megabyte 'code block' is almost always a pasted
@@ -3,7 +3,7 @@ import { Box, Text } from 'ink';
3
3
  /** Columns reserved for the rail, glyph included. */
4
4
  export const GUTTER_WIDTH = 2;
5
5
  export function Gutter({ glyph, color, children }) {
6
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: GUTTER_WIDTH, children: _jsx(Text, { color: color, children: glyph }) }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: children })] }));
6
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: GUTTER_WIDTH, children: _jsx(Text, { color: color, children: glyph }) }), _jsx(Box, { flexDirection: "column", flexBasis: 0, flexGrow: 1, flexShrink: 0, children: children })] }));
7
7
  }
8
8
  /**
9
9
  * The one-row gap between turns: a dim rail stub rather than a blank line, so
@@ -35,6 +35,21 @@ interface OverlayFrameSelfManaged extends OverlayFrameBase {
35
35
  }
36
36
  export type OverlayFrameProps = OverlayFrameControlled | OverlayFrameSelfManaged;
37
37
  export declare function OverlayFrame(props: OverlayFrameProps): React.ReactElement;
38
+ /**
39
+ * Rows one PgUp/PgDn - and one Shift+wheel notch - turns inside a controlled
40
+ * overlay (defect 11).
41
+ *
42
+ * THIS IS THE OVERLAY'S `scroll.pageSize`, deliberately the same rule: one window
43
+ * minus `PAGE_CONTEXT_LINES` of overlap, so a reader keeps two rows of context
44
+ * across a page turn exactly as `PgDn` in the transcript does. It replaces a
45
+ * constant 8, which was 42% of `/help`'s 19-row window on a 100x24 terminal and
46
+ * made reading a 93-row shortcut list a twelve-keystroke exercise.
47
+ *
48
+ * THE WINDOW HEIGHT COMES FROM `overlayBodyRows`, which is the same expression
49
+ * `OverlayFrame` renders with - computed twice here would be the two-authorities
50
+ * defect B1 is fixing one component over.
51
+ */
52
+ export declare function overlayPageSize(maxRows: number, cols: number): number;
38
53
  /** Body rows shared by controlled overlays and self-managed focused lists. */
39
54
  export declare function overlayBodyRows(maxRows: number, cols: number): number;
40
55
  export {};
@@ -4,6 +4,7 @@ import { Box, Text } from 'ink';
4
4
  import stringWidth from 'string-width';
5
5
  import { pickGlyphs } from '../glyphs.js';
6
6
  import { sliceWindow } from './overlay-window.js';
7
+ import { pageSize } from './scroll.js';
7
8
  /** Below this width the round border costs more than it conveys (§5.2). */
8
9
  const BORDERLESS_COLS = 48;
9
10
  /** Title + footer + top and bottom border. */
@@ -68,6 +69,23 @@ export function OverlayFrame(props) {
68
69
  }
69
70
  return (_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.primary, paddingX: 1, marginTop: 1, children: inner }));
70
71
  }
72
+ /**
73
+ * Rows one PgUp/PgDn - and one Shift+wheel notch - turns inside a controlled
74
+ * overlay (defect 11).
75
+ *
76
+ * THIS IS THE OVERLAY'S `scroll.pageSize`, deliberately the same rule: one window
77
+ * minus `PAGE_CONTEXT_LINES` of overlap, so a reader keeps two rows of context
78
+ * across a page turn exactly as `PgDn` in the transcript does. It replaces a
79
+ * constant 8, which was 42% of `/help`'s 19-row window on a 100x24 terminal and
80
+ * made reading a 93-row shortcut list a twelve-keystroke exercise.
81
+ *
82
+ * THE WINDOW HEIGHT COMES FROM `overlayBodyRows`, which is the same expression
83
+ * `OverlayFrame` renders with - computed twice here would be the two-authorities
84
+ * defect B1 is fixing one component over.
85
+ */
86
+ export function overlayPageSize(maxRows, cols) {
87
+ return pageSize(overlayBodyRows(maxRows, cols));
88
+ }
71
89
  /** Body rows shared by controlled overlays and self-managed focused lists. */
72
90
  export function overlayBodyRows(maxRows, cols) {
73
91
  if (!Number.isFinite(maxRows))
@@ -6,10 +6,17 @@ export const MIN_INDICATOR_COLS = 40;
6
6
  export function ScrollIndicator({ rows, thumb, dragging = false, theme, caps, }) {
7
7
  const glyphs = pickGlyphs(caps);
8
8
  const count = Math.max(0, Math.floor(rows) || 0);
9
+ // NO CELLS WITHOUT A THUMB (defect 6). `thumb === null` means the content fits,
10
+ // so there is no position to mark and a full-height column of track glyphs is
11
+ // pure noise next to an empty transcript. The BOX below is still rendered
12
+ // whatever the thumb is — see the feedback-loop note at the top of this file:
13
+ // the reserved column, not the glyphs, is what keeps the width constant.
9
14
  const cells = [];
10
- for (let i = 0; i < count; i += 1) {
11
- const onThumb = !!thumb && i >= thumb.start && i < thumb.start + thumb.size;
12
- cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.muted, bold: onThumb && dragging, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
15
+ if (thumb) {
16
+ for (let i = 0; i < count; i += 1) {
17
+ const onThumb = i >= thumb.start && i < thumb.start + thumb.size;
18
+ cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.muted, bold: onThumb && dragging, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
19
+ }
13
20
  }
14
21
  return (_jsx(Box, { flexDirection: "column", flexShrink: 0, width: 1, children: cells }));
15
22
  }
@@ -22,6 +22,21 @@ export interface FrameBudget {
22
22
  }
23
23
  /** Clamp the displayed draft, independently of any transcript or panel content. */
24
24
  export declare function clampDraftRows(rows: number, draftRows: number): number;
25
+ /**
26
+ * The share of `popupMaxHeight` a completion menu may actually occupy (defect 10).
27
+ *
28
+ * `popupCapacity` answers "how much COULD the editor slot take", and its floor is
29
+ * one message row - right for a picker that owns the frame, wrong for a menu that
30
+ * opens over a conversation. At 20 rows the whole capacity is 13, which leaves one
31
+ * transcript row and no TODO rail at all (measured, `todo-app-layout.test.tsx`).
32
+ * Half keeps the menu growing with the terminal - 6 rows at 20, 11 at 30, 16 at 40 -
33
+ * while the conversation and the rail keep their rows.
34
+ *
35
+ * A divisor rather than a row count or a fraction of the terminal: it is the only
36
+ * form that stays derived from the frame budget instead of becoming a second
37
+ * authority beside it, and it cannot drift when `popupCapacity` changes.
38
+ */
39
+ export declare const POPUP_CAPACITY_SHARE = 2;
25
40
  /** Total menu capacity, including its borders; reserve at least one message row. */
26
41
  export declare function popupCapacity(rows: number, draftRows: number, statusExpanded: boolean): number;
27
42
  /** Normalize terminal dimensions once and conserve every visible frame row. */
@@ -8,6 +8,21 @@ export function clampDraftRows(rows, draftRows) {
8
8
  const wanted = Number.isFinite(draftRows) ? Math.floor(draftRows) : 1;
9
9
  return Math.max(1, Math.min(wanted, draftMaxRows(rows)));
10
10
  }
11
+ /**
12
+ * The share of `popupMaxHeight` a completion menu may actually occupy (defect 10).
13
+ *
14
+ * `popupCapacity` answers "how much COULD the editor slot take", and its floor is
15
+ * one message row - right for a picker that owns the frame, wrong for a menu that
16
+ * opens over a conversation. At 20 rows the whole capacity is 13, which leaves one
17
+ * transcript row and no TODO rail at all (measured, `todo-app-layout.test.tsx`).
18
+ * Half keeps the menu growing with the terminal - 6 rows at 20, 11 at 30, 16 at 40 -
19
+ * while the conversation and the rail keep their rows.
20
+ *
21
+ * A divisor rather than a row count or a fraction of the terminal: it is the only
22
+ * form that stays derived from the frame budget instead of becoming a second
23
+ * authority beside it, and it cannot drift when `popupCapacity` changes.
24
+ */
25
+ export const POPUP_CAPACITY_SHARE = 2;
11
26
  /** Total menu capacity, including its borders; reserve at least one message row. */
12
27
  export function popupCapacity(rows, draftRows, statusExpanded) {
13
28
  return Math.max(0, rows - 1 - 1 - (statusExpanded ? 2 : 1)
@@ -36,6 +36,15 @@ export declare const HEADER_HINT_LIMITS: {
36
36
  readonly minLeftCells: 24;
37
37
  /** Fixed spacing between two hint clauses. */
38
38
  readonly gapCells: 2;
39
+ /**
40
+ * Blank cells between the header's left half and this hint column (defect 12).
41
+ *
42
+ * A SEPARATE CONSTANT FROM `gapCells`. That one spaces two hint clauses INSIDE
43
+ * the right column (`buildPlan` below); this one separates the session
44
+ * description from that column. One constant carrying two meanings is how a
45
+ * later change to clause spacing silently re-lays-out the whole row.
46
+ */
47
+ readonly separatorCells: 2;
39
48
  };
40
49
  export type HeaderHintId = 'branch' | 'index' | 'detail';
41
50
  export interface HeaderHintClause {
@@ -36,6 +36,15 @@ export const HEADER_HINT_LIMITS = {
36
36
  minLeftCells: 24,
37
37
  /** Fixed spacing between two hint clauses. */
38
38
  gapCells: 2,
39
+ /**
40
+ * Blank cells between the header's left half and this hint column (defect 12).
41
+ *
42
+ * A SEPARATE CONSTANT FROM `gapCells`. That one spaces two hint clauses INSIDE
43
+ * the right column (`buildPlan` below); this one separates the session
44
+ * description from that column. One constant carrying two meanings is how a
45
+ * later change to clause spacing silently re-lays-out the whole row.
46
+ */
47
+ separatorCells: 2,
39
48
  };
40
49
  /** The verbatim clause text (spec 3.8, appendix A). */
41
50
  const BRANCH_CLAUSE = { collapsed: '^L branch', expanded: '^L new branch' };
@@ -69,7 +78,11 @@ export function planHeaderHints(input) {
69
78
  return buildPlan([{ id: 'detail', text: detail }], scrollCells, true);
70
79
  }
71
80
  const ladder = ladderFor(input.statusExpanded);
72
- const budget = columns - HEADER_HINT_LIMITS.minLeftCells;
81
+ // THE SEPARATOR IS SPENT OUT OF THE SAME ROW (defect 12): left half + gap + this
82
+ // column have to add up to `columns`, so the gap comes off the budget instead of
83
+ // being assumed free.
84
+ const budget = columns - HEADER_HINT_LIMITS.minLeftCells
85
+ - HEADER_HINT_LIMITS.separatorCells;
73
86
  for (const rung of ladder) {
74
87
  const plan = buildPlan(rung, scrollCells, false);
75
88
  if (plan.cells <= budget)
@@ -90,6 +90,47 @@ function feedbackLabel(input, form) {
90
90
  };
91
91
  return labels[feedback.kind][form === 'full' ? 0 : form === 'short' ? 1 : 2];
92
92
  }
93
+ /**
94
+ * Kinds that are NOTIFICATIONS, not a phase word (defect 8 / 9).
95
+ *
96
+ * A notification says "something happened". An ACTION kind (`confirm`,
97
+ * `stopping`, `force-stop`, `exit`, `clear-input`, `copy-error`,
98
+ * `copy-cleanup`) says what the NEXT key does - that IS the phase slot's job, so
99
+ * those keep replacing it. Everything in this set gets its own field instead and
100
+ * `phase` is never displaced: `Idle` / `Running` is the one thing this row must
101
+ * always answer, and a notice that lands as a turn ends is exactly when a wrong
102
+ * answer misleads the user about whether the agent is still working.
103
+ */
104
+ const NOTIFICATION_KINDS = new Set([
105
+ 'notice', 'copied', 'copy-sent', 'copying',
106
+ ]);
107
+ /**
108
+ * The most cells a notification body may take on the primary row.
109
+ *
110
+ * The row is exactly one row high and measured, so the body is CLIPPED rather
111
+ * than wrapped. 32 cells carries `Theme set to light.` or `Todo panel off.`
112
+ * whole while leaving the phase word, the mode and the context gauge intact; a
113
+ * longer message still has the details row, where it is rendered in full.
114
+ */
115
+ const FEEDBACK_TEXT_CELLS = 32;
116
+ /**
117
+ * Clip `text` to at most `cells` display columns, never splitting a wide glyph.
118
+ *
119
+ * Local rather than `interaction-hints.clipStatusText`: that module already
120
+ * imports THIS one, and a cycle for a five-line guard is not worth it.
121
+ */
122
+ function clipFeedbackText(text, cells) {
123
+ const clean = cleanStatusText(text);
124
+ if (stringWidth(clean) <= cells)
125
+ return clean;
126
+ let clipped = '';
127
+ for (const char of clean) {
128
+ if (stringWidth(clipped + char) > cells)
129
+ break;
130
+ clipped += char;
131
+ }
132
+ return clipped;
133
+ }
93
134
  const thinkingCodes = { off: 'O', minimal: 'N', low: 'L', medium: 'M', high: 'H', xhigh: 'X' };
94
135
  /**
95
136
  * The mode word for the primary row: full rungs name the mode with
@@ -127,15 +168,35 @@ export function planPrimaryStatusFields(input) {
127
168
  const code = thinkingCodes[input.thinkingLevel] ?? '?';
128
169
  const pct = contextPercent(input.context);
129
170
  const speed = speedNumber(input);
171
+ // THE RUN'S OWN URGENCY, and the notification's, are two different things
172
+ // (defect 9). `tone` keeps today's meaning for an ACTION kind (it colours the
173
+ // phase word); a notification is painted with its own level and leaves the
174
+ // phase word to describe the run - a copy error must not make `Idle` red.
130
175
  const tone = input.feedback?.level === 'error' || input.runOutcome === 'failed'
131
176
  ? 'error' : input.feedback?.level === 'warn' || input.waitingForConfirmation || input.stopping ? 'warning' : 'normal';
177
+ const notification = !!input.feedback && NOTIFICATION_KINDS.has(input.feedback.kind);
178
+ const phaseTone = notification
179
+ ? input.runOutcome === 'failed' ? 'error'
180
+ : input.waitingForConfirmation || input.stopping ? 'warning' : 'normal'
181
+ : tone;
182
+ const feedbackTone = input.feedback?.level === 'error' ? 'error'
183
+ : input.feedback?.level === 'warn' ? 'warning' : 'normal';
132
184
  const build = (rung) => {
133
185
  const full = rung === 0;
134
- const label = feedbackLabel(input, full ? 'full' : rung >= 4 ? 'tiny' : 'short')
186
+ const form = full ? 'full' : rung >= 4 ? 'tiny' : 'short';
187
+ // THE BODY IS A FIELD OF ITS OWN (defect 8). The text has been in
188
+ // `feedback.text` all along; it only ever reached the screen on the SECOND
189
+ // status row, which is behind Ctrl+G and off by default. Rung 3 and tighter
190
+ // drop it: the ladder exists to keep the phase word, and a body that only fits
191
+ // by hiding `Running` would be the original defect back with extra steps.
192
+ const notice = notification && rung <= 2 && input.feedback
193
+ ? clipFeedbackText(input.feedback.text, FEEDBACK_TEXT_CELLS) : '';
194
+ const label = (notification ? undefined : feedbackLabel(input, form))
135
195
  ?? (full ? labels[1] : rung >= 4 ? labels[0].slice(0, 4) : labels[0]);
136
196
  const modeText = modeFieldText(input.mode, input.pendingMode, full);
137
197
  return statusLinePlan([
138
- statusField('phase', glyph + (rung >= 5 ? '' : ' ') + label, tone),
198
+ statusField('phase', glyph + (rung >= 5 ? '' : ' ') + label, phaseTone),
199
+ ...(notice ? [statusField('feedback', notice, feedbackTone)] : []),
139
200
  ...(modeText ? [statusField('mode', modeText, 'accent')] : []),
140
201
  statusField('context', (full ? 'Context ' : 'C') + pct),
141
202
  statusField('thinking', full ? 'Think ' + (thinkingCodes[input.thinkingLevel] ? input.thinkingLevel : '?')
@@ -32,9 +32,11 @@
32
32
  * `selectWindow` takes `offset` as an INPUT, would close a loop from the offset
33
33
  * through the mounted set and the measured heights back to the offset.
34
34
  */
35
+ import stringWidth from 'string-width';
35
36
  import { FAST_LIMITS } from '../../fast/limits.js';
36
37
  import { COMPACTION_LIMITS } from '../../compaction/limits.js';
37
38
  import { GUTTER_WIDTH } from './Gutter.js';
39
+ import { expandTabs } from '../display-tab.js';
38
40
  import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
39
41
  export const VIRTUAL_LIMITS = {
40
42
  /**
@@ -159,21 +161,52 @@ export function entryRevision(entry) {
159
161
  export function heightKey(entry, cols, expanded, density, flags = '') {
160
162
  return `${entry.id}|${entryRevision(entry)}|${cols}|${expanded ? 1 : 0}|${density}|${flags}`;
161
163
  }
162
- /** Rows a string occupies at `usable` columns, wrapping included. At least 1. */
164
+ /**
165
+ * Rows a string occupies at `usable` columns, wrapping included. At least 1.
166
+ *
167
+ * COUNTED IN DISPLAY COLUMNS, NOT UTF-16 CODE UNITS, and the difference is a
168
+ * correctness bug rather than a nicety: a CJK glyph is one code unit and two
169
+ * cells, and a TAB is one code unit and up to `TAB_WIDTH` cells. Counting code
170
+ * units therefore UNDER-estimates, which this file's own note names as the
171
+ * unsafe direction ("an over-estimate makes the scroll position slightly
172
+ * conservative and self-corrects ... an under-estimate can put the newest
173
+ * output off the bottom of a viewport the user believes is pinned"). It also
174
+ * had to change together with the renderers: a stored `\t` is now expanded at
175
+ * paint time, so measuring the unexpanded string would keep the estimate short
176
+ * while the painted row got taller.
177
+ */
163
178
  function wrappedRows(text, usable) {
164
179
  const width = Math.max(1, usable);
165
180
  let rows = 0;
166
- let lineLen = 0;
167
- for (let i = 0; i < text.length; i += 1) {
168
- if (text.charCodeAt(i) === 10) {
169
- rows += Math.max(1, Math.ceil(lineLen / width));
170
- lineLen = 0;
171
- }
172
- else {
173
- lineLen += 1;
174
- }
181
+ let start = 0;
182
+ for (;;) {
183
+ const end = text.indexOf('\n', start);
184
+ const line = end === -1 ? text.slice(start) : text.slice(start, end);
185
+ rows += Math.max(1, Math.ceil(displayColumns(line) / width));
186
+ if (end === -1)
187
+ return rows;
188
+ start = end + 1;
175
189
  }
176
- return rows + Math.max(1, Math.ceil(lineLen / width));
190
+ }
191
+ /**
192
+ * Printable ASCII with no TAB: one code unit is one cell, so the width table is
193
+ * not consulted at all.
194
+ *
195
+ * THE FAST PATH IS LOAD-BEARING, not a micro-optimisation. `stringWidth`
196
+ * strips ANSI and walks the East-Asian width table for every code point, and
197
+ * this function runs over EVERY unmeasured entry of a 10 000-entry document
198
+ * (`render-budget.test.tsx` mounts exactly that); measuring them all with the
199
+ * table turned that test into a timeout. Transcript text is overwhelmingly
200
+ * ASCII, so the branch is taken almost always.
201
+ */
202
+ const PLAIN_ASCII = /^[\x20-\x7e]*$/;
203
+ /** Display columns of one logical line: TABs expanded, CJK counted twice. */
204
+ function displayColumns(line) {
205
+ if (line.indexOf('\t') === -1 && PLAIN_ASCII.test(line))
206
+ return line.length;
207
+ // `expandTabs` mirrors what `entries/*` paints; `stringWidth` is the width
208
+ // table the composer already wraps with, so the two agree on CJK and emoji.
209
+ return stringWidth(expandTabs(line));
177
210
  }
178
211
  /** Collapsed preview rows in `ToolCard`; duplicated as a NUMBER, not imported. */
179
212
  const TOOL_COLLAPSED_LINES = 8;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Markdown parsing layer (spec §4.7). Pure and React-free so the grammar can be
3
- * unit-tested without rendering anything.
3
+ * unit-tested without rendering anything. `string-width` is the only import and
4
+ * it is a pure function of its argument, so the module stays side-effect free.
4
5
  *
5
6
  * Scope is deliberately small — this is not CommonMark. It covers what a coding
6
7
  * agent's answers actually contain: fenced code, headings, lists, quotes, rules,
@@ -31,11 +32,31 @@ export declare function parseTableRow(line: string): string[] | null;
31
32
  export declare function isTableDivider(line: string): boolean;
32
33
  export declare function tableAlignments(dividerLine: string): TableBlock['align'];
33
34
  /**
34
- * Pad a parsed table into fixed-width columns. Returns one string per row —
35
- * no borders, because a bordered table inside an already-indented gutter wraps
36
- * on any realistic terminal.
35
+ * How the table is drawn. Both characters are injected by the renderer from
36
+ * `pickGlyphs(caps)` because this file is inside the ASCII-only scan
37
+ * (`glyphs.test.ts::inScope`): a Unicode box-drawing literal here would fail
38
+ * that guard.
37
39
  */
38
- export declare function formatTable(rows: string[][], align: TableBlock['align']): string[];
40
+ export interface TableFormatOptions {
41
+ /** Drawn between cells; `Markdown.tsx` passes `glyphs.railVertical`. */
42
+ separator?: string;
43
+ /** Repeated to a column's padded width; `Markdown.tsx` passes `glyphs.hrule`. */
44
+ rule?: string;
45
+ }
46
+ /**
47
+ * Pad a parsed table into fixed-width columns and draw the header rule.
48
+ *
49
+ * Returns `[header, divider, ...bodyRows]`, one string per rendered row, so
50
+ * index 0 is always the header and index 1 always the divider. The divider is
51
+ * rendered rather than swallowed because the parser consumes the source
52
+ * `|---|` row to read alignment: without it, a `--no-color` terminal showed a
53
+ * table as unseparated prose (bold is dropped at colour level 0, and the old
54
+ * two-space join gave no column boundary either).
55
+ *
56
+ * Padding is by DISPLAY columns, not `String.length`: a CJK cell is one code
57
+ * unit but two terminal columns, so `.length` misaligned every such table.
58
+ */
59
+ export declare function formatTable(rows: string[][], align: TableBlock['align'], options?: TableFormatOptions): string[];
39
60
  /**
40
61
  * One parsed top-level block.
41
62
  *