koneck 2.120.0 → 2.121.0

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/ink-chat.js CHANGED
@@ -16,6 +16,7 @@ import { paceFrom } from './pace.js';
16
16
  import { policyFrom, FAILOVER_OFF } from './failover.js';
17
17
  import { listCheckpoints, revertCheckpoint, previewCheckpoint, snapshotTree, filesTouchedBy, createWorkspaceCheckpoint, checkpointScope } from './checkpoint.js';
18
18
  import { fileDiff, diffSummary } from './diff-view.js';
19
+ import { tokenizeLine, spanLangFor } from './highlight-spans.js';
19
20
  import { untrackedPatches } from './git-diff.js';
20
21
  import { listWorkspaceEntries, activeReference } from './workspace-files.js';
21
22
  import { attachReferences, linesIn } from './mention-resolve.js';
@@ -69,6 +70,27 @@ const DIM = '#5C717A';
69
70
  // as errors. These are the existing accents lifted a little, over bands dark enough to sit on a
70
71
  // black terminal without glowing.
71
72
  const DIFF_ADD = '#7FE3B0';
73
+ /*
74
+ * Syntax colours for the diff panel.
75
+ *
76
+ * Asked for directly: "I need all the colors as shown in the image when editing a file." These are
77
+ * the familiar dark-theme assignments — control flow purple, strings warm, comments green, calls
78
+ * yellow, types teal, plain identifiers pale blue — chosen because a reader coming from an editor
79
+ * already knows what each one means, so the diff reads as code rather than as coloured text.
80
+ *
81
+ * Each is legible on the tinted add and delete bands as well as on the panel's own background,
82
+ * which is the constraint that rules out anything dark.
83
+ */
84
+ const SYNTAX = {
85
+ keyword: '#C586C0',
86
+ string: '#CE9178',
87
+ comment: '#6A9955',
88
+ number: '#B5CEA8',
89
+ fn: '#DCDCAA',
90
+ type: '#4EC9B0',
91
+ plain: '#9CDCFE',
92
+ punct: '#A6ADB2',
93
+ };
72
94
  const DIFF_ADD_BG = '#10291F';
73
95
  const DIFF_DEL = '#F2909B';
74
96
  const DIFF_DEL_BG = '#2B1419';
