@aragon-agent/cli 0.6.34 → 0.6.36
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 +21 -0
- package/README.md +173 -7
- package/dist/agent/agent-mode.d.ts +20 -4
- package/dist/agent/agent-mode.js +20 -4
- package/dist/agent/controller.d.ts +67 -3
- package/dist/agent/controller.js +106 -5
- package/dist/agent/headless.js +31 -0
- package/dist/agent/kit-apply.d.ts +56 -0
- package/dist/agent/kit-apply.js +209 -0
- package/dist/agent/model-profile-settings.d.ts +28 -0
- package/dist/agent/model-profile-settings.js +142 -29
- package/dist/agent/reducer.d.ts +13 -2
- package/dist/agent/reducer.js +1 -0
- package/dist/cli.js +12 -0
- package/dist/commands/agents-command.d.ts +15 -0
- package/dist/commands/agents-command.js +27 -0
- package/dist/commands/builtins.js +135 -4
- package/dist/commands/kit-command.d.ts +20 -0
- package/dist/commands/kit-command.js +131 -0
- package/dist/commands/perf.d.ts +11 -0
- package/dist/commands/perf.js +2 -1
- package/dist/commands/registry.d.ts +14 -0
- package/dist/config/app-paths.d.ts +15 -0
- package/dist/config/app-paths.js +33 -2
- package/dist/config/env.d.ts +4 -1
- package/dist/config/env.js +28 -2
- package/dist/config/kit-resolution.d.ts +93 -0
- package/dist/config/kit-resolution.js +226 -0
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.js +8 -1
- package/dist/config/model-kits.d.ts +224 -0
- package/dist/config/model-kits.js +302 -0
- package/dist/config/model-profile-store.d.ts +25 -0
- package/dist/config/model-profiles.d.ts +41 -3
- package/dist/config/model-profiles.js +46 -5
- package/dist/config/schema.d.ts +84 -0
- package/dist/config/schema.js +39 -0
- package/dist/config/store.js +12 -1
- package/dist/config/ui-state.d.ts +18 -0
- package/dist/config/ui-state.js +9 -0
- package/dist/exec/events.d.ts +25 -0
- package/dist/exec/events.js +4 -0
- package/dist/exec/index.js +16 -1
- package/dist/exec/runner.js +10 -0
- package/dist/input/keymap.js +18 -4
- package/dist/proc/limits.d.ts +16 -0
- package/dist/proc/limits.js +16 -0
- package/dist/proc/supervisor.d.ts +20 -1
- package/dist/proc/supervisor.js +42 -14
- package/dist/prompts/command.d.ts +34 -0
- package/dist/prompts/command.js +237 -0
- package/dist/prompts/copy.d.ts +89 -0
- package/dist/prompts/copy.js +131 -0
- package/dist/prompts/fill.d.ts +51 -0
- package/dist/prompts/fill.js +46 -0
- package/dist/prompts/limits.d.ts +53 -0
- package/dist/prompts/limits.js +53 -0
- package/dist/prompts/normalize.d.ts +53 -0
- package/dist/prompts/normalize.js +190 -0
- package/dist/prompts/resolve.d.ts +31 -0
- package/dist/prompts/resolve.js +60 -0
- package/dist/prompts/search.d.ts +58 -0
- package/dist/prompts/search.js +146 -0
- package/dist/prompts/store.d.ts +86 -0
- package/dist/prompts/store.js +453 -0
- package/dist/prompts/types.d.ts +204 -0
- package/dist/prompts/types.js +13 -0
- package/dist/session/checkpoint.d.ts +8 -1
- package/dist/session/checkpoint.js +6 -0
- package/dist/session/persist.d.ts +19 -1
- package/dist/session/persist.js +13 -1
- package/dist/session/startup-session.d.ts +7 -2
- package/dist/session/startup-session.js +5 -2
- package/dist/session/store.js +3 -0
- package/dist/session/validate-session.js +64 -1
- package/dist/team/limits.d.ts +25 -0
- package/dist/team/limits.js +26 -0
- package/dist/team/runtime.js +27 -2
- package/dist/team/settled.d.ts +57 -0
- package/dist/team/settled.js +87 -0
- package/dist/team/subagent.js +48 -2
- package/dist/team/timeline.d.ts +74 -0
- package/dist/team/timeline.js +101 -0
- package/dist/team/types.d.ts +24 -1
- package/dist/todo/acceptance.d.ts +120 -0
- package/dist/todo/acceptance.js +399 -0
- package/dist/todo/follow-through.d.ts +48 -23
- package/dist/todo/follow-through.js +110 -44
- package/dist/todo/limits.d.ts +62 -1
- package/dist/todo/limits.js +62 -1
- package/dist/todo/panel-rows.d.ts +29 -39
- package/dist/todo/panel-rows.js +37 -83
- package/dist/todo/plan-sections.d.ts +60 -0
- package/dist/todo/plan-sections.js +108 -0
- package/dist/todo/prompt.d.ts +17 -12
- package/dist/todo/prompt.js +59 -40
- package/dist/todo/row-window.d.ts +51 -0
- package/dist/todo/row-window.js +107 -0
- package/dist/todo/store.d.ts +139 -31
- package/dist/todo/store.js +271 -73
- package/dist/todo/todo-tool.d.ts +33 -8
- package/dist/todo/todo-tool.js +186 -30
- package/dist/todo/types.d.ts +58 -0
- package/dist/ui/App.js +414 -54
- package/dist/ui/Composer.d.ts +6 -2
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/Header.d.ts +17 -1
- package/dist/ui/Header.js +8 -2
- package/dist/ui/PromptInput.d.ts +21 -1
- package/dist/ui/PromptInput.js +22 -2
- package/dist/ui/ScrollbackShell.d.ts +22 -7
- package/dist/ui/ScrollbackShell.js +8 -1
- package/dist/ui/StatusBar.d.ts +18 -0
- package/dist/ui/StatusBar.js +11 -3
- package/dist/ui/TeamPanel.d.ts +35 -41
- package/dist/ui/TeamPanel.js +53 -163
- package/dist/ui/TodoPanel.d.ts +17 -14
- package/dist/ui/TodoPanel.js +146 -38
- package/dist/ui/Transcript.js +23 -3
- package/dist/ui/composer-input.d.ts +10 -0
- package/dist/ui/composer-input.js +57 -24
- package/dist/ui/entries/TeamCard.d.ts +18 -2
- package/dist/ui/entries/TeamCard.js +47 -13
- package/dist/ui/entries/TodoCard.d.ts +26 -11
- package/dist/ui/entries/TodoCard.js +56 -15
- package/dist/ui/glyphs.d.ts +19 -0
- package/dist/ui/glyphs.js +4 -0
- package/dist/ui/layout/agents-board.d.ts +102 -0
- package/dist/ui/layout/agents-board.js +389 -0
- package/dist/ui/layout/cells.d.ts +61 -0
- package/dist/ui/layout/cells.js +156 -0
- package/dist/ui/layout/header-hints.d.ts +23 -3
- package/dist/ui/layout/header-hints.js +73 -7
- package/dist/ui/layout/kit-chip.d.ts +50 -0
- package/dist/ui/layout/kit-chip.js +81 -0
- package/dist/ui/layout/status-detail-layout.js +9 -0
- package/dist/ui/layout/status-layout.d.ts +13 -0
- package/dist/ui/layout/team-row.d.ts +139 -0
- package/dist/ui/layout/team-row.js +246 -0
- package/dist/ui/layout/todo-layout.d.ts +38 -0
- package/dist/ui/layout/todo-layout.js +75 -0
- package/dist/ui/layout/virtual-window.js +32 -3
- package/dist/ui/overlays/AgentsBoardOverlay.d.ts +57 -0
- package/dist/ui/overlays/AgentsBoardOverlay.js +183 -0
- package/dist/ui/overlays/HelpOverlay.js +24 -2
- package/dist/ui/overlays/KitEditor.d.ts +23 -0
- package/dist/ui/overlays/KitEditor.js +101 -0
- package/dist/ui/overlays/KitManager.d.ts +23 -0
- package/dist/ui/overlays/KitManager.js +104 -0
- package/dist/ui/overlays/KitPicker.d.ts +49 -0
- package/dist/ui/overlays/KitPicker.js +58 -0
- package/dist/ui/overlays/ModelProfilePicker.js +16 -6
- package/dist/ui/overlays/PromptEditorPage.d.ts +48 -0
- package/dist/ui/overlays/PromptEditorPage.js +227 -0
- package/dist/ui/overlays/PromptLibraryOverlay.d.ts +37 -0
- package/dist/ui/overlays/PromptLibraryOverlay.js +191 -0
- package/dist/ui/overlays/PromptPicker.d.ts +36 -0
- package/dist/ui/overlays/PromptPicker.js +129 -0
- package/dist/ui/overlays/PromptTextArea.d.ts +86 -0
- package/dist/ui/overlays/PromptTextArea.js +141 -0
- package/dist/ui/overlays/SettingsScreen.js +139 -16
- package/dist/ui/overlays/kit-picker-state.d.ts +87 -0
- package/dist/ui/overlays/kit-picker-state.js +136 -0
- package/dist/ui/overlays/kit-settings-state.d.ts +129 -0
- package/dist/ui/overlays/kit-settings-state.js +267 -0
- package/dist/ui/overlays/model-profile-state.d.ts +26 -3
- package/dist/ui/overlays/model-profile-state.js +59 -6
- package/dist/ui/overlays/prompt-library-state.d.ts +162 -0
- package/dist/ui/overlays/prompt-library-state.js +335 -0
- package/dist/ui/overlays/prompt-picker-state.d.ts +71 -0
- package/dist/ui/overlays/prompt-picker-state.js +98 -0
- package/dist/ui/scrollback/LiveTail.d.ts +2 -2
- package/dist/ui/scrollback/LiveTail.js +7 -2
- package/dist/ui/scrollback/band.js +14 -1
- package/dist/ui/scrollback/live-preview.js +10 -3
- package/dist/ui/scrollback/tail-window.d.ts +12 -0
- package/dist/ui/scrollback/tail-window.js +17 -2
- package/dist/ui/scrollback/use-scrollback-view.js +7 -0
- package/dist/ui/team-status.d.ts +166 -0
- package/dist/ui/team-status.js +252 -0
- package/dist/ui/transcript-text.js +13 -4
- package/dist/ui/use-startup-notices.d.ts +35 -1
- package/dist/ui/use-startup-notices.js +29 -2
- package/dist/ui/use-wheel-routing.js +10 -2
- package/package.json +2 -2
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents board's row model — pure projection, no Ink (tui-subagent-panel §4.4 / §7).
|
|
3
|
+
*
|
|
4
|
+
* WHY A PURE MODULE AND NOT JSX. The board's whole value is that it shows EVERY fact
|
|
5
|
+
* about every child, and the two things that can go wrong with that are both
|
|
6
|
+
* properties of a list: a row wider than the overlay (which silently desynchronises
|
|
7
|
+
* `OverlayFrame`'s position indicator) and a row count that grows without a bound.
|
|
8
|
+
* Both are assertable here, against plain strings, without a terminal - which is the
|
|
9
|
+
* same reason `layout/team-panel.ts` and `layout/budget.ts` are separate modules.
|
|
10
|
+
*
|
|
11
|
+
* LIVE-ONLY RULE (§4.4.2, RV-P0-2). The board has two sources with DIFFERENT fields:
|
|
12
|
+
* a live `TeamSnapshot` (has `messageCount`, `startedAt`, the activity prose and the
|
|
13
|
+
* timeline) and a settled `kind: 'team'` entry (has `durationMs` and the runs, and
|
|
14
|
+
* nothing else - `agent/reducer.ts`'s entry member carries no `messageCount` and no
|
|
15
|
+
* `startedAt`). A section whose source is missing is OMITTED, never filled with a
|
|
16
|
+
* zero: `mail 0` would be a lie, and an empty section title tells the reader nothing.
|
|
17
|
+
* The one section that survives both sources is `result` (`summary` / `error` is
|
|
18
|
+
* never stripped), which is precisely the fact a user opens the board to find.
|
|
19
|
+
*
|
|
20
|
+
* ASCII ONLY: `src/ui/**` is inside `glyphs.test.ts`'s static scan, so every symbol
|
|
21
|
+
* (the ellipsis, the separator, the cursor) arrives through `glyphs`.
|
|
22
|
+
*/
|
|
23
|
+
import { TEAM_LIMITS } from '../../team/limits.js';
|
|
24
|
+
import { childStatus, lastTeamEntry, rosterCounts, rosterHeadline, statusMarkerCell, } from '../team-status.js';
|
|
25
|
+
import { formatDuration, formatTokens } from '../../agent/usage.js';
|
|
26
|
+
import { sanitizeActivity } from '../../team/activity.js';
|
|
27
|
+
import { activityWithBadge, teamRowGeometry } from './team-row.js';
|
|
28
|
+
import { alignRightCells, fitCells, padCells, wrapCells } from './cells.js';
|
|
29
|
+
/**
|
|
30
|
+
* The board's single source shape, resolved in ONE place.
|
|
31
|
+
*
|
|
32
|
+
* PRIORITY: live snapshot > (the component's frozen copy) > the most recent settled
|
|
33
|
+
* entry. This function answers the first and the third; the frozen copy belongs to
|
|
34
|
+
* the component because it only exists while a dispatch is being read (§4.4.5).
|
|
35
|
+
*/
|
|
36
|
+
export function resolveBoardSource(input) {
|
|
37
|
+
const { team } = input;
|
|
38
|
+
if (team !== null) {
|
|
39
|
+
return {
|
|
40
|
+
dispatchId: team.dispatchId,
|
|
41
|
+
active: team.active,
|
|
42
|
+
startedAt: team.startedAt,
|
|
43
|
+
requested: team.requested,
|
|
44
|
+
runs: team.runs,
|
|
45
|
+
messageCount: team.messageCount,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const entry = lastTeamEntry(input.entries);
|
|
49
|
+
if (entry === null)
|
|
50
|
+
return null;
|
|
51
|
+
return {
|
|
52
|
+
dispatchId: entry.dispatchId,
|
|
53
|
+
active: entry.active === true,
|
|
54
|
+
requested: entry.requested,
|
|
55
|
+
runs: entry.runs,
|
|
56
|
+
...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The child the board is showing, resolved from its LABEL every frame (D-8).
|
|
61
|
+
*
|
|
62
|
+
* Labels are the only stable handle: `panel-rows.ts` re-sorts the roster by phase, so
|
|
63
|
+
* an index would silently jump to a different child the moment one of them settles.
|
|
64
|
+
* Fallback order is "the first running child, else the first child", which is the
|
|
65
|
+
* one a reader opening the board mid-dispatch wants.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveSelection(runs, label) {
|
|
68
|
+
const named = label === null ? undefined : runs.find((run) => run.label === label);
|
|
69
|
+
if (named !== undefined)
|
|
70
|
+
return named;
|
|
71
|
+
return runs.find((run) => childStatus(run) === 'running') ?? runs[0] ?? null;
|
|
72
|
+
}
|
|
73
|
+
/** Fixed label column of a `field` row, and the value column of its wide form. */
|
|
74
|
+
const FIELD_LABEL_CELLS = 11;
|
|
75
|
+
const FIELD_WIDE_VALUE_CELLS = 23;
|
|
76
|
+
/** Rows of prose a `now` / `result` section may spend. */
|
|
77
|
+
const NOW_MAX_ROWS = 6;
|
|
78
|
+
const RESULT_MAX_ROWS = 12;
|
|
79
|
+
/** Board width at which the field rows go 2-up, and the narrowest width that pads. */
|
|
80
|
+
const BOARD_WIDE_COLS = 110;
|
|
81
|
+
const BOARD_INLINE_COLS = 40;
|
|
82
|
+
/**
|
|
83
|
+
* The board's rows, in the order a reader wants them: what the dispatch did, then who
|
|
84
|
+
* is in it, then everything about the selected child, then its history.
|
|
85
|
+
*
|
|
86
|
+
* Bounded by `TEAM_LIMITS.boardMaxRows`, and the truncation is ANNOUNCED rather than
|
|
87
|
+
* silent - a list that stops mid-fact with no notice reads as a rendering bug.
|
|
88
|
+
*/
|
|
89
|
+
export function buildAgentBoardRows(input) {
|
|
90
|
+
const { source, glyphs } = input;
|
|
91
|
+
const cols = normalizeCols(input.cols);
|
|
92
|
+
const rows = [];
|
|
93
|
+
const selected = resolveSelection(source.runs, input.selectedLabel);
|
|
94
|
+
appendSummary(rows, input, selected);
|
|
95
|
+
appendChildren(rows, input, selected, cols);
|
|
96
|
+
if (selected !== null) {
|
|
97
|
+
appendFacts(rows, input, selected, cols);
|
|
98
|
+
appendProse(rows, input, selected, cols);
|
|
99
|
+
appendTimeline(rows, input, selected, cols);
|
|
100
|
+
}
|
|
101
|
+
if (rows.length > TEAM_LIMITS.boardMaxRows) {
|
|
102
|
+
const kept = rows.slice(0, TEAM_LIMITS.boardMaxRows);
|
|
103
|
+
const dropped = rows.length - TEAM_LIMITS.boardMaxRows;
|
|
104
|
+
kept[kept.length - 1] = {
|
|
105
|
+
key: 'truncated',
|
|
106
|
+
kind: 'hint',
|
|
107
|
+
text: fitCells(`+${dropped} more rows`, cols, glyphs.ellipsis),
|
|
108
|
+
tone: 'hint',
|
|
109
|
+
};
|
|
110
|
+
return kept;
|
|
111
|
+
}
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
function normalizeCols(value) {
|
|
115
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
116
|
+
}
|
|
117
|
+
/** A section title, then nothing else: callers add the body. */
|
|
118
|
+
function pushSection(rows, title, cols, glyphs) {
|
|
119
|
+
rows.push({
|
|
120
|
+
key: `section:${title}`,
|
|
121
|
+
kind: 'section',
|
|
122
|
+
text: fitCells(title, cols, glyphs.ellipsis),
|
|
123
|
+
tone: 'accent',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function pushBlank(rows, key) {
|
|
127
|
+
rows.push({ key: `blank:${key}`, kind: 'blank', text: '', tone: 'muted' });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Two summary lines, each a left clause and a right-anchored figure.
|
|
131
|
+
*
|
|
132
|
+
* The right figure is `alignRightCells`-ed rather than flex-ended because the row is a
|
|
133
|
+
* STRING here: the board hands `OverlayFrame` one element per row, so a row has to be
|
|
134
|
+
* a single measured line or the position indicator desynchronises.
|
|
135
|
+
*/
|
|
136
|
+
function summaryRow(rows, key, left, right, cols, glyphs, tone) {
|
|
137
|
+
if (cols === 0)
|
|
138
|
+
return;
|
|
139
|
+
const rightCells = Math.min(right.length, Math.max(0, cols - 1));
|
|
140
|
+
const leftCells = Math.max(0, cols - rightCells - 1);
|
|
141
|
+
const body = `${padCells(fitCells(left, leftCells, glyphs.ellipsis), leftCells)} ${right.slice(0, rightCells)}`;
|
|
142
|
+
rows.push({
|
|
143
|
+
key,
|
|
144
|
+
kind: 'summary',
|
|
145
|
+
text: padCells(fitCells(body, cols, glyphs.ellipsis), cols),
|
|
146
|
+
tone,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function appendSummary(rows, input, selected) {
|
|
150
|
+
const { source, glyphs } = input;
|
|
151
|
+
const cols = normalizeCols(input.cols);
|
|
152
|
+
const counts = rosterCounts(source.runs);
|
|
153
|
+
// A settled entry has no `startedAt`, so only `durationMs` is honest there; a live
|
|
154
|
+
// one may have neither (a dispatch that has not been stamped yet).
|
|
155
|
+
const totalMs = source.active && source.startedAt !== undefined
|
|
156
|
+
? Math.max(0, input.now - source.startedAt)
|
|
157
|
+
: source.durationMs;
|
|
158
|
+
const clock = totalMs === undefined ? '' : formatDuration(totalMs);
|
|
159
|
+
summaryRow(rows, 'summary:counts', rosterHeadline(counts, glyphs, cols), clock, cols, glyphs, 'primary');
|
|
160
|
+
const figures = [];
|
|
161
|
+
// THE LIVE-ONLY CLAUSE (I-15): no `messageCount` means no `mail` clause at all.
|
|
162
|
+
if (source.messageCount !== undefined)
|
|
163
|
+
figures.push(`mail ${source.messageCount}`);
|
|
164
|
+
const toolCalls = source.runs.reduce((sum, run) => sum + run.toolCalls, 0);
|
|
165
|
+
figures.push(`${toolCalls} tool calls`);
|
|
166
|
+
const usage = source.runs.reduce((acc, run) => ({
|
|
167
|
+
inputTokens: acc.inputTokens + run.usage.inputTokens,
|
|
168
|
+
outputTokens: acc.outputTokens + run.usage.outputTokens,
|
|
169
|
+
}), { inputTokens: 0, outputTokens: 0 });
|
|
170
|
+
figures.push(`${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out`);
|
|
171
|
+
const children = `${source.runs.length} children`;
|
|
172
|
+
if (cols >= BOARD_INLINE_COLS) {
|
|
173
|
+
summaryRow(rows, 'summary:figures', figures.join(` ${glyphs.midDot} `), children, cols, glyphs, 'muted');
|
|
174
|
+
}
|
|
175
|
+
pushBlank(rows, 'summary');
|
|
176
|
+
if (selected === null)
|
|
177
|
+
pushSection(rows, 'no children', cols, glyphs);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Every child, not the panel's `panelMaxRows` five: the board is the place a reader
|
|
181
|
+
* goes to see the child the panel folded away behind `+N more`.
|
|
182
|
+
*
|
|
183
|
+
* EACH ROW REUSES THE PANEL'S GEOMETRY one cell narrower and prefixes a cursor, so the
|
|
184
|
+
* columns line up with the panel exactly - "the same table at a second zoom level"
|
|
185
|
+
* (§1) rather than a second layout to keep in step.
|
|
186
|
+
*/
|
|
187
|
+
function appendChildren(rows, input, selected, cols) {
|
|
188
|
+
const { glyphs, source } = input;
|
|
189
|
+
pushSection(rows, 'children', cols, glyphs);
|
|
190
|
+
const inner = Math.max(0, cols - 1);
|
|
191
|
+
const geometry = teamRowGeometry(inner, glyphs);
|
|
192
|
+
for (const run of source.runs) {
|
|
193
|
+
const isSelected = selected !== null && run.label === selected.label;
|
|
194
|
+
const marker = statusMarkerCell(childStatus(run), glyphs);
|
|
195
|
+
const label = run.label.padEnd(8).slice(0, 8);
|
|
196
|
+
const descriptionCells = Math.max(0, inner - geometry.marker - geometry.label - geometry.gap - geometry.activity - geometry.clock);
|
|
197
|
+
const description = fitCells(run.description.replace(/\s+/g, ' '), descriptionCells, glyphs.ellipsis);
|
|
198
|
+
// THE MARKER TIER IS PASSED THROUGH (R-10): the ASCII tier's marker cell is 6 columns,
|
|
199
|
+
// so a budget computed against the Unicode tier's 3 would promise two columns this row
|
|
200
|
+
// does not have. `fitCells` is the second gate; the first one has to be told the truth
|
|
201
|
+
// or the text is ellipsised where it did fit.
|
|
202
|
+
const activity = fitCells(activityWithBadge(run, inner, input.now, geometry.marker), geometry.activity, glyphs.ellipsis);
|
|
203
|
+
const clock = run.startedAt === undefined
|
|
204
|
+
? ''
|
|
205
|
+
: formatDuration(Math.max(0, (run.endedAt ?? input.now) - run.startedAt));
|
|
206
|
+
const text = [
|
|
207
|
+
isSelected ? glyphs.cursor : ' ',
|
|
208
|
+
padCells(`${marker} `, geometry.marker),
|
|
209
|
+
padCells(label, geometry.label),
|
|
210
|
+
padCells(description, descriptionCells),
|
|
211
|
+
padCells(' '.repeat(geometry.gap), geometry.gap),
|
|
212
|
+
padCells(activity, geometry.activity),
|
|
213
|
+
alignRightCells(clock, geometry.clock, glyphs.ellipsis),
|
|
214
|
+
].join('');
|
|
215
|
+
rows.push({
|
|
216
|
+
key: `child:${run.label}`,
|
|
217
|
+
kind: 'child',
|
|
218
|
+
text: padCells(fitCells(text, cols, glyphs.ellipsis), cols),
|
|
219
|
+
tone: isSelected ? 'primary' : 'muted',
|
|
220
|
+
...(isSelected ? { selected: true } : {}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
pushBlank(rows, 'children');
|
|
224
|
+
}
|
|
225
|
+
/** The selected child's fixed-order fact list (§4.4.2's table, top to bottom). */
|
|
226
|
+
function appendFacts(rows, input, run, cols) {
|
|
227
|
+
const { glyphs } = input;
|
|
228
|
+
pushSection(rows, `${run.label} ${glyphs.midDot} ${run.description}`.trim(), cols, glyphs);
|
|
229
|
+
const facts = factPairs(run, glyphs);
|
|
230
|
+
// Two facts per row when there is room, one otherwise; the pairing is done here
|
|
231
|
+
// rather than in the component so the row COUNT is assertable.
|
|
232
|
+
const step = cols >= BOARD_WIDE_COLS ? 2 : 1;
|
|
233
|
+
for (let index = 0; index < facts.length; index += step) {
|
|
234
|
+
const first = facts[index];
|
|
235
|
+
if (first === undefined)
|
|
236
|
+
continue;
|
|
237
|
+
const second = step === 2 ? facts[index + 1] : undefined;
|
|
238
|
+
rows.push({
|
|
239
|
+
key: `field:${first[0]}:${index}`,
|
|
240
|
+
kind: 'field',
|
|
241
|
+
text: second === undefined
|
|
242
|
+
? fieldCells(first, cols, glyphs)
|
|
243
|
+
: fieldPairCells(first, second, cols, glyphs),
|
|
244
|
+
tone: 'primary',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
pushBlank(rows, 'facts');
|
|
248
|
+
}
|
|
249
|
+
/** The width one field occupies in the 2-up form: its label plus its value column. */
|
|
250
|
+
const FIELD_PAIR_CELLS = FIELD_LABEL_CELLS + FIELD_WIDE_VALUE_CELLS;
|
|
251
|
+
/** One `label value` field, padded so two of them stand in columns. */
|
|
252
|
+
function fieldCells(pair, cols, glyphs) {
|
|
253
|
+
const width = Math.max(0, cols);
|
|
254
|
+
return [
|
|
255
|
+
padCells(fitCells(pair[0], FIELD_LABEL_CELLS - 1, glyphs.ellipsis), FIELD_LABEL_CELLS - 1),
|
|
256
|
+
' ',
|
|
257
|
+
fitCells(pair[1], Math.max(0, width - FIELD_LABEL_CELLS), glyphs.ellipsis),
|
|
258
|
+
].join('');
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Two fields side by side, each with its own label and value column.
|
|
262
|
+
*
|
|
263
|
+
* BUILT FROM THE LEFT FIELD'S WIDTH, not from slicing a full-width row: a slice counts
|
|
264
|
+
* code units while a row is measured in cells, so a wide character in the first value
|
|
265
|
+
* could be cut in half and both columns would drift. `fieldCells` measures, so one space
|
|
266
|
+
* of gutter and a 34-cell left field are what fix the right field's offset.
|
|
267
|
+
*/
|
|
268
|
+
function fieldPairCells(first, second, cols, glyphs) {
|
|
269
|
+
const right = Math.max(0, cols - FIELD_PAIR_CELLS - 1);
|
|
270
|
+
return `${fieldCells(first, FIELD_PAIR_CELLS, glyphs)} ${fieldCells(second, right, glyphs)}`;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The facts, in a FIXED order, and with the zero-valued ones ABSENT.
|
|
274
|
+
*
|
|
275
|
+
* `supervisor` / `retries` / `compactions` / `truncated` only appear when they carry a
|
|
276
|
+
* fact; a row reading `supervisor 0` teaches a reader nothing and pushes the section
|
|
277
|
+
* a row down for every child that was never touched.
|
|
278
|
+
*/
|
|
279
|
+
export function factPairs(run, glyphs) {
|
|
280
|
+
const status = childStatus(run);
|
|
281
|
+
const state = status === 'running' ? `${status} ${glyphs.midDot} ${run.phase}` : status;
|
|
282
|
+
const pairs = [
|
|
283
|
+
['state', state],
|
|
284
|
+
['turns', String(run.turns)],
|
|
285
|
+
['tools', String(run.toolCalls)],
|
|
286
|
+
['tokens', `${formatTokens(run.usage.inputTokens)} / ${formatTokens(run.usage.outputTokens)}`],
|
|
287
|
+
];
|
|
288
|
+
if (run.filesTouched.length > 0) {
|
|
289
|
+
const head = run.filesTouched.slice(0, 3).join(', ');
|
|
290
|
+
const more = run.filesTouched.length > 3 ? ` (+${run.filesTouched.length - 3})` : '';
|
|
291
|
+
pairs.push(['files', `${run.filesTouched.length} (${head}${more})`]);
|
|
292
|
+
}
|
|
293
|
+
pairs.push(['mail', `${run.messagesSent} sent ${glyphs.midDot} ${run.blockedWaits ?? 0} blocked`]);
|
|
294
|
+
if ((run.interventions ?? 0) > 0 || (run.replacements ?? 0) > 0) {
|
|
295
|
+
const looks = `${run.interventions ?? 0} looks`;
|
|
296
|
+
const replaced = (run.replacements ?? 0) > 0 ? ` ${glyphs.midDot} ${run.replacements} replaced` : '';
|
|
297
|
+
const last = run.lastIntervention === undefined
|
|
298
|
+
? ''
|
|
299
|
+
: ` ${glyphs.midDot} ${run.lastIntervention.action}`;
|
|
300
|
+
pairs.push(['supervisor', `${looks}${replaced}${last}`]);
|
|
301
|
+
}
|
|
302
|
+
if (run.retry !== undefined) {
|
|
303
|
+
pairs.push(['retries', `retrying ${run.retry.attempt}/${run.retry.maxRetries}`]);
|
|
304
|
+
}
|
|
305
|
+
else if ((run.retries ?? 0) > 0) {
|
|
306
|
+
pairs.push(['retries', `${run.retries} cold start`]);
|
|
307
|
+
}
|
|
308
|
+
if ((run.compactions ?? 0) > 0)
|
|
309
|
+
pairs.push(['compactions', String(run.compactions)]);
|
|
310
|
+
if (run.truncated === true)
|
|
311
|
+
pairs.push(['truncated', 'stopped at turn cap']);
|
|
312
|
+
return pairs;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* `now` and `result`: the two prose sections, both folded by DISPLAY WIDTH.
|
|
316
|
+
*
|
|
317
|
+
* LIVE-ONLY, and honestly so: `now` needs `run.activity`, which is stripped on the way
|
|
318
|
+
* out of the dispatch, so a settled board says one line instead of drawing an empty
|
|
319
|
+
* section (I-15). `result` survives both sources and is the section a reader opens the
|
|
320
|
+
* board for when a child failed.
|
|
321
|
+
*/
|
|
322
|
+
function appendProse(rows, input, run, cols) {
|
|
323
|
+
const { glyphs } = input;
|
|
324
|
+
pushSection(rows, 'now', cols, glyphs);
|
|
325
|
+
const now = run.activity === undefined
|
|
326
|
+
? ''
|
|
327
|
+
: sanitizeActivity(run.activity, TEAM_LIMITS.timelineTextChars);
|
|
328
|
+
if (now.length === 0) {
|
|
329
|
+
pushUnavailable(rows, 'now', cols, glyphs);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const wrapped = wrapCells(now, Math.max(0, cols - 2), NOW_MAX_ROWS, glyphs.ellipsis);
|
|
333
|
+
wrapped.lines.forEach((line, index) => {
|
|
334
|
+
rows.push({ key: `now:${index}`, kind: 'event', text: fitCells(` ${line}`, cols, glyphs.ellipsis), tone: 'muted' });
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
pushSection(rows, 'result', cols, glyphs);
|
|
338
|
+
const result = run.error ?? run.summary ?? '';
|
|
339
|
+
if (result.length === 0) {
|
|
340
|
+
pushUnavailable(rows, 'result', cols, glyphs);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const wrapped = wrapCells(result, Math.max(0, cols - 2), RESULT_MAX_ROWS, glyphs.ellipsis);
|
|
344
|
+
wrapped.lines.forEach((line, index) => {
|
|
345
|
+
rows.push({
|
|
346
|
+
key: `result:${index}`,
|
|
347
|
+
kind: 'event',
|
|
348
|
+
text: fitCells(` ${line}`, cols, glyphs.ellipsis),
|
|
349
|
+
tone: run.error !== undefined ? 'error' : 'primary',
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The one honest sentence that replaces a section whose source is gone (§4.4.2 rule 2).
|
|
355
|
+
*
|
|
356
|
+
* QUOTE-SAFE AND PATH-FREE: it names the reason ("live only") rather than apologising,
|
|
357
|
+
* because the fact that matters is that the reader is not being shown a stale copy.
|
|
358
|
+
*/
|
|
359
|
+
function pushUnavailable(rows, section, cols, glyphs) {
|
|
360
|
+
rows.push({
|
|
361
|
+
key: `unavailable:${section}`,
|
|
362
|
+
kind: 'hint',
|
|
363
|
+
text: fitCells(' live only - not available after the dispatch ended', cols, glyphs.ellipsis),
|
|
364
|
+
tone: 'hint',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** The 24-event ring, oldest first, each prefixed with its offset from the start. */
|
|
368
|
+
function appendTimeline(rows, input, run, cols) {
|
|
369
|
+
const { glyphs } = input;
|
|
370
|
+
pushSection(rows, 'timeline', cols, glyphs);
|
|
371
|
+
const timeline = run.timeline ?? [];
|
|
372
|
+
if (timeline.length === 0) {
|
|
373
|
+
pushUnavailable(rows, 'timeline', cols, glyphs);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const start = run.startedAt ?? timeline[0]?.at ?? 0;
|
|
377
|
+
timeline.forEach((entry, index) => {
|
|
378
|
+
const offset = `+${formatDuration(Math.max(0, entry.at - start))}`;
|
|
379
|
+
const mark = entry.ok === undefined ? '' : entry.ok ? ' ok' : ' failed';
|
|
380
|
+
const took = entry.ms === undefined ? '' : ` ${formatDuration(entry.ms)}`;
|
|
381
|
+
const text = ` ${padCells(offset, 8)} ${entry.text}${took}${mark}`;
|
|
382
|
+
rows.push({
|
|
383
|
+
key: `event:${index}`,
|
|
384
|
+
kind: 'event',
|
|
385
|
+
text: fitCells(text, cols, glyphs.ellipsis),
|
|
386
|
+
tone: entry.ok === false ? 'warn' : 'muted',
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-width cell arithmetic (tui-subagent-panel §5 #1).
|
|
3
|
+
*
|
|
4
|
+
* EVERY FUNCTION HERE IS PURE AND TOTAL: no Ink, no clock, no terminal, and no
|
|
5
|
+
* input that throws. The panel row and the Agents board both need to place text
|
|
6
|
+
* in a FIXED number of terminal columns, and `padEnd` cannot do that: it counts
|
|
7
|
+
* code units while a terminal counts cells, so a CJK description padded with
|
|
8
|
+
* `padEnd` overflows its box by one column per wide character and pushes the
|
|
9
|
+
* next cell off the row. That is exactly the defect this round removes from the
|
|
10
|
+
* roster, one layer down.
|
|
11
|
+
*
|
|
12
|
+
* ASCII ONLY: `src/ui/**` is inside `glyphs.test.ts`'s static scan, so the
|
|
13
|
+
* ellipsis and any other symbol a caller wants must be passed IN (the board
|
|
14
|
+
* hands over `glyphs.ellipsis`), never spelled here.
|
|
15
|
+
*/
|
|
16
|
+
/** Display columns a string occupies, or 0 for a non-string (defensive at the edge only). */
|
|
17
|
+
export declare function cellWidth(text: string): number;
|
|
18
|
+
/**
|
|
19
|
+
* Cut `text` to at most `cells` display columns, marking the cut with `ellipsis`.
|
|
20
|
+
*
|
|
21
|
+
* THE ELLIPSIS IS PART OF THE BUDGET, not an extra column: a caller that asks for
|
|
22
|
+
* 8 cells gets a string that MEASURES 8, which is what makes a fixed-width cell
|
|
23
|
+
* a cell rather than a hope. When the budget cannot hold the ellipsis plus one
|
|
24
|
+
* character, the string is hard-cut instead - a marker wider than the cell it
|
|
25
|
+
* marks is the one outcome worse than no marker.
|
|
26
|
+
*
|
|
27
|
+
* Wide characters are never split: a surrogate pair or a double-width glyph that
|
|
28
|
+
* does not fit is dropped whole, so the result can measure one column less than
|
|
29
|
+
* asked but never one more.
|
|
30
|
+
*/
|
|
31
|
+
export declare function fitCells(text: string, cells: number, ellipsis?: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Pad `text` to exactly `cells` columns (or leave it alone when it already
|
|
34
|
+
* measures at least that).
|
|
35
|
+
*
|
|
36
|
+
* NOT `padEnd`: see this file's header. A padded cell is what keeps the next
|
|
37
|
+
* column at a stable offset, which is the whole point of the panel's table.
|
|
38
|
+
*/
|
|
39
|
+
export declare function padCells(text: string, cells: number): string;
|
|
40
|
+
/** `fitCells` then `padCells`: the result ALWAYS measures exactly `cells`. */
|
|
41
|
+
export declare function alignRightCells(text: string, cells: number, ellipsis?: string): string;
|
|
42
|
+
/** What one wrapped line is worth: the text, and whether lines were dropped. */
|
|
43
|
+
export interface WrappedCells {
|
|
44
|
+
lines: string[];
|
|
45
|
+
/** Lines that did not fit under `maxRows`; 0 when everything fitted. */
|
|
46
|
+
dropped: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Fold `text` into at most `maxRows` lines of at most `cells` columns each.
|
|
50
|
+
*
|
|
51
|
+
* BREAKS AT SPACES, HARD-CUTS ONLY WHEN A SINGLE TOKEN CANNOT FIT: a word that is
|
|
52
|
+
* wider than the column is the one case where an honest over-long token beats a
|
|
53
|
+
* line of nothing, so it is cut rather than pushed to a line that still cannot
|
|
54
|
+
* hold it. The last allowed line, when text remains, carries the `(+N lines)`
|
|
55
|
+
* tail so the reader learns that the section was truncated instead of believing
|
|
56
|
+
* the prose ended mid-sentence.
|
|
57
|
+
*
|
|
58
|
+
* Pure and total: `cells <= 0` or `maxRows <= 0` yields no lines, and the caller
|
|
59
|
+
* decides what an empty section means (the board omits it).
|
|
60
|
+
*/
|
|
61
|
+
export declare function wrapCells(text: string, cells: number, maxRows: number, ellipsis?: string): WrappedCells;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-width cell arithmetic (tui-subagent-panel §5 #1).
|
|
3
|
+
*
|
|
4
|
+
* EVERY FUNCTION HERE IS PURE AND TOTAL: no Ink, no clock, no terminal, and no
|
|
5
|
+
* input that throws. The panel row and the Agents board both need to place text
|
|
6
|
+
* in a FIXED number of terminal columns, and `padEnd` cannot do that: it counts
|
|
7
|
+
* code units while a terminal counts cells, so a CJK description padded with
|
|
8
|
+
* `padEnd` overflows its box by one column per wide character and pushes the
|
|
9
|
+
* next cell off the row. That is exactly the defect this round removes from the
|
|
10
|
+
* roster, one layer down.
|
|
11
|
+
*
|
|
12
|
+
* ASCII ONLY: `src/ui/**` is inside `glyphs.test.ts`'s static scan, so the
|
|
13
|
+
* ellipsis and any other symbol a caller wants must be passed IN (the board
|
|
14
|
+
* hands over `glyphs.ellipsis`), never spelled here.
|
|
15
|
+
*/
|
|
16
|
+
import stringWidth from 'string-width';
|
|
17
|
+
/** Invalid geometry becomes zero, exactly like the layout modules' `nonNegativeInt`. */
|
|
18
|
+
function nonNegativeInt(value) {
|
|
19
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
20
|
+
}
|
|
21
|
+
/** Display columns a string occupies, or 0 for a non-string (defensive at the edge only). */
|
|
22
|
+
export function cellWidth(text) {
|
|
23
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
24
|
+
return 0;
|
|
25
|
+
return stringWidth(text);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Cut `text` to at most `cells` display columns, marking the cut with `ellipsis`.
|
|
29
|
+
*
|
|
30
|
+
* THE ELLIPSIS IS PART OF THE BUDGET, not an extra column: a caller that asks for
|
|
31
|
+
* 8 cells gets a string that MEASURES 8, which is what makes a fixed-width cell
|
|
32
|
+
* a cell rather than a hope. When the budget cannot hold the ellipsis plus one
|
|
33
|
+
* character, the string is hard-cut instead - a marker wider than the cell it
|
|
34
|
+
* marks is the one outcome worse than no marker.
|
|
35
|
+
*
|
|
36
|
+
* Wide characters are never split: a surrogate pair or a double-width glyph that
|
|
37
|
+
* does not fit is dropped whole, so the result can measure one column less than
|
|
38
|
+
* asked but never one more.
|
|
39
|
+
*/
|
|
40
|
+
export function fitCells(text, cells, ellipsis = '...') {
|
|
41
|
+
const budget = nonNegativeInt(cells);
|
|
42
|
+
if (budget === 0)
|
|
43
|
+
return '';
|
|
44
|
+
if (cellWidth(text) <= budget)
|
|
45
|
+
return text;
|
|
46
|
+
const mark = cellWidth(ellipsis) < budget ? ellipsis : '';
|
|
47
|
+
const room = budget - cellWidth(mark);
|
|
48
|
+
let out = '';
|
|
49
|
+
let used = 0;
|
|
50
|
+
// `Array.from` iterates by CODE POINT, so a surrogate pair is one step and can
|
|
51
|
+
// never be cut in half by this loop.
|
|
52
|
+
for (const char of Array.from(text)) {
|
|
53
|
+
const width = cellWidth(char);
|
|
54
|
+
if (used + width > room)
|
|
55
|
+
break;
|
|
56
|
+
out += char;
|
|
57
|
+
used += width;
|
|
58
|
+
}
|
|
59
|
+
return `${out}${mark}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pad `text` to exactly `cells` columns (or leave it alone when it already
|
|
63
|
+
* measures at least that).
|
|
64
|
+
*
|
|
65
|
+
* NOT `padEnd`: see this file's header. A padded cell is what keeps the next
|
|
66
|
+
* column at a stable offset, which is the whole point of the panel's table.
|
|
67
|
+
*/
|
|
68
|
+
export function padCells(text, cells) {
|
|
69
|
+
const budget = nonNegativeInt(cells);
|
|
70
|
+
const width = cellWidth(text);
|
|
71
|
+
if (width >= budget)
|
|
72
|
+
return text;
|
|
73
|
+
return `${text}${' '.repeat(budget - width)}`;
|
|
74
|
+
}
|
|
75
|
+
/** `fitCells` then `padCells`: the result ALWAYS measures exactly `cells`. */
|
|
76
|
+
export function alignRightCells(text, cells, ellipsis = '...') {
|
|
77
|
+
const budget = nonNegativeInt(cells);
|
|
78
|
+
return padCells(fitCells(text, budget, ellipsis), budget);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Fold `text` into at most `maxRows` lines of at most `cells` columns each.
|
|
82
|
+
*
|
|
83
|
+
* BREAKS AT SPACES, HARD-CUTS ONLY WHEN A SINGLE TOKEN CANNOT FIT: a word that is
|
|
84
|
+
* wider than the column is the one case where an honest over-long token beats a
|
|
85
|
+
* line of nothing, so it is cut rather than pushed to a line that still cannot
|
|
86
|
+
* hold it. The last allowed line, when text remains, carries the `(+N lines)`
|
|
87
|
+
* tail so the reader learns that the section was truncated instead of believing
|
|
88
|
+
* the prose ended mid-sentence.
|
|
89
|
+
*
|
|
90
|
+
* Pure and total: `cells <= 0` or `maxRows <= 0` yields no lines, and the caller
|
|
91
|
+
* decides what an empty section means (the board omits it).
|
|
92
|
+
*/
|
|
93
|
+
export function wrapCells(text, cells, maxRows, ellipsis = '...') {
|
|
94
|
+
const width = nonNegativeInt(cells);
|
|
95
|
+
const limit = nonNegativeInt(maxRows);
|
|
96
|
+
const flat = (text ?? '').replace(/\s+/g, ' ').trim();
|
|
97
|
+
if (width === 0 || limit === 0 || flat.length === 0) {
|
|
98
|
+
return { lines: [], dropped: flat.length === 0 ? 0 : limit === 0 ? 1 : 0 };
|
|
99
|
+
}
|
|
100
|
+
const lines = [];
|
|
101
|
+
let rest = flat;
|
|
102
|
+
while (rest.length > 0) {
|
|
103
|
+
if (cellWidth(rest) <= width) {
|
|
104
|
+
lines.push(rest);
|
|
105
|
+
rest = '';
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
const cut = breakCells(rest, width);
|
|
109
|
+
lines.push(cut.line);
|
|
110
|
+
rest = cut.rest;
|
|
111
|
+
}
|
|
112
|
+
if (lines.length <= limit)
|
|
113
|
+
return { lines, dropped: 0 };
|
|
114
|
+
const kept = lines.slice(0, limit);
|
|
115
|
+
const dropped = lines.length - limit;
|
|
116
|
+
const tail = `(+${dropped} lines)`;
|
|
117
|
+
// THE ANNOUNCEMENT IS RESERVED, not appended and then truncated away: a reader who
|
|
118
|
+
// sees prose stop mid-sentence with no marker reads it as the end of the fact, which
|
|
119
|
+
// is exactly the wrong conclusion. When the column is too narrow to hold the marker
|
|
120
|
+
// at all, the line is simply cut - the caller's section is unusable at that width.
|
|
121
|
+
const tailCells = cellWidth(tail) + 1;
|
|
122
|
+
const last = kept[limit - 1] ?? '';
|
|
123
|
+
kept[limit - 1] = tailCells < width
|
|
124
|
+
? `${fitCells(last, width - tailCells, ellipsis)} ${tail}`
|
|
125
|
+
: fitCells(last, width, ellipsis);
|
|
126
|
+
return { lines: kept, dropped };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The longest prefix of `text` that fits `width` columns, preferring a space.
|
|
130
|
+
*
|
|
131
|
+
* Returns the consumed line and what is left, so the caller's loop is one
|
|
132
|
+
* assignment per iteration and the "one function does one thing" rule holds.
|
|
133
|
+
*/
|
|
134
|
+
function breakCells(text, width) {
|
|
135
|
+
let used = 0;
|
|
136
|
+
let taken = 0;
|
|
137
|
+
let lastSpace = -1;
|
|
138
|
+
for (const char of Array.from(text)) {
|
|
139
|
+
const charWidth = cellWidth(char);
|
|
140
|
+
if (used + charWidth > width)
|
|
141
|
+
break;
|
|
142
|
+
used += charWidth;
|
|
143
|
+
taken += char.length;
|
|
144
|
+
if (char === ' ')
|
|
145
|
+
lastSpace = taken;
|
|
146
|
+
}
|
|
147
|
+
// A single token wider than the column: hard-cut it rather than emit nothing.
|
|
148
|
+
if (taken === 0) {
|
|
149
|
+
const first = Array.from(text)[0] ?? '';
|
|
150
|
+
return { line: fitCells(first, width), rest: text.slice(first.length) };
|
|
151
|
+
}
|
|
152
|
+
if (lastSpace > 0) {
|
|
153
|
+
return { line: text.slice(0, lastSpace).trimEnd(), rest: text.slice(lastSpace).trimStart() };
|
|
154
|
+
}
|
|
155
|
+
return { line: text.slice(0, taken), rest: text.slice(taken).trimStart() };
|
|
156
|
+
}
|
|
@@ -46,7 +46,7 @@ export declare const HEADER_HINT_LIMITS: {
|
|
|
46
46
|
*/
|
|
47
47
|
readonly separatorCells: 2;
|
|
48
48
|
};
|
|
49
|
-
export type HeaderHintId = 'branch' | 'index' | 'detail';
|
|
49
|
+
export type HeaderHintId = 'branch' | 'prompts' | 'index' | 'kit' | 'detail';
|
|
50
50
|
export interface HeaderHintClause {
|
|
51
51
|
id: HeaderHintId;
|
|
52
52
|
text: string;
|
|
@@ -66,12 +66,32 @@ export interface HeaderHintPlan {
|
|
|
66
66
|
/**
|
|
67
67
|
* Plan the right-hand hints.
|
|
68
68
|
*
|
|
69
|
-
* The
|
|
70
|
-
* does not change, only the arithmetic behind it
|
|
69
|
+
* The inputs are the props `Header` receives plus the two switches that decide which
|
|
70
|
+
* clauses exist at all - the call site does not change, only the arithmetic behind it
|
|
71
|
+
* (spec 3.11 step 5).
|
|
71
72
|
*/
|
|
72
73
|
export declare function planHeaderHints(input: {
|
|
73
74
|
columns: number;
|
|
74
75
|
statusExpanded: boolean;
|
|
75
76
|
overlayOpen: boolean;
|
|
76
77
|
scrolledLines: number;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the `^K switch kit` clause exists at all (default `true`).
|
|
80
|
+
*
|
|
81
|
+
* `App` ALWAYS PASSES TRUE, including for a user with no kits: the whole point
|
|
82
|
+
* of the requirement is discoverability of `ctrl+K`, and an empty library is
|
|
83
|
+
* exactly the state that needs it (the picker's own `No kits yet` row tells
|
|
84
|
+
* them where to create one). `false` exists for tests and for the future
|
|
85
|
+
* switch, and it degrades the six expanded rungs back into today's four.
|
|
86
|
+
*/
|
|
87
|
+
kitHint?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Whether the `^P prompts` clause exists at all (default `true`).
|
|
90
|
+
*
|
|
91
|
+
* `false` is the `prompts.hint` config key and the only reason the ladder is
|
|
92
|
+
* parameterised: a user who turned the header hint off must get exactly the row
|
|
93
|
+
* they had before this feature, which is what the unfiltered ladder below
|
|
94
|
+
* degenerates into.
|
|
95
|
+
*/
|
|
96
|
+
promptsHint?: boolean;
|
|
77
97
|
}): HeaderHintPlan;
|