kritya 0.8.45-beta → 0.8.47-beta

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/App.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { Box, Static, Text, useApp, useBoxMetrics, useInput, useStderr, useWindowSize, } from "ink";
4
4
  import fs from "node:fs";
5
5
  import { glob } from "tinyglobby";
@@ -28,6 +28,7 @@ import { StreamViewport } from "./StreamViewport.js";
28
28
  import { TextInput } from "./TextInput.js";
29
29
  import { TranscriptItem } from "./TranscriptItem.js";
30
30
  import { terminalColumns, terminalRows } from "./viewport.js";
31
+ import { fitRow, listRows, ROW_GUTTER, ROW_SEPARATOR, windowList } from "./windowList.js";
31
32
  import { BUILTIN_COMMANDS, runCommand } from "../commands/registry.js";
32
33
  import { mcpPrompts, mcpResources } from "../mcp/client.js";
33
34
  import { useAgent } from "./useAgent.js";
@@ -147,7 +148,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
147
148
  .catch(() => { });
148
149
  }, [workspace]);
149
150
  useEffect(refreshFileList, [refreshFileList]);
150
- const { items, addItem, clearItems, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, bypassMode, setBypassMode, autoApprovedCount, cycleMode, onAcceptEditsConfirm, onBypassModeConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
151
+ const { items, addItem, clearItems, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, setChained, workflow, refreshWorkflow, prefill, setPrefill, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, bypassMode, setBypassMode, autoApprovedCount, cycleMode, onAcceptEditsConfirm, onBypassModeConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
151
152
  agent,
152
153
  workspace,
153
154
  modelRef,
@@ -167,6 +168,18 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
167
168
  useEffect(() => {
168
169
  onRequestElicitationReady?.(requestElicitation);
169
170
  }, [onRequestElicitationReady, requestElicitation]);
171
+ // Drop a prefilled command into the input line when a phase ends at a gate,
172
+ // so Enter continues the workflow instead of the user retyping `/flow`. The
173
+ // gate still holds: nothing runs until it is submitted, and any edit — or
174
+ // Esc — discards the suggestion. Applied only from the idle prompt, since a
175
+ // prefill arriving while a picker was open would clobber what was typed there.
176
+ useEffect(() => {
177
+ if (prefill === null || phase !== "input")
178
+ return;
179
+ setInput(prefill);
180
+ setInputKey((k) => k + 1);
181
+ setPrefill(null);
182
+ }, [prefill, phase, setPrefill]);
170
183
  // Tick an elapsed-seconds counter while the agent is working.
171
184
  useEffect(() => {
172
185
  if (phase !== "working") {
@@ -226,6 +239,37 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
226
239
  ? allCommands.filter((c) => c.name.startsWith(input.trim()))
227
240
  : [];
228
241
  const selectedCmd = suggestions.length ? Math.min(cmdIndex, suggestions.length - 1) : 0;
242
+ /**
243
+ * The suggestion list flattened to rendered lines, so a group heading costs
244
+ * a row like any other — windowing the commands alone would still let the
245
+ * headings push real rows off the bottom of the screen.
246
+ *
247
+ * `lineOfCmd[i]` is the line `suggestions[i]` landed on. The cursor indexes
248
+ * commands, because that is what the arrow keys move through; this is what
249
+ * maps it onto a line.
250
+ */
251
+ const suggestionLines = [];
252
+ const lineOfCmd = [];
253
+ {
254
+ // Group headers only earn their keep once more than one category is
255
+ // actually on screen — once typing has filtered down to a single family,
256
+ // a lone header is just noise.
257
+ const showHeaders = new Set(suggestions.map((c) => c.category)).size > 1;
258
+ let lastCategory;
259
+ for (const c of suggestions) {
260
+ if (showHeaders && c.category !== lastCategory) {
261
+ suggestionLines.push({ kind: "header", label: c.category ?? "Other" });
262
+ lastCategory = c.category;
263
+ }
264
+ lineOfCmd.push(suggestionLines.length);
265
+ suggestionLines.push({ kind: "cmd", cmd: c });
266
+ }
267
+ }
268
+ // The transcript above is static; this reserves the prompt box, the hint
269
+ // line and the status line, which is what has to stay visible.
270
+ const suggestionWindow = windowList(suggestionLines, lineOfCmd[selectedCmd] ?? 0, listRows(rows, 8));
271
+ // The list is indented by its own paddingLeft, then by the row's marker.
272
+ const suggestionRowWidth = contentWidth - 2 - ROW_GUTTER;
229
273
  // File suggestions while typing an @mention.
230
274
  const mentionMatch = phase === "input" && !input.startsWith("/") ? MENTION_RE.exec(input) : null;
231
275
  const mentionFragment = mentionMatch ? mentionMatch[2].toLowerCase() : null;
@@ -342,6 +386,14 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
342
386
  setActivity("cancelling…");
343
387
  abortRef.current?.abort();
344
388
  }
389
+ // Esc clears the idle input line. This is the documented way to decline a
390
+ // command prefilled at a workflow gate; it also just empties a half-typed
391
+ // prompt, which is what a bare Esc on an idle prompt is expected to do.
392
+ if (key.escape && phase === "input" && input) {
393
+ setInput("");
394
+ setInputKey((k) => k + 1);
395
+ return;
396
+ }
345
397
  // Ctrl+O toggles showing full tool output.
346
398
  if (key.ctrl && _input === "o") {
347
399
  setVerbose((v) => !v);
@@ -503,7 +555,9 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
503
555
  setPhase,
504
556
  setActivity,
505
557
  setRunningPhase,
558
+ setChained,
506
559
  refreshWorkflow,
560
+ setPrefill,
507
561
  setCtxPct,
508
562
  setTasks,
509
563
  setPlanMode,
@@ -685,18 +739,18 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
685
739
  setInput(v.replace(/\t/g, ""));
686
740
  setCmdIndex(0);
687
741
  setFileIndex(0);
688
- }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [(() => {
689
- // Group headers only earn their keep once more than one
690
- // category is actually on screen — once typing has filtered
691
- // down to a single family, a lone header is just noise.
692
- const showHeaders = new Set(suggestions.map((c) => c.category)).size > 1;
693
- let lastCategory;
694
- return suggestions.map((c, i) => {
695
- const showHeader = showHeaders && c.category !== lastCategory;
696
- lastCategory = c.category;
697
- return (_jsxs(Fragment, { children: [showHeader && _jsx(Text, { dimColor: true, children: c.category ?? "Other" }), _jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] })] }, c.name));
698
- });
699
- })(), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, bypassMode: bypassMode, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
742
+ }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [suggestionWindow.visible.map((line, i) => {
743
+ if (line.kind === "header") {
744
+ return (_jsx(Text, { dimColor: true, wrap: "truncate", children: line.label }, `header:${line.label}`));
745
+ }
746
+ const c = line.cmd;
747
+ const selected = i === suggestionWindow.cursor;
748
+ const row = fitRow(c.name, c.description, suggestionRowWidth);
749
+ return (_jsxs(Text, { color: selected ? "cyan" : undefined, wrap: "truncate", children: [selected ? "❯ " : " ", _jsx(Text, { bold: selected, children: row.label }), row.hint ? (_jsxs(Text, { dimColor: true, children: [ROW_SEPARATOR, row.hint] })) : null] }, c.name));
750
+ }), suggestionWindow.clipped ? (_jsxs(Text, { dimColor: true, children: [" ", suggestionWindow.from, "\u2013", suggestionWindow.to, " of ", suggestionWindow.total, " \u00B7 \u2191\u2193 to scroll"] })) : null, _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter insert \u00B7 Esc cancel" })] })), phase === "input" &&
751
+ input.startsWith("/") &&
752
+ !input.includes(" ") &&
753
+ suggestions.length === 0 && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["no command matches ", input.trim(), " \u00B7 Esc to clear"] }) })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, wrap: "truncate", children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, bypassMode: bypassMode, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
700
754
  ? `${model}-${modelDisplaySlug(servedModel)}`
