@aragon-agent/cli 0.6.25 → 0.6.27

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 (48) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +194 -13
  3. package/dist/agent/reducer.d.ts +22 -0
  4. package/dist/agent/reducer.js +4 -1
  5. package/dist/cli.js +214 -2
  6. package/dist/commands/branch-command.d.ts +61 -0
  7. package/dist/commands/branch-command.js +117 -0
  8. package/dist/commands/builtins.js +48 -6
  9. package/dist/commands/registry.d.ts +28 -0
  10. package/dist/commands/session-command.d.ts +54 -0
  11. package/dist/commands/session-command.js +109 -0
  12. package/dist/config/load.d.ts +15 -0
  13. package/dist/exec/index.js +10 -1
  14. package/dist/logging/logger.d.ts +1 -1
  15. package/dist/proc/terminal-plan.d.ts +173 -0
  16. package/dist/proc/terminal-plan.js +393 -0
  17. package/dist/session/branch-invocation.d.ts +127 -0
  18. package/dist/session/branch-invocation.js +153 -0
  19. package/dist/session/branch-launch.d.ts +79 -0
  20. package/dist/session/branch-launch.js +265 -0
  21. package/dist/session/checkpoint.d.ts +257 -0
  22. package/dist/session/checkpoint.js +512 -0
  23. package/dist/session/cli-commands.d.ts +2 -0
  24. package/dist/session/cli-commands.js +36 -5
  25. package/dist/session/identity.d.ts +72 -0
  26. package/dist/session/identity.js +148 -0
  27. package/dist/session/limits.d.ts +66 -0
  28. package/dist/session/limits.js +66 -0
  29. package/dist/session/persist.d.ts +30 -5
  30. package/dist/session/startup-session.d.ts +107 -0
  31. package/dist/session/startup-session.js +315 -0
  32. package/dist/session/store.d.ts +60 -1
  33. package/dist/session/store.js +95 -6
  34. package/dist/ui/App.d.ts +54 -2
  35. package/dist/ui/App.js +115 -21
  36. package/dist/ui/Header.js +8 -16
  37. package/dist/ui/StatusBar.d.ts +8 -0
  38. package/dist/ui/StatusBar.js +12 -3
  39. package/dist/ui/layout/header-hints.d.ts +68 -0
  40. package/dist/ui/layout/header-hints.js +138 -0
  41. package/dist/ui/layout/session-chip.d.ts +52 -0
  42. package/dist/ui/layout/session-chip.js +64 -0
  43. package/dist/ui/layout/status-layout.d.ts +10 -0
  44. package/dist/ui/layout/status-layout.js +8 -1
  45. package/dist/ui/overlays/HelpOverlay.js +9 -3
  46. package/dist/ui/session-bridge.d.ts +26 -0
  47. package/dist/ui/session-bridge.js +32 -0
  48. package/package.json +2 -2
package/dist/ui/App.js CHANGED
@@ -52,6 +52,7 @@ import { INDEX_KEY_FRAME } from '../input/limits.js';
52
52
  import { INDEX_SKILL_NAME, buildIndexConfirmSummary, hasProjectIndex, indexBuildInvocationArgs, } from '../commands/index-build.js';
53
53
  import { installConsoleBridge } from './console-bridge.js';
54
54
  import { publishExitSnapshot } from './exit-snapshot.js';
55
+ import { publishSessionView } from './session-bridge.js';
55
56
  import { OverlayFrame } from './layout/OverlayFrame.js';
56
57
  import { helpRows } from './overlays/HelpOverlay.js';
57
58
  import { ModelPicker } from './overlays/ModelPicker.js';
@@ -121,10 +122,17 @@ function liveServiceCount(services) {
121
122
  * The lazy `useReducer` initializer. Module scope, so the reference is stable
122
123
  * across renders and React never re-runs it.
123
124
  */
124
- function seedViewState(thinkingVisible) {
125
- return initialViewState({ thinkingVisible });
125
+ function seedViewState(seed) {
126
+ const base = initialViewState({ thinkingVisible: seed.thinkingVisible });
127
+ // THE REDUCER'S OWN ADOPTION LOGIC, NOT A COPY OF IT (3.12): `restoreEntries`
128
+ // already lifts `seq` past the restored ids, trims to the retain ring and
129
+ // clears the live-target pointers, and a second implementation of those thirty
130
+ // lines is how the two would drift.
131
+ return seed.entries && seed.entries.length > 0
132
+ ? viewReducer(base, { type: 'restoreEntries', entries: seed.entries })
133
+ : base;
126
134
  }
127
- export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
135
+ export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, initialEntries, session, sessionBridge, sessionNotice, }) {
128
136
  const { exit } = useApp();
129
137
  const { stdout } = useStdout();
130
138
  const size = useTerminalSize();
@@ -157,7 +165,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
157
165
  // `toggleThinking` would let `/resume` flash a restored thinking block on
158
166
  // frame 1. `seedViewState` is module-scope so the lazy initializer is a stable
159
167
  // function reference.
160
- const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
168
+ const [state, dispatch] = useReducer(viewReducer, { thinkingVisible: cfg.showThinking, ...(initialEntries ? { entries: initialEntries } : {}) }, seedViewState);
169
+ // THE IDENTITY, SEEDED FROM THE PROP AND REPLACED ON ROTATION (D-15). A ref
170
+ // travels beside it because both readers -- the publish effect and the
171
+ // `/session` port -- must see the CURRENT id, and a rotation can land between
172
+ // two renders.
173
+ const [identity, setIdentity] = useState(session);
174
+ const identityRef = useRef(session);
175
+ const sessionId = identity?.id;
161
176
  const metrics = useRef(createRunMetrics());
162
177
  const manualMetrics = useRef(createRunMetrics());
163
178
  const [, setMetricsClock] = useState(0);
@@ -1025,8 +1040,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1025
1040
  : undefined);
