@aragon-agent/cli 0.6.24 → 0.6.26
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/CHANGELOG.md +1924 -1786
- package/README.md +2730 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +49 -14
- package/dist/cli.js +214 -2
- package/dist/commands/branch-command.d.ts +61 -0
- package/dist/commands/branch-command.js +117 -0
- package/dist/commands/builtins.js +62 -40
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +48 -1
- package/dist/commands/session-command.d.ts +54 -0
- package/dist/commands/session-command.js +109 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +155 -0
- package/dist/proc/terminal-plan.js +360 -0
- package/dist/session/branch-invocation.d.ts +83 -0
- package/dist/session/branch-invocation.js +109 -0
- package/dist/session/branch-launch.d.ts +70 -0
- package/dist/session/branch-launch.js +248 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -0
- package/dist/session/cli-commands.d.ts +2 -0
- package/dist/session/cli-commands.js +36 -5
- package/dist/session/identity.d.ts +72 -0
- package/dist/session/identity.js +148 -0
- package/dist/session/limits.d.ts +66 -0
- package/dist/session/limits.js +66 -0
- package/dist/session/persist.d.ts +30 -5
- package/dist/session/startup-session.d.ts +107 -0
- package/dist/session/startup-session.js +315 -0
- package/dist/session/store.d.ts +60 -1
- package/dist/session/store.js +95 -6
- package/dist/todo/types.d.ts +5 -3
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +154 -28
- package/dist/ui/Header.js +8 -16
- package/dist/ui/StatusBar.d.ts +8 -0
- package/dist/ui/StatusBar.js +12 -3
- package/dist/ui/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- package/dist/ui/layout/header-hints.d.ts +68 -0
- package/dist/ui/layout/header-hints.js +138 -0
- package/dist/ui/layout/session-chip.d.ts +52 -0
- package/dist/ui/layout/session-chip.js +64 -0
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +8 -1
- package/dist/ui/overlays/HelpOverlay.js +17 -5
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- package/dist/ui/session-bridge.d.ts +26 -0
- package/dist/ui/session-bridge.js +32 -0
- package/package.json +2 -2
package/dist/session/store.js
CHANGED
|
@@ -91,6 +91,19 @@ export function sessionPathFor(id) {
|
|
|
91
91
|
* prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
|
|
92
92
|
* out of a file, and files are inputs.
|
|
93
93
|
*
|
|
94
|
+
* A TUI CHECKPOINT NEVER NAMES ITSELF (D-20 / R-1). This is the single line that
|
|
95
|
+
* keeps `aragon exec --resume <tui-id>` from OVERWRITING the parent checkpoint:
|
|
96
|
+
* before this check, a TUI file carrying `meta.id` made `exec` take that id,
|
|
97
|
+
* write its own result into `a4k2m9.json` and move the `--continue` pointer -
|
|
98
|
+
* silent, unrecoverable loss of the one conversation the user was still having,
|
|
99
|
+
* arriving through a directory the design had declared out of scope. The rule
|
|
100
|
+
* belongs HERE rather than in `exec/**` because every exec write-back path
|
|
101
|
+
* already funnels through this function, so one check covers all of them and
|
|
102
|
+
* `exec/**` stays untouched.
|
|
103
|
+
*
|
|
104
|
+
* The fallthrough is the "stranger file" branch that already existed below:
|
|
105
|
+
* mint a fresh id rather than build a path out of the leftovers.
|
|
106
|
+
*
|
|
94
107
|
* The order is the one that keeps every legitimate resume landing where the user
|
|
95
108
|
* expects: an exec-written session names itself, a TUI `/save` is named by its
|
|
96
109
|
* file, and anything else gets a fresh id rather than a guess - reported back in
|
|
@@ -98,6 +111,8 @@ export function sessionPathFor(id) {
|
|
|
98
111
|
* wondering which session it just wrote.
|
|
99
112
|
*/
|
|
100
113
|
export function resumedSessionId(path, saved) {
|
|
114
|
+
if (saved.meta?.origin === 'tui')
|
|
115
|
+
return randomUUID();
|
|
101
116
|
const declared = saved.meta?.id;
|
|
102
117
|
if (typeof declared === 'string' && isValidSessionId(declared))
|
|
103
118
|
return declared;
|
|
@@ -106,6 +121,17 @@ export function resumedSessionId(path, saved) {
|
|
|
106
121
|
return base;
|
|
107
122
|
return randomUUID();
|
|
108
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether a `--resume` value is meant as a PATH rather than an id.
|
|
126
|
+
*
|
|
127
|
+
* EXPORTED SO THE FAILURE MESSAGE CANNOT DISAGREE WITH THE LOOKUP: the startup
|
|
128
|
+
* path prints one sentence for "no such id" and another for "no such file", and
|
|
129
|
+
* a second copy of this predicate is a copy that can drift (`isAbsolute` was
|
|
130
|
+
* missing from exactly such a copy).
|
|
131
|
+
*/
|
|
132
|
+
export function looksLikeSessionPath(value) {
|
|
133
|
+
return (isAbsolute(value) || value.includes('/') || value.includes('\\') || value.endsWith('.json'));
|
|
134
|
+
}
|
|
109
135
|
function ensureDir() {
|
|
110
136
|
const dir = getSessionsDir();
|
|
111
137
|
if (!existsSync(dir))
|
|
@@ -135,8 +161,7 @@ export function resolveSessionRef(idOrPath, cwd) {
|
|
|
135
161
|
const value = idOrPath.trim();
|
|
136
162
|
if (value.length === 0)
|
|
137
163
|
return null;
|
|
138
|
-
const
|
|
139
|
-
const path = looksLikePath
|
|
164
|
+
const path = looksLikeSessionPath(value)
|
|
140
165
|
? resolve(cwd, value)
|
|
141
166
|
: isValidSessionId(value)
|
|
142
167
|
? sessionPathFor(value)
|
|
@@ -199,9 +224,24 @@ function describe(path, session) {
|
|
|
199
224
|
provider: meta?.provider ?? session.model?.providerId ?? '',
|
|
200
225
|
model: meta?.model ?? session.model?.modelId ?? '',
|
|
201
226
|
managed: typeof meta?.id === 'string' && meta.id.length > 0,
|
|
227
|
+
// UNTRUSTED INPUT, DEGRADED TO THE OLD BEHAVIOUR. `origin` is read out of a
|
|
228
|
+
// file, so an unknown or hostile value must not be able to make a TUI
|
|
229
|
+
// checkpoint look like an exec one (or the reverse); anything that is not
|
|
230
|
+
// exactly `'tui'` reads as `'exec'`, which is what every pre-feature file
|
|
231
|
+
// means.
|
|
232
|
+
origin: meta?.origin === 'tui' ? 'tui' : 'exec',
|
|
233
|
+
pinned: meta?.pinned === true,
|
|
234
|
+
live: meta?.live === true,
|
|
202
235
|
};
|
|
203
236
|
}
|
|
204
|
-
|
|
237
|
+
/**
|
|
238
|
+
* The file name without its directory or `.json` suffix.
|
|
239
|
+
*
|
|
240
|
+
* Exported because session IDENTITY derives the parent of a branch from this
|
|
241
|
+
* value and NOT from `meta.id` (D-5): the contents of a file are input, so a
|
|
242
|
+
* hand-written `meta.id` must not be able to name where this process writes.
|
|
243
|
+
*/
|
|
244
|
+
export function basenameId(path) {
|
|
205
245
|
const name = path.replace(/\\/g, '/').split('/').pop() ?? path;
|
|
206
246
|
return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
|
|
207
247
|
}
|
|
@@ -242,8 +282,12 @@ function safeReaddir(dir) {
|
|
|
242
282
|
/** `--dry-run` reports and deletes nothing; nothing is ever pruned automatically. */
|
|
243
283
|
export function pruneSessions(opts = {}) {
|
|
244
284
|
const now = (opts.now ?? Date.now)();
|
|
285
|
+
const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
245
286
|
const cutoff = opts.olderThanDays === undefined ? now : now - opts.olderThanDays * 24 * 60 * 60 * 1000;
|
|
246
|
-
const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff
|
|
287
|
+
const doomed = listSessions({ ...(opts.all ? { all: true } : {}) }).filter((entry) => entry.updatedAt <= cutoff &&
|
|
288
|
+
isInPruneScope(entry, opts.origin ?? 'exec') &&
|
|
289
|
+
!entry.pinned &&
|
|
290
|
+
!isOwnedByALiveProcess(entry, isAlive));
|
|
247
291
|
if (opts.dryRun)
|
|
248
292
|
return doomed;
|
|
249
293
|
for (const entry of doomed) {
|
|
@@ -257,6 +301,41 @@ export function pruneSessions(opts = {}) {
|
|
|
257
301
|
}
|
|
258
302
|
return doomed;
|
|
259
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* The origin half of the truth table. Files WITHOUT metadata are governed by
|
|
306
|
+
* `all` alone (they were already included or already excluded by the listing),
|
|
307
|
+
* so they pass here rather than being re-judged by a `meta` they do not have.
|
|
308
|
+
*/
|
|
309
|
+
function isInPruneScope(entry, origin) {
|
|
310
|
+
if (origin === 'all')
|
|
311
|
+
return true;
|
|
312
|
+
if (!entry.managed)
|
|
313
|
+
return true;
|
|
314
|
+
return (entry.origin ?? 'exec') === origin;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* `live: true` PLUS A LIVE OWNER PID IS NEVER DELETED (§3.7 / T-33).
|
|
318
|
+
*
|
|
319
|
+
* The pid comes from the file's `.lock` payload, which is the only per-session
|
|
320
|
+
* process identity this directory carries and the same source
|
|
321
|
+
* `tryAcquireSessionLock` trusts. A `live: true` file with NO lock is deletable,
|
|
322
|
+
* and that is the honest answer rather than a gap: the flag says a writer
|
|
323
|
+
* believed itself live, not that one is alive now - a process killed in the
|
|
324
|
+
* night leaves the flag behind, and a protection that could never expire is a
|
|
325
|
+
* file nobody can ever clean up.
|
|
326
|
+
*/
|
|
327
|
+
function isOwnedByALiveProcess(entry, isAlive) {
|
|
328
|
+
if (!entry.live)
|
|
329
|
+
return false;
|
|
330
|
+
// THE FILE NAME, NOT `meta.id`: the lock is named after the file, and `meta.id`
|
|
331
|
+
// comes out of that file (I-12). Reading it here would probe the wrong lock for
|
|
332
|
+
// a file whose metadata disagrees with its name - and, since `join` normalises,
|
|
333
|
+
// a hostile id containing separators would read outside this directory.
|
|
334
|
+
const holder = readLock(join(getSessionsDir(), `${basenameId(entry.path)}.lock`));
|
|
335
|
+
if (!holder || holder.host !== hostname())
|
|
336
|
+
return false;
|
|
337
|
+
return isAlive(holder.pid);
|
|
338
|
+
}
|
|
260
339
|
// ---------------------------------------------------------------------------
|
|
261
340
|
// `--continue`: the pointer, and the scan that makes it safe to keep
|
|
262
341
|
// ---------------------------------------------------------------------------
|
|
@@ -299,16 +378,26 @@ export function latestSessionForCwd(cwd) {
|
|
|
299
378
|
if (hinted && isValidSessionId(hinted.id)) {
|
|
300
379
|
const path = sessionPathFor(hinted.id);
|
|
301
380
|
const session = existsSync(path) ? readSessionFile(path) : null;
|
|
302
|
-
|
|
381
|
+
// BOTH BRANCHES EXCLUDE TUI CHECKPOINTS (tui-session-id-resume §3.7). The
|
|
382
|
+
// pointer branch is not defensive padding: `--continue` promises "the newest
|
|
383
|
+
// EXEC session", and TUI sessions are resumed by id. Today only
|
|
384
|
+
// `writeSession()` moves the pointer, so a TUI file cannot be named there -
|
|
385
|
+
// and that is precisely the kind of "nobody writes it today" assumption this
|
|
386
|
+
// repository has already paid for. One predicate, applied twice, is cheap.
|
|
387
|
+
if (session?.meta && originOf(session.meta) === 'exec' && sameCwd(session.meta.cwd, cwd)) {
|
|
303
388
|
return describe(path, session);
|
|
389
|
+
}
|
|
304
390
|
}
|
|
305
391
|
const target = resolve(cwd);
|
|
306
392
|
for (const entry of listSessions()) {
|
|
307
|
-
if (sameCwd(entry.cwd, target))
|
|
393
|
+
if (originOf(entry) === 'exec' && sameCwd(entry.cwd, target))
|
|
308
394
|
return entry;
|
|
309
395
|
}
|
|
310
396
|
return null;
|
|
311
397
|
}
|
|
398
|
+
function originOf(value) {
|
|
399
|
+
return value.origin === 'tui' ? 'tui' : 'exec';
|
|
400
|
+
}
|
|
312
401
|
function sameCwd(a, b) {
|
|
313
402
|
if (!a || !b)
|
|
314
403
|
return false;
|
package/dist/todo/types.d.ts
CHANGED
|
@@ -42,9 +42,11 @@ export interface TodoSnapshot {
|
|
|
42
42
|
/**
|
|
43
43
|
* Why a list went away.
|
|
44
44
|
*
|
|
45
|
-
* `user` - `/todo clear`, or `/clear
|
|
46
|
-
* which is I-2's standing exception;
|
|
47
|
-
*
|
|
45
|
+
* `user` - `/todo clear`, or `/clear screen` (the screen-only form). Both are
|
|
46
|
+
* an explicit user instruction, which is I-2's standing exception;
|
|
47
|
+
* neither touches `messages`.
|
|
48
|
+
* `reset` - `/clear` (or its alias `/reset`) through `clearMessages()`, or a
|
|
49
|
+
* `/resume` whose file carried no list (§3.13).
|
|
48
50
|
* `turn` - a new TUI task started, or a legacy caller began a turn after
|
|
49
51
|
* every item was completed.
|
|
50
52
|
* `stale` - unfinished, and unwritten for more than `TODO_LIMITS.staleTurns`
|
package/dist/ui/App.d.ts
CHANGED
|
@@ -8,10 +8,13 @@ import React from 'react';
|
|
|
8
8
|
import type { ScrollbarBridge } from './scrollbar-controller.js';
|
|
9
9
|
import type { AgentController } from '../agent/controller.js';
|
|
10
10
|
import type { ConfirmRequest } from '../tools/index.js';
|
|
11
|
-
import { type Overlay, type ToastLevel } from '../agent/reducer.js';
|
|
11
|
+
import { type Entry, type Overlay, type ToastLevel } from '../agent/reducer.js';
|
|
12
12
|
import type { UpdateBridge } from '../update/types.js';
|
|
13
13
|
import type { MouseSource } from '../input/stdin-filter.js';
|
|
14
14
|
import { type PasteBridge } from '../input/limits.js';
|
|
15
|
+
import type { CheckpointResult } from '../session/checkpoint.js';
|
|
16
|
+
import type { BranchCommandPort } from '../commands/branch-command.js';
|
|
17
|
+
import type { SessionIdentity } from '../session/startup-session.js';
|
|
15
18
|
import type { SelectionBridge } from './selection/selection-controller.js';
|
|
16
19
|
import type { HumanInputBridge } from '../tools/human-input.js';
|
|
17
20
|
export interface ConfirmBridge {
|
|
@@ -83,6 +86,55 @@ export interface AppProps {
|
|
|
83
86
|
*/
|
|
84
87
|
pasteBridge?: PasteBridge;
|
|
85
88
|
terminal?: TerminalBridge;
|
|
89
|
+
/**
|
|
90
|
+
* The transcript to seed the first frame with, from a resumed session
|
|
91
|
+
* (tui-session-id-resume 3.12).
|
|
92
|
+
*
|
|
93
|
+
* ABSENT IN EVERY ORDINARY LAUNCH, and absent is what keeps the first frame
|
|
94
|
+
* byte-identical to the pre-feature build (I-6).
|
|
95
|
+
*/
|
|
96
|
+
initialEntries?: Entry[];
|
|
97
|
+
/** This process's session identity. `undefined` means "no chip, no checkpoint". */
|
|
98
|
+
session?: SessionIdentity;
|
|
99
|
+
/** The two actions `cli.tsx` owns: checkpoint now, and rotate after `/resume`. */
|
|
100
|
+
sessionBridge?: SessionBridge;
|
|
101
|
+
/**
|
|
102
|
+
* One line to show when the identity is NOT the obvious one (D-13): a branch,
|
|
103
|
+
* or a `--save` name. An ordinary new session says nothing - the chip is the
|
|
104
|
+
* affordance, and a notice on every launch trains users to ignore notices.
|
|
105
|
+
*/
|
|
106
|
+
sessionNotice?: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `App`'s and `cli.tsx`'s small two-way surface for the session, shaped exactly
|
|
110
|
+
* like `confirmBridge` / `pasteBridge`.
|
|
111
|
+
*
|
|
112
|
+
* `checkpoint` and `rotate` are INSTALLED BY `cli.tsx` because the writer lives
|
|
113
|
+
* outside React (two of its callers are process-level); `onIdentity` is
|
|
114
|
+
* installed by the App so a rotation can move the chip, which is the only piece
|
|
115
|
+
* of the identity the view owns.
|
|
116
|
+
*/
|
|
117
|
+
export interface SessionBridge {
|
|
118
|
+
/**
|
|
119
|
+
* Write a checkpoint now; `pin` also marks the file as never-prunable, and
|
|
120
|
+
* `force` skips the fingerprint short circuit so `/branch` can guarantee that
|
|
121
|
+
* the file the new process is about to read is current (D-15).
|
|
122
|
+
*/
|
|
123
|
+
checkpoint?: (opts?: {
|
|
124
|
+
pin?: boolean;
|
|
125
|
+
force?: boolean;
|
|
126
|
+
}) => CheckpointResult;
|
|
127
|
+
/** Adopt another conversation's identity (D-15); `undefined` if it cannot. */
|
|
128
|
+
rotate?: (loadedPath: string) => SessionIdentity | undefined;
|
|
129
|
+
/**
|
|
130
|
+
* Ctrl+L / `/branch`: branch this session into a NEW terminal. Installed by
|
|
131
|
+
* `cli.tsx`, because opening a window is a process-level act the App only asks
|
|
132
|
+
* for (tui-branch-terminal-shortcut 3.11). Absent in a build with no session,
|
|
133
|
+
* which is what lets `/branch` say so instead of throwing.
|
|
134
|
+
*/
|
|
135
|
+
branch?: BranchCommandPort;
|
|
136
|
+
/** Installed by `App`; called by `rotate` after the new id is claimed. */
|
|
137
|
+
onIdentity?: (identity: SessionIdentity) => void;
|
|
86
138
|
}
|
|
87
139
|
export interface TerminalBridge {
|
|
88
140
|
scrollbar?: ScrollbarBridge;
|
|
@@ -96,4 +148,4 @@ export interface TerminalBridge {
|
|
|
96
148
|
writeForeign?: (text: string) => void;
|
|
97
149
|
selection?: SelectionBridge;
|
|
98
150
|
}
|
|
99
|
-
export declare function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }: AppProps): React.ReactElement;
|
|
151
|
+
export declare function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, initialEntries, session, sessionBridge, sessionNotice, }: AppProps): React.ReactElement;
|
package/dist/ui/App.js
CHANGED
|
@@ -52,9 +52,11 @@ import { INDEX_KEY_FRAME } from '../input/limits.js';
|
|
|
52
52
|
import { INDEX_SKILL_NAME, buildIndexConfirmSummary, hasProjectIndex, indexBuildInvocationArgs, } from '../commands/index-build.js';
|
|
53
53
|
import { installConsoleBridge } from './console-bridge.js';
|
|
54
54
|
import { publishExitSnapshot } from './exit-snapshot.js';
|
|
55
|
+
import { publishSessionView } from './session-bridge.js';
|
|
55
56
|
import { OverlayFrame } from './layout/OverlayFrame.js';
|
|
56
57
|
import { helpRows } from './overlays/HelpOverlay.js';
|
|
57
58
|
import { ModelPicker } from './overlays/ModelPicker.js';
|
|
59
|
+
import { createModelProbe } from '../agent/model-probe.js';
|
|
58
60
|
import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './overlays/SettingsScreen.js';
|
|
59
61
|
import { ConfirmDialog } from './overlays/ConfirmDialog.js';
|
|
60
62
|
import { QuestionOverlay } from './overlays/QuestionOverlay.js';
|
|
@@ -120,10 +122,17 @@ function liveServiceCount(services) {
|
|
|
120
122
|
* The lazy `useReducer` initializer. Module scope, so the reference is stable
|
|
121
123
|
* across renders and React never re-runs it.
|
|
122
124
|
*/
|
|
123
|
-
function seedViewState(
|
|
124
|
-
|
|
125
|
+
function seedViewState(seed) {
|
|
126
|
+
const base = initialViewState({ thinkingVisible: seed.thinkingVisible });
|
|
127
|
+
// THE REDUCER'S OWN ADOPTION LOGIC, NOT A COPY OF IT (3.12): `restoreEntries`
|
|
128
|
+
// already lifts `seq` past the restored ids, trims to the retain ring and
|
|
129
|
+
// clears the live-target pointers, and a second implementation of those thirty
|
|
130
|
+
// lines is how the two would drift.
|
|
131
|
+
return seed.entries && seed.entries.length > 0
|
|
132
|
+
? viewReducer(base, { type: 'restoreEntries', entries: seed.entries })
|
|
133
|
+
: base;
|
|
125
134
|
}
|
|
126
|
-
export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
|
|
135
|
+
export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, initialEntries, session, sessionBridge, sessionNotice, }) {
|
|
127
136
|
const { exit } = useApp();
|
|
128
137
|
const { stdout } = useStdout();
|
|
129
138
|
const size = useTerminalSize();
|
|
@@ -156,7 +165,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
156
165
|
// `toggleThinking` would let `/resume` flash a restored thinking block on
|
|
157
166
|
// frame 1. `seedViewState` is module-scope so the lazy initializer is a stable
|
|
158
167
|
// function reference.
|
|
159
|
-
const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
|
|
168
|
+
const [state, dispatch] = useReducer(viewReducer, { thinkingVisible: cfg.showThinking, ...(initialEntries ? { entries: initialEntries } : {}) }, seedViewState);
|
|
169
|
+
// THE IDENTITY, SEEDED FROM THE PROP AND REPLACED ON ROTATION (D-15). A ref
|
|
170
|
+
// travels beside it because both readers -- the publish effect and the
|
|
171
|
+
// `/session` port -- must see the CURRENT id, and a rotation can land between
|
|
172
|
+
// two renders.
|
|
173
|
+
const [identity, setIdentity] = useState(session);
|
|
174
|
+
const identityRef = useRef(session);
|
|
175
|
+
const sessionId = identity?.id;
|
|
160
176
|
const metrics = useRef(createRunMetrics());
|
|
161
177
|
const manualMetrics = useRef(createRunMetrics());
|
|
162
178
|
const [, setMetricsClock] = useState(0);
|
|
@@ -1024,8 +1040,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1024
1040
|
: undefined);
|
|
1025
1041
|
// --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
|
|
1026
1042
|
useEffect(() => {
|
|
1043
|
+
const merged = mergePendingEntries(state.entries, state.pendingSteering);
|
|
1027
1044
|
publishExitSnapshot({
|
|
1028
|
-
entries:
|
|
1045
|
+
entries: merged,
|
|
1029
1046
|
usageTotal: state.usageTotal,
|
|
1030
1047
|
provider: cfg.provider,
|
|
1031
1048
|
model: cfg.model,
|
|
@@ -1035,7 +1052,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1035
1052
|
// count (K-6).
|
|
1036
1053
|
droppedEntries: state.droppedEntries,
|
|
1037
1054
|
});
|
|
1038
|
-
|
|
1055
|
+
// THE SAME `merged`, THE SAME RENDER (D-2 / 3.12). The checkpoint describes
|
|
1056
|
+
// exactly what the exit summary would, because it is built from the same
|
|
1057
|
+
// array in the same pass - and no second effect or subscription is added.
|
|
1058
|
+
//
|
|
1059
|
+
// `sessionId` IN THE DEPENDENCY ARRAY IS LOAD-BEARING (R-16): a rotation
|
|
1060
|
+
// that leaves the `entries` reference untouched would otherwise never
|
|
1061
|
+
// re-publish, and the writer would keep aiming at the OLD id's file - the
|
|
1062
|
+
// "A's conversation written into B's file" failure I-5 rules out.
|
|
1063
|
+
if (sessionId) {
|
|
1064
|
+
publishSessionView({ id: sessionId, entries: merged, usageTotal: state.usageTotal });
|
|
1065
|
+
}
|
|
1066
|
+
}, [state.entries, state.pendingSteering, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model, sessionId]);
|
|
1039
1067
|
// --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
|
|
1040
1068
|
//
|
|
1041
1069
|
// The deps are the two `useCallback`d members, NOT the handle objects: both
|
|
@@ -1145,6 +1173,33 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1145
1173
|
// --- Helpers -----------------------------------------------------------
|
|
1146
1174
|
const notify = (level, text) => dispatch({ type: 'notice', level, text });
|
|
1147
1175
|
const toast = (level, text) => dispatch({ type: 'pushToast', level, text });
|
|
1176
|
+
// THE BRANCH / `--save` NOTICE (D-13). Once, on mount, and only when the
|
|
1177
|
+
// caller supplied one: "the session you branched from is unchanged" is the
|
|
1178
|
+
// sentence that makes `--resume` safe to reach for, and it belongs in the
|
|
1179
|
+
// transcript where it can be read again rather than in a toast that expires.
|
|
1180
|
+
useEffect(() => {
|
|
1181
|
+
if (sessionNotice)
|
|
1182
|
+
notify('info', sessionNotice);
|
|
1183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1184
|
+
}, []);
|
|
1185
|
+
// --- Session bridge: identity write-back (D-15 / 3.12). -------------------
|
|
1186
|
+
//
|
|
1187
|
+
// INSTALLED AND CLEARED EXACTLY LIKE `confirmBridge.handler`: the rotation is
|
|
1188
|
+
// performed by `cli.tsx` (which owns the writer's target file), and the only
|
|
1189
|
+
// thing the view owns is the id it draws. Clearing on unmount keeps a late
|
|
1190
|
+
// rotation from dispatching into a dead reducer.
|
|
1191
|
+
useEffect(() => {
|
|
1192
|
+
if (!sessionBridge)
|
|
1193
|
+
return undefined;
|
|
1194
|
+
const bridge = sessionBridge;
|
|
1195
|
+
bridge.onIdentity = (next) => {
|
|
1196
|
+
identityRef.current = next;
|
|
1197
|
+
setIdentity(next);
|
|
1198
|
+
};
|
|
1199
|
+
return () => {
|
|
1200
|
+
bridge.onIdentity = undefined;
|
|
1201
|
+
};
|
|
1202
|
+
}, [sessionBridge]);
|
|
1148
1203
|
// --- Paste bridge (tui-paste-handling section 5.1.2 / I-15). -------------
|
|
1149
1204
|
//
|
|
1150
1205
|
// The filter exists before this component does, so it holds a slot rather than
|
|
@@ -1388,6 +1443,35 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1388
1443
|
requestCopy: copyCoordinator.requestCopy,
|
|
1389
1444
|
controller,
|
|
1390
1445
|
state: stateRef.current,
|
|
1446
|
+
// THE ARCHIVE'S ONLY WAY TO SEE THE SCREEN (P0-1 / §3.7), and a thunk for a
|
|
1447
|
+
// reason `state` above illustrates: `state` is a snapshot of this render,
|
|
1448
|
+
// while `/clear` interrupts a run and then waits for it to unwind, so the last
|
|
1449
|
+
// tool cards of the abandoned run are appended AFTER the command starts. A
|
|
1450
|
+
// value here would archive the screen minus the part the user was watching.
|
|
1451
|
+
transcriptEntries: () => stateRef.current.entries,
|
|
1452
|
+
// THE SESSION PORT (D-14 / D-15). `identity()` reads the ref at command time
|
|
1453
|
+
// and never a captured value: `/resume` rotates the id under a live tree, and
|
|
1454
|
+
// a captured one would report the previous id for the rest of the session.
|
|
1455
|
+
// `checkpoint` / `rotate` are the writer's, installed by `cli.tsx`; the port
|
|
1456
|
+
// is absent when this build has no session at all, which is what lets
|
|
1457
|
+
// `/session` say so honestly instead of throwing.
|
|
1458
|
+
...(sessionBridge
|
|
1459
|
+
? {
|
|
1460
|
+
session: {
|
|
1461
|
+
identity: () => identityRef.current,
|
|
1462
|
+
checkpoint: (opts) => sessionBridge.checkpoint?.(opts) ?? {
|
|
1463
|
+
ok: false,
|
|
1464
|
+
reason: 'no-identity',
|
|
1465
|
+
message: 'This session has no id.',
|
|
1466
|
+
},
|
|
1467
|
+
rotate: (loadedPath) => sessionBridge.rotate?.(loadedPath),
|
|
1468
|
+
},
|
|
1469
|
+
}
|
|
1470
|
+
: {}),
|
|
1471
|
+
// The branch port is projected from the bridge here rather than captured, so
|
|
1472
|
+
// this object stays a view of the current bridge, exactly like `session`
|
|
1473
|
+
// above. Absent means `/branch` reports that the build has none.
|
|
1474
|
+
...(sessionBridge?.branch ? { branch: sessionBridge.branch } : {}),
|
|
1391
1475
|
dispatch: (action) => {
|
|
1392
1476
|
if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
|
|
1393
1477
|
metrics.current = createRunMetrics();
|
|
@@ -1535,12 +1619,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1535
1619
|
};
|
|
1536
1620
|
submitRef.current = submitMessage;
|
|
1537
1621
|
const executeSlashInput = async (raw) => {
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1622
|
+
// A CONVERSATION SWITCH OWNS THE QUEUE FOR THE WHOLE COMMAND (B-6 / §3.7).
|
|
1623
|
+
//
|
|
1624
|
+
// The guard used to be raised only when a compaction happened to be busy,
|
|
1625
|
+
// which left the one window this feature is about unwatched: `/clear` on an
|
|
1626
|
+
// IDLE session took the flag nowhere, and `drainCompactionQueue` was still
|
|
1627
|
+
// free to `continue()` a message queued in the previous conversation into
|
|
1628
|
+
// the one the user had just started. Whether there is work to cancel is a
|
|
1629
|
+
// separate question, so the two are now separate values: `switching` decides
|
|
1630
|
+
// the lock, `switchWhileBusy` decides the cancellation.
|
|
1631
|
+
//
|
|
1632
|
+
// `/resume` is still refused mid-run by `handleSubmit` below, so for it the
|
|
1633
|
+
// lock only ever matters while idle - that is the minimum this invariant
|
|
1634
|
+
// asks for, not a contradiction.
|
|
1635
|
+
const switching = /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
|
|
1636
|
+
const switchWhileBusy = switching && Boolean(controller.isCompactionBusy?.());
|
|
1637
|
+
if (switching)
|
|
1541
1638
|
switchingConversation.current = true;
|
|
1639
|
+
if (switchWhileBusy)
|
|
1542
1640
|
await controller.cancelCompaction();
|
|
1543
|
-
}
|
|
1544
1641
|
// Retain a recoverable command before any asynchronous handler can fail.
|
|
1545
1642
|
const safe = redactText(raw, getSecrets());
|
|
1546
1643
|
recordPrompt(safe);
|
|
@@ -1553,7 +1650,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1553
1650
|
notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
|
|
1554
1651
|
}
|
|
1555
1652
|
finally {
|
|
1556
|
-
if (
|
|
1653
|
+
if (switching) {
|
|
1557
1654
|
switchingConversation.current = false;
|
|
1558
1655
|
drainCompactionQueue.current();
|
|
1559
1656
|
}
|
|
@@ -1563,7 +1660,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1563
1660
|
const trimmed = raw.trimStart();
|
|
1564
1661
|
const slash = trimmed.startsWith('/') && !trimmed.startsWith('//');
|
|
1565
1662
|
if (slash && (interactionPhase.current === 'running' || controller.isRunning()) &&
|
|
1566
|
-
/^\/
|
|
1663
|
+
/^\/resume(?:\s|$)/.test(trimmed)) {
|
|
1567
1664
|
return { accepted: false,
|
|
1568
1665
|
reason: 'Interrupt the current run before switching conversations.' };
|
|
1569
1666
|
}
|
|
@@ -1848,6 +1945,23 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1848
1945
|
requestIndexBuild();
|
|
1849
1946
|
return;
|
|
1850
1947
|
}
|
|
1948
|
+
// Ctrl+L: branch this session into a NEW terminal (tui-branch-terminal-
|
|
1949
|
+
// shortcut D-1 / D-2). Guarded exactly like Ctrl+G and Ctrl+I above it: an
|
|
1950
|
+
// overlay owns the keyboard, and on a too-small terminal the feedback has
|
|
1951
|
+
// nowhere to go. It has to sit BEFORE the `tooSmall` return below, because
|
|
1952
|
+
// after that line the transcript and overlay paging own every key - and
|
|
1953
|
+
// "the key did nothing" must stay distinguishable from "the key never
|
|
1954
|
+
// arrived".
|
|
1955
|
+
//
|
|
1956
|
+
// NO `isRunning()` GUARD, unlike Ctrl+I: branching mid-run is the most likely
|
|
1957
|
+
// moment to want it (D-22), and the command body is where running decides
|
|
1958
|
+
// that the success needs a second, durable channel.
|
|
1959
|
+
if (key.ctrl && (input === 'l' || input === 'L')) {
|
|
1960
|
+
if (tooSmall || stateRef.current.overlay)
|
|
1961
|
+
return;
|
|
1962
|
+
void executeSlashInput('/branch');
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1851
1965
|
if (tooSmall)
|
|
1852
1966
|
return;
|
|
1853
1967
|
// R-P1-7: the overlay branch MUST come before the transcript scroll branch
|
|
@@ -1893,21 +2007,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1893
2007
|
applyMode(nextMode(controller.getAgentMode()));
|
|
1894
2008
|
return;
|
|
1895
2009
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2010
|
+
// Moved off Ctrl+L by D-1, because Ctrl+L is the branch key now.
|
|
2011
|
+
//
|
|
2012
|
+
// Ctrl+R, NOT Ctrl+Shift+L: legacy encodings deliver 0x0c for both, so a
|
|
2013
|
+
// Shift binding would exist only behind keyboard enhancement - exactly the
|
|
2014
|
+
// terminals least likely to need a recovery key. The overlay guard keeps the
|
|
2015
|
+
// settings screen's own Ctrl+R (Test connection) unambiguous.
|
|
2016
|
+
//
|
|
2017
|
+
// Writing \x1B[2J here would erase the screen and then leave it blank: Ink
|
|
2018
|
+
// sees no output change and skips the repaint (ink.js:132 +
|
|
2019
|
+
// log-update.js:13), possibly forever on an idle empty session. Bump the
|
|
2020
|
+
// nonce instead and let Ink redraw the frame normally (§4.13).
|
|
2021
|
+
if (key.ctrl && (input === 'r' || input === 'R')) {
|
|
2022
|
+
if (stateRef.current.overlay)
|
|
1903
2023
|
return;
|
|
1904
|
-
|
|
1905
|
-
try {
|
|
1906
|
-
stdout.write('\x1B[2J\x1B[3J\x1B[H');
|
|
1907
|
-
}
|
|
1908
|
-
catch {
|
|
1909
|
-
/* ignore */
|
|
1910
|
-
}
|
|
2024
|
+
setRedrawNonce((n) => n + 1);
|
|
1911
2025
|
return;
|
|
1912
2026
|
}
|
|
1913
2027
|
{
|
|
@@ -2050,6 +2164,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2050
2164
|
const overlay = state.overlay;
|
|
2051
2165
|
const settingsDraft = useMemo(() => overlay === 'settings'
|
|
2052
2166
|
? controller.createModelSettingsDraft() : undefined, [controller, overlay]);
|
|
2167
|
+
// ONE PROBE PER APP (model-profile-connection-test 5 row 9): the port needs the
|
|
2168
|
+
// controller's provider registry, which the overlay has no business owning.
|
|
2169
|
+
// Built only while the settings overlay is open, so no other screen pays for it.
|
|
2170
|
+
const probe = useMemo(() => {
|
|
2171
|
+
if (overlay !== 'settings')
|
|
2172
|
+
return undefined;
|
|
2173
|
+
// A test double or an embedded host may predate this port; absent means the
|
|
2174
|
+
// two probe rows read `(unavailable)` rather than the screen crashing.
|
|
2175
|
+
if (typeof controller.getProviderRegistry !== 'function')
|
|
2176
|
+
return undefined;
|
|
2177
|
+
return createModelProbe({ registry: controller.getProviderRegistry() });
|
|
2178
|
+
}, [controller, overlay]);
|
|
2053
2179
|
const hasKey = controller.hasApiKey();
|
|
2054
2180
|
const empty = state.entries.length === 0;
|
|
2055
2181
|
// RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
|
|
@@ -2155,7 +2281,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2155
2281
|
// the save handler cannot disagree about what `(inherit)` means.
|
|
2156
2282
|
...fastSettingsFrom(cfg.fast),
|
|
2157
2283
|
...compactionSettingsFrom(cfg.compaction),
|
|
2158
|
-
}, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
|
|
2284
|
+
}, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
|
|
2159
2285
|
// Read from `cfg` (render scope, re-read every frame), so `/retry off`
|
|
2160
2286
|
// while the screen is open shows the new value on the next paint.
|
|
2161
2287
|
retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { isActive: !tooSmall, state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { isActive: !tooSmall, questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { isActive: !tooSmall, plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
|
|
@@ -2249,7 +2375,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2249
2375
|
const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
|
|
2250
2376
|
const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
|
|
2251
2377
|
const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme })] }), rail: rail, scrollbar: terminal?.scrollbar, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, onGeometryChange: onGeometryChange, geometryKey: `${rows}:${cols}:${viewportBudget}:${frameBudget.composerSlotRows}:${frameBudget.statusRows}:${contentCols}:${overlay}:${tooSmall}`, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: transcriptEntries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
|
|
2252
|
-
return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: _jsx(BottomStatusRow, { plan: details, columns: cols, theme: theme }), status: _jsx(StatusBar, { columns: cols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
|
|
2378
|
+
return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: _jsx(BottomStatusRow, { plan: details, columns: cols, theme: theme }), status: _jsx(StatusBar, { columns: cols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
|
|
2253
2379
|
? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
|
|
2254
2380
|
// ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
|
|
2255
2381
|
// projection of "many sources of truth" into the props layer, and a
|
package/dist/ui/Header.js
CHANGED
|
@@ -2,21 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import { Logo } from './Logo.js';
|
|
5
|
+
import { planHeaderHints } from './layout/header-hints.js';
|
|
5
6
|
export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false }) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// scroll chip and the ^G toggle are the two affordances that must survive.
|
|
14
|
-
const indexHint = overlayOpen || columns < 72
|
|
15
|
-
? ''
|
|
16
|
-
: (statusExpanded ? '^I build index ' : '^I Index ');
|
|
17
|
-
const detail = overlayOpen ? (statusExpanded ? 'Details on' : 'Details off')
|
|
18
|
-
: indexHint + '^G ' + (statusExpanded ? 'less' : 'more');
|
|
19
|
-
// Widest content: '^9999+ ' (8) + '^I build index ^G less' (23) = 31.
|
|
20
|
-
const detailCells = indexHint ? 32 : 18;
|
|
21
|
-
return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - detailCells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: detailCells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: scroll + detail }) })] });
|
|
7
|
+
// The right-hand hints are planned, not assembled (tui-branch-terminal-shortcut
|
|
8
|
+
// 3.9 / D-11). `plan.text` is exactly `plan.cells` wide and already carries the
|
|
9
|
+
// scroll chip's own trailing spacing, so this component can no longer clip a
|
|
10
|
+
// hint by guessing one cell short - and the ladder that decides which hints
|
|
11
|
+
// survive a narrow terminal lives in a pure function with its own tests.
|
|
12
|
+
const plan = planHeaderHints({ columns, statusExpanded, overlayOpen, scrolledLines });
|
|
13
|
+
return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - plan.cells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
|
|
22
14
|
}
|
package/dist/ui/StatusBar.d.ts
CHANGED
|
@@ -37,6 +37,14 @@ export interface StatusBarProps {
|
|
|
37
37
|
caps: TermCapabilities;
|
|
38
38
|
scrolledLines?: number;
|
|
39
39
|
redrawNonce?: number;
|
|
40
|
+
/**
|
|
41
|
+
* This session's id, rendered at the right end of the primary row
|
|
42
|
+
* (tui-session-id-resume D-11 / D-12).
|
|
43
|
+
*
|
|
44
|
+
* OPTIONAL AND ABSENT BY DEFAULT, so a build without the feature and every
|
|
45
|
+
* existing test render exactly what they rendered before (I-6).
|
|
46
|
+
*/
|
|
47
|
+
sessionId?: string;
|
|
40
48
|
agentMode?: AgentMode;
|
|
41
49
|
pendingAgentMode?: AgentMode | null;
|
|
42
50
|
teamActive?: {
|