@@ -396,6 +418,7 @@ const COMMANDS = [
396
418
  { cmd: '/model', desc: 'Show or set the model by name' },
397
419
  { cmd: '/provider', desc: 'Show or switch provider' },
398
420
  { cmd: '/status', desc: 'Provider, model, mode, tokens, cost, workspace' },
421
+ { cmd: '/diff', desc: 'Show or hide the live diff panel beside the conversation' },
399
422
  { cmd: '/permissions', desc: 'Show or set approval posture: on or off' },
400
423
  { cmd: '/worktree', desc: 'Show or set isolated-worktree default for future CLI tasks' },
401
424
  { cmd: '/goal', desc: 'Set, inspect, or clear the session’s persistent outcome' },
@@ -515,6 +538,15 @@ export function expandPastes(draft, pastes) {
515
538
  const MAX_DIFF_BYTES = 512 * 1024;
516
539
  /** One tool rarely touches more; the cap stops a bulk move from flooding scrollback. */
517
540
  const MAX_DIFF_FILES = 4;
541
+ /*
542
+ * How many changed lines the side panel draws per file.
543
+ *
544
+ * Higher than the inline diff's cap, because the panel is a column of its own with the height of
545
+ * the terminal to fill — the inline one has to leave room for the conversation around it. Still
546
+ * capped: a 4,000-line rewrite scrolled past is no more readable in a panel than in a transcript,
547
+ * and the count of what was left out is shown instead.
548
+ */
549
+ const PANEL_DIFF_LINES = 400;
518
550
  /** The tabs of the usage pane, in the order they are shown. */
519
551
  const USAGE_TABS = ['settings', 'session', 'context', 'models', 'limits'];
520
552
  const MIN_REPLY_WIDTH = 24; // below this a table cannot render legibly anyway
@@ -1083,6 +1115,92 @@ export function defaultAltScreen(stdout = process.stdout, env = process.env) {
1083
1115
  return false;
1084
1116
  return true;
1085
1117
  }
1118
+ /**
1119
+ * One diff line, syntax-coloured, on its tinted band.
1120
+ *
1121
+ * The inline diff paints the whole line one colour — green for added, red for removed — which
1122
+ * says what happened to the line and nothing about what the line is. In the panel the code is
1123
+ * coloured as code: the tint carries add-or-remove, the spans carry meaning. The band is padded
1124
+ * to the full width first so the tint is a solid block, and the padding is emitted as its own
1125
+ * span so it takes the background without taking a foreground colour.
1126
+ */
1127
+ function renderCodeLine(text, kind, width, lang) {
1128
+ const bg = kind === 'add' ? DIFF_ADD_BG : kind === 'del' ? DIFF_DEL_BG : undefined;
1129
+ const body = text.replace(/\t/g, ' ');
1130
+ const cell = fitCells(body, width, true);
1131
+ const spans = tokenizeLine(cell.replace(/\s+$/, ''), lang);
1132
+ const drawn = spans.map(sp => sp.text).join('');
1133
+ const pad = cell.length > drawn.length ? cell.slice(drawn.length) : '';
1134
+ const out = spans.map((sp, i) => (_jsx(Text, { color: SYNTAX[sp.kind], ...(bg ? { backgroundColor: bg } : {}), children: sp.text }, i)));
1135
+ if (pad)
1136
+ out.push(_jsx(Text, { ...(bg ? { backgroundColor: bg } : {}), children: pad }, "pad"));
1137
+ return out;
1138
+ }
1139
+ /**
1140
+ * The live diff panel — the work as it happens, beside the conversation.
1141
+ *
1142
+ * Asked for: the same running view the web has, in the terminal. It shows every file the session
1143
+ * has changed, newest first, with the counts in the header, so a person watching can see the edit
1144
+ * land rather than reading a description of it afterwards. Bounded on both axes: the newest files
1145
+ * fill the height available and the rest are counted, since a panel that scrolls past what you
1146
+ * wanted is no better than no panel.
1147
+ */
1148
+ export function DiffPanel({ files, width, height }) {
1149
+ const added = files.reduce((n, d) => n + d.added, 0);
1150
+ const removed = files.reduce((n, d) => n + d.removed, 0);
1151
+ const inner = Math.max(20, width - 2);
1152
+ // Rows are laid out against the height available, newest file first, so the thing just
1153
+ // written is the thing on screen.
1154
+ const body = [];
1155
+ let used = 0;
1156
+ let shownFiles = 0;
1157
+ for (const d of [...files].reverse()) {
1158
+ if (used + 2 > height)
1159
+ break;
1160
+ const numbers = d.hunks.flatMap(h => h.lines.map(l => l.after ?? l.before ?? 0));
1161
+ const gutter = Math.max(3, String(Math.max(0, ...numbers)).length);
1162
+ const codeWidth = Math.max(12, inner - gutter - 2);
1163
+ const lang = spanLangFor(d.path);
1164
+ const tag = d.created ? 'new' : d.deleted ? 'deleted' : `+${d.added}${d.removed ? ` -${d.removed}` : ''}`;
1165
+ body.push(_jsxs(Box, { justifyContent: "space-between", width: inner, children: [_jsx(Text, { color: CYAN, wrap: "truncate-start", children: d.path }), _jsxs(Text, { color: d.deleted ? DIFF_DEL : DIFF_ADD, children: [" ", tag] })] }, `h-${d.path}`));
1166
+ used += 1;
1167
+ if (d.binary) {
1168
+ body.push(_jsx(Text, { color: DIM, children: " binary \u2014 not shown" }, `b-${d.path}`));
1169
+ used += 1;
1170
+ shownFiles++;
1171
+ continue;
1172
+ }
1173
+ for (const [hi, hunk] of d.hunks.entries()) {
1174
+ if (used >= height)
1175
+ break;
1176
+ if (hi > 0 && used < height) {
1177
+ body.push(_jsx(Text, { color: DIM, children: `${' '.repeat(gutter)} ⋯` }, `g-${d.path}-${hi}`));
1178
+ used += 1;
1179
+ }
1180
+ for (const [li, l] of hunk.lines.entries()) {
1181
+ if (used >= height)
1182
+ break;
1183
+ const shown = l.kind === 'del' ? l.before : l.after;
1184
+ const sign = l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' ';
1185
+ body.push(_jsxs(Box, { children: [_jsx(Text, { color: DIM, children: String(shown ?? '').padStart(gutter) }), _jsx(Text, { color: l.kind === 'add' ? DIFF_ADD : l.kind === 'del' ? DIFF_DEL : DIM, ...(l.kind !== 'ctx'
1186
+ ? { backgroundColor: l.kind === 'add' ? DIFF_ADD_BG : DIFF_DEL_BG } : {}), children: sign }), renderCodeLine(l.text, l.kind, codeWidth, lang)] }, `l-${d.path}-${hi}-${li}`));
1187
+ used += 1;
1188
+ }
1189
+ }
1190
+ if (d.hiddenLines > 0 && used < height) {
1191
+ body.push(_jsx(Text, { color: DIM, children: `${' '.repeat(gutter)} ⋯ ${d.hiddenLines} more` }, `m-${d.path}`));
1192
+ used += 1;
1193
+ }
1194
+ shownFiles++;
1195
+ used += 1;
1196
+ if (used < height)
1197
+ body.push(_jsx(Text, { children: " " }, `s-${d.path}`));
1198
+ }
1199
+ const summary = files.length === 0
1200
+ ? 'no changes yet'
1201
+ : `${files.length} file${files.length === 1 ? '' : 's'} changed`;
1202
+ return (_jsxs(Box, { flexDirection: "column", width: width, paddingLeft: 1, flexShrink: 0, overflowY: "hidden", children: [_jsxs(Box, { justifyContent: "space-between", width: inner, children: [_jsxs(Box, { children: [_jsx(Text, { color: INK, bold: true, children: summary }), added > 0 && _jsxs(Text, { color: DIFF_ADD, children: [" +", added] }), removed > 0 && _jsxs(Text, { color: DIFF_DEL, children: [" -", removed] })] }), _jsx(Text, { color: DIM, children: "/diff" })] }), _jsx(Box, { flexDirection: "column", flexShrink: 1, overflowY: "hidden", children: body }), shownFiles < files.length && (_jsx(Text, { color: DIM, children: `… ${files.length - shownFiles} more file(s) changed` }))] }));
1203
+ }
1086
1204
  /**
1087
1205
  * Exported for one reason: so a test can mount it.
1088
1206
  *
@@ -1187,6 +1305,18 @@ export function App({ config: initialConfig, clearFrame }) {
1187
1305
  const [queued, setQueued] = useState([]);
1188
1306
  /** Pre-edit contents of the files the running tool said it would touch. */
1189
1307
  const pendingDiffRef = useRef([]);
1308
+ /*
1309
+ * The whole session's changes, for the live diff panel.
1310
+ *
1311
+ * `pendingDiffRef` holds one tool's before-contents and is cleared when that tool finishes, so it
1312
+ * answers "what did this call change" and cannot answer "what has this session changed" — which
1313
+ * is what a panel showing "1 file changed +46" has to know. This keeps the contents each file had
1314
+ * when the session first touched it, and never overwrites an entry: edit the same file five times
1315
+ * and the diff still runs from the state it was in before the first edit, which is the change the
1316
+ * person actually wants to review.
1317
+ */
1318
+ const diffBaseRef = useRef(new Map());
1319
+ const [liveDiffs, setLiveDiffs] = useState([]);
1190
1320
  const charsRef = useRef(0); // streamed chars, for the live token estimate
1191
1321
  const realTokRef = useRef(0); // real output tokens this turn, once the provider reports usage
1192
1322
  const tokBaseRef = useRef(0); // session output-token count at the start of this turn
@@ -1265,6 +1395,11 @@ export function App({ config: initialConfig, clearFrame }) {
1265
1395
  turnToolsRef.current += 1;
1266
1396
  pendingDiffRef.current = filesTouchedBy(name, args).slice(0, MAX_DIFF_FILES)
1267
1397
  .map(rel => ({ rel, before: readForDiff(rel) }));
1398
+ // The session baseline: first touch only, so repeated edits still diff from the original.
1399
+ for (const { rel, before } of pendingDiffRef.current) {
1400
+ if (!diffBaseRef.current.has(rel))
1401
+ diffBaseRef.current.set(rel, before);
1402
+ }
1268
1403
  liveToolRef.current = { kind: 'tool', name, detail: toolDetail(name, args), startedAt: Date.now() };
1269
1404
  }
1270
1405
  function finishTool(name, ok, ms, detail) {
@@ -1288,6 +1423,34 @@ export function App({ config: initialConfig, clearFrame }) {
1288
1423
  ...(!ok && detail ? { error: detail } : {}),
1289
1424
  };
1290
1425
  setRows(prev => [...prev, { role: 'steps', steps: [done] }]);
1426
+ /*
1427
+ * Refresh the panel against every file the session has touched, not just this call's.
1428
+ *
1429
+ * Read from disk each time rather than accumulated, because a later tool — a shell command, a
1430
+ * formatter, the model reverting itself — can change a file this one had finished with. Disk is
1431
+ * the truth about what the change currently is.
1432
+ */
1433
+ if (diffBaseRef.current.size) {
1434
+ const all = [];
1435
+ for (const [rel, before] of diffBaseRef.current) {
1436
+ const d = fileDiff(rel, before, readForDiff(rel), { maxLines: PANEL_DIFF_LINES });
1437
+ if (!d.unchanged)
1438
+ all.push(d);
1439
+ }
1440
+ setLiveDiffs(all);
1441
+ /*
1442
+ * Open itself on the session's first change, once, unless the person has already decided.
1443
+ *
1444
+ * The panel is only useful when there is something in it, so it does not sit there empty at
1445
+ * startup; and it must not reopen after being closed, which is why the decision is remembered
1446
+ * rather than re-taken on every edit.
1447
+ */
1448
+ if (all.length && !diffPanelTouchedRef.current) {
1449
+ diffPanelTouchedRef.current = true;
1450
+ if (cfg.diffPanel !== false)
1451
+ setShowDiffPanel(true);
1452
+ }
1453
+ }
1291
1454
  }
1292
1455
  /**
1293
1456
  * Moves the agent panel into scrollback once the turn is over. The live panel is transient by
@@ -1459,6 +1622,15 @@ export function App({ config: initialConfig, clearFrame }) {
1459
1622
  */
1460
1623
  const activeSession = useState(() => ({ p: null }))[0];
1461
1624
  const [rows, setRows] = useState([{ role: 'header' }]);
1625
+ /*
1626
+ * Whether the live diff panel is open.
1627
+ *
1628
+ * Off until there is something to show, then it opens itself on the first change of a session and
1629
+ * stays as the person leaves it — because a panel that reappears every time you close it is worse
1630
+ * than one you have to ask for. `/diff` toggles it; `diffPanel: false` in config keeps it shut.
1631
+ */
1632
+ const [showDiffPanel, setShowDiffPanel] = useState(false);
1633
+ const diffPanelTouchedRef = useRef(false);
1462
1634
  const [draft, setDraft] = useState('');
1463
1635
  // Recall is deliberately session-local. It is for quick retries and refinements, not another
1464
1636
  // persistence channel for prompts (which may contain sensitive project context).
@@ -3474,6 +3646,32 @@ export function App({ config: initialConfig, clearFrame }) {
3474
3646
  'Pick a model with a known rate via /models to see cost, or read it from your provider dashboard.');
3475
3647
  return;
3476
3648
  }
3649
+ /*
3650
+ * The live diff panel, on or off.
3651
+ *
3652
+ * Takes on/off as well as toggling, so it can be put in a shell alias or a hook, and says why
3653
+ * nothing appeared when it cannot open — an inline session has no frame to put a column in,
3654
+ * and a session that has changed nothing has nothing to draw.
3655
+ */
3656
+ case '/diff': {
3657
+ const want = arg.trim() === 'off' ? false : arg.trim() === 'on' ? true : !showDiffPanel;
3658
+ setShowDiffPanel(want);
3659
+ diffPanelTouchedRef.current = true;
3660
+ const saved = { ...(await loadKoneckConfig()), diffPanel: want };
3661
+ await saveKoneckConfig(saved);
3662
+ setStored(saved);
3663
+ addSystem(want && !altScreen
3664
+ ? 'The diff panel needs the alternate screen, which this session is not using. '
3665
+ + '/altscreen on, then /diff.'
3666
+ : want && liveDiffs.length === 0
3667
+ ? 'Diff panel on — it will appear beside the conversation with the first edit. '
3668
+ + 'Saved as your default.'
3669
+ : want
3670
+ ? 'Diff panel open beside the conversation, following every edit as it lands. '
3671
+ + '/diff to hide it.'
3672
+ : 'Diff panel hidden. /diff to bring it back.');
3673
+ return;
3674
+ }
3477
3675
  case '/altscreen':
3478
3676
  case '/fullscreen': {
3479
3677
  const next = arg.trim() === 'off' ? false : arg.trim() === 'on' ? true : !altScreen;
@@ -5091,7 +5289,22 @@ export function App({ config: initialConfig, clearFrame }) {
5091
5289
  // One character of a different width than Ink assumed is enough to cause it, and East Asian
5092
5290
  // ambiguous glyphs (·, ↑↓, ⌕, –, all of which the UI uses) are rendered two cells wide by some
5093
5291
  // terminals and one by others. A spare column costs nothing and removes the whole class.
5094
- const barWidth = uiWidth - 5;
5292
+ /*
5293
+ * The panel takes its width off the conversation's, rather than being laid over it.
5294
+ *
5295
+ * Everything in the interface is sized from barWidth, so taking the panel's share out here means
5296
+ * the transcript, the composer and the tables inside a reply all reflow to the narrower column on
5297
+ * their own. Laying the panel on top instead would have left every one of them drawing under it.
5298
+ *
5299
+ * Only on the alternate screen: in inline mode the transcript is committed to the terminal's own
5300
+ * scrollback full-width, and there is no frame to put a second column in.
5301
+ */
5302
+ const diffPanelOpen = showDiffPanel && altScreen && liveDiffs.length > 0;
5303
+ const diffPanelWidth = diffPanelOpen
5304
+ ? Math.max(34, Math.min(Math.floor(uiWidth * 0.46), 96))
5305
+ : 0;
5306
+ const contentWidth = uiWidth - diffPanelWidth;
5307
+ const barWidth = contentWidth - 5;
5095
5308
  // Space a rendered reply actually has: the bar, minus the response box border (2), its
5096
5309
  // paddingX={1} (2), and the "ꓘK " prefix plus its gap (3). Getting this even one column
5097
5310
  // too wide makes Ink reflow table rows and shatter their borders.
@@ -5197,367 +5410,367 @@ export function App({ config: initialConfig, clearFrame }) {
5197
5410
  const visibleRows = altScreen
5198
5411
  ? rows.slice(Math.max(0, rows.length - scrollBack - VIEWPORT_ROWS), rows.length - scrollBack)
5199
5412
  : rows;
5200
- return (_jsxs(Box, { flexDirection: "column", width: uiWidth, paddingX: 2, ...(altScreen ? { height: frameRows } : {}), children: [altScreen && (
5201
- // Grows no further than its content, but shrinks when there is not enough screen.
5202
- //
5203
- // flexGrow={1} filled the screen whatever was in it, so a session with only the banner in
5204
- // it put the banner at the foot of a screen of black — reported as "it just sits at the
5205
- // bottom". Growing only to fit means a short transcript starts at the top with the prompt
5206
- // directly beneath it, and once the transcript outgrows the screen this box is the only
5207
- // child that can give ground: it shrinks, and with the overflow hidden and the content
5208
- // bottom-aligned inside it, the top is clipped and the newest rows stay visible.
5209
- //
5210
- // Both halves matter. Clipping the bottom would hide what just happened; anchoring
5211
- // always to the bottom is what put the banner there.
5212
- _jsx(Box, { flexGrow: 0, flexShrink: 1, flexDirection: "column", justifyContent: "flex-end", overflowY: "hidden", children: visibleRows.map((row, i) => (_jsx(Box, { flexDirection: "column", flexShrink: 0, children: renderRow(row, i) }, i))) })), altScreen && scrollBack > 0 && (_jsx(Text, { color: AMBER, children: `${G.caret} scrolled back ${scrollBack} rows — PageDown, or just type, to follow again` })), showAnalytics && (_jsxs(Box, { borderStyle: "round", borderColor: AMBER, paddingX: 1, marginBottom: 1, flexDirection: "column", width: barWidth, children: [_jsx(Text, { color: AMBER, bold: true, children: "\u25C6 Session Analytics" }), _jsxs(Text, { color: MUTED, children: ["Turns : ", _jsx(Text, { color: INK, children: stats.turns })] }), _jsxs(Text, { color: MUTED, children: ["Prompt tok : ", _jsx(Text, { color: INK, children: stats.promptTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Output tok : ", _jsx(Text, { color: INK, children: stats.completionTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Total tok : ", _jsx(Text, { color: INK, children: stats.totalTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Model : ", _jsx(Text, { color: INK, children: modelShort }), " Provider: ", _jsx(Text, { color: INK, children: cfg.provider })] }), _jsx(Text, { color: DIM, children: "Tab to close" })] })), _jsx(Static, { items: altScreen ? [] : rows, children: (row, index) => renderRow(row, index) }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [sayRef.current.trim() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
5213
- .split('\n').map((line, i) => _jsx(Text, { color: INK, children: line }, i)) })] })), liveToolRef.current && renderStep(liveToolRef.current, 1), queued.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: AMBER, children: "\u25AA" }), _jsxs(Text, { color: MUTED, children: [queued.length, " queued, will run when this finishes \u00B7 /aside to ask without waiting"] })] }), queued.map((q, i) => (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: fitCells(q.split('\n')[0] ?? '', Math.max(20, barWidth - 6), true) }) }, i)))] })), agents.length > 0 && (() => {
5214
- const done = agents.filter(a => a.status !== 'running').length;
5215
- const failed = agents.filter(a => a.status === 'failed').length;
5216
- const tokens = agents.reduce((n, a) => n + a.tokens, 0);
5217
- const started = Math.min(...agents.map(a => a.startedAt));
5218
- // The task text is clipped to whatever is left after the fixed columns, so a long
5219
- // task never wraps and breaks the alignment of the rows under it.
5220
- const taskWidth = Math.max(16, barWidth - 34);
5221
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, children: "\u25C6" }), _jsxs(Text, { color: INK, bold: true, children: [agents.length, " agents"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: done === agents.length ? GREEN : AMBER, children: [done, "/", agents.length, " complete"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: MUTED, children: [humanTokens(tokens), " tok"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsx(Text, { color: MUTED, children: fmtElapsed(Date.now() - started) }), failed > 0 && _jsxs(Text, { color: CRIMSON, children: ["\u00B7 ", failed, " failed"] })] }), agents.map(a => {
5222
- const glyph = a.status === 'running' ? SPINNER[spinFrame]
5223
- : a.status === 'failed' ? G.fail : G.ok;
5224
- const color = a.status === 'running' ? AMBER
5225
- : a.status === 'failed' ? CRIMSON : GREEN;
5226
- const took = (a.endedAt ?? Date.now()) - a.startedAt;
5227
- const task = a.task.length > taskWidth ? a.task.slice(0, taskWidth - 3) + '...' : a.task;
5228
- return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { color: DIM, children: String(a.index + 1).padStart(2) }), _jsx(Text, { color: a.status === 'running' ? INK : MUTED, children: fitCells(task, taskWidth) }), _jsx(Text, { color: DIM, children: humanTokens(a.tokens).padStart(6) }), _jsx(Text, { color: DIM, children: fmtElapsed(took).padStart(6) })] }, a.index));
5229
- })] }));
5230
- })(), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: GREEN, children: [SPINNER[spinFrame], " "] }), _jsx(Shimmer, { text: workWord({
5231
- elapsedMs, tools: turnToolsRef.current,
5232
- tokens: liveTokens, recovered: turnRecoveredRef.current,
5233
- }, spinWord)[0], frame: shimmerFrame, base: MUTED }), _jsx(Text, { color: MUTED, children: "\u2026 " }), _jsxs(Text, { color: DIM, children: ["(", fmtElapsed(elapsedMs), " | ", liveTokens > 0
5234
- ? `${fmtTokens(liveTokens)} tokens`
5235
- // A gateway routing to several backends sends content-free frames while it
5236
- // finds one. Saying "waiting for the first token" through that reads as a
5237
- // hang, when in fact the request was accepted and is being worked on.
5238
- : providerFramesRef.current > 0 ? `${cfg.provider} is holding the line, no output yet`
5239
- : elapsedMs > 8_000 ? `no response from ${cfg.provider} yet`
5240
- : 'starting', thinkingRef.current.chars > 0 && Date.now() - thinkingRef.current.at < 2_000
5241
- ? ` | thinking, ${fmtTokens(Math.round(thinkingRef.current.chars / 4))} reasoning tokens`
5242
- : '', ")"] })] }) }), showThinking && thinkingTextRef.current.trim() !== '' && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: thinkingTail(thinkingTextRef.current, THINKING_LINES, barWidth - 6).map((line, i) => (_jsx(Text, { color: DIM, italic: true, children: line }, i))) })), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), usagePane && (() => {
5243
- const width = Math.min(barWidth, usagePane === 'settings' ? 96 : 66);
5244
- const inner = width - 4;
5245
- const BAR_W = Math.max(12, inner - 14);
5246
- const tabs = USAGE_TABS.map(t => t === usagePane ? `**${t[0].toUpperCase() + t.slice(1)}**` : t[0].toUpperCase() + t.slice(1)).join(' ');
5247
- /** A labelled bar with its percentage, the shape used for every proportion here. */
5248
- const meter = (fraction, label) => [`${bar(fraction, BAR_W)} ${Math.round(fraction * 100)}%`, `${label}`, ''];
5249
- const lines = [tabs, '---'];
5250
- if (usagePane === 'settings') {
5251
- lines.push('**Settings** — written to disk and applied now', '');
5252
- // What is actually in force, not just what this file happens to hold. provider and
5253
- // baseURL commonly come from ~/.koneckrc, and showing those as "(not set)" made a
5254
- // configured session look unconfigured.
5255
- const endpoint = endpointFor(cfg.provider);
5256
- const labelW = Math.max(...CONFIG_SCHEMA.map(f => f.label.length));
5257
- for (const [i, f] of CONFIG_SCHEMA.entries()) {
5258
- const selected = i === settingRow;
5259
- const beingTyped = editing?.key === String(f.key);
5260
- // An endpoint always has a source worth naming, so it never reads as unset or as
5261
- // merely inherited: the question it answers is which file won.
5262
- const isEndpoint = f.key === 'baseURL';
5263
- const isModel = f.key === 'model';
5264
- const here = !isEndpoint && !isModel && stored[f.key] !== undefined;
5265
- const value = here ? stored[f.key] : liveSetting(String(f.key));
5266
- const raw = f.key === 'maxTurns' && (value === 0 || value === undefined)
5267
- ? 'no limit'
5268
- : displayValue(value);
5269
- const shown = beingTyped
5270
- ? `${editing.value}_`
5271
- : isEndpoint ? `${raw} (${shortSource(endpoint.source)})`
5272
- : isModel ? `${raw} (${shortSource(modelFor(cfg.provider).source)})`
5273
- : raw + (here || value === undefined ? '' : ' (inherited)');
5274
- // The marker is what makes the selection legible without colour, which a panel line
5275
- // drawn as one string cannot carry per-segment.
5276
- const mark = selected ? '>' : ' ';
5277
- const hint = !selected ? ''
5278
- : f.type.kind === 'boolean' ? ' enter toggles'
5279
- : f.type.kind === 'choice' ? ` enter cycles: ${f.type.choices.join(' / ')}`
5280
- : f.type.kind === 'number' ? ` type a number, enter to save${f.clearable ? ', x clears' : ''}`
5281
- : ` enter to edit${f.clearable ? ', x clears' : ''}`;
5282
- lines.push(`${mark} ${f.label.padEnd(labelW)} ${selected ? '**' + shown + '**' : shown}${hint}`);
5283
- }
5284
- lines.push('');
5285
- lines.push(editing
5286
- ? 'Typing. **enter** saves, **ctrl+u** empties the field, **esc** abandons.'
5287
- : 'Up/down choose · enter change · x clear · left/right tabs');
5288
- lines.push(`Saved to ${CONFIG_FILE}`);
5289
- }
5290
- if (usagePane === 'session') {
5291
- const wall = Date.now() - sessionStartRef.current;
5292
- const cost = estimateCost(cfg.model, stats.promptTokens, stats.completionTokens);
5293
- lines.push('**Session**', '');
5294
- lines.push(`Total cost ${cost.known ? formatCost(cost.total) : 'no published price for this model'}`);
5295
- lines.push(`Duration (API) ${humanDuration(apiMsRef.current)}`);
5296
- lines.push(`Duration (wall) ${humanDuration(wall)}`);
5297
- lines.push(`Turns ${stats.turns}`);
5298
- lines.push(`Code changes ${usageDiff === undefined ? 'reading the working tree…'
5299
- : usageDiff === null ? 'not a git repo — nothing to compare against'
5300
- : `${usageDiff.added} added, ${usageDiff.removed} removed, ${usageDiff.files} file${usageDiff.files === 1 ? '' : 's'}`}`);
5301
- lines.push(`Tokens ${humanTokens(stats.promptTokens)} in · ${humanTokens(stats.completionTokens)} out`);
5302
- lines.push('');
5303
- if (apiMsRef.current > 0 && wall > apiMsRef.current) {
5304
- const share = apiMsRef.current / wall;
5305
- lines.push(...meter(share, 'of the session was the model thinking.'));
5306
- lines.push('The rest was tools running and your own typing.');
5307
- }
5308
- }
5309
- if (usagePane === 'context') {
5310
- const window = modelCatalog.find(m => m.id === cfg.model)?.contextLength ?? contextWindowFor(cfg.model);
5311
- lines.push('**Context window**', '');
5312
- if (window) {
5313
- // This is cumulative across the session, not a made-up claim about the next request.
5314
- // It is still useful as a pressure signal: every long turn re-sends most of the
5315
- // history, but only the provider can count the exact request after compaction.
5316
- const used = stats.promptTokens;
5317
- lines.push(...meter(Math.min(1, used / window), `${humanTokens(used)} session prompt total · ${humanTokens(window)} window · ${cfg.model}`));
5318
- if (used / window > 0.7) {
5319
- lines.push('Close to the window. `/clear` starts fresh,');
5320
- lines.push('`/rewind` drops just the last exchange.');
5321
- }
5322
- else {
5323
- lines.push('Every turn re-sends the whole history, so a long');
5324
- lines.push('session costs more even when little is said.');
5325
- lines.push('`/clear` when you move to an unrelated task.');
5326
- }
5327
- }
5328
- else {
5329
- lines.push(`This provider publishes no window for **${cfg.model}**,`);
5330
- lines.push('so there is nothing honest to draw a bar against.');
5331
- }
5332
- lines.push('');
5333
- lines.push(`Session total: ${humanTokens(stats.promptTokens)} prompt · ${humanTokens(stats.completionTokens)} completion.`);
5334
- }
5335
- if (usagePane === 'models') {
5336
- const statuses = readModelStatus();
5337
- lines.push(`**Models** — ${cfg.provider}`, '');
5338
- if (modelCatalog.length === 0) {
5339
- // The reason, when there is one: a provider that does not publish a list is a normal
5340
- // configuration, not a fault, and saying so is the difference between "fix your setup"
5341
- // and "type the name yourself".
5342
- lines.push(catalogNote.current ?? 'Could not read the model list from this provider.');
5343
- }
5344
- else {
5345
- // Counted rather than assumed in either direction: what the provider said was free,
5346
- // and separately how many it said nothing about at all. "Free 3" out of 447 used to
5347
- // imply the other 444 were paid, which nobody had established.
5348
- const free = modelCatalog.filter(m => m.cost === 'free');
5349
- const priced = modelCatalog.filter(m => m.cost === 'paid');
5350
- const unknown = modelCatalog.filter(m => m.cost === undefined);
5351
- // Status is keyed by provider, not by catalogue membership, so an alias that has
5352
- // actually been used still counts as tried.
5353
- const prefix = `${cfg.provider}:`;
5354
- const tried = Object.values(statuses).filter(s => s.model.startsWith(prefix));
5355
- const bad = tried.filter(s => s.state !== 'ok');
5356
- lines.push(`Listed ${modelCatalog.length}`);
5357
- if (free.length)
5358
- lines.push(`Free ${free.length}`);
5359
- if (priced.length)
5360
- lines.push(`Priced ${priced.length}`);
5361
- if (unknown.length) {
5362
- lines.push(`No price reported ${unknown.length}`);
5363
- }
5364
- lines.push(`Tried here ${tried.length} — ${tried.length - bad.length} worked, ${bad.length} failed`);
5365
- lines.push('');
5366
- // The model in use is not always a catalogue entry: `auto` and similar aliases are
5367
- // routing instructions the gateway resolves per request, and never appear in a
5368
- // listing. Showing only listed models would leave the commonest setting invisible.
5369
- const current = modelCatalog.find(m => m.id === cfg.model);
5370
- const st = statuses[`${cfg.provider}:${cfg.model}`];
5371
- lines.push(`**In use** ${cfg.model}`);
5372
- if (current) {
5373
- // Local is about the runtime, not the address: a router on localhost needs no key and
5374
- // runs nothing, forwarding to a cloud that bills per token.
5375
- const hosted = servesOwnWeights(cfg.provider);
5376
- const money = hosted ? 'local'
5377
- : current.promptRate !== undefined && current.promptRate > 0
5378
- ? `$${rateText(current.promptRate)} per million in`
5379
- : current.cost === 'free' ? 'free'
5380
- : current.cost === 'paid' ? 'paid'
5381
- : 'no price reported';
5382
- lines.push(` ${money}` +
5383
- (current.contextLength ? ` · ${humanTokens(current.contextLength)} context` : '') +
5384
- (current.maxOutput ? ` · ${humanTokens(current.maxOutput)} max out` : '') +
5385
- (current.toolCalling === false ? ' · no tool calling' : '') +
5386
- (current.vision ? ' · takes images' : ''));
5387
- }
5388
- else {
5389
- lines.push(' a routing alias — the gateway picks the model per request');
5390
- }
5391
- lines.push(` status: ${st ? stateLabel(st.state) : 'not tried yet'}`);
5392
- if (bad.length) {
5393
- lines.push('');
5394
- lines.push('**Failing**');
5395
- for (const s of bad.slice(0, 6)) {
5396
- lines.push(` ${s.model.slice(prefix.length)} — ${stateLabel(s.state)}`);
5397
- }
5398
- }
5399
- lines.push('');
5400
- lines.push('Status accrues from use: a model is marked when it');
5401
- lines.push('answers or refuses. `/models` shows the same tags.');
5402
- }
5403
- }
5404
- if (usagePane === 'limits') {
5405
- lines.push('**Limits and quota**', '');
5406
- lines.push(`How much of your plan is left on **${cfg.provider}**`);
5407
- lines.push('cannot be shown.');
5408
- lines.push('');
5409
- lines.push('That is the provider, not a missing feature. Its');
5410
- lines.push('replies carry no rate-limit or balance headers, and');
5411
- lines.push('its account API refuses the key a session holds.');
5412
- lines.push('A number here would be invented, so there is none.');
5413
- lines.push('');
5414
- lines.push('**What is measured instead**');
5415
- lines.push(' · tokens actually spent — Session tab');
5416
- lines.push(' · context actually filled — Context tab');
5417
- lines.push(' · which models actually answered — Models tab');
5418
- lines.push('');
5419
- lines.push('A provider that returns `x-ratelimit-*` headers will');
5420
- lines.push('show a real bar here with no further work.');
5421
- }
5422
- lines.push('---');
5423
- lines.push('**{left}/{right}** switch tabs · **esc** to close');
5424
- return (_jsx(Box, { marginTop: 1, justifyContent: "flex-end", children: _jsx(Panel, { width: width, color: CYAN, title: "KONECK \u00B7 usage", children: lines }) }));
5425
- })(), updatePane && (() => {
5426
- const u = updatePane;
5427
- const lines = [];
5428
- lines.push(`Running **${u.current}**`);
5429
- if (u.latest === null && !u.error)
5430
- lines.push('Latest checking the registry…');
5431
- else if (u.error) {
5432
- lines.push(`Latest could not check — ${u.error}`);
5433
- lines.push('');
5434
- lines.push(`Update by hand with: \`${updateCommand()}\``);
5435
- }
5436
- else if (u.behind) {
5437
- lines.push(`Latest **${u.latest}** — an update is available`);
5438
- lines.push('');
5439
- lines.push(updating === 'idle' ? 'Press **u** to update now, **esc** to close.'
5440
- : updating === 'running' ? 'Updating… this runs npm and can take a minute.'
5441
- : updating === 'done' ? `Updated to ${u.latest}. **Restart koneck** for it to take effect.`
5442
- : 'The update failed. What the command printed:');
5443
- if (updating === 'failed') {
5444
- lines.push('');
5445
- if (updateLog)
5446
- lines.push('```\n' + updateLog + '\n```');
5447
- lines.push('Most often this is a permissions problem on the global npm prefix. Try:');
5448
- lines.push(`\`${updateCommand()}\` in your own shell, or`);
5449
- lines.push('`npm config get prefix` and make sure you own that directory.');
5450
- }
5451
- }
5452
- else {
5453
- lines.push(`Latest ${u.latest} — you are up to date.`);
5454
- lines.push('');
5455
- lines.push('Press **esc** to close.');
5456
- }
5457
- lines.push('');
5458
- lines.push('---');
5459
- lines.push('**Keys** tab analytics · shift+tab mode · ctrl+c exit · /update for this panel');
5460
- lines.push('**Editing** arrows move · ctrl+a/e ends · ctrl+w word · ctrl+u/k cut');
5461
- lines.push('**Sessions** /sessions · /send <repo> <task> · /ask <repo> <q> · /resume');
5462
- lines.push('**Changelog** github.com/Gubevu/konech/commits/main');
5463
- return (_jsx(Box, { marginTop: 1, justifyContent: "flex-end", children: _jsx(Panel, { width: Math.min(barWidth, 62), color: AMBER, title: "KONECK \u00B7 updates & guide", children: lines }) }));
5464
- })(), picker && (() => {
5465
- const list = visiblePickerItems;
5466
- // In the alternate screen, the picker shares a fixed frame with a bordered composer and
5467
- // footer. They consume six rows and the picker itself has six non-choice rows (margin,
5468
- // border, title/search and pager), so reserve thirteen in total. Letting 16 choices into
5469
- // a 24-row frame made Ink reflow the live region and visibly blink on every keypress.
5470
- const pickerRows = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
5471
- // Keep the highlighted row inside the visible window as the selection moves.
5472
- const start = Math.max(0, Math.min(pickIndex - Math.floor(pickerRows / 2), list.length - pickerRows));
5473
- const shown = list.slice(Math.max(0, start), Math.max(0, start) + pickerRows);
5474
- const title = picker.kind === 'command' ? 'Commands'
5475
- : picker.kind === 'model' ? `Models - ${cfg.provider}`
5476
- : picker.kind === 'effort' ? 'Reasoning effort'
5477
- : picker.kind === 'resume' ? 'Resume a saved session'
5478
- : picker.kind === 'file' ? 'Reference a file or directory'
5479
- : 'Connect provider';
5480
- // The palette is sized to the rows it is showing rather than to the terminal. On a wide
5481
- // screen a list of short model names in a full-width box is mostly empty box.
5482
- // The header is a title on the left and a count on the right, on one line. Estimating the
5483
- // right-hand side at a constant made "Reference a file or directory" wrap onto two rows.
5484
- const countText = pickerCountText(pickIndex, list.length, picker.items.length);
5485
- const headWidth = displayWidth(title) + displayWidth(countText) + 3;
5486
- // Measure the filtered list, not just its current scroll window. A palette whose right
5487
- // edge breathes as the highlight crosses a long description feels broken even when the
5488
- // selection itself is correct.
5489
- const rowWidth = Math.max(headWidth, ...list.map(it => 38 + (it.current ? 10 : 0) + displayWidth(it.desc) + displayWidth(it.group ?? '') + 3));
5490
- const boxWidth = pickerBoxWidth(barWidth, rowWidth + 4);
5491
- const width = boxWidth - 4;
5492
- // Room for the longest label the list actually holds, rather than a constant. A session
5493
- // title is what identifies the row, and 36 cells cut most of them in half.
5494
- const labelWidth = pickerLabelWidth(list.map(it => it.label), list.map(it => it.desc), width);
5495
- let lastGroup;
5496
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: boxWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: CYAN, bold: true, children: title }), _jsx(Text, { color: DIM, children: countText })] }), _jsxs(Box, { children: [_jsxs(Text, { color: DIM, children: [G.search, " "] }), pickQuery === ''
5497
- ? _jsx(Text, { color: DIM, children: "type to search" })
5498
- : _jsx(Text, { color: INK, children: pickQuery }), _jsx(Text, { color: CYAN, children: pickQuery === '' ? '' : '▌' })] }), list.length === 0 && _jsxs(Text, { color: MUTED, children: ["Nothing matches \"", pickQuery, "\""] }), shown.map((item, i) => {
5499
- const absolute = Math.max(0, start) + i;
5500
- const selected = absolute === pickIndex;
5501
- const header = item.group && item.group !== lastGroup ? item.group : null;
5502
- lastGroup = item.group;
5503
- const label = pickerLabel(item.label, labelWidth);
5504
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${label}` +
5505
- `${item.current ? '(current) ' : ''}${header ? `${header} · ` : ''}${item.desc}`, width) }) }, item.value + absolute));
5506
- }), list.length > pickerRows && (_jsxs(Text, { color: DIM, children: ["\u2191\u2193 navigate \u00B7 pgup/pgdn jump \u00B7 enter select \u00B7 showing ", Math.max(0, start) + 1, "\u2013", Math.max(0, start) + shown.length, " of ", list.length] }))] }));
5507
- })(), addProv && (() => {
5508
- const shadowed = BUILT_IN_PROVIDERS[addProv.name];
5509
- const ask = addProv.step === 'name' ? { label: 'name', hint: 'short and lowercase, e.g. openrouter' }
5510
- : addProv.step === 'url' ? { label: 'endpoint', hint: 'OpenAI-compatible, e.g. https://openrouter.ai/api/v1' }
5511
- : addProv.step === 'model' ? { label: 'default model', hint: 'optional — enter to skip' }
5512
- : { label: 'repoint ' + addProv.name + '? (y/n)',
5513
- hint: `it ships with KONECK, pointing at ${shadowed?.baseURL}` };
5514
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsx(Text, { color: CYAN, bold: true, children: "Declare a provider" }), _jsxs(Text, { color: MUTED, children: [ask.hint, " \u00B7 esc to cancel"] }), addProv.name !== '' && (_jsxs(Text, { color: DIM, children: [addProv.name, addProv.url ? ' ' + addProv.url : ''] })), addProv.error && _jsx(Text, { color: CRIMSON, children: addProv.error }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [ask.label, ": "] }), _jsx(Text, { color: INK, children: addProv.value }), _jsx(Text, { color: CYAN, children: "\u2588" })] })] }));
5515
- })(), keyPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: AMBER, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Text, { color: AMBER, bold: true, children: ["API key for ", keyPrompt.provider] }), _jsx(Text, { color: MUTED, children: "Paste it and press enter, or ctrl+v to read the clipboard. Held in memory for this session only; esc to cancel." }), keyPrompt.note && _jsx(Text, { color: CYAN, children: keyPrompt.note }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [keyPrompt.env, ": "] }), _jsx(Text, { color: INK, children: '*'.repeat(Math.min(keyPrompt.value.length, 48)) }), _jsx(Text, { color: CYAN, children: "\u2588" }), keyPrompt.value.length > 0 && (_jsxs(Text, { color: DIM, children: [" ", keyPrompt.value.length, " characters"] }))] })] })), ask && (() => {
5516
- let detail = '';
5517
- let isCommand = false;
5518
- try {
5519
- const parsed = JSON.parse(ask.args);
5520
- if (parsed['command'] !== undefined) {
5521
- detail = String(parsed['command']);
5522
- isCommand = true;
5523
- }
5524
- else
5525
- detail = String(parsed['path'] ?? parsed['source'] ?? '');
5526
- }
5527
- catch { /* show the tool alone */ }
5528
- const options = askOptions(ask.tool, ask.prefix);
5529
- /*
5530
- * The whole command, wrapped onto its own lines.
5531
- *
5532
- * It was `detail.slice(0, 200)` on the same line as the tool name, so a real command — a cd
5533
- * into a directory, an artisan call with a quoted PHP expression — arrived cut off mid-flight
5534
- * with nothing saying it had been cut. A command you cannot read is one you cannot honestly
5535
- * approve, and that is the whole point of asking. So it wraps instead of truncating, sits
5536
- * indented under a line that says what is about to happen, and if it is genuinely enormous
5537
- * the remainder is counted out loud rather than dropped in silence.
5538
- */
5539
- const room = Math.max(24, Math.min(barWidth, 100) - 8);
5540
- const SHOW_AT_MOST = 1200;
5541
- const shown = detail.slice(0, SHOW_AT_MOST);
5542
- const cut = detail.length - shown.length;
5543
- const detailLines = shown
5544
- ? wrapPreserving(shown, room).map(l => ' ' + l)
5545
- : [];
5546
- const lines = [
5547
- detail
5548
- ? `**${ask.tool}** wants to ${isCommand ? 'run' : 'touch'}:`
5549
- : `**${ask.tool}** wants to run.`,
5550
- ...(detailLines.length ? ['', ...detailLines] : []),
5551
- ...(cut > 0 ? [` *… and ${cut} more characters*`] : []),
5552
- '',
5553
- ...options.map((o, i) => `${i === ask.choice ? `**${G.caret} ${o.key}**` : ` ${o.key}`} ${o.label}`),
5554
- '',
5555
- ask.waiting > 0
5556
- ? `*type the number, or arrows and enter · ${ask.waiting} more waiting*`
5557
- : '*type the number, or arrows and enter · esc refuses*',
5558
- ];
5559
- return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 46), color: AMBER, title: `Permission — ${modeSpec(mode).label} mode`, children: lines }) }));
5560
- })(), _jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? DIM : CYAN, paddingX: 1, width: barWidth, flexShrink: 0, children: [_jsxs(Text, { color: CYAN, bold: true, children: [G.caret, " "] }), _jsx(Text, { color: INK, children: draft.slice(0, caret) }), busy
5413
+ return (_jsxs(Box, { flexDirection: "column", width: uiWidth, paddingX: 2, ...(altScreen ? { height: frameRows } : {}), children: [_jsxs(Box, { flexDirection: "row", flexGrow: 0, flexShrink: 1, overflowY: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: contentWidth, flexGrow: 0, flexShrink: 1, overflowY: "hidden", children: [altScreen && (
5414
+ // Grows no further than its content, but shrinks when there is not enough screen.
5415
+ //
5416
+ // flexGrow={1} filled the screen whatever was in it, so a session with only the banner in
5417
+ // it put the banner at the foot of a screen of black — reported as "it just sits at the
5418
+ // bottom". Growing only to fit means a short transcript starts at the top with the prompt
5419
+ // directly beneath it, and once the transcript outgrows the screen this box is the only
5420
+ // child that can give ground: it shrinks, and with the overflow hidden and the content
5421
+ // bottom-aligned inside it, the top is clipped and the newest rows stay visible.
5422
+ //
5423
+ // Both halves matter. Clipping the bottom would hide what just happened; anchoring
5424
+ // always to the bottom is what put the banner there.
5425
+ _jsx(Box, { flexGrow: 0, flexShrink: 1, flexDirection: "column", justifyContent: "flex-end", overflowY: "hidden", children: visibleRows.map((row, i) => (_jsx(Box, { flexDirection: "column", flexShrink: 0, children: renderRow(row, i) }, i))) })), altScreen && scrollBack > 0 && (_jsx(Text, { color: AMBER, children: `${G.caret} scrolled back ${scrollBack} rows — PageDown, or just type, to follow again` })), showAnalytics && (_jsxs(Box, { borderStyle: "round", borderColor: AMBER, paddingX: 1, marginBottom: 1, flexDirection: "column", width: barWidth, children: [_jsx(Text, { color: AMBER, bold: true, children: "\u25C6 Session Analytics" }), _jsxs(Text, { color: MUTED, children: ["Turns : ", _jsx(Text, { color: INK, children: stats.turns })] }), _jsxs(Text, { color: MUTED, children: ["Prompt tok : ", _jsx(Text, { color: INK, children: stats.promptTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Output tok : ", _jsx(Text, { color: INK, children: stats.completionTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Total tok : ", _jsx(Text, { color: INK, children: stats.totalTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Model : ", _jsx(Text, { color: INK, children: modelShort }), " Provider: ", _jsx(Text, { color: INK, children: cfg.provider })] }), _jsx(Text, { color: DIM, children: "Tab to close" })] })), _jsx(Static, { items: altScreen ? [] : rows, children: (row, index) => renderRow(row, index) }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [sayRef.current.trim() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
5426
+ .split('\n').map((line, i) => _jsx(Text, { color: INK, children: line }, i)) })] })), liveToolRef.current && renderStep(liveToolRef.current, 1), queued.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: AMBER, children: "\u25AA" }), _jsxs(Text, { color: MUTED, children: [queued.length, " queued, will run when this finishes \u00B7 /aside to ask without waiting"] })] }), queued.map((q, i) => (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: fitCells(q.split('\n')[0] ?? '', Math.max(20, barWidth - 6), true) }) }, i)))] })), agents.length > 0 && (() => {
5427
+ const done = agents.filter(a => a.status !== 'running').length;
5428
+ const failed = agents.filter(a => a.status === 'failed').length;
5429
+ const tokens = agents.reduce((n, a) => n + a.tokens, 0);
5430
+ const started = Math.min(...agents.map(a => a.startedAt));
5431
+ // The task text is clipped to whatever is left after the fixed columns, so a long
5432
+ // task never wraps and breaks the alignment of the rows under it.
5433
+ const taskWidth = Math.max(16, barWidth - 34);
5434
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, children: "\u25C6" }), _jsxs(Text, { color: INK, bold: true, children: [agents.length, " agents"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: done === agents.length ? GREEN : AMBER, children: [done, "/", agents.length, " complete"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: MUTED, children: [humanTokens(tokens), " tok"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsx(Text, { color: MUTED, children: fmtElapsed(Date.now() - started) }), failed > 0 && _jsxs(Text, { color: CRIMSON, children: ["\u00B7 ", failed, " failed"] })] }), agents.map(a => {
5435
+ const glyph = a.status === 'running' ? SPINNER[spinFrame]
5436
+ : a.status === 'failed' ? G.fail : G.ok;
5437
+ const color = a.status === 'running' ? AMBER
5438
+ : a.status === 'failed' ? CRIMSON : GREEN;
5439
+ const took = (a.endedAt ?? Date.now()) - a.startedAt;
5440
+ const task = a.task.length > taskWidth ? a.task.slice(0, taskWidth - 3) + '...' : a.task;
5441
+ return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { color: DIM, children: String(a.index + 1).padStart(2) }), _jsx(Text, { color: a.status === 'running' ? INK : MUTED, children: fitCells(task, taskWidth) }), _jsx(Text, { color: DIM, children: humanTokens(a.tokens).padStart(6) }), _jsx(Text, { color: DIM, children: fmtElapsed(took).padStart(6) })] }, a.index));
5442
+ })] }));
5443
+ })(), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: GREEN, children: [SPINNER[spinFrame], " "] }), _jsx(Shimmer, { text: workWord({
5444
+ elapsedMs, tools: turnToolsRef.current,
5445
+ tokens: liveTokens, recovered: turnRecoveredRef.current,
5446
+ }, spinWord)[0], frame: shimmerFrame, base: MUTED }), _jsx(Text, { color: MUTED, children: "\u2026 " }), _jsxs(Text, { color: DIM, children: ["(", fmtElapsed(elapsedMs), " | ", liveTokens > 0
5447
+ ? `${fmtTokens(liveTokens)} tokens`
5448
+ // A gateway routing to several backends sends content-free frames while it
5449
+ // finds one. Saying "waiting for the first token" through that reads as a
5450
+ // hang, when in fact the request was accepted and is being worked on.
5451
+ : providerFramesRef.current > 0 ? `${cfg.provider} is holding the line, no output yet`
5452
+ : elapsedMs > 8_000 ? `no response from ${cfg.provider} yet`
5453
+ : 'starting', thinkingRef.current.chars > 0 && Date.now() - thinkingRef.current.at < 2_000
5454
+ ? ` | thinking, ${fmtTokens(Math.round(thinkingRef.current.chars / 4))} reasoning tokens`
5455
+ : '', ")"] })] }) }), showThinking && thinkingTextRef.current.trim() !== '' && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: thinkingTail(thinkingTextRef.current, THINKING_LINES, barWidth - 6).map((line, i) => (_jsx(Text, { color: DIM, italic: true, children: line }, i))) })), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), usagePane && (() => {
5456
+ const width = Math.min(barWidth, usagePane === 'settings' ? 96 : 66);
5457
+ const inner = width - 4;
5458
+ const BAR_W = Math.max(12, inner - 14);
5459
+ const tabs = USAGE_TABS.map(t => t === usagePane ? `**${t[0].toUpperCase() + t.slice(1)}**` : t[0].toUpperCase() + t.slice(1)).join(' ');
5460
+ /** A labelled bar with its percentage, the shape used for every proportion here. */
5461
+ const meter = (fraction, label) => [`${bar(fraction, BAR_W)} ${Math.round(fraction * 100)}%`, `${label}`, ''];
5462
+ const lines = [tabs, '---'];
5463
+ if (usagePane === 'settings') {
5464
+ lines.push('**Settings** — written to disk and applied now', '');
5465
+ // What is actually in force, not just what this file happens to hold. provider and
5466
+ // baseURL commonly come from ~/.koneckrc, and showing those as "(not set)" made a
5467
+ // configured session look unconfigured.
5468
+ const endpoint = endpointFor(cfg.provider);
5469
+ const labelW = Math.max(...CONFIG_SCHEMA.map(f => f.label.length));
5470
+ for (const [i, f] of CONFIG_SCHEMA.entries()) {
5471
+ const selected = i === settingRow;
5472
+ const beingTyped = editing?.key === String(f.key);
5473
+ // An endpoint always has a source worth naming, so it never reads as unset or as
5474
+ // merely inherited: the question it answers is which file won.
5475
+ const isEndpoint = f.key === 'baseURL';
5476
+ const isModel = f.key === 'model';
5477
+ const here = !isEndpoint && !isModel && stored[f.key] !== undefined;
5478
+ const value = here ? stored[f.key] : liveSetting(String(f.key));
5479
+ const raw = f.key === 'maxTurns' && (value === 0 || value === undefined)
5480
+ ? 'no limit'
5481
+ : displayValue(value);
5482
+ const shown = beingTyped
5483
+ ? `${editing.value}_`
5484
+ : isEndpoint ? `${raw} (${shortSource(endpoint.source)})`
5485
+ : isModel ? `${raw} (${shortSource(modelFor(cfg.provider).source)})`
5486
+ : raw + (here || value === undefined ? '' : ' (inherited)');
5487
+ // The marker is what makes the selection legible without colour, which a panel line
5488
+ // drawn as one string cannot carry per-segment.
5489
+ const mark = selected ? '>' : ' ';
5490
+ const hint = !selected ? ''
5491
+ : f.type.kind === 'boolean' ? ' enter toggles'
5492
+ : f.type.kind === 'choice' ? ` enter cycles: ${f.type.choices.join(' / ')}`
5493
+ : f.type.kind === 'number' ? ` type a number, enter to save${f.clearable ? ', x clears' : ''}`
5494
+ : ` enter to edit${f.clearable ? ', x clears' : ''}`;
5495
+ lines.push(`${mark} ${f.label.padEnd(labelW)} ${selected ? '**' + shown + '**' : shown}${hint}`);
5496
+ }
5497
+ lines.push('');
5498
+ lines.push(editing
5499
+ ? 'Typing. **enter** saves, **ctrl+u** empties the field, **esc** abandons.'
5500
+ : 'Up/down choose · enter change · x clear · left/right tabs');
5501
+ lines.push(`Saved to ${CONFIG_FILE}`);
5502
+ }
5503
+ if (usagePane === 'session') {
5504
+ const wall = Date.now() - sessionStartRef.current;
5505
+ const cost = estimateCost(cfg.model, stats.promptTokens, stats.completionTokens);
5506
+ lines.push('**Session**', '');
5507
+ lines.push(`Total cost ${cost.known ? formatCost(cost.total) : 'no published price for this model'}`);
5508
+ lines.push(`Duration (API) ${humanDuration(apiMsRef.current)}`);
5509
+ lines.push(`Duration (wall) ${humanDuration(wall)}`);
5510
+ lines.push(`Turns ${stats.turns}`);
5511
+ lines.push(`Code changes ${usageDiff === undefined ? 'reading the working tree…'
5512
+ : usageDiff === null ? 'not a git repo — nothing to compare against'
5513
+ : `${usageDiff.added} added, ${usageDiff.removed} removed, ${usageDiff.files} file${usageDiff.files === 1 ? '' : 's'}`}`);
5514
+ lines.push(`Tokens ${humanTokens(stats.promptTokens)} in · ${humanTokens(stats.completionTokens)} out`);
5515
+ lines.push('');
5516
+ if (apiMsRef.current > 0 && wall > apiMsRef.current) {
5517
+ const share = apiMsRef.current / wall;
5518
+ lines.push(...meter(share, 'of the session was the model thinking.'));
5519
+ lines.push('The rest was tools running and your own typing.');
5520
+ }
5521
+ }
5522
+ if (usagePane === 'context') {
5523
+ const window = modelCatalog.find(m => m.id === cfg.model)?.contextLength ?? contextWindowFor(cfg.model);
5524
+ lines.push('**Context window**', '');
5525
+ if (window) {
5526
+ // This is cumulative across the session, not a made-up claim about the next request.
5527
+ // It is still useful as a pressure signal: every long turn re-sends most of the
5528
+ // history, but only the provider can count the exact request after compaction.
5529
+ const used = stats.promptTokens;
5530
+ lines.push(...meter(Math.min(1, used / window), `${humanTokens(used)} session prompt total · ${humanTokens(window)} window · ${cfg.model}`));
5531
+ if (used / window > 0.7) {
5532
+ lines.push('Close to the window. `/clear` starts fresh,');
5533
+ lines.push('`/rewind` drops just the last exchange.');
5534
+ }
5535
+ else {
5536
+ lines.push('Every turn re-sends the whole history, so a long');
5537
+ lines.push('session costs more even when little is said.');
5538
+ lines.push('`/clear` when you move to an unrelated task.');
5539
+ }
5540
+ }
5541
+ else {
5542
+ lines.push(`This provider publishes no window for **${cfg.model}**,`);
5543
+ lines.push('so there is nothing honest to draw a bar against.');
5544
+ }
5545
+ lines.push('');
5546
+ lines.push(`Session total: ${humanTokens(stats.promptTokens)} prompt · ${humanTokens(stats.completionTokens)} completion.`);
5547
+ }
5548
+ if (usagePane === 'models') {
5549
+ const statuses = readModelStatus();
5550
+ lines.push(`**Models** — ${cfg.provider}`, '');
5551
+ if (modelCatalog.length === 0) {
5552
+ // The reason, when there is one: a provider that does not publish a list is a normal
5553
+ // configuration, not a fault, and saying so is the difference between "fix your setup"
5554
+ // and "type the name yourself".
5555
+ lines.push(catalogNote.current ?? 'Could not read the model list from this provider.');
5556
+ }
5557
+ else {
5558
+ // Counted rather than assumed in either direction: what the provider said was free,
5559
+ // and separately how many it said nothing about at all. "Free 3" out of 447 used to
5560
+ // imply the other 444 were paid, which nobody had established.
5561
+ const free = modelCatalog.filter(m => m.cost === 'free');
5562
+ const priced = modelCatalog.filter(m => m.cost === 'paid');
5563
+ const unknown = modelCatalog.filter(m => m.cost === undefined);
5564
+ // Status is keyed by provider, not by catalogue membership, so an alias that has
5565
+ // actually been used still counts as tried.
5566
+ const prefix = `${cfg.provider}:`;
5567
+ const tried = Object.values(statuses).filter(s => s.model.startsWith(prefix));
5568
+ const bad = tried.filter(s => s.state !== 'ok');
5569
+ lines.push(`Listed ${modelCatalog.length}`);
5570
+ if (free.length)
5571
+ lines.push(`Free ${free.length}`);
5572
+ if (priced.length)
5573
+ lines.push(`Priced ${priced.length}`);
5574
+ if (unknown.length) {
5575
+ lines.push(`No price reported ${unknown.length}`);
5576
+ }
5577
+ lines.push(`Tried here ${tried.length} — ${tried.length - bad.length} worked, ${bad.length} failed`);
5578
+ lines.push('');
5579
+ // The model in use is not always a catalogue entry: `auto` and similar aliases are
5580
+ // routing instructions the gateway resolves per request, and never appear in a
5581
+ // listing. Showing only listed models would leave the commonest setting invisible.
5582
+ const current = modelCatalog.find(m => m.id === cfg.model);
5583
+ const st = statuses[`${cfg.provider}:${cfg.model}`];
5584
+ lines.push(`**In use** ${cfg.model}`);
5585
+ if (current) {
5586
+ // Local is about the runtime, not the address: a router on localhost needs no key and
5587
+ // runs nothing, forwarding to a cloud that bills per token.
5588
+ const hosted = servesOwnWeights(cfg.provider);
5589
+ const money = hosted ? 'local'
5590
+ : current.promptRate !== undefined && current.promptRate > 0
5591
+ ? `$${rateText(current.promptRate)} per million in`
5592
+ : current.cost === 'free' ? 'free'
5593
+ : current.cost === 'paid' ? 'paid'
5594
+ : 'no price reported';
5595
+ lines.push(` ${money}` +
5596
+ (current.contextLength ? ` · ${humanTokens(current.contextLength)} context` : '') +
5597
+ (current.maxOutput ? ` · ${humanTokens(current.maxOutput)} max out` : '') +
5598
+ (current.toolCalling === false ? ' · no tool calling' : '') +
5599
+ (current.vision ? ' · takes images' : ''));
5600
+ }
5601
+ else {
5602
+ lines.push(' a routing alias — the gateway picks the model per request');
5603
+ }
5604
+ lines.push(` status: ${st ? stateLabel(st.state) : 'not tried yet'}`);
5605
+ if (bad.length) {
5606
+ lines.push('');
5607
+ lines.push('**Failing**');
5608
+ for (const s of bad.slice(0, 6)) {
5609
+ lines.push(` ${s.model.slice(prefix.length)} — ${stateLabel(s.state)}`);
5610
+ }
5611
+ }
5612
+ lines.push('');
5613
+ lines.push('Status accrues from use: a model is marked when it');
5614
+ lines.push('answers or refuses. `/models` shows the same tags.');
5615
+ }
5616
+ }
5617
+ if (usagePane === 'limits') {
5618
+ lines.push('**Limits and quota**', '');
5619
+ lines.push(`How much of your plan is left on **${cfg.provider}**`);
5620
+ lines.push('cannot be shown.');
5621
+ lines.push('');
5622
+ lines.push('That is the provider, not a missing feature. Its');
5623
+ lines.push('replies carry no rate-limit or balance headers, and');
5624
+ lines.push('its account API refuses the key a session holds.');
5625
+ lines.push('A number here would be invented, so there is none.');
5626
+ lines.push('');
5627
+ lines.push('**What is measured instead**');
5628
+ lines.push(' · tokens actually spent — Session tab');
5629
+ lines.push(' · context actually filled — Context tab');
5630
+ lines.push(' · which models actually answered — Models tab');
5631
+ lines.push('');
5632
+ lines.push('A provider that returns `x-ratelimit-*` headers will');
5633
+ lines.push('show a real bar here with no further work.');
5634
+ }
5635
+ lines.push('---');
5636
+ lines.push('**{left}/{right}** switch tabs · **esc** to close');
5637
+ return (_jsx(Box, { marginTop: 1, justifyContent: "flex-end", children: _jsx(Panel, { width: width, color: CYAN, title: "KONECK \u00B7 usage", children: lines }) }));
5638
+ })(), updatePane && (() => {
5639
+ const u = updatePane;
5640
+ const lines = [];
5641
+ lines.push(`Running **${u.current}**`);
5642
+ if (u.latest === null && !u.error)
5643
+ lines.push('Latest checking the registry…');
5644
+ else if (u.error) {
5645
+ lines.push(`Latest could not check — ${u.error}`);
5646
+ lines.push('');
5647
+ lines.push(`Update by hand with: \`${updateCommand()}\``);
5648
+ }
5649
+ else if (u.behind) {
5650
+ lines.push(`Latest **${u.latest}** — an update is available`);
5651
+ lines.push('');
5652
+ lines.push(updating === 'idle' ? 'Press **u** to update now, **esc** to close.'
5653
+ : updating === 'running' ? 'Updating… this runs npm and can take a minute.'
5654
+ : updating === 'done' ? `Updated to ${u.latest}. **Restart koneck** for it to take effect.`
5655
+ : 'The update failed. What the command printed:');
5656
+ if (updating === 'failed') {
5657
+ lines.push('');
5658
+ if (updateLog)
5659
+ lines.push('```\n' + updateLog + '\n```');
5660
+ lines.push('Most often this is a permissions problem on the global npm prefix. Try:');
5661
+ lines.push(`\`${updateCommand()}\` in your own shell, or`);
5662
+ lines.push('`npm config get prefix` and make sure you own that directory.');
5663
+ }
5664
+ }
5665
+ else {
5666
+ lines.push(`Latest ${u.latest} — you are up to date.`);
5667
+ lines.push('');
5668
+ lines.push('Press **esc** to close.');
5669
+ }
5670
+ lines.push('');
5671
+ lines.push('---');
5672
+ lines.push('**Keys** tab analytics · shift+tab mode · ctrl+c exit · /update for this panel');
5673
+ lines.push('**Editing** arrows move · ctrl+a/e ends · ctrl+w word · ctrl+u/k cut');
5674
+ lines.push('**Sessions** /sessions · /send <repo> <task> · /ask <repo> <q> · /resume');
5675
+ lines.push('**Changelog** github.com/Gubevu/konech/commits/main');
5676
+ return (_jsx(Box, { marginTop: 1, justifyContent: "flex-end", children: _jsx(Panel, { width: Math.min(barWidth, 62), color: AMBER, title: "KONECK \u00B7 updates & guide", children: lines }) }));
5677
+ })(), picker && (() => {
5678
+ const list = visiblePickerItems;
5679
+ // In the alternate screen, the picker shares a fixed frame with a bordered composer and
5680
+ // footer. They consume six rows and the picker itself has six non-choice rows (margin,
5681
+ // border, title/search and pager), so reserve thirteen in total. Letting 16 choices into
5682
+ // a 24-row frame made Ink reflow the live region and visibly blink on every keypress.
5683
+ const pickerRows = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
5684
+ // Keep the highlighted row inside the visible window as the selection moves.
5685
+ const start = Math.max(0, Math.min(pickIndex - Math.floor(pickerRows / 2), list.length - pickerRows));
5686
+ const shown = list.slice(Math.max(0, start), Math.max(0, start) + pickerRows);
5687
+ const title = picker.kind === 'command' ? 'Commands'
5688
+ : picker.kind === 'model' ? `Models - ${cfg.provider}`
5689
+ : picker.kind === 'effort' ? 'Reasoning effort'
5690
+ : picker.kind === 'resume' ? 'Resume a saved session'
5691
+ : picker.kind === 'file' ? 'Reference a file or directory'
5692
+ : 'Connect provider';
5693
+ // The palette is sized to the rows it is showing rather than to the terminal. On a wide
5694
+ // screen a list of short model names in a full-width box is mostly empty box.
5695
+ // The header is a title on the left and a count on the right, on one line. Estimating the
5696
+ // right-hand side at a constant made "Reference a file or directory" wrap onto two rows.
5697
+ const countText = pickerCountText(pickIndex, list.length, picker.items.length);
5698
+ const headWidth = displayWidth(title) + displayWidth(countText) + 3;
5699
+ // Measure the filtered list, not just its current scroll window. A palette whose right
5700
+ // edge breathes as the highlight crosses a long description feels broken even when the
5701
+ // selection itself is correct.
5702
+ const rowWidth = Math.max(headWidth, ...list.map(it => 38 + (it.current ? 10 : 0) + displayWidth(it.desc) + displayWidth(it.group ?? '') + 3));
5703
+ const boxWidth = pickerBoxWidth(barWidth, rowWidth + 4);
5704
+ const width = boxWidth - 4;
5705
+ // Room for the longest label the list actually holds, rather than a constant. A session
5706
+ // title is what identifies the row, and 36 cells cut most of them in half.
5707
+ const labelWidth = pickerLabelWidth(list.map(it => it.label), list.map(it => it.desc), width);
5708
+ let lastGroup;
5709
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: boxWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: CYAN, bold: true, children: title }), _jsx(Text, { color: DIM, children: countText })] }), _jsxs(Box, { children: [_jsxs(Text, { color: DIM, children: [G.search, " "] }), pickQuery === ''
5710
+ ? _jsx(Text, { color: DIM, children: "type to search" })
5711
+ : _jsx(Text, { color: INK, children: pickQuery }), _jsx(Text, { color: CYAN, children: pickQuery === '' ? '' : '▌' })] }), list.length === 0 && _jsxs(Text, { color: MUTED, children: ["Nothing matches \"", pickQuery, "\""] }), shown.map((item, i) => {
5712
+ const absolute = Math.max(0, start) + i;
5713
+ const selected = absolute === pickIndex;
5714
+ const header = item.group && item.group !== lastGroup ? item.group : null;
5715
+ lastGroup = item.group;
5716
+ const label = pickerLabel(item.label, labelWidth);
5717
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${label}` +
5718
+ `${item.current ? '(current) ' : ''}${header ? `${header} · ` : ''}${item.desc}`, width) }) }, item.value + absolute));
5719
+ }), list.length > pickerRows && (_jsxs(Text, { color: DIM, children: ["\u2191\u2193 navigate \u00B7 pgup/pgdn jump \u00B7 enter select \u00B7 showing ", Math.max(0, start) + 1, "\u2013", Math.max(0, start) + shown.length, " of ", list.length] }))] }));
5720
+ })(), addProv && (() => {
5721
+ const shadowed = BUILT_IN_PROVIDERS[addProv.name];
5722
+ const ask = addProv.step === 'name' ? { label: 'name', hint: 'short and lowercase, e.g. openrouter' }
5723
+ : addProv.step === 'url' ? { label: 'endpoint', hint: 'OpenAI-compatible, e.g. https://openrouter.ai/api/v1' }
5724
+ : addProv.step === 'model' ? { label: 'default model', hint: 'optional — enter to skip' }
5725
+ : { label: 'repoint ' + addProv.name + '? (y/n)',
5726
+ hint: `it ships with KONECK, pointing at ${shadowed?.baseURL}` };
5727
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsx(Text, { color: CYAN, bold: true, children: "Declare a provider" }), _jsxs(Text, { color: MUTED, children: [ask.hint, " \u00B7 esc to cancel"] }), addProv.name !== '' && (_jsxs(Text, { color: DIM, children: [addProv.name, addProv.url ? ' ' + addProv.url : ''] })), addProv.error && _jsx(Text, { color: CRIMSON, children: addProv.error }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [ask.label, ": "] }), _jsx(Text, { color: INK, children: addProv.value }), _jsx(Text, { color: CYAN, children: "\u2588" })] })] }));
5728
+ })(), keyPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: AMBER, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Text, { color: AMBER, bold: true, children: ["API key for ", keyPrompt.provider] }), _jsx(Text, { color: MUTED, children: "Paste it and press enter, or ctrl+v to read the clipboard. Held in memory for this session only; esc to cancel." }), keyPrompt.note && _jsx(Text, { color: CYAN, children: keyPrompt.note }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [keyPrompt.env, ": "] }), _jsx(Text, { color: INK, children: '*'.repeat(Math.min(keyPrompt.value.length, 48)) }), _jsx(Text, { color: CYAN, children: "\u2588" }), keyPrompt.value.length > 0 && (_jsxs(Text, { color: DIM, children: [" ", keyPrompt.value.length, " characters"] }))] })] })), ask && (() => {
5729
+ let detail = '';
5730
+ let isCommand = false;
5731
+ try {
5732
+ const parsed = JSON.parse(ask.args);
5733
+ if (parsed['command'] !== undefined) {
5734
+ detail = String(parsed['command']);
5735
+ isCommand = true;
5736
+ }
5737
+ else
5738
+ detail = String(parsed['path'] ?? parsed['source'] ?? '');
5739
+ }
5740
+ catch { /* show the tool alone */ }
5741
+ const options = askOptions(ask.tool, ask.prefix);
5742
+ /*
5743
+ * The whole command, wrapped onto its own lines.
5744
+ *
5745
+ * It was `detail.slice(0, 200)` on the same line as the tool name, so a real command — a cd
5746
+ * into a directory, an artisan call with a quoted PHP expression — arrived cut off mid-flight
5747
+ * with nothing saying it had been cut. A command you cannot read is one you cannot honestly
5748
+ * approve, and that is the whole point of asking. So it wraps instead of truncating, sits
5749
+ * indented under a line that says what is about to happen, and if it is genuinely enormous
5750
+ * the remainder is counted out loud rather than dropped in silence.
5751
+ */
5752
+ const room = Math.max(24, Math.min(barWidth, 100) - 8);
5753
+ const SHOW_AT_MOST = 1200;
5754
+ const shown = detail.slice(0, SHOW_AT_MOST);
5755
+ const cut = detail.length - shown.length;
5756
+ const detailLines = shown
5757
+ ? wrapPreserving(shown, room).map(l => ' ' + l)
5758
+ : [];
5759
+ const lines = [
5760
+ detail
5761
+ ? `**${ask.tool}** wants to ${isCommand ? 'run' : 'touch'}:`
5762
+ : `**${ask.tool}** wants to run.`,
5763
+ ...(detailLines.length ? ['', ...detailLines] : []),
5764
+ ...(cut > 0 ? [` *… and ${cut} more characters*`] : []),
5765
+ '',
5766
+ ...options.map((o, i) => `${i === ask.choice ? `**${G.caret} ${o.key}**` : ` ${o.key}`} ${o.label}`),
5767
+ '',
5768
+ ask.waiting > 0
5769
+ ? `*type the number, or arrows and enter · ${ask.waiting} more waiting*`
5770
+ : '*type the number, or arrows and enter · esc refuses*',
5771
+ ];
5772
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 46), color: AMBER, title: `Permission — ${modeSpec(mode).label} mode`, children: lines }) }));
5773
+ })()] }), diffPanelOpen && (_jsx(DiffPanel, { files: liveDiffs, width: diffPanelWidth, height: Math.max(4, frameRows - 6) }))] }), _jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? DIM : CYAN, paddingX: 1, width: barWidth, flexShrink: 0, children: [_jsxs(Text, { color: CYAN, bold: true, children: [G.caret, " "] }), _jsx(Text, { color: INK, children: draft.slice(0, caret) }), busy
5561
5774
  ? _jsx(Text, { color: INK, children: draft.slice(caret) })
5562
5775
  : caret < draft.length
5563
5776
  ? _jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: CYAN, color: "#10222A", children: draft[caret] }), _jsx(Text, { color: INK, children: draft.slice(caret + 1) })] })