@aragon-agent/cli 0.6.37 → 0.6.39

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 (162) hide show
  1. package/CHANGELOG.md +2523 -2326
  2. package/README.md +3614 -3173
  3. package/dist/agent/controller.d.ts +142 -1
  4. package/dist/agent/controller.js +171 -3
  5. package/dist/agent/reducer.d.ts +41 -1
  6. package/dist/agent/reducer.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +85 -27
  10. package/dist/commands/builtins.js +13 -1
  11. package/dist/commands/keys-command.d.ts +23 -0
  12. package/dist/commands/keys-command.js +38 -0
  13. package/dist/commands/perf.d.ts +7 -1
  14. package/dist/commands/perf.js +2 -2
  15. package/dist/commands/registry.d.ts +39 -0
  16. package/dist/commands/task-command.d.ts +39 -0
  17. package/dist/commands/task-command.js +102 -0
  18. package/dist/config/cli-commands.d.ts +23 -0
  19. package/dist/config/cli-commands.js +36 -0
  20. package/dist/config/env.js +17 -4
  21. package/dist/config/load.js +6 -1
  22. package/dist/config/schema.d.ts +43 -4
  23. package/dist/config/schema.js +31 -5
  24. package/dist/config/store.js +17 -2
  25. package/dist/diagnostics/doctor.d.ts +9 -0
  26. package/dist/diagnostics/doctor.js +34 -0
  27. package/dist/diagnostics/info.d.ts +71 -0
  28. package/dist/diagnostics/info.js +91 -5
  29. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  30. package/dist/exec/bridge/caller-tools.js +213 -0
  31. package/dist/exec/bridge/decisions.d.ts +78 -0
  32. package/dist/exec/bridge/decisions.js +116 -0
  33. package/dist/exec/bridge/human-answers.d.ts +38 -0
  34. package/dist/exec/bridge/human-answers.js +164 -0
  35. package/dist/exec/bridge/limits.d.ts +68 -0
  36. package/dist/exec/bridge/limits.js +68 -0
  37. package/dist/exec/bridge/ndjson.d.ts +17 -0
  38. package/dist/exec/bridge/ndjson.js +83 -0
  39. package/dist/exec/bridge/pending.d.ts +70 -0
  40. package/dist/exec/bridge/pending.js +132 -0
  41. package/dist/exec/bridge/protocol.d.ts +346 -0
  42. package/dist/exec/bridge/protocol.js +256 -0
  43. package/dist/exec/bridge/server.d.ts +142 -0
  44. package/dist/exec/bridge/server.js +502 -0
  45. package/dist/exec/bridge/source.d.ts +58 -0
  46. package/dist/exec/bridge/source.js +145 -0
  47. package/dist/exec/bridge/writer.d.ts +26 -0
  48. package/dist/exec/bridge/writer.js +156 -0
  49. package/dist/exec/cli-commands.js +30 -2
  50. package/dist/exec/emitter.d.ts +16 -2
  51. package/dist/exec/emitter.js +26 -6
  52. package/dist/exec/events.d.ts +465 -10
  53. package/dist/exec/events.js +47 -1
  54. package/dist/exec/hooks/dispatch.d.ts +76 -0
  55. package/dist/exec/hooks/dispatch.js +169 -0
  56. package/dist/exec/hooks/index.d.ts +83 -0
  57. package/dist/exec/hooks/index.js +205 -0
  58. package/dist/exec/hooks/limits.d.ts +56 -0
  59. package/dist/exec/hooks/limits.js +49 -0
  60. package/dist/exec/hooks/registry.d.ts +49 -0
  61. package/dist/exec/hooks/registry.js +91 -0
  62. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  63. package/dist/exec/hooks/tool-wrapper.js +154 -0
  64. package/dist/exec/hooks/turn.d.ts +61 -0
  65. package/dist/exec/hooks/turn.js +59 -0
  66. package/dist/exec/hooks/validate.d.ts +85 -0
  67. package/dist/exec/hooks/validate.js +310 -0
  68. package/dist/exec/index.d.ts +130 -4
  69. package/dist/exec/index.js +501 -10
  70. package/dist/exec/options.d.ts +124 -2
  71. package/dist/exec/options.js +413 -6
  72. package/dist/exec/permission.d.ts +9 -1
  73. package/dist/exec/permission.js +7 -1
  74. package/dist/exec/request/limits.d.ts +51 -0
  75. package/dist/exec/request/limits.js +49 -0
  76. package/dist/exec/request/scope.d.ts +106 -0
  77. package/dist/exec/request/scope.js +149 -0
  78. package/dist/exec/result/limits.d.ts +74 -0
  79. package/dist/exec/result/limits.js +72 -0
  80. package/dist/exec/result/prompt.d.ts +28 -0
  81. package/dist/exec/result/prompt.js +74 -0
  82. package/dist/exec/result/schema.d.ts +99 -0
  83. package/dist/exec/result/schema.js +464 -0
  84. package/dist/exec/result/sink.d.ts +56 -0
  85. package/dist/exec/result/sink.js +50 -0
  86. package/dist/exec/result/tool.d.ts +37 -0
  87. package/dist/exec/result/tool.js +118 -0
  88. package/dist/exec/runner.d.ts +395 -1
  89. package/dist/exec/runner.js +860 -8
  90. package/dist/exec/threads/aggregate.d.ts +34 -0
  91. package/dist/exec/threads/aggregate.js +30 -0
  92. package/dist/exec/threads/capacity.d.ts +23 -0
  93. package/dist/exec/threads/capacity.js +38 -0
  94. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  95. package/dist/exec/threads/dispatch-source.js +106 -0
  96. package/dist/exec/threads/execute-threads.d.ts +53 -0
  97. package/dist/exec/threads/execute-threads.js +234 -0
  98. package/dist/exec/threads/gate.d.ts +42 -0
  99. package/dist/exec/threads/gate.js +81 -0
  100. package/dist/exec/threads/identity.d.ts +36 -0
  101. package/dist/exec/threads/identity.js +67 -0
  102. package/dist/exec/threads/interrupt.d.ts +29 -0
  103. package/dist/exec/threads/interrupt.js +79 -0
  104. package/dist/exec/threads/limits.d.ts +58 -0
  105. package/dist/exec/threads/limits.js +35 -0
  106. package/dist/exec/threads/outcome.d.ts +47 -0
  107. package/dist/exec/threads/outcome.js +26 -0
  108. package/dist/exec/threads/registry.d.ts +81 -0
  109. package/dist/exec/threads/registry.js +427 -0
  110. package/dist/exec/threads/runtime.d.ts +107 -0
  111. package/dist/exec/threads/runtime.js +523 -0
  112. package/dist/exec/threads/thread-source.d.ts +20 -0
  113. package/dist/exec/threads/thread-source.js +134 -0
  114. package/dist/fast/limits.d.ts +54 -0
  115. package/dist/fast/limits.js +54 -0
  116. package/dist/fast/summary-call.d.ts +72 -0
  117. package/dist/fast/summary-call.js +133 -0
  118. package/dist/fast/summary-prompt.d.ts +106 -0
  119. package/dist/fast/summary-prompt.js +320 -0
  120. package/dist/fast/task-summary-types.d.ts +66 -0
  121. package/dist/fast/task-summary-types.js +17 -0
  122. package/dist/logging/logger.d.ts +1 -1
  123. package/dist/proc/limits.d.ts +7 -0
  124. package/dist/proc/limits.js +7 -0
  125. package/dist/proc/output-streams.d.ts +50 -0
  126. package/dist/proc/output-streams.js +54 -0
  127. package/dist/team/runtime.d.ts +9 -0
  128. package/dist/team/runtime.js +8 -0
  129. package/dist/team/subagent.d.ts +15 -0
  130. package/dist/team/subagent.js +45 -1
  131. package/dist/tools/bash-tool.d.ts +10 -0
  132. package/dist/tools/bash-tool.js +71 -6
  133. package/dist/tools/index.d.ts +93 -1
  134. package/dist/tools/index.js +112 -3
  135. package/dist/tools/tool-gate.d.ts +100 -0
  136. package/dist/tools/tool-gate.js +87 -0
  137. package/dist/ui/App.js +224 -11
  138. package/dist/ui/Header.d.ts +28 -16
  139. package/dist/ui/Header.js +21 -19
  140. package/dist/ui/interaction-hints.js +6 -0
  141. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  142. package/dist/ui/layout/OverlayFrame.js +19 -4
  143. package/dist/ui/layout/header-hints.d.ts +55 -62
  144. package/dist/ui/layout/header-hints.js +59 -166
  145. package/dist/ui/layout/header-left.d.ts +115 -0
  146. package/dist/ui/layout/header-left.js +206 -0
  147. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  148. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  149. package/dist/ui/layout/status-detail-layout.js +5 -1
  150. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  151. package/dist/ui/layout/task-summary-cell.js +65 -0
  152. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  153. package/dist/ui/overlays/HelpOverlay.js +29 -2
  154. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  155. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  156. package/dist/ui/scrollback/canvas.d.ts +3 -2
  157. package/dist/ui/scrollback/canvas.js +3 -2
  158. package/dist/ui/scrollback/scrollback-writer.d.ts +13 -1
  159. package/dist/ui/scrollback/scrollback-writer.js +109 -10
  160. package/dist/ui/shortcuts.d.ts +81 -0
  161. package/dist/ui/shortcuts.js +221 -0
  162. package/package.json +2 -2
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The keyboard drawer (tui-shortcut-drawer-and-task-summary §4.5).
4
+ *
5
+ * ASCII ONLY: `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * IT OWNS NO CHROME AND NO ARITHMETIC. `OverlayFrame` draws the frame, the
8
+ * window and the position indicator; `shortcut-panel-layout.ts` decides the
9
+ * columns, the rows and which window contains the cursor. This component adds two
10
+ * things and nothing else: its own key handling and the text-run colours. v1
11
+ * planned 260 lines that re-implemented the frame's windowing and clamping - a
12
+ * second source of truth for the one thing overlays have already been rewritten
13
+ * once to unify.
14
+ *
15
+ * IT DOES NOT REGISTER `Esc` AND DOES NOT TOUCH ANY `ctrl`/`meta` COMBINATION:
16
+ * `Esc` belongs to `App` (it closes every overlay and cancels the pending
17
+ * question/plan), and `Ctrl+G` is handled there too. Two listeners both acting on
18
+ * one byte is how a panel closes twice.
19
+ */
20
+ import { useMemo, useState } from 'react';
21
+ import { Text, useInput } from 'ink';
22
+ import { pickGlyphs } from '../glyphs.js';
23
+ import { OverlayFrame, overlayBodyRows, overlayPageSize } from '../layout/OverlayFrame.js';
24
+ import { buildShortcutRows, shortcutPanelWidth, } from '../layout/shortcut-panel-layout.js';
25
+ import { filterShortcuts, isAvailable, SHORTCUTS } from '../shortcuts.js';
26
+ /** Colour per tone. `dim` is `muted` plus the dim attribute, never a second colour. */
27
+ function toneOf(row, theme) {
28
+ if (row.tone === 'accent')
29
+ return { color: theme.accent };
30
+ if (row.tone === 'primary')
31
+ return { color: theme.primary };
32
+ if (row.tone === 'dim')
33
+ return { color: theme.muted, dimColor: true };
34
+ return { color: theme.muted };
35
+ }
36
+ export function ShortcutPanel(props) {
37
+ const { cols, maxRows, theme, caps, isActive, native, running } = props;
38
+ const glyphs = pickGlyphs(caps);
39
+ const [cursor, setCursor] = useState(0);
40
+ const entries = useMemo(() => filterShortcuts(SHORTCUTS, props.query ?? ''), [props.query]);
41
+ const bodyRows = overlayBodyRows(maxRows, cols);
42
+ const page = overlayPageSize(maxRows, cols);
43
+ const result = useMemo(() => buildShortcutRows({
44
+ cols,
45
+ rows: bodyRows,
46
+ cursor,
47
+ entries,
48
+ task: props.task,
49
+ entryAvailable: (entry) => isAvailable(entry, { native, running }),
50
+ unavailableReason: native ? 'this mode leaves it to the terminal' : 'a run is in flight',
51
+ glyphs: {
52
+ ellipsis: glyphs.ellipsis,
53
+ cursor: glyphs.cursor,
54
+ hrule: glyphs.hrule,
55
+ midDot: glyphs.midDot,
56
+ },
57
+ }), [cols, bodyRows, cursor, entries, props.task, native, running,
58
+ glyphs.ellipsis, glyphs.cursor, glyphs.hrule, glyphs.midDot]);
59
+ const last = Math.max(0, entries.length - 1);
60
+ useInput((input, key) => {
61
+ // `ctrl`/`meta` ARE NOT OURS: `Ctrl+G` closes this panel and `Ctrl+C` owns the
62
+ // exit ladder, both in `App`. Consuming them here would double-handle a byte.
63
+ if (key.ctrl || key.meta)
64
+ return;
65
+ if (key.upArrow || input === 'k') {
66
+ setCursor((value) => Math.max(0, value - 1));
67
+ return;
68
+ }
69
+ if (key.downArrow || input === 'j') {
70
+ setCursor((value) => Math.min(last, value + 1));
71
+ return;
72
+ }
73
+ // PAGING MOVES THE CURSOR, one page minus one row of overlap - the same rule
74
+ // `overlayPageSize` uses for every other overlay, so a page turn keeps a row of
75
+ // context. An independent offset would let the selected row - and the detail
76
+ // line that explains it - slide off screen (P1-7).
77
+ if (key.pageUp) {
78
+ setCursor((value) => Math.max(0, value - Math.max(1, page - 1)));
79
+ return;
80
+ }
81
+ if (key.pageDown) {
82
+ setCursor((value) => Math.min(last, value + Math.max(1, page - 1)));
83
+ return;
84
+ }
85
+ if (input === 'd') {
86
+ props.onToggleDetails();
87
+ return;
88
+ }
89
+ if (input === 'r') {
90
+ props.onRefresh();
91
+ }
92
+ }, { isActive });
93
+ const rows = result.rows.map((row, index) => (_jsx(Text, { wrap: "truncate", ...toneOf(row, theme), children: row.text }, `${row.kind}-${row.entryId ?? 'x'}-${index}`)));
94
+ // THE `d` MARKER LIVES ON THE FRAME'S OWN TITLE LINE (D-6), which is chrome that is
95
+ // already paid for. It is spelled `d:on` / `d:off` rather than `details on|off`
96
+ // because the title row also carries the frame's position indicator (`v 1-13/37`,
97
+ // 13 cells) and the drawer's content is only 26 cells wide at 80 columns: the long
98
+ // form is truncated to `details...`, which is a marker that says nothing. The
99
+ // footer's `d details` clause names the key, so the pair reads as one sentence.
100
+ const title = `Keys d:${props.detailsExpanded ? 'on' : 'off'}`;
101
+ return (_jsx(OverlayFrame, { title: title, hint: result.hint, maxRows: maxRows, cols: cols, width: shortcutPanelWidth(cols), align: "right", rows: rows,
102
+ // THE WINDOW IS DERIVED FROM THE CURSOR, so the offset handed to the frame is
103
+ // a conclusion rather than state. `onScrollClamp` is deliberately not passed:
104
+ // there is nothing here that could drift out of step with it.
105
+ scrollOffset: result.window.start, theme: theme, caps: caps }));
106
+ }
@@ -78,8 +78,9 @@ export declare function screenCapacity(rows: number | undefined): number;
78
78
  export declare function clampSpaceBelow(spaceBelow: number, rows: number | undefined): number;
79
79
  /**
80
80
  * A terminal that grew adds rows BELOW the cursor, so the capacity grows by the
81
- * exact difference (D6). Shrinking is NOT handled here: it re-measures instead of
82
- * guessing (D5), so a smaller value would only hide a lost measurement.
81
+ * exact difference (D6). Shrinking is NOT handled here either: the writer keeps the
82
+ * rows the screen still has and re-pins them (I-B17) instead of guessing a smaller
83
+ * value, which would only hide a lost measurement (D5).
83
84
  */
84
85
  export declare function applyResizeGrowth(capacity: number, rowsPrev: number, rowsNow: number): number;
85
86
  /**
@@ -48,8 +48,9 @@ export function clampSpaceBelow(spaceBelow, rows) {
48
48
  }
49
49
  /**
50
50
  * A terminal that grew adds rows BELOW the cursor, so the capacity grows by the
51
- * exact difference (D6). Shrinking is NOT handled here: it re-measures instead of
52
- * guessing (D5), so a smaller value would only hide a lost measurement.
51
+ * exact difference (D6). Shrinking is NOT handled here either: the writer keeps the
52
+ * rows the screen still has and re-pins them (I-B17) instead of guessing a smaller
53
+ * value, which would only hide a lost measurement (D5).
53
54
  */
54
55
  export function applyResizeGrowth(capacity, rowsPrev, rowsNow) {
55
56
  // No capacity to grow: a broken number must not become a canvas by arithmetic.
@@ -54,6 +54,16 @@
54
54
  * - I-B15 · every batch starts by reconciling `top + capacity` with the live
55
55
  * `rows - 1` (a resize event can arrive one frame late), and a column change
56
56
  * resets before it writes.
57
+ * - I-B17 · a SHRINK re-pins the canvas, it never drops it. The canvas keeps
58
+ * `min(canvasRows, screenCapacity(rows))` rows — the screen keeps the rows nearest
59
+ * the parked cursor, so the survivors are its SUFFIX — and its top moves down to
60
+ * `capacity - kept`, which preserves LB-3. Dropping the cache instead made the next
61
+ * batch a SEED, and a seed assumes nothing is below the cursor
62
+ * (`writer-shapes.ts:60-64`); after a shrink that is false, so the batch overflowed
63
+ * the screen and the previous frame — status row included — went into the
64
+ * scrollback: the same row in two places. For the same reason a frame TALLER than
65
+ * the screen gives way at its TOP instead of being swallowed whole: the composer
66
+ * and the status line are the two rows a human is interacting with.
57
67
  * - I-A9 · the writer never writes a probe byte; it only asks the host, which owns
58
68
  * deferral, coalescing and the death switch.
59
69
  */
@@ -102,6 +112,8 @@ export interface ScrollbackWriterStats {
102
112
  guardHits: number;
103
113
  /** The subset NOT explained by a recent resize: the real defect signal. */
104
114
  guardHitsOutsideResize: number;
115
+ /** Frames taller than the screen, drawn after giving way at their TOP (I-B17). */
116
+ tallFramesTrimmed: number;
105
117
  clippedRows: number;
106
118
  elidedEntries: number;
107
119
  resizes: number;
@@ -116,7 +128,7 @@ export interface ScrollbackWriterStats {
116
128
  anchorGapRows: number;
117
129
  /** Rows this session pushed into the scrollback (the "is it scrolling" signal). */
118
130
  anchorScrollRows: number;
119
- /** Times the geometry was dropped on purpose (column change, shrink, gap lost). */
131
+ /** Times the geometry was dropped on purpose (column change, nothing left to pin). */
120
132
  anchorResets: number;
121
133
  /** Measurements accepted AFTER the first one — the re-probe count (not resize events). */
122
134
  anchorReprobes: number;
@@ -54,6 +54,16 @@
54
54
  * - I-B15 · every batch starts by reconciling `top + capacity` with the live
55
55
  * `rows - 1` (a resize event can arrive one frame late), and a column change
56
56
  * resets before it writes.
57
+ * - I-B17 · a SHRINK re-pins the canvas, it never drops it. The canvas keeps
58
+ * `min(canvasRows, screenCapacity(rows))` rows — the screen keeps the rows nearest
59
+ * the parked cursor, so the survivors are its SUFFIX — and its top moves down to
60
+ * `capacity - kept`, which preserves LB-3. Dropping the cache instead made the next
61
+ * batch a SEED, and a seed assumes nothing is below the cursor
62
+ * (`writer-shapes.ts:60-64`); after a shrink that is false, so the batch overflowed
63
+ * the screen and the previous frame — status row included — went into the
64
+ * scrollback: the same row in two places. For the same reason a frame TALLER than
65
+ * the screen gives way at its TOP instead of being swallowed whole: the composer
66
+ * and the status line are the two rows a human is interacting with.
57
67
  * - I-A9 · the writer never writes a probe byte; it only asks the host, which owns
58
68
  * deferral, coalescing and the death switch.
59
69
  */
@@ -110,6 +120,7 @@ export function createScrollbackWriter(o) {
110
120
  fallbacks: 0, guardHits: 0, guardHitsOutsideResize: 0, clippedRows: 0, elidedEntries: 0,
111
121
  resizes: 0, repaints: 0, anchorSource: 'none', anchorCanvasRows: 0, anchorHeldRows: 0,
112
122
  anchorGapRows: 0, anchorScrollRows: 0, anchorResets: 0, anchorReprobes: 0,
123
+ tallFramesTrimmed: 0,
113
124
  };
114
125
  const liveCols = () => {
115
126
  const cols = o.cols();
@@ -205,6 +216,68 @@ export function createScrollbackWriter(o) {
205
216
  stats.bytesWritten += bytes.length;
206
217
  return bytes;
207
218
  };
219
+ /**
220
+ * Drop from the canvas cache the rows a shorter screen no longer has (I-B17).
221
+ *
222
+ * A shrink deletes rows ABOVE the parked cursor, so what survives is the canvas's
223
+ * SUFFIX: the gap goes first, then the oldest content rows. Trimming the cache is
224
+ * what lets the next batch be a DIFF against what is really on screen. Dropping the
225
+ * cache (`reset()`) made it a SEED instead, and a seed assumes nothing is below the
226
+ * cursor (`writer-shapes.ts:60-64`) — after a shrink that is false, so the batch
227
+ * overflowed and the previous frame, status row included, was pushed into the
228
+ * scrollback instead of being erased.
229
+ */
230
+ const trimCanvasCache = (capacity) => {
231
+ const lost = canvasRows - capacity;
232
+ if (lost <= 0)
233
+ return;
234
+ const lostFromGap = Math.min(lost, gapRows);
235
+ gapRows -= lostFromGap;
236
+ held = held.slice(Math.min(lost - lostFromGap, held.length));
237
+ prev = prev === null ? null : prev.slice(lost);
238
+ canvasRows = capacity;
239
+ // A drastic shrink can eat into the region itself (E2). `frameRows` is the height
240
+ // the next diff is aligned against, so it may never claim rows that are gone.
241
+ frameRows = Math.min(frameRows, Math.max(0, capacity - gapRows - held.length));
242
+ };
243
+ /**
244
+ * Re-pin a canvas the terminal just got SHORTER, instead of dropping it (I-B17).
245
+ *
246
+ * `top` moves DOWN (`capacity - kept`) because the rows the screen lost were the ones
247
+ * above the cursor: the parked row is still `rows - 1`, so LB-3 holds for the same
248
+ * reason it always did.
249
+ *
250
+ * `capacity < 1` keeps the old answer (E3): a terminal with no row above the parked one
251
+ * has no canvas left to address, and re-measuring is then the only honest move. Columns
252
+ * are deliberately NOT handled here: a width change re-flows the rows themselves, so
253
+ * the cache describes nothing and still resets (D5 / E4).
254
+ */
255
+ const repinShrunk = (rows) => {
256
+ const room = screenCapacity(rows);
257
+ // Only a MEASURED canvas may be re-pinned. `unknown` means nothing ever established
258
+ // that the cursor sits on the last row, and the arithmetic below starts from exactly
259
+ // that assumption; the append path erases nothing, so it stays the honest answer and
260
+ // keeps its bytes exactly as they were (I-B7).
261
+ const capacity = anchor.kind === 'pinned' ? Math.min(canvasRows, room) : 0;
262
+ if (capacity < 1) {
263
+ reset();
264
+ return;
265
+ }
266
+ trimCanvasCache(capacity);
267
+ anchor = { kind: 'pinned', top: room - capacity, capacity };
268
+ seedDownToTop = 0;
269
+ lastRows = rows;
270
+ // The re-draw this answers is the host's, deferred and coalesced (P0-2 / P0-3):
271
+ // `anchorDirty` is what lets a `repaint()` land while the feed is idle.
272
+ anchorDirty = true;
273
+ publishAnchorStats();
274
+ // Re-measure anyway (I-B14). Keeping the canvas is a bet that the parked row is still
275
+ // `rows - 1`, and only a measurement can confirm the terminal agrees; dropping the
276
+ // probe here would leave a wrong geometry with nothing left to correct it. A drag
277
+ // that fires dozens of resize events still costs one probe: the host coalesces them
278
+ // (P1-3 / E6), and `anchorReprobes` counts measurements, never requests.
279
+ requestProbe();
280
+ };
208
281
  /**
209
282
  * Bring `top + capacity` back in line with the live `rows - 1` BEFORE the batch
210
283
  * reads either of them (§6.11, [P0-2]).
@@ -235,7 +308,10 @@ export function createScrollbackWriter(o) {
235
308
  lastRows = rows;
236
309
  return;
237
310
  }
238
- reset();
311
+ // The `resize` event can arrive a whole frame late, and on a terminal that never
312
+ // answers CPR it never arrives at all: this batch is then the last chance to keep
313
+ // the canvas instead of dropping it (I-B17, W6).
314
+ repinShrunk(rows);
239
315
  };
240
316
  const afterCanvasBatch = (layout, heldNext, frame, raw, cols, rows) => {
241
317
  prev = [...new Array(layout.gap).fill(''), ...heldNext, ...frame];
@@ -402,6 +478,23 @@ export function createScrollbackWriter(o) {
402
478
  canvasRows = lines.length;
403
479
  gapRows = 0;
404
480
  };
481
+ /**
482
+ * Give way at the TOP of a frame taller than the screen (I-B17 / W4).
483
+ *
484
+ * Such a frame cannot be addressed as ONE block, and swallowing it left the PREVIOUS
485
+ * frame on screen — which is how the status row came to be printed twice. The region's
486
+ * last rows are the composer and the status line, the two rows a human is interacting
487
+ * with, so the oldest rows go first and the next (shorter) frame restores the rest.
488
+ *
489
+ * BOTH entry points that draw a frame need this: `repaint()` replays the frame it kept,
490
+ * and that frame can predate a shrink that has since made the screen shorter.
491
+ */
492
+ const giveWayAtTop = (lines, rows) => {
493
+ if (lines.length + 1 <= rows)
494
+ return lines;
495
+ stats.tallFramesTrimmed += 1;
496
+ return lines.slice(lines.length - (rows - 1));
497
+ };
405
498
  const transform = (chunk) => {
406
499
  if (finalized)
407
500
  return '';
@@ -419,12 +512,7 @@ export function createScrollbackWriter(o) {
419
512
  }
420
513
  if (lines === null)
421
514
  return countFallback(!firstChunk);
422
- if (lines.length + 1 > rows) {
423
- // Unreachable by construction (I-S1); never erase on a guess.
424
- reset();
425
- return '';
426
- }
427
- return assemble(lines, rows, liveCols(), 'frame');
515
+ return assemble(giveWayAtTop(lines, rows), rows, liveCols(), 'frame');
428
516
  };
429
517
  const repaint = () => {
430
518
  repaintQueued = false;
@@ -437,7 +525,10 @@ export function createScrollbackWriter(o) {
437
525
  const rows = liveRows();
438
526
  if (rows === undefined || rows < 2)
439
527
  return '';
440
- return assemble(prevRawFrame, rows, liveCols(), 'repaint');
528
+ // The kept frame may predate a shrink, so a replay gets the same top give-way as a
529
+ // live frame (W4): drawing it whole would overflow the new screen and push this
530
+ // session's own rows into the scrollback through the repaint door.
531
+ return assemble(giveWayAtTop(prevRawFrame, rows), rows, liveCols(), 'repaint');
441
532
  };
442
533
  const finalize = (summary) => {
443
534
  if (finalized)
@@ -478,8 +569,10 @@ export function createScrollbackWriter(o) {
478
569
  }
479
570
  if (rowsNow !== undefined && lastRows !== undefined) {
480
571
  if (rowsNow < lastRows) {
481
- // The terminal deleted rows; they are gone and must not be redrawn.
482
- reset();
572
+ // I-B17: the screen kept the rows nearest the cursor, so re-pin the canvas onto
573
+ // them instead of dropping it. A reset made the next batch a seed over a screen
574
+ // that is not empty, which is what left the old status row in the scrollback.
575
+ repinShrunk(rowsNow);
483
576
  return;
484
577
  }
485
578
  if (anchor.kind === 'pinned') {
@@ -503,6 +596,12 @@ export function createScrollbackWriter(o) {
503
596
  return;
504
597
  const rows = liveRows();
505
598
  const capacity = screenCapacity(rows);
599
+ // I-B17: a measurement can arrive after the terminal got shorter without ever
600
+ // announcing it (`rows` is read live, the `resize` event is optional). The cache
601
+ // must then stop describing rows the screen has already dropped, or the reach
602
+ // below would hand the next batch an erase distance longer than the canvas left.
603
+ if (rows !== undefined)
604
+ trimCanvasCache(capacity);
506
605
  // I-B14: the report is "rows BELOW the cursor", and the canvas also still owns
507
606
  // `canvasRows` rows ABOVE it, so the reachable height is their sum. Keeping only
508
607
  // the first term collapses the canvas onto the parked row, and the next batch
@@ -0,0 +1,81 @@
1
+ /**
2
+ * THE shortcut table (tui-shortcut-drawer-and-task-summary §4.6 / appendix B).
3
+ *
4
+ * ASCII ONLY, AND THAT IS A HARD CONSTRAINT RATHER THAN A STYLE CHOICE: this file
5
+ * is inside `glyphs.test.ts::inScope` (`ui/` prefix), whose `A-1` case fails on a
6
+ * single non-ASCII byte. Every `keyword` and `detail` below is English on
7
+ * purpose; translating them would turn the scanner red, and the only escape
8
+ * hatch - adding this file to the exemptions - is the thing that helper's own
9
+ * comment argues against.
10
+ *
11
+ * THIS IS THE ONLY TRUTH ABOUT SHORTCUTS. The drawer, `/keys` and the tests all
12
+ * derive from it, and `shortcut-table.test.ts` holds it in a BIDIRECTIONAL set
13
+ * relation with `HelpOverlay`'s key column (D-8). The reverse direction is the
14
+ * one that matters: without it `Alt+K`, `Esc twice` and `Esc again` could sit in
15
+ * `/help` forever and never appear in the panel whose entire requirement is
16
+ * "list ALL of them".
17
+ *
18
+ * `keyword` IS CAPPED AT 10 CELLS (`SHORTCUT_PANEL_LIMITS.keywordMaxCells`). A
19
+ * longer one is a spec error, not a width problem: at 80 columns the keyword
20
+ * column is 11 cells wide, and the only remaining remedy would be truncating it -
21
+ * which is the exact defect (a column of `promp...`) this table exists to avoid.
22
+ */
23
+ export type ShortcutGroupId = 'compose' | 'run' | 'panels' | 'session' | 'view';
24
+ /**
25
+ * When a row can actually be used. Unavailable rows are STILL LISTED - the user
26
+ * needs to know the key exists - but they render dim and explain themselves in
27
+ * the detail line when selected.
28
+ */
29
+ export type ShortcutAvailability = 'always' | 'idle' | 'running' | 'app' | 'native';
30
+ export interface ShortcutEntry {
31
+ /** Stable id used by tests and by `/keys <query>`. */
32
+ id: string;
33
+ /** The primary binding, spelled in full the way `HelpOverlay` spells it. */
34
+ keys: string;
35
+ /** One to two words, ASCII only, at most 10 columns. The requirement's second column. */
36
+ keyword: string;
37
+ /** One ASCII sentence, shown when the row is selected. */
38
+ detail: string;
39
+ group: ShortcutGroupId;
40
+ availability: ShortcutAvailability;
41
+ /** Extra spellings that must also resolve in `/keys <query>`. */
42
+ aliases?: readonly string[];
43
+ }
44
+ export declare const SHORTCUT_GROUPS: readonly {
45
+ id: ShortcutGroupId;
46
+ title: string;
47
+ }[];
48
+ /**
49
+ * The 31 rows, in reading order. Group order is fixed by `SHORTCUT_GROUPS`, and
50
+ * within a group the order is "how often a user reaches for it", not alphabetical:
51
+ * a reference panel is scanned from the top.
52
+ */
53
+ export declare const SHORTCUTS: readonly ShortcutEntry[];
54
+ /**
55
+ * The longest `keys` string in cells - the input the column derivation needs.
56
+ *
57
+ * DERIVED, NEVER HARD-CODED: v1 fixed the key column at 21 cells, which left six
58
+ * for the keyword at 80 columns and turned `prompt library` into `promp...`. A
59
+ * derived value cannot go stale the day someone adds a longer binding.
60
+ */
61
+ export declare function widestKeysCells(entries: readonly ShortcutEntry[]): number;
62
+ /**
63
+ * Filter for `/keys <query>`: case-insensitive substring over id, keys, keyword
64
+ * and aliases.
65
+ *
66
+ * NO KEYBOARD SEARCH IN THE PANEL ITSELF (§11 F-2): typing would fight `Esc` for
67
+ * "clear the filter or close the panel", and `Esc` is App-owned. The command is
68
+ * the zero-conflict way to reach the same filter.
69
+ */
70
+ export declare function filterShortcuts(entries: readonly ShortcutEntry[], query: string): ShortcutEntry[];
71
+ /**
72
+ * Whether a row can be used right now.
73
+ *
74
+ * `running` MEANS "ONLY WHILE RUNNING", not "unavailable while running" - the two
75
+ * halves of the table would otherwise need two different predicates, which is the
76
+ * kind of asymmetry a later reader gets backwards.
77
+ */
78
+ export declare function isAvailable(entry: ShortcutEntry, context: {
79
+ native: boolean;
80
+ running: boolean;
81
+ }): boolean;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * THE shortcut table (tui-shortcut-drawer-and-task-summary §4.6 / appendix B).
3
+ *
4
+ * ASCII ONLY, AND THAT IS A HARD CONSTRAINT RATHER THAN A STYLE CHOICE: this file
5
+ * is inside `glyphs.test.ts::inScope` (`ui/` prefix), whose `A-1` case fails on a
6
+ * single non-ASCII byte. Every `keyword` and `detail` below is English on
7
+ * purpose; translating them would turn the scanner red, and the only escape
8
+ * hatch - adding this file to the exemptions - is the thing that helper's own
9
+ * comment argues against.
10
+ *
11
+ * THIS IS THE ONLY TRUTH ABOUT SHORTCUTS. The drawer, `/keys` and the tests all
12
+ * derive from it, and `shortcut-table.test.ts` holds it in a BIDIRECTIONAL set
13
+ * relation with `HelpOverlay`'s key column (D-8). The reverse direction is the
14
+ * one that matters: without it `Alt+K`, `Esc twice` and `Esc again` could sit in
15
+ * `/help` forever and never appear in the panel whose entire requirement is
16
+ * "list ALL of them".
17
+ *
18
+ * `keyword` IS CAPPED AT 10 CELLS (`SHORTCUT_PANEL_LIMITS.keywordMaxCells`). A
19
+ * longer one is a spec error, not a width problem: at 80 columns the keyword
20
+ * column is 11 cells wide, and the only remaining remedy would be truncating it -
21
+ * which is the exact defect (a column of `promp...`) this table exists to avoid.
22
+ */
23
+ import { cellWidth } from './layout/cells.js';
24
+ export const SHORTCUT_GROUPS = [
25
+ { id: 'compose', title: 'COMPOSE' },
26
+ { id: 'run', title: 'RUN' },
27
+ { id: 'panels', title: 'PANELS' },
28
+ { id: 'session', title: 'SESSION' },
29
+ { id: 'view', title: 'VIEW' },
30
+ ];
31
+ /**
32
+ * The 31 rows, in reading order. Group order is fixed by `SHORTCUT_GROUPS`, and
33
+ * within a group the order is "how often a user reaches for it", not alphabetical:
34
+ * a reference panel is scanned from the top.
35
+ */
36
+ export const SHORTCUTS = [
37
+ // --- COMPOSE ---------------------------------------------------------------
38
+ {
39
+ id: 'enter', keys: 'Enter', keyword: 'send', group: 'compose', availability: 'always',
40
+ detail: 'Submits when idle; queues as steering while running;'
41
+ + ' re-pins the view to the newest output.',
42
+ },
43
+ {
44
+ id: 'alt-enter', keys: 'Alt+Enter', keyword: 'newline', group: 'compose',
45
+ availability: 'always',
46
+ detail: 'Multiline input; the host may intercept this combination.',
47
+ },
48
+ {
49
+ id: 'shift-enter', keys: 'Shift+Enter', keyword: 'newline', group: 'compose',
50
+ availability: 'always',
51
+ detail: 'Needs keyboard enhancement; /terminal-setup explains how to bind it.',
52
+ },
53
+ {
54
+ id: 'ctrl-j', keys: 'Ctrl+J', keyword: 'newline', group: 'compose', availability: 'always',
55
+ detail: 'Newline fallback that does not depend on keyboard enhancement.',
56
+ },
57
+ {
58
+ id: 'history', keys: 'Up / Down', keyword: 'history', group: 'compose', availability: 'always',
59
+ detail: 'Recalls earlier prompts when the input box is empty.',
60
+ },
61
+ {
62
+ id: 'paste', keys: 'Paste', keyword: 'paste', group: 'compose', availability: 'always',
63
+ detail: 'Up to 6 lines inserted as-is; larger becomes [Pasted text #N]; --no-paste disables.',
64
+ },
65
+ {
66
+ id: 'ctrl-o', keys: 'Ctrl+O', keyword: 'expand', group: 'compose', availability: 'always',
67
+ detail: 'Expands the newest tool output; in native mode it reprints it as a new block.',
68
+ },
69
+ {
70
+ id: 'ctrl-c', keys: 'Ctrl+C', keyword: 'copy/exit', group: 'compose', availability: 'always',
71
+ aliases: ['Ctrl+C (with a selection)'],
72
+ detail: 'Copies a pending selection; with a draft x2 clears and x3 exits;'
73
+ + ' empty input x2 exits.',
74
+ },
75
+ // --- RUN -------------------------------------------------------------------
76
+ {
77
+ id: 'esc', keys: 'Esc', keyword: 'close', group: 'run', availability: 'always',
78
+ detail: 'Closes the open overlay or menu; with nothing open it arms the interrupt ladder.',
79
+ },
80
+ {
81
+ id: 'esc-twice', keys: 'Esc twice', keyword: 'interrupt', group: 'run', availability: 'running',
82
+ aliases: ['Esc again'],
83
+ detail: 'Requests an interrupt within 1.5s; the next press forces the stop.',
84
+ },
85
+ {
86
+ id: 'shift-tab', keys: 'Shift+Tab', keyword: 'plan mode', group: 'run', availability: 'always',
87
+ detail: 'Toggles BUILD and PLAN; writes are refused and the agent only investigates.',
88
+ },
89
+ {
90
+ id: 'ctrl-n', keys: 'Ctrl+N', keyword: 'plan mode', group: 'run', availability: 'always',
91
+ detail: 'The same toggle, for Windows consoles that cannot deliver Shift+Tab.',
92
+ },
93
+ {
94
+ id: 'ctrl-t', keys: 'Ctrl+T', keyword: 'thinking', group: 'run', availability: 'always',
95
+ detail: "Shows or hides the model's thinking (off by default).",
96
+ },
97
+ {
98
+ id: 'ctrl-r', keys: 'Ctrl+R', keyword: 'redraw', group: 'run', availability: 'always',
99
+ detail: 'Repaints the whole frame; use it when the terminal is garbled.',
100
+ },
101
+ // --- PANELS ----------------------------------------------------------------
102
+ {
103
+ id: 'ctrl-g', keys: 'Ctrl+G', keyword: 'all keys', group: 'panels', availability: 'always',
104
+ detail: 'Opens this panel; a second Ctrl+G closes it. Every shortcut is listed here.',
105
+ },
106
+ {
107
+ id: 'ctrl-b', keys: 'Ctrl+B', keyword: 'agents', group: 'panels', availability: 'always',
108
+ detail: 'Read-only subagent board: every child, its facts and its timeline.',
109
+ },
110
+ {
111
+ id: 'ctrl-p', keys: 'Ctrl+P', keyword: 'prompts', group: 'panels', availability: 'always',
112
+ detail: 'Quick prompt library: pick, edit, reorder.',
113
+ },
114
+ {
115
+ id: 'ctrl-k', keys: 'Ctrl+K', keyword: 'kits', group: 'panels', availability: 'always',
116
+ detail: 'Switch the main / fast / subagent model kit for this session.',
117
+ },
118
+ {
119
+ id: 'ctrl-i', keys: 'Ctrl+I', keyword: 'index', group: 'panels', availability: 'idle',
120
+ detail: 'Unavailable while running; asks first, then runs the project-indexer skill.',
121
+ },
122
+ {
123
+ id: 'alt-k', keys: 'Alt+K', keyword: 'kill line', group: 'panels', availability: 'always',
124
+ detail: 'Deletes from the cursor to the end of the line'
125
+ + ' (this was Ctrl+K before the kit picker).',
126
+ },
127
+ {
128
+ id: 'help', keys: '/help', keyword: 'reference', group: 'panels', availability: 'always',
129
+ detail: 'Full reference: keys, slash commands, plan mode, team, skills.',
130
+ },
131
+ // --- SESSION ---------------------------------------------------------------
132
+ {
133
+ id: 'ctrl-l', keys: 'Ctrl+L', keyword: 'branch', group: 'session', availability: 'always',
134
+ detail: 'Opens this session in a new terminal window with its own session id.',
135
+ },
136
+ {
137
+ id: 'clear', keys: '/clear', keyword: 'new task', group: 'session', availability: 'always',
138
+ detail: 'Archives the old conversation, clears the context;'
139
+ + ' the task summary is cleared with it.',
140
+ },
141
+ {
142
+ id: 'resume', keys: '/resume', keyword: 'switch', group: 'session', availability: 'always',
143
+ detail: 'Loads a saved session; the current session moves to a new id.',
144
+ },
145
+ {
146
+ id: 'session', keys: '/session', keyword: 'identity', group: 'session', availability: 'always',
147
+ detail: 'Shows the session id and file, copies the resume command, pins the id.',
148
+ },
149
+ // --- VIEW ------------------------------------------------------------------
150
+ {
151
+ id: 'page', keys: 'PgUp / PgDn', keyword: 'page', group: 'view', availability: 'always',
152
+ detail: 'Pages the transcript, or the overlay that currently owns the keyboard.',
153
+ },
154
+ {
155
+ id: 'wheel', keys: 'Wheel', keyword: 'scroll', group: 'view', availability: 'app',
156
+ detail: 'Scrolls the transcript in app mode; in native mode the terminal owns this.',
157
+ },
158
+ {
159
+ id: 'shift-wheel', keys: 'Shift+Wheel', keyword: 'page', group: 'view', availability: 'app',
160
+ detail: 'Scrolls a whole page.',
161
+ },
162
+ {
163
+ id: 'shift-arrows', keys: 'Shift+Up / Shift+Down', keyword: 'line', group: 'view',
164
+ availability: 'app',
165
+ detail: 'Scrolls one line; native mode leaves this to the terminal.',
166
+ },
167
+ {
168
+ id: 'drag-edge', keys: 'Drag right edge', keyword: 'scrollbar', group: 'view',
169
+ availability: 'app',
170
+ detail: 'Drags the right-edge scrollbar; the input stays fixed.',
171
+ },
172
+ {
173
+ id: 'drag-text', keys: 'Drag text (left button)', keyword: 'select', group: 'view',
174
+ availability: 'app',
175
+ detail: 'Drags to select on-screen text; the highlight waits for a copy.',
176
+ },
177
+ ];
178
+ /**
179
+ * The longest `keys` string in cells - the input the column derivation needs.
180
+ *
181
+ * DERIVED, NEVER HARD-CODED: v1 fixed the key column at 21 cells, which left six
182
+ * for the keyword at 80 columns and turned `prompt library` into `promp...`. A
183
+ * derived value cannot go stale the day someone adds a longer binding.
184
+ */
185
+ export function widestKeysCells(entries) {
186
+ return entries.reduce((widest, entry) => Math.max(widest, cellWidth(entry.keys)), 0);
187
+ }
188
+ /**
189
+ * Filter for `/keys <query>`: case-insensitive substring over id, keys, keyword
190
+ * and aliases.
191
+ *
192
+ * NO KEYBOARD SEARCH IN THE PANEL ITSELF (§11 F-2): typing would fight `Esc` for
193
+ * "clear the filter or close the panel", and `Esc` is App-owned. The command is
194
+ * the zero-conflict way to reach the same filter.
195
+ */
196
+ export function filterShortcuts(entries, query) {
197
+ const needle = query.trim().toLowerCase();
198
+ if (needle.length === 0)
199
+ return [...entries];
200
+ return entries.filter((entry) => {
201
+ const haystack = [entry.id, entry.keys, entry.keyword, ...(entry.aliases ?? [])];
202
+ return haystack.some((field) => field.toLowerCase().includes(needle));
203
+ });
204
+ }
205
+ /**
206
+ * Whether a row can be used right now.
207
+ *
208
+ * `running` MEANS "ONLY WHILE RUNNING", not "unavailable while running" - the two
209
+ * halves of the table would otherwise need two different predicates, which is the
210
+ * kind of asymmetry a later reader gets backwards.
211
+ */
212
+ export function isAvailable(entry, context) {
213
+ switch (entry.availability) {
214
+ case 'always': return true;
215
+ case 'idle': return !context.running;
216
+ case 'running': return context.running;
217
+ case 'app': return !context.native;
218
+ case 'native': return context.native;
219
+ default: return true;
220
+ }
221
+ }