1026
1041
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
1027
1042
  useEffect(() => {
1043
+ const merged = mergePendingEntries(state.entries, state.pendingSteering);
1028
1044
  publishExitSnapshot({
1029
- entries: mergePendingEntries(state.entries, state.pendingSteering),
1045
+ entries: merged,
1030
1046
  usageTotal: state.usageTotal,
1031
1047
  provider: cfg.provider,
1032
1048
  model: cfg.model,
@@ -1036,7 +1052,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1036
1052
  // count (K-6).
1037
1053
  droppedEntries: state.droppedEntries,
1038
1054
  });
1039
- }, [state.entries, state.pendingSteering, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
1055
+ // THE SAME `merged`, THE SAME RENDER (D-2 / 3.12). The checkpoint describes
1056
+ // exactly what the exit summary would, because it is built from the same
1057
+ // array in the same pass - and no second effect or subscription is added.
1058
+ //
1059
+ // `sessionId` IN THE DEPENDENCY ARRAY IS LOAD-BEARING (R-16): a rotation
1060
+ // that leaves the `entries` reference untouched would otherwise never
1061
+ // re-publish, and the writer would keep aiming at the OLD id's file - the
1062
+ // "A's conversation written into B's file" failure I-5 rules out.
1063
+ if (sessionId) {
1064
+ publishSessionView({ id: sessionId, entries: merged, usageTotal: state.usageTotal });
1065
+ }
1066
+ }, [state.entries, state.pendingSteering, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model, sessionId]);
1040
1067
  // --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
1041
1068
  //
1042
1069
  // The deps are the two `useCallback`d members, NOT the handle objects: both
@@ -1146,6 +1173,33 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1146
1173
  // --- Helpers -----------------------------------------------------------
1147
1174
  const notify = (level, text) => dispatch({ type: 'notice', level, text });
1148
1175
  const toast = (level, text) => dispatch({ type: 'pushToast', level, text });
1176
+ // THE BRANCH / `--save` NOTICE (D-13). Once, on mount, and only when the
1177
+ // caller supplied one: "the session you branched from is unchanged" is the
1178
+ // sentence that makes `--resume` safe to reach for, and it belongs in the
1179
+ // transcript where it can be read again rather than in a toast that expires.
1180
+ useEffect(() => {
1181
+ if (sessionNotice)
1182
+ notify('info', sessionNotice);
1183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1184
+ }, []);
1185
+ // --- Session bridge: identity write-back (D-15 / 3.12). -------------------
1186
+ //
1187
+ // INSTALLED AND CLEARED EXACTLY LIKE `confirmBridge.handler`: the rotation is
1188
+ // performed by `cli.tsx` (which owns the writer's target file), and the only
1189
+ // thing the view owns is the id it draws. Clearing on unmount keeps a late
1190
+ // rotation from dispatching into a dead reducer.
1191
+ useEffect(() => {
1192
+ if (!sessionBridge)
1193
+ return undefined;
1194
+ const bridge = sessionBridge;
1195
+ bridge.onIdentity = (next) => {
1196
+ identityRef.current = next;
1197
+ setIdentity(next);
1198
+ };
1199
+ return () => {
1200
+ bridge.onIdentity = undefined;
1201
+ };
1202
+ }, [sessionBridge]);
1149
1203
  // --- Paste bridge (tui-paste-handling section 5.1.2 / I-15). -------------
1150
1204
  //
1151
1205
  // The filter exists before this component does, so it holds a slot rather than
@@ -1395,6 +1449,29 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1395
1449
  // tool cards of the abandoned run are appended AFTER the command starts. A
1396
1450
  // value here would archive the screen minus the part the user was watching.
1397
1451
  transcriptEntries: () => stateRef.current.entries,
