@aragon-agent/cli 0.6.10 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/App.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { mergePendingEntries } from '../agent/queued-messages.js';
2
3
  /**
3
4
  * App — the root Ink component. Owns the view reducer, subscribes to the
4
5
  * controller's event stream (with a streaming coalescer), wires global
@@ -18,7 +19,8 @@ import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
18
19
  import { bumpSubmitCount, setMouseNoticeVersion, setVtInputNoticeVersion, } from '../config/ui-state.js';
19
20
  import { getLogger } from '../logging/logger.js';
20
21
  import { isTerminalStatus } from '../proc/types.js';
21
- import { registerSecret } from '../logging/secret-registry.js';
22
+ import { getSecrets, registerSecret } from '../logging/secret-registry.js';
23
+ import { redactText } from '../logging/redact.js';
22
24
  import { detectCapabilities } from './capabilities.js';
23
25
  import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
24
26
  import { getTheme } from './theme.js';
@@ -433,6 +435,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
433
435
  // makes stale events inert WITHOUT making the next run's events inert too.
434
436
  let generation = controller.runGeneration;
435
437
  const unsubscribe = controller.subscribe((event) => {
438
+ // Receipts describe committed history even when force-stop invalidated the run.
439
+ if (event.type === 'steering_accepted') {
440
+ dispatch({ type: 'steeringAccepted', ids: event.ids });
441
+ return;
442
+ }
436
443
  if (controller.runGeneration !== generation) {
437
444
  // The engine is unwinding a force-stopped run. Adopt the new generation
438
445
  // only once it has finished doing so, so a late `agent_end` cannot be
@@ -937,7 +944,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
937
944
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
938
945
  useEffect(() => {
939
946
  publishExitSnapshot({
940
- entries: state.entries,
947
+ entries: mergePendingEntries(state.entries, state.pendingSteering),
941
948
  usageTotal: state.usageTotal,
942
949
  provider: cfg.provider,
943
950
  model: cfg.model,
@@ -947,7 +954,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
947
954
  // count (K-6).
948
955
  droppedEntries: state.droppedEntries,
949
956
  });
950
- }, [state.entries, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
957
+ }, [state.entries, state.pendingSteering, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
951
958
  // --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
952
959
  //
953
960
  // The deps are the two `useCallback`d members, NOT the handle objects: both
@@ -1306,7 +1313,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1306
1313
  toast,
1307
1314
  persistConfig,
1308
1315
  exit: doExit,
1309
- submit: (text, opts) => submitMessage(text, opts),
1316
+ submit: (text, opts) => {
1317
+ const result = submitMessage(text, opts);
1318
+ if (!result.accepted)
1319
+ throw new Error(result.reason ?? 'Message was not accepted.');
1320
+ },
1310
1321
  refreshSkills: () => setSkillsNonce((n) => n + 1),
1311
1322
  applyAgentMode: applyMode,
1312
1323
  // Read at command time, so `/todo status` reports the live count rather than
@@ -1361,24 +1372,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1361
1372
  * start with `/`, recurse into command dispatch.
1362
1373
  */
1363
1374
  const submitMessage = (message, opts = {}) => {
1375
+ if (interactionPhase.current === 'starting') {
1376
+ return { accepted: false, reason: 'A run is still starting. Press Esc twice to cancel.' };
1377
+ }
1364
1378
  // A USER MESSAGE IS A NEW INTENT AND OUTRANKS A QUEUED ONE (AC-19). First
1365
1379
  // line, before the steering branch: a message typed during the grace window
1366
1380
  // must be the one that runs, whichever branch it takes.
1367
1381
  cancelFollowThrough();
1368
1382
  cancelConfirmation();
1369
1383
  if (interactionPhase.current === 'running') {
1370
- controller.steer(message);
1371
- // THE TRANSCRIPT ENTRY IS THE FEEDBACK (tui-shift-enter-copy-queue
1372
- // 5.3). A 2.5 s toast cannot satisfy "until the message is picked
1373
- // up": the `queued` entry stays visible and is rewritten in place
1374
- // as the user message it became on the next `turn_start`. Keeping
1375
- // the toast too would confirm one event twice.
1376
- dispatch({ type: 'steerQueued', text: message });
1377
- return;
1378
- }
1379
- if (interactionPhase.current === 'starting') {
1380
- toast('info', 'A run is still starting. Press Esc twice to cancel.');
1381
- return;
1384
+ const queueId = controller.queueUserMessage(message);
1385
+ dispatch({ type: 'steerQueued', queueId, text: message });
1386
+ return { accepted: true };
1382
1387
  }
1383
1388
  resetInterrupt();
1384
1389
  const sequence = ++submissionSequence.current;
@@ -1401,7 +1406,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1401
1406
  resetInterrupt();
1402
1407
  autoContinuationRef.current = false;
1403
1408
  dispatch({ type: 'runEnd' }); // reset status; nothing was started
1404
- return;
1409
+ return { accepted: true };
1405
1410
  }
