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/README.md +52 -20
- package/dist/agent/systemPrompt.js +3 -3
- package/dist/agent/workflow.js +492 -12
- package/dist/commands/registry.js +400 -44
- package/dist/headless.js +34 -0
- package/dist/ui/App.js +68 -14
- package/dist/ui/SelectList.js +30 -4
- package/dist/ui/StatusLine.js +2 -1
- package/dist/ui/useAgent.js +52 -8
- package/dist/ui/windowList.js +100 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
}
|
package/dist/ui/SelectList.js
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { Box, Text, useInput } from "ink";
|
|
4
|
-
|
|
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 (
|
|
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
|
}
|
package/dist/ui/StatusLine.js
CHANGED
|
@@ -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, "
|
|
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
|
}
|
package/dist/ui/useAgent.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
}
|