1452
+ // THE SESSION PORT (D-14 / D-15). `identity()` reads the ref at command time
1453
+ // and never a captured value: `/resume` rotates the id under a live tree, and
1454
+ // a captured one would report the previous id for the rest of the session.
1455
+ // `checkpoint` / `rotate` are the writer's, installed by `cli.tsx`; the port
1456
+ // is absent when this build has no session at all, which is what lets
1457
+ // `/session` say so honestly instead of throwing.
1458
+ ...(sessionBridge
1459
+ ? {
1460
+ session: {
1461
+ identity: () => identityRef.current,
1462
+ checkpoint: (opts) => sessionBridge.checkpoint?.(opts) ?? {
1463
+ ok: false,
1464
+ reason: 'no-identity',
1465
+ message: 'This session has no id.',
1466
+ },
1467
+ rotate: (loadedPath) => sessionBridge.rotate?.(loadedPath),
1468
+ },
1469
+ }
1470
+ : {}),
1471
+ // The branch port is projected from the bridge here rather than captured, so
1472
+ // this object stays a view of the current bridge, exactly like `session`
1473
+ // above. Absent means `/branch` reports that the build has none.
1474
+ ...(sessionBridge?.branch ? { branch: sessionBridge.branch } : {}),
1398
1475
  dispatch: (action) => {
1399
1476
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1400
1477
  metrics.current = createRunMetrics();
@@ -1868,6 +1945,23 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1868
1945
  requestIndexBuild();
1869
1946
  return;
1870
1947
  }
1948
+ // Ctrl+L: branch this session into a NEW terminal (tui-branch-terminal-
1949
+ // shortcut D-1 / D-2). Guarded exactly like Ctrl+G and Ctrl+I above it: an
1950
+ // overlay owns the keyboard, and on a too-small terminal the feedback has
1951
+ // nowhere to go. It has to sit BEFORE the `tooSmall` return below, because
1952
+ // after that line the transcript and overlay paging own every key - and
1953
+ // "the key did nothing" must stay distinguishable from "the key never
1954
+ // arrived".
1955
+ //
1956
+ // NO `isRunning()` GUARD, unlike Ctrl+I: branching mid-run is the most likely
1957
+ // moment to want it (D-22), and the command body is where running decides
1958
+ // that the success needs a second, durable channel.
1959
+ if (key.ctrl && (input === 'l' || input === 'L')) {
1960
+ if (tooSmall || stateRef.current.overlay)
1961
+ return;
1962
+ void executeSlashInput('/branch');
1963
+ return;
1964
+ }
1871
1965
  if (tooSmall)
1872
1966
  return;
1873
1967
  // R-P1-7: the overlay branch MUST come before the transcript scroll branch
@@ -1913,21 +2007,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1913
2007
  applyMode(nextMode(controller.getAgentMode()));
1914
2008
  return;
1915
2009
  }
1916
- if (key.ctrl && (input === 'l' || input === 'L')) {
1917
- {
1918
- // Writing \x1B[2J here would erase the screen and then leave it blank:
1919
- // Ink sees no output change and skips the repaint (ink.js:132 +
1920
- // log-update.js:13), possibly forever on an idle empty session. Bump
1921
- // the nonce instead and let Ink redraw the frame normally (§4.13).
1922
- setRedrawNonce((n) => n + 1);
2010
+ // Moved off Ctrl+L by D-1, because Ctrl+L is the branch key now.
2011
+ //
2012
+ // Ctrl+R, NOT Ctrl+Shift+L: legacy encodings deliver 0x0c for both, so a
2013
+ // Shift binding would exist only behind keyboard enhancement - exactly the
2014
+ // terminals least likely to need a recovery key. The overlay guard keeps the
2015
+ // settings screen's own Ctrl+R (Test connection) unambiguous.
2016
+ //
2017
+ // Writing \x1B[2J here would erase the screen and then leave it blank: Ink
2018
+ // sees no output change and skips the repaint (ink.js:132 +
2019
+ // log-update.js:13), possibly forever on an idle empty session. Bump the
2020
+ // nonce instead and let Ink redraw the frame normally (§4.13).
2021
+ if (key.ctrl && (input === 'r' || input === 'R')) {
2022
+ if (stateRef.current.overlay)
1923
2023
  return;
1924
- }
1925
- try {
1926
- stdout.write('\x1B[2J\x1B[3J\x1B[H');
1927
- }
1928
- catch {
1929
- /* ignore */
1930
- }
2024
+ setRedrawNonce((n) => n + 1);
1931
2025
  return;
1932
2026
  }
1933
2027
  {
@@ -2281,7 +2375,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2281
2375
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2282
2376
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2283
2377
  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 })] }));
2284
- 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, waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
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'
2285
2379
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2286
2380
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2287
2381
  // projection of "many sources of truth" into the props layer, and a
