omniharness-cli 0.1.81 → 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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Data shaping for the sidebar. The layout lives in the Ink component; every
3
+ * decision it has to make is here so it can be tested without a terminal.
4
+ *
5
+ * The shape follows OpenCode's session sidebar (MIT, sst/opencode): a fixed
6
+ * narrow panel carrying what the conversation column should not have to —
7
+ * session identity, what is running, and what is queued. The implementation is
8
+ * ours; Ink has no scrollbox and no absolute positioning, so the behaviour
9
+ * differs where it has to.
10
+ */
11
+ /** Fixed width of the panel, matching the measure OpenCode settled on. */
12
+ export const SIDEBAR_WIDTH = 34;
13
+ /**
14
+ * Below this the terminal cannot hold a sidebar and a readable conversation
15
+ * at once, so the toggle shows the sidebar *instead of* the conversation
16
+ * rather than beside it. Ink cannot overlay one panel on another, which is
17
+ * what OpenCode does at this size.
18
+ */
19
+ export const SIDEBAR_MIN_SPLIT = 92;
20
+ /**
21
+ * How the sidebar should be shown, given the terminal width and whether the
22
+ * user has it toggled on.
23
+ */
24
+ export function sidebarMode(width, wanted) {
25
+ if (!wanted)
26
+ return 'hidden';
27
+ return width >= SIDEBAR_MIN_SPLIT ? 'split' : 'replace';
28
+ }
29
+ /** Width left for the conversation once the sidebar has taken its share. */
30
+ export function conversationWidth(width, mode) {
31
+ if (mode !== 'split')
32
+ return width;
33
+ return Math.max(20, width - SIDEBAR_WIDTH - 1);
34
+ }
35
+ /**
36
+ * Render a token count the way it is read: exact while small, then thousands.
37
+ * A six-digit number in a 34-column panel is noise, not information.
38
+ */
39
+ export function compactTokens(n) {
40
+ if (!Number.isFinite(n) || n < 0)
41
+ return '0';
42
+ if (n < 1000)
43
+ return String(Math.round(n));
44
+ if (n < 1_000_000) {
45
+ const k = n / 1000;
46
+ return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`;
47
+ }
48
+ return `${(n / 1_000_000).toFixed(1)}M`;
49
+ }
50
+ /**
51
+ * Cost to four places under a cent, two above. Sub-cent spend is the normal
52
+ * case for a single turn and "$0.00" would report every one of them as free.
53
+ */
54
+ export function formatCost(usd) {
55
+ if (!Number.isFinite(usd) || usd <= 0)
56
+ return '$0';
57
+ return usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`;
58
+ }
59
+ /**
60
+ * The usage rows, or none at all when nothing has been spent yet. An empty
61
+ * panel section reads as broken; a missing one reads as "not yet".
62
+ */
63
+ export function usageRows(usage) {
64
+ const rows = [];
65
+ const inTokens = usage.tokensIn ?? 0;
66
+ const outTokens = usage.tokensOut ?? 0;
67
+ if (inTokens > 0 || outTokens > 0) {
68
+ // Only name a direction that was measured: "1.2k in · 0 out" claims zero
69
+ // output rather than admitting it is not counted here.
70
+ const parts = [];
71
+ if (inTokens > 0)
72
+ parts.push(`${compactTokens(inTokens)} in`);
73
+ if (outTokens > 0)
74
+ parts.push(`${compactTokens(outTokens)} out`);
75
+ rows.push({ label: 'tokens', value: parts.join(' · ') });
76
+ }
77
+ if ((usage.costUSD ?? 0) > 0)
78
+ rows.push({ label: 'cost', value: formatCost(usage.costUSD ?? 0) });
79
+ if ((usage.requests ?? 0) > 0)
80
+ rows.push({ label: 'calls', value: String(usage.requests) });
81
+ return rows;
82
+ }
83
+ /**
84
+ * Task queue rows. Plain-word markers rather than glyphs, matching the rest of
85
+ * the interface. The active item is marked so the eye finds it without colour,
86
+ * which matters under NO_COLOR.
87
+ */
88
+ export function todoRows(todos, limit, width) {
89
+ return todos.slice(0, Math.max(0, limit)).map((todo) => ({
90
+ marker: todo.status === 'done' ? 'x' : todo.status === 'active' ? '>' : '-',
91
+ title: clip(todo.title, Math.max(4, width)),
92
+ active: todo.status === 'active',
93
+ }));
94
+ }
95
+ /** How many queued items were not shown, for a "+N more" line. */
96
+ export function overflowCount(total, shown) {
97
+ return Math.max(0, total - shown);
98
+ }
99
+ export function clip(text, width) {
100
+ const runes = [...text.replace(/\s+/g, ' ')];
101
+ if (runes.length <= width)
102
+ return runes.join('');
103
+ return `${runes.slice(0, Math.max(0, width - 1)).join('')}…`;
104
+ }
105
+ //# sourceMappingURL=sidebar.js.map
@@ -9,6 +9,9 @@ import { renderMarkdown } from './markdown.js';
9
9
  import { looksLikeDiff, diffSegments } from './diff.js';
10
10
  import { palette } from './palette.js';
11
11
  import { capabilityLine, recentRows, shortenPath, twoColumn } from './home.js';
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';
12
15
  import { contextMeter, meterBar } from './modelWindows.js';
13
16
  import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
14
17
  import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
@@ -86,8 +89,12 @@ const PERM_COLOR = (p) => (p === 'bypass' ? PALETTE.error : p === 'acceptEdits'
86
89
  * the end of it, and the chrome ends up further from what it describes.
87
90
  */
88
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';
89
94
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
90
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);
91
98
  const clip = (text, width) => text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
92
99
  /** Word-wrap text to width, honoring existing newlines and hard-breaking long words. */
93
100
  function wrap(text, width) {
@@ -180,6 +187,30 @@ function TranscriptEntry({ line, width, fallbackModel }) {
180
187
  const bullet = line.role === 'user' ? '>' : line.role === 'error' ? '!' : '-';
181
188
  return _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: colorFor(line.role), children: [bullet, " ", label] }), rows.map((segments, index) => _jsx(SegmentText, { segments: segments, role: line.role }, index)), line.saved ? _jsxs(Text, { dimColor: true, children: [" ", line.saved] }) : null] });
