@aragon-agent/cli 0.5.3 → 0.5.6
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 +670 -540
- package/README.md +1105 -932
- package/dist/agent/controller.d.ts +71 -1
- package/dist/agent/controller.js +135 -1
- package/dist/agent/headless.d.ts +29 -0
- package/dist/agent/headless.js +86 -1
- package/dist/agent/reducer.d.ts +45 -0
- package/dist/agent/reducer.js +80 -1
- package/dist/agent/system-prompt.d.ts +8 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +72 -6
- package/dist/commands/builtins.d.ts +2 -2
- package/dist/commands/builtins.js +217 -3
- package/dist/commands/registry.d.ts +27 -1
- package/dist/config/cli-commands.js +20 -2
- package/dist/config/env.d.ts +2 -1
- package/dist/config/env.js +60 -5
- package/dist/config/load.d.ts +27 -0
- package/dist/config/load.js +67 -2
- package/dist/config/schema.d.ts +111 -2
- package/dist/config/schema.js +111 -2
- package/dist/config/store.js +37 -1
- package/dist/session/persist.d.ts +19 -1
- package/dist/session/persist.js +21 -2
- package/dist/team/subagent.js +11 -0
- package/dist/todo/follow-through.d.ts +117 -0
- package/dist/todo/follow-through.js +186 -0
- package/dist/todo/limits.d.ts +114 -0
- package/dist/todo/limits.js +114 -0
- package/dist/todo/normalize.d.ts +33 -0
- package/dist/todo/normalize.js +117 -0
- package/dist/todo/panel-rows.d.ts +53 -0
- package/dist/todo/panel-rows.js +103 -0
- package/dist/todo/prompt.d.ts +54 -0
- package/dist/todo/prompt.js +76 -0
- package/dist/todo/store.d.ts +86 -0
- package/dist/todo/store.js +183 -0
- package/dist/todo/todo-tool.d.ts +38 -0
- package/dist/todo/todo-tool.js +134 -0
- package/dist/todo/types.d.ts +62 -0
- package/dist/todo/types.js +12 -0
- package/dist/tools/index.d.ts +16 -2
- package/dist/tools/index.js +13 -0
- package/dist/ui/App.d.ts +22 -1
- package/dist/ui/App.js +292 -13
- package/dist/ui/StatusBar.d.ts +18 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/TodoPanel.d.ts +48 -0
- package/dist/ui/TodoPanel.js +74 -0
- package/dist/ui/TodoStrip.d.ts +36 -0
- package/dist/ui/TodoStrip.js +32 -0
- package/dist/ui/Transcript.js +11 -1
- package/dist/ui/entries/TodoCard.d.ts +31 -0
- package/dist/ui/entries/TodoCard.js +35 -0
- package/dist/ui/glyphs.d.ts +12 -0
- package/dist/ui/glyphs.js +8 -0
- package/dist/ui/layout/AppShell.d.ts +34 -1
- package/dist/ui/layout/AppShell.js +3 -3
- package/dist/ui/layout/ScrollViewport.d.ts +13 -1
- package/dist/ui/layout/ScrollViewport.js +2 -2
- package/dist/ui/layout/rail.d.ts +56 -0
- package/dist/ui/layout/rail.js +68 -0
- package/dist/ui/overlays/HelpOverlay.js +5 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +12 -1
- package/dist/ui/overlays/SettingsScreen.js +38 -1
- package/package.json +3 -2
package/dist/ui/App.js
CHANGED
|
@@ -11,7 +11,7 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
|
11
11
|
import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
|
|
12
12
|
import { mergeDeltas } from '../agent/coalesce.js';
|
|
13
13
|
import { computeCost } from '../agent/usage.js';
|
|
14
|
-
import {
|
|
14
|
+
import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
|
|
15
15
|
import { updatePersistedConfig } from '../config/store.js';
|
|
16
16
|
import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
|
|
17
17
|
import { bumpSubmitCount, setMouseNoticeSeen } from '../config/ui-state.js';
|
|
@@ -29,9 +29,14 @@ import { PromptInput } from './PromptInput.js';
|
|
|
29
29
|
import { Composer } from './Composer.js';
|
|
30
30
|
import { StatusBar } from './StatusBar.js';
|
|
31
31
|
import { TeamPanel } from './TeamPanel.js';
|
|
32
|
+
import { TodoPanel } from './TodoPanel.js';
|
|
33
|
+
import { TodoStrip } from './TodoStrip.js';
|
|
32
34
|
import { ToastStack } from './ToastStack.js';
|
|
33
35
|
import { AppShell } from './layout/AppShell.js';
|
|
34
36
|
import { ScrollViewport } from './layout/ScrollViewport.js';
|
|
37
|
+
import { todoRailRows, todoRailWidth } from './layout/rail.js';
|
|
38
|
+
import { TODO_LIMITS } from '../todo/limits.js';
|
|
39
|
+
import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
|
|
35
40
|
import { useTerminalSize } from './layout/useTerminalSize.js';
|
|
36
41
|
import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
|
|
37
42
|
import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
|
|
@@ -50,6 +55,41 @@ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
|
|
|
50
55
|
import { registerBuiltinCommands } from '../commands/builtins.js';
|
|
51
56
|
import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
|
|
52
57
|
import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
|
|
58
|
+
/**
|
|
59
|
+
* Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
|
|
60
|
+
* discard the rest of the form.
|
|
61
|
+
*
|
|
62
|
+
* The three outcomes are genuinely different and must stay distinguishable:
|
|
63
|
+
* `auto` is a setting, a clamped number is a setting the user should be told
|
|
64
|
+
* about, and nonsense is not a setting at all — it keeps the previous value and
|
|
65
|
+
* says so, rather than silently resolving to a default the user never chose.
|
|
66
|
+
*
|
|
67
|
+
* Exported for the tests; there is no other caller.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveSettingsMaxTokens(raw, previous) {
|
|
70
|
+
const parsed = parseMaxTokensInput(raw);
|
|
71
|
+
if (parsed.kind === 'auto') {
|
|
72
|
+
return {
|
|
73
|
+
applied: undefined,
|
|
74
|
+
persist: null,
|
|
75
|
+
toast: ['info', 'Max tokens: auto (per-model ceiling).'],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (parsed.kind === 'value') {
|
|
79
|
+
return {
|
|
80
|
+
applied: parsed.value,
|
|
81
|
+
persist: parsed.value,
|
|
82
|
+
...(parsed.clamped
|
|
83
|
+
? { toast: ['warn', `Max tokens clamped to ${parsed.value}.`] }
|
|
84
|
+
: {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
applied: previous,
|
|
89
|
+
persist: 'skip',
|
|
90
|
+
toast: ['warn', 'Max tokens must be a number or "auto".'],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
53
93
|
/** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
|
|
54
94
|
const COALESCE_MS = 33;
|
|
55
95
|
export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
|
|
@@ -109,6 +149,66 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
109
149
|
const pendingHuman = useRef(new Set());
|
|
110
150
|
/** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
|
|
111
151
|
const geometryRef = useRef(UNMEASURED_GEOMETRY);
|
|
152
|
+
// --- Follow-through (todo-plan-followthrough §3.4). ---------------------
|
|
153
|
+
//
|
|
154
|
+
// THREE REFS AND NO VIEW STATE, DELIBERATELY (§5.2). Every field added to
|
|
155
|
+
// `ViewState` is a field `restoreEntries`, `/clear`, `/reset` and the session
|
|
156
|
+
// round-trip each have to have an opinion about, and none of them has an
|
|
157
|
+
// opinion about a 3-second timer.
|
|
158
|
+
const budgetRef = useRef(emptyBudget());
|
|
159
|
+
const followTimer = useRef(null);
|
|
160
|
+
/** True while the run in flight was started by the grace timer, not the user. */
|
|
161
|
+
const autoContinuationRef = useRef(false);
|
|
162
|
+
/**
|
|
163
|
+
* WHY THE END REASON TRAVELS IN A REF THIS FEATURE OWNS (C-11 / D-17).
|
|
164
|
+
*
|
|
165
|
+
* `ViewState.aborted` / `.errorNoticed` are the DEFINITION of the end reason —
|
|
166
|
+
* `runEnd` reads exactly that pair for its own silent-failure guard — but the
|
|
167
|
+
* reducer is not a safe CHANNEL for it, because the answer is needed inside a
|
|
168
|
+
* controller callback rather than during a render.
|
|
169
|
+
*
|
|
170
|
+
* Reading `stateRef.current` there happens to work under Ink 5 only because
|
|
171
|
+
* Ink mounts a LEGACY root (`ink.js`: `createContainer(rootNode, 0 …)`), so a
|
|
172
|
+
* dispatch from a promise context flushes synchronously. Under a concurrent
|
|
173
|
+
* root — an Ink major bump, or its `experimental` renderer — React 18's
|
|
174
|
+
* automatic batching returns, and a provider stream error emitted as
|
|
175
|
+
* `message_update` immediately before `agent_end` (core throws in the loop and
|
|
176
|
+
* emits `agent_end` from `runLoopWithLifecycle`'s `finally`, microtasks apart)
|
|
177
|
+
* would still read `errorNoticed: false`. That auto-continues into a run that
|
|
178
|
+
* just failed authentication: silent, and billable.
|
|
179
|
+
*
|
|
180
|
+
* So this ref is mutated in the SAME SYNCHRONOUS CALLBACK that dispatches the
|
|
181
|
+
* corresponding action, and the reducer fields stay as the cross-check.
|
|
182
|
+
*/
|
|
183
|
+
const endReasonRef = useRef({
|
|
184
|
+
aborted: false,
|
|
185
|
+
errored: false,
|
|
186
|
+
});
|
|
187
|
+
/**
|
|
188
|
+
* Disarm an armed continuation. Idempotent; NEVER touches the budget — a
|
|
189
|
+
* cancelled continuation was never issued, so it must not be charged (AC-18).
|
|
190
|
+
*
|
|
191
|
+
* Three callers and there must be no fourth: `Esc` in the idle branch, the
|
|
192
|
+
* first line of `submitMessage`, and the subscription effect's cleanup.
|
|
193
|
+
*/
|
|
194
|
+
const cancelFollowThrough = useCallback(() => {
|
|
195
|
+
if (followTimer.current) {
|
|
196
|
+
clearTimeout(followTimer.current);
|
|
197
|
+
followTimer.current = null;
|
|
198
|
+
}
|
|
199
|
+
}, []);
|
|
200
|
+
/**
|
|
201
|
+
* `submitMessage` as of the LATEST render, for the grace timer.
|
|
202
|
+
*
|
|
203
|
+
* The subscription effect below never re-runs (its deps are stable by
|
|
204
|
+
* construction), so a direct call would close over the mount render's binding
|
|
205
|
+
* forever. That binding happens to be functionally correct today because
|
|
206
|
+
* `submitMessage` reads everything mutable through refs — which is exactly the
|
|
207
|
+
* kind of "happens to be correct" this feature has already had to unwind once
|
|
208
|
+
* (P1-1, P1-6). Assigned during render, the same discipline `stateRef` uses
|
|
209
|
+
* twenty lines up.
|
|
210
|
+
*/
|
|
211
|
+
const submitRef = useRef(() => { });
|
|
112
212
|
/**
|
|
113
213
|
* THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
|
|
114
214
|
* clears the overlay as its last act.
|
|
@@ -165,6 +265,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
165
265
|
const unsubscribe = controller.subscribe((event) => {
|
|
166
266
|
const cost = controller.getModelInfo().cost;
|
|
167
267
|
for (const action of reduceEvent(event, cost)) {
|
|
268
|
+
// MUTATED HERE, ONE INSTANT BEFORE THE ACTION THAT SETS
|
|
269
|
+
// `ViewState.errorNoticed`, rather than read back from the reducer one
|
|
270
|
+
// commit later (C-11). This is the whole of the batching-independence
|
|
271
|
+
// guarantee: `agent_end` can follow this action in the same tick with no
|
|
272
|
+
// render in between, and AC-40 asserts exactly that sequence.
|
|
273
|
+
if (action.type === 'notice' && action.level === 'error') {
|
|
274
|
+
endReasonRef.current.errored = true;
|
|
275
|
+
}
|
|
168
276
|
if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
|
|
169
277
|
pending.current.push(action);
|
|
170
278
|
if (!flushTimer.current) {
|
|
@@ -185,6 +293,69 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
185
293
|
// Without it, an aborted run leaves a plan card on screen whose `a` key
|
|
186
294
|
// silently flips the session to build, with no visible cause.
|
|
187
295
|
cancelPendingHuman();
|
|
296
|
+
// --- Follow-through (todo-plan-followthrough §3.4). ------------------
|
|
297
|
+
//
|
|
298
|
+
// Round 1 printed one notice here and stopped, under a good argument
|
|
299
|
+
// ("auto-continuation is an unbounded cost loop wearing a helpful hat").
|
|
300
|
+
// That judgement is not overturned: the loop is BOUNDED first, by two
|
|
301
|
+
// structural counters, and only then permitted — opt-in, and off by
|
|
302
|
+
// default, so `followThrough: 'notify'` reproduces the old notice byte for
|
|
303
|
+
// byte (AC-1).
|
|
304
|
+
const todos = controller.getTodoSnapshot();
|
|
305
|
+
budgetRef.current = advanceBudget(budgetRef.current, todos, autoContinuationRef.current);
|
|
306
|
+
autoContinuationRef.current = false;
|
|
307
|
+
const decision = decideFollowThrough({
|
|
308
|
+
// FROM THE CONTROLLER, NOT FROM `cfg` (P1-1). This effect's dependency
|
|
309
|
+
// list never changes, so it closes over the `cfg` object from the render
|
|
310
|
+
// in which it mounted — and `setTodoConfig` builds a NEW object, which
|
|
311
|
+
// that closure will never see. `cfg.todo.followThrough` would therefore
|
|
312
|
+
// be pinned to the launch value for the whole session and
|
|
313
|
+
// `/todo follow auto` would report success and do nothing. Note the
|
|
314
|
+
// asymmetry with `showStrip` further down, which is right to use `cfg`
|
|
315
|
+
// because it runs in render scope where `cfg` is re-read every frame.
|
|
316
|
+
mode: controller.getTodoConfig().followThrough,
|
|
317
|
+
snapshot: todos,
|
|
318
|
+
runEnd: { ...endReasonRef.current },
|
|
319
|
+
budget: budgetRef.current,
|
|
320
|
+
interactive: true,
|
|
321
|
+
});
|
|
322
|
+
endReasonRef.current = { aborted: false, errored: false };
|
|
323
|
+
if (decision.kind === 'notify') {
|
|
324
|
+
dispatch({ type: 'notice', level: decision.level, text: decision.text });
|
|
325
|
+
}
|
|
326
|
+
else if (decision.kind === 'continue') {
|
|
327
|
+
dispatch({ type: 'notice', level: 'info', text: decision.notice });
|
|
328
|
+
// NO LIVE COUNTDOWN (D-10). The notice is written once and does not
|
|
329
|
+
// tick: a 10 Hz re-render of the whole frame to animate a number is the
|
|
330
|
+
// repaint cost this package refuses elsewhere, and Ink's two dedupe
|
|
331
|
+
// gates mean the alternative is not free either.
|
|
332
|
+
cancelFollowThrough(); // I-3: at most one armed continuation, ever
|
|
333
|
+
followTimer.current = setTimeout(() => {
|
|
334
|
+
followTimer.current = null;
|
|
335
|
+
// TWO RE-CHECKS AT FIRE TIME, not only at decision time. The grace
|
|
336
|
+
// window is 3 s and the user can type in it (C-6): without the first,
|
|
337
|
+
// a fired continuation would either be swallowed into
|
|
338
|
+
// `controller.steer()` (the quiet failure) or reach
|
|
339
|
+
// `controller.prompt()`, which REJECTS while running.
|
|
340
|
+
if (controller.isRunning())
|
|
341
|
+
return;
|
|
342
|
+
const live = controller.getTodoSnapshot();
|
|
343
|
+
if (!live || live.doneCount >= live.total)
|
|
344
|
+
return; // the list moved
|
|
345
|
+
budgetRef.current = { ...budgetRef.current, used: budgetRef.current.used + 1 };
|
|
346
|
+
autoContinuationRef.current = true;
|
|
347
|
+
// COUNTS ONLY, NEVER ITEM TEXT (D-19), and only at the fire point —
|
|
348
|
+
// the quiet paths must stay quiet.
|
|
349
|
+
getLogger().debug('agent', 'todo_follow', {
|
|
350
|
+
used: budgetRef.current.used,
|
|
351
|
+
noProgressStreak: budgetRef.current.noProgressStreak,
|
|
352
|
+
total: live.total,
|
|
353
|
+
done: live.doneCount,
|
|
354
|
+
mode: 'auto',
|
|
355
|
+
});
|
|
356
|
+
submitRef.current(decision.message, { userInitiated: false });
|
|
357
|
+
}, decision.graceMs);
|
|
358
|
+
}
|
|
188
359
|
// A deferred `plan -> build` becomes real here and nowhere else (§3.2).
|
|
189
360
|
const applied = controller.applyPendingMode();
|
|
190
361
|
if (applied) {
|
|
@@ -198,9 +369,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
198
369
|
});
|
|
199
370
|
return () => {
|
|
200
371
|
flushPending();
|
|
372
|
+
// An armed continuation must not outlive the tree that armed it (AC-21):
|
|
373
|
+
// the timer would fire into an unmounted App and `submitRef.current` would
|
|
374
|
+
// prompt a controller nobody is rendering.
|
|
375
|
+
cancelFollowThrough();
|
|
201
376
|
unsubscribe();
|
|
202
377
|
};
|
|
203
|
-
}, [controller, flushPending, cancelPendingHuman]);
|
|
378
|
+
}, [controller, flushPending, cancelPendingHuman, cancelFollowThrough]);
|
|
204
379
|
// --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
|
|
205
380
|
//
|
|
206
381
|
// A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
|
|
@@ -257,6 +432,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
257
432
|
controller.dispose();
|
|
258
433
|
};
|
|
259
434
|
}, [controller]);
|
|
435
|
+
// --- Todo event stream (todo-plan-execution §3.8 / §5.2). --------------
|
|
436
|
+
//
|
|
437
|
+
// A THIRD SUBSCRIPTION, for the reason the team one records: `TodoEvent` is
|
|
438
|
+
// deliberately not a member of core's `AgentEvent` union (D-11), so it cannot
|
|
439
|
+
// arrive through `controller.subscribe`.
|
|
440
|
+
//
|
|
441
|
+
// NOT ROUTED THROUGH THE STREAMING COALESCER either. That buffer exists to
|
|
442
|
+
// merge text/thinking deltas; a `todo_write` is one deliberate act per step,
|
|
443
|
+
// and delaying the one surface the user is watching a long task through would
|
|
444
|
+
// be exactly backwards.
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
return controller.subscribeTodos((event) => {
|
|
447
|
+
switch (event.type) {
|
|
448
|
+
case 'updated':
|
|
449
|
+
dispatch({ type: 'todoUpdate', snapshot: event.snapshot });
|
|
450
|
+
break;
|
|
451
|
+
case 'cleared':
|
|
452
|
+
dispatch({ type: 'todoCleared' });
|
|
453
|
+
break;
|
|
454
|
+
case 'rejected':
|
|
455
|
+
// THE COMPENSATING PATH FOR SUPPRESSING THE TOOL CARD (§3.8). Without
|
|
456
|
+
// it, the one case where `todo_write` produces nothing visible would
|
|
457
|
+
// be the case that most needs to be visible.
|
|
458
|
+
dispatch({ type: 'notice', level: 'warn', text: event.reason });
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}, [controller]);
|
|
260
463
|
// --- Elapsed timer + tokens/sec baseline while running. ----------------
|
|
261
464
|
useEffect(() => {
|
|
262
465
|
if (state.status !== 'running') {
|
|
@@ -460,9 +663,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
460
663
|
toast,
|
|
461
664
|
persistConfig,
|
|
462
665
|
exit: doExit,
|
|
463
|
-
submit: (text) => submitMessage(text),
|
|
666
|
+
submit: (text, opts) => submitMessage(text, opts),
|
|
464
667
|
refreshSkills: () => setSkillsNonce((n) => n + 1),
|
|
465
668
|
applyAgentMode: applyMode,
|
|
669
|
+
// Read at command time, so `/todo status` reports the live count rather than
|
|
670
|
+
// whatever it was when this context was built.
|
|
671
|
+
followBudget: budgetRef.current,
|
|
466
672
|
});
|
|
467
673
|
/**
|
|
468
674
|
* Submitting a prompt no longer touches `config.json` at all — it appends one
|
|
@@ -492,13 +698,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
492
698
|
* `handleSubmit` would re-parse it as input and, for a body that happens to
|
|
493
699
|
* start with `/`, recurse into command dispatch.
|
|
494
700
|
*/
|
|
495
|
-
const submitMessage = (message) => {
|
|
701
|
+
const submitMessage = (message, opts = {}) => {
|
|
702
|
+
// A USER MESSAGE IS A NEW INTENT AND OUTRANKS A QUEUED ONE (AC-19). First
|
|
703
|
+
// line, before the steering branch: a message typed during the grace window
|
|
704
|
+
// must be the one that runs, whichever branch it takes.
|
|
705
|
+
cancelFollowThrough();
|
|
496
706
|
if (stateRef.current.status === 'running') {
|
|
497
707
|
controller.steer(message);
|
|
498
708
|
toast('info', 'Steering queued.');
|
|
499
709
|
return;
|
|
500
710
|
}
|
|
501
|
-
|
|
711
|
+
// `userInitiated: false` skips TWO things and both are wanted: the
|
|
712
|
+
// prompt-history append, and the `submitCount` bump that drives the
|
|
713
|
+
// composer's hint fade. An auto-continuation demonstrates nothing about what
|
|
714
|
+
// the user has learned, so suppressing the fade is correct rather than
|
|
715
|
+
// incidental — which is why the parameter is named for the CAUSE (P2-8).
|
|
716
|
+
if (opts.userInitiated !== false)
|
|
717
|
+
recordPrompt(message);
|
|
718
|
+
// Mirrors where the reducer clears its own copies, in the same action
|
|
719
|
+
// (C-11): a new turn's end reason must not inherit the previous one's.
|
|
720
|
+
endReasonRef.current = { aborted: false, errored: false };
|
|
502
721
|
dispatch({ type: 'submit', text: message });
|
|
503
722
|
const pre = controller.preflight();
|
|
504
723
|
if (!pre.ok) {
|
|
@@ -509,6 +728,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
509
728
|
// Fire-and-forget: events drive the UI. prompt() never rejects.
|
|
510
729
|
void controller.prompt(message);
|
|
511
730
|
};
|
|
731
|
+
submitRef.current = submitMessage;
|
|
512
732
|
const handleSubmit = async (raw) => {
|
|
513
733
|
// Submitting is an unconditional "take me to the newest output" (§4.5).
|
|
514
734
|
setPinToBottomNonce((n) => n + 1);
|
|
@@ -518,10 +738,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
518
738
|
submitMessage(raw.startsWith('//') ? raw.slice(1) : raw);
|
|
519
739
|
};
|
|
520
740
|
const handleSettingsSave = (values) => {
|
|
521
|
-
|
|
741
|
+
// An unusable cap must NOT discard the rest of the form: the same Enter
|
|
742
|
+
// press is often carrying a freshly pasted API key, and losing that to a
|
|
743
|
+
// typo in an unrelated field is the worst outcome available here.
|
|
744
|
+
const cap = resolveSettingsMaxTokens(values.maxTokens, controller.getConfig().maxTokens);
|
|
522
745
|
controller.setModel(values.provider, values.model, values.baseUrl || undefined);
|
|
523
746
|
controller.setThinkingLevel(values.thinkingLevel);
|
|
524
|
-
controller.setMaxTokens(
|
|
747
|
+
controller.setMaxTokens(cap.applied);
|
|
525
748
|
const key = values.apiKey.trim();
|
|
526
749
|
if (key.length > 0) {
|
|
527
750
|
// Registration site 4 (§4.4.3), and the one that matters most: this is how
|
|
@@ -536,7 +759,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
536
759
|
model: values.model,
|
|
537
760
|
baseUrl: values.baseUrl.trim() ? values.baseUrl.trim() : null,
|
|
538
761
|
thinkingLevel: values.thinkingLevel,
|
|
539
|
-
|
|
762
|
+
// Omitted entirely when the field was unusable, so the previous value
|
|
763
|
+
// survives instead of being overwritten with a guess.
|
|
764
|
+
...(cap.persist !== 'skip' ? { maxTokens: cap.persist } : {}),
|
|
540
765
|
};
|
|
541
766
|
if (key.length > 0)
|
|
542
767
|
patch.apiKeys = { [values.provider]: key };
|
|
@@ -547,7 +772,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
547
772
|
persistConfig(patch);
|
|
548
773
|
getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
|
|
549
774
|
dispatch({ type: 'setOverlay', overlay: null });
|
|
550
|
-
toast('success', 'Settings saved.');
|
|
775
|
+
toast(cap.toast?.[0] ?? 'success', cap.toast?.[1] ?? 'Settings saved.');
|
|
551
776
|
};
|
|
552
777
|
const handleModelSelect = (provider, model) => {
|
|
553
778
|
controller.setModel(provider, model);
|
|
@@ -742,8 +967,24 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
742
967
|
}
|
|
743
968
|
if (stateRef.current.status === 'running') {
|
|
744
969
|
controller.abort();
|
|
970
|
+
// Next to `abortMark`, in the same callback, for the reason
|
|
971
|
+
// `endReasonRef`'s own comment gives (C-11). D-5 turns this into
|
|
972
|
+
// silence at `agent_end`: the user's Esc is the statement, and
|
|
973
|
+
// restating what they interrupted is the CLI arguing.
|
|
974
|
+
endReasonRef.current.aborted = true;
|
|
745
975
|
dispatch({ type: 'abortMark' });
|
|
746
976
|
toast('info', 'Run aborted.');
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
// IDLE. Today this branch does nothing at all, which is what makes it free
|
|
980
|
+
// to take (R-4). OVERLAY PRECEDENCE IS UNCHANGED AND DELIBERATE (P2-9):
|
|
981
|
+
// the block above returns while an overlay is open, so with one open the
|
|
982
|
+
// first Esc closes the overlay and the second cancels the continuation.
|
|
983
|
+
// Reaching past it would make "close this dialog" silently also mean
|
|
984
|
+
// "abandon the plan".
|
|
985
|
+
if (followTimer.current) {
|
|
986
|
+
cancelFollowThrough();
|
|
987
|
+
toast('info', 'Auto-continue cancelled.');
|
|
747
988
|
}
|
|
748
989
|
return;
|
|
749
990
|
}
|
|
@@ -762,12 +1003,48 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
762
1003
|
// tells `OverlayFrame` to render everything and show no position indicator.
|
|
763
1004
|
const viewportBudget = computeViewportRows(rows);
|
|
764
1005
|
const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
|
|
1006
|
+
// --- The todo rail (todo-plan-execution §3.9). --------------------------
|
|
1007
|
+
//
|
|
1008
|
+
// ONE NUMBER FOR THE GATE AND FOR THE LAYOUT (I-10). `railRows` is both what
|
|
1009
|
+
// decides whether the panel mounts and what the panel lays out against; a
|
|
1010
|
+
// panel that mounts on one number and renders against another shows nothing
|
|
1011
|
+
// and reports nothing. `todoRailRows` is NOT `viewportBudget`: the live team
|
|
1012
|
+
// roster costs up to 8 rows inside `AppShell`'s measured bottom box that the
|
|
1013
|
+
// budget knows nothing about (P1-3).
|
|
1014
|
+
const railRows = todoRailRows(viewportBudget, state.team !== null);
|
|
1015
|
+
const railWidth = todoRailWidth(cols);
|
|
1016
|
+
const showRail = fullscreen && // non-goal 4: inline mode has no fixed frame
|
|
1017
|
+
cfg.todo.panel && // `/todo panel off`, persisted
|
|
1018
|
+
overlay === null && // a modal owns the screen (D-7)
|
|
1019
|
+
state.todos !== null && // no list -> no furniture, zero layout cost
|
|
1020
|
+
railWidth > 0 && // too narrow (under 80 columns)
|
|
1021
|
+
railRows >= TODO_LIMITS.panelMinRows; // too short to say anything useful
|
|
1022
|
+
// The inline plan strip (todo-plan-followthrough §3.7 / W2).
|
|
1023
|
+
//
|
|
1024
|
+
// DELIBERATELY NOT `!showRail`, which is true for FOUR different reasons and
|
|
1025
|
+
// three of them mean "do not draw this". `cfg.todo.panel` is included because
|
|
1026
|
+
// `--no-todo-panel` means "keep the planning, drop the display" and a strip is
|
|
1027
|
+
// a display. Overlays are NOT consulted: inline mode's overlays are part of
|
|
1028
|
+
// the document flow, not a modal that owns the screen.
|
|
1029
|
+
//
|
|
1030
|
+
// `cfg` is the right read here and `controller.getTodoConfig()` would be
|
|
1031
|
+
// needless — this runs in RENDER scope, where `cfg` is re-read every frame.
|
|
1032
|
+
// The asymmetry with the `agent_end` handler above is the whole of P1-1.
|
|
1033
|
+
const showStrip = !fullscreen && cfg.todo.panel && state.todos !== null;
|
|
1034
|
+
// Every consumer INSIDE the viewport slot switches to this; the header,
|
|
1035
|
+
// composer, status bar, toasts and every overlay keep the full frame width
|
|
1036
|
+
// (C-7). Overlays keep it because `showRail` already includes `overlay ===
|
|
1037
|
+
// null`: splitting the screen with a status panel behind a modal is both worse
|
|
1038
|
+
// design and a second source of width-contract bugs in six components.
|
|
1039
|
+
const contentCols = cols - (showRail ? railWidth : 0);
|
|
765
1040
|
const overlayNode = overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { initial: {
|
|
766
1041
|
provider: cfg.provider,
|
|
767
1042
|
model: cfg.model,
|
|
768
1043
|
baseUrl: cfg.baseUrl ?? '',
|
|
769
1044
|
thinkingLevel: cfg.thinkingLevel,
|
|
770
|
-
|
|
1045
|
+
// `auto`, not an empty string: an empty field reads as "unset", which
|
|
1046
|
+
// is indistinguishable from AUTO and is not a state this can be in.
|
|
1047
|
+
maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
|
|
771
1048
|
apiKey: '',
|
|
772
1049
|
logLevel: cfg.log.level,
|
|
773
1050
|
}, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { 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, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
|
|
@@ -792,15 +1069,15 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
792
1069
|
// The wordmark + getting-started card now live INSIDE the viewport, as its
|
|
793
1070
|
// first block of content, so they scroll away instead of being yanked out of
|
|
794
1071
|
// the header the moment the first message lands.
|
|
795
|
-
const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget,
|
|
1072
|
+
const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, contentCols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
|
|
796
1073
|
const density = cfg.density;
|
|
797
|
-
const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
|
|
1074
|
+
const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
|
|
798
1075
|
// The completion popup lives inside the bottom chrome and can add up to 9
|
|
799
1076
|
// rows; unbounded, it pushes the status bar past the frame height where
|
|
800
1077
|
// `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
|
|
801
1078
|
const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
|
|
802
1079
|
const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, historyIntent: historyIntent, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
|
|
803
|
-
return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ...(state.team
|
|
1080
|
+
return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: state.status === 'running', reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ...(state.team
|
|
804
1081
|
? {
|
|
805
1082
|
teamActive: {
|
|
806
1083
|
running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
|
|
@@ -808,5 +1085,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
808
1085
|
total: state.team.runs.length,
|
|
809
1086
|
},
|
|
810
1087
|
}
|
|
1088
|
+
: {}), ...(state.todos && !showRail
|
|
1089
|
+
? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
|
|
811
1090
|
: {}) }) }));
|
|
812
1091
|
}
|
package/dist/ui/StatusBar.d.ts
CHANGED
|
@@ -70,6 +70,24 @@ interface StatusBarProps {
|
|
|
70
70
|
running: number;
|
|
71
71
|
total: number;
|
|
72
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Live todo counts (todo-plan-execution §6.3), or absent.
|
|
75
|
+
*
|
|
76
|
+
* `App` sends this ONLY when a list exists AND the rail does not: with the
|
|
77
|
+
* rail on screen the numbers are already there in a larger typeface, and
|
|
78
|
+
* duplicating them costs the context gauge columns it cannot spare. This is
|
|
79
|
+
* therefore the INLINE-MODE and NARROW-TERMINAL fallback for R-d, which is
|
|
80
|
+
* exactly why it is here rather than being folded into the rail's own gate.
|
|
81
|
+
*
|
|
82
|
+
* Degrades below `TODO_LIMITS.statusCompactCols` from `todo 2/7` to `[2/7]`,
|
|
83
|
+
* the same treatment and the same justification as the team counter's
|
|
84
|
+
* `agents 3/5` -> `[3]`. See the note on `agentMode` for why that rule does
|
|
85
|
+
* NOT apply to the mode word.
|
|
86
|
+
*/
|
|
87
|
+
todoActive?: {
|
|
88
|
+
done: number;
|
|
89
|
+
total: number;
|
|
90
|
+
};
|
|
73
91
|
}
|
|
74
92
|
export declare function StatusBar(props: StatusBarProps): React.ReactElement;
|
|
75
93
|
export {};
|
package/dist/ui/StatusBar.js
CHANGED
|
@@ -5,6 +5,7 @@ import { formatCost, formatTokens, formatDuration } from '../agent/usage.js';
|
|
|
5
5
|
import { buildGauge } from './gauge.js';
|
|
6
6
|
import { MODE_LABEL } from '../agent/agent-mode.js';
|
|
7
7
|
import { TEAM_LIMITS } from '../team/limits.js';
|
|
8
|
+
import { TODO_LIMITS } from '../todo/limits.js';
|
|
8
9
|
/**
|
|
9
10
|
* I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
|
|
10
11
|
* gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
|
|
@@ -24,7 +25,7 @@ function redrawChar(nonce) {
|
|
|
24
25
|
return nonce % 2 === 0 ? ' ' : NBSP;
|
|
25
26
|
}
|
|
26
27
|
export function StatusBar(props) {
|
|
27
|
-
const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, } = props;
|
|
28
|
+
const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, } = props;
|
|
28
29
|
const { stdout } = useStdout();
|
|
29
30
|
const cols = stdout?.columns ?? 80;
|
|
30
31
|
const running = status === 'running';
|
|
@@ -50,5 +51,7 @@ export function StatusBar(props) {
|
|
|
50
51
|
// land in one predictable place.
|
|
51
52
|
_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), teamActive && (_jsxs(Text, { color: theme.toolRunning, bold: true, children: [' ', cols >= TEAM_LIMITS.statusCompactCols
|
|
52
53
|
? `agents ${teamActive.running}/${teamActive.total}`
|
|
53
|
-
: `[${teamActive.running}]`] })),
|
|
54
|
+
: `[${teamActive.running}]`] })), todoActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= TODO_LIMITS.statusCompactCols
|
|
55
|
+
? `todo ${todoActive.done}/${todoActive.total}`
|
|
56
|
+
: `[${todoActive.done}/${todoActive.total}]`] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
|
|
54
57
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TodoPanel — the right rail (todo-plan-execution §6.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
|
|
5
|
+
* capability probe and shows mojibake on a legacy console (`glyphs.ts` header).
|
|
6
|
+
*
|
|
7
|
+
* MOUNTED ONLY WHEN A LIST EXISTS. `App` passes `null` the rest of the time and
|
|
8
|
+
* this component is not rendered at all, so a session that never plans has zero
|
|
9
|
+
* columns and zero layout shift (R-g's "no furniture when idle" / AC-24).
|
|
10
|
+
*
|
|
11
|
+
* IT MUST BE MOUNTED IN `AppShell`'s MIDDLE BAND, NEVER INSIDE `bottomRef`
|
|
12
|
+
* (C-6 / I-4). That box is MEASURED and the wheel router reads the composer band
|
|
13
|
+
* boundary from it; a column added there would inflate the measurement and start
|
|
14
|
+
* routing transcript scrolls into prompt history.
|
|
15
|
+
*
|
|
16
|
+
* WIDTH IS MEASURED IN COLUMNS, NEVER IN CODE UNITS (P2-3). This rail is 18-36
|
|
17
|
+
* columns wide and the system prompt's last line is "Respond in the language the
|
|
18
|
+
* user writes in", so CJK item text in an 18-column rail is the NORMAL case
|
|
19
|
+
* rather than the edge case. Every text cell is `wrap="truncate"` and Ink's
|
|
20
|
+
* truncation is display-width aware, so nothing can overflow the box — and NO
|
|
21
|
+
* ARITHMETIC HERE MAY BUDGET USER TEXT BY `.length`. The `padEnd(3)` /
|
|
22
|
+
* `padStart(2)` cells below are ASCII markers and indices, which is why they are
|
|
23
|
+
* safe; a "characters that fit" calculation over `content` would not be. That is
|
|
24
|
+
* the whole of the lesson `TeamPanel`'s `WIDE_CHAR_ALLOWANCE` records at length,
|
|
25
|
+
* and the mitigation here is to have no such budget at all.
|
|
26
|
+
*/
|
|
27
|
+
import React from 'react';
|
|
28
|
+
import type { Theme } from './theme.js';
|
|
29
|
+
import type { TermCapabilities } from './capabilities.js';
|
|
30
|
+
import type { TodoSnapshot } from '../todo/types.js';
|
|
31
|
+
export interface TodoPanelProps {
|
|
32
|
+
snapshot: TodoSnapshot;
|
|
33
|
+
/** From `todoRailWidth(cols)`. The rail's whole column budget, separator included. */
|
|
34
|
+
width: number;
|
|
35
|
+
/**
|
|
36
|
+
* From `todoRailRows(viewportBudget, teamActive)` — NOT `viewportBudget`
|
|
37
|
+
* (I-10 / P1-3). The gate that mounts this component and the number it lays
|
|
38
|
+
* out against are ONE value computed once in `App`: a panel that mounts on one
|
|
39
|
+
* number and lays out against another renders nothing and reports nothing.
|
|
40
|
+
*/
|
|
41
|
+
rows: number;
|
|
42
|
+
/** Whether the agent is running; gates the spinner (never animate when idle). */
|
|
43
|
+
running: boolean;
|
|
44
|
+
reducedMotion: boolean;
|
|
45
|
+
theme: Theme;
|
|
46
|
+
caps: TermCapabilities;
|
|
47
|
+
}
|
|
48
|
+
export declare function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }: TodoPanelProps): React.ReactElement;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { pickGlyphs } from './glyphs.js';
|
|
5
|
+
import { buildGauge } from './gauge.js';
|
|
6
|
+
import { railBorderProps } from './layout/Gutter.js';
|
|
7
|
+
import { TODO_RAIL_INDEX_MIN_COLS } from './layout/rail.js';
|
|
8
|
+
import { TODO_LIMITS } from '../todo/limits.js';
|
|
9
|
+
import { selectTodoRows } from '../todo/panel-rows.js';
|
|
10
|
+
/** Header row + the blank row under the gauge. */
|
|
11
|
+
const FIXED_ROWS = 2;
|
|
12
|
+
/** The gauge is the one purely decorative element, so it is the first to go. */
|
|
13
|
+
const GAUGE_MIN_ROWS = 8;
|
|
14
|
+
const GAUGE_MIN_COLS = 20;
|
|
15
|
+
/** Separator column + one space of padding, supplied by `railBorderProps`. */
|
|
16
|
+
const CHROME_COLS = 2;
|
|
17
|
+
/** Marker cell. Three, because the ASCII tier's `[x]` / `[ ]` are three wide. */
|
|
18
|
+
const MARKER_COLS = 3;
|
|
19
|
+
/** Index cell: two digits plus the space that separates it from the text. */
|
|
20
|
+
const INDEX_COLS = 3;
|
|
21
|
+
function marker(item, glyphs) {
|
|
22
|
+
if (item.status === 'completed')
|
|
23
|
+
return glyphs.todoDone;
|
|
24
|
+
if (item.status === 'in_progress')
|
|
25
|
+
return glyphs.todoActive;
|
|
26
|
+
return glyphs.todoPending;
|
|
27
|
+
}
|
|
28
|
+
function rowColor(item, theme) {
|
|
29
|
+
if (item.status === 'completed')
|
|
30
|
+
return theme.muted;
|
|
31
|
+
if (item.status === 'in_progress')
|
|
32
|
+
return theme.primary;
|
|
33
|
+
return theme.hintFg ?? theme.muted;
|
|
34
|
+
}
|
|
35
|
+
export function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }) {
|
|
36
|
+
const glyphs = pickGlyphs(caps);
|
|
37
|
+
const showGauge = width >= GAUGE_MIN_COLS && rows >= GAUGE_MIN_ROWS;
|
|
38
|
+
const showIndex = width >= TODO_RAIL_INDEX_MIN_COLS;
|
|
39
|
+
const itemRows = Math.max(1, rows - FIXED_ROWS - (showGauge ? 1 : 0));
|
|
40
|
+
const { visible, hiddenAbove, hiddenBelow } = selectTodoRows(snapshot.items, itemRows);
|
|
41
|
+
const pct = snapshot.total > 0 ? Math.round((snapshot.doneCount / snapshot.total) * 100) : 0;
|
|
42
|
+
const gauge = buildGauge(pct, Math.max(4, width - CHROME_COLS - 2), theme, caps);
|
|
43
|
+
const complete = snapshot.doneCount === snapshot.total;
|
|
44
|
+
return (
|
|
45
|
+
// `flexShrink={0}` IS LOAD-BEARING (P1-4). Ink's `Box.defaultProps` is
|
|
46
|
+
// `{ flexWrap:'nowrap', flexDirection:'row', flexGrow:0, flexShrink:1 }`, so
|
|
47
|
+
// a `width={n}` box inside a shrinking row is SQUEEZED under pressure — and
|
|
48
|
+
// every piece of arithmetic in `rail.ts` silently becomes a suggestion at
|
|
49
|
+
// exactly the narrow widths where the guarantee matters. `ScrollIndicator`
|
|
50
|
+
// spells the same pair one level down. Cross-axis height needs no prop:
|
|
51
|
+
// `alignItems` is unset in this tree and yoga's default is `stretch`.
|
|
52
|
+
_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, overflow: "hidden", paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: "TODO" }), _jsxs(Text, { wrap: "truncate", color: complete ? theme.toolDone : theme.muted, children: [snapshot.doneCount, "/", snapshot.total, complete ? ' done' : ''] })] }), showGauge && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty })] })), _jsx(Text, { children: " " }), hiddenAbove > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenAbove, " above"] })), visible.map(({ item, index }) => {
|
|
53
|
+
const active = item.status === 'in_progress';
|
|
54
|
+
// The spinner is gated on `running`: a spinner while the agent is idle
|
|
55
|
+
// is a lie about the state of the world, and this panel outlives the run
|
|
56
|
+
// that filled it. Reduced motion and a non-Unicode terminal fall back to
|
|
57
|
+
// the same static marker, exactly as `AssistantEntry` and `TeamPanel` do.
|
|
58
|
+
const animate = active && running && !reducedMotion && caps.unicode;
|
|
59
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: MARKER_COLS, children: _jsx(Text, { color: rowColor(item, theme), bold: active, children: animate ? _jsx(Spinner, { type: "dots" }) : marker(item, glyphs) }) }), showIndex && (_jsx(Box, { flexShrink: 0, width: INDEX_COLS, children: _jsx(Text, { color: theme.muted, children: String(index + 1).padStart(2) }) })), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: active ? 'wrap' : 'truncate', color: rowColor(item, theme), bold: active, children: active ? clampWrapped(item.activeForm, width) : item.content }) })] }, `${index}-${item.content}`));
|
|
60
|
+
}), hiddenBelow > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenBelow, " more"] }))] }));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Keep the wrapping row inside `TODO_LIMITS.activeWrapRows`.
|
|
64
|
+
*
|
|
65
|
+
* A CHARACTER BUDGET IS NOT A COLUMN BUDGET (P2-3), so this is deliberately
|
|
66
|
+
* generous rather than exact: it exists to stop a maximum-length `activeForm`
|
|
67
|
+
* from claiming five rows of an 18-column rail, and Ink's display-width-aware
|
|
68
|
+
* truncation is what actually keeps the row inside the box. Erring long costs
|
|
69
|
+
* nothing — the surrounding `overflow="hidden"` clips it.
|
|
70
|
+
*/
|
|
71
|
+
function clampWrapped(text, width) {
|
|
72
|
+
const budget = Math.max(1, width - CHROME_COLS) * TODO_LIMITS.activeWrapRows;
|
|
73
|
+
return text.length > budget ? text.slice(0, budget) : text;
|
|
74
|
+
}
|