@aragon-agent/cli 0.6.29 → 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 (58) hide show
  1. package/CHANGELOG.md +2123 -2103
  2. package/README.md +81 -14
  3. package/dist/agent/project-guidance-prompt.js +20 -20
  4. package/dist/cli.js +169 -56
  5. package/dist/commands/builtins.js +4 -0
  6. package/dist/commands/perf.d.ts +25 -1
  7. package/dist/commands/perf.js +26 -2
  8. package/dist/commands/registry.d.ts +6 -0
  9. package/dist/config/env.js +4 -0
  10. package/dist/config/load.d.ts +3 -1
  11. package/dist/config/load.js +12 -1
  12. package/dist/config/schema.d.ts +27 -0
  13. package/dist/config/schema.js +9 -0
  14. package/dist/config/ui-state.d.ts +9 -0
  15. package/dist/config/ui-state.js +9 -0
  16. package/dist/ui/App.d.ts +11 -1
  17. package/dist/ui/App.js +122 -62
  18. package/dist/ui/ScrollbackShell.d.ts +28 -0
  19. package/dist/ui/ScrollbackShell.js +10 -0
  20. package/dist/ui/frame-differ.d.ts +12 -0
  21. package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
  22. package/dist/ui/overlays/HelpOverlay.js +21 -3
  23. package/dist/ui/screen.d.ts +16 -0
  24. package/dist/ui/screen.js +32 -0
  25. package/dist/ui/scrollback/budget.d.ts +43 -0
  26. package/dist/ui/scrollback/budget.js +44 -0
  27. package/dist/ui/scrollback/drain.d.ts +32 -0
  28. package/dist/ui/scrollback/drain.js +64 -0
  29. package/dist/ui/scrollback/exit-summary.d.ts +23 -0
  30. package/dist/ui/scrollback/exit-summary.js +25 -0
  31. package/dist/ui/scrollback/limits.d.ts +25 -0
  32. package/dist/ui/scrollback/limits.js +25 -0
  33. package/dist/ui/scrollback/live-preview.d.ts +26 -0
  34. package/dist/ui/scrollback/live-preview.js +106 -0
  35. package/dist/ui/scrollback/renderer.d.ts +38 -0
  36. package/dist/ui/scrollback/renderer.js +158 -0
  37. package/dist/ui/scrollback/scrollback-writer.d.ts +74 -0
  38. package/dist/ui/scrollback/scrollback-writer.js +263 -0
  39. package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
  40. package/dist/ui/scrollback/settled-boundary.js +106 -0
  41. package/dist/ui/scrollback/use-scrollback-view.d.ts +92 -0
  42. package/dist/ui/scrollback/use-scrollback-view.js +263 -0
  43. package/dist/ui/scrollback/writer-shapes.d.ts +61 -0
  44. package/dist/ui/scrollback/writer-shapes.js +114 -0
  45. package/dist/ui/stdout-frame-writer.js +12 -2
  46. package/dist/ui/terminal-plan.d.ts +52 -0
  47. package/dist/ui/terminal-plan.js +49 -0
  48. package/dist/ui/transcript-text.d.ts +11 -0
  49. package/dist/ui/transcript-text.js +21 -7
  50. package/dist/ui/use-startup-notices.d.ts +12 -1
  51. package/dist/ui/use-startup-notices.js +28 -2
  52. package/package.json +2 -2
  53. package/skills/project-indexer/SKILL.md +163 -163
  54. package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
  55. package/skills/project-indexer/templates/cleancode-template.md +94 -94
  56. package/skills/project-indexer/templates/config-template.md +69 -69
  57. package/skills/project-indexer/templates/index-template.md +129 -129
  58. 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';
@@ -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();
@@ -164,8 +171,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
164
171
  * can never step past the window and skip content. The unsafe direction is the
165
172
  * one that matters, and it is unreachable.
166
173
  */
167
- const overlayPage = overlayPageSize(dimensions.viewportRows, cols);
168
- const tooSmall = dimensions.inactive;
174
+ const overlayPage = overlayPageSize(dimensions.viewportRows + (native ? 1 : 0), cols);
175
+ const tooSmall = dimensions.inactive || (native && guardTripped);
169
176
  // --- Render governor (tui-render-performance L4). ------------------------
170
177
  //
171
178
  // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
