omniharness-cli 0.1.82 → 0.1.83

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.
@@ -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,7 +979,7 @@ 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
984
  const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
970
985
  const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
@@ -980,18 +995,27 @@ export function TerminalInterface({ engine }) {
980
995
  const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
981
996
  // Cap the streaming region so a long think/answer can't crowd out the chrome;
982
997
  // 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);
998
+ // What fits. The old budget subtracted a fixed 14 rows of guesswork and
999
+ // never bounded the sections it was competing with, so a short terminal
1000
+ // still produced a frame taller than itself.
1001
+ const view = planViewport({
1002
+ rows: terminalRows,
1003
+ editorLines: editorLayout.lines.length,
1004
+ toolCards: toolCards.length,
1005
+ todos: taskQueue.length,
1006
+ wantHero: lines.length === 0 && !busy,
1007
+ });
1008
+ const liveBudget = view.liveLines;
984
1009
  const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
985
1010
  const liveAnswerView = liveAnswerLines.slice(-liveBudget);
986
1011
  const doneAgents = agents.filter((lane) => lane.status === 'done').length;
987
1012
  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) => {
1013
+ 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
1014
  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);
1015
+ const status = card.status === 'running' ? 'running' : card.status === 'error' ? 'error' : 'done';
1016
+ const statusColor = status === 'running' ? PALETTE.warn : status === 'error' ? PALETTE.error : PALETTE.success;
1017
+ const head = toolHead(card.name === 'run_command' ? '$' : toolVerb(card.name), card.name === 'run_command' ? (card.target || '…') : card.target, contentWidth, expanded ? COLLAPSE_HINT.length : 0);
1018
+ 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
1019
  }), 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
1020
  const color = AGENT_COLORS[index % AGENT_COLORS.length];
997
1021
  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.83",
4
4
  "description": "OmniHarness — local-first agent orchestration harness for OmniRoute.",
5
5
  "license": "MIT",
6
6
  "type": "module",