package/dist/ui/Header.js CHANGED
@@ -2,21 +2,13 @@ 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
6
  export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false }) {
6
- const scroll = Number.isFinite(scrolledLines) && scrolledLines > 0
7
- ? (scrolledLines > 9999 ? '^9999+' : '^' + Math.floor(scrolledLines)) + (overlayOpen ? ' ' : ' ') : '';
8
- // The Ctrl+I project-index affordance sits LEFT of the ^G detail toggle:
9
- // collapsed it names the key and the noun ('^I Index'); expanded - the
10
- // same Ctrl+G state that adds the status detail row - it spells the action
11
- // ('^I build index'). Hidden while an overlay owns the header and below the
12
- // 72-column cutoff the composer hints also use: on a narrow terminal the
13
- // scroll chip and the ^G toggle are the two affordances that must survive.
14
- const indexHint = overlayOpen || columns < 72
15
- ? ''
16
- : (statusExpanded ? '^I build index ' : '^I Index ');
17
- const detail = overlayOpen ? (statusExpanded ? 'Details on' : 'Details off')
18
- : indexHint + '^G ' + (statusExpanded ? 'less' : 'more');
19
- // Widest content: '^9999+ ' (8) + '^I build index ^G less' (23) = 31.
20
- const detailCells = indexHint ? 32 : 18;
21
- return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - detailCells), 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: detailCells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: scroll + detail }) })] });
7
+ // The right-hand hints are planned, not assembled (tui-branch-terminal-shortcut
8
+ // 3.9 / D-11). `plan.text` is exactly `plan.cells` wide and already carries the
9
+ // scroll chip's own trailing spacing, so this component can no longer clip a
10
+ // hint by guessing one cell short - and the ladder that decides which hints
11
+ // survive a narrow terminal lives in a pure function with its own tests.
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 }) })] });
22
14
  }