@@ -1054,6 +1061,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1054
1061
  nodeVersion: vtInputWarning.nodeVersion,
1055
1062
  onSeen: () => setVtInputNoticeVersion(VT_INPUT_NOTICE_VERSION),
1056
1063
  }
1064
+ : undefined, native
1065
+ ? { onSeen: () => setScrollModeNoticeVersion(SCROLL_MODE_NOTICE_VERSION) }
1057
1066
  : undefined);
1058
1067
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
1059
1068
  useEffect(() => {
@@ -1491,6 +1500,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1491
1500
  ...(sessionBridge?.branch ? { branch: sessionBridge.branch } : {}),
1492
1501
  dispatch: (action) => {
1493
1502
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1503
+ scrollbackViewRef.current?.notifyReset(action.type === 'resetConversation' ? 'clear' : 'restore');
1494
1504
  metrics.current = createRunMetrics();
1495
1505
  manualMetrics.current = createRunMetrics();
1496
1506
  setScrollResetKey((n) => n + 1);
@@ -1541,6 +1551,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1541
1551
  }
1542
1552
  : {}),
1543
1553
  ...(terminal?.writeForeign ? { writeForeign: terminal.writeForeign } : {}),
1554
+ ...(native ? { nativeScroll: true } : {}),
1544
1555
  });
