@cruxy/cli 1.3.0 → 1.5.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 +96 -0
- package/dist/cli/commands/run.js +54 -3
- package/dist/cli/session-commands.js +16 -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/errors/constructors.js +90 -10
- package/dist/limits/cache.js +100 -0
- package/dist/limits/index.js +11 -0
- package/dist/limits/reduce.js +172 -0
- package/dist/limits/types.js +25 -0
- package/dist/onboarding/detect.js +95 -9
- package/dist/onboarding/types.js +29 -1
- package/dist/render/diff.js +7 -1
- package/dist/render/status-view.js +58 -6
- package/dist/theme/tokens.js +7 -0
- package/dist/tui/app.js +125 -2
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/git-status.js +46 -1
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +8 -2
- package/dist/tui/layout.js +46 -0
- package/dist/tui/limits-panel.js +255 -0
- package/dist/tui/overview.js +61 -0
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +431 -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/disk.js +95 -0
- package/dist/utils/git.js +113 -0
- package/package.json +2 -2
|
@@ -1,36 +1,122 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { ONBOARDING_FILE_NAME } from "../constants.js";
|
|
4
4
|
import { globalDir, resolveApiKey } from "../config/index.js";
|
|
5
|
+
import { ONBOARDING_VERSION, OnboardingStateSchema, } from "./types.js";
|
|
5
6
|
/**
|
|
6
7
|
* First-run detection + onboarding-state persistence (U.6). "First run" is
|
|
7
8
|
* deliberately derived from observable facts (no key + no completion marker)
|
|
8
9
|
* rather than a flag, and is **TTY-gated** so a non-interactive run never
|
|
9
10
|
* branches into an interactive flow.
|
|
10
11
|
*/
|
|
11
|
-
const ONBOARDING_VERSION = 1;
|
|
12
12
|
/** `~/.cruxy/onboarding.json` */
|
|
13
13
|
export function onboardingStatePath() {
|
|
14
14
|
return join(globalDir(), ONBOARDING_FILE_NAME);
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Read persisted onboarding state, or `null` if absent, unreadable, or not the
|
|
18
|
+
* shape this file is supposed to hold.
|
|
19
|
+
*
|
|
20
|
+
* VALIDATED, not cast. `as OnboardingState` accepted any object at all, so a
|
|
21
|
+
* truncated write or a hand-edit could hand the flow a `completedAt` that was a
|
|
22
|
+
* number — and the completion marker is the one fact that decides whether a
|
|
23
|
+
* user is walked through setup again or silently isn't.
|
|
24
|
+
*
|
|
25
|
+
* An invalid file reads as ABSENT rather than throwing, which is the same
|
|
26
|
+
* treatment corrupt JSON already got: the worst outcome of re-onboarding is one
|
|
27
|
+
* extra prompt, and the worst outcome of a hard error is a CLI that won't start
|
|
28
|
+
* because of a file that only exists to say "you've done this before".
|
|
29
|
+
*/
|
|
17
30
|
export function readOnboardingState(file = onboardingStatePath()) {
|
|
18
31
|
if (!existsSync(file))
|
|
19
32
|
return null;
|
|
33
|
+
let parsed;
|
|
20
34
|
try {
|
|
21
|
-
|
|
22
|
-
if (parsed && typeof parsed === "object")
|
|
23
|
-
return parsed;
|
|
35
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
24
36
|
}
|
|
25
37
|
catch {
|
|
26
38
|
// A corrupt marker is treated as absent — onboarding can rewrite it.
|
|
39
|
+
return null;
|
|
27
40
|
}
|
|
28
|
-
|
|
41
|
+
const result = OnboardingStateSchema.safeParse(parsed);
|
|
42
|
+
return result.success ? migrateState(result.data) : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The version branch the file has been writing a number for since U.6 and
|
|
46
|
+
* nothing has ever read.
|
|
47
|
+
*
|
|
48
|
+
* OLDER than this build: bring it forward. There is exactly one version today,
|
|
49
|
+
* so this is a re-stamp and nothing else — the point is that the next bump has
|
|
50
|
+
* one obvious place to add its step, instead of discovering at that moment that
|
|
51
|
+
* every reader assumed v1.
|
|
52
|
+
*
|
|
53
|
+
* NEWER than this build: return it untouched and keep trusting `completedAt`.
|
|
54
|
+
* Both alternatives are worse. Discarding it re-runs onboarding for someone who
|
|
55
|
+
* has plainly finished it, every time they use the older binary — running two
|
|
56
|
+
* cruxy versions against one home directory is ordinary, and being re-prompted
|
|
57
|
+
* for a key you already saved is not a reasonable punishment for it. Rewriting
|
|
58
|
+
* it down to our shape would destroy state we don't understand. `completedAt`
|
|
59
|
+
* is the marker in every version that has one; reading it across a version we
|
|
60
|
+
* don't know is a smaller assumption than either.
|
|
61
|
+
*/
|
|
62
|
+
function migrateState(state) {
|
|
63
|
+
if (state.version >= ONBOARDING_VERSION)
|
|
64
|
+
return state;
|
|
65
|
+
return { ...state, version: ONBOARDING_VERSION };
|
|
29
66
|
}
|
|
30
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Persist onboarding state (creates `~/.cruxy` if needed), owner-only.
|
|
69
|
+
*
|
|
70
|
+
* MERGED over what is on disk, not written over it. The schema passes unknown
|
|
71
|
+
* keys through, so a field a newer cruxy wrote survives a run of an older one
|
|
72
|
+
* — tolerating it on read and then flattening it on the next write would lose
|
|
73
|
+
* the data anyway, just one write later.
|
|
74
|
+
*
|
|
75
|
+
* The version is the MAX of the two for the same reason `migrateState` doesn't
|
|
76
|
+
* discard a future file: this build preserved the newer keys, so stamping our
|
|
77
|
+
* own lower number over them would tell the newer cruxy that its migration
|
|
78
|
+
* hasn't run when its data is in fact all still there.
|
|
79
|
+
*/
|
|
31
80
|
export function writeOnboardingState(state, file = onboardingStatePath()) {
|
|
81
|
+
const existing = readOnboardingState(file);
|
|
82
|
+
const merged = {
|
|
83
|
+
...existing,
|
|
84
|
+
...state,
|
|
85
|
+
version: Math.max(existing?.version ?? 0, state.version),
|
|
86
|
+
};
|
|
32
87
|
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
-
writeFileSync(file, JSON.stringify(
|
|
88
|
+
writeFileSync(file, JSON.stringify(merged, null, 2) + "\n", {
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
mode: 0o600,
|
|
91
|
+
});
|
|
92
|
+
restrictToOwner(file);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Make the marker owner-only. It was the one store under `~/.cruxy` still at
|
|
96
|
+
* `0644` — usage, memory trust, MCP trust and hook trust are all `0600` — and
|
|
97
|
+
* while it holds no secret, it does record that this machine's user has a cruxy
|
|
98
|
+
* key configured and when they set it up. That is nobody else's business on a
|
|
99
|
+
* shared box, and "every file cruxy writes to your home directory is yours
|
|
100
|
+
* alone" is a rule worth being able to state without an exception.
|
|
101
|
+
*
|
|
102
|
+
* The `mode` on the write above only applies when the file is CREATED, so it
|
|
103
|
+
* does nothing for the `0644` files already out there; this fixes those on the
|
|
104
|
+
* next write. Best-effort and non-fatal, deliberately: unlike the credential
|
|
105
|
+
* store — which refuses to persist a secret it cannot secure — refusing to
|
|
106
|
+
* write this one would mean re-running onboarding forever on a filesystem
|
|
107
|
+
* without modes. Windows is skipped because POSIX modes are meaningless there
|
|
108
|
+
* (see `config/owner-only.ts`), and an `icacls` edit is more than a marker
|
|
109
|
+
* warrants.
|
|
110
|
+
*/
|
|
111
|
+
function restrictToOwner(file) {
|
|
112
|
+
if (process.platform === "win32")
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
chmodSync(file, 0o600);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A filesystem that can't do modes still gets a working marker.
|
|
119
|
+
}
|
|
34
120
|
}
|
|
35
121
|
/** A fresh state object. */
|
|
36
122
|
export function newOnboardingState() {
|
package/dist/onboarding/types.js
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
/**
|
|
2
3
|
* Types for the first-run onboarding flow (U.6). The flow is built from injected
|
|
3
4
|
* dependencies (validation, persistence, the first-win runner) so every step is
|
|
4
5
|
* unit-testable with scripted IO and no real network or filesystem.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Bump when the on-disk onboarding state shape changes. The number is only
|
|
9
|
+
* worth writing if something READS it — see `migrateState` in `detect.ts`,
|
|
10
|
+
* which is where the branch it enables lives.
|
|
11
|
+
*/
|
|
12
|
+
export const ONBOARDING_VERSION = 1;
|
|
13
|
+
/**
|
|
14
|
+
* The persisted state (`~/.cruxy/onboarding.json`), as a schema rather than a
|
|
15
|
+
* cast. It was read with `as OnboardingState` — a cast validates nothing, so
|
|
16
|
+
* `{"completedAt": 42}` or a JSON array parsed clean and the completion marker
|
|
17
|
+
* became whatever the file happened to contain.
|
|
18
|
+
*
|
|
19
|
+
* `.passthrough()`, for the reason `usage/types.ts` sets out at length: a read
|
|
20
|
+
* that can't validate is a read that discards, and a store hostile to its own
|
|
21
|
+
* future silently drops what a newer cruxy wrote the moment an older one runs
|
|
22
|
+
* against the same home directory. Passthrough both TOLERATES an unknown key
|
|
23
|
+
* and PRESERVES it, so `writeOnboardingState`'s merge hands it back intact.
|
|
24
|
+
*/
|
|
25
|
+
export const OnboardingStateSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
/** Shape version — see {@link ONBOARDING_VERSION}. */
|
|
28
|
+
version: z.number().int().nonnegative(),
|
|
29
|
+
/** ISO timestamp; **its presence is the completion marker**. */
|
|
30
|
+
completedAt: z.string().min(1).optional(),
|
|
31
|
+
/** Whether a key has been successfully configured at least once. */
|
|
32
|
+
keyConfigured: z.boolean().optional(),
|
|
33
|
+
})
|
|
34
|
+
.passthrough();
|
package/dist/render/diff.js
CHANGED
|
@@ -99,7 +99,13 @@ export function previewStats(preview) {
|
|
|
99
99
|
}
|
|
100
100
|
return [];
|
|
101
101
|
}
|
|
102
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count.
|
|
104
|
+
*
|
|
105
|
+
* Takes only the three fields it reads rather than a whole {@link DiffFileStat},
|
|
106
|
+
* so P7's git-status changes render through this same formatter instead of a
|
|
107
|
+
* second one that would eventually disagree with it about what `null` means.
|
|
108
|
+
*/
|
|
103
109
|
export function formatStat(stat, c) {
|
|
104
110
|
const parts = [];
|
|
105
111
|
if (stat.added !== null && stat.added > 0)
|
|
@@ -1,9 +1,55 @@
|
|
|
1
|
+
import { capacityLevel, formatCapacity, } from "../utils/disk.js";
|
|
1
2
|
import { fit } from "./layout.js";
|
|
2
3
|
import { formatTokens } from "./state.js";
|
|
3
4
|
/** `key value`, aligned on a fixed gutter so the column is scannable. */
|
|
4
5
|
function row(key, value, t) {
|
|
5
6
|
return ` ${t.muted(key.padEnd(11))} ${value}`;
|
|
6
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Collapse locations that report identical figures into one row, naming all of
|
|
10
|
+
* them.
|
|
11
|
+
*
|
|
12
|
+
* A project under `~` and `~/.cruxy` are usually the same filesystem, and two
|
|
13
|
+
* rows saying `54% free · 251 GiB of 465 GiB` twice is noise dressed as detail.
|
|
14
|
+
* Grouping is on the RENDERED numbers rather than on mount identity because
|
|
15
|
+
* `statfs` gives no filesystem id to compare — and grouping on the numbers is
|
|
16
|
+
* the honest test anyway: if two locations report the same total and the same
|
|
17
|
+
* free bytes, either they are one filesystem or they are two the reader has no
|
|
18
|
+
* way to tell apart, and in both cases one row is the whole of what's known.
|
|
19
|
+
*/
|
|
20
|
+
function groupLocations(locations) {
|
|
21
|
+
const groups = new Map();
|
|
22
|
+
for (const loc of locations) {
|
|
23
|
+
const key = `${loc.capacity.totalBytes}:${loc.capacity.freeBytes}`;
|
|
24
|
+
const existing = groups.get(key);
|
|
25
|
+
if (existing)
|
|
26
|
+
existing.labels.push(loc.label);
|
|
27
|
+
else
|
|
28
|
+
groups.set(key, { labels: [loc.label], capacity: loc.capacity });
|
|
29
|
+
}
|
|
30
|
+
return [...groups.values()];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The disk rows: `disk cli, ~/.cruxy 54% free · 251 GiB of 465 GiB`.
|
|
34
|
+
*
|
|
35
|
+
* Coloured by {@link capacityLevel}, which judges absolute bytes — so a nearly
|
|
36
|
+
* full 4 TB disk with plenty left in it is not dressed up as an emergency, and
|
|
37
|
+
* a small VM disk with 800 MB left is, whatever percentage that happens to be.
|
|
38
|
+
* Only the first row carries the key; the rest align under it, because a `disk`
|
|
39
|
+
* label repeated down the column reads as several different facts.
|
|
40
|
+
*/
|
|
41
|
+
function diskLines(locations, t) {
|
|
42
|
+
return groupLocations(locations).map((group, i) => {
|
|
43
|
+
const text = formatCapacity(group.capacity);
|
|
44
|
+
const level = capacityLevel(group.capacity);
|
|
45
|
+
const value = level === "critical"
|
|
46
|
+
? t.danger(text)
|
|
47
|
+
: level === "low"
|
|
48
|
+
? t.warning(text)
|
|
49
|
+
: t.muted(text);
|
|
50
|
+
return row(i === 0 ? "disk" : "", `${group.labels.join(", ")} ${value}`, t);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
7
53
|
/** The full `/status` block as lines to print. */
|
|
8
54
|
export function sessionStatusLines(status, t, width = Infinity) {
|
|
9
55
|
const lines = [t.heading("status")];
|
|
@@ -33,6 +79,12 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
33
79
|
lines.push(row("checkpoints", status.checkpoints
|
|
34
80
|
? t.muted("on — `cruxy rollback` can undo a run's file changes")
|
|
35
81
|
: t.warning("off — file changes are not restorable"), t));
|
|
82
|
+
// Next to checkpoints, the feature that turns a turn into a shadow copy of
|
|
83
|
+
// the tree: the two rows answer one question between them — will this
|
|
84
|
+
// session's writes land.
|
|
85
|
+
if (status.disk && status.disk.length > 0) {
|
|
86
|
+
lines.push(...diskLines(status.disk, t));
|
|
87
|
+
}
|
|
36
88
|
if (status.jobs) {
|
|
37
89
|
const { total, running, needingApproval } = status.jobs;
|
|
38
90
|
const detail = total === 0
|
|
@@ -50,12 +102,12 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
50
102
|
lines.push(t.strong(status.roots.length === 1 ? "root" : "roots"));
|
|
51
103
|
for (const r of status.roots) {
|
|
52
104
|
const mark = r.primary ? t.accent(t.glyph.pointer) : " ";
|
|
53
|
-
const git = r.
|
|
54
|
-
? t.muted(
|
|
55
|
-
:
|
|
56
|
-
? ""
|
|
57
|
-
: r.changed > 0
|
|
58
|
-
? t.warning(`${r.changed} changed`)
|
|
105
|
+
const git = r.git === undefined
|
|
106
|
+
? t.muted(`checking${t.glyph.ellipsis}`)
|
|
107
|
+
: r.git === null
|
|
108
|
+
? t.muted("not a git repo")
|
|
109
|
+
: `${t.strong(r.git.branch)} ${r.git.changed > 0
|
|
110
|
+
? t.warning(`${r.git.changed} changed`)
|
|
59
111
|
: t.success("clean")}`;
|
|
60
112
|
lines.push(`${mark} ${r.name.padEnd(12)} ${git}`);
|
|
61
113
|
lines.push(` ${t.muted(r.path)}`);
|
package/dist/theme/tokens.js
CHANGED
|
@@ -22,6 +22,8 @@ export const UNICODE_GLYPHS = {
|
|
|
22
22
|
caretDown: "↓",
|
|
23
23
|
cached: "↻",
|
|
24
24
|
cursorBar: "▏",
|
|
25
|
+
barFilled: "█",
|
|
26
|
+
barEmpty: "░",
|
|
25
27
|
bullet: "•",
|
|
26
28
|
sep: "·",
|
|
27
29
|
ellipsis: "…",
|
|
@@ -45,6 +47,8 @@ export const ASCII_GLYPHS = {
|
|
|
45
47
|
caretDown: "v",
|
|
46
48
|
cached: "",
|
|
47
49
|
cursorBar: "|",
|
|
50
|
+
barFilled: "#",
|
|
51
|
+
barEmpty: ".",
|
|
48
52
|
bullet: "*",
|
|
49
53
|
sep: "-",
|
|
50
54
|
ellipsis: "...",
|
|
@@ -72,6 +76,9 @@ export const SCREEN_READER_GLYPHS = {
|
|
|
72
76
|
caretDown: "down",
|
|
73
77
|
cached: "",
|
|
74
78
|
cursorBar: "",
|
|
79
|
+
// A bar announces as nothing; the percentage beside it says everything.
|
|
80
|
+
barFilled: "",
|
|
81
|
+
barEmpty: "",
|
|
75
82
|
bullet: "-",
|
|
76
83
|
sep: "-",
|
|
77
84
|
ellipsis: "...",
|
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;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readDiskCapacity } from "../utils/disk.js";
|
|
2
|
+
export class WorkspaceDiskCache {
|
|
3
|
+
paths;
|
|
4
|
+
probe;
|
|
5
|
+
values = new Map();
|
|
6
|
+
/** The refresh in flight, so concurrent triggers share one pass. */
|
|
7
|
+
inFlight = null;
|
|
8
|
+
constructor(paths, probe = readDiskCapacity) {
|
|
9
|
+
// Deduplicated: the same path twice would be the same syscall twice for the
|
|
10
|
+
// same answer. Two DIFFERENT paths on one filesystem are NOT collapsed here
|
|
11
|
+
// — that is a question about the numbers, and it is answered where they are
|
|
12
|
+
// rendered (see `status-view.ts`), not by guessing at mount identity.
|
|
13
|
+
this.paths = [...new Set(paths)];
|
|
14
|
+
this.probe = probe;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The last known capacity for one path — a map read, safe from the paint
|
|
18
|
+
* path. `undefined` means not probed yet, probed and unreadable, or a path
|
|
19
|
+
* this cache was never given; every one of those renders as nothing, so they
|
|
20
|
+
* do not need telling apart the way the git tri-state does.
|
|
21
|
+
*/
|
|
22
|
+
current(path) {
|
|
23
|
+
return this.values.get(path);
|
|
24
|
+
}
|
|
25
|
+
/** Re-probe every path, concurrently, and resolve when all have settled. */
|
|
26
|
+
async refresh() {
|
|
27
|
+
if (this.inFlight)
|
|
28
|
+
return this.inFlight;
|
|
29
|
+
this.inFlight = this.run();
|
|
30
|
+
try {
|
|
31
|
+
await this.inFlight;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
this.inFlight = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async run() {
|
|
38
|
+
await Promise.all(this.paths.map(async (path) => {
|
|
39
|
+
const value = await this.probe(path);
|
|
40
|
+
// A failed probe keeps the last good reading rather than blanking the
|
|
41
|
+
// row. A transient failure is not evidence that the disk changed, and a
|
|
42
|
+
// figure that flickers in and out is read as a bug in the tool.
|
|
43
|
+
if (value)
|
|
44
|
+
this.values.set(path, value);
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
}
|
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
|
+
}
|