@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.
- package/CHANGELOG.md +104 -0
- package/README.md +194 -13
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +4 -1
- package/dist/cli.js +214 -2
- package/dist/commands/branch-command.d.ts +61 -0
- package/dist/commands/branch-command.js +117 -0
- package/dist/commands/builtins.js +48 -6
- package/dist/commands/registry.d.ts +28 -0
- package/dist/commands/session-command.d.ts +54 -0
- package/dist/commands/session-command.js +109 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +173 -0
- package/dist/proc/terminal-plan.js +393 -0
- package/dist/session/branch-invocation.d.ts +127 -0
- package/dist/session/branch-invocation.js +153 -0
- package/dist/session/branch-launch.d.ts +79 -0
- package/dist/session/branch-launch.js +265 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/cli-commands.d.ts +2 -0
- package/dist/session/cli-commands.js +36 -5
- package/dist/session/identity.d.ts +72 -0
- package/dist/session/identity.js +148 -0
- package/dist/session/limits.d.ts +66 -0
- package/dist/session/limits.js +66 -0
- package/dist/session/persist.d.ts +30 -5
- package/dist/session/startup-session.d.ts +107 -0
- package/dist/session/startup-session.js +315 -0
- package/dist/session/store.d.ts +60 -1
- package/dist/session/store.js +95 -6
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +115 -21
- package/dist/ui/Header.js +8 -16
- package/dist/ui/StatusBar.d.ts +8 -0
- package/dist/ui/StatusBar.js +12 -3
- package/dist/ui/layout/header-hints.d.ts +68 -0
- package/dist/ui/layout/header-hints.js +138 -0
- package/dist/ui/layout/session-chip.d.ts +52 -0
- package/dist/ui/layout/session-chip.js +64 -0
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +8 -1
- package/dist/ui/overlays/HelpOverlay.js +9 -3
- package/dist/ui/session-bridge.d.ts +26 -0
- package/dist/ui/session-bridge.js +32 -0
- 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(
|
|
125
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|
package/dist/ui/StatusBar.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/ui/StatusBar.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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;
|