@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
@@ -1,11 +1,13 @@
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,
7
8
  * tables, and inline emphasis / code / strikethrough.
8
9
  */
10
+ import stringWidth from 'string-width';
9
11
  const HEADING_RE = /^(#{1,6})\s+(.*)$/;
10
12
  /** `---`, `***` or `___`, three or more, nothing else on the line. */
11
13
  const RULE_RE = /^(?:-{3,}|\*{3,}|_{3,})$/;
@@ -46,53 +48,128 @@ export function tableAlignments(dividerLine) {
46
48
  return 'left';
47
49
  });
48
50
  }
51
+ /** Default column separator: pure ASCII, so this module holds no glyph literal. */
52
+ const DEFAULT_TABLE_SEPARATOR = '|';
53
+ /** Default header rule: pure ASCII for the same reason. */
54
+ const DEFAULT_TABLE_RULE = '-';
55
+ /** One space either side of the separator, so a boundary is unmistakable. */
56
+ const TABLE_CELL_GAP = ' ';
49
57
  /**
50
- * Pad a parsed table into fixed-width columns. Returns one string per row —
51
- * no borders, because a bordered table inside an already-indented gutter wraps
52
- * on any realistic terminal.
58
+ * Pad a parsed table into fixed-width columns and draw the header rule.
59
+ *
60
+ * Returns `[header, divider, ...bodyRows]`, one string per rendered row, so
61
+ * index 0 is always the header and index 1 always the divider. The divider is
62
+ * rendered rather than swallowed because the parser consumes the source
63
+ * `|---|` row to read alignment: without it, a `--no-color` terminal showed a
64
+ * table as unseparated prose (bold is dropped at colour level 0, and the old
65
+ * two-space join gave no column boundary either).
66
+ *
67
+ * Padding is by DISPLAY columns, not `String.length`: a CJK cell is one code
68
+ * unit but two terminal columns, so `.length` misaligned every such table.
53
69
  */
