@cruxy/cli 1.3.0 → 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/status.js +56 -0
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/session-commands.js +11 -49
- package/dist/components/keys.js +38 -0
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/render/diff.js +7 -1
- package/dist/render/status-view.js +6 -6
- package/dist/tui/app.js +125 -2
- package/dist/tui/git-status.js +46 -1
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +7 -2
- package/dist/tui/layout.js +45 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/renderer.js +365 -20
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/views.js +66 -0
- package/dist/utils/git.js +113 -0
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -2,6 +2,7 @@ import { DEFAULT_MODE, MODE_LABELS, modeAutoApproves, } from "../agent/index.js"
|
|
|
2
2
|
import { completeLine } from "../components/autocomplete.js";
|
|
3
3
|
import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
|
|
4
4
|
import { selectList } from "../components/select.js";
|
|
5
|
+
import { viewLabel, viewOrder } from "./views.js";
|
|
5
6
|
import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
|
|
6
7
|
import { openPalette } from "./palette.js";
|
|
7
8
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
@@ -41,6 +42,7 @@ export const TUI_COMMANDS = [
|
|
|
41
42
|
...SHARED_COMMANDS,
|
|
42
43
|
"/close",
|
|
43
44
|
"/open",
|
|
45
|
+
"/view",
|
|
44
46
|
].sort();
|
|
45
47
|
const HELP = [
|
|
46
48
|
"commands:",
|
|
@@ -48,9 +50,12 @@ const HELP = [
|
|
|
48
50
|
" /close <panel> hide a panel (sidebar | context | model | git | tools)",
|
|
49
51
|
" or a whole column (rail = all four rail panels)",
|
|
50
52
|
" /open <panel> show a hidden panel",
|
|
53
|
+
" /view [name] show a main-pane view, or list them",
|
|
51
54
|
" Tab complete a slash command",
|
|
52
55
|
" Ctrl+K open the command palette",
|
|
56
|
+
" Ctrl+B focus the sidebar nav (arrows switch view, Esc leaves)",
|
|
53
57
|
" Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
|
|
58
|
+
" PgUp / PgDn scroll the pane; Esc returns to the live view",
|
|
54
59
|
" Ctrl+D leave cruxy",
|
|
55
60
|
];
|
|
56
61
|
/**
|
|
@@ -98,6 +103,43 @@ function panelArg(input, command) {
|
|
|
98
103
|
const match = CLOSABLE_PANELS.find((p) => p === arg);
|
|
99
104
|
return match ? { label: PANEL_LABELS[match], panels: [match] } : null;
|
|
100
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
|
+
}
|
|
101
143
|
/**
|
|
102
144
|
* Read one line from the TUI's input row. Takes raw stdin for the duration and
|
|
103
145
|
* always releases it. Resolves `null` on Ctrl+D / EOF.
|
|
@@ -110,11 +152,27 @@ async function readLine(keys, renderer, hooks) {
|
|
|
110
152
|
try {
|
|
111
153
|
for (;;) {
|
|
112
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
|
+
}
|
|
113
165
|
switch (key.kind) {
|
|
114
166
|
case "enter": {
|
|
115
167
|
const text = editor.text;
|
|
116
168
|
editor.text = "";
|
|
117
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();
|
|
118
176
|
paint();
|
|
119
177
|
return text;
|
|
120
178
|
}
|
|
@@ -205,9 +263,37 @@ async function readLine(keys, renderer, hooks) {
|
|
|
205
263
|
hooks.cycleMode();
|
|
206
264
|
paint();
|
|
207
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;
|
|
208
294
|
default:
|
|
209
|
-
//
|
|
210
|
-
//
|
|
295
|
+
// arrows: no binding yet, deliberately inert rather than leaking a
|
|
296
|
+
// control char into the buffer.
|
|
211
297
|
break;
|
|
212
298
|
}
|
|
213
299
|
}
|
|
@@ -255,6 +341,10 @@ async function dispatch(input, session, renderer, out, slashCommands, checkpoint
|
|
|
255
341
|
handlePanel(trimmed, "/open", true, renderer);
|
|
256
342
|
return null;
|
|
257
343
|
}
|
|
344
|
+
if (trimmed === "/view" || trimmed.startsWith("/view ")) {
|
|
345
|
+
handleView(trimmed, renderer);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
258
348
|
// Everything else is the SHARED implementation (P5 track 5). P1 forked this
|
|
259
349
|
// loop out of `repl.ts` and left every one of these behind — including
|
|
260
350
|
// `/plan`, which was then the only way to reach plan mode at all.
|
|
@@ -281,6 +371,39 @@ async function dispatch(input, session, renderer, out, slashCommands, checkpoint
|
|
|
281
371
|
}
|
|
282
372
|
return null;
|
|
283
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
|
+
}
|
|
284
407
|
/** `/close <panel>` and `/open <panel>` share everything but the target state. */
|
|
285
408
|
function handlePanel(input, command, open, renderer) {
|
|
286
409
|
const t = renderer.theme;
|
package/dist/tui/git-status.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getGitInfoAsync } from "../utils/git.js";
|
|
1
|
+
import { getGitInfoAsync, getGitTreeAsync, } from "../utils/git.js";
|
|
2
2
|
export class GitStatusCache {
|
|
3
3
|
cwd;
|
|
4
4
|
probe;
|
|
@@ -61,3 +61,48 @@ export class GitStatusCache {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The same discipline across every declared root (P7 track 3).
|
|
66
|
+
*
|
|
67
|
+
* The rail names ONE branch because it has one line to do it in; the Overview
|
|
68
|
+
* view reports every root, because writes fan across all of them when
|
|
69
|
+
* checkpoints are on and a primary-only answer would under-report what a turn
|
|
70
|
+
* touched. That is more subprocesses, not a different rule — so this is a fan
|
|
71
|
+
* over {@link GitStatusCache} rather than a second implementation of it, and
|
|
72
|
+
* `current` stays the pure field read the paint path is allowed to call.
|
|
73
|
+
*
|
|
74
|
+
* Probes run CONCURRENTLY. They are independent processes against independent
|
|
75
|
+
* working trees, and serialising them would make a four-root workspace four
|
|
76
|
+
* times slower to settle for no gain.
|
|
77
|
+
*/
|
|
78
|
+
export class WorkspaceGitCache {
|
|
79
|
+
caches = new Map();
|
|
80
|
+
/**
|
|
81
|
+
* Probes with {@link getGitTreeAsync} by default, not the rail's cheaper
|
|
82
|
+
* {@link getGitInfoAsync} (P7 track 4): its consumers are the Overview and
|
|
83
|
+
* Git views, and the Git view needs the per-file list. The rail keeps the two
|
|
84
|
+
* -subprocess probe, because one line of branch-and-count has no use for a
|
|
85
|
+
* third.
|
|
86
|
+
*/
|
|
87
|
+
constructor(paths, probe = getGitTreeAsync) {
|
|
88
|
+
for (const p of paths)
|
|
89
|
+
this.caches.set(p, new GitStatusCache(p, probe));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Last known state for one root — a field read, safe from the paint path.
|
|
93
|
+
* An unknown path reads as unprobed rather than throwing: the workspace can
|
|
94
|
+
* gain a root (`/add-root`) between this cache being built and a paint.
|
|
95
|
+
*/
|
|
96
|
+
current(absPath) {
|
|
97
|
+
return this.caches.get(absPath)?.current();
|
|
98
|
+
}
|
|
99
|
+
/** Mark every root as possibly changed. Cheap and idempotent. */
|
|
100
|
+
invalidate() {
|
|
101
|
+
for (const c of this.caches.values())
|
|
102
|
+
c.invalidate();
|
|
103
|
+
}
|
|
104
|
+
/** Re-probe every stale root, in parallel, and resolve when all have settled. */
|
|
105
|
+
async refresh() {
|
|
106
|
+
await Promise.all([...this.caches.values()].map((c) => c.refresh()));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { formatStat } from "../render/diff.js";
|
|
2
|
+
import { fitMiddle } from "../render/layout.js";
|
|
3
|
+
/** Verb column, fixed width so the paths align into a scannable column. */
|
|
4
|
+
const VERBS = {
|
|
5
|
+
added: "added",
|
|
6
|
+
modified: "modified",
|
|
7
|
+
deleted: "deleted",
|
|
8
|
+
renamed: "renamed",
|
|
9
|
+
untracked: "new",
|
|
10
|
+
conflicted: "conflict",
|
|
11
|
+
};
|
|
12
|
+
const VERB_COLS = 8;
|
|
13
|
+
/**
|
|
14
|
+
* How many files to list per root before summarising the rest.
|
|
15
|
+
*
|
|
16
|
+
* A cap rather than the whole list because a `node_modules` accident produces
|
|
17
|
+
* thousands of untracked paths, and a pane that has to be scrolled past them to
|
|
18
|
+
* reach the next root is worse than one that says how many it held back. The
|
|
19
|
+
* remainder is COUNTED, never silently dropped.
|
|
20
|
+
*/
|
|
21
|
+
export const GIT_VIEW_MAX_FILES = 200;
|
|
22
|
+
/** Colour by what happened, so the shape of a change is legible before the path. */
|
|
23
|
+
function styleVerb(status, theme) {
|
|
24
|
+
const verb = VERBS[status].padEnd(VERB_COLS);
|
|
25
|
+
switch (status) {
|
|
26
|
+
case "deleted":
|
|
27
|
+
return theme.danger(verb);
|
|
28
|
+
case "added":
|
|
29
|
+
case "untracked":
|
|
30
|
+
return theme.success(verb);
|
|
31
|
+
case "conflicted":
|
|
32
|
+
return theme.warning(verb);
|
|
33
|
+
default:
|
|
34
|
+
return theme.muted(verb);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** `modified src/x.ts +12/-3` — verb, path, then the counts when known. */
|
|
38
|
+
function fileRow(file, theme, cols) {
|
|
39
|
+
const stat = formatStat(file, theme);
|
|
40
|
+
// The path is middle-truncated so the basename — the part that identifies the
|
|
41
|
+
// file — survives a narrow pane, the same rule `render/diff.ts` applies.
|
|
42
|
+
const room = Math.max(8, cols - VERB_COLS - 2 - (stat === "" ? 0 : 9));
|
|
43
|
+
const path = fitMiddle(file.path, room, theme.glyph.ellipsis);
|
|
44
|
+
return `${styleVerb(file.status, theme)} ${path}${stat === "" ? "" : ` ${stat}`}`;
|
|
45
|
+
}
|
|
46
|
+
/** One root's block: its heading, then its files or why there are none. */
|
|
47
|
+
function rootLines(root, theme, cols, multiRoot) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
if (multiRoot) {
|
|
50
|
+
const mark = root.primary ? theme.accent(theme.glyph.pointer) : " ";
|
|
51
|
+
lines.push(`${mark} ${theme.strong(root.name)}`);
|
|
52
|
+
}
|
|
53
|
+
// The tri-state, drawn as three different sentences. "Not probed" and "not a
|
|
54
|
+
// repo" are not the same claim, and neither is "clean" — see `status-view.ts`.
|
|
55
|
+
if (root.state === undefined) {
|
|
56
|
+
lines.push(theme.muted(` checking${theme.glyph.ellipsis}`));
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
if (root.state === null) {
|
|
60
|
+
lines.push(theme.muted(" not a git repo"));
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
const { branch, files } = root.state;
|
|
64
|
+
lines.push(` ${theme.strong(branch)}`);
|
|
65
|
+
if (files === undefined) {
|
|
66
|
+
// The cheap probe ran, so dirtiness is known but the list was never asked
|
|
67
|
+
// for. Say that rather than render an empty list as "nothing changed".
|
|
68
|
+
lines.push(theme.muted(root.state.changed > 0
|
|
69
|
+
? ` ${root.state.changed} changed — file list not loaded`
|
|
70
|
+
: " clean"));
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
if (files.length === 0) {
|
|
74
|
+
lines.push(theme.success(" clean"));
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
for (const file of files.slice(0, GIT_VIEW_MAX_FILES)) {
|
|
78
|
+
lines.push(` ${fileRow(file, theme, Math.max(8, cols - 2))}`);
|
|
79
|
+
}
|
|
80
|
+
const hidden = files.length - GIT_VIEW_MAX_FILES;
|
|
81
|
+
if (hidden > 0) {
|
|
82
|
+
lines.push(theme.muted(` ${theme.glyph.ellipsis}${hidden} more`));
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
/** The whole Git view, as lines. Pure: no probe, no disk, no clock. */
|
|
87
|
+
export function gitViewLines(theme, cols, roots) {
|
|
88
|
+
const lines = [theme.heading("git")];
|
|
89
|
+
if (roots.length === 0) {
|
|
90
|
+
lines.push(theme.muted("no declared roots"));
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
const multiRoot = roots.length > 1;
|
|
94
|
+
for (const root of roots) {
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(...rootLines(root, theme, cols, multiRoot));
|
|
97
|
+
}
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The Git view as a registered source.
|
|
102
|
+
*
|
|
103
|
+
* `roots` is a callback rather than an array so `/add-root` mid-session is
|
|
104
|
+
* reflected without re-registering the view — the workspace is the owner of
|
|
105
|
+
* that list, and copying it here would make this the second place it lives.
|
|
106
|
+
*/
|
|
107
|
+
export function createGitView(roots, git) {
|
|
108
|
+
return {
|
|
109
|
+
id: "git",
|
|
110
|
+
label: "git",
|
|
111
|
+
lines: (theme, cols) => gitViewLines(theme, cols, roots().map((r) => ({
|
|
112
|
+
name: r.name,
|
|
113
|
+
primary: r.primary,
|
|
114
|
+
state: git.current(r.absPath),
|
|
115
|
+
}))),
|
|
116
|
+
refresh: async () => {
|
|
117
|
+
git.invalidate();
|
|
118
|
+
await git.refresh();
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/tui/index.js
CHANGED
|
@@ -2,8 +2,13 @@ export { budgetColumns, bodyRows, columnOf, composeScreen, droppedForWidth, fitB
|
|
|
2
2
|
export { COLUMN_LABELS, PANEL_LABELS, contextPanelLines, gitPanelLines, headerModel, modelPanelLines, mainWelcome, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
|
|
3
3
|
export { ContextGauge, readContext, } from "./context-gauge.js";
|
|
4
4
|
export { ToolVersions, parseVersion, } from "./tool-versions.js";
|
|
5
|
-
export { GitStatusCache, } from "./git-status.js";
|
|
6
|
-
export {
|
|
5
|
+
export { GitStatusCache, WorkspaceGitCache, } from "./git-status.js";
|
|
6
|
+
export { createOverviewView } from "./overview.js";
|
|
7
|
+
export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
|
|
8
|
+
export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
|
|
9
|
+
export { createSettingsView, settingsViewLines, formatSettingValue, } from "./settings-view.js";
|
|
10
|
+
export { TuiRenderer, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
|
|
11
|
+
export { CONVERSATION_VIEW, cycleView, navLines, viewLabel, viewOrder, } from "./views.js";
|
|
7
12
|
export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
|
|
8
13
|
export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
|
|
9
14
|
export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
|
package/dist/tui/layout.js
CHANGED
|
@@ -123,6 +123,51 @@ export function fitBlock(lines, rows, width) {
|
|
|
123
123
|
out.push(" ".repeat(Math.max(0, width)));
|
|
124
124
|
return out;
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Window `rows` lines out of a block, `offset` display lines up from the end
|
|
128
|
+
* (P7 track 1). `offset === 0` is the tail view {@link fitBlock} gives, and the
|
|
129
|
+
* two agree line-for-line there.
|
|
130
|
+
*
|
|
131
|
+
* The offset is measured from the BOTTOM because that is the edge the user is
|
|
132
|
+
* anchored to: the live tail is the home position, and "how far back am I" is
|
|
133
|
+
* the question both the clamp and the notice need answered. Measuring from the
|
|
134
|
+
* top would make every append renumber the position.
|
|
135
|
+
*
|
|
136
|
+
* Clamping belongs here rather than at the key handler because only this
|
|
137
|
+
* function knows the content: the handler would have to re-derive the wrapped
|
|
138
|
+
* line count to bound a keypress, which is the calculation this already did.
|
|
139
|
+
*/
|
|
140
|
+
export function scrollWindow(lines, rows, offset) {
|
|
141
|
+
const visible = Math.max(0, rows);
|
|
142
|
+
// Everything above the last `visible` lines is reachable, and no further: the
|
|
143
|
+
// top of the document is the top of the window, never a screen of blank rows
|
|
144
|
+
// above it.
|
|
145
|
+
const maxOffset = Math.max(0, lines.length - visible);
|
|
146
|
+
const clamped = Math.min(Math.max(0, Math.floor(offset)), maxOffset);
|
|
147
|
+
const end = lines.length - clamped;
|
|
148
|
+
const start = Math.max(0, end - visible);
|
|
149
|
+
return {
|
|
150
|
+
lines: lines.slice(start, end),
|
|
151
|
+
offset: clamped,
|
|
152
|
+
hiddenBelow: clamped,
|
|
153
|
+
hiddenAbove: start,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The scrollback notice: how far back the view is, and how to get out of it.
|
|
158
|
+
*
|
|
159
|
+
* Shown only while scrolled, and it costs a row from the window rather than
|
|
160
|
+
* overlaying one — the same trade {@link stackPanels} makes for its overflow
|
|
161
|
+
* notice, for the same reason: silently hiding a line to report hidden lines
|
|
162
|
+
* would be the one dishonest way to draw this.
|
|
163
|
+
*
|
|
164
|
+
* It names the key because a scrolled view is a mode, and a mode the user
|
|
165
|
+
* cannot see the exit from is a trap — there is no scrollbar here to drag.
|
|
166
|
+
*/
|
|
167
|
+
export function scrollNotice(hiddenBelow, theme) {
|
|
168
|
+
const glyph = theme.unicode ? "↓" : "v";
|
|
169
|
+
return theme.warning(`${glyph} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below · PgDn / Esc to return`);
|
|
170
|
+
}
|
|
126
171
|
/** Rows the overflow notice costs when at least one panel is dropped. */
|
|
127
172
|
const OVERFLOW_ROWS = 1;
|
|
128
173
|
/**
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { buildSessionStatus } from "../agent/status.js";
|
|
2
|
+
import { sessionStatusLines } from "../render/status-view.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Overview view (P7 track 3) — the first concrete {@link ViewSource}.
|
|
5
|
+
*
|
|
6
|
+
* There is almost nothing here, and that is the result rather than an accident.
|
|
7
|
+
* `sessionStatusLines` already composed this screen for `/status`, and
|
|
8
|
+
* `buildSessionStatus` already assembles what it renders; the only thing that
|
|
9
|
+
* was missing was somewhere for it to LIVE. As a command it printed once into
|
|
10
|
+
* the scrollback and was gone by the next turn — a standing description of the
|
|
11
|
+
* session that could not stand.
|
|
12
|
+
*
|
|
13
|
+
* LIVE VS PER-PAINT, decided per fact rather than per view:
|
|
14
|
+
*
|
|
15
|
+
* - **Per-paint** — turns, mode, model, served tier, context occupancy,
|
|
16
|
+
* sandbox, checkpoints, job counts, tool count. Every one is a field read or
|
|
17
|
+
* a filter over an in-memory array, so recomputing them at 30fps costs
|
|
18
|
+
* nothing measurable and they are never a frame stale.
|
|
19
|
+
* - **Cached, off the paint path** — branch and change count, per root. These
|
|
20
|
+
* cost two subprocesses each, ~45ms warm and worse on Windows, and the pane
|
|
21
|
+
* repaints on every frame and every resize. {@link WorkspaceGitCache} does
|
|
22
|
+
* the probing after a turn; {@link lines} only ever reads the last settled
|
|
23
|
+
* value.
|
|
24
|
+
*
|
|
25
|
+
* The rule the split enforces: nothing in `lines` may spawn, block, or touch
|
|
26
|
+
* the disk. Anything that needs to is a cache read here and a `refresh` there.
|
|
27
|
+
*/
|
|
28
|
+
export function createOverviewView(session, git,
|
|
29
|
+
/**
|
|
30
|
+
* The tier the gateway last said served a request — a getter, not a value,
|
|
31
|
+
* because it changes per turn and the view is registered once. Supplied by
|
|
32
|
+
* the renderer, the only object that sees the stream's routing frame.
|
|
33
|
+
*/
|
|
34
|
+
servedTier = () => undefined) {
|
|
35
|
+
return {
|
|
36
|
+
id: "overview",
|
|
37
|
+
label: "overview",
|
|
38
|
+
lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier()), theme, cols),
|
|
39
|
+
/**
|
|
40
|
+
* Re-probe every root after a turn. Invalidate-then-refresh because a turn
|
|
41
|
+
* is exactly when the tree may have moved; the cache coalesces, so the ten
|
|
42
|
+
* writes a turn makes still cost one probe per root.
|
|
43
|
+
*/
|
|
44
|
+
refresh: async () => {
|
|
45
|
+
git.invalidate();
|
|
46
|
+
await git.refresh();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|