@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
package/dist/ui/App.js CHANGED
@@ -16,13 +16,13 @@ import { computeCost } from '../agent/usage.js';
16
16
  import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
17
17
  import { updatePersistedConfig } from '../config/store.js';
18
18
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
19
- import { bumpSubmitCount, setMouseNoticeVersion, setVtInputNoticeVersion, } from '../config/ui-state.js';
19
+ import { bumpSubmitCount, setMouseNoticeVersion, setScrollModeNoticeVersion, setVtInputNoticeVersion, } from '../config/ui-state.js';
20
20
  import { getLogger } from '../logging/logger.js';
21
21
  import { isTerminalStatus } from '../proc/types.js';
22
22
  import { getSecrets } from '../logging/secret-registry.js';
23
23
  import { redactText } from '../logging/redact.js';
24
24
  import { resolveTuiCapabilities } from './capabilities.js';
25
- import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
25
+ import { useStartupNotices, MOUSE_NOTICE_VERSION, SCROLL_MODE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
26
26
  import { getTheme } from './theme.js';
27
27
  import { pickGlyphs } from './glyphs.js';
28
28
  import { Header } from './Header.js';
@@ -38,6 +38,9 @@ import { BottomStatusRow } from './BottomStatusRow.js';
38
38
  // Only the updater's pure visibility predicate enters the status detail plan.
39
39
  import { shouldRenderUpdateLine } from '../update/types.js';
40
40
  import { AppShell } from './layout/AppShell.js';
41
+ import { ScrollbackShell } from './ScrollbackShell.js';
42
+ import { useScrollbackView } from './scrollback/use-scrollback-view.js';
43
+ import { buildExitSummary } from './scrollback/exit-summary.js';
41
44
  import { ScrollViewport } from './layout/ScrollViewport.js';
42
45
  import { buildTodoRailLayout } from './layout/todo-layout.js';
43
46
  import { buildTeamPanelLayout } from './layout/team-panel.js';
@@ -46,14 +49,14 @@ import { useTerminalSize } from './layout/useTerminalSize.js';
46
49
  import { useHeightStore } from './use-height-store.js';
47
50
  import { useRenderGovernor } from './use-render-governor.js';
48
51
  import { readScrollbarPerf, setPerfResetHook, setPerfSnapshotProvider, } from '../commands/perf.js';
49
- import { buildFrameBudget } from './layout/budget.js';
50
- import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
52
+ import { buildFrameBudget, POPUP_CAPACITY_SHARE } from './layout/budget.js';
53
+ import { useWheelRouting } from './use-wheel-routing.js';
51
54
  import { INDEX_KEY_FRAME } from '../input/limits.js';
52
55
  import { INDEX_SKILL_NAME, buildIndexConfirmSummary, hasProjectIndex, indexBuildInvocationArgs, } from '../commands/index-build.js';
53
56
  import { installConsoleBridge } from './console-bridge.js';
54
57
  import { publishExitSnapshot } from './exit-snapshot.js';
55
58
  import { publishSessionView } from './session-bridge.js';
56
- import { OverlayFrame } from './layout/OverlayFrame.js';
59
+ import { OverlayFrame, overlayPageSize } from './layout/OverlayFrame.js';
57
60
  import { helpRows } from './overlays/HelpOverlay.js';
58
61
  import { ModelPicker } from './overlays/ModelPicker.js';
59
62
  import { createModelProbe } from '../agent/model-probe.js';
@@ -132,7 +135,11 @@ function seedViewState(seed) {
132
135
  ? viewReducer(base, { type: 'restoreEntries', entries: seed.entries })
133
136
  : base;
134
137
  }
135
- export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, initialEntries, session, sessionBridge, sessionNotice, }) {
138
+ export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, scrollMode = 'app', scrollback, initialEntries, session, sessionBridge, sessionNotice, }) {
139
+ const native = scrollMode === 'native' && scrollback !== undefined;
140
+ /** Set when the native writer swallowed repeated tall frames; cleared by a resize. */
141
+ const [guardTripped, setGuardTripped] = useState(false);
142
+ const scrollbackViewRef = useRef(null);
136
143
  const { exit } = useApp();
137
144
  const { stdout } = useStdout();
138
145
  const size = useTerminalSize();
@@ -148,7 +155,24 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
148
155
  const [statusExpanded, setStatusExpanded] = useState(false);
149
156
  const dimensions = buildFrameBudget({ ...size, draftRows, popupRows, statusExpanded });
150
157
  const { rows, cols } = dimensions;
151
- const tooSmall = dimensions.inactive;
158
+ /**
159
+ * Rows one PgUp/PgDn - and one Shift+wheel notch - turns inside a controlled
160
+ * overlay (defect 11).
161
+ *
162
+ * ONE VALUE FOR BOTH BRANCHES, derived from the overlay's real window: a
163
+ * constant 8 was 42% of `/help`'s 19-row window, so a shortcut list needed
164
+ * twelve keystrokes and overlapped itself by eleven rows on every turn. The
165
+ * wheel branch gets it through `getOverlayPage`, so the two can never disagree.
166
+ *
167
+ * MEASURED FROM THIS BUDGET, NOT `frameBudget` BELOW. The two agree on every
168
+ * frame except the one that opens an overlay with the completion menu still
169
+ * reported (`frameBudget` zeroes `popupRows` there), and that exception only
170
+ * makes the real window LARGER - so this page can under-turn for one frame but
171
+ * can never step past the window and skip content. The unsafe direction is the
172
+ * one that matters, and it is unreachable.
173
+ */
174
+ const overlayPage = overlayPageSize(dimensions.viewportRows + (native ? 1 : 0), cols);
175
+ const tooSmall = dimensions.inactive || (native && guardTripped);
152
176
  // --- Render governor (tui-render-performance L4). ------------------------
153
177
  //
154
178
  // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
@@ -1037,6 +1061,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1037
1061
  nodeVersion: vtInputWarning.nodeVersion,
1038
1062
  onSeen: () => setVtInputNoticeVersion(VT_INPUT_NOTICE_VERSION),
1039
1063
  }