54
- export function formatTable(rows, align) {
70
+ export function formatTable(rows, align, options = {}) {
71
+ const separator = options.separator ?? DEFAULT_TABLE_SEPARATOR;
72
+ const rule = options.rule ?? DEFAULT_TABLE_RULE;
55
73
  const colCount = Math.max(...rows.map((r) => r.length), 0);
56
74
  const widths = [];
57
75
  for (let c = 0; c < colCount; c += 1) {
58
- widths[c] = Math.max(...rows.map((r) => (r[c] ?? '').length), 0);
76
+ widths[c] = Math.max(...rows.map((r) => displayColumns(r[c] ?? '')), 0);
59
77
  }
60
- return rows.map((row) => row
61
- .map((cell, c) => {
62
- const w = widths[c] ?? 0;
63
- const a = align[c] ?? 'left';
64
- if (a === 'right')
65
- return cell.padStart(w);
66
- if (a === 'center') {
67
- const total = w - cell.length;
68
- const left = Math.floor(total / 2);
69
- return ' '.repeat(left) + cell + ' '.repeat(total - left);
78
+ const gap = `${TABLE_CELL_GAP}${separator}${TABLE_CELL_GAP}`;
79
+ const body = rows.map((row) => padRow(row, widths, align).join(gap).trimEnd());
80
+ if (body.length === 0)
81
+ return [];
82
+ const divider = widths.map((w) => rule.repeat(w)).join(gap).trimEnd();
83
+ return [body[0], divider, ...body.slice(1)];
84
+ }
85
+ function padRow(row, widths, align) {
86
+ return row.map((cell, c) => padCell(cell, widths[c] ?? 0, align[c] ?? 'left'));
87
+ }
88
+ function padCell(cell, width, align) {
89
+ const pad = width - displayColumns(cell);
90
+ if (pad <= 0)
91
+ return cell;
92
+ const filler = ' '.repeat(pad);
93
+ if (align === 'right')
94
+ return filler + cell;
95
+ if (align === 'center') {
96
+ const left = Math.floor(pad / 2);
97
+ return `${' '.repeat(left)}${cell}${' '.repeat(pad - left)}`;
98
+ }
99
+ return cell + filler;
100
+ }
101
+ /**
102
+ * Printable ASCII with no TAB: one code unit is one cell, so the width table is
103
+ * not consulted at all.
104
+ *
105
+ * The same load-bearing fast path `virtual-window.displayColumns` carries, and for
106
+ * the same reason: `stringWidth` strips ANSI and walks the East-Asian width table
107
+ * per code point, while this runs once per CELL of every table row on every frame.
108
+ * Table cells are overwhelmingly ASCII, so the branch is taken almost always -
109
+ * and when it is not, the table is CJK and the table is exactly what must be used.
110
+ */
111
+ const PLAIN_ASCII = /^[\x20-\x7e]*$/;
112
+ /** Terminal columns occupied by `text` (wide glyphs count two). */
113
+ function displayColumns(text) {
114
+ return PLAIN_ASCII.test(text) ? text.length : stringWidth(text);
115
+ }
116
+ /**
117
+ * An opening fence is three or more backticks plus an optional info string.
118
+ *
119
+ * The old `(\w*)` info group was too narrow for real answers: ```c++ and
120
+ * ```objective-c did not open a block at all, so the body degraded to prose and
121
+ * the closing fence was then swallowed as a new empty code block. A backtick is
122
+ * the one character CommonMark forbids in an info string; everything else is
123
+ * accepted here and the language is simply the FIRST token.
124
+ */
125
+ const FENCE_OPEN_RE = /^(`{3,})[ \t]*([^`]*)$/;
126
+ /** A closing fence carries backticks only — no info string. */
127
+ const FENCE_CLOSE_RE = /^(`{3,})[ \t]*$/;
128
+ function parseFenceOpen(line) {
129
+ const m = FENCE_OPEN_RE.exec(line);
130
+ if (!m)
131
+ return null;
132
+ const info = (m[2] ?? '').trim();
133
+ return { count: m[1].length, lang: info.split(/\s+/)[0] ?? '' };
134
+ }
135
+ /** Backtick count of a line that could close a fence; 0 when it could not. */
136
+ function closingFenceLength(line) {
137
+ const m = FENCE_CLOSE_RE.exec(line);
138
+ return m ? m[1].length : 0;
139
+ }
140
+ /**
141
+ * Collect a fence body starting after the opening fence.
142
+ *
143
+ * A closer must carry AT LEAST as many backticks as its opener, so a
144
+ * four-backtick block may contain ``` lines — that is how a nested markdown
145
+ * example survives. A shorter run is content, not the end of the block.
146
+ */
147
+ function readFenceBody(lines, openIndex, openCount) {
148
+ const body = [];
149
+ let i = openIndex + 1;
150
+ while (i < lines.length) {
151
+ if (closingFenceLength((lines[i] ?? '').trim()) >= openCount) {
152
+ return { code: body.join('\n'), next: i + 1 };
70
153
  }
71
- return cell.padEnd(w);
72
- })
73
- .join(' ')
74
- .trimEnd());
154
+ body.push(lines[i] ?? '');
155
+ i += 1;
156
+ }
157
+ return { code: body.join('\n'), next: i };
75
158
  }
76
- /** A fence line: ```` ```lang ```` or a bare ```` ``` ````. */
77
- const FENCE_RE = /^```(\w*)\s*$/;
78
159
  /** Split a document into fenced code, pipe tables, and everything else. */
79
160
  export function parseMarkdownBlocks(text) {
80
- const lines = text.split('\n');
161
+ // CRLF-safe: a bare `split('\n')` left a '\r' on every code line, which then
162
+ // reached the highlighter and the display-width maths.
163
+ const lines = text.split(/\r?\n/);
81
164
  const blocks = [];
82
165
  let i = 0;
83
166
  while (i < lines.length) {
84
167
  const line = lines[i] ?? '';
85
- const fence = FENCE_RE.exec(line.trim());
168
+ const fence = parseFenceOpen(line.trim());
86
169
  if (fence) {
87
- const lang = fence[1] || '';
88
- const codeLines = [];
89
- i += 1;
90
- while (i < lines.length && !FENCE_RE.test((lines[i] ?? '').trim())) {
91
- codeLines.push(lines[i] ?? '');
92
- i += 1;
93
- }
94
- i += 1; // skip the closing fence
95
- blocks.push({ kind: 'code', code: codeLines.join('\n'), lang });
170
+ const { code, next } = readFenceBody(lines, i, fence.count);
171
+ blocks.push({ kind: 'code', code, lang: fence.lang });
172
+ i = next;
96
173
  continue;
97
174
  }
98
175
  // A pipe row followed by a divider row starts a table; consume the run.
@@ -82,6 +82,10 @@ export declare function hasPasteFrame(input: string): boolean;
82
82
  * nothing on screen to explain it.
83
83
  *
84
84
  * This is strictly better than v0.6.3 for both consumers: a pasted key carrying a
85
- * stray CR or an ANSI sequence is now cleaned rather than stored raw.
85
+ * stray CR or an ANSI sequence is now cleaned rather than stored raw. The
86
+ * unframed branch strips escape SEQUENCES before the control sweep, because
87
+ * deleting only the introducer left `[0m` in the stored value -- and in a
88
+ * settings field that residue is invisible behind `maskDot` while the value
89
+ * handed to `registerSecret()` is silently wrong.
86
90
  */
87
91
  export declare function stripPasteFrames(input: string): string;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { PASTE_CLOSE, PASTE_OPEN } from '../input/limits.js';
9
9
  import { sanitisePaste } from '../input/paste-parse.js';
10
+ import { stripControlSequences } from '../input/control-sequences.js';
10
11
  /**
11
12
  * Remove every C0 byte, DEL and C1 byte from text that arrived OUTSIDE a frame
12
13
  * (I-14). NO CR -> LF conversion, and that omission is the point.
@@ -101,11 +102,15 @@ export function hasPasteFrame(input) {
101
102
  * nothing on screen to explain it.
102
103
  *
103
104
  * This is strictly better than v0.6.3 for both consumers: a pasted key carrying a
104
- * stray CR or an ANSI sequence is now cleaned rather than stored raw.
105
+ * stray CR or an ANSI sequence is now cleaned rather than stored raw. The
106
+ * unframed branch strips escape SEQUENCES before the control sweep, because
107
+ * deleting only the introducer left `[0m` in the stored value -- and in a
108
+ * settings field that residue is invisible behind `maskDot` while the value
109
+ * handed to `registerSecret()` is silently wrong.
105
110
  */
106
111
  export function stripPasteFrames(input) {
107
112
  if (!hasPasteFrame(input))
108
- return sanitiseTyped(input);
113
+ return sanitiseTyped(stripControlSequences(input));
109
114
  return splitPasteFrames(input)
110
115
  .map((segment) => (segment.kind === 'paste' ? sanitiseTyped(segment.text) : segment.text))
111
116
  .join('');
@@ -1,12 +1,21 @@
1
1
  import { formatCost, formatDuration, formatTokens, promptTokensOf } from '../agent/usage.js';
2
2
  import { formatRetryLine } from '../agent/retry-view.js';
3
+ import { expandTabs } from './display-tab.js';
3
4
  const DEFAULT_MAX_ENTRIES = 200;
4
5
  const CONTINUATION_INDENT = ' ';
5
6
  const TOOL_INDENT = ' ';
6
7
  const MAX_ARG_CHARS = 60;
7
- /** Prefix the first line with `marker`, indent the rest to line up under it. */
8
+ /**
9
+ * Prefix the first line with `marker`, indent the rest to line up under it.
10
+ *
11
+ * `expandTabs` RUNS BEFORE THE PREFIX IS ADDED, so a stored line lands on the
12
+ * same columns the TUI drew it at. Without it this replay -- the only record
13
+ * left once the alternate screen is torn down -- disagreed with the screen
14
+ * about every TAB, because the terminal painted an eight-column stop under text
15
+ * the composer had laid out at four.
16
+ */
8
17
  function block(marker, text) {
9
- const lines = text.replace(/\r\n/g, '\n').split('\n');
18
+ const lines = expandTabs(text.replace(/\r\n/g, '\n')).split('\n');
10
19
  const head = `${marker} ${lines[0] ?? ''}`.trimEnd();
11
20
  const rest = lines.slice(1).map((l) => `${CONTINUATION_INDENT}${l}`.trimEnd());
12
21
  return [head, ...rest];
@@ -22,13 +22,6 @@
22
22
  import type { Overlay } from '../agent/reducer.js';
23
23
  import type { MouseSource } from '../input/stdin-filter.js';
24
24
  import type { ScrollIntent } from './layout/scroll.js';
25
- /**
26
- * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
27
- * overlay. Declared here rather than in `App.tsx` because both the keyboard
28
- * branch and the wheel branch need it and `App` imports this module; the other
29
- * direction would be a cycle.
30
- */
31
- export declare const OVERLAY_PAGE = 8;
32
25
  export interface WheelRoutingOptions {
33
26
  isPointerCaptured?: () => boolean;
34
27
  isActive?: () => boolean;
@@ -39,6 +32,16 @@ export interface WheelRoutingOptions {
39
32
  onScroll: (kind: ScrollIntent, repeat: number) => void;
40
33
  /** Signed row delta; the caller clamps at 0 the way the keyboard branch does. */
41
34
  onOverlayScroll: (delta: number) => void;
35
+ /**
36
+ * Rows one Shift+wheel notch turns inside a controlled overlay (defect 11).
37
+ *
38
+ * A GETTER, read at event time for the same reason `getOverlay` is: the window
39
+ * height changes with the terminal, with `statusExpanded` and with the rail, and
40
+ * a value captured when the subscription was built is the value that goes stale.
41
+ * `App` answers with `overlayPageSize(...)`, the same function its keyboard
42
+ * branch uses, so the wheel and `PgDn` can never disagree.
43
+ */
44
+ getOverlayPage?: () => number;
42
45
  }
43
46
  export interface WheelRouting {
44
47
  /**
@@ -21,12 +21,15 @@
21
21
  */
22
22
  import { useCallback, useEffect, useRef } from 'react';
23
23
  /**
24
- * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
25
- * overlay. Declared here rather than in `App.tsx` because both the keyboard
26
- * branch and the wheel branch need it and `App` imports this module; the other
27
- * direction would be a cycle.
24
+ * Page rows used ONLY when the host supplies no `getOverlayPage`.
25
+ *
26
+ * `App` always supplies one (defect 11): the step is the overlay's visible window
27
+ * minus two rows of context, so `PgDn` in `/help` turns a page instead of 42% of
28
+ * one. This fallback exists so a host that owns no overlay window - this module's
29
+ * own routing tests are the only such host - still gets a page rather than
30
+ * silently degrading a Shift+wheel to a three-row notch.
28
31
  */
29
- export const OVERLAY_PAGE = 8;
32
+ const FALLBACK_OVERLAY_PAGE = 8;
30
33
  /** Transcript rows per notch. The terminal's own convention. */
31
34
  const CONTENT_LINES_PER_NOTCH = 3;
32
35
  /** Overlay rows per notch. Overlay content is short; a page needs Shift. */
@@ -113,7 +116,9 @@ export function useWheelRouting(options) {
113
116
  // D-3: the row is not consulted here either. Behind an overlay the
114
117
  // composer is inactive and the overlay is the only scrollable thing on
115
118
  // screen, so a notch anywhere scrolls it.
116
- const step = event.shift ? OVERLAY_PAGE : OVERLAY_LINES_PER_NOTCH;
119
+ const step = event.shift
120
+ ? current.getOverlayPage?.() ?? FALLBACK_OVERLAY_PAGE
121
+ : OVERLAY_LINES_PER_NOTCH;
117
122
  current.onOverlayScroll(event.dir === 'up' ? -step : step);
118
123
  return;
119
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.39",
59
+ "@aragon-agent/core": "^0.2.41",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",