moqi-tui 0.2.0
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/LICENSE +21 -0
- package/README.md +782 -0
- package/bin/moqi.mjs +40 -0
- package/cordis.patch.yml +41 -0
- package/lib/cross-find.js +217 -0
- package/lib/file-index.js +121 -0
- package/lib/fleet-sources.js +114 -0
- package/lib/index.js +3999 -0
- package/lib/persist.js +194 -0
- package/lib/plugins.js +371 -0
- package/lib/presence.js +144 -0
- package/lib/rename.js +35 -0
- package/lib/rewind.js +94 -0
- package/lib/sessions-store.js +134 -0
- package/lib/startup.js +92 -0
- package/lib/tui/atfile.js +154 -0
- package/lib/tui/export.js +48 -0
- package/lib/tui/fleet.js +346 -0
- package/lib/tui/i18n.js +201 -0
- package/lib/tui/jobs.js +65 -0
- package/lib/tui/keys.js +205 -0
- package/lib/tui/markdown.js +368 -0
- package/lib/tui/mcp.js +95 -0
- package/lib/tui/panels.js +231 -0
- package/lib/tui/screen.js +156 -0
- package/lib/tui/state.js +502 -0
- package/lib/tui/stream.js +109 -0
- package/lib/tui/text.js +173 -0
- package/lib/tui/theme.js +183 -0
- package/lib/tui/themes.js +153 -0
- package/lib/tui/tooldetail.js +140 -0
- package/lib/tui/view.js +830 -0
- package/lib/tui/vim.js +222 -0
- package/lib/tui-host-core.js +141 -0
- package/lib/tui-host.js +48 -0
- package/lib/types/cross-find.d.ts +66 -0
- package/lib/types/file-index.d.ts +34 -0
- package/lib/types/fleet-sources.d.ts +34 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/persist.d.ts +116 -0
- package/lib/types/plugins.d.ts +218 -0
- package/lib/types/presence.d.ts +48 -0
- package/lib/types/rename.d.ts +32 -0
- package/lib/types/rewind.d.ts +75 -0
- package/lib/types/sessions-store.d.ts +46 -0
- package/lib/types/startup.d.ts +45 -0
- package/lib/types/tui/atfile.d.ts +90 -0
- package/lib/types/tui/export.d.ts +18 -0
- package/lib/types/tui/fleet.d.ts +209 -0
- package/lib/types/tui/i18n.d.ts +34 -0
- package/lib/types/tui/jobs.d.ts +28 -0
- package/lib/types/tui/keys.d.ts +52 -0
- package/lib/types/tui/markdown.d.ts +14 -0
- package/lib/types/tui/mcp.d.ts +34 -0
- package/lib/types/tui/panels.d.ts +125 -0
- package/lib/types/tui/screen.d.ts +79 -0
- package/lib/types/tui/state.d.ts +323 -0
- package/lib/types/tui/stream.d.ts +78 -0
- package/lib/types/tui/text.d.ts +28 -0
- package/lib/types/tui/theme.d.ts +87 -0
- package/lib/types/tui/themes.d.ts +70 -0
- package/lib/types/tui/tooldetail.d.ts +45 -0
- package/lib/types/tui/view.d.ts +163 -0
- package/lib/types/tui/vim.d.ts +64 -0
- package/lib/types/tui-host-core.d.ts +62 -0
- package/lib/types/tui-host.d.ts +42 -0
- package/lib/types/version.d.ts +8 -0
- package/lib/types/voice.d.ts +227 -0
- package/lib/version.js +32 -0
- package/lib/voice.js +405 -0
- package/package.json +119 -0
- package/scripts/harness-root.mjs +88 -0
- package/scripts/install-profile.mjs +133 -0
package/lib/tui/view.js
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame composition: header, transcript, palette popup, composer, footer.
|
|
3
|
+
*
|
|
4
|
+
* The renderer is pure — it turns a snapshot of the app into the exact lines
|
|
5
|
+
* the screen should show, and reports where the cursor belongs. Nothing here
|
|
6
|
+
* touches the terminal or the Harness.
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { fleetLineOf, fleetSummary, renderFleet, } from "./fleet.js";
|
|
10
|
+
import { renderMarkdown } from "./markdown.js";
|
|
11
|
+
import { Composer, estimateTokens, formatTokens, MAX_INPUT_LINES, messageText, messageTools, segmentsText, } from "./state.js";
|
|
12
|
+
import { displayWidth, padEnd, stripAnsi, truncate, wrap } from "./text.js";
|
|
13
|
+
import { accent, bold, colAccent, colBorder, colGold, colGreen, colMuted, colRose, colText, colWarn, muted, ok, selected, style, warn, } from "./theme.js";
|
|
14
|
+
import { isImagePath } from "./atfile.js";
|
|
15
|
+
import { helpText, t, translate } from "./i18n.js";
|
|
16
|
+
/** Most file-completion rows listed at once before the popup scrolls. */
|
|
17
|
+
const MAX_AT_ROWS = 6;
|
|
18
|
+
/** Rows of chrome the layout reserves around the transcript. */
|
|
19
|
+
const HEADER_ROWS = 2;
|
|
20
|
+
const FOOTER_ROWS = 1;
|
|
21
|
+
const GAP_ROWS = 1;
|
|
22
|
+
const MIN_VIEWPORT_ROWS = 3;
|
|
23
|
+
const POPUP_BORDER_ROWS = 2;
|
|
24
|
+
/** Most background agents listed at once before the panel scrolls. */
|
|
25
|
+
const MAX_BACKGROUND_ROWS = 6;
|
|
26
|
+
/** The usable width inside the one-column gutter on each side. */
|
|
27
|
+
function contentWidth(columns) {
|
|
28
|
+
// Never wider than the window: a floor here would push styled rows past the
|
|
29
|
+
// right edge on a very narrow terminal instead of merely looking cramped.
|
|
30
|
+
return Math.max(Math.min(columns - 2, columns), 4);
|
|
31
|
+
}
|
|
32
|
+
/** Compute the geometry for a frame. */
|
|
33
|
+
export function layout(snapshot) {
|
|
34
|
+
const width = contentWidth(snapshot.columns);
|
|
35
|
+
const composerRows = snapshot.composer.height(width - 4);
|
|
36
|
+
const inputRows = composerRows + 2;
|
|
37
|
+
let paletteRows = 0;
|
|
38
|
+
if (snapshot.palette.open) {
|
|
39
|
+
const available = snapshot.rows -
|
|
40
|
+
HEADER_ROWS -
|
|
41
|
+
GAP_ROWS -
|
|
42
|
+
inputRows -
|
|
43
|
+
FOOTER_ROWS -
|
|
44
|
+
MIN_VIEWPORT_ROWS -
|
|
45
|
+
POPUP_BORDER_ROWS;
|
|
46
|
+
paletteRows = Math.max(Math.min(snapshot.palette.matches.length, available), 0);
|
|
47
|
+
}
|
|
48
|
+
const paletteHeight = paletteRows > 0 ? paletteRows + POPUP_BORDER_ROWS : 0;
|
|
49
|
+
let atRows = 0;
|
|
50
|
+
if (snapshot.atMenu?.open === true) {
|
|
51
|
+
const available = snapshot.rows -
|
|
52
|
+
HEADER_ROWS -
|
|
53
|
+
GAP_ROWS -
|
|
54
|
+
inputRows -
|
|
55
|
+
FOOTER_ROWS -
|
|
56
|
+
MIN_VIEWPORT_ROWS -
|
|
57
|
+
POPUP_BORDER_ROWS -
|
|
58
|
+
paletteHeight;
|
|
59
|
+
atRows = Math.max(Math.min(snapshot.atMenu.matches.length, MAX_AT_ROWS, available), 0);
|
|
60
|
+
}
|
|
61
|
+
const atHeight = atRows > 0 ? atRows + POPUP_BORDER_ROWS : 0;
|
|
62
|
+
// The tab bar earns its row only once there is more than one session.
|
|
63
|
+
let sessionRows = snapshot.sessions.length > 1 ? 1 : 0;
|
|
64
|
+
// A plugin's status line is one row, surrendered first when space is short.
|
|
65
|
+
let pluginRows = snapshot.pluginLine !== undefined && snapshot.pluginLine.trim() !== '' ? 1 : 0;
|
|
66
|
+
// The background strip is one line when collapsed, or a bordered list.
|
|
67
|
+
let backgroundRows = 0;
|
|
68
|
+
if (snapshot.background.length > 0) {
|
|
69
|
+
backgroundRows = snapshot.expandBackground
|
|
70
|
+
? Math.min(snapshot.background.length, MAX_BACKGROUND_ROWS) + POPUP_BORDER_ROWS
|
|
71
|
+
: 1;
|
|
72
|
+
}
|
|
73
|
+
// The composer and the footer are the last things to go: on a window too
|
|
74
|
+
// short for everything, shed the header, then the separator row, and only
|
|
75
|
+
// then let the transcript collapse to nothing.
|
|
76
|
+
let showHeader = true;
|
|
77
|
+
let showGap = true;
|
|
78
|
+
const chrome = () => (showHeader ? HEADER_ROWS : 0) +
|
|
79
|
+
sessionRows +
|
|
80
|
+
(showGap ? GAP_ROWS : 0) +
|
|
81
|
+
paletteHeight +
|
|
82
|
+
atHeight +
|
|
83
|
+
pluginRows +
|
|
84
|
+
backgroundRows +
|
|
85
|
+
inputRows +
|
|
86
|
+
FOOTER_ROWS;
|
|
87
|
+
const spare = () => snapshot.rows - chrome();
|
|
88
|
+
// Shed chrome until the transcript has its minimum, cheapest first: the
|
|
89
|
+
// expanded agent list collapses to its one-line form, then the header goes,
|
|
90
|
+
// then the separator, and only a window too small for even that loses the
|
|
91
|
+
// strip entirely. The transcript outranks all of them — a frame showing a
|
|
92
|
+
// four-row agent panel and no conversation would be the wrong trade.
|
|
93
|
+
if (spare() < MIN_VIEWPORT_ROWS && pluginRows > 0)
|
|
94
|
+
pluginRows = 0;
|
|
95
|
+
if (spare() < MIN_VIEWPORT_ROWS && backgroundRows > 1)
|
|
96
|
+
backgroundRows = 1;
|
|
97
|
+
if (spare() < MIN_VIEWPORT_ROWS && showHeader)
|
|
98
|
+
showHeader = false;
|
|
99
|
+
if (spare() < MIN_VIEWPORT_ROWS && showGap)
|
|
100
|
+
showGap = false;
|
|
101
|
+
if (spare() < MIN_VIEWPORT_ROWS && backgroundRows > 0)
|
|
102
|
+
backgroundRows = 0;
|
|
103
|
+
if (spare() < MIN_VIEWPORT_ROWS && sessionRows > 0)
|
|
104
|
+
sessionRows = 0;
|
|
105
|
+
const viewportRows = Math.max(spare(), 0);
|
|
106
|
+
return {
|
|
107
|
+
contentWidth: width,
|
|
108
|
+
viewportRows,
|
|
109
|
+
paletteRows,
|
|
110
|
+
atRows,
|
|
111
|
+
pluginRows,
|
|
112
|
+
inputRows,
|
|
113
|
+
backgroundRows,
|
|
114
|
+
sessionRows,
|
|
115
|
+
showHeader,
|
|
116
|
+
showGap,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Strip the scheme and trailing slash from a base URL for the header. */
|
|
120
|
+
export function hostLabel(base) {
|
|
121
|
+
return base.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
|
122
|
+
}
|
|
123
|
+
/** The `dsh` header line: mark and title on the left, host on the right. */
|
|
124
|
+
function header(snapshot, width) {
|
|
125
|
+
const title = snapshot.title === '' ? 'new conversation' : snapshot.title;
|
|
126
|
+
const left = `${bold('◆ moqi')}${muted(` ${title}`)}`;
|
|
127
|
+
const right = muted(snapshot.host);
|
|
128
|
+
const gap = width - displayWidth(left) - displayWidth(right);
|
|
129
|
+
if (gap < 2) {
|
|
130
|
+
return `${bold('◆ moqi')}${muted(` ${truncate(title, Math.max(width - 8, 4))}`)}`;
|
|
131
|
+
}
|
|
132
|
+
return left + ' '.repeat(gap) + right;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* One tool call, where it happened.
|
|
136
|
+
*
|
|
137
|
+
* A call is one line — its mark, its name, and what it does — so it reads as a
|
|
138
|
+
* step the agent took between two things it said, and the prose on either side
|
|
139
|
+
* keeps its own shape. `ctrl+o` adds each call's outcome underneath the very
|
|
140
|
+
* call that produced it. The call in flight carries the spinner and its elapsed
|
|
141
|
+
* time instead of a status mark.
|
|
142
|
+
*/
|
|
143
|
+
function renderTool(tool, width, toolStyle) {
|
|
144
|
+
const mark = tool.status === 'running'
|
|
145
|
+
? toolStyle.spinner === ''
|
|
146
|
+
? style('●', { fg: colGreen })
|
|
147
|
+
: style(toolStyle.spinner, { fg: colAccent })
|
|
148
|
+
: tool.status === 'ok'
|
|
149
|
+
? ok('✓')
|
|
150
|
+
: warn('✗');
|
|
151
|
+
// The elapsed time belongs to the call in hand, not to the turn: it is the
|
|
152
|
+
// one number that says "this is still going" rather than "this took a while".
|
|
153
|
+
const elapsed = tool.status === 'running' && toolStyle.elapsed > 0
|
|
154
|
+
? muted(` ${formatElapsed(toolStyle.elapsed)}`)
|
|
155
|
+
: '';
|
|
156
|
+
const detail = tool.detail === undefined || tool.detail === ''
|
|
157
|
+
? ''
|
|
158
|
+
: muted(` ${truncate(tool.detail, Math.max(width - displayWidth(tool.name) - displayWidth(elapsed) - 8, 8))}`);
|
|
159
|
+
const head = truncate(`${mark} ${style(tool.name, { fg: colText })}${detail}${elapsed}`, width);
|
|
160
|
+
if (!toolStyle.expand)
|
|
161
|
+
return [head];
|
|
162
|
+
const result = tool.result ?? '';
|
|
163
|
+
if (result === '')
|
|
164
|
+
return [head];
|
|
165
|
+
const under = (tool.status === 'error' ? warn : muted)(` ↳ ${truncate(result, Math.max(width - 4, 8))}`);
|
|
166
|
+
return [head, under];
|
|
167
|
+
}
|
|
168
|
+
/** Seconds as a compact duration: 8s, 1m12s. */
|
|
169
|
+
function formatElapsed(seconds) {
|
|
170
|
+
if (seconds < 60)
|
|
171
|
+
return `${String(seconds)}s`;
|
|
172
|
+
return `${String(Math.floor(seconds / 60))}m${String(seconds % 60)}s`;
|
|
173
|
+
}
|
|
174
|
+
function renderMessage(message, width, showThinking, toolStyle, selectedTurn = false) {
|
|
175
|
+
const content = renderMessageBody(message, width, showThinking, toolStyle);
|
|
176
|
+
if (!selectedTurn)
|
|
177
|
+
return content;
|
|
178
|
+
// A selection is a frame, not a repaint: the gold bar marks the turn whose
|
|
179
|
+
// text `alt+c` would copy without disturbing any of the turn's own styling.
|
|
180
|
+
const bar = style('▏', { fg: colGold });
|
|
181
|
+
return content.map((line) => `${bar}${line}`);
|
|
182
|
+
}
|
|
183
|
+
/** One turn's lines, without selection decoration. */
|
|
184
|
+
function renderMessageBody(message, width, showThinking, toolStyle) {
|
|
185
|
+
const out = [];
|
|
186
|
+
if (message.role === 'user') {
|
|
187
|
+
const bar = style('▌', { fg: message.steering === true ? colMuted : colAccent });
|
|
188
|
+
for (const line of wrap(messageText(message), width - 2)) {
|
|
189
|
+
out.push(message.steering === true ? `${bar} ${muted(line)}` : `${bar} ${style(line, { fg: colText })}`);
|
|
190
|
+
}
|
|
191
|
+
if ((message.attachments ?? []).length > 0) {
|
|
192
|
+
const names = (message.attachments ?? [])
|
|
193
|
+
.map((image) => `🖼 ${image.name} ${String(image.width)}×${String(image.height)}`)
|
|
194
|
+
.join(' ');
|
|
195
|
+
out.push(`${bar} ${muted(names)}`);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
if (message.command !== undefined) {
|
|
200
|
+
const mark = message.command.ok ? ok('✓') : warn('✗');
|
|
201
|
+
const label = style(`/${message.command.name}`, { fg: colAccent });
|
|
202
|
+
out.push(`${mark} ${label}`);
|
|
203
|
+
for (const line of wrap(messageText(message), width - 2)) {
|
|
204
|
+
out.push(` ${muted(line)}`);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
if (showThinking && (message.reasoning ?? '').trim() !== '') {
|
|
209
|
+
const bar = style('┆', { fg: colMuted });
|
|
210
|
+
for (const line of wrap((message.reasoning ?? '').trim(), width - 2)) {
|
|
211
|
+
out.push(`${bar} ${style(line, { fg: colMuted, italic: true })}`);
|
|
212
|
+
}
|
|
213
|
+
if (out.length > 0)
|
|
214
|
+
out.push('');
|
|
215
|
+
}
|
|
216
|
+
// The turn in the order it happened: what the agent said, the call it made,
|
|
217
|
+
// what it said next. Each piece is rendered as itself and separated by a
|
|
218
|
+
// blank line, so neither the prose nor the calls run together.
|
|
219
|
+
for (const segment of message.segments) {
|
|
220
|
+
const lines = segment.kind === 'tool'
|
|
221
|
+
? renderTool(segment.tool, width, toolStyle)
|
|
222
|
+
: segment.text.trim() === ''
|
|
223
|
+
? []
|
|
224
|
+
: renderMarkdown(segment.text.trim(), width).split('\n');
|
|
225
|
+
if (lines.length === 0)
|
|
226
|
+
continue;
|
|
227
|
+
if (out.length > 0)
|
|
228
|
+
out.push('');
|
|
229
|
+
out.push(...lines);
|
|
230
|
+
}
|
|
231
|
+
// The collapsed summary used to be what advertised ctrl+o. Now that calls
|
|
232
|
+
// render in place there is no summary, so the hint goes where it is still
|
|
233
|
+
// true: once per turn, and only when expanding would actually reveal
|
|
234
|
+
// something that is currently hidden.
|
|
235
|
+
const hidden = !toolStyle.expand &&
|
|
236
|
+
messageTools(message).some((tool) => tool.result !== undefined && tool.result !== '');
|
|
237
|
+
if (hidden)
|
|
238
|
+
out.push(muted(' ctrl+o for detail'));
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
const messageLineCache = new WeakMap();
|
|
242
|
+
/** Render one message through the cache. */
|
|
243
|
+
function cachedMessageLines(message, width, showThinking, toolStyle, selected) {
|
|
244
|
+
// A streaming turn animates and a selected turn carries a bar, so neither
|
|
245
|
+
// may be served from the cache of its unmarked form.
|
|
246
|
+
const animating = toolStyle.spinner !== '' && messageTools(message).length > 0;
|
|
247
|
+
if (animating || selected) {
|
|
248
|
+
return renderMessage(message, width, showThinking, toolStyle, selected);
|
|
249
|
+
}
|
|
250
|
+
const hit = messageLineCache.get(message);
|
|
251
|
+
if (hit !== undefined &&
|
|
252
|
+
hit.width === width &&
|
|
253
|
+
hit.expand === toolStyle.expand &&
|
|
254
|
+
hit.showThinking === showThinking) {
|
|
255
|
+
return hit.lines;
|
|
256
|
+
}
|
|
257
|
+
const lines = renderMessage(message, width, showThinking, toolStyle);
|
|
258
|
+
messageLineCache.set(message, { width, expand: toolStyle.expand, showThinking, lines });
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
function transcript(snapshot, width) {
|
|
262
|
+
// A settled turn never animates, so its spinner frame is irrelevant.
|
|
263
|
+
const settled = { expand: snapshot.expandTools, spinner: '', elapsed: 0 };
|
|
264
|
+
const live = {
|
|
265
|
+
expand: snapshot.expandTools,
|
|
266
|
+
spinner: snapshot.spinner,
|
|
267
|
+
elapsed: snapshot.elapsedSeconds,
|
|
268
|
+
};
|
|
269
|
+
const blocks = [];
|
|
270
|
+
snapshot.messages.forEach((message, index) => {
|
|
271
|
+
const rendered = cachedMessageLines(message, width, snapshot.showThinking, settled, snapshot.selectedTurn === index);
|
|
272
|
+
if (rendered.length > 0)
|
|
273
|
+
blocks.push(rendered);
|
|
274
|
+
});
|
|
275
|
+
if (snapshot.streaming) {
|
|
276
|
+
const running = renderMessage({
|
|
277
|
+
role: 'assistant',
|
|
278
|
+
segments: snapshot.streamingSegments,
|
|
279
|
+
reasoning: snapshot.streamingReasoning,
|
|
280
|
+
}, width, snapshot.showThinking, live);
|
|
281
|
+
if (running.length > 0)
|
|
282
|
+
blocks.push(running);
|
|
283
|
+
}
|
|
284
|
+
const queued = snapshot.queued ?? [];
|
|
285
|
+
if (queued.length > 0) {
|
|
286
|
+
// Queued prompts borrow the user bar's shape but read as waiting: a
|
|
287
|
+
// muted marker and dim text, so they are recognizably yours-to-come
|
|
288
|
+
// rather than already-sent.
|
|
289
|
+
const bar = muted('▌');
|
|
290
|
+
const block = [];
|
|
291
|
+
for (const text of queued) {
|
|
292
|
+
for (const line of wrap(text.replace(/\s+$/, ''), width - 2)) {
|
|
293
|
+
block.push(`${bar} ${style(line, { fg: colMuted, dim: true })}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
block.push(muted('· queued — sends when the reply finishes'));
|
|
297
|
+
blocks.push(block);
|
|
298
|
+
}
|
|
299
|
+
const out = [];
|
|
300
|
+
blocks.forEach((block, index) => {
|
|
301
|
+
if (index > 0)
|
|
302
|
+
out.push('');
|
|
303
|
+
out.push(...block);
|
|
304
|
+
});
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
/** The first-run panel, shown while the transcript is empty. */
|
|
308
|
+
function welcome(snapshot) {
|
|
309
|
+
return [
|
|
310
|
+
bold(t('welcome.title')),
|
|
311
|
+
'',
|
|
312
|
+
muted(t('welcome.connected', { host: snapshot.host, model: snapshot.modelName })),
|
|
313
|
+
muted(t('welcome.harness')),
|
|
314
|
+
'',
|
|
315
|
+
`${muted(t('welcome.type'))}${accent('/')}${muted(t('welcome.forCommands'))}`,
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
/** The scrollable transcript pane, anchored to the bottom. */
|
|
319
|
+
/** Everything the transcript pane would show, before scrolling or clipping. */
|
|
320
|
+
function bodyLines(snapshot, width) {
|
|
321
|
+
if (snapshot.overlay !== '')
|
|
322
|
+
return renderMarkdown(snapshot.overlay, width).split('\n');
|
|
323
|
+
if (snapshot.messages.length === 0 && !snapshot.streaming)
|
|
324
|
+
return welcome(snapshot);
|
|
325
|
+
return transcript(snapshot, width);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* How far back the transcript can scroll: anything beyond this is empty space
|
|
329
|
+
* above the first line, so the caller clamps to it rather than letting the view
|
|
330
|
+
* drift off the top.
|
|
331
|
+
*/
|
|
332
|
+
export function maxScrollBack(snapshot) {
|
|
333
|
+
const geometry = layout(snapshot);
|
|
334
|
+
const body = bodyLines(snapshot, geometry.contentWidth);
|
|
335
|
+
return Math.max(body.length - geometry.viewportRows, 0);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Lines of the rendered body that contain `query`, case-insensitively.
|
|
339
|
+
*
|
|
340
|
+
* Matching runs over the printable text of each line — the styled form is full
|
|
341
|
+
* of SGR escapes the user never typed — and returns indexes into the same
|
|
342
|
+
* line array the viewport slices, so a hit can be scrolled to directly.
|
|
343
|
+
*/
|
|
344
|
+
export function findMatches(snapshot, query) {
|
|
345
|
+
const needle = query.trim().toLowerCase();
|
|
346
|
+
if (needle === '')
|
|
347
|
+
return [];
|
|
348
|
+
const geometry = layout(snapshot);
|
|
349
|
+
const hits = [];
|
|
350
|
+
bodyLines(snapshot, geometry.contentWidth).forEach((line, index) => {
|
|
351
|
+
if (stripAnsi(line).toLowerCase().includes(needle))
|
|
352
|
+
hits.push(index);
|
|
353
|
+
});
|
|
354
|
+
return hits;
|
|
355
|
+
}
|
|
356
|
+
function viewport(snapshot, geometry) {
|
|
357
|
+
const width = geometry.contentWidth;
|
|
358
|
+
const body = bodyLines(snapshot, width);
|
|
359
|
+
const height = geometry.viewportRows;
|
|
360
|
+
if (body.length <= height) {
|
|
361
|
+
// Anchor short transcripts to the bottom so the conversation grows upward
|
|
362
|
+
// out of the composer rather than hanging from the top of the screen.
|
|
363
|
+
return [...Array(height - body.length).fill(''), ...body];
|
|
364
|
+
}
|
|
365
|
+
const maxStart = body.length - height;
|
|
366
|
+
const start = Math.max(Math.min(maxStart - snapshot.scrollBack, maxStart), 0);
|
|
367
|
+
return body.slice(start, start + height);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The picker pane, which replaces the transcript while it is open.
|
|
371
|
+
*
|
|
372
|
+
* Grouped mode prints a header whenever the subtitle changes, so a model list
|
|
373
|
+
* reads provider by provider. Headers are laid out as part of the scrolling
|
|
374
|
+
* body, which is why the visible window is computed over rendered lines rather
|
|
375
|
+
* than over items.
|
|
376
|
+
*/
|
|
377
|
+
function pickerPane(snapshot, geometry) {
|
|
378
|
+
const width = geometry.contentWidth;
|
|
379
|
+
const height = geometry.viewportRows;
|
|
380
|
+
const picker = snapshot.picker;
|
|
381
|
+
const matches = picker.matches();
|
|
382
|
+
// Title line, plus a filter line that doubles as the query display.
|
|
383
|
+
const head = [bold(picker.title)];
|
|
384
|
+
const hint = picker.query === '' ? muted('type to filter') : '';
|
|
385
|
+
head.push(`${muted('› ')}${style(picker.query, { fg: colText })}${hint}`);
|
|
386
|
+
head.push('');
|
|
387
|
+
// Build every body line, remembering which one carries the selection.
|
|
388
|
+
const body = [];
|
|
389
|
+
let selectedLine = -1;
|
|
390
|
+
let group = '';
|
|
391
|
+
matches.forEach((item, index) => {
|
|
392
|
+
if (picker.grouped && item.subtitle !== group) {
|
|
393
|
+
group = item.subtitle;
|
|
394
|
+
if (body.length > 0)
|
|
395
|
+
body.push('');
|
|
396
|
+
body.push(style(group, { fg: colAccent, bold: true }));
|
|
397
|
+
}
|
|
398
|
+
const marker = item.active === true ? '● ' : ' ';
|
|
399
|
+
const right = picker.grouped ? '' : item.subtitle;
|
|
400
|
+
const rightWidth = displayWidth(right);
|
|
401
|
+
const titleWidth = Math.max(width - rightWidth - displayWidth(marker) - 3, 8);
|
|
402
|
+
const label = truncate(item.title.replace(/\n/g, ' '), titleWidth);
|
|
403
|
+
const pad = Math.max(width - displayWidth(label) - rightWidth - displayWidth(marker) - 1, 1);
|
|
404
|
+
const row = ` ${marker}${label}${' '.repeat(pad)}${right}`;
|
|
405
|
+
if (index === picker.selected)
|
|
406
|
+
selectedLine = body.length;
|
|
407
|
+
body.push(index === picker.selected ? selected(padEnd(row, width)) : muted(padEnd(row, width)));
|
|
408
|
+
});
|
|
409
|
+
if (matches.length === 0)
|
|
410
|
+
body.push(muted(' no matches'));
|
|
411
|
+
// Scroll so the selected line stays visible.
|
|
412
|
+
const bodyHeight = Math.max(height - head.length - 1, 1);
|
|
413
|
+
let start = 0;
|
|
414
|
+
if (selectedLine >= bodyHeight)
|
|
415
|
+
start = selectedLine - bodyHeight + 1;
|
|
416
|
+
const visible = body.slice(start, start + bodyHeight);
|
|
417
|
+
const out = [...head, ...visible];
|
|
418
|
+
while (out.length < height - 1)
|
|
419
|
+
out.push('');
|
|
420
|
+
const action = picker.kind === 'models' || picker.kind === 'themes'
|
|
421
|
+
? 'select'
|
|
422
|
+
: picker.kind === 'plugins'
|
|
423
|
+
? 'enable or disable'
|
|
424
|
+
: picker.kind === 'delete' ? 'delete' : 'open';
|
|
425
|
+
const count = `${matches.length}/${picker.items.length}`;
|
|
426
|
+
out.push(muted(`↑↓ move · enter ${action} · esc back`) +
|
|
427
|
+
' '.repeat(Math.max(width -
|
|
428
|
+
displayWidth(`↑↓ move · enter ${action} · esc back`) -
|
|
429
|
+
displayWidth(count), 1)) +
|
|
430
|
+
muted(count));
|
|
431
|
+
return out.slice(0, height);
|
|
432
|
+
}
|
|
433
|
+
/** The slash-command popup drawn above the composer. */
|
|
434
|
+
function palettePane(snapshot, geometry) {
|
|
435
|
+
const rows = geometry.paletteRows;
|
|
436
|
+
if (rows < 1)
|
|
437
|
+
return [];
|
|
438
|
+
const width = geometry.contentWidth;
|
|
439
|
+
const inner = Math.max(width - 4, 10);
|
|
440
|
+
const start = snapshot.palette.selected >= rows ? snapshot.palette.selected - rows + 1 : 0;
|
|
441
|
+
const visible = snapshot.palette.matches.slice(start, start + rows);
|
|
442
|
+
let nameColumn = 0;
|
|
443
|
+
for (const command of visible) {
|
|
444
|
+
const label = `/${command.name}${command.args === '' ? '' : ` ${command.args}`}`;
|
|
445
|
+
nameColumn = Math.max(nameColumn, label.length);
|
|
446
|
+
}
|
|
447
|
+
nameColumn += 2;
|
|
448
|
+
const body = visible.map((command, index) => {
|
|
449
|
+
const label = `/${command.name}${command.args === '' ? '' : ` ${command.args}`}`;
|
|
450
|
+
const pad = Math.max(nameColumn - label.length, 1);
|
|
451
|
+
const row = truncate(`${label}${' '.repeat(pad)}${command.description}`, inner);
|
|
452
|
+
const padded = padEnd(row, inner);
|
|
453
|
+
return start + index === snapshot.palette.selected ? selected(padded) : muted(padded);
|
|
454
|
+
});
|
|
455
|
+
return box(body, inner, colAccent);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The `@` file-completion popup drawn between the palette and the composer.
|
|
459
|
+
*
|
|
460
|
+
* Directories carry a trailing slash and images a mark, so the shape of the
|
|
461
|
+
* workspace is legible without color. The row count is bounded by the layout,
|
|
462
|
+
* so a huge workspace scrolls the list rather than the transcript.
|
|
463
|
+
*/
|
|
464
|
+
function atPane(snapshot, geometry) {
|
|
465
|
+
const menu = snapshot.atMenu;
|
|
466
|
+
const rows = geometry.atRows;
|
|
467
|
+
if (menu === undefined || !menu.open || rows < 1)
|
|
468
|
+
return [];
|
|
469
|
+
const inner = Math.max(geometry.contentWidth - 4, 10);
|
|
470
|
+
const start = menu.selected >= rows ? menu.selected - rows + 1 : 0;
|
|
471
|
+
const visible = menu.matches.slice(start, start + rows);
|
|
472
|
+
const body = visible.map((match, index) => {
|
|
473
|
+
const label = match.directory ? `${match.path}/` : match.path;
|
|
474
|
+
const marked = isImagePath(label) ? `🖼 ${label}` : ` ${label}`;
|
|
475
|
+
const padded = padEnd(truncate(marked, inner), inner);
|
|
476
|
+
return start + index === menu.selected ? selected(padded) : muted(padded);
|
|
477
|
+
});
|
|
478
|
+
return box(body, inner, colBorder);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The session tab bar.
|
|
482
|
+
*
|
|
483
|
+
* Only drawn with more than one session open. Each tab carries a status mark —
|
|
484
|
+
* a spinner while its turn runs, a filled dot when a finished answer is
|
|
485
|
+
* waiting, nothing when it has been seen — so an unattended session advertises
|
|
486
|
+
* itself without stealing the screen.
|
|
487
|
+
*/
|
|
488
|
+
function sessionBar(snapshot, geometry) {
|
|
489
|
+
if (geometry.sessionRows === 0)
|
|
490
|
+
return [];
|
|
491
|
+
const width = geometry.contentWidth;
|
|
492
|
+
const cells = snapshot.sessions.map((session, index) => {
|
|
493
|
+
const mark = session.status === 'running'
|
|
494
|
+
? style(snapshot.spinner, { fg: colGreen })
|
|
495
|
+
: session.status === 'ready'
|
|
496
|
+
? style('●', { fg: colGold })
|
|
497
|
+
: muted('·');
|
|
498
|
+
const name = session.title === '' ? 'new' : session.title;
|
|
499
|
+
const label = `${String(index + 1)} ${name}`;
|
|
500
|
+
const body = `${mark} ${truncate(label, 18)}`;
|
|
501
|
+
return session.active ? selected(` ${body} `) : muted(` ${body} `);
|
|
502
|
+
});
|
|
503
|
+
const bar = cells.join(muted('│'));
|
|
504
|
+
if (displayWidth(bar) <= width)
|
|
505
|
+
return [bar];
|
|
506
|
+
// Too many to show: keep the active one and say how many are hidden.
|
|
507
|
+
const activeIndex = snapshot.sessions.findIndex((session) => session.active);
|
|
508
|
+
const shown = cells.slice(Math.max(activeIndex - 1, 0), Math.max(activeIndex - 1, 0) + 2);
|
|
509
|
+
const more = muted(` +${String(snapshot.sessions.length - shown.length)}`);
|
|
510
|
+
return [truncate(shown.join(muted('│')) + more, width)];
|
|
511
|
+
}
|
|
512
|
+
/** A compact duration for an agent that has been alive a while. */
|
|
513
|
+
function agentAge(agent, now) {
|
|
514
|
+
return formatElapsed(Math.max(Math.floor((now - agent.startedAt) / 1000), 0));
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The background-agent strip, drawn between the transcript and the composer.
|
|
518
|
+
*
|
|
519
|
+
* Delegated work is otherwise invisible: the transcript only shows the
|
|
520
|
+
* foreground agent, so a turn that spawned subagents looks idle while the
|
|
521
|
+
* machine is busy. Collapsed it is one line with a count; `ctrl+b` lists them.
|
|
522
|
+
*/
|
|
523
|
+
function backgroundPane(snapshot, geometry) {
|
|
524
|
+
if (geometry.backgroundRows === 0)
|
|
525
|
+
return [];
|
|
526
|
+
const width = geometry.contentWidth;
|
|
527
|
+
const agents = snapshot.background;
|
|
528
|
+
const running = agents.filter((agent) => agent.status === 'running').length;
|
|
529
|
+
const now = Date.now();
|
|
530
|
+
// The layout may have collapsed an expanded strip to buy the transcript its
|
|
531
|
+
// minimum height, so the geometry decides the form, not the toggle alone.
|
|
532
|
+
if (!snapshot.expandBackground || geometry.backgroundRows === 1) {
|
|
533
|
+
const mark = running > 0 ? style(snapshot.spinner, { fg: colGreen }) : ok('✓');
|
|
534
|
+
const count = agents.length === 1 ? '1 agent' : `${String(agents.length)} agents`;
|
|
535
|
+
const state = running > 0 ? `${String(running)} running` : 'idle';
|
|
536
|
+
const names = agents
|
|
537
|
+
.slice(0, 3)
|
|
538
|
+
.map((agent) => agent.label)
|
|
539
|
+
.join(', ');
|
|
540
|
+
const left = `${mark} ${style(count, { fg: colText })}${muted(` ${state}`)}${muted(` · ${names}`)}`;
|
|
541
|
+
const right = muted('ctrl+b');
|
|
542
|
+
const gap = width - displayWidth(left) - displayWidth(right);
|
|
543
|
+
return [gap < 2 ? truncate(left, width) : left + ' '.repeat(gap) + right];
|
|
544
|
+
}
|
|
545
|
+
const inner = Math.max(width - 4, 10);
|
|
546
|
+
const visible = agents.slice(0, MAX_BACKGROUND_ROWS);
|
|
547
|
+
const body = visible.map((agent) => {
|
|
548
|
+
const mark = agent.status === 'running' ? style(snapshot.spinner, { fg: colGreen }) : muted('·');
|
|
549
|
+
const depth = agent.depth > 1 ? muted(`${' '.repeat(agent.depth - 1)}↳ `) : '';
|
|
550
|
+
const age = muted(agentAge(agent, now));
|
|
551
|
+
const label = `${mark} ${depth}${style(agent.label, { fg: colText })}`;
|
|
552
|
+
const pad = Math.max(inner - displayWidth(label) - displayWidth(age), 1);
|
|
553
|
+
return truncate(label + ' '.repeat(pad) + age, inner);
|
|
554
|
+
});
|
|
555
|
+
if (agents.length > visible.length) {
|
|
556
|
+
body.push(muted(` and ${String(agents.length - visible.length)} more`));
|
|
557
|
+
}
|
|
558
|
+
return box(body, inner, colGreen);
|
|
559
|
+
}
|
|
560
|
+
/** Wrap lines in a rounded border of the given accent color. */
|
|
561
|
+
function box(body, inner, color) {
|
|
562
|
+
const top = style(`╭${'─'.repeat(inner + 2)}╮`, { fg: color });
|
|
563
|
+
const bottom = style(`╰${'─'.repeat(inner + 2)}╯`, { fg: color });
|
|
564
|
+
const side = style('│', { fg: color });
|
|
565
|
+
return [top, ...body.map((line) => `${side} ${padEnd(line, inner)} ${side}`), bottom];
|
|
566
|
+
}
|
|
567
|
+
/** The bordered composer, plus the cursor position inside it. */
|
|
568
|
+
function composerPane(snapshot, geometry) {
|
|
569
|
+
const inner = Math.max(geometry.contentWidth - 4, 10);
|
|
570
|
+
const rows = snapshot.composer.layout(inner);
|
|
571
|
+
const visibleRows = Math.min(Math.max(rows.length, 1), MAX_INPUT_LINES);
|
|
572
|
+
// Scroll the composer so the cursor's row stays visible in a long draft.
|
|
573
|
+
const cursorRow = Math.max(rows.findIndex((row) => snapshot.composer.position() >= row.start && snapshot.composer.position() <= row.end), 0);
|
|
574
|
+
const first = Math.max(Math.min(cursorRow - visibleRows + 1, rows.length - visibleRows), 0);
|
|
575
|
+
const slice = rows.slice(first, first + visibleRows);
|
|
576
|
+
const empty = snapshot.composer.value() === '';
|
|
577
|
+
// A pending yes/no question takes the composer: it is the one place the
|
|
578
|
+
// next keystroke is guaranteed to land, so the prompt belongs there rather
|
|
579
|
+
// than in a status line the eye has already left.
|
|
580
|
+
const question = snapshot.confirmText === undefined ? undefined : `${snapshot.confirmText} (y/n)`;
|
|
581
|
+
// A narrow terminal has to drop the hint before it drops the prompt.
|
|
582
|
+
const placeholder = question !== undefined && inner >= 12
|
|
583
|
+
? truncate(question, inner)
|
|
584
|
+
: inner >= 34
|
|
585
|
+
? 'Ask the harness… (/ for commands)'
|
|
586
|
+
: inner >= 16
|
|
587
|
+
? 'Ask the harness…'
|
|
588
|
+
: '…';
|
|
589
|
+
const body = slice.map((row, index) => {
|
|
590
|
+
if (empty && index === 0) {
|
|
591
|
+
return muted(padEnd(truncate(placeholder, inner), inner));
|
|
592
|
+
}
|
|
593
|
+
return padEnd(truncate(style(row.text, { fg: colText }), inner), inner);
|
|
594
|
+
});
|
|
595
|
+
while (body.length < visibleRows)
|
|
596
|
+
body.push(' '.repeat(inner));
|
|
597
|
+
const color = snapshot.streaming ? colAccent : colBorder;
|
|
598
|
+
const lines = box(body, inner, color);
|
|
599
|
+
const active = rows[cursorRow];
|
|
600
|
+
const column = active === undefined ? 0 : displayWidth(active.text.slice(0, snapshot.composer.position() - active.start));
|
|
601
|
+
return {
|
|
602
|
+
lines,
|
|
603
|
+
// +1 for the box's top border, +1 for the gutter and the border column.
|
|
604
|
+
cursor: { row: 1 + (cursorRow - first), column: 2 + Math.min(column, inner - 1) },
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/** The status footer: model, context budget, usage, and the current status. */
|
|
608
|
+
function footer(snapshot, width) {
|
|
609
|
+
const used = snapshot.haveUsage
|
|
610
|
+
? snapshot.totalTokens
|
|
611
|
+
: estimateTokens(snapshot.messages.map(messageText).join('\n') + segmentsText(snapshot.streamingSegments));
|
|
612
|
+
const percent = snapshot.contextLimit > 0 ? Math.floor((used * 100) / snapshot.contextLimit) : 0;
|
|
613
|
+
const approx = snapshot.haveUsage ? '' : '~';
|
|
614
|
+
const context = `ctx ${approx}${formatTokens(used)}/${formatTokens(snapshot.contextLimit)} ${percent}%`;
|
|
615
|
+
const separator = muted(' · ');
|
|
616
|
+
const segments = [muted(snapshot.modelName)];
|
|
617
|
+
segments.push(percent >= 80 ? warn(context) : muted(context));
|
|
618
|
+
if (snapshot.haveUsage) {
|
|
619
|
+
segments.push(muted(`↑${formatTokens(snapshot.promptTokens)} ↓${formatTokens(snapshot.completionTokens)}`));
|
|
620
|
+
const tps = snapshot.tps ?? 0;
|
|
621
|
+
if (tps > 0)
|
|
622
|
+
segments.push(muted(`${tps.toFixed(0)} tok/s`));
|
|
623
|
+
// A cache-hit rate is only honest when the prompt was non-trivial: an
|
|
624
|
+
// empty request reads as 100% and means nothing.
|
|
625
|
+
const cacheRead = snapshot.cacheReadTokens ?? 0;
|
|
626
|
+
if (cacheRead > 0 && snapshot.promptTokens > 0) {
|
|
627
|
+
const rate = Math.min(Math.floor((cacheRead * 100) / snapshot.promptTokens), 100);
|
|
628
|
+
segments.push(muted(`cache ${String(rate)}%`));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
let left = segments.join(separator);
|
|
632
|
+
if (snapshot.streaming)
|
|
633
|
+
left = `${accent(snapshot.spinner)} ${left}`;
|
|
634
|
+
let right = '';
|
|
635
|
+
// A search counter or an error must not be hidden by the scroll indicator —
|
|
636
|
+
// a match jump leaves the view scrolled, which is exactly when the "no
|
|
637
|
+
// matches" error and the `match i/n` counter matter most.
|
|
638
|
+
const outranksScroll = snapshot.statusIsError || snapshot.searchActive === true;
|
|
639
|
+
if (snapshot.voice !== undefined) {
|
|
640
|
+
// An open microphone outranks all of it. Nothing else the footer says is
|
|
641
|
+
// worth a person not knowing the room is being recorded, so this line
|
|
642
|
+
// holds the slot for as long as the take lasts.
|
|
643
|
+
right =
|
|
644
|
+
snapshot.voice === 'recording'
|
|
645
|
+
? style(t('footer.recording', { spinner: snapshot.spinner }), { fg: colRose })
|
|
646
|
+
: style(t('footer.transcribing', { spinner: snapshot.spinner }), { fg: colGold });
|
|
647
|
+
}
|
|
648
|
+
else if (snapshot.scrollBack > 0 && !outranksScroll) {
|
|
649
|
+
// Scrolled away from the newest output: say so, and say how to get back.
|
|
650
|
+
right = style(t('footer.scrolled', {
|
|
651
|
+
lines: snapshot.scrollBack,
|
|
652
|
+
s: snapshot.scrollBack === 1 ? '' : 's',
|
|
653
|
+
}), { fg: colGold });
|
|
654
|
+
}
|
|
655
|
+
else if (snapshot.status !== '') {
|
|
656
|
+
const clipped = truncate(snapshot.status, Math.max(Math.floor(width / 2), 10));
|
|
657
|
+
right = snapshot.statusIsError ? warn(clipped) : ok(clipped);
|
|
658
|
+
}
|
|
659
|
+
else if (snapshot.picker.kind === 'none' && !snapshot.palette.open) {
|
|
660
|
+
right = snapshot.vimMode === undefined
|
|
661
|
+
? muted(t('footer.hint'))
|
|
662
|
+
: style(snapshot.vimMode === 'normal' ? ' NORMAL ' : ' INSERT ', {
|
|
663
|
+
fg: colText,
|
|
664
|
+
bg: snapshot.vimMode === 'normal' ? colAccent : colBorder,
|
|
665
|
+
bold: true,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
const gap = width - displayWidth(left) - displayWidth(right);
|
|
669
|
+
if (gap < 2)
|
|
670
|
+
return truncate(left, width);
|
|
671
|
+
return left + ' '.repeat(gap) + right;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* The fleet overview pane, which replaces the transcript while it is open.
|
|
675
|
+
*
|
|
676
|
+
* The rows themselves come from `renderFleet`, so this function only supplies
|
|
677
|
+
* the chrome the pane needs: a heading, the summary line, scrolling, and the
|
|
678
|
+
* key hints. Keeping the row rendering in `fleet.ts` is what lets the overview
|
|
679
|
+
* be tested without a terminal.
|
|
680
|
+
*/
|
|
681
|
+
function fleetPane(snapshot, geometry) {
|
|
682
|
+
const width = geometry.contentWidth;
|
|
683
|
+
const height = geometry.viewportRows;
|
|
684
|
+
const fleet = snapshot.fleet;
|
|
685
|
+
if (fleet === undefined)
|
|
686
|
+
return [];
|
|
687
|
+
const head = [bold('Fleet')];
|
|
688
|
+
head.push(fleet.loading && fleet.sessions.length === 0
|
|
689
|
+
? muted('collecting from every device…')
|
|
690
|
+
: muted(fleetSummary(fleet.sessions)));
|
|
691
|
+
head.push('');
|
|
692
|
+
const body = renderFleet(fleet.sessions, {
|
|
693
|
+
width,
|
|
694
|
+
selectedIndex: fleet.sessions.length === 0 ? -1 : fleet.selected,
|
|
695
|
+
spinner: snapshot.spinner,
|
|
696
|
+
sources: fleet.sources,
|
|
697
|
+
});
|
|
698
|
+
// Scroll so the selected row stays visible. The renderer inserts a heading
|
|
699
|
+
// per device, so the row's index is not its line -- fleetLineOf maps it.
|
|
700
|
+
const bodyHeight = Math.max(height - head.length - 1, 1);
|
|
701
|
+
const selectedLine = fleetLineOf(fleet.sessions, fleet.selected);
|
|
702
|
+
const start = selectedLine >= bodyHeight ? selectedLine - bodyHeight + 1 : 0;
|
|
703
|
+
const visible = body.slice(start, start + bodyHeight);
|
|
704
|
+
const out = [...head, ...visible];
|
|
705
|
+
while (out.length < height - 1)
|
|
706
|
+
out.push('');
|
|
707
|
+
// While a device is being added the footer becomes that prompt: the keys it
|
|
708
|
+
// would otherwise advertise are the ones now being typed into it.
|
|
709
|
+
if (fleet.adding) {
|
|
710
|
+
const label = 'add device: ';
|
|
711
|
+
const typed = style(fleet.draft, { fg: colText });
|
|
712
|
+
const help = muted(' enter add · esc cancel');
|
|
713
|
+
const line = `${accent(label)}${typed}${help}`;
|
|
714
|
+
out.push(truncate(line, width));
|
|
715
|
+
return out.slice(0, height);
|
|
716
|
+
}
|
|
717
|
+
const current = fleet.sessions[fleet.selected];
|
|
718
|
+
// Only a local session can be opened in place; a remote one is reached over
|
|
719
|
+
// SSH, so the hint promises to copy the command rather than to open it.
|
|
720
|
+
const action = current === undefined ? 'open' : current.local ? 'enter open' : 'enter copy ssh';
|
|
721
|
+
const hint = `↑↓ move · ${action} · a add · x remove · r refresh · esc back`;
|
|
722
|
+
const count = fleet.loading ? 'refreshing…' : `${String(fleet.sessions.length)} sessions`;
|
|
723
|
+
const pad = Math.max(width - displayWidth(hint) - displayWidth(count), 1);
|
|
724
|
+
out.push(muted(hint) + ' '.repeat(pad) + muted(count));
|
|
725
|
+
return out.slice(0, height);
|
|
726
|
+
}
|
|
727
|
+
/** Build a full frame plus the cursor position for the screen to place. */
|
|
728
|
+
export function render(snapshot) {
|
|
729
|
+
const geometry = layout(snapshot);
|
|
730
|
+
const width = geometry.contentWidth;
|
|
731
|
+
const gutter = ' ';
|
|
732
|
+
const rows = [];
|
|
733
|
+
if (geometry.showHeader) {
|
|
734
|
+
rows.push(header(snapshot, width));
|
|
735
|
+
rows.push('');
|
|
736
|
+
}
|
|
737
|
+
rows.push(...sessionBar(snapshot, geometry));
|
|
738
|
+
if (geometry.viewportRows > 0) {
|
|
739
|
+
const body = snapshot.panel !== undefined
|
|
740
|
+
? panelPane(snapshot, geometry)
|
|
741
|
+
: snapshot.fleet?.open === true
|
|
742
|
+
? fleetPane(snapshot, geometry)
|
|
743
|
+
: snapshot.picker.kind === 'none'
|
|
744
|
+
? viewport(snapshot, geometry)
|
|
745
|
+
: pickerPane(snapshot, geometry);
|
|
746
|
+
rows.push(...body);
|
|
747
|
+
}
|
|
748
|
+
if (geometry.showGap)
|
|
749
|
+
rows.push('');
|
|
750
|
+
rows.push(...backgroundPane(snapshot, geometry));
|
|
751
|
+
const palette = palettePane(snapshot, geometry);
|
|
752
|
+
rows.push(...palette);
|
|
753
|
+
rows.push(...atPane(snapshot, geometry));
|
|
754
|
+
if (geometry.pluginRows > 0 && snapshot.pluginLine !== undefined) {
|
|
755
|
+
rows.push(muted(truncate(snapshot.pluginLine, width)));
|
|
756
|
+
}
|
|
757
|
+
const composer = composerPane(snapshot, geometry);
|
|
758
|
+
const composerTop = rows.length;
|
|
759
|
+
rows.push(...composer.lines);
|
|
760
|
+
rows.push(footer(snapshot, width));
|
|
761
|
+
// The whole frame sits inside a one-column gutter. The cursor has to move
|
|
762
|
+
// with it: composerPane reports a column inside its own box, and every line
|
|
763
|
+
// of that box is about to be shifted right by the gutter.
|
|
764
|
+
const lines = rows.map((line) => gutter + line);
|
|
765
|
+
const cursor = snapshot.picker.kind === 'none' && snapshot.fleet?.open !== true && snapshot.panel === undefined
|
|
766
|
+
? {
|
|
767
|
+
row: composerTop + composer.cursor.row,
|
|
768
|
+
column: composer.cursor.column + gutter.length,
|
|
769
|
+
}
|
|
770
|
+
: undefined;
|
|
771
|
+
return { lines, cursor };
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* A trust-surface panel: what the agent is asking, the choices, and the way
|
|
775
|
+
* out. It borrows the transcript's rows rather than floating, so a small
|
|
776
|
+
* terminal still shows the whole decision.
|
|
777
|
+
*/
|
|
778
|
+
function panelPane(snapshot, geometry) {
|
|
779
|
+
const panel = snapshot.panel;
|
|
780
|
+
if (panel === undefined)
|
|
781
|
+
return [];
|
|
782
|
+
const width = geometry.contentWidth;
|
|
783
|
+
const inner = Math.max(width - 2, 10);
|
|
784
|
+
const out = [];
|
|
785
|
+
out.push(bold(truncate(panel.title, inner)));
|
|
786
|
+
out.push('');
|
|
787
|
+
const detail = panel.detail.trim();
|
|
788
|
+
if (detail !== '') {
|
|
789
|
+
for (const line of renderMarkdown(detail, width).split('\n'))
|
|
790
|
+
out.push(truncate(line, inner));
|
|
791
|
+
out.push('');
|
|
792
|
+
}
|
|
793
|
+
for (const row of panel.rows) {
|
|
794
|
+
const mark = row.checked === undefined ? (row.selected ? '❯' : ' ') : row.checked ? '◉' : '○';
|
|
795
|
+
const body = truncate(`${mark} ${row.label}${row.description === undefined ? '' : ` ${row.description}`}`, inner);
|
|
796
|
+
out.push(row.selected ? selected(padEnd(body, inner)) : body);
|
|
797
|
+
}
|
|
798
|
+
if (panel.inputLabel !== undefined) {
|
|
799
|
+
const text = panel.inputText === undefined || panel.inputText === '' ? '(type an answer)' : panel.inputText;
|
|
800
|
+
const line = truncate(`${panel.inputLabel}: ${text}`, inner);
|
|
801
|
+
out.push(panel.inputFocused === true ? selected(padEnd(line, inner)) : muted(line));
|
|
802
|
+
}
|
|
803
|
+
out.push('');
|
|
804
|
+
out.push(muted(truncate(panel.hint, inner)));
|
|
805
|
+
return out.slice(0, Math.max(geometry.viewportRows, 0));
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The help text shown by `/help`, rendered as markdown in the transcript pane.
|
|
809
|
+
*
|
|
810
|
+
* It is longer than a default 80x24 window, and the overlay shows the *tail*
|
|
811
|
+
* of it, so the list has a budget: every line added here pushes one off the
|
|
812
|
+
* top, and what falls off first is the session keys. A new section therefore
|
|
813
|
+
* comes with an equal number of lines folded together further down — which is
|
|
814
|
+
* why several entries below read as two keys on one row.
|
|
815
|
+
*/
|
|
816
|
+
/**
|
|
817
|
+
* The key reference.
|
|
818
|
+
*
|
|
819
|
+
* The strings live in the `i18n` catalog so one list covers both languages;
|
|
820
|
+
* this export is the English one, which the render tests assert against.
|
|
821
|
+
*
|
|
822
|
+
* The overlay shows the tail of it, so the list has a budget: a new section
|
|
823
|
+
* comes with an equal number of lines folded elsewhere. A line added to one
|
|
824
|
+
* language's copy in `i18n.ts` must be added to the other, or the two drift.
|
|
825
|
+
*/
|
|
826
|
+
export const HELP_TEXT = translate('en', 'help.body');
|
|
827
|
+
/** The key reference in the active language, for the `/help` overlay. */
|
|
828
|
+
export function keyReference() {
|
|
829
|
+
return helpText();
|
|
830
|
+
}
|