1064
+ : undefined, native
1065
+ ? { onSeen: () => setScrollModeNoticeVersion(SCROLL_MODE_NOTICE_VERSION) }
1040
1066
  : undefined);
1041
1067
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
1042
1068
  useEffect(() => {
@@ -1474,6 +1500,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1474
1500
  ...(sessionBridge?.branch ? { branch: sessionBridge.branch } : {}),
1475
1501
  dispatch: (action) => {
1476
1502
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1503
+ scrollbackViewRef.current?.notifyReset(action.type === 'resetConversation' ? 'clear' : 'restore');
1477
1504
  metrics.current = createRunMetrics();
1478
1505
  manualMetrics.current = createRunMetrics();
1479
1506
  setScrollResetKey((n) => n + 1);
@@ -1524,6 +1551,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1524
1551
  }
1525
1552
  : {}),
1526
1553
  ...(terminal?.writeForeign ? { writeForeign: terminal.writeForeign } : {}),
1554
+ ...(native ? { nativeScroll: true } : {}),
1527
1555
  });
1528
1556
  /**
1529
1557
  * Submitting a prompt no longer touches `config.json` at all — it appends one
@@ -1764,6 +1792,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1764
1792
  isPointerCaptured: () => terminal?.scrollbar?.controller?.isCaptured() ?? false,
1765
1793
  isActive: () => !tooSmall,
1766
1794
  getOverlay: () => stateRef.current.overlay,
1795
+ getOverlayPage: () => overlayPage,
1767
1796
  onScroll: scrollBy,
1768
1797
  onOverlayScroll: (delta) => setOverlayScroll((n) => Math.max(0, n + delta)),
1769
1798
  });
@@ -1975,12 +2004,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1975
2004
  const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan' || overlay === 'queue';
1976
2005
  if (key.pageUp) {
1977
2006
  if (controlled)
1978
- setOverlayScroll((n) => Math.max(0, n - OVERLAY_PAGE));
2007
+ setOverlayScroll((n) => Math.max(0, n - overlayPage));
1979
2008
  return;
1980
2009
  }
1981
2010
  if (key.pageDown) {
1982
2011
  if (controlled)
1983
- setOverlayScroll((n) => n + OVERLAY_PAGE);
2012
+ setOverlayScroll((n) => n + overlayPage);
1984
2013
  return;
1985
2014
  }
1986
2015
  // `settings` keeps Up/Down for field movement — a high-frequency action
@@ -2024,7 +2053,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2024
2053
  setRedrawNonce((n) => n + 1);
2025
2054
  return;
2026
2055
  }
2027
- {
2056
+ // Native mode has no scrollable viewport: the terminal owns scrolling, so these
2057
+ // keys are left alone. An open overlay is application content and keeps them.
2058
+ if (!native || stateRef.current.overlay) {
2028
2059
  if (key.pageUp) {
2029
2060
  scrollBy('pageUp');
2030
2061
  return;
@@ -2045,10 +2076,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2045
2076
  if (key.ctrl && (input === 't' || input === 'T')) {
2046
2077
  const revealing = !stateRef.current.thinkingVisible;
2047
2078
  dispatch({ type: 'toggleThinking' });
2079
+ // Printed history never changes, so say what the toggle actually reaches.
2080
+ const scope = native ? ' Applies to new messages.' : '';
2048
2081
  if (!revealing)
2049
- toast('info', 'Thinking hidden.');
2082
+ toast('info', `Thinking hidden.${scope}`);
2050
2083
  else
2051
- toast('info', 'Thinking shown.');
2084
+ toast('info', `Thinking shown.${scope}`);
2052
2085
  return;
2053
2086
  }
2054
2087
  if (key.ctrl && (input === 'o' || input === 'O')) {
@@ -2072,7 +2105,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2072
2105
  e.kind === 'team' ||
2073
2106
  e.kind === 'compaction' ||
2074
2107
  (e.kind === 'service' && !e.terminal));
2075
- if (last)
2108
+ // Native mode cannot rewrite what is already printed, so it PRINTS an expanded
2109
+ // copy as a new block instead of toggling the old one (spec 4.7).
2110
+ if (last && native) {
2111
+ if (!scrollbackViewRef.current?.reprintExpanded(last.id))
2112
+ toast('info', 'Nothing to expand.');
2113
+ }
2114
+ else if (last)
2076
2115
  dispatch({ type: 'toggleExpand', id: last.id });
2077
2116
  // WIDENED FROM `No tool output to expand.`: with three expandable kinds the
2078
2117
  // old wording would be wrong in two of the three empty cases.
@@ -2225,7 +2264,6 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2225
2264
  const frameBudget = buildFrameBudget({ rows, cols, draftRows,
2226
2265
  popupRows: overlay ? 0 : popupRows, statusExpanded });
2227
2266
  const viewportBudget = frameBudget.viewportRows;
2228
- const overlayMaxRows = viewportBudget;
2229
2267
  const composerBaseRows = 0;
2230
2268
  const baseRail = buildTodoRailLayout({
2231
2269
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
@@ -2245,9 +2283,49 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2245
2283
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
2246
2284
  teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows: 0, composerBaseRows,
2247
2285
  });
2248
- const { visible: showRail, width: railWidth, rows: railRows, contentCols } = railLayout;
2286
+ const { visible: railVisible, width: railWidth, rows: railRows, contentCols } = railLayout;
2287
+ // No fixed frame means no side rail in native mode (N4); the status chip carries it.
2288
+ const showRail = railVisible && !native;
2249
2289
  const transcriptEntries = useMemo(() => selectTranscriptEntries(state.entries, state.pendingSteering), [state.entries, state.pendingSteering]);
2250
- const overlayNode = overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: cols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { isActive: !tooSmall, initial: {
2290
+ // --- Native scroll (tui-native-terminal-scroll). -----------------------------
2291
+ //
2292
+ // ALWAYS CALLED, INERT WHEN `enabled` IS FALSE: hooks cannot be conditional, and
2293
+ // an inert hook is what keeps `scrollMode: 'app'` byte-identical (I-S9).
2294
+ const viewCols = native ? Math.max(1, cols - 1) : cols;
2295
+ const header = (_jsx(Header, { columns: viewCols, statusExpanded: statusExpanded, scrolledLines: scrolledLines, overlayOpen: overlay !== null, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
2296
+ // The wordmark + getting-started card now live INSIDE the viewport, as its
2297
+ // first block of content, so they scroll away instead of being yanked out of
2298
+ // the header the moment the first message lands.
2299
+ const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(native ? Math.max(0, rows - 8) : viewportBudget, native ? viewCols : contentCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: native ? Math.max(0, rows - 8) : viewportBudget, theme: theme, caps: caps })) : null;
2300
+ const scrollbackView = useScrollbackView({
2301
+ enabled: native, bridge: scrollback, entries: state.entries, runActive: running,
2302
+ theme, caps, density: cfg.density, thinkingVisible: state.thinkingVisible,
2303
+ reducedMotion, rows, cols, headerNode: header, openerNode: opener,
2304
+ overlayOpen: overlay !== null, draftRows, popupRows: overlay ? 0 : popupRows,
2305
+ statusExpanded, forceInactive: native && guardTripped,
2306
+ });
2307
+ scrollbackViewRef.current = scrollbackView;
2308
+ // The two hooks `cli.tsx` calls from OUTSIDE React: the exit summary (every exit
2309
+ // path, some of which run after this tree is gone, so it reads refs, never state)
2310
+ // and the writer's tall-frame guard. A resize clears the degraded state.
2311
+ useLayoutEffect(() => {
2312
+ if (!native || !scrollback)
2313
+ return undefined;
2314
+ scrollback.getSummaryRows = () => {
2315
+ const live = controller.getConfig();
2316
+ return buildExitSummary({
2317
+ entries: stateRef.current.entries, usageTotal: stateRef.current.usageTotal,
2318
+ provider: live.provider, model: live.model, elapsedMs: Date.now() - startedAt.current,
2319
+ ...(identityRef.current ? { sessionId: identityRef.current.id } : {}), glyphs,
2320
+ });
2321
+ };
2322
+ scrollback.onGuardTrip = () => setGuardTripped(true);
2323
+ return () => { scrollback.onGuardTrip = null; };
2324
+ }, [native, scrollback, controller, glyphs]);
2325
+ useEffect(() => { setGuardTripped(false); }, [rows, cols]);
2326
+ const nativeBudget = native ? scrollbackView.budget : null;
2327
+ const overlayMaxRows = nativeBudget ? nativeBudget.overlayRows : viewportBudget;
2328
+ const overlayNode = overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: viewCols, rows: helpRows(theme, caps, native), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { isActive: !tooSmall, initial: {
2251
2329
  provider: cfg.provider,
2252
2330
  model: cfg.model,
2253
2331
  baseUrl: cfg.baseUrl ?? '',
@@ -2281,15 +2359,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2281
2359
  // the save handler cannot disagree about what `(inherit)` means.
2282
2360
  ...fastSettingsFrom(cfg.fast),
2283
2361
  ...compactionSettingsFrom(cfg.compaction),
2284
- }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
2362
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: viewCols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
2285
2363
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
2286
2364
  // while the screen is open shows the new value on the next paint.
2287
- retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { isActive: !tooSmall, state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { isActive: !tooSmall, questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { isActive: !tooSmall, plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
2288
- const header = (_jsx(Header, { columns: cols, statusExpanded: statusExpanded, scrolledLines: scrolledLines, overlayOpen: overlay !== null, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
2289
- // The wordmark + getting-started card now live INSIDE the viewport, as its
2290
- // first block of content, so they scroll away instead of being yanked out of
2291
- // the header the moment the first message lands.
2292
- const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(viewportBudget, contentCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: viewportBudget, theme: theme, caps: caps })) : null;
2365
+ retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { isActive: !tooSmall, state: confirmState, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { isActive: !tooSmall, questions: humanRequest.questions, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { isActive: !tooSmall, plan: humanRequest.plan, maxRows: overlayMaxRows, cols: viewCols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
2293
2366
  const density = cfg.density;
2294
2367
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
2295
2368
  // The fixed status bar remains visible underneath every overlay and toast.
@@ -2308,8 +2381,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2308
2381
  heightsMeasured: heights.stats().measured,
2309
2382
  heightsEstimated: heights.stats().estimated,
2310
2383
  cols: contentCols,
2311
- mode: 'fullscreen',
2312
- viewportRows: viewportBudget,
2384
+ mode: native ? 'native' : 'fullscreen',
2385
+ viewportRows: nativeBudget ? nativeBudget.liveRows : viewportBudget,
2313
2386
  offset: scrolledLines,
2314
2387
  // A getter: the bridge's `frameReady` and geometry change outside React renders.
2315
2388
  get scrollbar() {
@@ -2319,7 +2392,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2319
2392
  },
2320
2393
  };
2321
2394
  // Completion shares the fixed editor slot and its frame budget.
2322
- const popupMaxRows = 6;
2395
+ //
2396
+ // THE FRAME BUDGET, NOT A LITERAL 6 (defect 10), BUT NOT ALL OF IT EITHER.
2397
+ // `popupMaxHeight` is the capacity `buildFrameBudget` already computed, and a
2398
+ // second, smaller constant here is what made 57 commands browsable six at a time
2399
+ // on a 30-row terminal. Handing over the WHOLE capacity is wrong in the other
2400
+ // direction: `popupCapacity` reserves exactly one message row, so at 20 rows it
2401
+ // grants 13, the transcript collapses to a single row and the TODO rail
2402
+ // disappears - measured, `todo-app-layout.test.tsx` catches it. Half the granted
2403
+ // capacity keeps the menu growing with the terminal (6 rows at 20, 11 at 30, 16
2404
+ // at 40) while the conversation and the rail keep theirs.
2405
+ const popupMaxRows = Math.floor(frameBudget.popupMaxHeight / POPUP_CAPACITY_SHARE);
2323
2406
  // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
2324
2407
  // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
2325
2408
  // because that prop is already the single answer to "can this console deliver
@@ -2348,7 +2431,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2348
2431
  level: latestToast.level === 'success' ? 'info' : latestToast.level } : undefined,
2349
2432
  });
2350
2433
  const statusInput = {
2351
- columns: cols, phase: state.runPhase, pendingCount: state.pendingSteering.length,
2434
+ columns: viewCols, phase: state.runPhase, pendingCount: state.pendingSteering.length,
2352
2435
  context: state.context, usageTotal: state.usageTotal, thinkingLevel: cfg.thinkingLevel,
2353
2436
  elapsedMs, tokPerSec, speedKnown, activeTool: state.activeTool, runOutcome: state.runOutcome,
2354
2437
  waitingForConfirmation: !!humanRequest || !!confirmState,
@@ -2364,7 +2447,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2364
2447
  ...feedbackProjection,
2365
2448
  };
2366
2449
  const details = planStatusDetail({ status: statusInput, model: cfg.model, provider: cfg.provider,
2367
- hints: { interactionPhase: interactionPhase.current, cols, completion, overlay,
2450
+ hints: { interactionPhase: interactionPhase.current, cols: viewCols, completion, overlay,
2368
2451
  selectionPending: selectionController?.hasPendingSelection() ?? false,
2369
2452
  copyInFlight: copyState.busy, copyCleanupPending: copyState.cleanupPending,
2370
2453
  hasDraft: hasDraftRef.current, services: liveServices,
@@ -2375,43 +2458,48 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2375
2458
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2376
2459
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2377
2460
  const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme })] }), rail: rail, scrollbar: terminal?.scrollbar, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, onGeometryChange: onGeometryChange, geometryKey: `${rows}:${cols}:${viewportBudget}:${frameBudget.composerSlotRows}:${frameBudget.statusRows}:${contentCols}:${overlay}:${tooSmall}`, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: transcriptEntries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2378
- return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: _jsx(BottomStatusRow, { plan: details, columns: cols, theme: theme }), status: _jsx(StatusBar, { columns: cols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2379
- ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2380
- // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2381
- // projection of "many sources of truth" into the props layer, and a
2382
- // caller could update three of them; a single reading cannot be
2383
- // partially stale. The window and its `known` flag now travel INSIDE
2384
- // it, resolved by the meter - which is what makes the user's
2385
- // `contextWindow` override reach the bar.
2386
- context: state.context, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
2387
- ? {
2388
- teamActive: {
2389
- running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
2390
- r.phase !== 'failed' && r.phase !== 'aborted').length,
2391
- total: state.team.runs.length,
2392
- },
2393
- }
2394
- : {}), ...(state.todos && !showRail
2395
- ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
2396
- : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}),
2397
- // Manual compaction starts before its next snapshot is published.
2398
- compactionActive: { inFlight: statusInput.compacting ?? false }, ...(controller.isCompactionEnabled()
2399
- ? {
2400
- gaugeMarks: {
2401
- warn: cfg.compaction.warnThreshold * 100,
2402
- high: cfg.compaction.threshold * 100,
2403
- },
2404
- }
2405
- : {}), ...(state.retry
2406
- ? {
2407
- retryActive: {
2408
- attempt: state.retry.attempt,
2409
- max: state.retry.maxRetries,
2410
- // Recomputed at RENDER from the absolute `resumeAt`, which is
2411
- // what keeps the chip and the transcript card showing the same
2412
- // number without either of them owning a second clock.
2413
- secondsLeft: secondsLeft(state.retry.resumeAt, Date.now()),
2414
- },
2415
- }
2416
- : {}) }) }));
2461
+ const detailsNode = _jsx(BottomStatusRow, { plan: details, columns: viewCols, theme: theme });
2462
+ const statusNode = (_jsx(StatusBar, { columns: viewCols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2463
+ ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2464
+ // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2465
+ // projection of "many sources of truth" into the props layer, and a
2466
+ // caller could update three of them; a single reading cannot be
2467
+ // partially stale. The window and its `known` flag now travel INSIDE
2468
+ // it, resolved by the meter - which is what makes the user's
2469
+ // `contextWindow` override reach the bar.
2470
+ context: state.context, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
2471
+ ? {
2472
+ teamActive: {
2473
+ running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
2474
+ r.phase !== 'failed' && r.phase !== 'aborted').length,
2475
+ total: state.team.runs.length,
2476
+ },
2477
+ }
2478
+ : {}), ...(state.todos && !showRail
2479
+ ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
2480
+ : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}),
2481
+ // Manual compaction starts before its next snapshot is published.
2482
+ compactionActive: { inFlight: statusInput.compacting ?? false }, ...(controller.isCompactionEnabled()
2483
+ ? {
2484
+ gaugeMarks: {
2485
+ warn: cfg.compaction.warnThreshold * 100,
2486
+ high: cfg.compaction.threshold * 100,
2487
+ },
2488
+ }
2489
+ : {}), ...(state.retry
2490
+ ? {
2491
+ retryActive: {
2492
+ attempt: state.retry.attempt,
2493
+ max: state.retry.maxRetries,
2494
+ // Recomputed at RENDER from the absolute `resumeAt`, which is
2495
+ // what keeps the chip and the transcript card showing the same
2496
+ // number without either of them owning a second clock.
2497
+ secondsLeft: secondsLeft(state.retry.resumeAt, Date.now()),
2498
+ },
2499
+ }
2500
+ : {}) }));
2501
+ if (nativeBudget) {
2502
+ return (_jsx(ScrollbackShell, { budget: nativeBudget, overlay: overlayNode, liveTailLines: scrollbackView.liveTailLines, composer: composer, status: statusNode, details: detailsNode, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), theme: theme }));
2503
+ }
2504
+ return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: detailsNode, status: statusNode }));
2417
2505
  }
@@ -12,6 +12,6 @@ export function AutocompletePopup({ items, selected, theme, caps, maxRows, layou
12
12
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.border, paddingX: 1, children: [visible.map((item, i) => {
13
13
  const idx = windowStart + i;
14
14
  const active = idx === selected;
15
- return (_jsxs(Text, { inverse: active, wrap: "truncate", children: [_jsx(Text, { color: active ? undefined : theme.primary, children: item.label.replace(/\s+/g, ' ') }), item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint.replace(/\s+/g, ' ')] }) : null] }, idx));
15
+ return (_jsxs(Text, { inverse: active, wrap: "truncate", children: [_jsx(Text, { color: active ? theme.accent : theme.muted, children: active ? `${glyphs.caret} ` : ' ' }), _jsx(Text, { color: active ? undefined : theme.primary, children: item.label.replace(/\s+/g, ' ') }), item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint.replace(/\s+/g, ' ')] }) : null] }, idx));
16
16
  }), showMore && _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [" +", moreBelow, " more"] })] }));
17
17
  }
package/dist/ui/Header.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { basename } from 'node:path';
3
3
  import { Box, Text } from 'ink';
4
4
  import { Logo } from './Logo.js';
5
- import { planHeaderHints } from './layout/header-hints.js';
5
+ import { planHeaderHints, HEADER_HINT_LIMITS } from './layout/header-hints.js';
6
6
  export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false }) {
7
7
  // The right-hand hints are planned, not assembled (tui-branch-terminal-shortcut
8
8
  // 3.9 / D-11). `plan.text` is exactly `plan.cells` wide and already carries the
@@ -10,5 +10,10 @@ export function Header({ cwd, provider, model, hasKey, variant, theme, caps, col
10
10
  // hint by guessing one cell short - and the ladder that decides which hints
11
11
  // survive a narrow terminal lives in a pure function with its own tests.
12
12
  const plan = planHeaderHints({ columns, statusExpanded, overlayOpen, scrolledLines });
13
- return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - plan.cells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
13
+ // THE TWO HALVES ARE SEPARATED BY CELLS, NOT BY LUCK (defect 12). The left half
14
+ // is `wrap="truncate"`, which draws its ellipsis in the LAST cell it owns; with
15
+ // the right half starting in the very next cell a narrow terminal read
16
+ // `aragon...Details off` as one string. The blank column comes out of the left
17
+ // half's width, so the row still spans exactly `columns`.
18
+ return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - plan.cells - HEADER_HINT_LIMITS.separatorCells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: HEADER_HINT_LIMITS.separatorCells, flexShrink: 0 }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
14
19
  }
@@ -16,22 +16,49 @@ function renderBlock(block, key, theme, glyphs) {
16
16
  case 'code':
17
17
  return (_jsx(CodeBlock, { code: block.code, lang: block.lang, theme: theme, glyphs: glyphs }, `b${key}`));
18
18
  case 'table': {
19
- const formatted = formatTable(block.rows, block.align);
20
- return (_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: r === 0 ? theme.accent : undefined, children: row }, r))) }, `b${key}`));
19
+ // Glyphs are injected rather than hardcoded: this module is inside the
20
+ // ASCII-only scan, and the pure parser defaults to ASCII `|` / `-`.
21
+ const formatted = formatTable(block.rows, block.align, {
22
+ separator: glyphs.railVertical,
23
+ rule: glyphs.hrule,
24
+ });
25
+ return (_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: tableRowColor(r, theme), children: row }, r))) }, `b${key}`));
21
26
  }
22
27
  default:
23
28
  return _jsx(InlineLine, { line: block.text, theme: theme, glyphs: glyphs }, `b${key}`);
24
29
  }
25
30
  }
31
+ /**
32
+ * Table row 0 is the header (bold + accent); row 1 is the divider drawn under it.
33
+ *
34
+ * The divider must stay distinguishable at colour level 0, where bold is dropped
35
+ * to nothing, so it is MUTED and never bold: in `--no-color` the `----` run is
36
+ * itself the signal, and in a coloured tier the weight difference separates the
37
+ * header from the rule beneath it.
38
+ */
39
+ function tableRowColor(index, theme) {
40
+ if (index === 0)
41
+ return theme.accent;
42
+ if (index === 1)
43
+ return theme.muted;
44
+ return undefined;
45
+ }
26
46
  /**
27
47
  * Fenced code. The round border is gone (§4.3): nested inside a tool card it
28
48
  * produced a box inside a box and squeezed an 80-column terminal down to 74
29
49
  * usable columns. A left rail plus the language tag conveys the same "this is
30
50
  * code" boundary for 1 column and 0 rows.
51
+ *
52
+ * The language tag is a muted TITLE, not a body row: it used to carry a chip
53
+ * background and one padding space either side of the text, so it rendered as
54
+ * ` | ts` above code rows that start at `| export ...` — a body line that
55
+ * happened to be the language. It keeps its own row on purpose: the height
56
+ * estimator (`virtual-window.estimateEntryRows`) counts RAW markdown lines and
57
+ * has no code-block branch, so the fence line must still occupy exactly 1 row.
31
58
  */