182
189
  }
190
+ /**
191
+ * The session panel, shown beside the live region on Ctrl+B.
192
+ *
193
+ * It carries what the conversation column should not have to: what is
194
+ * running, what is queued, and what the turn has cost. The shape follows
195
+ * OpenCode's session sidebar (MIT); the implementation is ours, because Ink
196
+ * has neither a scrollbox nor absolute positioning, so it cannot scroll
197
+ * independently or overlay the conversation.
198
+ *
199
+ * Sections are absent rather than empty. A heading with nothing under it
200
+ * reads as something failing to load.
201
+ */
202
+ function SidebarPanel(props) {
203
+ const { width, model, mode, perm, workspace, usage, agents, todos, skills, plugins } = props;
204
+ const inner = Math.max(10, width - 4);
205
+ // Two columns of slack, not one. At an exact fit Ink still wraps, and a
206
+ // wrapped value in a 34-column panel shows as a stray blank line under a
207
+ // bare label — measured, not reasoned about.
208
+ const value = Math.max(6, inner - LABEL_WIDTH - 2);
209
+ const usageLines = usageRows(usage);
210
+ const shownTodos = todoRows(todos, 6, inner - 2);
211
+ const moreTodos = overflowCount(todos.length, shownTodos.length);
212
+ return _jsxs(Box, { flexDirection: "column", width: width, borderStyle: "round", borderColor: PALETTE.muted, paddingX: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "session" }), _jsx(Field, { label: "model", children: clip(model, value) }), _jsx(Field, { label: "mode", children: _jsx(Text, { color: MODE_ACCENT[mode], children: mode }) }), _jsx(Field, { label: "perms", children: _jsx(Text, { color: mode === 'crazy' ? PALETTE.error : PERM_COLOR(perm), children: mode === 'crazy' ? 'bypass' : PERM_LABEL[perm] }) }), _jsx(Field, { label: "cwd", children: shortenPath(workspace, value) }), skills > 0 && _jsx(Field, { label: "skills", children: clip(plugins > 0 ? `${skills} · ${plugins} plugins` : String(skills), value) }), usageLines.length > 0 && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "usage" }), usageLines.map((row) => _jsx(Field, { label: row.label, children: clip(row.value, value) }, row.label))] }), agents.length > 0 && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "agents" }), agents.slice(-6).map((lane, index) => _jsxs(Text, { children: [_jsx(Text, { color: AGENT_COLORS[index % AGENT_COLORS.length], children: lane.status === 'error' ? 'FAIL' : lane.status === 'done' ? 'ok ' : '.. ' }), _jsx(Text, { dimColor: true, children: clipRow(lane.label, inner - 5) })] }, lane.id))] }), shownTodos.length > 0 && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "plan" }), shownTodos.map((row) => _jsxs(Text, { children: [_jsxs(Text, { color: row.active ? PALETTE.accent : PALETTE.muted, children: [row.marker, " "] }), _jsx(Text, { dimColor: !row.active, children: row.title })] }, row.title)), moreTodos > 0 && _jsxs(Text, { dimColor: true, children: ["+", moreTodos, " more"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Ctrl+B hides this" }) })] });
213
+ }
183
214
  /**
184
215
  * Width of the dim label column on the home screen. The value gets whatever is
185
216
  * left, and every caller has to subtract this: a value sized to the whole box
@@ -219,8 +250,14 @@ export function TerminalInterface({ engine }) {
219
250
  const { stdout } = useStdout();
220
251
  const { stdin } = useStdin();
221
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));
222
259
  const [edit, setEdit] = useState({ value: '', cursor: 0 });
223
- const inputWidth = Math.max(16, Math.min(width, MAX_MEASURE) - 12);
260
+ const inputWidth = Math.max(16, Math.min(width, MAX_MEASURE) - 12); // recomputed below once the split is known
224
261
  // Settled transcript. Rendered once each into <Static> — the terminal's own
225
262
  // scrollback is the history; there is no in-app viewport to scroll.
226
263
  const [lines, setLines] = useState(() => engine.state.messages.map(lineFromMessage));
@@ -256,6 +293,9 @@ export function TerminalInterface({ engine }) {
256
293
  // Recent snapshots for the home screen. Read once on mount and left
257
294
  // alone: the home screen is only on screen before the first turn.
258
295
  const [recentSessions, setRecentSessions] = useState([]);
296
+ // Off by default: it is a second thing to read, and the conversation is
297
+ // the first. Ctrl+B brings it in.
298
+ const [sidebarWanted, setSidebarWanted] = useState(false);
259
299
  const [layoutDebug, setLayoutDebug] = useState(false);
260
300
  const syncRestoreRef = useRef(null);
261
301
  const pushLine = (line) => setLines((current) => [...current, line]);
@@ -273,7 +313,10 @@ export function TerminalInterface({ engine }) {
273
313
  return () => { alive = false; };
274
314
  }, []);
275
315
  useEffect(() => {
276
- const onResize = () => setWidth(widthOf(stdout));
316
+ const onResize = () => {
317
+ setWidth(widthOf(stdout));
318
+ setRows(rowsOf(stdout));
319
+ };
277
320
  stdout.on('resize', onResize);
278
321
  stdout.write(KITTY_PUSH);
279
322
  let kittyTimer;
@@ -696,6 +739,9 @@ export function TerminalInterface({ engine }) {
696
739
  case 'ctrlE':
697
740
  cycleMode();
698
741
  return;
742
+ case 'ctrlB':
743
+ setSidebarWanted((on) => !on);
744
+ return;
699
745
  case 'up':
700
746
  if (sessionsOpen) {
701
747
  setSessionsIndex((current) => clamp(current - 1, 0, Math.max(0, sessionsList.length - 1)));
@@ -789,6 +835,10 @@ export function TerminalInterface({ engine }) {
789
835
  applyAction({ kind: 'ctrlE' });
790
836
  return;
791
837
  }
838
+ if (key.ctrl && value.toLowerCase() === 'b') {
839
+ applyAction({ kind: 'ctrlB' });
840
+ return;
841
+ }
792
842
  if (pickerOpen) {
793
843
  if (key.escape) {
794
844
  applyAction({ kind: 'escape' });
@@ -922,10 +972,14 @@ export function TerminalInterface({ engine }) {
922
972
  // input box and the text you are reading at opposite ends of the screen.
923
973
  // Hold the content to a readable measure and centre it instead; below that
924
974
  // measure the gutter collapses and nothing changes.
925
- const measure = Math.min(width, MAX_MEASURE);
975
+ const sideMode = sidebarMode(width, sidebarWanted);
976
+ // With the panel beside it the conversation gets what is left, so the
977
+ // centred measure applies to the pair rather than to the text alone.
978
+ const measure = Math.min(width, sideMode === 'split' ? MAX_MEASURE + SIDEBAR_WIDTH + 1 : MAX_MEASURE);
926
979
  const gutter = Math.max(2, Math.floor((width - measure) / 2));
927
- const contentWidth = Math.max(20, measure - 6);
928
- const terminalRows = stdout.rows ?? 24;
980
+ const convoWidth = conversationWidth(measure, sideMode);
981
+ const contentWidth = Math.max(20, convoWidth - 6);
982
+ const terminalRows = rows;
929
983
  const metrics = engine.client.snapshotMetrics();
930
984
  const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
931
985
  const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
@@ -941,33 +995,45 @@ export function TerminalInterface({ engine }) {
941
995
  const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
942
996
  // Cap the streaming region so a long think/answer can't crowd out the chrome;
943
997
  // the complete text lands in <Static> once the event fires.
944
- 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;
945
1009
  const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
946
1010
  const liveAnswerView = liveAnswerLines.slice(-liveBudget);
947
1011
  const doneAgents = agents.filter((lane) => lane.status === 'done').length;
948
- 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), _jsxs(Box, { flexDirection: "column", 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) => {
949
- const expanded = expandedTool === card.id;
950
- 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" });
951
- const head = card.name === 'run_command'
952
- ? `$ ${clip(card.target || '', Math.max(10, contentWidth - 20))}`
953
- : `${toolVerb(card.name)}${card.target ? ` ${clip(card.target, Math.max(10, contentWidth - 24))}` : ''}`;
954
- return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [dot, " ", head, expanded ? ' Ctrl+T collapse' : ''] }), expanded && renderToolBody(card, contentWidth, PALETTE)] }, card.id);
955
- }), 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) => {
956
- const color = AGENT_COLORS[index % AGENT_COLORS.length];
957
- const glyph = lane.status === 'done' ? 'ok' : lane.status === 'error' ? 'FAIL' : lane.status === 'working' ? '..' : '--';
958
- const detail = lane.note ?? (lane.label !== lane.id ? lane.label : lane.status);
959
- return _jsxs(Text, { color: color, children: [glyph, " ", lane.id, " ", _jsx(Text, { dimColor: true, children: clip(detail, Math.max(12, contentWidth - 8)) })] }, lane.id);
960
- })] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
961
- const marker = item.status === 'done' ? 'ok' : item.status === 'active' ? '>' : '-';
962
- const color = item.status === 'done' ? PALETTE.success : item.status === 'active' ? PALETTE.accent : undefined;
963
- return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
964
- })] }), engine.state.preview && _jsxs(Text, { color: PALETTE.success, children: ["preview live \u00B7 ", engine.state.preview.url] }), sessionsOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.info, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.info, children: "saved sessions" }), _jsx(Text, { dimColor: true, children: "up/down navigate \u00B7 enter resume \u00B7 esc close" }), sessionsList.map((session, index) => (_jsxs(Text, { color: index === sessionsIndex ? PALETTE.info : undefined, children: [index === sessionsIndex ? '> ' : ' ', session.name, session.savedAt ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", session.savedAt] }) : null] }, session.name)))] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "up/down / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: PALETTE.error, children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "no models returned by OmniRoute." }), pickerItems.map((item, index) => {
965
- const header = index === 0 || pickerItems[index - 1].group !== item.group
966
- ? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
967
- : null;
968
- return _jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Text, { color: index === pickerIndex ? PALETTE.accent : undefined, children: [index === pickerIndex ? '> ' : ' ', item.id, item.strategy ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.strategy] }) : null, item.id === engine.state.activeModel ? ' *' : ''] })] }, item.id);
969
- })] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warn, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, color: PALETTE.warn, children: ["approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(JSON.stringify(approval.input), contentWidth)] }), approval.scopes.map((scope, index) => _jsxs(Text, { dimColor: true, children: [" ", index + 1, " \u00B7 ", clip(scope.label, Math.max(12, contentWidth - 6))] }, scope.id)), _jsxs(Text, { dimColor: true, children: ["y allow once \u00B7 n deny \u00B7 t always allow \u00B7 1\u2013", approval.scopes.length, " pick a trust scope"] })] }), layoutDebug && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["layout \u00B7 ", width, "\u00D7", terminalRows, " \u00B7 Ctrl+L to hide"] }), _jsxs(Text, { dimColor: true, children: ["static entries ", lines.length, " \u00B7 live budget ", liveBudget, " \u00B7 think ", liveThinkLines.length, " \u00B7 answer ", liveAnswerLines.length] }), _jsxs(Text, { dimColor: true, children: ["plan ", taskQueue.length, " \u00B7 swarm ", agents.length, " \u00B7 tool cards ", toolCards.length, " \u00B7 editor rows ", editorLayout.lines.length] })] }), queued && _jsxs(Text, { color: PALETTE.warn, children: ["queued \u00B7 ", clip(queued, Math.max(12, contentWidth - 12))] }), _jsx(Box, { borderStyle: "round", borderColor: error ? PALETTE.error : modeAccent, paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
970
- ? _jsxs(Text, { color: modeAccent, children: ['>', " ", _jsx(Text, { dimColor: true, children: busy ? 'type to queue the next task' : 'describe the work and press enter' })] })
971
- : editorLayout.lines.map((text, index) => _jsxs(Text, { color: modeAccent, children: [index === 0 ? '> ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: busy ? modeAccent : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "line" }), busy ? ' ' : '', phase, elapsed ? ` · ${elapsed}` : '', agents.length > 0 ? ` · swarm ${doneAgents}/${agents.length}` : ''] }), _jsxs(Text, { color: PALETTE.muted, children: [_jsx(Text, { color: modeAccent, children: mode }), _jsx(Text, { children: " \u00B7 " }), _jsx(Text, { color: mode === 'crazy' ? PALETTE.error : PERM_COLOR(permMode), children: mode === 'crazy' ? 'bypass' : PERM_LABEL[permMode] }), _jsxs(Text, { children: [" \u00B7 ", engine.state.activeModel] }), metrics.fallback.activeProvider ? _jsxs(Text, { children: [" \u00B7 via ", metrics.fallback.activeProvider] }) : null, contextLabel ? _jsxs(Text, { color: meterColor, children: [" \u00B7 ", contextLabel] }) : null] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7 Ctrl+J newline \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 Shift+Tab perms \u00B7 Ctrl+T tool \u00B7 Ctrl+Y copy \u00B7 Ctrl+C ", busy ? 'cancel' : 'quit', " \u00B7 /help"] }), compression ? _jsxs(Text, { dimColor: true, children: ["saved ", compression, metrics.remainingQuota !== undefined ? ` · quota ${metrics.remainingQuota}` : ''] }) : null] })] })] })] });
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 });
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) => {
1014
+ const expanded = expandedTool === 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);
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) => {
1020
+ const color = AGENT_COLORS[index % AGENT_COLORS.length];
1021
+ const glyph = lane.status === 'done' ? 'ok' : lane.status === 'error' ? 'FAIL' : lane.status === 'working' ? '..' : '--';
1022
+ const detail = lane.note ?? (lane.label !== lane.id ? lane.label : lane.status);
1023
+ return _jsxs(Text, { color: color, children: [glyph, " ", lane.id, " ", _jsx(Text, { dimColor: true, children: clip(detail, Math.max(12, contentWidth - 8)) })] }, lane.id);
1024
+ })] }), sideMode === 'hidden' && taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
1025
+ const marker = item.status === 'done' ? 'ok' : item.status === 'active' ? '>' : '-';
1026
+ const color = item.status === 'done' ? PALETTE.success : item.status === 'active' ? PALETTE.accent : undefined;
1027
+ return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
1028
+ })] }), engine.state.preview && _jsxs(Text, { color: PALETTE.success, children: ["preview live \u00B7 ", engine.state.preview.url] }), sessionsOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.info, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.info, children: "saved sessions" }), _jsx(Text, { dimColor: true, children: "up/down navigate \u00B7 enter resume \u00B7 esc close" }), sessionsList.map((session, index) => (_jsxs(Text, { color: index === sessionsIndex ? PALETTE.info : undefined, children: [index === sessionsIndex ? '> ' : ' ', session.name, session.savedAt ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", session.savedAt] }) : null] }, session.name)))] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "up/down / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: PALETTE.error, children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "no models returned by OmniRoute." }), pickerItems.map((item, index) => {
1029
+ const header = index === 0 || pickerItems[index - 1].group !== item.group
1030
+ ? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
1031
+ : null;
1032
+ return _jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Text, { color: index === pickerIndex ? PALETTE.accent : undefined, children: [index === pickerIndex ? '> ' : ' ', item.id, item.strategy ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.strategy] }) : null, item.id === engine.state.activeModel ? ' *' : ''] })] }, item.id);
1033
+ })] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warn, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, color: PALETTE.warn, children: ["approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(JSON.stringify(approval.input), contentWidth)] }), approval.scopes.map((scope, index) => _jsxs(Text, { dimColor: true, children: [" ", index + 1, " \u00B7 ", clip(scope.label, Math.max(12, contentWidth - 6))] }, scope.id)), _jsxs(Text, { dimColor: true, children: ["y allow once \u00B7 n deny \u00B7 t always allow \u00B7 1\u2013", approval.scopes.length, " pick a trust scope"] })] }), layoutDebug && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["layout \u00B7 ", width, "\u00D7", terminalRows, " \u00B7 Ctrl+L to hide"] }), _jsxs(Text, { dimColor: true, children: ["static entries ", lines.length, " \u00B7 live budget ", liveBudget, " \u00B7 think ", liveThinkLines.length, " \u00B7 answer ", liveAnswerLines.length] }), _jsxs(Text, { dimColor: true, children: ["plan ", taskQueue.length, " \u00B7 swarm ", agents.length, " \u00B7 tool cards ", toolCards.length, " \u00B7 editor rows ", editorLayout.lines.length] })] }), queued && _jsxs(Text, { color: PALETTE.warn, children: ["queued \u00B7 ", clip(queued, Math.max(12, contentWidth - 12))] }), _jsx(Box, { borderStyle: "round", borderColor: error ? PALETTE.error : modeAccent, paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
1034
+ ? _jsxs(Text, { color: modeAccent, children: ['>', " ", _jsx(Text, { dimColor: true, children: busy ? 'type to queue the next task' : 'describe the work and press enter' })] })
1035
+ : editorLayout.lines.map((text, index) => _jsxs(Text, { color: modeAccent, children: [index === 0 ? '> ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: busy ? modeAccent : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "line" }), busy ? ' ' : '', phase, elapsed ? ` · ${elapsed}` : '', agents.length > 0 ? ` · swarm ${doneAgents}/${agents.length}` : ''] }), _jsxs(Text, { color: PALETTE.muted, children: [_jsx(Text, { color: modeAccent, children: mode }), _jsx(Text, { children: " \u00B7 " }), _jsx(Text, { color: mode === 'crazy' ? PALETTE.error : PERM_COLOR(permMode), children: mode === 'crazy' ? 'bypass' : PERM_LABEL[permMode] }), _jsxs(Text, { children: [" \u00B7 ", engine.state.activeModel] }), metrics.fallback.activeProvider ? _jsxs(Text, { children: [" \u00B7 via ", metrics.fallback.activeProvider] }) : null, contextLabel ? _jsxs(Text, { color: meterColor, children: [" \u00B7 ", contextLabel] }) : null] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: sideMode === 'split'
1036
+ ? `Ctrl+B panel · Ctrl+${modeKey} mode · Ctrl+C ${busy ? 'cancel' : 'quit'} · /help`
1037
+ : `Enter send · Ctrl+J newline · Ctrl+O models · Ctrl+${modeKey} mode · Shift+Tab perms · Ctrl+B panel · Ctrl+T tool · Ctrl+Y copy · Ctrl+C ${busy ? 'cancel' : 'quit'} · /help` }), compression ? _jsxs(Text, { dimColor: true, children: ["saved ", compression, metrics.remainingQuota !== undefined ? ` · quota ${metrics.remainingQuota}` : ''] }) : null] })] })] }), sideMode === 'split' && _jsx(Box, { marginLeft: 1, children: panel })] })] });
972
1038
  }
973
1039
  //# sourceMappingURL=terminalInterface.js.map
@@ -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.81",
3
+ "version": "0.1.83",
4
4
  "description": "OmniHarness — local-first agent orchestration harness for OmniRoute.",
5
5
  "license": "MIT",
6
6
  "type": "module",