@aragon-agent/cli 0.6.28 → 0.6.31

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 (80) hide show
  1. package/CHANGELOG.md +2123 -1975
  2. package/README.md +81 -14
  3. package/dist/agent/project-guidance-prompt.js +20 -20
  4. package/dist/agent/reducer.js +47 -2
  5. package/dist/cli.js +169 -56
  6. package/dist/commands/builtins.js +4 -0
  7. package/dist/commands/perf.d.ts +25 -1
  8. package/dist/commands/perf.js +26 -2
  9. package/dist/commands/registry.d.ts +6 -0
  10. package/dist/config/env.js +4 -0
  11. package/dist/config/load.d.ts +3 -1
  12. package/dist/config/load.js +12 -1
  13. package/dist/config/schema.d.ts +27 -0
  14. package/dist/config/schema.js +9 -0
  15. package/dist/config/ui-state.d.ts +9 -0
  16. package/dist/config/ui-state.js +9 -0
  17. package/dist/input/limits.d.ts +20 -0
  18. package/dist/input/limits.js +20 -0
  19. package/dist/input/stdin-filter.js +68 -5
  20. package/dist/ui/App.d.ts +11 -1
  21. package/dist/ui/App.js +155 -67
  22. package/dist/ui/AutocompletePopup.js +1 -1
  23. package/dist/ui/Header.js +7 -2
  24. package/dist/ui/Markdown.js +30 -3
  25. package/dist/ui/PromptInput.js +41 -3
  26. package/dist/ui/ScrollbackShell.d.ts +28 -0
  27. package/dist/ui/ScrollbackShell.js +10 -0
  28. package/dist/ui/Transcript.js +7 -0
  29. package/dist/ui/frame-differ.d.ts +12 -0
  30. package/dist/ui/layout/Gutter.js +1 -1
  31. package/dist/ui/layout/OverlayFrame.d.ts +15 -0
  32. package/dist/ui/layout/OverlayFrame.js +18 -0
  33. package/dist/ui/layout/ScrollIndicator.js +10 -3
  34. package/dist/ui/layout/budget.d.ts +15 -0
  35. package/dist/ui/layout/budget.js +15 -0
  36. package/dist/ui/layout/header-hints.d.ts +9 -0
  37. package/dist/ui/layout/header-hints.js +14 -1
  38. package/dist/ui/layout/status-layout.js +63 -2
  39. package/dist/ui/markdown-blocks.d.ts +26 -5
  40. package/dist/ui/markdown-blocks.js +110 -33
  41. package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
  42. package/dist/ui/overlays/HelpOverlay.js +21 -3
  43. package/dist/ui/screen.d.ts +16 -0
  44. package/dist/ui/screen.js +32 -0
  45. package/dist/ui/scrollback/budget.d.ts +43 -0
  46. package/dist/ui/scrollback/budget.js +44 -0
  47. package/dist/ui/scrollback/drain.d.ts +32 -0
  48. package/dist/ui/scrollback/drain.js +64 -0
  49. package/dist/ui/scrollback/exit-summary.d.ts +23 -0
  50. package/dist/ui/scrollback/exit-summary.js +25 -0
  51. package/dist/ui/scrollback/limits.d.ts +25 -0
  52. package/dist/ui/scrollback/limits.js +25 -0
  53. package/dist/ui/scrollback/live-preview.d.ts +26 -0
  54. package/dist/ui/scrollback/live-preview.js +106 -0
  55. package/dist/ui/scrollback/renderer.d.ts +38 -0
  56. package/dist/ui/scrollback/renderer.js +158 -0
  57. package/dist/ui/scrollback/scrollback-writer.d.ts +74 -0
  58. package/dist/ui/scrollback/scrollback-writer.js +263 -0
  59. package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
  60. package/dist/ui/scrollback/settled-boundary.js +106 -0
  61. package/dist/ui/scrollback/use-scrollback-view.d.ts +92 -0
  62. package/dist/ui/scrollback/use-scrollback-view.js +263 -0
  63. package/dist/ui/scrollback/writer-shapes.d.ts +61 -0
  64. package/dist/ui/scrollback/writer-shapes.js +114 -0
  65. package/dist/ui/stdout-frame-writer.js +12 -2
  66. package/dist/ui/terminal-plan.d.ts +52 -0
  67. package/dist/ui/terminal-plan.js +49 -0
  68. package/dist/ui/transcript-text.d.ts +11 -0
  69. package/dist/ui/transcript-text.js +21 -7
  70. package/dist/ui/use-startup-notices.d.ts +12 -1
  71. package/dist/ui/use-startup-notices.js +28 -2
  72. package/dist/ui/use-wheel-routing.d.ts +10 -7
  73. package/dist/ui/use-wheel-routing.js +11 -6
  74. package/package.json +2 -2
  75. package/skills/project-indexer/SKILL.md +163 -163
  76. package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
  77. package/skills/project-indexer/templates/cleancode-template.md +94 -94
  78. package/skills/project-indexer/templates/config-template.md +69 -69
  79. package/skills/project-indexer/templates/index-template.md +129 -129
  80. package/skills/skill-creator/SKILL.md +96 -96