32
59
  function CodeBlock({ code, lang, theme, glyphs, }) {
33
60
  const rendered = highlightCached(code, lang);
34
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [lang.length > 0 && (_jsx(Text, { color: theme.chip.fg, backgroundColor: theme.chip.bg, children: ` ${lang} ` })), _jsx(Text, { children: rendered })] }));
61
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [lang.length > 0 && (_jsx(Text, { bold: true, color: theme.muted, children: lang })), _jsx(Text, { children: rendered })] }));
35
62
  }
36
63
  /** Six heading levels used to render identically; now they carry three weights. */
37
64
  function headingStyle(level, theme) {
@@ -234,6 +234,31 @@ export function fileTokenAt(buffer, cursor) {
234
234
  end += 1;
235
235
  return { start, end, query: buffer.slice(start + 1, end) };
236
236
  }
237
+ /**
238
+ * How many `@file` candidates reach the popup.
239
+ *
240
+ * The popup has its own row budget; this is the list it windows. Twenty ranked
241
+ * paths are enough to spot the one you meant, and it bounds the array that every
242
+ * draft edit re-renders.
243
+ */
244
+ const FILE_MATCH_LIMIT = 20;
245
+ /**
246
+ * Segments in a path - the first ranking key for an EMPTY `@` query (defect 10).
247
+ *
248
+ * A bare `@` matches every file in the tree, and `tinyGlob` returns them in
249
+ * whatever order the filesystem walked, so opening the popup without a rank gave
250
+ * a correct-but-arbitrary twenty. Shallowest first, then shortest, then
251
+ * alphabetical: the paths a user is reaching for (`src/`, a root config) come
252
+ * before `packages/cli/src/ui/layout/deep/thing.ts`.
253
+ */
254
+ function pathDepth(path) {
255
+ let depth = 1;
256
+ for (const char of path) {
257
+ if (char === '/' || char === '\\')
258
+ depth += 1;
259
+ }
260
+ return depth;
261
+ }
237
262
  /** A leading C0 control byte or ESC sequence should never be inserted raw. */
238
263
  function isControlSeq(input) {
239
264
  if (input.length === 0)
@@ -253,7 +278,11 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
253
278
  const slashSug = dismissed ? null : slashSuggestions(buffer, commands);
254
279
  const fileTok = fileTokenAt(buffer, cursor);
255
280
  const fileQuery = fileTok ? fileTok.query : null;
256
- const fileActive = !dismissed && !!fileTok && fileTok.query.length >= 1 && fileMatches.length > 0;
281
+ // A BARE `@` OPENS THE POPUP (defect 10). The `query.length >= 1` gate here and
282
+ // the `< 1` early return in the scan effect below were the whole reason a lone
283
+ // `@` showed nothing while `README.md` promises "@ opens a file-path completion
284
+ // popup". An empty query is a legitimate one: it lists the shallowest paths.
285
+ const fileActive = !dismissed && !!fileTok && fileMatches.length > 0;
257
286
  const popupKind = slashSug && slashSug.length > 0 ? 'slash' : fileActive ? 'file' : null;
258
287
  const popupItems = popupKind === 'slash'
259
288
  ? slashSug
@@ -263,7 +292,7 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
263
292
  const clampedSel = popupItems.length > 0 ? Math.min(sel, popupItems.length - 1) : 0;
264
293
  // --- Debounced `@file` scan. ---------------------------------------------
265
294
  useEffect(() => {
266
- if (fileQuery === null || fileQuery.length < 1) {
295
+ if (fileQuery === null) {
267
296
  setFileMatches([]);
268
297
  return;
269
298
  }
@@ -279,7 +308,16 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
279
308
  if (cancelled)
280
309
  return;
281
310
  const q = fileQuery.toLowerCase();
282
- setFileMatches(matches.filter((m) => m.toLowerCase().includes(q)).slice(0, 20));
311
+ // RANKED, NOT WALK ORDER (defect 10). An empty query matches every path
312
+ // in the tree, so without a sort the popup for a bare `@` was
313
+ // correct-but-arbitrary - the first twenty the filesystem happened to
314
+ // hand back.
315
+ const ranked = matches
316
+ .filter((m) => m.toLowerCase().includes(q))
317
+ .sort((a, b) => pathDepth(a) - pathDepth(b)
318
+ || a.length - b.length || (a < b ? -1 : a > b ? 1 : 0))
319
+ .slice(0, FILE_MATCH_LIMIT);
320
+ setFileMatches(ranked);
283
321
  })
284
322
  .catch(() => {
285
323
  if (!cancelled)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * ScrollbackShell — the bounded LIVE REGION of native-scroll mode
3
+ * (tui-native-terminal-scroll §4.6).
4
+ *
5
+ * The full-screen `AppShell` fills the whole terminal; this shell is only as tall as
6
+ * `budget.liveRows` (the root box height IS that number: giving it the frame height
7
+ * would make the "small region" a whole screen again, and Ink would then repaint a
8
+ * screenful per frame). Everything settled has already been printed into the
9
+ * terminal's own scrollback above it.
10
+ *
11
+ * Top to bottom: [overlay | streaming preview] -> composer -> status (-> details).
12
+ */
13
+ import React from 'react';
14
+ import type { Theme } from './theme.js';
15
+ import type { ScrollbackBudget } from './scrollback/budget.js';
16
+ export interface ScrollbackShellProps {
17
+ budget: ScrollbackBudget;
18
+ /** Shown instead of the preview when an overlay is open. */
19
+ overlay: React.ReactNode | null;
20
+ /** Plain preview rows, bottom-aligned just above the composer. */
21
+ liveTailLines: readonly string[];
22
+ composer: React.ReactNode;
23
+ status: React.ReactNode;
24
+ details?: React.ReactNode;
25
+ placeholder: React.ReactNode;
26
+ theme: Theme;
27
+ }
28
+ export declare function ScrollbackShell({ budget, overlay, liveTailLines, composer, status, details, placeholder, theme, }: ScrollbackShellProps): React.ReactElement;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function ScrollbackShell({ budget, overlay, liveTailLines, composer, status, details, placeholder, theme, }) {
4
+ const width = budget.contentCols;
5
+ if (budget.inactive) {
6
+ return (_jsx(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: budget.liveRows > 0 ? placeholder : null }));
7
+ }
8
+ const tail = liveTailLines.slice(-budget.liveTailRows);
9
+ return (_jsxs(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: [budget.overlayRows > 0 && (_jsx(Box, { flexDirection: "column", height: budget.overlayRows, flexShrink: 0, overflow: "hidden", children: overlay })), budget.liveTailRows > 0 && (_jsx(Box, { flexDirection: "column", height: budget.liveTailRows, flexShrink: 0, justifyContent: "flex-end", overflow: "hidden", children: tail.map((line, index) => (_jsx(Text, { wrap: "truncate", color: theme.muted, children: line }, index))) })), _jsx(Box, { flexDirection: "column", height: budget.composerSlotRows, justifyContent: "flex-end", flexShrink: 0, overflow: "hidden", children: composer }), _jsx(Box, { height: budget.statusRows > 0 ? 1 : 0, flexShrink: 0, overflow: "hidden", children: status }), budget.statusRows === 2 && (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: details }))] }));
10
+ }
@@ -65,6 +65,13 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
65
65
  // place by `turnStart` (5.2.3).
66
66
  return frame(glyphs.queued, theme.muted, _jsx(QueuedEntry, { text: entry.text, theme: theme, caps: caps }));
67
67
  case 'assistant': {
68
+ // Render-layer backstop for the reducer prune (defect 5) - the reducer is the
69
+ // source of truth, and this only catches a settled, entirely empty bubble
70
+ // that reached here some other way. Returned BEFORE `frame(...)`, so neither
71
+ // the glyph row nor the separation row is drawn.
72
+ if (!entry.streaming && entry.text.trim() === '' && (entry.thinking ?? '').trim() === '') {
73
+ return null;
74
+ }
68
75
  const animate = entry.streaming && !reducedMotion && caps.unicode;
69
76
  return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted,
70
77
  // `EntryViewImpl` hands each renderer EXPLICIT SCALAR PROPS, so