@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/CHANGELOG.md +10 -1
- package/README.md +24 -13
- package/dist/agent/controller.d.ts +9 -2
- package/dist/agent/controller.js +29 -39
- package/dist/agent/queued-messages.d.ts +8 -0
- package/dist/agent/queued-messages.js +28 -0
- package/dist/agent/reducer.d.ts +8 -1
- package/dist/agent/reducer.js +23 -34
- package/dist/commands/builtins.js +21 -7
- package/dist/input/enter-sequences.d.ts +11 -10
- package/dist/input/enter-sequences.js +29 -27
- package/dist/input/stdin-filter.js +90 -26
- package/dist/logging/install.d.ts +3 -0
- package/dist/session/persist.d.ts +6 -7
- package/dist/session/persist.js +6 -9
- package/dist/session/store.d.ts +4 -1
- package/dist/session/validate-session.d.ts +3 -0
- package/dist/session/validate-session.js +207 -0
- package/dist/ui/App.js +55 -28
- package/dist/ui/Composer.d.ts +2 -1
- package/dist/ui/PromptInput.d.ts +2 -1
- package/dist/ui/PromptInput.js +50 -71
- package/dist/ui/QueueStatusRow.d.ts +22 -0
- package/dist/ui/QueueStatusRow.js +51 -0
- package/dist/ui/StatusBar.d.ts +2 -0
- package/dist/ui/StatusBar.js +30 -0
- package/dist/ui/composer-input.d.ts +28 -0
- package/dist/ui/composer-input.js +46 -0
- package/dist/ui/editor-reducer.d.ts +5 -0
- package/dist/ui/editor-reducer.js +2 -0
- package/dist/ui/enter-frames.d.ts +15 -31
- package/dist/ui/enter-frames.js +14 -33
- package/dist/ui/overlays/HelpOverlay.js +8 -0
- package/dist/ui/selection/selection-controller.js +57 -20
- package/dist/ui/transcript-text.js +20 -12
- package/package.json +2 -2
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) =>
|
|
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.
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
1427
|
-
|
|
1428
|
-
return;
|
|
1429
|
-
|
|
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:
|
|
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
|
package/dist/ui/Composer.d.ts
CHANGED
|
@@ -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) =>
|
|
113
|
+
onSubmit: (text: string) => ComposerSubmitResult;
|
|
113
114
|
onHelp?: () => void;
|
|
114
115
|
}
|
|
115
116
|
/**
|
package/dist/ui/PromptInput.d.ts
CHANGED
|
@@ -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) =>
|
|
55
|
+
onSubmit: (text: string) => ComposerSubmitResult;
|
|
55
56
|
onHelp?: () => void;
|
|
56
57
|
onEscape?: () => void;
|
|
57
58
|
onEscapeDismiss?: () => void;
|
package/dist/ui/PromptInput.js
CHANGED
|
@@ -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
|
|
32
|
-
import { hasEnterFrame,
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/ui/StatusBar.d.ts
CHANGED
|
@@ -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;
|
package/dist/ui/StatusBar.js
CHANGED
|
@@ -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.
|