701
755
  : model, workflow: workflow, branch: branch, budgetPct: budgetPct, budgetStopped: budgetStopped, totalCost: totalCost, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
702
756
  }
@@ -1,10 +1,33 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
- import { Box, Text, useInput } from "ink";
4
- export function SelectList({ items, onSelect, onCancel, }) {
3
+ import { Box, Text, useInput, useWindowSize } from "ink";
4
+ import { terminalColumns, terminalRows } from "./viewport.js";
5
+ import { fitRow, listRows, ROW_GUTTER, ROW_SEPARATOR, windowList } from "./windowList.js";
6
+ /**
7
+ * The list every picker in the app renders through — the Ctrl+P palette, the
8
+ * model picker, the permission and trust prompts, the elicitation prompt, and
9
+ * session resume.
10
+ *
11
+ * It measures the terminal itself and windows to fit, so all of those inherit
12
+ * scrolling and row truncation without passing anything: a list longer than
13
+ * the screen scrolls with the cursor kept in view, and a long row is cut
14
+ * instead of wrapping onto a second line. `width`/`maxRows` are there for a
15
+ * caller that knows its own layout better than the terminal size suggests.
16
+ */
17
+ export function SelectList({ items, onSelect, onCancel, width, maxRows, reservedRows, }) {
5
18
  const [rawIndex, setIndex] = useState(0);
19
+ const windowSize = useWindowSize();
20
+ const rows = maxRows ??
21
+ (reservedRows === undefined
22
+ ? listRows(terminalRows(windowSize))
23
+ : listRows(terminalRows(windowSize), reservedRows));
24
+ // A row also spends ROW_GUTTER columns on its selection marker, and the
25
+ // border/padding the caller wrapped us in is not ours to know — so leave a
26
+ // couple of columns of slack rather than assume the terminal's full width.
27
+ const rowWidth = (width ?? terminalColumns(windowSize) - 4) - ROW_GUTTER;
6
28
  // Items can shrink under us (e.g. a live filter above the list).
7
29
  const index = items.length ? Math.min(rawIndex, items.length - 1) : 0;
30
+ const list = windowList(items, index, rows);
8
31
  useInput((_input, key) => {
9
32
  if (!items.length) {
10
33
  if (key.escape && onCancel)
@@ -20,5 +43,8 @@ export function SelectList({ items, onSelect, onCancel, }) {
20
43
  else if (key.escape && onCancel)
21
44
  onCancel();
22
45
  });
23
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => (_jsxs(Text, { color: i === index ? "green" : undefined, children: [i === index ? "❯ " : " ", item.label, item.hint ? _jsxs(Text, { dimColor: true, children: [" \u2014 ", item.hint] }) : null] }, item.value))) }));
46
+ return (_jsxs(Box, { flexDirection: "column", children: [list.visible.map((item, i) => {
47
+ const row = fitRow(item.label, item.hint, rowWidth);
48
+ return (_jsxs(Text, { color: i === list.cursor ? "green" : undefined, wrap: "truncate", children: [i === list.cursor ? "❯ " : " ", row.label, row.hint ? (_jsxs(Text, { dimColor: true, children: [ROW_SEPARATOR, row.hint] })) : null] }, item.value));
49
+ }), list.clipped ? (_jsxs(Text, { dimColor: true, children: [" ", list.from, "\u2013", list.to, " of ", list.total, " \u00B7 \u2191\u2193 to scroll"] })) : null] }));
24
50
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from "ink";
3
+ import { PHASE_ORDER, phaseIndex } from "../agent/workflow.js";
3
4
  import { displayModelId } from "../config/models.js";