@@ -37,6 +37,14 @@ export interface StatusBarProps {
37
37
  caps: TermCapabilities;
38
38
  scrolledLines?: number;
39
39
  redrawNonce?: number;
40
+ /**
41
+ * This session's id, rendered at the right end of the primary row
42
+ * (tui-session-id-resume D-11 / D-12).
43
+ *
44
+ * OPTIONAL AND ABSENT BY DEFAULT, so a build without the feature and every
45
+ * existing test render exactly what they rendered before (I-6).
46
+ */
47
+ sessionId?: string;
40
48
  agentMode?: AgentMode;
41
49
  pendingAgentMode?: AgentMode | null;
42
50
  teamActive?: {
@@ -5,6 +5,7 @@ import { Box, Text } from 'ink';
5
5
  import { pickGlyphs } from './glyphs.js';
6
6
  import { liveSpinner } from './ActivityLine.js';
7
7
  import { planPrimaryStatusFields } from './layout/status-layout.js';
8
+ import { planSessionChip } from './layout/session-chip.js';
8
9
  /** An invisible leading byte change forces Ink to repaint on Ctrl+L. */
9
10
  const redrawChar = (nonce) => nonce % 2 ? String.fromCharCode(0x00a0) : ' ';
10
11
  export function StatusBar(props) {
@@ -13,7 +14,15 @@ export function StatusBar(props) {
13
14
  const active = phase !== 'idle' || !!props.compactionActive?.inFlight;
14
15
  const glyph = pickGlyphs(props.caps).spinnerStill;
15
16
  const spinner = active ? liveSpinner(props.reducedMotion ?? false, props.caps) : null;
17
+ // THE CHIP IS PLANNED FIRST, and the plan then feeds the field budget: the two
18
+ // cannot disagree about how wide the terminal is if only one of them is asked.
19
+ const chip = planSessionChip({
20
+ columns: cols,
21
+ ...(props.sessionId ? { sessionId: props.sessionId } : {}),
22
+ });
23
+ const chipCells = chip.visible ? chip.cells + chip.gap : 0;
16
24
  const plan = planPrimaryStatusFields({
25
+ reserveRight: chipCells,
17
26
  columns: cols, phase, pendingCount: props.pendingSteering?.length ?? 0,
18
27
  context: props.context, usageTotal: props.usageTotal,
19
28
  thinkingLevel: props.thinkingLevel, elapsedMs: props.elapsedMs, tokPerSec: props.tokPerSec,
@@ -29,7 +38,7 @@ export function StatusBar(props) {
29
38
  const tones = { normal: props.theme.primary, muted: props.theme.muted,
30
39
  warning: props.theme.noticeWarn, error: props.theme.noticeError,
31
40
  accent: props.theme.accent };
32
- return _jsxs(Box, { width: cols, height: 1, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }) }), plan.fields.map((item, index) => _jsxs(React.Fragment, { children: [index > 0 && _jsx(Box, { width: plan.separatorCells, flexShrink: 0, children: _jsx(Text, { color: props.theme.muted, children: plan.separator }) }), _jsx(Box, { width: item.cells, flexShrink: 0, children: _jsx(Text, { color: tones[item.tone], wrap: "truncate", children: item.id === 'phase' && active
33
- ? _jsxs(_Fragment, { children: [spinner ?? glyph, item.text.slice(glyph.length)] })
34
- : item.text }) })] }, item.id))] });
41
+ return _jsxs(Box, { width: cols, height: 1, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }) }), _jsx(Box, { width: Math.max(0, cols - 1 - chipCells), flexShrink: 0, flexDirection: "row", overflow: "hidden", children: plan.fields.map((item, index) => _jsxs(React.Fragment, { children: [index > 0 && _jsx(Box, { width: plan.separatorCells, flexShrink: 0, children: _jsx(Text, { color: props.theme.muted, children: plan.separator }) }), _jsx(Box, { width: item.cells, flexShrink: 0, children: _jsx(Text, { color: tones[item.tone], wrap: "truncate", children: item.id === 'phase' && active
42
+ ? _jsxs(_Fragment, { children: [spinner ?? glyph, item.text.slice(glyph.length)] })
43
+ : item.text }) })] }, item.id)) }), chip.visible && _jsx(Box, { width: chipCells, flexShrink: 0, justifyContent: "flex-end", overflow: "hidden", children: _jsx(Text, { color: props.theme.muted, wrap: "truncate", children: chip.text }) })] });
35
44
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The right-hand hint budget for the header row (tui-branch-terminal-shortcut 3.8).
3
+ *
4
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * WHY THIS IS A PLANNER AND NOT ANOTHER TERNARY IN THE JSX (R-0-4): the header's
7
+ * right side used to be one hand-computed width constant (`detailCells = indexHint
8
+ * ? 32 : 18`). Adding a third affordance to that arithmetic produces either a
9
+ * clipped hint or a pushed-out word mark on a 72-column terminal, and NO TEST
10
+ * WOULD SEE IT - the only thing lost is trailing characters. `ui/layout/session-chip.ts`
11
+ * already set the precedent for the same problem: the chip has to participate in
12
+ * one width budget rather than be appended and hoped for.
13
+ *
14
+ * THE RULE IS ONE COMPARISON. `budget = columns - minLeftCells`, then take the
15
+ * first ladder rung whose `cells` fits. There is no second magic constant, and the
16
+ * threshold table in the spec is what that comparison WORKS OUT TO, not an extra
17
+ * branch.
18
+ *
19
+ * WHY `minLeftCells` IS 24: the word mark is 13 cells and `provider:model` + cwd +
20
+ * the key symbol need at least 11 more to stay readable. It is lower than
21
+ * `status-layout`'s 34 because the top-left half has a brand to lose while the
22
+ * bottom-left half has a phase that must not disappear.
23
+ */
24
+ /**
25
+ * Bounds for the hint budget.
26
+ *
27
+ * WHY THEY LIVE IN THIS FILE (R-21, accepted): `CLAUDE.md`'s general rule is that
28
+ * structural bounds live in the subsystem's `limits.ts`, and `ui/layout/` has none
29
+ * - `budget.ts` keeps `HINT_MIN_ROWS` beside its own module for the same reason.
30
+ * This module has exactly one consumer. If `ui/layout/` ever grows a real
31
+ * `limits.ts`, these three constants should move there together; this comment is
32
+ * that future migration's note.
33
+ */
34
+ export declare const HEADER_HINT_LIMITS: {
35
+ /** Cells the LEFT half of the header must keep (`AragonAgent` is 13 of them). */
36
+ readonly minLeftCells: 24;
37
+ /** Fixed spacing between two hint clauses. */
38
+ readonly gapCells: 2;
39
+ };
40
+ export type HeaderHintId = 'branch' | 'index' | 'detail';
41
+ export interface HeaderHintClause {
42
+ id: HeaderHintId;
43
+ text: string;
44
+ }
45
+ export interface HeaderHintPlan {
46
+ /** Clauses left to right (including the overlay's `Details on/off` replacement). */
47
+ clauses: HeaderHintClause[];
48
+ /** The whole right-hand string, scroll chip included with its trailing spacing. */
49
+ text: string;
50
+ /** Total cells of the right column; `Header` uses it to size the left one. */
51
+ cells: number;
52
+ /** Cells taken by the scroll chip (0 when there is none). */
53
+ scrollCells: number;
54
+ /** True while an overlay owns the keyboard: only the detail word remains. */
55
+ detailsOnly: boolean;
56
+ }
57
+ /**
58
+ * Plan the right-hand hints.
59
+ *
60
+ * The four inputs are exactly the props `Header` already receives - the call site
61
+ * does not change, only the arithmetic behind it (spec 3.11 step 5).
62
+ */
63
+ export declare function planHeaderHints(input: {
64
+ columns: number;
65
+ statusExpanded: boolean;
66
+ overlayOpen: boolean;
67
+ scrolledLines: number;
68
+ }): HeaderHintPlan;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The right-hand hint budget for the header row (tui-branch-terminal-shortcut 3.8).
3
+ *
4
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * WHY THIS IS A PLANNER AND NOT ANOTHER TERNARY IN THE JSX (R-0-4): the header's
7
+ * right side used to be one hand-computed width constant (`detailCells = indexHint
8
+ * ? 32 : 18`). Adding a third affordance to that arithmetic produces either a
9
+ * clipped hint or a pushed-out word mark on a 72-column terminal, and NO TEST
10
+ * WOULD SEE IT - the only thing lost is trailing characters. `ui/layout/session-chip.ts`
11
+ * already set the precedent for the same problem: the chip has to participate in
12
+ * one width budget rather than be appended and hoped for.
13
+ *
14
+ * THE RULE IS ONE COMPARISON. `budget = columns - minLeftCells`, then take the
15
+ * first ladder rung whose `cells` fits. There is no second magic constant, and the
16
+ * threshold table in the spec is what that comparison WORKS OUT TO, not an extra
17
+ * branch.
18
+ *
19
+ * WHY `minLeftCells` IS 24: the word mark is 13 cells and `provider:model` + cwd +
20
+ * the key symbol need at least 11 more to stay readable. It is lower than
21
+ * `status-layout`'s 34 because the top-left half has a brand to lose while the
22
+ * bottom-left half has a phase that must not disappear.
23
+ */
24
+ /**
25
+ * Bounds for the hint budget.
26
+ *
27
+ * WHY THEY LIVE IN THIS FILE (R-21, accepted): `CLAUDE.md`'s general rule is that
28
+ * structural bounds live in the subsystem's `limits.ts`, and `ui/layout/` has none
29
+ * - `budget.ts` keeps `HINT_MIN_ROWS` beside its own module for the same reason.
30
+ * This module has exactly one consumer. If `ui/layout/` ever grows a real
31
+ * `limits.ts`, these three constants should move there together; this comment is
32
+ * that future migration's note.
33
+ */
34
+ export const HEADER_HINT_LIMITS = {
35
+ /** Cells the LEFT half of the header must keep (`AragonAgent` is 13 of them). */
36
+ minLeftCells: 24,
37
+ /** Fixed spacing between two hint clauses. */
38
+ gapCells: 2,
39
+ };
40
+ /** The verbatim clause text (spec 3.8, appendix A). */
41
+ const BRANCH_CLAUSE = { collapsed: '^L branch', expanded: '^L new branch' };
42
+ const INDEX_CLAUSE = { collapsed: '^I Index', expanded: '^I build index' };
43
+ const DETAIL_CLAUSE = { collapsed: '^G more', expanded: '^G less' };
44
+ /** L3 gives the key name and nothing else: discoverability at the last width. */
45
+ const BRANCH_KEY_ONLY = '^L';
46
+ /** Overlay wording, kept exactly as `Header.tsx` had it. */
47
+ const DETAILS_ON = 'Details on';
48
+ const DETAILS_OFF = 'Details off';
49
+ /** Above this, the chip stops counting and saturates (`^9999+`), as it always has. */
50
+ const SCROLL_CHIP_MAX = 9999;
51
+ /**
52
+ * Plan the right-hand hints.
53
+ *
54
+ * The four inputs are exactly the props `Header` already receives - the call site
55
+ * does not change, only the arithmetic behind it (spec 3.11 step 5).
56
+ */
57
+ export function planHeaderHints(input) {
58
+ // NORMALISED HERE, NOT TRUSTED (R-14). `Header` defaults to 80, but a test or a
59
+ // future caller may pass anything; `budget.ts::dimension()` already set the rule
60
+ // (non-finite or < 1 falls back, everything else floors) and this follows it.
61
+ const columns = normalizeDimension(input.columns, 80);
62
+ const scrolledLines = Math.max(0, Math.floor(normalizeDimension(input.scrolledLines, 0)));
63
+ const scrollCells = scrollChip(scrolledLines, input.overlayOpen);
64
+ if (input.overlayOpen) {
65
+ // THE OVERLAY OWNS THE KEYBOARD, so every key hint disappears (D-14's sibling
66
+ // rule, unchanged from the old JSX). The scroll chip stays: it is a position
67
+ // indicator, not an affordance.
68
+ const detail = input.statusExpanded ? DETAILS_ON : DETAILS_OFF;
69
+ return buildPlan([{ id: 'detail', text: detail }], scrollCells, true);
70
+ }
71
+ const ladder = ladderFor(input.statusExpanded);
72
+ const budget = columns - HEADER_HINT_LIMITS.minLeftCells;
73
+ for (const rung of ladder) {
74
+ const plan = buildPlan(rung, scrollCells, false);
75
+ if (plan.cells <= budget)
76
+ return plan;
77
+ }
78
+ // L4 IS UNCONDITIONAL: `^G` is the only entry in this table that may never be
79
+ // dropped - it is how a user who hid the status detail fields gets them back.
80
+ // A header narrower than the last rung still renders; the LEFT column is what
81
+ // shrinks (to zero), and `overflow="hidden"` keeps that from overflowing.
82
+ return buildPlan(ladder[ladder.length - 1] ?? [], scrollCells, false);
83
+ }
84
+ /** The four rungs, widest first. Built per call because the widths are text-derived. */
85
+ function ladderFor(statusExpanded) {
86
+ const branch = {
87
+ id: 'branch',
88
+ text: statusExpanded ? BRANCH_CLAUSE.expanded : BRANCH_CLAUSE.collapsed,
89
+ };
90
+ const index = {
91
+ id: 'index',
92
+ text: statusExpanded ? INDEX_CLAUSE.expanded : INDEX_CLAUSE.collapsed,
93
+ };
94
+ const detail = {
95
+ id: 'detail',
96
+ text: statusExpanded ? DETAIL_CLAUSE.expanded : DETAIL_CLAUSE.collapsed,
97
+ };
98
+ return [
99
+ [branch, index, detail],
100
+ [branch, detail],
101
+ [{ id: 'branch', text: BRANCH_KEY_ONLY }, detail],
102
+ [detail],
103
+ ];
104
+ }
105
+ /**
106
+ * Compose the final string. The chip carries its OWN trailing spacing and is the
107
+ * only source of it (R-8): one space while an overlay is open, two otherwise -
108
+ * copied byte for byte from the expression the header already used.
109
+ */
110
+ function buildPlan(clauses, scrollChipText, detailsOnly) {
111
+ const body = clauses.map((clause) => clause.text).join(' '.repeat(HEADER_HINT_LIMITS.gapCells));
112
+ const text = scrollChipText.length > 0 ? `${scrollChipText}${body}` : body;
113
+ return {
114
+ clauses,
115
+ text,
116
+ cells: text.length,
117
+ scrollCells: scrollChipText.length,
118
+ detailsOnly,
119
+ };
120
+ }
121
+ /**
122
+ * `^<n>` plus its trailing spacing, or `''`.
123
+ *
124
+ * THE SPACING IS PART OF THE CHIP, not something `Header` adds afterwards: with two
125
+ * sources of spacing a reviewer has to read two files to answer "how wide is the
126
+ * chip", and the overlay's single-space case lived in the JSX as a ternary nobody
127
+ * would think to check.
128
+ */
129
+ function scrollChip(scrolledLines, overlayOpen) {
130
+ if (scrolledLines <= 0)
131
+ return '';
132
+ const label = scrolledLines > SCROLL_CHIP_MAX ? `^${SCROLL_CHIP_MAX}+` : `^${scrolledLines}`;
133
+ return label + (overlayOpen ? ' ' : ' ');
134
+ }
135
+ /** The `budget.ts::dimension()` rule, local because that helper is not exported. */
136
+ function normalizeDimension(value, fallback) {
137
+ return Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback;
138
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The session-id chip: pure width planning, no JSX
3
+ * (tui-session-id-resume §3.9 / D-11 / D-12).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY A PLANNER AND NOT A STYLED `<Text>`: the status bar is a cell-by-cell
8
+ * measured row with a six-rung degradation ladder (`status-layout.ts`), not a
9
+ * flex layout.
10
+ * Anything positioned "just to the right" pushes the left half over or covers
11
+ * `phase`, and `phase` is the field a user needs while a run is in flight. The
12
+ * chip therefore spends cells out of the SAME budget as every other field.
13
+ *
14
+ * THE ID IS NEVER TRUNCATED (D-12). A truncated id produces a `--resume` command
15
+ * that is guaranteed to fail, which is strictly worse than showing nothing: the
16
+ * rungs below are, in order, "drop the label" and only then "hide the whole
17
+ * chip", and `/session` reports the full id at any width.
18
+ */
19
+ export declare const SESSION_CHIP_LIMITS: {
20
+ /** The same threshold `Header.tsx` uses for its `^I Index` hint. */
21
+ readonly labelMinCols: 72;
22
+ /** Blank cells between the last status field and the chip. */
23
+ readonly gapCells: 2;
24
+ /**
25
+ * The tightest rung's worst-case width for the fields on the LEFT, summed:
26
+ * phase glyph(1)+label(4)=5, sep 1 -> 6, deferred mode 'C>B' 3 -> 9,
27
+ * sep 1 -> 10, 'C'+'~999%' 6 -> 16, sep 1 -> 17, thinking code 1 -> 18,
28
+ * sep 1 -> 19, '>99M/s' 6 -> 25, sep 1 -> 26, '>999d' 5 -> 31.
29
+ *
30
+ * 34 IS A FLOOR ROUNDED UP, not a measurement: over-estimating hides the chip
31
+ * one column early, which is invisible; under-estimating lets the chip eat the
32
+ * phase field, which is not.
33
+ */
34
+ readonly minStatusCells: 34;
35
+ };
36
+ export interface SessionChipPlan {
37
+ visible: boolean;
38
+ text: string;
39
+ cells: number;
40
+ gap: number;
41
+ }
42
+ /**
43
+ * Plan the chip for `columns` and this session's id.
44
+ *
45
+ * Inputs are the same ones the status row already has, so the two plans cannot
46
+ * disagree about the terminal: `StatusBar` computes `chip` first and hands
47
+ * `chip.cells + chip.gap` to `planPrimaryStatusFields` as `reserveRight`.
48
+ */
49
+ export declare function planSessionChip(input: {
50
+ columns: number;
51
+ sessionId?: string | null;
52
+ }): SessionChipPlan;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The session-id chip: pure width planning, no JSX
3
+ * (tui-session-id-resume §3.9 / D-11 / D-12).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY A PLANNER AND NOT A STYLED `<Text>`: the status bar is a cell-by-cell
8
+ * measured row with a six-rung degradation ladder (`status-layout.ts`), not a
9
+ * flex layout.
10
+ * Anything positioned "just to the right" pushes the left half over or covers
11
+ * `phase`, and `phase` is the field a user needs while a run is in flight. The
12
+ * chip therefore spends cells out of the SAME budget as every other field.
13
+ *
14
+ * THE ID IS NEVER TRUNCATED (D-12). A truncated id produces a `--resume` command
15
+ * that is guaranteed to fail, which is strictly worse than showing nothing: the
16
+ * rungs below are, in order, "drop the label" and only then "hide the whole
17
+ * chip", and `/session` reports the full id at any width.
18
+ */
19
+ import stringWidth from 'string-width';
20
+ export const SESSION_CHIP_LIMITS = {
21
+ /** The same threshold `Header.tsx` uses for its `^I Index` hint. */
22
+ labelMinCols: 72,
23
+ /** Blank cells between the last status field and the chip. */
24
+ gapCells: 2,
25
+ /**
26
+ * The tightest rung's worst-case width for the fields on the LEFT, summed:
27
+ * phase glyph(1)+label(4)=5, sep 1 -> 6, deferred mode 'C>B' 3 -> 9,
28
+ * sep 1 -> 10, 'C'+'~999%' 6 -> 16, sep 1 -> 17, thinking code 1 -> 18,
29
+ * sep 1 -> 19, '>99M/s' 6 -> 25, sep 1 -> 26, '>999d' 5 -> 31.
30
+ *
31
+ * 34 IS A FLOOR ROUNDED UP, not a measurement: over-estimating hides the chip
32
+ * one column early, which is invisible; under-estimating lets the chip eat the
33
+ * phase field, which is not.
34
+ */
35
+ minStatusCells: 34,
36
+ };
37
+ /** `{visible:false}` - the shape every caller can cheaply branch on. */
38
+ const HIDDEN = { visible: false, text: '', cells: 0, gap: 0 };
39
+ /**
40
+ * Plan the chip for `columns` and this session's id.
41
+ *
42
+ * Inputs are the same ones the status row already has, so the two plans cannot
43
+ * disagree about the terminal: `StatusBar` computes `chip` first and hands
44
+ * `chip.cells + chip.gap` to `planPrimaryStatusFields` as `reserveRight`.
45
+ */
46
+ export function planSessionChip(input) {
47
+ // No identity (the default build, a test, or a host with no session) is the
48
+ // `undefined` case, and it must cost nothing: reserveRight stays 0 and the
49
+ // status row's output is byte-identical to the pre-feature build (I-6).
50
+ if (!input.sessionId)
51
+ return HIDDEN;
52
+ const columns = Number.isFinite(input.columns) ? Math.floor(input.columns) : 80;
53
+ const id = input.sessionId;
54
+ const gap = SESSION_CHIP_LIMITS.gapCells;
55
+ const fits = (text) => columns - 1 - gap - stringWidth(text) >= SESSION_CHIP_LIMITS.minStatusCells;
56
+ // Widest form first, then drop the label, then hide: the order is the decision.
57
+ const labelled = `id ${id}`;
58
+ if (columns >= SESSION_CHIP_LIMITS.labelMinCols && fits(labelled)) {
59
+ return { visible: true, text: labelled, cells: stringWidth(labelled), gap };
60
+ }
61
+ if (fits(id))
62
+ return { visible: true, text: id, cells: stringWidth(id), gap };
63
+ return HIDDEN;
64
+ }
@@ -44,6 +44,16 @@ export interface StatusLayoutInput {
44
44
  inFlight: boolean;
45
45
  };
46
46
  scrolledLines?: number;
47
+ /**
48
+ * Cells already spent at the RIGHT end of this same row
49
+ * (tui-session-id-resume 3.10 / I-6).
50
+ *
51
+ * The session-id chip is not a layer above this row: it takes its cells out of
52
+ * the SAME budget, because the row is measured rather than laid out and
53
+ * anything overlapping it eats `phase` first. ABSENT MEANS 0, which is what
54
+ * keeps every existing caller's plan byte-identical (I-6).
55
+ */
56
+ reserveRight?: number;
47
57
  }
48
58
  export interface StatusPrimaryInput extends StatusLayoutInput {
49
59
  feedback?: StatusFeedback;