@@ -150,5 +150,17 @@ export interface FrameDiffer {
150
150
  * appears one frame later instead of never.
151
151
  */
152
152
  repaint(): string;
153
+ /**
154
+ * The stream was resized. Optional (tui-native-terminal-scroll §5.1): the
155
+ * full-screen differ does not implement it and is invalidated instead, which is
156
+ * its existing behaviour; the scrollback writer keeps its cache for a reflow
157
+ * estimate.
158
+ */
159
+ onResize?(): void;
160
+ /**
161
+ * An own write that moves neither a cell nor the cursor (OSC 52 copy). Optional
162
+ * for the same reason: absent, the write invalidates the cache as before.
163
+ */
164
+ cursorNeutralWrite?(): void;
153
165
  }
154
166
  export declare function createFrameDiffer(options: FrameDifferOptions): FrameDiffer;
@@ -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 : '?')
@@ -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
  *
@@ -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.
@@ -14,4 +14,4 @@ import React from 'react';
14
14
  import type { Theme } from '../theme.js';
15
15
  import type { TermCapabilities } from '../capabilities.js';
16
16
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
17
- export declare function helpRows(theme: Theme, caps: TermCapabilities): React.ReactElement[];
17
+ export declare function helpRows(theme: Theme, caps: TermCapabilities, native?: boolean): React.ReactElement[];
@@ -2,7 +2,25 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from 'ink';
3
3
  import { pickGlyphs } from '../glyphs.js';
4
4
  const KEY_PAD = 24;
5
- function keyRows(times) {
5
+ /**
6
+ * Rows that only make sense when the APPLICATION scrolls and selects. In native
7
+ * scroll mode (tui-native-terminal-scroll §4.7) the terminal does both, so they are
8
+ * dropped and one honest row replaces them.
9
+ */
10
+ const APP_SCROLL_ROWS = new Set([
11
+ 'Shift+Up / Shift+Down', 'Wheel', 'Shift+Wheel', 'Drag right edge', '^N',
12
+ 'Drag text (left button)', 'Ctrl+C (with a selection)',
13
+ ]);
14
+ function keyRows(times, native = false) {
15
+ const all = allKeyRows(times);
16
+ if (!native)
17
+ return all;
18
+ return all
19
+ .filter(([key]) => !APP_SCROLL_ROWS.has(key))
20
+ .map(([key, description]) => key === 'PgUp / PgDn' ? [key, 'Page through an open overlay'] : [key, description])
21
+ .concat([['Scroll', 'Use your terminal (wheel, Shift+PgUp, scrollbar, search)']]);
22
+ }
23
+ function allKeyRows(times) {
6
24
  return [
7
25
  ['Enter', 'Submit (idle) / queue steering (running); re-pins to the newest output'],
8
26
  ['Alt+Enter / Shift+Enter', 'Newline when supported; the host may intercept either binding'],
@@ -133,13 +151,13 @@ const FAST_ROWS = [
133
151
  ['<fast_review>', 'An automated second opinion from the fast model - advice, not the user'],
134
152
  ];
135
153
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
136
- export function helpRows(theme, caps) {
154
+ export function helpRows(theme, caps, native = false) {
137
155
  const glyphs = pickGlyphs(caps);
138
156
  const rows = [];
139
157
  const section = (title) => (_jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: title }, `s-${title}`));
140
158
  const pair = (k, d) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.primary, children: k.padEnd(KEY_PAD) }), _jsx(Text, { color: theme.muted, children: d })] }, `r-${k}`));