4
5
  /**
5
6
  * The single dim status bar pinned to the bottom of the screen. Keeps only
@@ -17,5 +18,5 @@ export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdi
17
18
  ? "plan"
18
19
  : acceptEdits
19
20
  ? `accept-edits (${autoApprovedCount} auto)`
20
- : "default", " · "] }), displayModelId(provider, model), _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", budgetStopped || budgetPct >= 80 ? (_jsxs(Text, { color: budgetStopped ? "red" : "yellow", children: [" \u00B7 budget ", budgetPct, "%"] })) : (""), totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", " · /diff review · /undo revert · Ctrl+K stop · /status for details"] }));
21
+ : "default", " · "] }), displayModelId(provider, model), _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, " ", phaseIndex(workflow.phase), "/", PHASE_ORDER.length, " ", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", budgetStopped || budgetPct >= 80 ? (_jsxs(Text, { color: budgetStopped ? "red" : "yellow", children: [" \u00B7 budget ", budgetPct, "%"] })) : (""), totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", " · /diff review · /undo revert · Ctrl+K stop · /status for details"] }));
21
22
  }
@@ -9,7 +9,7 @@ import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchya
9
9
  import { KillSwitchError } from "../agent/killSwitch.js";
10
10
  import { TAGLINE } from "./Banner.js";
11
11
  import { defaultSandboxMode, sandboxAvailable } from "../shell/sandbox.js";
12
- import { loadProjectState, nextPhase, PHASE_COMMAND, } from "../agent/workflow.js";
12
+ import { loadProjectState, nextPhase, PHASE_ORDER, phaseDoneMessage, phaseIndex, prefillAfter, } from "../agent/workflow.js";
13
13
  import { tavilySearch } from "../tools/webSearch.js";
14
14
  import { killActiveNotice, useKillSwitch } from "./useKillSwitch.js";
15
15
  import { useUsageBudget } from "./useUsageBudget.js";
@@ -69,6 +69,21 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
69
69
  : `Resumed previous session (${resumedCount} messages)${taskNote}.`,
70
70
  });
71
71
  }
72
+ // A project parked mid-flow is the single most useful thing to say at
73
+ // launch: without it the user has to remember which phase they were in,
74
+ // and `/flow` on its own does not say where it will resume from.
75
+ const active = loadProjectState(workspace);
76
+ if (active) {
77
+ const next = nextPhase(active.phase);
78
+ initial.push({
79
+ id: nextId.current++,
80
+ kind: "info",
81
+ text: `Project "${active.name}" is at the ${active.phase} phase ` +
82
+ `(${phaseIndex(active.phase)}/${PHASE_ORDER.length}). ` +
83
+ (next ? `/flow continues with ${next} · ` : `every phase has run · `) +
84
+ `/project for status.`,
85
+ });
86
+ }
72
87
  return initial;
73
88
  });
74
89
  const [phase, setPhase] = useState(resumeSessions?.length ? "resume" : "input");
@@ -103,8 +118,27 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
103
118
  runningPhaseRef.current = p;
104
119
  setRunningPhaseState(p);
105
120
  }, []);
121
+ /**
122
+ * True while one command is running several phases back to back. A ref, not
123
+ * state: the teardown reads it in the same tick the command is still inside,
124
+ * and nothing renders from it.
125
+ */
126
+ const chainedRef = useRef(false);
127
+ const setChained = useCallback((chained) => {
128
+ chainedRef.current = chained;
129
+ }, []);
106
130
  /** The active project workflow, for the statusline. Persists between turns. */
