@cruxy/cli 1.2.1 → 1.4.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/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/agent/status.js +56 -0
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +401 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +717 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +204 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +163 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { DEFAULT_MODE, MODE_LABELS, modeAutoApproves, } from "../agent/index.js";
|
|
2
|
+
import { completeLine } from "../components/autocomplete.js";
|
|
3
|
+
import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
|
|
4
|
+
import { selectList } from "../components/select.js";
|
|
5
|
+
import { viewLabel, viewOrder } from "./views.js";
|
|
6
|
+
import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
|
|
7
|
+
import { openPalette } from "./palette.js";
|
|
8
|
+
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
9
|
+
import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
|
|
10
|
+
import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
|
|
11
|
+
/**
|
|
12
|
+
* The TUI's input loop (P1) — the piece that replaces `repl.ts`'s readline
|
|
13
|
+
* loop. It owns exactly two things: the edit buffer and command dispatch.
|
|
14
|
+
* Everything visible is the renderer's; everything the agent does is the
|
|
15
|
+
* session's.
|
|
16
|
+
*
|
|
17
|
+
* The stdin discipline is inherited from the REPL for the same reason it
|
|
18
|
+
* existed there: while a turn runs, the approval prompt grabs stdin in raw mode
|
|
19
|
+
* through the shared `readSingleKey`. Two raw-mode readers on one stdin would
|
|
20
|
+
* contend, so this loop takes stdin only while collecting a line and RELEASES it
|
|
21
|
+
* (`restore()` in a `finally`) before `session.send` — the approval prompt then
|
|
22
|
+
* has uncontested ownership, exactly as with the per-line readline interface.
|
|
23
|
+
*
|
|
24
|
+
* P5 track 1 keeps that behaviour and changes what enforces it. The loop now
|
|
25
|
+
* reads through a {@link KeyLease} rather than a reader of its own: one reader
|
|
26
|
+
* for the whole session, refcounted, so releasing between lines still leaves
|
|
27
|
+
* raw mode (the count returns to zero, and the approval prompt's ownership is
|
|
28
|
+
* unchanged) while a modal opened DURING a read borrows the live reader instead
|
|
29
|
+
* of opening a second one. Release-before-send stops being the only thing
|
|
30
|
+
* standing between the TUI and two readers on one stdin.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Every command the TUI completes on Tab — the shared set plus this shell's own
|
|
34
|
+
* panel commands (P5 track 5).
|
|
35
|
+
*
|
|
36
|
+
* This constant existed before and was consumed by NOTHING: exported from
|
|
37
|
+
* `tui/index.ts`, imported nowhere, while the TUI had no completion at all.
|
|
38
|
+
* It is wired to Tab now rather than deleted, because the thing it was reaching
|
|
39
|
+
* for — the REPL's readline completer, which the TUI cannot use — is real.
|
|
40
|
+
*/
|
|
41
|
+
export const TUI_COMMANDS = [
|
|
42
|
+
...SHARED_COMMANDS,
|
|
43
|
+
"/close",
|
|
44
|
+
"/open",
|
|
45
|
+
"/view",
|
|
46
|
+
].sort();
|
|
47
|
+
const HELP = [
|
|
48
|
+
"commands:",
|
|
49
|
+
...SHARED_HELP,
|
|
50
|
+
" /close <panel> hide a panel (sidebar | context | model | git | tools)",
|
|
51
|
+
" or a whole column (rail = all four rail panels)",
|
|
52
|
+
" /open <panel> show a hidden panel",
|
|
53
|
+
" /view [name] show a main-pane view, or list them",
|
|
54
|
+
" Tab complete a slash command",
|
|
55
|
+
" Ctrl+K open the command palette",
|
|
56
|
+
" Ctrl+B focus the sidebar nav (arrows switch view, Esc leaves)",
|
|
57
|
+
" Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
|
|
58
|
+
" PgUp / PgDn scroll the pane; Esc returns to the live view",
|
|
59
|
+
" Ctrl+D leave cruxy",
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* Group names `/close` and `/open` accept beside individual panels. `rail` is
|
|
63
|
+
* the one that matters for compatibility: it is what P1 shipped and what users
|
|
64
|
+
* already have in their fingers, so it keeps working — as the whole stack.
|
|
65
|
+
*/
|
|
66
|
+
const PANEL_GROUPS = {
|
|
67
|
+
rail: RAIL_PANELS,
|
|
68
|
+
};
|
|
69
|
+
const emptyEditor = () => ({ text: "", cursor: 0 });
|
|
70
|
+
/**
|
|
71
|
+
* Render the input row: mode chip, prompt, text, and a visible caret at the
|
|
72
|
+
* cursor. The caret is drawn rather than moved, because the frame owns the real
|
|
73
|
+
* cursor — it parks after the last painted row so a repaint can erase upward.
|
|
74
|
+
*
|
|
75
|
+
* The chip is shown for every mode EXCEPT `manual` (P5 track 4). Manual is the
|
|
76
|
+
* default and asks about everything, so a chip there would be noise on every
|
|
77
|
+
* session; the modes worth a permanent marker are the ones where the agent is
|
|
78
|
+
* doing something you did not individually agree to, and those must never be
|
|
79
|
+
* inferable only from what has already happened. `full-auto` and `auto-approve`
|
|
80
|
+
* carry the warning role rather than the muted one for the same reason.
|
|
81
|
+
*/
|
|
82
|
+
export function renderInput(editor, theme, mode = DEFAULT_MODE) {
|
|
83
|
+
const chip = mode === DEFAULT_MODE
|
|
84
|
+
? ""
|
|
85
|
+
: `${modeAutoApproves(mode) ? theme.warning(`[${MODE_LABELS[mode]}]`) : theme.muted(`[${MODE_LABELS[mode]}]`)} `;
|
|
86
|
+
const prompt = `${chip}${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
|
|
87
|
+
const before = editor.text.slice(0, editor.cursor);
|
|
88
|
+
const at = editor.text.slice(editor.cursor, editor.cursor + 1);
|
|
89
|
+
const after = editor.text.slice(editor.cursor + 1);
|
|
90
|
+
const caret = at === "" ? theme.accent(theme.glyph.cursorBar) : theme.strong(at);
|
|
91
|
+
return prompt + before + caret + after;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse `/close git` → ["git"], `/close rail` → the four rail panels. Returns
|
|
95
|
+
* null when the argument is missing or unknown — never a guess, so a typo can
|
|
96
|
+
* not close something the user did not name.
|
|
97
|
+
*/
|
|
98
|
+
function panelArg(input, command) {
|
|
99
|
+
const arg = input.slice(command.length).trim().toLowerCase();
|
|
100
|
+
const group = PANEL_GROUPS[arg];
|
|
101
|
+
if (group)
|
|
102
|
+
return { label: arg, panels: group };
|
|
103
|
+
const match = CLOSABLE_PANELS.find((p) => p === arg);
|
|
104
|
+
return match ? { label: PANEL_LABELS[match], panels: [match] } : null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Keys the focused sidebar nav claims (P7 track 2). Returns whether it took
|
|
108
|
+
* the key — anything it declines falls through to the input line untouched.
|
|
109
|
+
*
|
|
110
|
+
* The set is deliberately small. Up/down move the selection and Enter is not
|
|
111
|
+
* needed to commit it: selecting a view IS the action, and a two-step
|
|
112
|
+
* move-then-confirm would make the arrows do nothing visible on their own.
|
|
113
|
+
* Esc and Ctrl+B both hand the keyboard back, so the way out is whichever of
|
|
114
|
+
* the two the user reaches for first.
|
|
115
|
+
*
|
|
116
|
+
* A PRINTABLE CHARACTER RETURNS FOCUS AND IS NOT SWALLOWED. Someone who starts
|
|
117
|
+
* typing has stopped navigating, and losing the first letter of a prompt to a
|
|
118
|
+
* focus mode they forgot they were in is the failure that makes people stop
|
|
119
|
+
* using the binding.
|
|
120
|
+
*/
|
|
121
|
+
function handleNavKey(key, renderer) {
|
|
122
|
+
switch (key.kind) {
|
|
123
|
+
case "up":
|
|
124
|
+
renderer.cycleView(-1);
|
|
125
|
+
return true;
|
|
126
|
+
case "down":
|
|
127
|
+
renderer.cycleView(1);
|
|
128
|
+
return true;
|
|
129
|
+
case "escape":
|
|
130
|
+
case "ctrl-b":
|
|
131
|
+
case "enter":
|
|
132
|
+
renderer.focusSidebar(false);
|
|
133
|
+
return true;
|
|
134
|
+
case "char":
|
|
135
|
+
// Claim the focus change only; the character itself falls through to the
|
|
136
|
+
// editor on this same keypress.
|
|
137
|
+
renderer.focusSidebar(false);
|
|
138
|
+
return false;
|
|
139
|
+
default:
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Read one line from the TUI's input row. Takes raw stdin for the duration and
|
|
145
|
+
* always releases it. Resolves `null` on Ctrl+D / EOF.
|
|
146
|
+
*/
|
|
147
|
+
async function readLine(keys, renderer, hooks) {
|
|
148
|
+
const editor = emptyEditor();
|
|
149
|
+
const paint = () => renderer.setInput(renderInput(editor, renderer.theme, hooks.mode()));
|
|
150
|
+
paint();
|
|
151
|
+
keys.begin();
|
|
152
|
+
try {
|
|
153
|
+
for (;;) {
|
|
154
|
+
const key = await keys.read();
|
|
155
|
+
// FOCUS IS A ROUTING DECISION, resolved before the editor sees anything
|
|
156
|
+
// (P7 track 2). While the sidebar holds the keyboard the arrows move the
|
|
157
|
+
// view selection instead of the cursor — but only the keys that mean
|
|
158
|
+
// something there are claimed, so scrolling, the palette and the mode ring
|
|
159
|
+
// keep working exactly as they do at the input line. A view is persistent
|
|
160
|
+
// and NON-EXCLUSIVE; this is not a modal, and nothing here blocks.
|
|
161
|
+
if (renderer.sidebarHasFocus() && handleNavKey(key, renderer)) {
|
|
162
|
+
paint();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
switch (key.kind) {
|
|
166
|
+
case "enter": {
|
|
167
|
+
const text = editor.text;
|
|
168
|
+
editor.text = "";
|
|
169
|
+
editor.cursor = 0;
|
|
170
|
+
// Submitting returns to the live view (P7 track 1). Scrollback holds
|
|
171
|
+
// its position against output arriving on its own, which is the point
|
|
172
|
+
// — but the user asking for that output is not "on its own", and
|
|
173
|
+
// answering into a pane they cannot see is the one case where holding
|
|
174
|
+
// still is wrong.
|
|
175
|
+
renderer.scrollToLive();
|
|
176
|
+
paint();
|
|
177
|
+
return text;
|
|
178
|
+
}
|
|
179
|
+
case "eof":
|
|
180
|
+
return null;
|
|
181
|
+
case "ctrl-c":
|
|
182
|
+
// A populated buffer: clear it (an escape hatch from a half-typed
|
|
183
|
+
// line). An empty one: Ctrl-C means leave, same as the REPL.
|
|
184
|
+
if (editor.text === "")
|
|
185
|
+
return null;
|
|
186
|
+
editor.text = "";
|
|
187
|
+
editor.cursor = 0;
|
|
188
|
+
paint();
|
|
189
|
+
break;
|
|
190
|
+
case "backspace":
|
|
191
|
+
if (editor.cursor > 0) {
|
|
192
|
+
editor.text =
|
|
193
|
+
editor.text.slice(0, editor.cursor - 1) +
|
|
194
|
+
editor.text.slice(editor.cursor);
|
|
195
|
+
editor.cursor--;
|
|
196
|
+
paint();
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
case "left":
|
|
200
|
+
if (editor.cursor > 0) {
|
|
201
|
+
editor.cursor--;
|
|
202
|
+
paint();
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
case "right":
|
|
206
|
+
if (editor.cursor < editor.text.length) {
|
|
207
|
+
editor.cursor++;
|
|
208
|
+
paint();
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
case "char":
|
|
212
|
+
editor.text =
|
|
213
|
+
editor.text.slice(0, editor.cursor) +
|
|
214
|
+
key.char +
|
|
215
|
+
editor.text.slice(editor.cursor);
|
|
216
|
+
editor.cursor += key.char.length;
|
|
217
|
+
paint();
|
|
218
|
+
break;
|
|
219
|
+
case "tab": {
|
|
220
|
+
// Complete a slash command in place (P5 track 5). Only the leading
|
|
221
|
+
// word of a `/…` line completes — everything else is prose bound for
|
|
222
|
+
// the model, and Tab must never mangle it. Completing REWRITES the
|
|
223
|
+
// buffer and never submits: Enter stays the only trigger, exactly as
|
|
224
|
+
// the REPL's readline completer behaves.
|
|
225
|
+
const head = editor.text.slice(0, editor.cursor);
|
|
226
|
+
if (head.startsWith("/") && !/\s/.test(head)) {
|
|
227
|
+
const { line, suggestions } = completeLine(head, TUI_COMMANDS);
|
|
228
|
+
if (suggestions.length > 1) {
|
|
229
|
+
renderer.println(renderer.theme.muted(suggestions.join(" ")));
|
|
230
|
+
}
|
|
231
|
+
if (line !== head) {
|
|
232
|
+
editor.text = line + editor.text.slice(editor.cursor);
|
|
233
|
+
editor.cursor = line.length;
|
|
234
|
+
}
|
|
235
|
+
paint();
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case "ctrl-k": {
|
|
240
|
+
// The command palette (P5 track 6). It runs on the SAME reader this
|
|
241
|
+
// loop is holding — track 1's lease makes the nested claim a no-op —
|
|
242
|
+
// and paints into the same frame, so the conversation stays behind it.
|
|
243
|
+
const picked = await hooks.openPalette();
|
|
244
|
+
if (picked !== null) {
|
|
245
|
+
// Inserted at the cursor, not appended: the palette is reachable
|
|
246
|
+
// mid-line, and a command pasted onto the end of a half-typed
|
|
247
|
+
// sentence is not what anyone meant by it.
|
|
248
|
+
editor.text =
|
|
249
|
+
editor.text.slice(0, editor.cursor) +
|
|
250
|
+
picked +
|
|
251
|
+
editor.text.slice(editor.cursor);
|
|
252
|
+
editor.cursor += picked.length;
|
|
253
|
+
}
|
|
254
|
+
// Repaint either way: the drawer coming down leaves rows to reclaim.
|
|
255
|
+
paint();
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case "shift-tab":
|
|
259
|
+
// Cycle the mode WITHOUT disturbing the line being typed (P5 track 4).
|
|
260
|
+
// Mode is a property of the session, not of the message — losing a
|
|
261
|
+
// half-written prompt to a mode switch would make the binding one
|
|
262
|
+
// people learn not to press.
|
|
263
|
+
hooks.cycleMode();
|
|
264
|
+
paint();
|
|
265
|
+
break;
|
|
266
|
+
case "page-up":
|
|
267
|
+
case "page-down":
|
|
268
|
+
// Scroll the conversation (P7 track 1). Like Shift+Tab, this leaves
|
|
269
|
+
// the line being typed completely alone: reading back over what was
|
|
270
|
+
// said is the most ordinary thing to do WHILE composing a reply, and
|
|
271
|
+
// a scroll that cost the draft would be unusable for exactly that.
|
|
272
|
+
//
|
|
273
|
+
// No `paint()`: the input row has not changed, and the renderer
|
|
274
|
+
// schedules its own repaint for the rows that did.
|
|
275
|
+
renderer.scrollPage(key.kind === "page-up" ? 1 : -1);
|
|
276
|
+
break;
|
|
277
|
+
case "escape":
|
|
278
|
+
// Esc leaves the scrolled view — a mode needs a visible exit, and the
|
|
279
|
+
// notice names this key. It stays inert at the live tail rather than
|
|
280
|
+
// being claimed unconditionally, so the binding remains free for
|
|
281
|
+
// whatever a later track wants Esc to mean when nothing is scrolled.
|
|
282
|
+
renderer.scrollToLive();
|
|
283
|
+
break;
|
|
284
|
+
case "ctrl-b":
|
|
285
|
+
// Take the keyboard to the sidebar nav (P7 track 2). Refused when the
|
|
286
|
+
// sidebar is closed or dropped for width, and SAID rather than
|
|
287
|
+
// silently ignored: focus the user cannot see would make the arrows
|
|
288
|
+
// change meaning with nothing on screen to account for it.
|
|
289
|
+
if (!renderer.focusSidebar(true)) {
|
|
290
|
+
renderer.println(renderer.theme.muted("(no sidebar to focus — /open sidebar, or widen the terminal)"));
|
|
291
|
+
}
|
|
292
|
+
paint();
|
|
293
|
+
break;
|
|
294
|
+
default:
|
|
295
|
+
// arrows: no binding yet, deliberately inert rather than leaking a
|
|
296
|
+
// control char into the buffer.
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
keys.restore();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Render a failed turn into the conversation and carry on — never exit. */
|
|
306
|
+
function printTurnError(renderer, err) {
|
|
307
|
+
const cruxy = fromUnknown(err);
|
|
308
|
+
const text = formatError(cruxy, {
|
|
309
|
+
verbose: isVerbose(),
|
|
310
|
+
color: shouldUseColor(process.stdout),
|
|
311
|
+
});
|
|
312
|
+
for (const line of text.split("\n"))
|
|
313
|
+
renderer.println(line);
|
|
314
|
+
}
|
|
315
|
+
/** Echo the submitted line into the conversation, the way the REPL echoes a turn. */
|
|
316
|
+
function echoPrompt(renderer, text) {
|
|
317
|
+
const t = renderer.theme;
|
|
318
|
+
renderer.println(`${t.accent("cruxy")} ${t.muted(t.glyph.caret)} ${text}`);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Dispatch one submitted line. Returns `"exit"` when the loop should end,
|
|
322
|
+
* `null` to continue.
|
|
323
|
+
*/
|
|
324
|
+
async function dispatch(input, session, renderer, out, slashCommands, checkpoints, pick) {
|
|
325
|
+
const trimmed = input.trim();
|
|
326
|
+
if (trimmed === "")
|
|
327
|
+
return null;
|
|
328
|
+
// This shell's own commands first — they are about panels, which the REPL
|
|
329
|
+
// has none of.
|
|
330
|
+
if (trimmed === "/help") {
|
|
331
|
+
for (const line of HELP)
|
|
332
|
+
renderer.println(renderer.theme.muted(line));
|
|
333
|
+
renderer.println();
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
if (trimmed === "/close" || trimmed.startsWith("/close ")) {
|
|
337
|
+
handlePanel(trimmed, "/close", false, renderer);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
if (trimmed === "/open" || trimmed.startsWith("/open ")) {
|
|
341
|
+
handlePanel(trimmed, "/open", true, renderer);
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
if (trimmed === "/view" || trimmed.startsWith("/view ")) {
|
|
345
|
+
handleView(trimmed, renderer);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
// Everything else is the SHARED implementation (P5 track 5). P1 forked this
|
|
349
|
+
// loop out of `repl.ts` and left every one of these behind — including
|
|
350
|
+
// `/plan`, which was then the only way to reach plan mode at all.
|
|
351
|
+
const outcome = await dispatchCommand(input, {
|
|
352
|
+
session,
|
|
353
|
+
out,
|
|
354
|
+
slashCommands,
|
|
355
|
+
tty: true, // the TUI only runs on a terminal
|
|
356
|
+
...(pick ? { pick } : {}),
|
|
357
|
+
});
|
|
358
|
+
if (outcome.kind === "exit")
|
|
359
|
+
return "exit";
|
|
360
|
+
if (outcome.kind === "handled")
|
|
361
|
+
return null;
|
|
362
|
+
// A real turn. Assistant text streams into the main column through the
|
|
363
|
+
// renderer; the loop below owns nothing but input.
|
|
364
|
+
echoPrompt(renderer, outcome.text);
|
|
365
|
+
try {
|
|
366
|
+
checkpoints?.beginRun(outcome.text);
|
|
367
|
+
await session.send(outcome.text, renderer);
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
printTurnError(renderer, err);
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* `/view [name]` — select a main-pane view, or list what there is (P7 track 2).
|
|
376
|
+
*
|
|
377
|
+
* Exists so switching views never depends on the sidebar. The nav is the
|
|
378
|
+
* discoverable path, but it can be closed or dropped for width, and a UI whose
|
|
379
|
+
* only route to a surface can vanish is one that strands people. This is also
|
|
380
|
+
* the scriptable path, which is what makes the register testable end to end.
|
|
381
|
+
*/
|
|
382
|
+
function handleView(input, renderer) {
|
|
383
|
+
const t = renderer.theme;
|
|
384
|
+
const sources = renderer.viewSources();
|
|
385
|
+
const arg = input.slice("/view".length).trim().toLowerCase();
|
|
386
|
+
const ids = viewOrder(sources);
|
|
387
|
+
if (arg === "") {
|
|
388
|
+
renderer.println(t.muted("views:"));
|
|
389
|
+
for (const id of ids) {
|
|
390
|
+
const mark = id === renderer.view() ? t.glyph.pointer : " ";
|
|
391
|
+
renderer.println(t.muted(` ${mark} ${viewLabel(id, sources)}`));
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// Matched on the id AND the label, because the sidebar shows the label and
|
|
396
|
+
// that is what a user will type back. They are the same string for every
|
|
397
|
+
// view so far; keeping both accepted means they can diverge without the
|
|
398
|
+
// command quietly becoming wrong.
|
|
399
|
+
const target = ids.find((id) => id === arg || viewLabel(id, sources).toLowerCase() === arg);
|
|
400
|
+
if (target === undefined || !renderer.setView(target)) {
|
|
401
|
+
renderer.println(t.muted(`unknown view: ${arg}`));
|
|
402
|
+
renderer.println(t.muted(`try: ${ids.map((id) => viewLabel(id, sources)).join(" | ")}`));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
renderer.println(t.muted(`showing ${viewLabel(target, sources)}`));
|
|
406
|
+
}
|
|
407
|
+
/** `/close <panel>` and `/open <panel>` share everything but the target state. */
|
|
408
|
+
function handlePanel(input, command, open, renderer) {
|
|
409
|
+
const t = renderer.theme;
|
|
410
|
+
const target = panelArg(input, command);
|
|
411
|
+
if (target === null) {
|
|
412
|
+
const names = [...CLOSABLE_PANELS.map((p) => PANEL_LABELS[p]), "rail"].join(" | ");
|
|
413
|
+
renderer.println(t.muted(`usage: ${command} <${names}>`));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const { label, panels } = target;
|
|
417
|
+
// A group counts as changed when ANY of its panels moved, so `/open rail`
|
|
418
|
+
// with one panel already open still opens the other three.
|
|
419
|
+
const changed = panels
|
|
420
|
+
.map((p) => renderer.setPanelOpen(p, open))
|
|
421
|
+
.some(Boolean);
|
|
422
|
+
if (!changed) {
|
|
423
|
+
renderer.println(t.muted(`${label} is already ${open ? "open" : "closed"}`));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const verb = open ? "opened" : "closed";
|
|
427
|
+
const restore = open ? "/close" : "/open";
|
|
428
|
+
renderer.println(t.muted(`${label} ${verb} — ${restore} ${label} to undo`));
|
|
429
|
+
if (!open)
|
|
430
|
+
return;
|
|
431
|
+
// Opening a panel the screen cannot show must SAY so; silently painting
|
|
432
|
+
// nothing would read as a broken command. Width and height fail differently
|
|
433
|
+
// and are fixed differently, so they are reported separately rather than
|
|
434
|
+
// collapsed into one vague "doesn't fit".
|
|
435
|
+
const columns = renderer.droppedColumns();
|
|
436
|
+
const lost = columns.find((c) => panels.some((p) => columnOf(p) === c));
|
|
437
|
+
if (lost !== undefined) {
|
|
438
|
+
renderer.println(t.muted(`(not enough width for the ${COLUMN_LABELS[lost]} — widen the terminal)`));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const short = renderer.droppedRailPanels();
|
|
442
|
+
if (panels.some((p) => p !== "sidebar" && short.includes(p))) {
|
|
443
|
+
renderer.println(t.muted(`(not enough height to show it yet — make the terminal taller)`));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Drive the TUI: paint the shell, then prompt → read → dispatch until the user
|
|
448
|
+
* leaves. `initialMessage` (from `cruxy "<message>"`) runs as the first turn
|
|
449
|
+
* before the first prompt, so the shell is already on screen while it streams.
|
|
450
|
+
*/
|
|
451
|
+
export async function runTui(session, renderer, opts = {}) {
|
|
452
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
453
|
+
renderer.setInput(renderInput(emptyEditor(), renderer.theme, session.getMode()));
|
|
454
|
+
if (opts.initialMessage && opts.initialMessage.trim() !== "") {
|
|
455
|
+
const first = opts.initialMessage.trim();
|
|
456
|
+
echoPrompt(renderer, first);
|
|
457
|
+
try {
|
|
458
|
+
opts.checkpoints?.beginRun(first);
|
|
459
|
+
await session.send(first, renderer);
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
printTurnError(renderer, err);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// One reader for the whole session, leased per line. Released before any turn
|
|
466
|
+
// runs — see the stdin note at the top of this file.
|
|
467
|
+
const lease = opts.lease ?? createKeyLease(stdin);
|
|
468
|
+
// The TUI's command output: committed into the conversation column. No
|
|
469
|
+
// width fitting — `main` reflows at paint time, so truncating here would
|
|
470
|
+
// clip a line the column was about to wrap correctly.
|
|
471
|
+
const out = {
|
|
472
|
+
print: (line = "") => renderer.println(line),
|
|
473
|
+
theme: renderer.theme,
|
|
474
|
+
fit: (line) => line,
|
|
475
|
+
};
|
|
476
|
+
const hooks = {
|
|
477
|
+
mode: () => session.getMode(),
|
|
478
|
+
cycleMode: () => announceMode(out, session.cycleMode()),
|
|
479
|
+
openPalette: () => openPalette(renderer, lease, opts.slashCommands ?? []),
|
|
480
|
+
};
|
|
481
|
+
// The TUI's picker (P6 track 1): an overlay drawer on the SAME reader this
|
|
482
|
+
// loop holds, exactly like the palette. Declines rather than painting into a
|
|
483
|
+
// terminal with no room for a drawer — a modal that renders nothing while
|
|
484
|
+
// still eating every keystroke is indistinguishable from a hang.
|
|
485
|
+
const pick = async (items, pickOpts) => {
|
|
486
|
+
if (!canOverlay(renderer))
|
|
487
|
+
return null;
|
|
488
|
+
const result = await selectList(items, {
|
|
489
|
+
title: pickOpts.title,
|
|
490
|
+
toLabel: pickOpts.toLabel,
|
|
491
|
+
...(pickOpts.initialIndex === undefined
|
|
492
|
+
? {}
|
|
493
|
+
: { initialIndex: pickOpts.initialIndex }),
|
|
494
|
+
// The drawer's budget, minus the title row and the key hint the
|
|
495
|
+
// component draws around the list.
|
|
496
|
+
maxVisible: Math.max(1, renderer.overlayRows() - 2),
|
|
497
|
+
}, createOverlayIO(renderer, lease));
|
|
498
|
+
return result.kind === "selected" ? result.value : null;
|
|
499
|
+
};
|
|
500
|
+
for (;;) {
|
|
501
|
+
const line = await readLine(lease.handle(), renderer, hooks);
|
|
502
|
+
if (line === null)
|
|
503
|
+
return "eof";
|
|
504
|
+
const outcome = await dispatch(line, session, renderer, out, opts.slashCommands ?? [], opts.checkpoints, pick);
|
|
505
|
+
if (outcome !== null)
|
|
506
|
+
return outcome;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval prompt as an in-viewport modal (P5 track 2).
|
|
3
|
+
*
|
|
4
|
+
* Before this, the prompt was the one thing in the TUI that could not be drawn:
|
|
5
|
+
* it wrote to stderr, so the renderer's only way to keep those bytes legible was
|
|
6
|
+
* to clear the entire shell for the duration. You answered "may I overwrite
|
|
7
|
+
* this file?" with the diff, the conversation, and the file's own name gone from
|
|
8
|
+
* the screen. The question survived; everything that made it answerable did not.
|
|
9
|
+
*
|
|
10
|
+
* Nothing about the prompt's LOGIC changes here — `promptForApproval` still
|
|
11
|
+
* renders the same bytes, reads the same 4-way choice, and default-denies on
|
|
12
|
+
* anything unrecognized. Only the surface moves: `write` composes overlay rows
|
|
13
|
+
* instead of terminal bytes, and the reads come from the session's key lease
|
|
14
|
+
* rather than a reader of their own.
|
|
15
|
+
*
|
|
16
|
+
* The displaced-state latch is untouched and still lives in the renderer:
|
|
17
|
+
* `beginPrompt` sets `awaiting-approval` (stashing the phase and its clock) and
|
|
18
|
+
* `endPrompt` calls `promptResolved` to put them back. That part was already
|
|
19
|
+
* correct — this only stops the phase from meaning "blank the screen".
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Build a {@link PromptIO} that draws into the TUI's overlay drawer.
|
|
23
|
+
*
|
|
24
|
+
* The write model is append-and-recompose, which is what lets the existing
|
|
25
|
+
* prompt code run unmodified: `promptForApproval` emits its block, then a
|
|
26
|
+
* newline, then possibly a follow-up label, treating `write` as a terminal that
|
|
27
|
+
* remembers. Buffering the whole transcript and re-splitting it on every write
|
|
28
|
+
* gives exactly that behaviour without the prompt knowing it is in a modal.
|
|
29
|
+
*/
|
|
30
|
+
export function createOverlayPromptIO(surface, lease, color) {
|
|
31
|
+
/** Everything the prompt has written this interaction, verbatim. */
|
|
32
|
+
let transcript = "";
|
|
33
|
+
/** The line being typed into a `readLine` follow-up, if one is open. */
|
|
34
|
+
let typing = null;
|
|
35
|
+
/** This interaction's claim on the shared reader; null while none is open. */
|
|
36
|
+
let keys = null;
|
|
37
|
+
const paint = () => {
|
|
38
|
+
// A trailing "\n" from the prompt means "that line is done", not "add a
|
|
39
|
+
// blank row" — `split` would produce an empty last element and the drawer
|
|
40
|
+
// would grow a dead row under the choices.
|
|
41
|
+
const body = transcript.replace(/\n$/, "");
|
|
42
|
+
const rows = body === "" ? [] : body.split("\n");
|
|
43
|
+
// The caret is drawn, not moved: the frame parks the real cursor after the
|
|
44
|
+
// last painted row so a repaint can erase upward, exactly as the input row
|
|
45
|
+
// does. An invisible cursor in a text field reads as a hang, so the field
|
|
46
|
+
// always shows where the next character lands.
|
|
47
|
+
if (typing !== null) {
|
|
48
|
+
rows[Math.max(0, rows.length - 1)] =
|
|
49
|
+
`${rows[rows.length - 1] ?? ""}${typing}▏`;
|
|
50
|
+
}
|
|
51
|
+
surface.setOverlay(rows);
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
color,
|
|
55
|
+
// The drawer spans the viewport, so the prompt reflows against the width it
|
|
56
|
+
// is actually painted at — not stderr's, which is what it measured when it
|
|
57
|
+
// was writing to stderr.
|
|
58
|
+
get columns() {
|
|
59
|
+
return surface.overlayWidth();
|
|
60
|
+
},
|
|
61
|
+
beginPrompt() {
|
|
62
|
+
transcript = "";
|
|
63
|
+
typing = null;
|
|
64
|
+
// Borrow the session's reader. When the input loop is between lines this
|
|
65
|
+
// is the first claim and enters raw mode; when a modal is opened from
|
|
66
|
+
// inside a read it is a no-op on a terminal already in raw mode.
|
|
67
|
+
keys = lease.handle();
|
|
68
|
+
keys.begin();
|
|
69
|
+
},
|
|
70
|
+
endPrompt() {
|
|
71
|
+
// Take the drawer down and hand the reader back on EVERY path, including
|
|
72
|
+
// a default-deny on EOF. A modal that yields nothing back leaves the
|
|
73
|
+
// conversation permanently short a few rows.
|
|
74
|
+
surface.setOverlay(null);
|
|
75
|
+
transcript = "";
|
|
76
|
+
typing = null;
|
|
77
|
+
keys?.restore();
|
|
78
|
+
keys = null;
|
|
79
|
+
},
|
|
80
|
+
write(text) {
|
|
81
|
+
transcript += text;
|
|
82
|
+
paint();
|
|
83
|
+
},
|
|
84
|
+
async readKey() {
|
|
85
|
+
const key = await read(keys);
|
|
86
|
+
return keyToChar(key);
|
|
87
|
+
},
|
|
88
|
+
/**
|
|
89
|
+
* The follow-up line for `n` (reason) and `t` (instruction), edited in the
|
|
90
|
+
* drawer. Append-only plus backspace: this is a one-line reason field, not
|
|
91
|
+
* the input row, and cursor movement inside it would be the first thing to
|
|
92
|
+
* need a second editor implementation for no user-visible gain.
|
|
93
|
+
*
|
|
94
|
+
* Escape and Ctrl-C resolve "" — which `promptForApproval` reads as "no
|
|
95
|
+
* reason given", collapsing `instruct` to a plain reject. Backing out of the
|
|
96
|
+
* reason field can only ever make the decision MORE conservative.
|
|
97
|
+
*/
|
|
98
|
+
async readLine() {
|
|
99
|
+
typing = "";
|
|
100
|
+
paint();
|
|
101
|
+
try {
|
|
102
|
+
for (;;) {
|
|
103
|
+
const key = await read(keys);
|
|
104
|
+
switch (key.kind) {
|
|
105
|
+
case "enter":
|
|
106
|
+
return typing;
|
|
107
|
+
case "eof":
|
|
108
|
+
case "ctrl-c":
|
|
109
|
+
case "escape":
|
|
110
|
+
return "";
|
|
111
|
+
case "backspace":
|
|
112
|
+
if (typing.length > 0)
|
|
113
|
+
typing = typing.slice(0, -1);
|
|
114
|
+
break;
|
|
115
|
+
case "char":
|
|
116
|
+
typing += key.char;
|
|
117
|
+
break;
|
|
118
|
+
default:
|
|
119
|
+
// Arrows and tab: inert rather than leaking a control char into
|
|
120
|
+
// text that is sent verbatim to the agent.
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
paint();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
typing = null;
|
|
128
|
+
paint();
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read one key, or EOF when no reader is open.
|
|
135
|
+
*
|
|
136
|
+
* A read outside a `beginPrompt`/`endPrompt` bracket is a wiring bug, and EOF is
|
|
137
|
+
* the only safe answer to it: the prompt maps EOF to default-deny, so the
|
|
138
|
+
* failure mode is a refused action rather than a hang or an approval nobody gave.
|
|
139
|
+
*/
|
|
140
|
+
async function read(keys) {
|
|
141
|
+
if (keys === null)
|
|
142
|
+
return { kind: "eof" };
|
|
143
|
+
return keys.read();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The {@link PromptIO.readKey} contract, matching `readSingleKey` exactly: the
|
|
147
|
+
* printable character, `"\n"` for enter, and `""` for everything that means "no
|
|
148
|
+
* answer" — so the prompt's default-deny mapping is identical in the drawer and
|
|
149
|
+
* on a bare terminal.
|
|
150
|
+
*/
|
|
151
|
+
function keyToChar(key) {
|
|
152
|
+
switch (key.kind) {
|
|
153
|
+
case "char":
|
|
154
|
+
return key.char;
|
|
155
|
+
case "enter":
|
|
156
|
+
return "\n";
|
|
157
|
+
default:
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
}
|