1406
1411
  // Fire-and-forget: events drive the UI. prompt() never rejects.
1407
1412
  void controller.prompt(message, { todoPolicy: opts.todoPolicy ?? 'new-task' }).then((outcome) => {
@@ -1414,19 +1419,37 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1414
1419
  autoContinuationRef.current = false;
1415
1420
  dispatch({ type: 'runEnd' });
1416
1421
  });
1422
+ return { accepted: true };
1417
1423
  };
1418
1424
  submitRef.current = submitMessage;
1419
- const handleSubmit = async (raw) => {
1420
- if (interactionPhase.current === 'running' && /^\s*\/(reset|resume)(?:\s|$)/.test(raw)) {
1421
- notify('warn', 'Interrupt the current run before switching conversations.');
1422
- return;
1425
+ const executeSlashInput = async (raw) => {
1426
+ // Retain a recoverable command before any asynchronous handler can fail.
1427
+ const safe = redactText(raw, getSecrets());
1428
+ recordPrompt(safe);
1429
+ notify('info', `Command: ${safe}`);
1430
+ try {
1431
+ await runSlashInput(registry, raw, makeCtx);
1432
+ }
1433
+ catch (error) {
1434
+ const reason = error instanceof Error ? error.message : String(error);
1435
+ notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
1436
+ }
1437
+ };
1438
+ const handleSubmit = (raw) => {
1439
+ const trimmed = raw.trimStart();
1440
+ const slash = trimmed.startsWith('/') && !trimmed.startsWith('//');
1441
+ if (slash && (interactionPhase.current === 'running' || controller.isRunning()) &&
1442
+ /^\/(reset|resume)(?:\s|$)/.test(trimmed)) {
1443
+ return { accepted: false,
1444
+ reason: 'Interrupt the current run before switching conversations.' };
1423
1445
  }
1424
1446
  // Submitting is an unconditional "take me to the newest output" (§4.5).
1425
1447
  setPinToBottomNonce((n) => n + 1);
1426
- const handled = await runSlashInput(registry, raw, makeCtx);
1427
- if (handled)
1428
- return;
1429
- submitMessage(raw.startsWith('//') ? raw.slice(1) : raw);
1448
+ if (slash) {
1449
+ void executeSlashInput(raw);
1450
+ return { accepted: true };
1451
+ }
1452
+ return submitMessage(trimmed.startsWith('//') ? raw.replace('//', '/') : raw);
1430
1453
  };
1431
1454
  const handleSettingsSave = (values) => {
1432
1455
  // An unusable cap must NOT discard the rest of the form: the same Enter
@@ -1623,11 +1646,15 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1623
1646
  (input === 'c' || input === 'C') &&
1624
1647
  (selectionController?.hasPendingSelection() ?? false)) {
1625
1648
  const payload = selectionController.takeSelection();
1649
+ ctrlCArmed.current = false;
1650
+ if (ctrlCTimer.current)
1651
+ clearTimeout(ctrlCTimer.current);
1652
+ ctrlCTimer.current = null;
1626
1653
  if (payload) {
1627
1654
  const via = copyText(payload.text, terminal?.writeForeign);
1628
1655
  selectionBridge?.onCopied?.(via, payload.lines, payload.text.length);
1629
- return; // copied -- no service stop, no arm, no clear
1630
1656
  }
1657
+ return; // A consumed copy intent never becomes exit or service stop.
1631
1658
  }
1632
1659
  selectionController?.clear();
1633
1660
  if (key.ctrl && (input === 'c' || input === 'C')) {
@@ -2046,7 +2073,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2046
2073
  const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
2047
2074
  const composer = (_jsx(Composer, { measureRef: composerRef, runRow: runRowEnabled
2048
2075
  ? { activity: runActivity, live: runRowShown && !overlayNode, rowRef: activityRef }
2049
- : null, cols: contentCols, cursorVisible: cursorVisible, onInteraction: returnToComposer, reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, 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, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2076
+ : null, cols: contentCols, cursorVisible: cursorVisible, onInteraction: returnToComposer, reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, 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, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: handleSubmit, onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2050
2077
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2051
2078
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2052
2079
  const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: tooSmall ? null : overlayNode, footer: _jsxs(_Fragment, { children: [team, composer] }), rail: rail, scrollbar: terminal?.scrollbar, composerRef: composerRef, onComposerVisibilityChange: setCursorVisible, activityRef: activityRef, onActivityVisibilityChange: setActivityRowVisible, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
@@ -2076,7 +2103,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2076
2103
  // out-ranking it inside this row; with the activity line moved above
2077
2104
  // the input, the row can be free during a run, so the silence is stated
2078
2105
  // here instead of inherited.
2079
- update: updateSnapshot && !overlayNode && !running && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), status: _jsx(StatusBar, { interruptHint: interruptGesture.current.phase === 'stopping'
2106
+ update: updateSnapshot && !overlayNode && !running && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), status: _jsx(StatusBar, { pendingSteering: state.overlay ? undefined : state.pendingSteering, interruptHint: interruptGesture.current.phase === 'stopping'
2080
2107
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2081
2108
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2082
2109
  // projection of "many sources of truth" into the props layer, and a
@@ -17,6 +17,7 @@ import { type DOMElement } from 'ink';
17
17
  import type { Theme } from './theme.js';
18
18
  import type { TermCapabilities } from './capabilities.js';
19
19
  import { pickGlyphs } from './glyphs.js';
20
+ import type { ComposerSubmitResult } from './composer-input.js';
20
21
  import { type CommandOption } from './PromptInput.js';
21
22
  import { type RunActivity } from './run-status-row.js';
22
23
  import { type AgentMode } from '../agent/agent-mode.js';
@@ -109,7 +110,7 @@ export interface ComposerProps {
109
110
  onNotice?: (level: 'warn' | 'error', text: string) => void;
110
111
  theme: Theme;
111
112
  caps: TermCapabilities;
112
- onSubmit: (text: string) => void;
113
+ onSubmit: (text: string) => ComposerSubmitResult;
113
114
  onHelp?: () => void;
114
115
  }
115
116
  /**
@@ -25,6 +25,7 @@ import { pickGlyphs } from './glyphs.js';
25
25
  import { type EditIntent } from '../input/keymap.js';
26
26
  import { type Suggestion } from './AutocompletePopup.js';
27
27
  import { type EditorState, type InputSegment } from './editor-reducer.js';
28
+ import { type ComposerSubmitResult } from './composer-input.js';
28
29
  import type { AgentMode } from '../agent/agent-mode.js';
29
30
  export interface CommandOption {
30
31
  name: string;
@@ -51,7 +52,7 @@ interface PromptInputProps {
51
52
  /** Total menu rows, including borders and the overflow hint. */
52
53
  popupMaxHeight?: number;
53
54
  onPopupRowsChange?: (rows: number) => void;
54
- onSubmit: (text: string) => void;
55
+ onSubmit: (text: string) => ComposerSubmitResult;
55
56
  onHelp?: () => void;
56
57
  onEscape?: () => void;
57
58
  onEscapeDismiss?: () => void;
@@ -28,8 +28,9 @@ import { recognize } from '../input/keymap.js';
28
28
  import { AutocompletePopup } from './AutocompletePopup.js';
29
29
  import { buildAutocompleteLayout } from './layout/autocomplete.js';
30
30
  import { editorReducer, INITIAL_EDITOR_STATE, } from './editor-reducer.js';
31
- import { hasPasteFrame, splitPasteFrames } from './paste-frames.js';
32
- import { hasEnterFrame, mergeWithPasteRuns, splitEnterFrames, } from './enter-frames.js';
31
+ import { hasPasteFrame } from './paste-frames.js';
32
+ import { hasEnterFrame, splitEnterFrames, } from './enter-frames.js';
33
+ import { planComposerInput } from './composer-input.js';
33
34
  import { allocatePasteId, expandPastes, expandRangeOverTokens } from './paste-tokens.js';
34
35
  import { draftMaxRows } from './composer-limits.js';
35
36
  import { layoutComposer, splitRowAtColumn } from './composer-rows.js';
@@ -371,18 +372,42 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
371
372
  onInteraction?.();
372
373
  dispatch({ type: 'insert', text });
373
374
  };
374
- /**
375
- * Expand every token back to its payload, then send (D-7 / D-9).
376
- *
377
- * `onSubmit`'s signature is unchanged, so `Composer`, `App.handleSubmit`,
378
- * `runSlashInput` and `submitMessage` are all untouched — and a slash command
379
- * therefore sees the EXPANDED text, which is what `/skill foo <pasted body>`
380
- * needs. `clear` releases every payload in the same dispatch.
381
- */
382
- const submit = (text) => {
375
+ const resolveSubmit = (state) => {
376
+ const suggestions = state.dismissed ? null : slashSuggestions(state.buffer, commands);
377
+ const selectedIndex = Math.min(state.sel, (suggestions?.length ?? 0) - 1);
378
+ const suggestionLayout = buildAutocompleteLayout({
379
+ itemCount: isActive ? suggestions?.length ?? 0 : 0,
380
+ selected: selectedIndex, maxRows: popupMaxRows, maxHeight: popupMaxHeight,
381
+ });
382
+ const selected = suggestionLayout.rowCount > 0 ? suggestions?.[selectedIndex] : undefined;
383
+ const text = selected?.label ?? expandPastes(state.buffer, state.pastes);
384
+ return text.trim().length > 0 ? text : null;
385
+ };
386
+ const transact = (intents) => {
387
+ const plan = planComposerInput({
388
+ editor, intents, resolveSubmit, checkPasteLimit: draftLimitRefusal,
389
+ });
390
+ if (plan.refusal) {
391
+ onNotice?.('warn', plan.refusal);
392
+ return;
393
+ }
383
394
  onInteraction?.();
384
- onSubmit(expandPastes(text, editor.pastes));
385
- dispatch({ type: 'clear' });
395
+ let result = { accepted: true };
396
+ if (plan.submission !== undefined) {
397
+ try {
398
+ result = onSubmit(plan.submission);
399
+ }
400
+ catch (error) {
401
+ result = { accepted: false, reason: error instanceof Error ? error.message : String(error) };
402
+ }
403
+ }
404
+ dispatch({ type: 'adopt', state: result.accepted ? plan.nextEditor : plan.rejectedEditor });
405
+ if (!result.accepted) {
406
+ onNotice?.('error', result.reason ?? 'Message was not accepted; input is kept in the draft.');
407
+ }
408
+ else if (plan.notice) {
409
+ onNotice?.('warn', plan.notice);
410
+ }
386
411
  };
387
412
  const recallUp = () => {
388
413
  if (history.length === 0)
@@ -447,6 +472,16 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
447
472
  return;
448
473
  if (key.pageUp || key.pageDown || (key.shift && (key.upArrow || key.downArrow)))
449
474
  return;
475
+ if ((key.return || input === '\r') && (key.meta || key.shift)) {
476
+ insert('\n');
477
+ return;
478
+ }
479
+ if (hasPasteFrame(input) || hasEnterFrame(input) || /[\r\n]/.test(input)) {
480
+ const intents = splitEnterFrames(input).map((frame) => frame.kind === 'paste' ? { ...frame, id: allocatePasteId() } : frame);
481
+ if (intents.length > 0)
482
+ transact(intents);
483
+ return;
484
+ }
450
485
  // Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
451
486
  if (popupVisible) {
452
487
  if (key.upArrow) {
@@ -464,9 +499,7 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
464
499
  return;
465
500
  }
466
501
  if (key.return && popupKind === 'slash') {
467
- const label = popupItems[clampedSel]?.label;
468
- if (label)
469
- submit(label);
502
+ transact([{ kind: 'submit' }]);
470
503
  return;
471
504
  }
472
505
  if (key.escape) {
@@ -497,7 +530,7 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
497
530
  }
498
531
  if (buffer.trim().length === 0)
499
532
  return;
500
- submit(buffer);
533
+ transact([{ kind: 'submit' }]);
501
534
  return;
502
535
  }
503
536
  if (key.backspace || key.delete) {
@@ -537,60 +570,6 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
537
570
  onHelp();
538
571
  return;
539
572
  }
540
- // A PASTE (section 5.4). It sits AFTER every key branch so a paste can
541
- // never be shadowed by a key test, and BEFORE `isControlSeq` because a
542
- // framed string starts with NUL and would otherwise be dropped.
543
- //
544
- // The text runs inside `segments` are already sanitised (I-14): Ink drains
545
- // its whole buffer in one `read()`, so this same string can carry the `\r`
546
- // of a user who pasted and pressed Enter inside the burst window.
547
- //
548
- // NO NEWLINE INTENT IN THE CHUNK (tui-shift-enter-copy-queue 3.5):
549
- // that same one-`read()` coalescing means a paste frame can share an
550
- // `input` with an enter frame or a bare Ctrl+J, and those chunks
551
- // belong to the newline branch below, whose `mergeWithPasteRuns`
552
- // interleaves both families in byte order. This branch's
553
- // `splitPasteFrames` would strip the enter frame's NUL and leak its
554
- // letter as text. A multi-line payload (a `\n` of its own) routes
555
- // there too and comes back byte-identical inside its paste segment.
556
- if (hasPasteFrame(input) && !hasEnterFrame(input) && !input.includes('\n')) {
557
- const runs = splitPasteFrames(input);
558
- const segments = runs.map((run) => run.kind === 'paste' ? { kind: 'paste', text: run.text, id: allocatePasteId() } : run);
559
- if (segments.length === 0)
560
- return;
561
- const refusal = draftLimitRefusal(segments, editor);
562
- if (refusal) {
563
- // A refusal dispatches NOTHING, so the draft is byte-for-byte
564
- // unchanged (AC-9) — and it is never silent (D-11).
565
- onNotice?.('warn', refusal);
566
- return;
567
- }
568
- onInteraction?.();
569
- dispatch({ type: 'input', segments });
570
- return;
571
- }
572
- // A NEWLINE INTENT, from either source (tui-shift-enter-copy-queue
573
- // 3.5): the NUL frame the filter wrote for Shift+Enter / Alt+Enter /
574
- // CSI-u Enter, or a bare `\n` (Ctrl+J), which Ink delivers WITHOUT
575
- // `key.return`. Sits beside the paste branch -- after every key test so
576
- // it can never shadow one, before `isControlSeq` because both sources
577
- // begin with a control byte and would otherwise be dropped. ONE
578
- // dispatch carries the whole ordered chunk, paste runs included when
579
- // Ink coalesced the filter's two writes into one input string.
580
- if (hasEnterFrame(input) || input.includes('\n')) {
581
- const runs = mergeWithPasteRuns(input, splitEnterFrames(input));
582
- const segments = runs.map((run) => run.kind === 'paste' ? { kind: 'paste', text: run.text, id: allocatePasteId() } : run);
583
- if (segments.length === 0)
584
- return;
585
- const refusal = draftLimitRefusal(segments, editor);
586
- if (refusal) {
587
- onNotice?.('warn', refusal);
588
- return;
589
- }
590
- onInteraction?.();
591
- dispatch({ type: 'input', segments });
592
- return;
593
- }
594
573
  // Printable input — drop any unrecognized control/escape byte.
595
574
  if (input && !key.tab && !isControlSeq(input))
596
575
  insert(input);
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import type { PendingSteering } from '../agent/queued-messages.js';
3
+ import type { TermCapabilities } from './capabilities.js';
4
+ import type { Theme } from './theme.js';
5
+ export interface QueueStatusRowProps {
6
+ pending: readonly PendingSteering[];
7
+ paused: boolean;
8
+ columns: number;
9
+ compact: boolean;
10
+ theme: Theme;
11
+ caps: TermCapabilities;
12
+ }
13
+ /** Return the first nonempty logical line without terminal control characters. */
14
+ export declare function queueSummary(text: string): string;
15
+ /** Format a queue using only the columns assigned by the status bar. */
16
+ export declare function formatQueueStatus(props: Omit<QueueStatusRowProps, 'theme'>): {
17
+ prefix: string;
18
+ body: string;
19
+ suffix: string;
20
+ };
21
+ /** Fixed single-line pending receipt feedback; no timeout or spinner. */
22
+ export declare function QueueStatusRow(props: QueueStatusRowProps): React.ReactElement;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import stringWidth from 'string-width';
4
+ import stripAnsi from 'strip-ansi';
5
+ import { pickGlyphs } from './glyphs.js';
6
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
7
+ /** Return the first nonempty logical line without terminal control characters. */
8
+ export function queueSummary(text) {
9
+ return stripAnsi(text).split(/\r\n|[\r\n]/)
10
+ .map((line) => line.replace(/\t/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim())
11
+ .find(Boolean) ?? '(empty)';
12
+ }
13
+ /** Truncate at a whole grapheme, reserving the ellipsis in the same cell budget. */
14
+ function truncateSummary(text, columns, ellipsis) {
15
+ if (stringWidth(text) <= columns)
16
+ return text;
17
+ if (columns < stringWidth(ellipsis))
18
+ return '';
19
+ let out = '';
20
+ let used = stringWidth(ellipsis);
21
+ for (const { segment } of segmenter.segment(text)) {
22
+ const width = stringWidth(segment);
23
+ if (used + width > columns)
24
+ break;
25
+ used += width;
26
+ out += segment;
27
+ }
28
+ return out + ellipsis;
29
+ }
30
+ /** Format a queue using only the columns assigned by the status bar. */
31
+ export function formatQueueStatus(props) {
32
+ if (!props.pending.length)
33
+ return { prefix: '', body: '', suffix: '' };
34
+ const prefix = props.paused
35
+ ? (props.compact ? 'Queue(p): ' : 'Queue (paused): ') : 'Queue: ';
36
+ const remaining = props.pending.length - 1;
37
+ const suffix = remaining === 0 ? '' : props.compact
38
+ ? `(+${remaining > 99 ? '99+' : remaining})` : `(+${remaining} more)`;
39
+ const tail = suffix ? ` ${suffix}` : '';
40
+ const budget = Math.max(0, props.columns - stringWidth(prefix + tail));
41
+ return {
42
+ prefix,
43
+ body: truncateSummary(queueSummary(props.pending[0].text), budget, pickGlyphs(props.caps).ellipsis),
44
+ suffix: tail,
45
+ };
46
+ }
47
+ /** Fixed single-line pending receipt feedback; no timeout or spinner. */
48
+ export function QueueStatusRow(props) {
49
+ const parts = formatQueueStatus(props);
50
+ return _jsxs(Text, { children: [_jsx(Text, { color: props.theme.accent, children: parts.prefix }), _jsxs(Text, { color: props.theme.muted, children: [parts.body, parts.suffix] })] });
51
+ }
@@ -20,7 +20,9 @@ import type { TermCapabilities } from './capabilities.js';
20
20
  import type { UsageTotal } from '../agent/reducer.js';
21
21
  import type { ContextUsageSnapshot } from '../compaction/types.js';
22
22
  import { type AgentMode } from '../agent/agent-mode.js';
23
+ import type { PendingSteering } from '../agent/queued-messages.js';
23
24
  interface StatusBarProps {
25
+ pendingSteering?: readonly PendingSteering[];
24
26
  interruptHint?: string;
25
27
  model: string;
26
28
  provider: string;
@@ -11,6 +11,7 @@ import { COMPACTION_LIMITS } from '../compaction/limits.js';
11
11
  import { TEAM_LIMITS } from '../team/limits.js';
12
12
  import { TODO_LIMITS } from '../todo/limits.js';
13
13
  import { PROC_LIMITS } from '../proc/limits.js';
14
+ import { QueueStatusRow } from './QueueStatusRow.js';
14
15
  /** Below this the `eco` chip costs the context gauge more than it is worth. */
15
16
  const ECO_MIN_COLS = 72;
16
17
  /**
@@ -51,11 +52,40 @@ const NBSP = String.fromCharCode(0x00a0);
51
52
  function redrawChar(nonce) {
52
53
  return nonce % 2 === 0 ? ' ' : NBSP;
53
54
  }
55
+ function queueStateLabels(props, compact) {
56
+ const mode = props.agentMode ?? 'build';
57
+ const next = props.pendingAgentMode;
58
+ const modeLabel = compact ? mode[0].toUpperCase() : mode;
59
+ const nextLabel = next ? (compact ? next[0].toUpperCase() : next) : '';
60
+ const stopping = /force|again/i.test(props.interruptHint ?? '');
61
+ return [
62
+ nextLabel ? `${modeLabel}>${nextLabel}` : modeLabel,
63
+ stopping ? (compact ? 'E!' : 'Esc!')
64
+ : props.status === 'running' ? (compact ? 'E2' : 'Esc x2') : '',
65
+ props.servicesActive?.live ? (compact ? 'S+' : `svc ${props.servicesActive.live}`) : '',
66
+ props.todoActive?.total ? (compact ? 'T+'
67
+ : `todo ${props.todoActive.done}/${props.todoActive.total}`) : '',
68
+ ].filter(Boolean).join(' ') + ' ';
69
+ }
70
+ function QueueStatusBar(props) {
71
+ const pending = props.pendingSteering;
72
+ const normal = queueStateLabels(props, false);
73
+ const paused = props.status !== 'running';
74
+ const suffix = pending.length > 1 ? ` (+${pending.length - 1} more)` : '';
75
+ const minimum = stringWidth((paused ? 'Queue (paused): ' : 'Queue: ') + suffix)
76
+ + stringWidth(pickGlyphs(props.caps).ellipsis);
77
+ const compact = stringWidth(normal) + minimum + 1 > props.columns;
78
+ const labels = compact ? queueStateLabels(props, true) : normal;
79
+ const columns = props.columns - 1 - stringWidth(labels);
80
+ return _jsxs(Box, { width: props.columns, height: 1, flexShrink: 0, children: [_jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }), _jsx(Text, { color: props.theme.noticeWarn, children: labels }), _jsx(QueueStatusRow, { pending: pending, paused: paused, columns: columns, compact: compact, theme: props.theme, caps: props.caps })] });
81
+ }
54
82
  export function StatusBar(props) {
55
83
  const { model, provider, usageTotal, context, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, servicesActive, retryActive, ecoRung = 0, fastActive, compactionActive, gaugeMarks, } = props;
56
84
  const { stdout } = useStdout();
57
85
  const cols = stdout?.columns ?? 80;
58
86
  const running = status === 'running';
87
+ if (props.pendingSteering?.length)
88
+ return _jsx(QueueStatusBar, { ...props, columns: cols });
59
89
  const glyphs = pickGlyphs(caps);
60
90
  // THE PERCENTAGE IS NOT RECOMPUTED HERE ANY MORE. It arrives already divided
61
91
  // and clamped by the same code the compaction trigger reads, which is the
@@ -0,0 +1,28 @@
1
+ import { type EditorState, type InputSegment } from './editor-reducer.js';
2
+ export interface ComposerSubmitResult {
3
+ accepted: boolean;
4
+ reason?: string;
5
+ }
6
+ export type ComposerInputIntent = InputSegment | {
7
+ kind: 'newline';
8
+ } | {
9
+ kind: 'submit';
10
+ };
11
+ export interface ComposerInputOptions {
12
+ editor: EditorState;
13
+ intents: readonly ComposerInputIntent[];
14
+ resolveSubmit: (editor: EditorState) => string | null;
15
+ checkPasteLimit: (segments: InputSegment[], editor: EditorState) => string | null;
16
+ }
17
+ export interface ComposerInputPlan {
18
+ nextEditor: EditorState;
19
+ rejectedEditor: EditorState;
20
+ submission?: string;
21
+ notice?: string;
22
+ refusal?: string;
23
+ }
24
+ /**
25
+ * Plan both accepted and rejected drafts before any submission side effect.
26
+ * Uses caller-assigned paste IDs and pure callbacks; never mutates the source editor.
27
+ */
28
+ export declare function planComposerInput(options: ComposerInputOptions): ComposerInputPlan;
@@ -0,0 +1,46 @@
1
+ import { editorReducer } from './editor-reducer.js';
2
+ const MORE_INPUT_NOTICE = 'More input is kept in the draft; press Enter to send.';
3
+ /**
4
+ * Plan both accepted and rejected drafts before any submission side effect.
5
+ * Uses caller-assigned paste IDs and pure callbacks; never mutates the source editor.
6
+ */
7
+ export function planComposerInput(options) {
8
+ let nextEditor = options.editor;
9
+ let rejectedEditor = options.editor;
10
+ let acceptedBase = options.editor;
11
+ let acceptedPastes = [];
12
+ const rejectedPastes = [];
13
+ let submission;
14
+ let notice;
15
+ for (const intent of options.intents) {
16
+ if (intent.kind === 'submit') {
17
+ const text = options.resolveSubmit(nextEditor);
18
+ if (submission !== undefined) {
19
+ notice = MORE_INPUT_NOTICE;
20
+ }
21
+ else if (text !== null && text.trim().length > 0) {
22
+ submission = text;
23
+ nextEditor = editorReducer(nextEditor, { type: 'clear' });
24
+ acceptedBase = nextEditor;
25
+ acceptedPastes = [];
26
+ }
27
+ continue;
28
+ }
29
+ const segment = intent.kind === 'newline'
30
+ ? { kind: 'text', text: '\n' } : intent;
31
+ const segments = [segment];
32
+ if (segment.kind === 'paste') {
33
+ // Inline pastes have no map entry; count every paste in this callback as before.
34
+ acceptedPastes.push(segment);
35
+ rejectedPastes.push(segment);
36
+ const refusal = options.checkPasteLimit(acceptedPastes, acceptedBase)
37
+ ?? options.checkPasteLimit(rejectedPastes, options.editor);
38
+ if (refusal) {
39
+ return { nextEditor: options.editor, rejectedEditor: options.editor, refusal };
40
+ }
41
+ }
42
+ nextEditor = editorReducer(nextEditor, { type: 'input', segments });
43
+ rejectedEditor = editorReducer(rejectedEditor, { type: 'input', segments });
44
+ }
45
+ return { nextEditor, rejectedEditor, submission, notice };
46
+ }
@@ -86,6 +86,11 @@ export type EditorAction = {
86
86
  } | {
87
87
  type: 'clear';
88
88
  }
89
+ /** Only local reducer-planned states may be adopted; prune token payloads again. */
90
+ | {
91
+ type: 'adopt';
92
+ state: EditorState;
93
+ }
89
94
  /**
90
95
  * ONE chunk of stdin, already split into ordered runs. The only new action,
91
96
  * and it is what keeps the one-dispatch-per-key rule true for a chunk that
@@ -82,6 +82,8 @@ function withPrune(next) {
82
82
  }
83
83
  export function editorReducer(state, action) {
84
84
  switch (action.type) {
85
+ case 'adopt':
86
+ return withPrune(action.state);
85
87
  case 'insert': {
86
88
  // D-8: typing can never land INSIDE a token. A no-op on a buffer with no
87
89
  // tokens, which is every buffer until the first collapse.