107
131
  const [workflow, setWorkflow] = useState(() => loadProjectState(workspace));
132
+ /**
133
+ * Text to drop into the input line once the turn ends.
134
+ *
135
+ * Stopping at a gate costs two steps — read the result, then type the next
136
+ * command — and the second one is pure retyping of something the app already
137
+ * knows. Prefilling it means Enter continues and Esc (or any edit) discards
138
+ * it, without taking the decision away: nothing runs until the user submits.
139
+ * One-shot; the UI clears it as soon as it is applied.
140
+ */
141
+ const [prefill, setPrefill] = useState(null);
108
142
  const [permission, setPermission] = useState(null);
109
143
  const [elicitation, setElicitation] = useState(null);
110
144
  const [model, setModel] = useState(modelRef.current);
@@ -394,22 +428,29 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
394
428
  * paraphrasing its instructions drops it often enough that the handoff can't
395
429
  * depend on that — the user is left at a prompt with no idea what comes next.
396
430
  * Printing it here makes it deterministic.
431
+ *
432
+ * This covers a single-phase command only. The last phase of a *chain* is
433
+ * announced by `runFlow` instead: this runs at the end of every turn, which
434
+ * for a chain is while the chain is still going, so it has no way to tell
435
+ * "middle of a chain" from "chain just ended at a gate" — and staying quiet
436
+ * for both is the safe half of that. `runFlow` knows which it is.
397
437
  */
