@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/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
- // escape / arrows up-down / ctrl-k: no binding yet, deliberately
210
- // inert rather than leaking a control char into the buffer.
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;
@@ -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 { TuiRenderer, PAINT_INTERVAL_MS, SCROLLBACK_LINES, } from "./renderer.js";
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";
@@ -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
+ }