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.
@@ -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 = () => setWidth(widthOf(stdout));
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 = stdout.rows ?? 24;
982
+ const terminalRows = rows;
968
983
  const metrics = engine.client.snapshotMetrics();
969
- const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
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 = metrics.compression.inputTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
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
- const liveBudget = Math.max(3, terminalRows - 14 - Math.min(6, taskQueue.length) - Math.min(4, agents.length) - editorLayout.lines.length);
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.compression.inputTokens, 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 });
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: [lines.length === 0 && !busy && _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))] }), toolCards.slice(-5).map((card) => {
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 dot = card.status === 'running' ? _jsx(Text, { color: PALETTE.warn, children: ".." }) : card.status === 'error' ? _jsx(Text, { color: PALETTE.error, children: "FAIL" }) : _jsx(Text, { color: PALETTE.success, children: "ok" });
991
- const head = card.name === 'run_command'
992
- ? `$ ${clip(card.target || '…', Math.max(10, contentWidth - 20))}`
993
- : `${toolVerb(card.name)}${card.target ? ` ${clip(card.target, Math.max(10, contentWidth - 24))}` : ''}`;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omniharness-cli",
3
- "version": "0.1.82",
3
+ "version": "0.1.84",
4
4
  "description": "OmniHarness — local-first agent orchestration harness for OmniRoute.",
5
5
  "license": "MIT",
6
6
  "type": "module",