@cruxy/cli 1.2.1 → 1.3.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/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 +374 -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 +755 -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 +27 -3
- package/dist/components/select.js +3 -3
- 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 +198 -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 +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -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 +50 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readContext } from "../agent/context.js";
|
|
2
|
+
/**
|
|
3
|
+
* The rail's context-window reading (P4 track 3) — the memoized, TUI-side half.
|
|
4
|
+
*
|
|
5
|
+
* The measurement itself moved to `agent/context.ts` in P6 track 3, so the
|
|
6
|
+
* panel, `/context` and the compaction seam all read one implementation. What
|
|
7
|
+
* stays here is the only thing that was ever TUI-specific: the memo.
|
|
8
|
+
*
|
|
9
|
+
* `estimateTokens` walks every block of every message, so it is O(history) — a
|
|
10
|
+
* full-window conversation is ~400k characters to scan. The rail repaints at
|
|
11
|
+
* ~30fps, so sampling on the paint path would scan that history thirty times a
|
|
12
|
+
* second to produce a number that only changes when a message is added.
|
|
13
|
+
*
|
|
14
|
+
* So the read and the walk are separated, the same shape as `GitStatusCache`:
|
|
15
|
+
* {@link current} is a field read the paint path may call freely, and
|
|
16
|
+
* {@link sample} does the walk at the seams where the history can actually have
|
|
17
|
+
* changed.
|
|
18
|
+
*
|
|
19
|
+
* WHERE THE NUMBER COMES FROM, and where it deliberately does NOT: see
|
|
20
|
+
* `agent/context.ts`. The short version is that `Session.usage` is the wrong
|
|
21
|
+
* source — it sums input+output across every `send` and never decreases, not
|
|
22
|
+
* even when compaction frees the window it is supposed to describe.
|
|
23
|
+
*/
|
|
24
|
+
export { readContext, } from "../agent/context.js";
|
|
25
|
+
/** A memoized context reading. */
|
|
26
|
+
export class ContextGauge {
|
|
27
|
+
source;
|
|
28
|
+
budget;
|
|
29
|
+
value;
|
|
30
|
+
constructor(source, budget) {
|
|
31
|
+
this.source = source;
|
|
32
|
+
this.budget = budget;
|
|
33
|
+
}
|
|
34
|
+
/** The last sampled reading; `undefined` before the first sample. */
|
|
35
|
+
current() {
|
|
36
|
+
return this.value;
|
|
37
|
+
}
|
|
38
|
+
/** Re-measure the history. Cheap enough at a turn or tool boundary, not per frame. */
|
|
39
|
+
sample() {
|
|
40
|
+
try {
|
|
41
|
+
this.value = readContext(this.source(), this.budget);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// A source that throws (a session torn down mid-turn) keeps the previous
|
|
45
|
+
// reading rather than blanking a panel that was correct a moment ago.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getGitInfoAsync } from "../utils/git.js";
|
|
2
|
+
export class GitStatusCache {
|
|
3
|
+
cwd;
|
|
4
|
+
probe;
|
|
5
|
+
value = undefined;
|
|
6
|
+
/** Set by {@link invalidate}; cleared once a refresh has actually settled. */
|
|
7
|
+
stale = true;
|
|
8
|
+
/** The refresh in flight, so concurrent triggers share one probe. */
|
|
9
|
+
inFlight = null;
|
|
10
|
+
constructor(cwd, probe = getGitInfoAsync) {
|
|
11
|
+
this.cwd = cwd;
|
|
12
|
+
this.probe = probe;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The last known state — a plain field read, safe from the paint path. Never
|
|
16
|
+
* probes, never blocks, and never lies about freshness: a stale value is the
|
|
17
|
+
* previous truth, which is a better answer than a stalled frame.
|
|
18
|
+
*/
|
|
19
|
+
current() {
|
|
20
|
+
return this.value;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Mark the working tree as possibly changed. Cheap and idempotent — call it
|
|
24
|
+
* from every seam that could have written a file; the coalescing in
|
|
25
|
+
* {@link refresh} makes over-calling free.
|
|
26
|
+
*/
|
|
27
|
+
invalidate() {
|
|
28
|
+
this.stale = true;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Re-probe if stale, and resolve once the value has settled.
|
|
32
|
+
*
|
|
33
|
+
* Concurrent callers share the in-flight probe rather than starting their own:
|
|
34
|
+
* a turn that writes ten files fires ten invalidations, and those must not
|
|
35
|
+
* become ten `git status` runs.
|
|
36
|
+
*/
|
|
37
|
+
async refresh() {
|
|
38
|
+
if (!this.stale)
|
|
39
|
+
return;
|
|
40
|
+
if (this.inFlight)
|
|
41
|
+
return this.inFlight;
|
|
42
|
+
// Cleared BEFORE awaiting, so a write landing mid-probe re-marks it and the
|
|
43
|
+
// next refresh runs again. Clearing after would swallow that invalidation
|
|
44
|
+
// and leave the panel showing a tree that has already moved on.
|
|
45
|
+
this.stale = false;
|
|
46
|
+
this.inFlight = this.run();
|
|
47
|
+
try {
|
|
48
|
+
await this.inFlight;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
this.inFlight = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async run() {
|
|
55
|
+
try {
|
|
56
|
+
this.value = await this.probe(this.cwd);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A probe that throws is unknown, not "not a repo" — keep the last known
|
|
60
|
+
// value rather than downgrading the panel on a transient failure.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { budgetColumns, bodyRows, columnOf, composeScreen, droppedForWidth, fitBlock, padTo, railOpen, stackPanels, CHROME_ROWS, CLOSABLE_PANELS, GUTTER, MIN_MAIN_COLS, RAIL_COLS, RAIL_PANELS, SIDEBAR_COLS, } from "./layout.js";
|
|
2
|
+
export { COLUMN_LABELS, PANEL_LABELS, contextPanelLines, gitPanelLines, headerModel, modelPanelLines, mainWelcome, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
|
|
3
|
+
export { ContextGauge, readContext, } from "./context-gauge.js";
|
|
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";
|
|
7
|
+
export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
|
|
8
|
+
export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
|
|
9
|
+
export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
|
|
10
|
+
export { supportsTui } from "./supports.js";
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { fit, visibleWidth } from "../render/layout.js";
|
|
2
|
+
export const RAIL_PANELS = [
|
|
3
|
+
"context",
|
|
4
|
+
"model",
|
|
5
|
+
"git",
|
|
6
|
+
"tools",
|
|
7
|
+
];
|
|
8
|
+
export const CLOSABLE_PANELS = ["sidebar", ...RAIL_PANELS];
|
|
9
|
+
/** Which column a panel is drawn in — the bridge between the two identities. */
|
|
10
|
+
export function columnOf(panel) {
|
|
11
|
+
return panel === "sidebar" ? "sidebar" : "rail";
|
|
12
|
+
}
|
|
13
|
+
/** True when any rail panel is open, i.e. the rail column should be budgeted. */
|
|
14
|
+
export function railOpen(open) {
|
|
15
|
+
return RAIL_PANELS.some((p) => open.has(p));
|
|
16
|
+
}
|
|
17
|
+
/** Fixed column widths; `main` takes whatever is left. */
|
|
18
|
+
export const SIDEBAR_COLS = 18;
|
|
19
|
+
/**
|
|
20
|
+
* 24, not the 20 P1 shipped.
|
|
21
|
+
*
|
|
22
|
+
* The rail's widest real content is a git branch name, and 20 could not hold
|
|
23
|
+
* one: `feat/p3-test-results` is exactly 20 columns before the marker glyph and
|
|
24
|
+
* change count the git panel has to put beside it. Every line here is hard
|
|
25
|
+
* truncated by {@link padTo}, so the branch — the part that identifies WHERE the
|
|
26
|
+
* work is happening — was the first thing to be cut.
|
|
27
|
+
*
|
|
28
|
+
* The cost is 4 columns off `main`, and it is affordable at the size that
|
|
29
|
+
* matters: at an 80-column terminal `main` goes 40 → 36, still comfortably over
|
|
30
|
+
* {@link MIN_MAIN_COLS}. Below that the rail was already being dropped whole.
|
|
31
|
+
*/
|
|
32
|
+
export const RAIL_COLS = 24;
|
|
33
|
+
/** One blank column between adjacent panels. */
|
|
34
|
+
export const GUTTER = 1;
|
|
35
|
+
/** Below this, `main` is too cramped to read and a side column is dropped. */
|
|
36
|
+
export const MIN_MAIN_COLS = 24;
|
|
37
|
+
/** Rows the chrome costs: header + rule + (body) + rule + status + input. */
|
|
38
|
+
export const CHROME_ROWS = 5;
|
|
39
|
+
/** Below this height there is no room for a body; the shell collapses. */
|
|
40
|
+
export const MIN_BODY_ROWS = 1;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve column widths for an inner width, honoring what the user has open.
|
|
43
|
+
* Degrades the same way the status line does (U.12) — shed context, keep the
|
|
44
|
+
* thing that carries meaning:
|
|
45
|
+
*
|
|
46
|
+
* - wide: sidebar + main + rail
|
|
47
|
+
* - medium: drop the rail, keep sidebar + main
|
|
48
|
+
* - narrow: main only
|
|
49
|
+
*
|
|
50
|
+
* A dropped column returns 0 — the caller distinguishes "closed by the user"
|
|
51
|
+
* from "dropped for width" via {@link droppedForWidth}, so the TUI can say so
|
|
52
|
+
* rather than silently losing a panel the user opened.
|
|
53
|
+
*/
|
|
54
|
+
export function budgetColumns(width, open) {
|
|
55
|
+
const wantSidebar = open.has("sidebar");
|
|
56
|
+
const wantRail = railOpen(open);
|
|
57
|
+
const gutters = (n) => Math.max(0, n - 1) * GUTTER;
|
|
58
|
+
if (wantSidebar && wantRail) {
|
|
59
|
+
const fixed = SIDEBAR_COLS + RAIL_COLS + gutters(3);
|
|
60
|
+
if (width - fixed >= MIN_MAIN_COLS) {
|
|
61
|
+
return { sidebar: SIDEBAR_COLS, main: width - fixed, rail: RAIL_COLS };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const single = wantSidebar ? SIDEBAR_COLS : wantRail ? RAIL_COLS : 0;
|
|
65
|
+
if (single > 0) {
|
|
66
|
+
const fixed = single + gutters(2);
|
|
67
|
+
if (width - fixed >= MIN_MAIN_COLS) {
|
|
68
|
+
return {
|
|
69
|
+
sidebar: wantSidebar ? SIDEBAR_COLS : 0,
|
|
70
|
+
main: width - fixed,
|
|
71
|
+
rail: wantSidebar ? 0 : RAIL_COLS,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { sidebar: 0, main: Math.max(1, width), rail: 0 };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* COLUMNS the user has open but the current width cannot fit — the honest
|
|
79
|
+
* complement to {@link budgetColumns}. The TUI surfaces these rather than
|
|
80
|
+
* letting a column vanish with no explanation.
|
|
81
|
+
*
|
|
82
|
+
* Reported per column, not per panel: width is a column-level budget, so four
|
|
83
|
+
* open rail panels losing their column is one fact, not four. Callers map a
|
|
84
|
+
* panel to its column with {@link columnOf}. The vertical mirror of this is
|
|
85
|
+
* {@link stackPanels}, which reports what a column's HEIGHT could not fit.
|
|
86
|
+
*/
|
|
87
|
+
export function droppedForWidth(budget, open) {
|
|
88
|
+
const dropped = [];
|
|
89
|
+
if (open.has("sidebar") && budget.sidebar === 0)
|
|
90
|
+
dropped.push("sidebar");
|
|
91
|
+
if (railOpen(open) && budget.rail === 0)
|
|
92
|
+
dropped.push("rail");
|
|
93
|
+
return dropped;
|
|
94
|
+
}
|
|
95
|
+
/** Rows available to the body at a given terminal height (at least 1). */
|
|
96
|
+
export function bodyRows(height) {
|
|
97
|
+
return Math.max(MIN_BODY_ROWS, height - CHROME_ROWS);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Truncate to exactly `width` visible columns and pad with spaces to fill it.
|
|
101
|
+
* Column joining needs every cell to be the SAME visible width or the grid
|
|
102
|
+
* shears — `fit` alone only guarantees the upper bound.
|
|
103
|
+
*/
|
|
104
|
+
export function padTo(text, width) {
|
|
105
|
+
if (width <= 0)
|
|
106
|
+
return "";
|
|
107
|
+
const clipped = fit(text, width);
|
|
108
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Take the LAST `rows` lines of a panel's content and pad the block to exactly
|
|
112
|
+
* that many rows. Last-N (not first-N) because a SCROLLING panel is a tail view:
|
|
113
|
+
* the newest conversation line, the newest session, matter most.
|
|
114
|
+
*
|
|
115
|
+
* That rule is right for `main` and the sidebar and WRONG for the rail, which is
|
|
116
|
+
* a stack of fixed panels rather than a feed — see {@link stackPanels}. Do not
|
|
117
|
+
* reach for this to fit the rail.
|
|
118
|
+
*/
|
|
119
|
+
export function fitBlock(lines, rows, width) {
|
|
120
|
+
const tail = lines.length > rows ? lines.slice(lines.length - rows) : lines;
|
|
121
|
+
const out = tail.map((l) => padTo(l, width));
|
|
122
|
+
while (out.length < rows)
|
|
123
|
+
out.push(" ".repeat(Math.max(0, width)));
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/** Rows the overflow notice costs when at least one panel is dropped. */
|
|
127
|
+
const OVERFLOW_ROWS = 1;
|
|
128
|
+
/**
|
|
129
|
+
* The overflow notice: how many panels the height could not hold. Kept short
|
|
130
|
+
* enough to survive {@link RAIL_COLS} after truncation.
|
|
131
|
+
*/
|
|
132
|
+
function overflowNotice(count, theme) {
|
|
133
|
+
return theme.muted(`${theme.glyph.ellipsis}${count} more panel${count === 1 ? "" : "s"}`);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Stack fixed panels TOP-DOWN into a row budget, reporting what did not fit.
|
|
137
|
+
*
|
|
138
|
+
* This is the vertical mirror of {@link droppedForWidth}, and it exists because
|
|
139
|
+
* {@link fitBlock} is actively wrong here. `fitBlock` keeps the LAST N rows —
|
|
140
|
+
* correct for a feed, but applied to a stack of fixed panels it eats the TOP of
|
|
141
|
+
* the rail first. Below roughly 19 body rows that silently deleted the context
|
|
142
|
+
* panel: no marker, no notice, a panel the user had open simply absent. A panel
|
|
143
|
+
* must never vanish without saying so — the same rule `droppedForWidth` enforces
|
|
144
|
+
* across the other axis.
|
|
145
|
+
*
|
|
146
|
+
* Panels are placed whole or not at all. A half-drawn gauge or a title with its
|
|
147
|
+
* body cut off reads as a rendering bug, and worse, reads as DATA — a context
|
|
148
|
+
* meter clipped to its first row looks like a real (wrong) number. Dropping it
|
|
149
|
+
* and counting it is the honest failure.
|
|
150
|
+
*
|
|
151
|
+
* One blank separator row sits between adjacent panels, and is charged for here
|
|
152
|
+
* rather than baked into each block, so a panel's own height stays its own
|
|
153
|
+
* business.
|
|
154
|
+
*/
|
|
155
|
+
export function stackPanels(blocks, rows, theme) {
|
|
156
|
+
if (rows <= 0) {
|
|
157
|
+
return { lines: [], dropped: blocks.map((b) => b.id) };
|
|
158
|
+
}
|
|
159
|
+
// Cost of placing the first `n` blocks: their heights plus the separators
|
|
160
|
+
// between them. Computed against the same rule the placement loop uses, so
|
|
161
|
+
// the "does everything fit" test below cannot drift from the loop.
|
|
162
|
+
const costOf = (n) => blocks
|
|
163
|
+
.slice(0, n)
|
|
164
|
+
.reduce((sum, b, i) => sum + b.lines.length + (i > 0 ? 1 : 0), 0);
|
|
165
|
+
// Everything fits: no notice row, nothing dropped, no budget games.
|
|
166
|
+
const fitsWhole = costOf(blocks.length) <= rows;
|
|
167
|
+
// Otherwise the notice itself needs a row, and it must be paid for BEFORE
|
|
168
|
+
// deciding what fits — reserving it afterwards could push out a panel that
|
|
169
|
+
// the user was just told fit.
|
|
170
|
+
const budget = fitsWhole ? rows : rows - OVERFLOW_ROWS;
|
|
171
|
+
// The longest PREFIX that fits. Not a best-fit pack: skipping a tall panel to
|
|
172
|
+
// squeeze in a shorter one below it would reorder the rail, and the order is
|
|
173
|
+
// meaningful — it is the order the user reads, and the order `/help` lists.
|
|
174
|
+
let placed = 0;
|
|
175
|
+
while (placed < blocks.length && costOf(placed + 1) <= budget)
|
|
176
|
+
placed++;
|
|
177
|
+
const lines = [];
|
|
178
|
+
for (let i = 0; i < placed; i++) {
|
|
179
|
+
if (i > 0)
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push(...blocks[i].lines);
|
|
182
|
+
}
|
|
183
|
+
const dropped = blocks.slice(placed).map((b) => b.id);
|
|
184
|
+
if (dropped.length > 0)
|
|
185
|
+
lines.push(overflowNotice(dropped.length, theme));
|
|
186
|
+
return { lines, dropped };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Body rows an overlay may occupy at a given height — its budget, so a caller
|
|
190
|
+
* can fit its own content to the drawer instead of discovering the truncation.
|
|
191
|
+
* Always leaves at least one row to the columns: an overlay never blanks the
|
|
192
|
+
* conversation the way the old full-frame clear did.
|
|
193
|
+
*/
|
|
194
|
+
export function overlayRows(height) {
|
|
195
|
+
return Math.max(0, bodyRows(height) - 1);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Fit overlay content to `rows`, keeping the FIRST row and the tail.
|
|
199
|
+
*
|
|
200
|
+
* A safety net, not the fitting strategy: {@link overlayRows} publishes the
|
|
201
|
+
* budget and callers are expected to compose within it. When one does not, this
|
|
202
|
+
* keeps the two rows a modal cannot lose — row 0 (its identity: what is being
|
|
203
|
+
* asked, and at what risk) and the last rows (what the user can press) — rather
|
|
204
|
+
* than the plain tail every feed-shaped block here uses. A drawer is not a feed.
|
|
205
|
+
*/
|
|
206
|
+
export function fitOverlay(lines, rows) {
|
|
207
|
+
if (rows <= 0)
|
|
208
|
+
return [];
|
|
209
|
+
if (lines.length <= rows)
|
|
210
|
+
return lines;
|
|
211
|
+
if (rows === 1)
|
|
212
|
+
return [lines[0]];
|
|
213
|
+
return [lines[0], ...lines.slice(lines.length - (rows - 1))];
|
|
214
|
+
}
|
|
215
|
+
/** Compose the header row: left flush, right flush, dropped when it won't fit. */
|
|
216
|
+
function headerRow(vm, width, theme) {
|
|
217
|
+
const left = theme.strong(theme.accent(fit(vm.headerLeft, width)));
|
|
218
|
+
const leftW = visibleWidth(fit(vm.headerLeft, width));
|
|
219
|
+
const room = width - leftW - 2;
|
|
220
|
+
if (room < 8 || vm.headerRight === "")
|
|
221
|
+
return padTo(left, width);
|
|
222
|
+
const right = fit(vm.headerRight, room);
|
|
223
|
+
const gap = width - leftW - visibleWidth(right);
|
|
224
|
+
return left + " ".repeat(Math.max(1, gap)) + theme.muted(right);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Compose the full screen: exactly `height` rows, each at most `width` visible
|
|
228
|
+
* columns. The caller hands these straight to the transient frame.
|
|
229
|
+
*
|
|
230
|
+
* Every row is padded to the full width so a repaint fully overwrites the row
|
|
231
|
+
* beneath it — a short row would leave the previous paint's tail on screen.
|
|
232
|
+
*/
|
|
233
|
+
export function composeScreen(vm, width, height, open, theme) {
|
|
234
|
+
const budget = budgetColumns(width, open);
|
|
235
|
+
const rows = bodyRows(height);
|
|
236
|
+
// The rule follows the theme's unicode axis (U.1) like every other glyph —
|
|
237
|
+
// a box-drawing dash would mojibake on a CRUXY_ASCII / dumb terminal.
|
|
238
|
+
const rule = theme.muted((theme.unicode ? "─" : "-").repeat(Math.max(0, width)));
|
|
239
|
+
// An overlay is a drawer: it takes rows from the BOTTOM of the body and the
|
|
240
|
+
// columns render into what is left. The columns keep at least one row, so a
|
|
241
|
+
// modal never blanks the conversation behind it.
|
|
242
|
+
const drawer = fitOverlay(vm.overlay ?? [], overlayRows(height));
|
|
243
|
+
const columnRows = Math.max(1, rows - drawer.length);
|
|
244
|
+
const columns = [];
|
|
245
|
+
if (budget.sidebar > 0)
|
|
246
|
+
columns.push(fitBlock(vm.sidebar, columnRows, budget.sidebar));
|
|
247
|
+
columns.push(fitBlock(vm.main, columnRows, budget.main));
|
|
248
|
+
if (budget.rail > 0)
|
|
249
|
+
columns.push(fitBlock(vm.rail, columnRows, budget.rail));
|
|
250
|
+
const body = [];
|
|
251
|
+
for (let r = 0; r < columnRows; r++) {
|
|
252
|
+
body.push(columns.map((c) => c[r]).join(" ".repeat(GUTTER)));
|
|
253
|
+
}
|
|
254
|
+
// Padded to the full width like every other row: the grid shears otherwise,
|
|
255
|
+
// and a short overlay row would leave stale column bytes to its right.
|
|
256
|
+
for (const line of drawer)
|
|
257
|
+
body.push(padTo(line, width));
|
|
258
|
+
const screen = [
|
|
259
|
+
headerRow(vm, width, theme),
|
|
260
|
+
rule,
|
|
261
|
+
...body,
|
|
262
|
+
rule,
|
|
263
|
+
padTo(vm.status === "" ? "" : theme.muted(vm.status), width),
|
|
264
|
+
padTo(vm.input, width),
|
|
265
|
+
];
|
|
266
|
+
// A terminal shorter than the chrome itself: keep the last `height` rows, so
|
|
267
|
+
// the input line — the only row the user can act on — always survives.
|
|
268
|
+
return screen.length > height ? screen.slice(screen.length - height) : screen;
|
|
269
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createKeyReader, } from "../components/input.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build the lease over one real reader.
|
|
4
|
+
*
|
|
5
|
+
* `restore()` is idempotent PER HANDLE, not just in aggregate: a component that
|
|
6
|
+
* calls it twice in a `finally` must not decrement the count twice and yank the
|
|
7
|
+
* terminal out from under the loop that lent it.
|
|
8
|
+
*/
|
|
9
|
+
export function createKeyLease(stdin) {
|
|
10
|
+
const reader = createKeyReader(stdin);
|
|
11
|
+
let holders = 0;
|
|
12
|
+
return {
|
|
13
|
+
handle() {
|
|
14
|
+
let mine = false;
|
|
15
|
+
return {
|
|
16
|
+
begin() {
|
|
17
|
+
if (mine)
|
|
18
|
+
return;
|
|
19
|
+
mine = true;
|
|
20
|
+
if (holders++ === 0)
|
|
21
|
+
reader.begin();
|
|
22
|
+
},
|
|
23
|
+
read() {
|
|
24
|
+
return reader.read();
|
|
25
|
+
},
|
|
26
|
+
restore() {
|
|
27
|
+
if (!mine)
|
|
28
|
+
return;
|
|
29
|
+
mine = false;
|
|
30
|
+
if (--holders === 0)
|
|
31
|
+
reader.restore();
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
held: () => holders > 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A {@link Frame} that paints into the renderer's overlay drawer instead of the
|
|
40
|
+
* terminal. Same contract as `components/frame.ts` — render replaces the
|
|
41
|
+
* region, `clear()` removes it entirely and is idempotent — so a component
|
|
42
|
+
* cannot tell the difference, and the `finally { frame.clear() }` every one of
|
|
43
|
+
* them already has is what takes the drawer back down.
|
|
44
|
+
*/
|
|
45
|
+
export function createOverlayFrame(surface) {
|
|
46
|
+
let cleared = false;
|
|
47
|
+
return {
|
|
48
|
+
render(lines) {
|
|
49
|
+
if (cleared)
|
|
50
|
+
return;
|
|
51
|
+
surface.setOverlay(lines);
|
|
52
|
+
},
|
|
53
|
+
clear() {
|
|
54
|
+
if (cleared)
|
|
55
|
+
return;
|
|
56
|
+
cleared = true;
|
|
57
|
+
surface.setOverlay(null);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A {@link ComponentIO} that runs a component inside the TUI viewport: frames
|
|
63
|
+
* become overlay rows, keys come from the shared lease.
|
|
64
|
+
*
|
|
65
|
+
* `caps` is a LIVE view, not a snapshot. The real capabilities object is mutated
|
|
66
|
+
* in place on SIGWINCH, and the drawer's usable width and height are both
|
|
67
|
+
* derived from it, so these have to be getters — a spread would freeze the
|
|
68
|
+
* overlay at the width it was opened with and every row would be fitted to a
|
|
69
|
+
* terminal that no longer exists. (Content composed before a resize is not
|
|
70
|
+
* re-flowed until the component's next repaint; the renderer pads and truncates
|
|
71
|
+
* every row it paints, so the intermediate frame is stale, never corrupt.)
|
|
72
|
+
*
|
|
73
|
+
* `write` is deliberately a sink. Components emit frame bytes through the frame
|
|
74
|
+
* and nothing else; anything that did reach here would be raw ANSI landing in
|
|
75
|
+
* the middle of a managed screen, so dropping it is the honest behaviour rather
|
|
76
|
+
* than forwarding corruption to the terminal.
|
|
77
|
+
*/
|
|
78
|
+
export function createOverlayIO(surface, lease) {
|
|
79
|
+
const host = surface.caps;
|
|
80
|
+
const caps = {
|
|
81
|
+
...host,
|
|
82
|
+
get width() {
|
|
83
|
+
return surface.overlayWidth();
|
|
84
|
+
},
|
|
85
|
+
get height() {
|
|
86
|
+
return surface.overlayRows();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
caps,
|
|
91
|
+
interactive: host.interactive,
|
|
92
|
+
write: () => { },
|
|
93
|
+
keys: lease.handle(),
|
|
94
|
+
makeFrame: () => createOverlayFrame(surface),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether the screen can host a modal at all. A terminal too short for a drawer
|
|
99
|
+
* leaves {@link OverlaySurface.overlayRows} at 0, and a component painting into
|
|
100
|
+
* zero rows would read as a hang — the prompt never appears, the key loop still
|
|
101
|
+
* blocks. Callers check this and fall back to a non-overlay path instead.
|
|
102
|
+
*/
|
|
103
|
+
export function canOverlay(surface) {
|
|
104
|
+
return surface.caps.interactive && surface.overlayRows() > 0;
|
|
105
|
+
}
|