1545
1556
  /**
1546
1557
  * Submitting a prompt no longer touches `config.json` at all — it appends one
@@ -2042,7 +2053,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2042
2053
  setRedrawNonce((n) => n + 1);
2043
2054
  return;
2044
2055
  }
2045
- {
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) {
2046
2059
  if (key.pageUp) {
2047
2060
  scrollBy('pageUp');
2048
2061
  return;
@@ -2063,10 +2076,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2063
2076
  if (key.ctrl && (input === 't' || input === 'T')) {
2064
2077
  const revealing = !stateRef.current.thinkingVisible;
2065
2078
  dispatch({ type: 'toggleThinking' });
2079
+ // Printed history never changes, so say what the toggle actually reaches.
2080
+ const scope = native ? ' Applies to new messages.' : '';
2066
2081
  if (!revealing)
2067
- toast('info', 'Thinking hidden.');
2082
+ toast('info', `Thinking hidden.${scope}`);
2068
2083
  else
2069
- toast('info', 'Thinking shown.');
2084
+ toast('info', `Thinking shown.${scope}`);
2070
2085
  return;
2071
2086
  }
2072
2087
  if (key.ctrl && (input === 'o' || input === 'O')) {
@@ -2090,7 +2105,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2090
2105
  e.kind === 'team' ||
2091
2106
  e.kind === 'compaction' ||
2092
2107
  (e.kind === 'service' && !e.terminal));
2093
- 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)
2094
2115
  dispatch({ type: 'toggleExpand', id: last.id });
2095
2116
  // WIDENED FROM `No tool output to expand.`: with three expandable kinds the
2096
2117
  // old wording would be wrong in two of the three empty cases.
@@ -2243,7 +2264,6 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2243
2264
  const frameBudget = buildFrameBudget({ rows, cols, draftRows,
2244
2265
  popupRows: overlay ? 0 : popupRows, statusExpanded });
2245
2266
  const viewportBudget = frameBudget.viewportRows;
2246
- const overlayMaxRows = viewportBudget;
2247
2267
  const composerBaseRows = 0;
2248
2268
  const baseRail = buildTodoRailLayout({
2249
2269
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
@@ -2263,9 +2283,49 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2263
2283
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
2264
2284
  teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows: 0, composerBaseRows,
2265
2285
  });
2266
- 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;
2267
2289
  const transcriptEntries = useMemo(() => selectTranscriptEntries(state.entries, state.pendingSteering), [state.entries, state.pendingSteering]);
2268
- 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: {
2269
2329
  provider: cfg.provider,
2270
2330
  model: cfg.model,
2271
2331
  baseUrl: cfg.baseUrl ?? '',
@@ -2299,15 +2359,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2299
2359
  // the save handler cannot disagree about what `(inherit)` means.
2300
2360
  ...fastSettingsFrom(cfg.fast),
2301
2361
  ...compactionSettingsFrom(cfg.compaction),
2302
- }, 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 }),
2303
2363
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
2304
2364
  // while the screen is open shows the new value on the next paint.
2305
- 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;
2306
- 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 }));
2307
- // The wordmark + getting-started card now live INSIDE the viewport, as its
2308
- // first block of content, so they scroll away instead of being yanked out of
2309
- // the header the moment the first message lands.
2310
- 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;
2311
2366
  const density = cfg.density;
2312
2367
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
2313
2368
  // The fixed status bar remains visible underneath every overlay and toast.
@@ -2326,8 +2381,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2326
2381
  heightsMeasured: heights.stats().measured,
2327
2382
  heightsEstimated: heights.stats().estimated,
2328
2383
  cols: contentCols,
2329
- mode: 'fullscreen',
2330
- viewportRows: viewportBudget,
2384
+ mode: native ? 'native' : 'fullscreen',
2385
+ viewportRows: nativeBudget ? nativeBudget.liveRows : viewportBudget,
2331
2386
  offset: scrolledLines,
2332
2387
  // A getter: the bridge's `frameReady` and geometry change outside React renders.
2333
2388
  get scrollbar() {
@@ -2376,7 +2431,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2376
2431
  level: latestToast.level === 'success' ? 'info' : latestToast.level } : undefined,
2377
2432
  });
2378
2433
  const statusInput = {
2379
- columns: cols, phase: state.runPhase, pendingCount: state.pendingSteering.length,
2434
+ columns: viewCols, phase: state.runPhase, pendingCount: state.pendingSteering.length,
2380
2435
  context: state.context, usageTotal: state.usageTotal, thinkingLevel: cfg.thinkingLevel,
2381
2436
  elapsedMs, tokPerSec, speedKnown, activeTool: state.activeTool, runOutcome: state.runOutcome,
2382
2437
  waitingForConfirmation: !!humanRequest || !!confirmState,
@@ -2392,7 +2447,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2392
2447
  ...feedbackProjection,
2393
2448
  };
2394
2449
  const details = planStatusDetail({ status: statusInput, model: cfg.model, provider: cfg.provider,
2395
- hints: { interactionPhase: interactionPhase.current, cols, completion, overlay,
2450
+ hints: { interactionPhase: interactionPhase.current, cols: viewCols, completion, overlay,
2396
2451
  selectionPending: selectionController?.hasPendingSelection() ?? false,
2397
2452
  copyInFlight: copyState.busy, copyCleanupPending: copyState.cleanupPending,
2398
2453
  hasDraft: hasDraftRef.current, services: liveServices,
@@ -2403,43 +2458,48 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2403
2458
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2404
2459
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2405
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 })] }));
2406
- 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'
2407
- ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2408
- // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2409
- // projection of "many sources of truth" into the props layer, and a
2410
- // caller could update three of them; a single reading cannot be
2411
- // partially stale. The window and its `known` flag now travel INSIDE
2412
- // it, resolved by the meter - which is what makes the user's
2413
- // `contextWindow` override reach the bar.
2414
- 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
2415
- ? {
2416
- teamActive: {
2417
- running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
2418
- r.phase !== 'failed' && r.phase !== 'aborted').length,
2419
- total: state.team.runs.length,
2420
- },
2421
- }
2422
- : {}), ...(state.todos && !showRail
2423
- ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
2424
- : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}),
2425
- // Manual compaction starts before its next snapshot is published.
2426
- compactionActive: { inFlight: statusInput.compacting ?? false }, ...(controller.isCompactionEnabled()
2427
- ? {
2428
- gaugeMarks: {
2429
- warn: cfg.compaction.warnThreshold * 100,
2430
- high: cfg.compaction.threshold * 100,
2431
- },
2432
- }
2433
- : {}), ...(state.retry
2434
- ? {
2435
- retryActive: {
2436
- attempt: state.retry.attempt,
2437
- max: state.retry.maxRetries,
2438
- // Recomputed at RENDER from the absolute `resumeAt`, which is
2439
- // what keeps the chip and the transcript card showing the same
2440
- // number without either of them owning a second clock.
2441
- secondsLeft: secondsLeft(state.retry.resumeAt, Date.now()),
2442
- },
2443
- }
2444
- : {}) }) }));
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 }));
2445
2505
  }
@@ -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
+ }
@@ -150,5 +150,17 @@ export interface FrameDiffer {
150
150
  * appears one frame later instead of never.
151
151
  */
152
152
  repaint(): string;