141
159
  rows.push(section('Keybindings'));
142
- for (const [k, d] of keyRows(glyphs.times))
160
+ for (const [k, d] of keyRows(glyphs.times, native))
143
161
  rows.push(pair(k, d));
144
162
  rows.push(section('Slash commands'));
145
163
  for (const [c, d] of COMMANDS)
@@ -71,6 +71,22 @@ export interface AltScreenOptions {
71
71
  * CI) — writing screen-control sequences into a redirected file corrupts it.
72
72
  */
73
73
  export declare function enterAltScreen(stdout: NodeJS.WriteStream | undefined, options?: AltScreenOptions): ScreenHandle;
74
+ export interface NativeScreenOptions {
75
+ /** Same discipline as `AltScreenOptions.bracketedPaste`: a filter that consumes it exists. */
76
+ readonly bracketedPaste?: boolean;
77
+ }
78
+ /**
79
+ * Native-scroll mode's counterpart of `enterAltScreen` (tui-native-terminal-scroll
80
+ * §4.8 / D6): it deliberately does almost nothing. The main buffer stays in use so
81
+ * the conversation lands in the terminal's own scrollback; there is no alternate
82
+ * screen, no mouse reporting and no DEC 1007 save/restore.
83
+ *
84
+ * The only mode it touches is DEC 2004 (bracketed paste), which changes how input
85
+ * is encoded and has nothing to do with scrolling. Returns the SAME `ScreenHandle`
86
+ * shape, so the four idempotent restore paths in `cli.tsx` need not know which mode
87
+ * is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
88
+ */
89
+ export declare function enterNativeScreen(stdout: NodeJS.WriteStream | undefined, options?: NativeScreenOptions): ScreenHandle;
74
90
  /**
75
91
  * Replay the session summary into the normal buffer after `restore()`, so the
76
92
  * conversation does not evaporate along with the alternate screen.
package/dist/ui/screen.js CHANGED
@@ -181,6 +181,38 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
181
181
  setMouseCapture,
182
182
  };
183
183
  }
184
+ /**
185
+ * Native-scroll mode's counterpart of `enterAltScreen` (tui-native-terminal-scroll
186
+ * §4.8 / D6): it deliberately does almost nothing. The main buffer stays in use so
187
+ * the conversation lands in the terminal's own scrollback; there is no alternate
188
+ * screen, no mouse reporting and no DEC 1007 save/restore.
189
+ *
190
+ * The only mode it touches is DEC 2004 (bracketed paste), which changes how input
191
+ * is encoded and has nothing to do with scrolling. Returns the SAME `ScreenHandle`
192
+ * shape, so the four idempotent restore paths in `cli.tsx` need not know which mode
193
+ * is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
194
+ */
195
+ export function enterNativeScreen(stdout, options = {}) {
196
+ if (!stdout || !stdout.isTTY || typeof stdout.write !== 'function')
197
+ return NOOP_HANDLE;
198
+ const bracketedPaste = options.bracketedPaste === true;
199
+ if (bracketedPaste)
200
+ safeWrite(stdout, ENABLE_BRACKETED_PASTE);
201
+ let restored = false;
202
+ return {
203
+ restore() {
204
+ if (restored)
205
+ return;
206
+ restored = true;
207
+ // 2004 is FORCED off rather than saved/restored, for the reason recorded at
208
+ // `DISABLE_BRACKETED_PASTE`. The cursor is shown again because Ink's
209
+ // log-update hides it and a signal kill never gives it back.
210
+ safeWrite(stdout, (bracketedPaste ? DISABLE_BRACKETED_PASTE : '') + SHOW_CURSOR);
211
+ },
212
+ // No mouse reporting in this mode: nothing to hand back or take again.
213
+ setMouseCapture() { },
214
+ };
215
+ }
184
216
  /**
185
217
  * Replay the session summary into the normal buffer after `restore()`, so the
186
218
  * conversation does not evaporate along with the alternate screen.