omniharness-cli 0.1.82 → 0.1.84
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/dist/config/omniRoute.js +66 -0
- package/dist/ui/terminalInterface.js +39 -12
- package/dist/ui/toolrow.js +53 -0
- package/dist/ui/viewport.js +62 -0
- package/package.json +1 -1
package/dist/config/omniRoute.js
CHANGED
|
@@ -32,6 +32,7 @@ export class OmniRouteClient {
|
|
|
32
32
|
metrics = {
|
|
33
33
|
compression: { inputTokens: 0, compressedTokens: 0, ratio: 1, strategy: 'none', updatedAt: new Date().toISOString() },
|
|
34
34
|
fallback: { attempts: 0 },
|
|
35
|
+
usage: { contextTokens: 0, tokensIn: 0, tokensOut: 0, costUsd: 0 },
|
|
35
36
|
requestCount: 0,
|
|
36
37
|
};
|
|
37
38
|
constructor(config = {}) {
|
|
@@ -186,7 +187,19 @@ export class OmniRouteClient {
|
|
|
186
187
|
let lineBuffer = '';
|
|
187
188
|
// Partially-accumulated tool calls keyed by stream index.
|
|
188
189
|
const toolStreams = new Map();
|
|
190
|
+
// OmniRoute's response headers are sent at stream start, before the
|
|
191
|
+
// latency, usage and cost are known, so on a stream they carry zeros. With
|
|
192
|
+
// OMNIROUTE_SSE_COMMENTS on, the gateway ends the stream with the same
|
|
193
|
+
// fields as `: x-omniroute-<name>=<value>` comment lines — the values that
|
|
194
|
+
// were final. They are collected here and read like a second header set.
|
|
195
|
+
const trailer = new Headers();
|
|
189
196
|
const flushData = (line) => {
|
|
197
|
+
if (line.startsWith(':')) {
|
|
198
|
+
const meta = /^:\s*(x-omniroute-[a-z0-9-]+)=(.*)$/i.exec(line);
|
|
199
|
+
if (meta)
|
|
200
|
+
trailer.set(meta[1].toLowerCase(), meta[2].trim());
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
190
203
|
const sep = line.indexOf(':');
|
|
191
204
|
if (sep === -1)
|
|
192
205
|
return;
|
|
@@ -274,6 +287,10 @@ export class OmniRouteClient {
|
|
|
274
287
|
const toolCalls = [...toolStreams.values()]
|
|
275
288
|
.filter((entry) => entry.id !== '' && entry.name !== '')
|
|
276
289
|
.map((entry) => ({ id: entry.id, type: 'function', function: { name: entry.name, arguments: entry.argsFragments.join('') } }));
|
|
290
|
+
// The trailer names the provider and model that finished the stream, so it
|
|
291
|
+
// supersedes the routing picture taken from the initial headers.
|
|
292
|
+
this.updateMetrics(trailer);
|
|
293
|
+
this.recordCompletion(response.headers, usage, trailer);
|
|
277
294
|
return { content, model: answered, finishReason, reasoning: reasoning || undefined, toolCalls, usage, headers: response.headers, compression: this.compressionFrom(response.headers) };
|
|
278
295
|
}
|
|
279
296
|
async chat(model, messages, options = {}) {
|
|
@@ -294,6 +311,11 @@ export class OmniRouteClient {
|
|
|
294
311
|
// (the combo may have routed anywhere); fall back to the requested id.
|
|
295
312
|
const answered = typeof payload.model === 'string' && payload.model.trim() !== '' ? payload.model : model;
|
|
296
313
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls.map((call) => this.asToolCall(call)).filter((call) => call !== null) : undefined;
|
|
314
|
+
this.recordCompletion(response.headers, usage ? {
|
|
315
|
+
inputTokens: this.number(usage.prompt_tokens),
|
|
316
|
+
outputTokens: this.number(usage.completion_tokens),
|
|
317
|
+
totalTokens: this.number(usage.total_tokens),
|
|
318
|
+
} : undefined);
|
|
297
319
|
return {
|
|
298
320
|
content: typeof message.content === 'string' ? message.content : '',
|
|
299
321
|
model: answered,
|
|
@@ -309,6 +331,50 @@ export class OmniRouteClient {
|
|
|
309
331
|
compression: this.compressionFrom(response.headers),
|
|
310
332
|
};
|
|
311
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* OmniRoute's cost-telemetry set for one completion, or undefined when the
|
|
336
|
+
* headers carry no token counts. A stream's initial headers hold zeros for
|
|
337
|
+
* every field the gateway could not know yet, and zeros are treated as
|
|
338
|
+
* absent so they never overwrite a count read elsewhere.
|
|
339
|
+
*/
|
|
340
|
+
usageFromHeaders(headers) {
|
|
341
|
+
const inputTokens = this.headerNumber(headers, 'x-omniroute-tokens-in') ?? 0;
|
|
342
|
+
const outputTokens = this.headerNumber(headers, 'x-omniroute-tokens-out') ?? 0;
|
|
343
|
+
if (inputTokens <= 0 && outputTokens <= 0)
|
|
344
|
+
return undefined;
|
|
345
|
+
const costUsd = this.headerNumber(headers, 'x-omniroute-response-cost');
|
|
346
|
+
const latencyMs = this.headerNumber(headers, 'x-omniroute-latency-ms');
|
|
347
|
+
return {
|
|
348
|
+
inputTokens: Math.max(0, inputTokens),
|
|
349
|
+
outputTokens: Math.max(0, outputTokens),
|
|
350
|
+
costUsd: costUsd !== undefined && costUsd > 0 ? costUsd : undefined,
|
|
351
|
+
latencyMs: latencyMs !== undefined && latencyMs > 0 ? latencyMs : undefined,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Fold one completion into the session's usage. Sources, most authoritative
|
|
356
|
+
* first: the SSE metadata trailer (final values of a stream), the response
|
|
357
|
+
* headers (final on a non-streaming reply, zeros on a stream), then the
|
|
358
|
+
* `usage` object in the body. Token counts come from one source only, so a
|
|
359
|
+
* reply that reports itself twice is still counted once.
|
|
360
|
+
*/
|
|
361
|
+
recordCompletion(headers, body, trailer) {
|
|
362
|
+
const usage = this.metrics.usage;
|
|
363
|
+
const measured = (trailer && this.usageFromHeaders(trailer))
|
|
364
|
+
?? this.usageFromHeaders(headers)
|
|
365
|
+
?? (body && (body.inputTokens > 0 || body.outputTokens > 0) ? { inputTokens: body.inputTokens, outputTokens: body.outputTokens } : undefined);
|
|
366
|
+
if (!measured)
|
|
367
|
+
return;
|
|
368
|
+
if (measured.inputTokens > 0)
|
|
369
|
+
usage.contextTokens = measured.inputTokens;
|
|
370
|
+
usage.tokensIn += measured.inputTokens;
|
|
371
|
+
usage.tokensOut += measured.outputTokens;
|
|
372
|
+
if (measured.costUsd !== undefined)
|
|
373
|
+
usage.costUsd += measured.costUsd;
|
|
374
|
+
if (measured.latencyMs !== undefined)
|
|
375
|
+
usage.latencyMs = measured.latencyMs;
|
|
376
|
+
usage.updatedAt = new Date().toISOString();
|
|
377
|
+
}
|
|
312
378
|
compressionFrom(headers) {
|
|
313
379
|
const input = this.headerNumber(headers, 'x-omniroute-input-tokens');
|
|
314
380
|
const compressed = this.headerNumber(headers, 'x-omniroute-compressed-tokens');
|
|
@@ -10,6 +10,8 @@ import { looksLikeDiff, diffSegments } from './diff.js';
|
|
|
10
10
|
import { palette } from './palette.js';
|
|
11
11
|
import { capabilityLine, recentRows, shortenPath, twoColumn } from './home.js';
|
|
12
12
|
import { conversationWidth, overflowCount, sidebarMode, SIDEBAR_WIDTH, todoRows, usageRows, clip as clipRow } from './sidebar.js';
|
|
13
|
+
import { planViewport } from './viewport.js';
|
|
14
|
+
import { statusMarker, toolHead } from './toolrow.js';
|
|
13
15
|
import { contextMeter, meterBar } from './modelWindows.js';
|
|
14
16
|
import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
|
|
15
17
|
import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
|
|
@@ -87,8 +89,12 @@ const PERM_COLOR = (p) => (p === 'bypass' ? PALETTE.error : p === 'acceptEdits'
|
|
|
87
89
|
* the end of it, and the chrome ends up further from what it describes.
|
|
88
90
|
*/
|
|
89
91
|
const MAX_MEASURE = 100;
|
|
92
|
+
/** Shown after an expanded tool head; the head reserves room for it. */
|
|
93
|
+
const COLLAPSE_HINT = ' Ctrl+T collapse';
|
|
90
94
|
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
91
95
|
const widthOf = (stdout) => Math.max(48, stdout.columns ?? 80);
|
|
96
|
+
/** Terminal height, floored so the layout maths never goes negative. */
|
|
97
|
+
const rowsOf = (stdout) => Math.max(8, stdout.rows ?? 24);
|
|
92
98
|
const clip = (text, width) => text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
93
99
|
/** Word-wrap text to width, honoring existing newlines and hard-breaking long words. */
|
|
94
100
|
function wrap(text, width) {
|
|
@@ -244,6 +250,12 @@ export function TerminalInterface({ engine }) {
|
|
|
244
250
|
const { stdout } = useStdout();
|
|
245
251
|
const { stdin } = useStdin();
|
|
246
252
|
const [width, setWidth] = useState(() => widthOf(stdout));
|
|
253
|
+
// Height has to be state for the same reason width is. Maximising and
|
|
254
|
+
// then floating a window changes rows without necessarily changing
|
|
255
|
+
// columns, and setting width to the value it already had re-renders
|
|
256
|
+
// nothing — so the live region kept the height budget of the old
|
|
257
|
+
// terminal and overran the viewport.
|
|
258
|
+
const [rows, setRows] = useState(() => rowsOf(stdout));
|
|
247
259
|
const [edit, setEdit] = useState({ value: '', cursor: 0 });
|
|
248
260
|
const inputWidth = Math.max(16, Math.min(width, MAX_MEASURE) - 12); // recomputed below once the split is known
|
|
249
261
|
// Settled transcript. Rendered once each into <Static> — the terminal's own
|
|
@@ -301,7 +313,10 @@ export function TerminalInterface({ engine }) {
|
|
|
301
313
|
return () => { alive = false; };
|
|
302
314
|
}, []);
|
|
303
315
|
useEffect(() => {
|
|
304
|
-
const onResize = () =>
|
|
316
|
+
const onResize = () => {
|
|
317
|
+
setWidth(widthOf(stdout));
|
|
318
|
+
setRows(rowsOf(stdout));
|
|
319
|
+
};
|
|
305
320
|
stdout.on('resize', onResize);
|
|
306
321
|
stdout.write(KITTY_PUSH);
|
|
307
322
|
let kittyTimer;
|
|
@@ -964,11 +979,14 @@ export function TerminalInterface({ engine }) {
|
|
|
964
979
|
const gutter = Math.max(2, Math.floor((width - measure) / 2));
|
|
965
980
|
const convoWidth = conversationWidth(measure, sideMode);
|
|
966
981
|
const contentWidth = Math.max(20, convoWidth - 6);
|
|
967
|
-
const terminalRows =
|
|
982
|
+
const terminalRows = rows;
|
|
968
983
|
const metrics = engine.client.snapshotMetrics();
|
|
969
|
-
|
|
984
|
+
// The prompt tokens of the last completion, as the gateway counted them,
|
|
985
|
+
// are the size of the context the next turn will carry.
|
|
986
|
+
const contextTokens = metrics.usage?.contextTokens ?? 0;
|
|
987
|
+
const meter = contextMeter(contextTokens, engine.state.activeModel, metrics.fallback.activeProvider);
|
|
970
988
|
const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
|
|
971
|
-
const contextLabel =
|
|
989
|
+
const contextLabel = contextTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
|
|
972
990
|
const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
|
|
973
991
|
const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
|
|
974
992
|
const elapsedMs = busy && runStartedAt.current !== null ? Math.max(0, now - runStartedAt.current) : 0;
|
|
@@ -980,18 +998,27 @@ export function TerminalInterface({ engine }) {
|
|
|
980
998
|
const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
|
|
981
999
|
// Cap the streaming region so a long think/answer can't crowd out the chrome;
|
|
982
1000
|
// the complete text lands in <Static> once the event fires.
|
|
983
|
-
|
|
1001
|
+
// What fits. The old budget subtracted a fixed 14 rows of guesswork and
|
|
1002
|
+
// never bounded the sections it was competing with, so a short terminal
|
|
1003
|
+
// still produced a frame taller than itself.
|
|
1004
|
+
const view = planViewport({
|
|
1005
|
+
rows: terminalRows,
|
|
1006
|
+
editorLines: editorLayout.lines.length,
|
|
1007
|
+
toolCards: toolCards.length,
|
|
1008
|
+
todos: taskQueue.length,
|
|
1009
|
+
wantHero: lines.length === 0 && !busy,
|
|
1010
|
+
});
|
|
1011
|
+
const liveBudget = view.liveLines;
|
|
984
1012
|
const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
|
|
985
1013
|
const liveAnswerView = liveAnswerLines.slice(-liveBudget);
|
|
986
1014
|
const doneAgents = agents.filter((lane) => lane.status === 'done').length;
|
|
987
|
-
const panel = _jsx(SidebarPanel, { width: SIDEBAR_WIDTH, model: engine.state.activeModel, mode: mode, perm: permMode, workspace: engine.state.workspace.root, usage: { tokensIn: metrics.
|
|
988
|
-
return _jsxs(Box, { flexDirection: "column", width: width, paddingLeft: gutter, paddingRight: gutter, children: [_jsx(Static, { items: lines, children: (line, index) => _jsx(TranscriptEntry, { line: line, width: contentWidth, fallbackModel: engine.state.activeModel }, index) }, staticKey), sideMode === 'replace' && _jsx(Box, { marginBottom: 1, children: panel }), _jsxs(Box, { flexDirection: "row", alignItems: "flex-start", children: [_jsxs(Box, { flexDirection: "column", width: sideMode === 'split' ? convoWidth : undefined, flexGrow: sideMode === 'split' ? 0 : 1, children: [
|
|
1015
|
+
const panel = _jsx(SidebarPanel, { width: SIDEBAR_WIDTH, model: engine.state.activeModel, mode: mode, perm: permMode, workspace: engine.state.workspace.root, usage: { tokensIn: metrics.usage?.tokensIn, tokensOut: metrics.usage?.tokensOut, costUSD: metrics.usage?.costUsd, requests: metrics.requestCount }, agents: agents, todos: taskQueue, skills: engine.skills.length, plugins: new Set(engine.skills.map((skill) => skill.source).filter((source) => source !== undefined)).size });
|
|
1016
|
+
return _jsxs(Box, { flexDirection: "column", width: width, paddingLeft: gutter, paddingRight: gutter, children: [_jsx(Static, { items: lines, children: (line, index) => _jsx(TranscriptEntry, { line: line, width: contentWidth, fallbackModel: engine.state.activeModel }, index) }, staticKey), sideMode === 'replace' && _jsx(Box, { marginBottom: 1, children: panel }), _jsxs(Box, { flexDirection: "row", alignItems: "flex-start", children: [_jsxs(Box, { flexDirection: "column", width: sideMode === 'split' ? convoWidth : undefined, flexGrow: sideMode === 'split' ? 0 : 1, children: [view.showHero && _jsx(Hero, { width: contentWidth + 2, endpoint: engine.client.endpoint ?? 'omniroute', model: engine.state.activeModel, mode: mode, perm: permMode, workspace: engine.state.workspace.root, sessions: recentSessions, skills: engine.skills.length, plugins: new Set(engine.skills.map((skill) => skill.source).filter((source) => source !== undefined)).size, mcpTools: engine.mcpTools.length }), liveThink !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warn, children: "\u00B7 thinking" }), liveThinkView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "thinking" }, index))] }), liveAnswer !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: engine.state.activeModel }), liveAnswerView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "assistant" }, index))] }), (view.toolCards > 0 ? toolCards.slice(-view.toolCards) : []).map((card) => {
|
|
989
1017
|
const expanded = expandedTool === card.id;
|
|
990
|
-
const
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [dot, " ", head, expanded ? ' Ctrl+T collapse' : ''] }), expanded && renderToolBody(card, contentWidth, PALETTE)] }, card.id);
|
|
1018
|
+
const status = card.status === 'running' ? 'running' : card.status === 'error' ? 'error' : 'done';
|
|
1019
|
+
const statusColor = status === 'running' ? PALETTE.warn : status === 'error' ? PALETTE.error : PALETTE.success;
|
|
1020
|
+
const head = toolHead(card.name === 'run_command' ? '$' : toolVerb(card.name), card.name === 'run_command' ? (card.target || '…') : card.target, contentWidth, expanded ? COLLAPSE_HINT.length : 0);
|
|
1021
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: statusColor, children: statusMarker(status) }), _jsx(Text, { dimColor: true, children: head }), expanded ? _jsx(Text, { dimColor: true, children: COLLAPSE_HINT }) : null] }), expanded && _jsx(Box, { borderStyle: "round", borderColor: statusColor, borderTop: false, borderBottom: false, borderRight: false, paddingLeft: 1, marginLeft: 1, flexDirection: "column", children: renderToolBody(card, Math.max(10, contentWidth - 4), PALETTE) })] }, card.id);
|
|
995
1022
|
}), sideMode === 'hidden' && agents.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.error, paddingX: 2, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: PALETTE.error, children: "swarm" }), _jsxs(Text, { dimColor: true, children: [doneAgents, "/", agents.length, " lanes done"] })] }), agents.map((lane, index) => {
|
|
996
1023
|
const color = AGENT_COLORS[index % AGENT_COLORS.length];
|
|
997
1024
|
const glyph = lane.status === 'done' ? 'ok' : lane.status === 'error' ? 'FAIL' : lane.status === 'working' ? '..' : '--';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool row composition.
|
|
3
|
+
*
|
|
4
|
+
* Follows the two shapes OpenCode uses (MIT, anomalyco/opencode): a one-line
|
|
5
|
+
* inline row with a fixed-width status column so descriptions align down the
|
|
6
|
+
* page, and a block form for output, marked with a rule down its left edge
|
|
7
|
+
* rather than boxed on all four sides — a full border around every tool
|
|
8
|
+
* result turns a transcript into a stack of crates.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Width of the status column. Every marker is padded to it, so the tool
|
|
12
|
+
* descriptions start at the same column whatever happened to the call. The
|
|
13
|
+
* markers were 'ok' (2), 'FAIL' (4) and '..' (2), which meant every failure
|
|
14
|
+
* shunted its own description two columns right.
|
|
15
|
+
*/
|
|
16
|
+
// Five, not four: the longest marker is 'FAIL', and padding to its own
|
|
17
|
+
// length leaves no gap, so a failed call rendered as "FAIL$ go test ...".
|
|
18
|
+
// The extra column is the separator, which is why the head is not padded
|
|
19
|
+
// again on the other side.
|
|
20
|
+
export const STATUS_WIDTH = 5;
|
|
21
|
+
/** Plain-word status marker, padded to a fixed column. No glyphs. */
|
|
22
|
+
export function statusMarker(status) {
|
|
23
|
+
switch (status) {
|
|
24
|
+
case 'running': return '..'.padEnd(STATUS_WIDTH);
|
|
25
|
+
case 'error': return 'FAIL'.padEnd(STATUS_WIDTH);
|
|
26
|
+
case 'denied': return 'no'.padEnd(STATUS_WIDTH);
|
|
27
|
+
default: return 'ok'.padEnd(STATUS_WIDTH);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The text beside the marker: a verb and its target, clipped to the room
|
|
32
|
+
* actually left once the status column and the indent are accounted for.
|
|
33
|
+
* Sizing this to the full width is what makes rows wrap and the column
|
|
34
|
+
* collapse.
|
|
35
|
+
*/
|
|
36
|
+
export function toolHead(verb, target, width, reserve = 0) {
|
|
37
|
+
// `reserve` is room kept for something drawn after the head on the same
|
|
38
|
+
// row — the collapse hint, today. Without it the hint pushed itself onto a
|
|
39
|
+
// line of its own, which is worse than not showing it.
|
|
40
|
+
const room = Math.max(8, width - STATUS_WIDTH - 2 - Math.max(0, reserve));
|
|
41
|
+
if (target === '')
|
|
42
|
+
return clip(verb, room);
|
|
43
|
+
const head = `${verb} ${target}`;
|
|
44
|
+
return clip(head, room);
|
|
45
|
+
}
|
|
46
|
+
export function clip(text, width) {
|
|
47
|
+
const flat = text.replace(/\s+/g, ' ');
|
|
48
|
+
const runes = [...flat];
|
|
49
|
+
if (runes.length <= width)
|
|
50
|
+
return flat;
|
|
51
|
+
return `${runes.slice(0, Math.max(0, width - 1)).join('')}…`;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=toolrow.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much of the live region fits in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Ink redraws its live region by walking the cursor up over the lines it
|
|
5
|
+
* wrote last time and erasing them. That accounting only holds while the
|
|
6
|
+
* frame fits on screen: once it is taller than the viewport the terminal
|
|
7
|
+
* scrolls it, the cursor no longer lands where Ink expects, and the redraw
|
|
8
|
+
* eats the transcript above or leaves a trail of half-erased frames. It shows
|
|
9
|
+
* up as the display "going weird" after a resize, because maximising and then
|
|
10
|
+
* floating a window is the ordinary way to make the viewport smaller than the
|
|
11
|
+
* frame that was drawn for it.
|
|
12
|
+
*
|
|
13
|
+
* So the sections that can grow are given a budget rather than a fixed cap.
|
|
14
|
+
*/
|
|
15
|
+
/** Rows the input frame, status line and key hints always need. */
|
|
16
|
+
const CHROME_ROWS = 6;
|
|
17
|
+
/** Rows the home screen occupies when it is drawn. */
|
|
18
|
+
const HERO_ROWS = 12;
|
|
19
|
+
/** Streaming never drops below this, or a running turn looks like a hang. */
|
|
20
|
+
const LIVE_MIN = 2;
|
|
21
|
+
const MAX_TOOL_CARDS = 5;
|
|
22
|
+
const MAX_TODO_ROWS = 6;
|
|
23
|
+
/**
|
|
24
|
+
* Decide what fits. Order of sacrifice, least useful first: the home screen
|
|
25
|
+
* goes before tool cards, tool cards before the queue, and streaming text
|
|
26
|
+
* keeps a floor because a turn with nothing visible reads as a hang.
|
|
27
|
+
*/
|
|
28
|
+
export function planViewport(input) {
|
|
29
|
+
const rows = Math.max(8, Math.floor(input.rows));
|
|
30
|
+
const editor = Math.max(1, Math.floor(input.editorLines));
|
|
31
|
+
let free = rows - CHROME_ROWS - editor;
|
|
32
|
+
// The home screen only appears before the first turn, and only when there
|
|
33
|
+
// is genuinely room: half a home screen is worse than none.
|
|
34
|
+
const showHero = input.wantHero && free >= HERO_ROWS + LIVE_MIN;
|
|
35
|
+
if (showHero)
|
|
36
|
+
free -= HERO_ROWS;
|
|
37
|
+
// The bounded sections are allocated first and streaming absorbs the rest.
|
|
38
|
+
// Taking a share of the remainder for streaming up front looks fair and is
|
|
39
|
+
// not: on a tall terminal it starved the queue of rows there was plenty of
|
|
40
|
+
// room for.
|
|
41
|
+
let budget = free - LIVE_MIN;
|
|
42
|
+
const toolCards = clamp(input.toolCards, 0, Math.max(0, Math.min(MAX_TOOL_CARDS, budget)));
|
|
43
|
+
budget -= toolCards;
|
|
44
|
+
const todoRows = clamp(input.todos, 0, Math.max(0, Math.min(MAX_TODO_ROWS, budget)));
|
|
45
|
+
budget -= todoRows;
|
|
46
|
+
// Whatever is left goes to the running turn, never below the floor — a turn
|
|
47
|
+
// with nothing visible reads as a hang.
|
|
48
|
+
const liveLines = LIVE_MIN + Math.max(0, budget);
|
|
49
|
+
return { toolCards, todoRows, liveLines, showHero };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The smallest frame this can produce: the input and the chrome around it,
|
|
53
|
+
* plus the streaming floor. Below this the terminal is simply too short and
|
|
54
|
+
* nothing can be given up to fix it.
|
|
55
|
+
*/
|
|
56
|
+
export function minimumHeight(editorLines) {
|
|
57
|
+
return CHROME_ROWS + Math.max(1, Math.floor(editorLines)) + LIVE_MIN;
|
|
58
|
+
}
|
|
59
|
+
function clamp(value, min, max) {
|
|
60
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=viewport.js.map
|