@cruxy/cli 1.10.0 → 1.11.1
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/README.md +13 -1
- package/dist/agent/context.js +6 -0
- package/dist/agent/session.js +11 -0
- package/dist/cli/command-catalog.js +5 -1
- package/dist/cli/commands/config.js +18 -3
- package/dist/cli/commands/logs.js +149 -0
- package/dist/cli/commands/pr.js +1 -10
- package/dist/cli/commands/run.js +6 -3
- package/dist/cli/commands/sessions.js +27 -2
- package/dist/cli/onboard.js +0 -9
- package/dist/cli/program.js +15 -1
- package/dist/cli/session-commands.js +49 -2
- package/dist/components/input.js +18 -1
- package/dist/components/keys.js +66 -3
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +61 -12
- package/dist/errors/constructors.js +35 -2
- package/dist/errors/types.js +6 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/log-renderer.js +10 -5
- package/dist/jobs/log-store.js +505 -0
- package/dist/jobs/manager.js +69 -1
- package/dist/render/context-view.js +12 -3
- package/dist/render/index.js +2 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/router.js +34 -14
- package/dist/routing/types.js +0 -2
- package/dist/session/prune.js +83 -23
- package/dist/subagent/orchestrator.js +16 -3
- package/dist/tui/app.js +30 -7
- package/dist/tui/approval-overlay.js +4 -1
- package/dist/tui/layout.js +25 -1
- package/dist/tui/panels.js +44 -6
- package/dist/tui/renderer.js +187 -14
- package/dist/tui/supports.js +15 -0
- package/dist/usage/types.js +25 -0
- package/dist/utils/logger.js +52 -6
- package/package.json +1 -1
package/dist/routing/router.js
CHANGED
|
@@ -2,16 +2,11 @@ import { MODEL_TIERS } from "../brand/voice.js";
|
|
|
2
2
|
import { routingTierUnavailable } from "../errors/index.js";
|
|
3
3
|
import { resolveModelId } from "./resolve.js";
|
|
4
4
|
import { AUTO_MODEL, } from "./types.js";
|
|
5
|
-
/**
|
|
6
|
-
* The tier a config resolves to when nothing else pins one down — mirrors the
|
|
7
|
-
* gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
|
|
8
|
-
* cruxy session lands on exactly the tier it does today.
|
|
9
|
-
*/
|
|
10
|
-
export const DEFAULT_TIER = "vaani";
|
|
11
5
|
/**
|
|
12
6
|
* The config-driven {@link Router}: maps a declared task class to a tier from
|
|
13
7
|
* `{ default, map }`, and fails loud when the resolved tier is not offered. It
|
|
14
|
-
* NEVER inspects prompt content — selection is purely
|
|
8
|
+
* NEVER inspects prompt content — selection is purely
|
|
9
|
+
* `map[taskClass] ?? default ?? null`.
|
|
15
10
|
*/
|
|
16
11
|
export class ConfigRouter {
|
|
17
12
|
cfg;
|
|
@@ -28,9 +23,17 @@ export class ConfigRouter {
|
|
|
28
23
|
this.offered = new Set(offered);
|
|
29
24
|
}
|
|
30
25
|
select(taskClass) {
|
|
31
|
-
// Explicit override, else the default
|
|
32
|
-
//
|
|
33
|
-
|
|
26
|
+
// Explicit override, else the table's default, else DECLINE. An unmapped
|
|
27
|
+
// class is not an error and never a crash — but with no `default` it is not
|
|
28
|
+
// a tier either. Declining means `auto`: the gateway routes that request
|
|
29
|
+
// (see `Router.select`), which is what a table saying nothing about a class
|
|
30
|
+
// actually asked for. Picking some tier here instead would be the same
|
|
31
|
+
// silent substitution the throw below refuses, just quieter — a user who
|
|
32
|
+
// wrote `map: { summarize: kavi }` and no default said nothing whatsoever
|
|
33
|
+
// about the other four classes, and "nothing" is not a vote for a tier.
|
|
34
|
+
const tier = this.cfg.map[taskClass] ?? this.cfg.default ?? null;
|
|
35
|
+
if (tier === null)
|
|
36
|
+
return null;
|
|
34
37
|
// Fail loud: a configured tier the gateway does not offer is a usage error
|
|
35
38
|
// to fix, NOT a silent substitution to some other tier (a user who asked for
|
|
36
39
|
// mira reasoning must never be quietly handed kavi).
|
|
@@ -42,14 +45,16 @@ export class ConfigRouter {
|
|
|
42
45
|
}
|
|
43
46
|
/**
|
|
44
47
|
* The base tier implied by the session's `model.model`: a real tier passes
|
|
45
|
-
* through
|
|
46
|
-
* Used so that when a user has pinned a single tier, an opt-in routing
|
|
47
|
-
* that omits `routing.default` still defaults to THEIR tier, not a fixed
|
|
48
|
+
* through, and `auto` (or any non-tier value) implies none — `undefined`, not a
|
|
49
|
+
* stand-in. Used so that when a user has pinned a single tier, an opt-in routing
|
|
50
|
+
* table that omits `routing.default` still defaults to THEIR tier, not a fixed
|
|
51
|
+
* one; and so that when they pinned nothing, the table does not acquire a
|
|
52
|
+
* default they never wrote.
|
|
48
53
|
*/
|
|
49
54
|
function baseTierFromModel(model) {
|
|
50
55
|
return MODEL_TIERS.includes(model)
|
|
51
56
|
? model
|
|
52
|
-
:
|
|
57
|
+
: undefined;
|
|
53
58
|
}
|
|
54
59
|
/**
|
|
55
60
|
* Build a router from resolved config, or `null` when routing should stay
|
|
@@ -68,6 +73,21 @@ export function routerForConfig(config) {
|
|
|
68
73
|
const configured = def !== undefined || Object.keys(map).length > 0;
|
|
69
74
|
if (!configured)
|
|
70
75
|
return null;
|
|
76
|
+
// The fill is deliberate in ONE direction. When `model.model` names a real
|
|
77
|
+
// tier the user declared a session-wide model, so a table without a `default`
|
|
78
|
+
// inherits it — dropping those classes to `auto` would substitute the
|
|
79
|
+
// gateway's judgement for a choice they made, which is the same wrong as
|
|
80
|
+
// substituting vaani for `auto`, from the other side. When it names no tier
|
|
81
|
+
// (`auto`, the schema default) `baseTierFromModel` returns undefined and the
|
|
82
|
+
// table keeps its silence.
|
|
83
|
+
//
|
|
84
|
+
// NOT DEAD CODE, despite looking it from the session path: `session-factory`
|
|
85
|
+
// hands this router to a `SessionModel` that consults it only while the choice
|
|
86
|
+
// is `auto` (session-factory.ts:341), and a `model.model` naming a tier makes
|
|
87
|
+
// the choice that tier — so the tier branch never fires there. `cruxy pr`
|
|
88
|
+
// calls `routerForConfig` directly (cli/commands/pr.ts:69) with no
|
|
89
|
+
// `SessionModel` in front of it, and that is where a pinned `model.model`
|
|
90
|
+
// reaches this line.
|
|
71
91
|
return new ConfigRouter({
|
|
72
92
|
default: def ?? baseTierFromModel(config.model.model),
|
|
73
93
|
map,
|
package/dist/routing/types.js
CHANGED
package/dist/session/prune.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { unlinkSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKeysPresent, } from "../jobs/log-store.js";
|
|
3
4
|
import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
|
|
4
5
|
import { sessionFilesByRecency } from "./list.js";
|
|
5
6
|
import { SESSION_FILE_EXT } from "./paths.js";
|
|
@@ -30,6 +31,8 @@ export function pruneSessions(cwd, opts) {
|
|
|
30
31
|
kept: 0,
|
|
31
32
|
bytesFreed: 0,
|
|
32
33
|
failed: 0,
|
|
34
|
+
jobLogsRemoved: 0,
|
|
35
|
+
jobLogBytesFreed: 0,
|
|
33
36
|
};
|
|
34
37
|
if (!opts.sessions.enabled)
|
|
35
38
|
return result;
|
|
@@ -72,35 +75,92 @@ export function pruneSessions(cwd, opts) {
|
|
|
72
75
|
result.kept++;
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
|
-
sweepOrphanedJobLogs();
|
|
78
|
+
sweepOrphanedJobLogs(cwd, result, opts);
|
|
76
79
|
return result;
|
|
77
80
|
}
|
|
78
81
|
/**
|
|
79
|
-
* Drop
|
|
82
|
+
* Drop the job logs this project no longer has a reason to keep (#172 item 1).
|
|
80
83
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* the session.
|
|
84
|
+
* This is the second phase of the same mark-and-sweep `CheckpointService.prune`
|
|
85
|
+
* runs: the doomed sessions are already gone above, and what follows is the
|
|
86
|
+
* content that belonged to them. A job log IS content owned by its session,
|
|
87
|
+
* keyed on the session, exactly as a checkpoint's shadow objects are content
|
|
88
|
+
* owned by its manifest — which is the answer #172 asked for in passing and
|
|
89
|
+
* #257 was filed to settle: pruned WITH the session, not independently.
|
|
87
90
|
*
|
|
88
|
-
*
|
|
89
|
-
* checkpoint's shadow objects are content owned by its manifest — so this is
|
|
90
|
-
* `CheckpointService.prune`'s second phase, the mark-and-sweep that runs after
|
|
91
|
-
* the doomed manifests are gone. Filling it in needs one thing the writer does
|
|
92
|
-
* not have yet: a job log has to record which session owns it, in its path or
|
|
93
|
-
* its first line.
|
|
91
|
+
* ## The three rules, and why each one is where the line falls
|
|
94
92
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
93
|
+
* **A finished job's log goes at the next start.** A `done` job's OUTCOME is
|
|
94
|
+
* already durable somewhere better: the dispatch tool's result is a
|
|
95
|
+
* `tool_result` in the session's own transcript, its file mutations are a
|
|
96
|
+
* checkpoint set `cruxy rollback <id>` still finds, and its spend is a usage
|
|
97
|
+
* record under the same id. What only this file holds is the intermediate
|
|
98
|
+
* chatter of a run that went fine — the least interesting artefact in the set.
|
|
99
|
+
* Dropping it eagerly is what keeps the steady-state cost of this subtree near
|
|
100
|
+
* zero, so the logs that survive are the ones somebody would actually open: the
|
|
101
|
+
* failed and the interrupted.
|
|
99
102
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
+
* **An orphan goes when its session does.** Not only the sessions THIS prune
|
|
104
|
+
* removed: the rule is "no session file with this key is present", which also
|
|
105
|
+
* covers `cruxy sessions rm`, a prune from another process, and a file the user
|
|
106
|
+
* deleted by hand. Keying it on what the directory holds rather than on
|
|
107
|
+
* `result.removed` means there is no path by which a log outlives its session
|
|
108
|
+
* unnoticed.
|
|
109
|
+
*
|
|
110
|
+
* **Nothing without a terminal record is deleted while it is young.** A log
|
|
111
|
+
* with no `end` record is one of two things, and this sweep cannot tell them
|
|
112
|
+
* apart: a job that was interrupted (the whole reason to keep logs at all) or a
|
|
113
|
+
* job that is RUNNING RIGHT NOW in another terminal, whose session may not have
|
|
114
|
+
* flushed its own file yet. Treating it as garbage would delete a live job's
|
|
115
|
+
* output from under it. Both cases are handled by leaving it alone until it is
|
|
116
|
+
* older than `maxAgeDays`, at which point it is certainly not live and its
|
|
117
|
+
* session is certainly gone.
|
|
118
|
+
*
|
|
119
|
+
* The active session is skipped outright, for the reason `pruneSessions` spares
|
|
120
|
+
* its own file: "almost always survives" is not a policy to hand a user's
|
|
121
|
+
* running work.
|
|
122
|
+
*
|
|
123
|
+
* ## What it must not do
|
|
124
|
+
*
|
|
125
|
+
* No `rm -r`, and no assumption that every entry here is a session's — the
|
|
126
|
+
* scan behind {@link jobLogFilesByRecency} filters to regular `.jsonl` files
|
|
127
|
+
* whose name splits into exactly two ids, so anything else in `subagents/`
|
|
128
|
+
* (including the subtree's own subdirectories, should P3 add any) is never even
|
|
129
|
+
* considered. Never throws: a log that cannot be unlinked is left, exactly as a
|
|
130
|
+
* session that cannot be is.
|
|
103
131
|
*/
|
|
104
|
-
function sweepOrphanedJobLogs() {
|
|
105
|
-
|
|
132
|
+
function sweepOrphanedJobLogs(cwd, result, opts) {
|
|
133
|
+
const refs = jobLogFilesByRecency(cwd);
|
|
134
|
+
if (refs.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
// Derived from the directory as it stands NOW — after the deletes above — so
|
|
137
|
+
// the sessions this prune just removed are absent from it by construction and
|
|
138
|
+
// need no separate bookkeeping.
|
|
139
|
+
const present = sessionKeysPresent(sessionFilesByRecency(cwd));
|
|
140
|
+
const activeKey = opts.activeSessionId === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: idKey(opts.activeSessionId);
|
|
143
|
+
const now = opts.now ?? Date.now();
|
|
144
|
+
const cutoff = now - opts.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
145
|
+
for (const ref of refs) {
|
|
146
|
+
if (activeKey !== undefined && ref.sessionKey === activeKey)
|
|
147
|
+
continue;
|
|
148
|
+
// One bounded tail read per file, never a full one: see `TAIL_BYTES`.
|
|
149
|
+
const status = readJobLogTerminal(ref.file);
|
|
150
|
+
const orphaned = !present.has(ref.sessionKey);
|
|
151
|
+
const doomed = status === "done" ||
|
|
152
|
+
(orphaned && (status !== INTERRUPTED || ref.mtimeMs < cutoff));
|
|
153
|
+
if (!doomed)
|
|
154
|
+
continue;
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(ref.file);
|
|
157
|
+
result.jobLogsRemoved++;
|
|
158
|
+
result.jobLogBytesFreed += ref.size;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Left in place, and deliberately NOT counted in `failed` — that field
|
|
162
|
+
// counts sessions, and inflating it here would report a conversation that
|
|
163
|
+
// could not be deleted when none was even tried.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
106
166
|
}
|
|
@@ -147,7 +147,6 @@ export class SubagentOrchestrator {
|
|
|
147
147
|
const startedAt = new Date().toISOString();
|
|
148
148
|
try {
|
|
149
149
|
return await this.runChild({
|
|
150
|
-
spec,
|
|
151
150
|
opts,
|
|
152
151
|
messages,
|
|
153
152
|
registry,
|
|
@@ -193,7 +192,7 @@ export class SubagentOrchestrator {
|
|
|
193
192
|
* usage fold above can be a `finally` over every path this can leave by. */
|
|
194
193
|
async runChild(args) {
|
|
195
194
|
const { deps } = this;
|
|
196
|
-
const {
|
|
195
|
+
const { opts, messages, registry, budget, ctx, artifacts } = args;
|
|
197
196
|
const { label, noun, tag, usage } = args;
|
|
198
197
|
let run;
|
|
199
198
|
try {
|
|
@@ -211,7 +210,13 @@ export class SubagentOrchestrator {
|
|
|
211
210
|
subagent: true,
|
|
212
211
|
budget,
|
|
213
212
|
router: deps.router,
|
|
214
|
-
|
|
213
|
+
// A LITERAL, not a per-spawn override (cli#281). Every child is a
|
|
214
|
+
// `subagent` by construction; letting the spec name its own class would
|
|
215
|
+
// hand the model a lever to route itself out of `routing.map.subagent`
|
|
216
|
+
// by relabelling, and would put the difficulty guess back in a call site
|
|
217
|
+
// whose whole contract (`routing/types.ts`) is that callers declare and
|
|
218
|
+
// nobody sniffs.
|
|
219
|
+
taskClass: "subagent",
|
|
215
220
|
signal: opts.signal,
|
|
216
221
|
// Both, and in this order (cli#244). The child's OWN collector is what
|
|
217
222
|
// the budget fold below reads — it needs a per-child record — while the
|
|
@@ -439,6 +444,14 @@ export class SubagentOrchestrator {
|
|
|
439
444
|
return undefined; // no cruxy routing — not a weighted-pool request
|
|
440
445
|
// No tier means the router chose `auto` and the gateway decides — a request
|
|
441
446
|
// that still draws on the pool, so it is weighed at the worst case.
|
|
447
|
+
//
|
|
448
|
+
// A routing table that maps some classes and declares no `default` now
|
|
449
|
+
// lands here for `subagent` (it used to be filled with vaani), so such a
|
|
450
|
+
// fan-out is weighed at MAX_TIER_MULTIPLIER 3.8 rather than vaani's 3.25 —
|
|
451
|
+
// about 17% heavier against the pool. That is the intended answer, not
|
|
452
|
+
// drift: the tier genuinely is not known before the request, and a
|
|
453
|
+
// fan-out the gateway sends to kavi must not have been admitted on
|
|
454
|
+
// vaani's arithmetic.
|
|
442
455
|
return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
|
|
443
456
|
}
|
|
444
457
|
/**
|
package/dist/tui/app.js
CHANGED
|
@@ -3,13 +3,14 @@ import { completeLine } from "../components/autocomplete.js";
|
|
|
3
3
|
import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
|
|
4
4
|
import { TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
|
|
5
5
|
import { selectList } from "../components/select.js";
|
|
6
|
-
import { viewLabel, viewOrder } from "./views.js";
|
|
6
|
+
import { CONVERSATION_VIEW, viewLabel, viewOrder } from "./views.js";
|
|
7
7
|
import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
|
|
8
8
|
import { ModeRing } from "./mode-ring.js";
|
|
9
9
|
import { openPalette } from "./palette.js";
|
|
10
10
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
11
11
|
import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
|
|
12
12
|
import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
|
|
13
|
+
import { WHEEL_LINES } from "./renderer.js";
|
|
13
14
|
/**
|
|
14
15
|
* The TUI's input loop (P1) — the piece that replaces `repl.ts`'s readline
|
|
15
16
|
* loop. It owns exactly two things: the edit buffer and command dispatch.
|
|
@@ -59,7 +60,10 @@ const HELP = [
|
|
|
59
60
|
" Ctrl+K open the command palette",
|
|
60
61
|
" Ctrl+B focus the sidebar nav (arrows switch view, Esc leaves)",
|
|
61
62
|
" Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
|
|
62
|
-
" PgUp / PgDn scroll the pane
|
|
63
|
+
" PgUp / PgDn scroll the pane (the mouse wheel does too; to select",
|
|
64
|
+
" text hold Shift — Option in Terminal.app — or set",
|
|
65
|
+
" CRUXY_NO_MOUSE=1 to give the wheel back to the terminal)",
|
|
66
|
+
" Esc back to the live view, then back to the conversation",
|
|
63
67
|
" Ctrl+D leave cruxy",
|
|
64
68
|
];
|
|
65
69
|
/**
|
|
@@ -177,6 +181,10 @@ async function readLine(keys, renderer, hooks) {
|
|
|
177
181
|
// answering into a pane they cannot see is the one case where holding
|
|
178
182
|
// still is wrong.
|
|
179
183
|
renderer.scrollToLive();
|
|
184
|
+
// And clears the last command's output from under a view (cli#7b):
|
|
185
|
+
// what this line produces must not stack beneath what the last one
|
|
186
|
+
// did. It is all still in the conversation.
|
|
187
|
+
renderer.dismissNotices();
|
|
180
188
|
paint();
|
|
181
189
|
return text;
|
|
182
190
|
}
|
|
@@ -282,12 +290,23 @@ async function readLine(keys, renderer, hooks) {
|
|
|
282
290
|
// schedules its own repaint for the rows that did.
|
|
283
291
|
renderer.scrollPage(key.kind === "page-up" ? 1 : -1);
|
|
284
292
|
break;
|
|
293
|
+
case "wheel-up":
|
|
294
|
+
case "wheel-down":
|
|
295
|
+
// The mouse wheel (cli#1) is the same scroll at a finer grain. It
|
|
296
|
+
// only arrives because the renderer turned mouse reporting on; the
|
|
297
|
+
// terminal would otherwise have scrolled its own window (Terminal.app)
|
|
298
|
+
// or sent arrows this loop ignores (iTerm2).
|
|
299
|
+
renderer.scrollBy(key.kind === "wheel-up" ? WHEEL_LINES : -WHEEL_LINES);
|
|
300
|
+
break;
|
|
285
301
|
case "escape":
|
|
286
|
-
// Esc
|
|
287
|
-
// notice names this key
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
302
|
+
// Esc peels one layer (cli#7b): out of scrollback first — a mode
|
|
303
|
+
// needs a visible exit, and the notice names this key — and, at the
|
|
304
|
+
// live tail, out of a view and back to the conversation. Two presses
|
|
305
|
+
// from anywhere reach the home position; each one is a step the
|
|
306
|
+
// screen can show. Inert at the conversation's live tail, so the
|
|
307
|
+
// binding stays free for whatever a later track wants it to mean.
|
|
308
|
+
if (!renderer.scrollToLive())
|
|
309
|
+
renderer.setView(CONVERSATION_VIEW);
|
|
291
310
|
break;
|
|
292
311
|
case "ctrl-b":
|
|
293
312
|
// Take the keyboard to the sidebar nav (P7 track 2). Refused when the
|
|
@@ -485,6 +504,10 @@ export async function runTui(session, renderer, opts = {}) {
|
|
|
485
504
|
print: (line = "") => renderer.println(line),
|
|
486
505
|
theme: renderer.theme,
|
|
487
506
|
fit: (line) => line,
|
|
507
|
+
// `/clear` (cli#2): the TUI owns its scrollback, so a cleared history is
|
|
508
|
+
// also a cleared screen. The REPL has no equivalent — its transcript is the
|
|
509
|
+
// terminal's own scrollback, which is not the CLI's to erase.
|
|
510
|
+
clear: () => renderer.clearScrollback(),
|
|
488
511
|
};
|
|
489
512
|
// The mode ring (Q5). The chip follows every press; the session is told once,
|
|
490
513
|
// when the presses stop — see `mode-ring.ts` for why passing through a mode is
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readAnswerKey } from "../components/input.js";
|
|
1
2
|
import { themeForColor } from "../theme/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The approval prompt as an in-viewport modal (P5 track 2).
|
|
@@ -88,7 +89,9 @@ export function createOverlayPromptIO(surface, lease, color) {
|
|
|
88
89
|
paint();
|
|
89
90
|
},
|
|
90
91
|
async readKey() {
|
|
91
|
-
|
|
92
|
+
// A wheel notch scrolls the conversation behind the drawer; it is not an
|
|
93
|
+
// answer, and mapping it to "" would deny the action being asked about.
|
|
94
|
+
const key = keys === null ? await read(keys) : await readAnswerKey(keys);
|
|
92
95
|
return keyToChar(key);
|
|
93
96
|
},
|
|
94
97
|
/**
|
package/dist/tui/layout.js
CHANGED
|
@@ -124,6 +124,28 @@ export function fitBlock(lines, rows, width) {
|
|
|
124
124
|
out.push(" ".repeat(Math.max(0, width)));
|
|
125
125
|
return out;
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Take the FIRST `rows` lines of a block and pad it to exactly that many rows —
|
|
129
|
+
* the mirror of {@link fitBlock}, for a column whose top is the part that must
|
|
130
|
+
* survive.
|
|
131
|
+
*
|
|
132
|
+
* The sidebar is that column (cli#7a). It opens with the view nav: a heading
|
|
133
|
+
* and one row per view, fixed-height, and NAVIGABLE — Ctrl+B puts the keyboard
|
|
134
|
+
* on it, and the arrows move a pointer down its rows. `fitBlock`'s tail rule
|
|
135
|
+
* applied to that column ate the heading and the first four view rows at 30
|
|
136
|
+
* rows, which left Ctrl+B moving a pointer the screen never showed. Nothing
|
|
137
|
+
* at the bottom of the sidebar is worth that: the session list below the nav
|
|
138
|
+
* is composed to its own budget by the renderer (see `sidebarLines`), so what
|
|
139
|
+
* reaches here already fits, and when it does not — a terminal too short for
|
|
140
|
+
* the nav itself — the rows to lose are the last ones, not the first.
|
|
141
|
+
*/
|
|
142
|
+
export function fitHead(lines, rows, width) {
|
|
143
|
+
const head = lines.length > rows ? lines.slice(0, Math.max(0, rows)) : lines;
|
|
144
|
+
const out = head.map((l) => padTo(l, width));
|
|
145
|
+
while (out.length < rows)
|
|
146
|
+
out.push(" ".repeat(Math.max(0, width)));
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
127
149
|
/**
|
|
128
150
|
* Window `rows` lines out of a block, `offset` display lines up from the end
|
|
129
151
|
* (P7 track 1). `offset === 0` is the tail view {@link fitBlock} gives, and the
|
|
@@ -293,8 +315,10 @@ export function composeScreen(vm, width, height, open, theme) {
|
|
|
293
315
|
const drawer = fitOverlay(vm.overlay ?? [], overlayRows(height));
|
|
294
316
|
const columnRows = Math.max(1, rows - drawer.length);
|
|
295
317
|
const columns = [];
|
|
318
|
+
// HEAD-fitted, not tail: the sidebar is a nav over a list, and the nav is the
|
|
319
|
+
// part that has to be on screen — see `fitHead`. `main` stays a tail view.
|
|
296
320
|
if (budget.sidebar > 0)
|
|
297
|
-
columns.push(
|
|
321
|
+
columns.push(fitHead(vm.sidebar, columnRows, budget.sidebar));
|
|
298
322
|
columns.push(fitBlock(vm.main, columnRows, budget.main));
|
|
299
323
|
if (budget.rail > 0)
|
|
300
324
|
columns.push(fitBlock(vm.rail, columnRows, budget.rail));
|
package/dist/tui/panels.js
CHANGED
|
@@ -50,22 +50,44 @@ function title(text, theme) {
|
|
|
50
50
|
* The column is narrow (18 columns), so each session takes two lines: its short
|
|
51
51
|
* id and age, then its title. The layout truncates per line, which keeps the id
|
|
52
52
|
* — the part you would type into `--resume` — always fully visible.
|
|
53
|
+
*
|
|
54
|
+
* `rows` is the HEIGHT budget (cli#7a), and the list is fitted to it here the
|
|
55
|
+
* way `stackPanels` fits the rail: whole sessions, newest first, and a count of
|
|
56
|
+
* what did not fit rather than a session cut in half or silently absent. The
|
|
57
|
+
* list sits under the view nav, which is fixed-height and keyboard-driven, so
|
|
58
|
+
* it is the list that yields — and because it is newest-first, the rows to
|
|
59
|
+
* yield are at the END. The old tail-fit lost the nav heading and the newest
|
|
60
|
+
* sessions at once, which is the wrong end of both blocks.
|
|
53
61
|
*/
|
|
54
|
-
export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now()) {
|
|
62
|
+
export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now(), rows = Infinity) {
|
|
63
|
+
if (rows <= 0)
|
|
64
|
+
return [];
|
|
55
65
|
const lines = title("sessions", theme);
|
|
56
66
|
if (sessions.length === 0) {
|
|
57
67
|
lines.push(theme.muted("no saved sessions"));
|
|
58
68
|
lines.push(theme.muted("for this project yet."));
|
|
59
|
-
return lines;
|
|
69
|
+
return lines.slice(0, rows);
|
|
60
70
|
}
|
|
61
|
-
|
|
71
|
+
// Rows left for sessions after the title. Each session costs two, and when
|
|
72
|
+
// not all fit, one row is charged for the notice BEFORE deciding how many do
|
|
73
|
+
// — reserving it afterwards could evict a session just counted as shown.
|
|
74
|
+
const room = Math.max(0, rows - lines.length);
|
|
75
|
+
const fitsWhole = sessions.length * 2 <= room;
|
|
76
|
+
const shown = fitsWhole
|
|
77
|
+
? sessions.length
|
|
78
|
+
: Math.max(0, Math.floor((room - 1) / 2));
|
|
79
|
+
for (const s of sessions.slice(0, shown)) {
|
|
62
80
|
const active = s.sessionId === activeSessionId;
|
|
63
81
|
const mark = active ? theme.accent(theme.glyph.pointer) : " ";
|
|
64
82
|
const head = `${mark} ${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)}`;
|
|
65
83
|
lines.push(active ? theme.strong(head) : head);
|
|
66
84
|
lines.push(theme.muted(` ${s.title}`));
|
|
67
85
|
}
|
|
68
|
-
|
|
86
|
+
if (!fitsWhole && room > 0) {
|
|
87
|
+
const hidden = sessions.length - shown;
|
|
88
|
+
lines.push(theme.muted(`${theme.glyph.ellipsis}${hidden} more session${hidden === 1 ? "" : "s"}`));
|
|
89
|
+
}
|
|
90
|
+
return lines.slice(0, rows);
|
|
69
91
|
}
|
|
70
92
|
/** A panel with no state yet: says so, rather than inventing a plausible value. */
|
|
71
93
|
function pending(theme) {
|
|
@@ -236,19 +258,35 @@ function shortTokens(n) {
|
|
|
236
258
|
*
|
|
237
259
|
* The compaction threshold is shown because it is the only actionable thing
|
|
238
260
|
* here: it says when the CLI will start folding history away.
|
|
261
|
+
*
|
|
262
|
+
* THE ALLOWANCE IS NAMED (cli#4). `used` includes `context.reserveTokens` — a
|
|
263
|
+
* fixed 4,500 by default — because the compaction seam adds it, and the panel
|
|
264
|
+
* must measure what the seam measures. But shown as a bare figure it read as
|
|
265
|
+
* consumption: an empty session opened at "~5k / 100k", as if something had
|
|
266
|
+
* already been spent. Nothing had. The constant is a fair size for what it
|
|
267
|
+
* stands in for (the system prompt plus the default tool catalogue measure
|
|
268
|
+
* about 3.7k), and it is an ALLOWANCE, not a reading: it does not move when
|
|
269
|
+
* CRUXY.md, memory recall, LSP, web or MCP schemas make the real request
|
|
270
|
+
* larger. The second row says so, in the room a 24-column strip has.
|
|
239
271
|
*/
|
|
240
272
|
export function contextPanelLines(theme, reading) {
|
|
241
273
|
if (reading === undefined) {
|
|
242
274
|
return [theme.muted(`measuring${theme.glyph.ellipsis}`)];
|
|
243
275
|
}
|
|
244
|
-
const { used, total, compactAt } = reading;
|
|
276
|
+
const { used, total, compactAt, reserve } = reading;
|
|
245
277
|
const figure = `~${shortTokens(used)} / ${shortTokens(total)} budget`;
|
|
246
278
|
// Past the threshold the next turn compacts, which is worth flagging — but as
|
|
247
279
|
// a statement of what happens next, not as an alarm about a guessed number.
|
|
248
280
|
// Compared on `used`, not the clamped fraction: the clamp is for display, and
|
|
249
281
|
// a history that has overrun the budget must not compare as merely "at" it.
|
|
250
282
|
const style = used >= compactAt ? theme.warning : theme.strong;
|
|
251
|
-
return [
|
|
283
|
+
return [
|
|
284
|
+
style(figure),
|
|
285
|
+
...(reserve === undefined || reserve <= 0
|
|
286
|
+
? []
|
|
287
|
+
: [theme.muted(`incl. ~${shortTokens(reserve)} allowance`)]),
|
|
288
|
+
theme.muted(`compacts at ${shortTokens(compactAt)}`),
|
|
289
|
+
];
|
|
252
290
|
}
|
|
253
291
|
/** The opening lines of the main column, before any turn has run. */
|
|
254
292
|
export function mainWelcome(theme, hint) {
|