153
+ /**
154
+ * The stream was resized. Optional (tui-native-terminal-scroll §5.1): the
155
+ * full-screen differ does not implement it and is invalidated instead, which is
156
+ * its existing behaviour; the scrollback writer keeps its cache for a reflow
157
+ * estimate.
158
+ */
159
+ onResize?(): void;
160
+ /**
161
+ * An own write that moves neither a cell nor the cursor (OSC 52 copy). Optional
162
+ * for the same reason: absent, the write invalidates the cache as before.
163
+ */
164
+ cursorNeutralWrite?(): void;
153
165
  }
154
166
  export declare function createFrameDiffer(options: FrameDifferOptions): FrameDiffer;
@@ -14,4 +14,4 @@ import React from 'react';
14
14
  import type { Theme } from '../theme.js';
15
15
  import type { TermCapabilities } from '../capabilities.js';
16
16
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
17
- export declare function helpRows(theme: Theme, caps: TermCapabilities): React.ReactElement[];
17
+ export declare function helpRows(theme: Theme, caps: TermCapabilities, native?: boolean): React.ReactElement[];
@@ -2,7 +2,25 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from 'ink';
3
3
  import { pickGlyphs } from '../glyphs.js';
4
4
  const KEY_PAD = 24;
5
- function keyRows(times) {
5
+ /**
6
+ * Rows that only make sense when the APPLICATION scrolls and selects. In native
7
+ * scroll mode (tui-native-terminal-scroll §4.7) the terminal does both, so they are
8
+ * dropped and one honest row replaces them.
9
+ */
10
+ const APP_SCROLL_ROWS = new Set([
11
+ 'Shift+Up / Shift+Down', 'Wheel', 'Shift+Wheel', 'Drag right edge', '^N',
12
+ 'Drag text (left button)', 'Ctrl+C (with a selection)',
13
+ ]);
14
+ function keyRows(times, native = false) {
15
+ const all = allKeyRows(times);
16
+ if (!native)
17
+ return all;
18
+ return all
19
+ .filter(([key]) => !APP_SCROLL_ROWS.has(key))
20
+ .map(([key, description]) => key === 'PgUp / PgDn' ? [key, 'Page through an open overlay'] : [key, description])
21
+ .concat([['Scroll', 'Use your terminal (wheel, Shift+PgUp, scrollbar, search)']]);
22
+ }
23
+ function allKeyRows(times) {
6
24
  return [
7
25
  ['Enter', 'Submit (idle) / queue steering (running); re-pins to the newest output'],
8
26
  ['Alt+Enter / Shift+Enter', 'Newline when supported; the host may intercept either binding'],
@@ -133,13 +151,13 @@ const FAST_ROWS = [
133
151
  ['<fast_review>', 'An automated second opinion from the fast model - advice, not the user'],
134
152
  ];
135
153
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
136
- export function helpRows(theme, caps) {
154
+ export function helpRows(theme, caps, native = false) {
137
155
  const glyphs = pickGlyphs(caps);
138
156
  const rows = [];
139
157
  const section = (title) => (_jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: title }, `s-${title}`));
140
158
  const pair = (k, d) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.primary, children: k.padEnd(KEY_PAD) }), _jsx(Text, { color: theme.muted, children: d })] }, `r-${k}`));
141
159
  rows.push(section('Keybindings'));
142
- for (const [k, d] of keyRows(glyphs.times))
160
+ for (const [k, d] of keyRows(glyphs.times, native))
143
161
  rows.push(pair(k, d));
144
162
  rows.push(section('Slash commands'));
145
163
  for (const [c, d] of COMMANDS)
@@ -71,6 +71,22 @@ export interface AltScreenOptions {
71
71
  * CI) — writing screen-control sequences into a redirected file corrupts it.
72
72
  */
73
73
  export declare function enterAltScreen(stdout: NodeJS.WriteStream | undefined, options?: AltScreenOptions): ScreenHandle;
