@aragon-agent/cli 0.5.4 → 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 -577
- package/README.md +1105 -995
- 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 +40 -1
- package/dist/commands/builtins.js +166 -0
- package/dist/commands/registry.d.ts +27 -1
- package/dist/config/env.js +43 -0
- package/dist/config/load.d.ts +27 -0
- package/dist/config/load.js +25 -1
- package/dist/config/schema.d.ts +47 -0
- package/dist/config/schema.js +38 -0
- package/dist/config/store.js +10 -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.js +244 -7
- 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 +4 -0
- package/package.json +3 -2
package/dist/ui/App.js
CHANGED
|
@@ -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';
|
|
@@ -144,6 +149,66 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
144
149
|
const pendingHuman = useRef(new Set());
|
|
145
150
|
/** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
|
|
146
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(() => { });
|
|
147
212
|
/**
|
|
148
213
|
* THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
|
|
149
214
|
* clears the overlay as its last act.
|
|
@@ -200,6 +265,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
200
265
|
const unsubscribe = controller.subscribe((event) => {
|
|
201
266
|
const cost = controller.getModelInfo().cost;
|
|
202
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
|
+
}
|
|
203
276
|
if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
|
|
204
277
|
pending.current.push(action);
|
|
205
278
|
if (!flushTimer.current) {
|
|
@@ -220,6 +293,69 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
220
293
|
// Without it, an aborted run leaves a plan card on screen whose `a` key
|
|
221
294
|
// silently flips the session to build, with no visible cause.
|
|
222
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
|
+
}
|
|
223
359
|
// A deferred `plan -> build` becomes real here and nowhere else (§3.2).
|
|
224
360
|
const applied = controller.applyPendingMode();
|
|
225
361
|
if (applied) {
|
|
@@ -233,9 +369,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
233
369
|
});
|
|
234
370
|
return () => {
|
|
235
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();
|
|
236
376
|
unsubscribe();
|
|
237
377
|
};
|
|
238
|
-
}, [controller, flushPending, cancelPendingHuman]);
|
|
378
|
+
}, [controller, flushPending, cancelPendingHuman, cancelFollowThrough]);
|
|
239
379
|
// --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
|
|
240
380
|
//
|
|
241
381
|
// A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
|
|
@@ -292,6 +432,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
292
432
|
controller.dispose();
|
|
293
433
|
};
|
|
294
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]);
|
|
295
463
|
// --- Elapsed timer + tokens/sec baseline while running. ----------------
|
|
296
464
|
useEffect(() => {
|
|
297
465
|
if (state.status !== 'running') {
|
|
@@ -495,9 +663,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
495
663
|
toast,
|
|
496
664
|
persistConfig,
|
|
497
665
|
exit: doExit,
|
|
498
|
-
submit: (text) => submitMessage(text),
|
|
666
|
+
submit: (text, opts) => submitMessage(text, opts),
|
|
499
667
|
refreshSkills: () => setSkillsNonce((n) => n + 1),
|
|
500
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,
|
|
501
672
|
});
|
|
502
673
|
/**
|
|
503
674
|
* Submitting a prompt no longer touches `config.json` at all — it appends one
|
|
@@ -527,13 +698,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
527
698
|
* `handleSubmit` would re-parse it as input and, for a body that happens to
|
|
528
699
|
* start with `/`, recurse into command dispatch.
|
|
529
700
|
*/
|
|
530
|
-
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();
|
|
531
706
|
if (stateRef.current.status === 'running') {
|
|
532
707
|
controller.steer(message);
|
|
533
708
|
toast('info', 'Steering queued.');
|
|
534
709
|
return;
|
|
535
710
|
}
|
|
536
|
-
|
|
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 };
|
|
537
721
|
dispatch({ type: 'submit', text: message });
|
|
538
722
|
const pre = controller.preflight();
|
|
539
723
|
if (!pre.ok) {
|
|
@@ -544,6 +728,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
544
728
|
// Fire-and-forget: events drive the UI. prompt() never rejects.
|
|
545
729
|
void controller.prompt(message);
|
|
546
730
|
};
|
|
731
|
+
submitRef.current = submitMessage;
|
|
547
732
|
const handleSubmit = async (raw) => {
|
|
548
733
|
// Submitting is an unconditional "take me to the newest output" (§4.5).
|
|
549
734
|
setPinToBottomNonce((n) => n + 1);
|
|
@@ -782,8 +967,24 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
782
967
|
}
|
|
783
968
|
if (stateRef.current.status === 'running') {
|
|
784
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;
|
|
785
975
|
dispatch({ type: 'abortMark' });
|
|
786
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.');
|
|
787
988
|
}
|
|
788
989
|
return;
|
|
789
990
|
}
|
|
@@ -802,6 +1003,40 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
802
1003
|
// tells `OverlayFrame` to render everything and show no position indicator.
|
|
803
1004
|
const viewportBudget = computeViewportRows(rows);
|
|
804
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);
|
|
805
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: {
|
|
806
1041
|
provider: cfg.provider,
|
|
807
1042
|
model: cfg.model,
|
|
@@ -834,15 +1069,15 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
834
1069
|
// The wordmark + getting-started card now live INSIDE the viewport, as its
|
|
835
1070
|
// first block of content, so they scroll away instead of being yanked out of
|
|
836
1071
|
// the header the moment the first message lands.
|
|
837
|
-
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;
|
|
838
1073
|
const density = cfg.density;
|
|
839
|
-
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] }));
|
|
840
1075
|
// The completion popup lives inside the bottom chrome and can add up to 9
|
|
841
1076
|
// rows; unbounded, it pushes the status bar past the frame height where
|
|
842
1077
|
// `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
|
|
843
1078
|
const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
|
|
844
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' }) }) }));
|
|
845
|
-
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
|
|
846
1081
|
? {
|
|
847
1082
|
teamActive: {
|
|
848
1083
|
running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
|
|
@@ -850,5 +1085,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
850
1085
|
total: state.team.runs.length,
|
|
851
1086
|
},
|
|
852
1087
|
}
|
|
1088
|
+
: {}), ...(state.todos && !showRail
|
|
1089
|
+
? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
|
|
853
1090
|
: {}) }) }));
|
|
854
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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TodoStrip — the inline-mode plan surface (todo-plan-followthrough §3.7 / W2).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
|
|
5
|
+
* capability probe and shows mojibake on a legacy console. This file is inside
|
|
6
|
+
* the glyph scanner's scope because it is under `ui/`, NOT because of the
|
|
7
|
+
* `todo/` clause (C-4) — moving it would not move the guard rail with it.
|
|
8
|
+
*
|
|
9
|
+
* WHY A STRIP AND NOT THE RAIL. `decideRenderMode` picks inline for every pipe,
|
|
10
|
+
* every `CI=1`, every `TERM=dumb` and every terminal under 12 rows, and inline
|
|
11
|
+
* has no fixed frame to hang a column off (round 1's non-goal 4). Before this,
|
|
12
|
+
* such a session had the tool and a `todo 3/7` counter in the status bar, and no
|
|
13
|
+
* way at all to see WHICH step was running (OQ-1).
|
|
14
|
+
*
|
|
15
|
+
* WHY NOT IN THE STATUS BAR. The bar's left cluster is `flexShrink={0}` and its
|
|
16
|
+
* own comment records what happens when the row over-subscribes: Ink drops a
|
|
17
|
+
* character from EACH cluster and it renders `idl` / `? hel`. A variable-length
|
|
18
|
+
* step title cannot go there (C-7), which is what forces this into its own row.
|
|
19
|
+
*
|
|
20
|
+
* EXACTLY ONE RENDERED ROW, ALWAYS (AC-29). A strip that wrapped would push the
|
|
21
|
+
* composer down by an unpredictable number of rows on every keystroke, which is
|
|
22
|
+
* the worst possible behaviour for the mode with no frame to absorb it — hence
|
|
23
|
+
* `wrap="truncate"` inside a `flexGrow`/`flexShrink` box and no exceptions.
|
|
24
|
+
*/
|
|
25
|
+
import React from 'react';
|
|
26
|
+
import type { Theme } from './theme.js';
|
|
27
|
+
import type { TermCapabilities } from './capabilities.js';
|
|
28
|
+
import type { TodoSnapshot } from '../todo/types.js';
|
|
29
|
+
export interface TodoStripProps {
|
|
30
|
+
snapshot: TodoSnapshot;
|
|
31
|
+
/** Full terminal width; the strip spans the row in inline mode. */
|
|
32
|
+
cols: number;
|
|
33
|
+
theme: Theme;
|
|
34
|
+
caps: TermCapabilities;
|
|
35
|
+
}
|
|
36
|
+
export declare function TodoStrip({ snapshot, cols, theme, caps }: TodoStripProps): React.ReactElement;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { pickGlyphs } from './glyphs.js';
|
|
4
|
+
import { TODO_LIMITS } from '../todo/limits.js';
|
|
5
|
+
import { todoAnchorIndex } from '../todo/panel-rows.js';
|
|
6
|
+
export function TodoStrip({ snapshot, cols, theme, caps }) {
|
|
7
|
+
const glyphs = pickGlyphs(caps);
|
|
8
|
+
// THE SAME FUNCTION THE RAIL USES (R-9 / AC-30). Two surfaces that each decide
|
|
9
|
+
// for themselves which step is "current" is two surfaces that disagree, and
|
|
10
|
+
// this one is on screen in exactly the mode where the rail is not, so the
|
|
11
|
+
// disagreement would never be seen side by side and never be reported.
|
|
12
|
+
const anchor = todoAnchorIndex(snapshot.items);
|
|
13
|
+
const item = snapshot.items[anchor];
|
|
14
|
+
const active = item?.status === 'in_progress';
|
|
15
|
+
// `activeForm` while in progress, `content` otherwise — `TodoPanel`'s rule.
|
|
16
|
+
const label = item ? (active ? item.activeForm : item.content) : '';
|
|
17
|
+
// The same degradation the status bar already applies, so the two counters can
|
|
18
|
+
// never disagree about how much room they need.
|
|
19
|
+
const counter = cols >= TODO_LIMITS.statusCompactCols
|
|
20
|
+
? `todo ${snapshot.doneCount}/${snapshot.total}`
|
|
21
|
+
: `[${snapshot.doneCount}/${snapshot.total}]`;
|
|
22
|
+
const showDone = snapshot.doneCount > 0 && cols >= TODO_LIMITS.stripDoneCols;
|
|
23
|
+
return (
|
|
24
|
+
// `width` IS LOAD-BEARING, exactly as it is on `TodoPanel` and for a related
|
|
25
|
+
// reason. Inline mode has no fixed frame, so nothing upstream constrains
|
|
26
|
+
// this row: without an explicit width the flexible middle cell is sized by
|
|
27
|
+
// whatever the parent offers, and a long item overflows the terminal and
|
|
28
|
+
// WRAPS — the one thing this component exists not to do. Pinning it here
|
|
29
|
+
// makes the truncation deterministic and self-contained rather than a
|
|
30
|
+
// property of the tree above.
|
|
31
|
+
_jsxs(Box, { flexDirection: "row", width: cols, overflow: "hidden", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { wrap: "truncate", color: theme.accent, children: counter }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', glyphs.arrowRight, ' '] }) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: active ? theme.primary : theme.muted, bold: active, children: label }) }), showDone && (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", snapshot.doneCount, " done"] }) }))] }));
|
|
32
|
+
}
|