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.
- package/dist/ui/sidebar.js +105 -0
- package/dist/ui/terminalInterface.js +96 -30
- package/dist/ui/toolrow.js +53 -0
- package/dist/ui/viewport.js +62 -0
- package/package.json +1 -1
|
@@ -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 = () =>
|
|
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
|
|
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
|
|
928
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|