74
+ export interface NativeScreenOptions {
75
+ /** Same discipline as `AltScreenOptions.bracketedPaste`: a filter that consumes it exists. */
76
+ readonly bracketedPaste?: boolean;
77
+ }
78
+ /**
79
+ * Native-scroll mode's counterpart of `enterAltScreen` (tui-native-terminal-scroll
80
+ * §4.8 / D6): it deliberately does almost nothing. The main buffer stays in use so
81
+ * the conversation lands in the terminal's own scrollback; there is no alternate
82
+ * screen, no mouse reporting and no DEC 1007 save/restore.
83
+ *
84
+ * The only mode it touches is DEC 2004 (bracketed paste), which changes how input
85
+ * is encoded and has nothing to do with scrolling. Returns the SAME `ScreenHandle`
86
+ * shape, so the four idempotent restore paths in `cli.tsx` need not know which mode
87
+ * is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
88
+ */
89
+ export declare function enterNativeScreen(stdout: NodeJS.WriteStream | undefined, options?: NativeScreenOptions): ScreenHandle;
74
90
  /**
75
91
  * Replay the session summary into the normal buffer after `restore()`, so the
76
92
  * conversation does not evaporate along with the alternate screen.
package/dist/ui/screen.js CHANGED
@@ -181,6 +181,38 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
181
181
  setMouseCapture,
182
182
  };
183
183
  }
184
+ /**
185
+ * Native-scroll mode's counterpart of `enterAltScreen` (tui-native-terminal-scroll
186
+ * §4.8 / D6): it deliberately does almost nothing. The main buffer stays in use so
187
+ * the conversation lands in the terminal's own scrollback; there is no alternate
188
+ * screen, no mouse reporting and no DEC 1007 save/restore.
189
+ *
190
+ * The only mode it touches is DEC 2004 (bracketed paste), which changes how input
191
+ * is encoded and has nothing to do with scrolling. Returns the SAME `ScreenHandle`
192
+ * shape, so the four idempotent restore paths in `cli.tsx` need not know which mode
193
+ * is running. A non-TTY stdout is a hard no-op, like `enterAltScreen`.
194
+ */
195
+ export function enterNativeScreen(stdout, options = {}) {
196
+ if (!stdout || !stdout.isTTY || typeof stdout.write !== 'function')
197
+ return NOOP_HANDLE;
198
+ const bracketedPaste = options.bracketedPaste === true;
199
+ if (bracketedPaste)
200
+ safeWrite(stdout, ENABLE_BRACKETED_PASTE);
201
+ let restored = false;
202
+ return {
203
+ restore() {
204
+ if (restored)
205
+ return;
206
+ restored = true;
207
+ // 2004 is FORCED off rather than saved/restored, for the reason recorded at
208
+ // `DISABLE_BRACKETED_PASTE`. The cursor is shown again because Ink's
209
+ // log-update hides it and a signal kill never gives it back.
210
+ safeWrite(stdout, (bracketedPaste ? DISABLE_BRACKETED_PASTE : '') + SHOW_CURSOR);
211
+ },
212
+ // No mouse reporting in this mode: nothing to hand back or take again.
213
+ setMouseCapture() { },
214
+ };
215
+ }
184
216
  /**
185
217
  * Replay the session summary into the normal buffer after `restore()`, so the
186
218
  * conversation does not evaporate along with the alternate screen.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
3
+ *
4
+ * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
+ * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
+ * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
+ * a tidy region and data loss, so it is established by construction here and
8
+ * enumerated by a property test rather than trusted.
9
+ *
10
+ * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
+ * full-screen layout uses, so the two modes cannot disagree about how tall the
12
+ * editor is. What differs is the top: there is no header, so the row it would
13
+ * occupy joins the pool shared by the overlay and the streaming preview.
14
+ */
15
+ import { type FrameBudgetInput } from '../layout/budget.js';
16
+ export interface ScrollbackBudgetInput extends FrameBudgetInput {
17
+ overlayOpen: boolean;
18
+ /** Preview rows the content would like; the budget grants what fits. */
19
+ liveTailWanted: number;
20
+ /** The writer tripped its tall-frame guard: degrade to the placeholder until a resize. */
21
+ forceInactive?: boolean;
22
+ }
23
+ export interface ScrollbackBudget {
24
+ rows: number;
25
+ cols: number;
26
+ /** Width everything is rendered and printed at (`cols - 1`, D5). */
27
+ contentCols: number;
28
+ /** Terminal below the 40x12 floor: the region is a one-row placeholder. */
29
+ inactive: boolean;
30
+ statusRows: 0 | 1 | 2;
31
+ composerRows: number;
32
+ composerSlotRows: number;
33
+ composerCols: number;
34
+ popupRows: number;
35
+ popupMaxHeight: number;
36
+ overlayRows: number;
37
+ liveTailRows: number;
38
+ /** Rows above the composer: overlay or preview. */
39
+ topRows: number;
40
+ /** Total live-region height; what Ink renders. */
41
+ liveRows: number;
42
+ }
43
+ export declare function buildScrollbackBudget(input: ScrollbackBudgetInput): ScrollbackBudget;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
3
+ *
4
+ * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
+ * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
+ * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
+ * a tidy region and data loss, so it is established by construction here and
8
+ * enumerated by a property test rather than trusted.
9
+ *
10
+ * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
+ * full-screen layout uses, so the two modes cannot disagree about how tall the
12
+ * editor is. What differs is the top: there is no header, so the row it would
13
+ * occupy joins the pool shared by the overlay and the streaming preview.
14
+ */
15
+ import { buildFrameBudget } from '../layout/budget.js';
16
+ import { SCROLLBACK_LIMITS } from './limits.js';
17
+ export function buildScrollbackBudget(input) {
18
+ const base = buildFrameBudget(input);
19
+ const contentCols = Math.max(1, base.cols - 1);
20
+ if (base.inactive || input.forceInactive === true) {
21
+ return {
22
+ rows: base.rows, cols: base.cols, contentCols, inactive: true, statusRows: 0,
23
+ composerRows: 0, composerSlotRows: 0, composerCols: base.composerCols, popupRows: 0,
24
+ popupMaxHeight: 0, overlayRows: 0, liveTailRows: 0, topRows: 0,
25
+ liveRows: Math.max(0, Math.min(1, base.rows - 1)),
26
+ };
27
+ }
28
+ // The full-screen frame spends one row on the header; here it joins the pool.
29
+ const remaining = Math.max(0, base.viewportRows + base.headerRows);
30
+ const overlayRows = input.overlayOpen ? remaining : 0;
31
+ const tailCap = Math.min(SCROLLBACK_LIMITS.maxLiveTailRows, Math.floor(base.rows * SCROLLBACK_LIMITS.liveTailRowShare));
32
+ const liveTailRows = input.overlayOpen
33
+ ? 0
34
+ : Math.max(0, Math.min(Math.floor(input.liveTailWanted), remaining, tailCap));
35
+ const topRows = overlayRows + liveTailRows;
36
+ const liveRows = topRows + base.composerSlotRows + base.statusRows;
37
+ return {
38
+ rows: base.rows, cols: base.cols, contentCols, inactive: false,
39
+ statusRows: base.statusRows, composerRows: base.composerRows,
40
+ composerSlotRows: base.composerSlotRows, composerCols: base.composerCols,
41
+ popupRows: base.popupRows, popupMaxHeight: base.popupMaxHeight, overlayRows,
42
+ liveTailRows, topRows, liveRows,
43
+ };
44
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Static-row feed — the queue between the promotion pipeline (which decides an
3
+ * entry is final) and the scrollback writer (which prints it)
4
+ * (tui-native-terminal-scroll §4.3 "feed").
5
+ *
6
+ * FIFO, bounded. When the writer falls behind and the queue overflows, the OLDEST
7
+ * pending entries are folded into one ASCII marker and counted: a visible, counted
8
+ * omission, never a silent one (I-S2). Entries are taken as whole blocks and never
9
+ * split, so a batch boundary can never land inside one entry's rows.
10
+ */
11
+ export interface EntryRef {
12
+ id: string;
13
+ kind: string;
14
+ }
15
+ export interface ScrollbackDrain {
16
+ enqueue(rows: readonly string[], meta: EntryRef): void;
17
+ takeAll(): readonly string[];
18
+ /** Whole entries only; always takes at least one when anything is pending. */
19
+ takeUpTo(maxRows: number): readonly string[];
20
+ /** Rows waiting, including a pending overflow marker. */
21
+ pending(): number;
22
+ /** Whole entries waiting (the overflow marker is not an entry). */
23
+ pendingEntries(): number;
24
+ /** Entries folded away by overflow, cumulative. */
25
+ elided(): number;
26
+ onEnqueue: (() => void) | null;
27
+ onDrained: (() => void) | null;
28
+ }
29
+ export declare function overflowMarker(count: number): string;
30
+ export declare function createScrollbackDrain(options?: {
31
+ capacity?: number;
32
+ }): ScrollbackDrain;