398
438
  const announceNextPhase = useCallback(() => {
399
439
  const ran = runningPhaseRef.current;
400
440
  setRunningPhase(null);
401
441
  if (!ran)
402
442
  return;
443
+ // A chain runs the next phase itself, so naming the command to type would
444
+ // point at work that is already starting. The chain prints its own plan.
445
+ if (chainedRef.current)
446
+ return;
403
447
  // Prefer the phase on disk: an autonomous turn may have advanced it past
404
448
  // whatever the command started.
405
449
  const current = loadProjectState(workspace)?.phase ?? ran;
406
- const next = nextPhase(current);
407
- addItem({
408
- kind: "info",
409
- text: next
410
- ? `✓ ${current} phase done — next: ${PHASE_COMMAND[next]} (or /project to review where you are)`
411
- : `✓ ${current} phase done — the workflow is complete. /project clear ends it.`,
412
- });
450
+ addItem({ kind: "info", text: phaseDoneMessage(current) });
451
+ const prefill = prefillAfter(current);
452
+ if (prefill)
453
+ setPrefill(prefill);
413
454
  }, [workspace, addItem, setRunningPhase]);
414
455
  const runWebSearch = async (query) => {
415
456
  if (agent.kill.active) {
@@ -606,8 +647,11 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
606
647
  setActivity,
607
648
  runningPhase,
608
649
  setRunningPhase,
650
+ setChained,
609
651
  workflow,
610
652
  refreshWorkflow,
653
+ prefill,
654
+ setPrefill,
611
655
  permission,
612
656
  elicitation,
613
657
  inFlight,
@@ -0,0 +1,100 @@
1
+ import stringWidth from "string-width";
2
+ import { truncateToWidth } from "./inline.js";
3
+ /**
4
+ * Windowing and width-fitting for every list in the UI: the Ctrl+P palette,
5
+ * the inline `/` suggestions, the history search, the model picker, and the
6
+ * permission/trust prompts. They were each written separately and each one
7
+ * grew its own way of not fitting on screen — some wrapped a long row onto a
8
+ * second line, and the long ones (a bare `/` is 33 commands plus group
9
+ * headings) simply ran off the bottom of the terminal with no way to scroll.
10
+ *
11
+ * Kept pure and React-free so the arithmetic is testable on its own, and so
12
+ * the renderers only have to decide what a row looks like, never how much of
13
+ * it fits.
14
+ */
15
+ /** Columns the selection marker (`"❯ "` / `" "`) occupies at the start of a row. */
16
+ export const ROW_GUTTER = 2;
17
+ /** What sits between a row's label and its hint. */
18
+ export const ROW_SEPARATOR = " — ";
19
+ /**
20
+ * The label keeps at least this many columns before a hint is allowed any
21
+ * space — below it the label alone is more use than a two-letter stub plus a
22
+ * truncated hint.
23
+ */
24
+ const MIN_LABEL = 8;
25
+ /** Under this a hint is dropped outright rather than squeezed to a stub. */
26
+ const MIN_HINT = 8;
27
+ /** Chrome a list should leave alone by default: prompt box, hints, status line. */
28
+ const DEFAULT_RESERVED_ROWS = 10;
29
+ /**
30
+ * Rows a list may occupy: the terminal, less the chrome around it.
31
+ *
32
+ * The floor is deliberately low rather than generous — a very short terminal
33
+ * should still show a usable list instead of collapsing to nothing, and the
34
+ * caller can always pass an explicit `maxRows` when it knows its own layout.
35
+ */
36
+ export function listRows(terminalRows, reserved = DEFAULT_RESERVED_ROWS) {
37
+ return Math.max(3, Math.floor(terminalRows) - Math.max(0, Math.floor(reserved)));
38
+ }
39
+ /**
40
+ * The slice of `items` to render, plus where the cursor sits inside it.
41
+ *
42
+ * The window is centred on the cursor and then clamped to the ends, which
43
+ * makes it a pure function of the cursor — there is no scroll offset to carry
44
+ * between renders, so a list can never get stuck scrolled away from the row
45
+ * it is highlighting. Centring also means moving within the first page does
46
+ * not shift the list at all, and the last row is reachable without the window
47
+ * running off the bottom.
48
+ */
49
+ export function windowList(items, cursor, maxRows) {
50
+ const total = items.length;
51
+ const rows = Math.max(1, Math.floor(maxRows));
52
+ if (total === 0) {
53
+ return { visible: [], cursor: 0, from: 0, to: 0, total: 0, clipped: false };
54
+ }
55
+ // `cursor` can be stale — items shrink under a live filter — so clamp it
56
+ // rather than trust it, exactly like the renderers used to do by hand.
57
+ const at = Math.min(Math.max(Math.floor(cursor), 0), total - 1);
58
+ const offset = Math.min(Math.max(at - Math.floor(rows / 2), 0), Math.max(0, total - rows));
59
+ const visible = items.slice(offset, offset + rows);
60
+ return {
61
+ visible,
62
+ cursor: at - offset,
63
+ from: offset + 1,
64
+ to: offset + visible.length,
65
+ total,
66
+ clipped: visible.length < total,
67
+ };
68
+ }
69
+ /**
70
+ * Fit one row into `width` display columns, cutting the *hint* before the
71
+ * label: the label is what identifies the row, so a long description must
72
+ * never be the reason a command's name is unreadable. When there is no room
73
+ * for both, the hint goes entirely.
74
+ *
75
+ * `width` is what is left after the gutter, and may be unknown (-1, or 0 from
76
+ * a terminal that has not reported a size yet) — in which case the row is
77
+ * returned untouched and the renderer's `wrap="truncate"` backstop takes over.
78
+ */
79
+ export function fitRow(label, hint, width) {
80
+ if (width <= 0)
81
+ return { label, hint };
82
+ if (!hint)
83
+ return { label: truncateToWidth(label, width), hint: undefined };
84
+ const sepWidth = stringWidth(ROW_SEPARATOR);
85
+ const labelWidth = stringWidth(label);
86
+ if (width < MIN_LABEL + sepWidth + MIN_HINT) {
87
+ return { label: truncateToWidth(label, width), hint: undefined };
88
+ }
89
+ if (labelWidth + sepWidth + MIN_HINT <= width) {
90
+ return { label, hint: truncateToWidth(hint, width - labelWidth - sepWidth) };
91
+ }
92
+ // The label itself has to give ground, but never below MIN_LABEL's worth of
93
+ // room for the hint to be worth showing at all.
94
+ const fittedLabel = truncateToWidth(label, width - sepWidth - MIN_HINT);
95
+ const rest = width - stringWidth(fittedLabel) - sepWidth;
96
+ return {
97
+ label: fittedLabel,
98
+ hint: rest >= MIN_HINT ? truncateToWidth(hint, rest) : undefined,
99
+ };
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.45-beta",
3
+ "version": "0.8.47-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {