@aragon-agent/cli 0.6.19 → 0.6.21

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.
@@ -103,6 +103,8 @@ export declare class ContextMeter {
103
103
  private last;
104
104
  /** Whether the history has changed since the last measurement. */
105
105
  private dirty;
106
+ /** Whether the one-per-run empty-measurement warning has been spent. */
107
+ private hasWarnedUnmeasuredInput;
106
108
  private prefixLast;
107
109
  private sampledPrompt;
108
110
  private sampledVersion;
@@ -206,6 +208,16 @@ export declare class ContextMeter {
206
208
  */
207
209
  attach(subscribe: (listener: (event: AgentEvent) => void) => () => void): void;
208
210
  dispose(): void;
211
+ /**
212
+ * ONE warn per run (context-usage-zero-input-tokens B). A single day of
213
+ * real usage logged 1,973 zero-input turns; a per-turn warning is a flood
214
+ * that teaches whoever reads the file to ignore it.
215
+ *
216
+ * The logger is resolved LAZILY rather than captured at construction:
217
+ * hosts build this class before `installLogging()`, and a child captured
218
+ * then would stay bound to the silent pre-install logger forever.
219
+ */
220
+ private warnUnmeasuredInputOnce;
209
221
  private onAgentEvent;
210
222
  /**
211
223
  * The structural belt for a missed invalidation site (I-9).
@@ -27,7 +27,8 @@
27
27
  * `CompactionWiring.settlePending`'s own synchronous code (I-9), and why
28
28
  * `current()` carries a structural belt for a missed invalidation site.
29
29
  */
30
- import { computeEstimateOffset, computePressure } from './pressure.js';
30
+ import { getLogger } from '../logging/logger.js';
31
+ import { computeEstimateOffset, computePressure, isInputSideUnmeasured } from './pressure.js';
31
32
  /**
32
33
  * How long a scheduled publish waits.
33
34
  *
@@ -103,6 +104,8 @@ export class ContextMeter {
103
104
  last = null;
104
105
  /** Whether the history has changed since the last measurement. */
105
106
  dirty = true;
107
+ /** Whether the one-per-run empty-measurement warning has been spent. */
108
+ hasWarnedUnmeasuredInput = false;
106
109
  prefixLast;
107
110
  sampledPrompt;
108
111
  sampledVersion;
@@ -174,6 +177,21 @@ export class ContextMeter {
174
177
  * better number to wait for.
175
178
  */
176
179
  onTurnEnd(usage) {
180
+ if (isInputSideUnmeasured(usage)) {
181
+ // A usage that discloses NOTHING on the input side is not a
182
+ // measurement of this history (context-usage-zero-input-tokens B):
183
+ // arming the measured branch with it collapses occupancy to the
184
+ // turn's own outputTokens and strips the `~` off the gauge. KEEP
185
+ // WHATEVER IS STILL TRUSTED - an earlier good measurement of a
186
+ // prefix that still exists (the history only grows between splices,
187
+ // so "old good base + appended estimate" stays self-consistent), and
188
+ // the estimate offset it calibrated - and re-measure NOW so the
189
+ // gauge degrades in this frame instead of after the next turn.
190
+ this.warnUnmeasuredInputOnce(usage);
191
+ this.dirty = true;
192
+ this.current();
193
+ return;
194
+ }
177
195
  const messages = this.deps.getMessages();
178
196
  const systemPrompt = this.readSystemPrompt();
179
197
  this.lastUsage = usage;
@@ -307,6 +325,21 @@ export class ContextMeter {
307
325
  // =========================================================================
308
326
  // Internals
309
327
  // =========================================================================
328
+ /**
329
+ * ONE warn per run (context-usage-zero-input-tokens B). A single day of
330
+ * real usage logged 1,973 zero-input turns; a per-turn warning is a flood
331
+ * that teaches whoever reads the file to ignore it.
332
+ *
333
+ * The logger is resolved LAZILY rather than captured at construction:
334
+ * hosts build this class before `installLogging()`, and a child captured
335
+ * then would stay bound to the silent pre-install logger forever.
336
+ */
337
+ warnUnmeasuredInputOnce(usage) {
338
+ if (this.hasWarnedUnmeasuredInput)
339
+ return;
340
+ this.hasWarnedUnmeasuredInput = true;
341
+ getLogger().warn('compaction', 'context_input_tokens_unreported', { in: usage.inputTokens, out: usage.outputTokens });
342
+ }
310
343
  onAgentEvent(event) {
311
344
  switch (event.type) {
312
345
  case 'turn_end':
@@ -40,6 +40,35 @@ import type { Pressure } from './types.js';
40
40
  * the feature is broken, and turns it off.
41
41
  */
42
42
  export declare function occupiedTokens(usage: TokenUsage): number;
43
+ /**
44
+ * Whether a usage measured ANYTHING on the input side
45
+ * (context-usage-zero-input-tokens B).
46
+ *
47
+ * THE EMPTY MEASUREMENT THIS EXISTS FOR. A turn that billed output tokens
48
+ * while reporting inputTokens 0 AND no cache terms did not measure the
49
+ * request - no real request is free of input. Anthropic-COMPATIBLE relays
50
+ * emit exactly this shape (bigmodel on every turn, Kimi intermittently),
51
+ * and the meter used to arm its measured branch with it: occupancy collapsed
52
+ * to the turn's own outputTokens, the `~` honesty marker vanished with
53
+ * `source: 'usage'`, and the trigger - which reads the same number - never
54
+ * fired. The defense is to not trust such a usage as a measurement.
55
+ *
56
+ * THE CACHE TERMS ARE PART OF THE INPUT SIDE, DELIBERATELY. Anthropic's real
57
+ * `input_tokens` EXCLUDES cached tokens, so a deeply cached turn reports a
58
+ * tiny `input_tokens` next to a huge `cache_read_input_tokens`. That IS a
59
+ * valid measurement (C-14), and judging on `inputTokens === 0` alone would
60
+ * reject it and strand a correct gauge on the estimator.
61
+ *
62
+ * `outputTokens > 0` separates "the stream ran, billed output, and disclosed
63
+ * no input" from a degenerate all-zero usage, which the reviewed predicate
64
+ * deliberately does not speak for.
65
+ *
66
+ * SINGLE AUTHORITY. The meter consumes this, and any future observability
67
+ * hint (fix C) must too: two inline copies of "did the gateway disclose
68
+ * input" would drift apart exactly the way the gauge and the trigger once
69
+ * could (R-11).
70
+ */
71
+ export declare function isInputSideUnmeasured(usage: TokenUsage): boolean;
43
72
  /**
44
73
  * The calibration offset for this turn (D-23 / P1-11).
45
74
  *
@@ -44,6 +44,38 @@ export function occupiedTokens(usage) {
44
44
  (usage.cacheWriteTokens ?? 0) +
45
45
  usage.outputTokens);
46
46
  }
47
+ /**
48
+ * Whether a usage measured ANYTHING on the input side
49
+ * (context-usage-zero-input-tokens B).
50
+ *
51
+ * THE EMPTY MEASUREMENT THIS EXISTS FOR. A turn that billed output tokens
52
+ * while reporting inputTokens 0 AND no cache terms did not measure the
53
+ * request - no real request is free of input. Anthropic-COMPATIBLE relays
54
+ * emit exactly this shape (bigmodel on every turn, Kimi intermittently),
55
+ * and the meter used to arm its measured branch with it: occupancy collapsed
56
+ * to the turn's own outputTokens, the `~` honesty marker vanished with
57
+ * `source: 'usage'`, and the trigger - which reads the same number - never
58
+ * fired. The defense is to not trust such a usage as a measurement.
59
+ *
60
+ * THE CACHE TERMS ARE PART OF THE INPUT SIDE, DELIBERATELY. Anthropic's real
61
+ * `input_tokens` EXCLUDES cached tokens, so a deeply cached turn reports a
62
+ * tiny `input_tokens` next to a huge `cache_read_input_tokens`. That IS a
63
+ * valid measurement (C-14), and judging on `inputTokens === 0` alone would
64
+ * reject it and strand a correct gauge on the estimator.
65
+ *
66
+ * `outputTokens > 0` separates "the stream ran, billed output, and disclosed
67
+ * no input" from a degenerate all-zero usage, which the reviewed predicate
68
+ * deliberately does not speak for.
69
+ *
70
+ * SINGLE AUTHORITY. The meter consumes this, and any future observability
71
+ * hint (fix C) must too: two inline copies of "did the gateway disclose
72
+ * input" would drift apart exactly the way the gauge and the trigger once
73
+ * could (R-11).
74
+ */
75
+ export function isInputSideUnmeasured(usage) {
76
+ const inputSide = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
77
+ return inputSide === 0 && usage.outputTokens > 0;
78
+ }
47
79
  /**
48
80
  * The calibration offset for this turn (D-23 / P1-11).
49
81
  *
package/dist/ui/App.js CHANGED
@@ -128,6 +128,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
128
128
  const { stdout } = useStdout();
129
129
  const size = useTerminalSize();
130
130
  const [draftRows, setDraftRows] = useState(1);
131
+ // Ctrl+C draft ladder (ctrl-c-clear-draft-exit). The nonce ticks when
132
+ // the armed ladder spends its clear rung; the flag marks "clear spent"
133
+ // so the render-time feedback already reads the exit wording at the
134
+ // nonce render (the async clear lands a frame later - see the timing
135
+ // invariant beside the ladder itself).
136
+ const [draftClearNonce, setDraftClearNonce] = useState(0);
137
+ const [draftClearedByCtrlC, setDraftClearedByCtrlC] = useState(false);
131
138
  const [popupRows, setPopupRows] = useState(0);
132
139
  const [statusExpanded, setStatusExpanded] = useState(false);
133
140
  const dimensions = buildFrameBudget({ ...size, draftRows, popupRows, statusExpanded });
@@ -1145,6 +1152,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1145
1152
  const onDraftRows = useCallback((next) => {
1146
1153
  setDraftRows(previous => previous === next ? previous : next);
1147
1154
  }, []);
1155
+ /**
1156
+ * Draft presence, written to a REF on purpose (ctrl-c-clear-draft-exit):
1157
+ * promoting it to state would re-render the transcript on the first
1158
+ * keystroke of every message - the exact cost Composer.tsx documents
1159
+ * for keeping `hasDraft` local. The ladder reads the ref inside its key
1160
+ * handler, where freshness is guaranteed; the render-time feedback
1161
+ * uses `hasDraftRef.current && !draftClearedByCtrlC` instead.
1162
+ */
1163
+ const hasDraftRef = useRef(false);
1164
+ const onDraftPresence = useCallback((hasDraft) => {
1165
+ hasDraftRef.current = hasDraft;
1166
+ // A draft that re-appears after a ladder clear re-arms the clear rung.
1167
+ if (hasDraft)
1168
+ setDraftClearedByCtrlC(false);
1169
+ }, []);
1148
1170
  // --- Auto-update bridge (cli-auto-update section 3.8 / 6.3). ------------
1149
1171
  //
1150
1172
  // TWO ARRIVAL ORDERS, AND BOTH HAPPEN. The service is built by a dynamic
@@ -1743,7 +1765,43 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1743
1765
  toast('warn', `Stopping ${live} service${live === 1 ? '' : 's'}.`);
1744
1766
  return;
1745
1767
  }
1768
+ //
1769
+ // RENDER-TIMING INVARIANT (part of this fix, not an implementation
1770
+ // detail): the status feedback is evaluated during render, but
1771
+ // `hasDraftRef` is a ref that does not itself trigger one. Every
1772
+ // rung still lands exactly one render - arm bumps `metricsClock`,
1773
+ // the clear rung bumps `draftClearNonce`/`draftClearedByCtrlC` - and
1774
+ // the async clear (nonce -> effect -> 'clear' -> the onDraftChange
1775
+ // transition) refills the ref within the same tick, far faster than
1776
+ // a human's next press. `draftClearedByCtrlC` exists because the
1777
+ // nonce render happens BEFORE that chain: without it the feedback
1778
+ // would still read hasDraft=true and repeat the clear prompt after
1779
+ // clearing. If a future rung ever becomes a pure ref write with no
1780
+ // state change, its feedback text will silently go stale.
1781
+ //
1782
+ // TWO ACCEPTED EDGES (facts drive the ladder, by design):
1783
+ // - arm (empty, "to exit") then PASTE a draft within 1.5s: the
1784
+ // second press clears instead of exiting - the cleared text is
1785
+ // still in the clipboard, so nothing is actually lost.
1786
+ // - arm (draft, "to clear") then Ctrl+U kills the draft by hand:
1787
+ // the second press falls to the exit branch - with nothing left
1788
+ // to clear, the next rung owns the press (tmux/htop behave the
1789
+ // same way).
1790
+ //
1791
+ // RUNG TWO/TREE, composed from two existing facts: armed x
1792
+ // hasDraft. No new counter, no new timer - clearing the draft
1793
+ // flips hasDraftRef to false, so the NEXT press naturally lands on
1794
+ // the exit branch: draft present -> x2 clears, x3 exits; empty ->
1795
+ // x2 exits, byte-identical to the previous behaviour.
1746
1796
  if (ctrlCArmed.current) {
1797
+ if (hasDraftRef.current) {
1798
+ setDraftClearNonce(value => value + 1);
1799
+ setDraftClearedByCtrlC(true);
1800
+ // Stays armed and the 1500ms clock keeps running: the clear
1801
+ // rung must not re-arm the whole ladder (that would make
1802
+ // exiting a draft x4 presses).
1803
+ return;
1804
+ }
1747
1805
  doExit();
1748
1806
  return;
1749
1807
  }
@@ -1755,6 +1813,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1755
1813
  ctrlCTimer.current = setTimeout(() => {
1756
1814
  ctrlCTimer.current = null;
1757
1815
  ctrlCArmed.current = false;
1816
+ setDraftClearedByCtrlC(false);
1758
1817
  setMetricsClock(value => value + 1);
1759
1818
  }, 1500);
1760
1819
  return;
@@ -2135,6 +2194,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2135
2194
  const feedbackProjection = projectStatusFeedback({
2136
2195
  interactionPhase: interactionPhase.current, interruptPhase: interruptGesture.current.phase,
2137
2196
  completion, overlay, copyState, copyResult, ctrlCArmed: ctrlCArmed.current,
2197
+ // The flag, not the raw ref: the nonce render precedes the async
2198
+ // clear, so the ref alone would repeat the clear wording (see the
2199
+ // render-timing invariant beside the ladder).
2200
+ hasDraft: hasDraftRef.current && !draftClearedByCtrlC,
2138
2201
  selectionPending: selectionController?.hasPendingSelection() ?? false, liveServices,
2139
2202
  toast: latestToast ? { text: latestToast.text,
2140
2203
  level: latestToast.level === 'success' ? 'info' : latestToast.level } : undefined,
@@ -2159,10 +2222,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2159
2222
  hints: { interactionPhase: interactionPhase.current, cols, completion, overlay,
2160
2223
  selectionPending: selectionController?.hasPendingSelection() ?? false,
2161
2224
  copyInFlight: copyState.busy, copyCleanupPending: copyState.cleanupPending,
2162
- services: liveServices, hintsEnabled: cfg.hints, modeToggleKey,
2225
+ hasDraft: hasDraftRef.current, services: liveServices,
2226
+ hintsEnabled: cfg.hints, modeToggleKey,
2163
2227
  interruptPhase: interruptGesture.current.phase,
2164
2228
  updateAvailable: !!updateSnapshot && shouldRenderUpdateLine(updateSnapshot) } });
2165
- const composer = (_jsx(Composer, { deleteDisambiguated: terminal?.deleteDisambiguated ?? false, onCompletionContextChange: setCompletion, cols: frameBudget.composerCols, terminalRows: rows, statusExpanded: statusExpanded, 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: false, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: frameBudget.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: handleSubmit, onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2229
+ const composer = (_jsx(Composer, { deleteDisambiguated: terminal?.deleteDisambiguated ?? false, onCompletionContextChange: setCompletion, cols: frameBudget.composerCols, terminalRows: rows, statusExpanded: statusExpanded, 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: false, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: frameBudget.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onDraftRows: onDraftRows, onDraftPresence: onDraftPresence, draftClearNonce: draftClearNonce, onNotice: notify, theme: theme, caps: caps, onSubmit: handleSubmit, onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2166
2230
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2167
2231
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2168
2232
  const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme })] }), rail: rail, scrollbar: terminal?.scrollbar, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, onGeometryChange: onGeometryChange, geometryKey: `${rows}:${cols}:${viewportBudget}:${frameBudget.composerSlotRows}:${frameBudget.statusRows}:${contentCols}:${overlay}:${tooSmall}`, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: transcriptEntries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
@@ -64,6 +64,19 @@ export interface ComposerProps {
64
64
  * character, which is what keeps the re-render rate the same (R-6).
65
65
  */
66
66
  onDraftRows?: (rows: number) => void;
67
+ /**
68
+ * Draft PRESENCE, forwarded upward for the Ctrl+C ladder
69
+ * (ctrl-c-clear-draft-exit). App stores it in a ref only - no state,
70
+ * so the transcript does not re-render on the first keystroke of a
71
+ * message (the same cost the comment above documents).
72
+ */
73
+ onDraftPresence?: (hasDraft: boolean) => void;
74
+ /**
75
+ * Downward half of the same channel: ticks when App asks the editor
76
+ * to clear the draft (Ctrl+C x2 while drafting). Passed through to
77
+ * PromptInput unchanged.
78
+ */
79
+ draftClearNonce?: number;
67
80
  /** Paste refusals from `PromptInput`, routed to `App`'s notice dispatch. */
68
81
  onNotice?: (level: 'warn' | 'error', text: string) => void;
69
82
  theme: Theme;
@@ -88,5 +101,5 @@ declare function hintText(opts: {
88
101
  }): string;
89
102
  /** Compatibility export for existing hint contract tests. */
90
103
  export declare const hintTextForTest: typeof hintText;
91
- export declare function Composer({ deleteDisambiguated, onCompletionContextChange, cols, terminalRows, statusExpanded, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, agentMode, popupMaxRows, popupMaxHeight, onPopupRowsChange, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
104
+ export declare function Composer({ deleteDisambiguated, onCompletionContextChange, cols, terminalRows, statusExpanded, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, agentMode, popupMaxRows, popupMaxHeight, onPopupRowsChange, onDraftRows, onDraftPresence, draftClearNonce, onNotice, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
92
105
  export {};
@@ -36,12 +36,15 @@ function hintText(opts) {
36
36
  }
37
37
  /** Compatibility export for existing hint contract tests. */
38
38
  export const hintTextForTest = hintText;
39
- export function Composer({ deleteDisambiguated, onCompletionContextChange, cols, terminalRows, statusExpanded, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, agentMode, popupMaxRows, popupMaxHeight, onPopupRowsChange, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }) {
39
+ export function Composer({ deleteDisambiguated, onCompletionContextChange, cols, terminalRows, statusExpanded, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, agentMode, popupMaxRows, popupMaxHeight, onPopupRowsChange, onDraftRows, onDraftPresence, draftClearNonce, onNotice, theme, caps, onSubmit, onHelp, }) {
40
40
  const [hasDraft, setHasDraft] = useState(false);
41
41
  const onDraftChange = useCallback((next) => {
42
42
  setHasDraft(next.hasDraft);
43
+ // Presence FIRST: the App ref must be current before the row-count
44
+ // report below can trigger the App render that reads it.
45
+ onDraftPresence?.(next.hasDraft);
43
46
  onDraftRows?.(next.rows);
44
- }, [onDraftRows]);
47
+ }, [onDraftPresence, onDraftRows]);
45
48
  // Plan mode inserts ONE branch, above `draft` and below `running`. The
46
49
  // ordering is load-bearing: an in-flight run's border must stay `toolRunning`,
47
50
  // because "is it running" is more urgent than "which mode".
@@ -54,5 +57,5 @@ export function Composer({ deleteDisambiguated, onCompletionContextChange, cols,
54
57
  : hasDraft
55
58
  ? theme.focusBorder ?? theme.primary
56
59
  : theme.idleBorder ?? theme.border;
57
- return (_jsx(Box, { ref: measureRef, width: cols, flexDirection: "column", flexShrink: 0, children: _jsx(PromptInput, { deleteDisambiguated: deleteDisambiguated, onCompletionContextChange: onCompletionContextChange, cols: cols, terminalRows: terminalRows, statusExpanded: statusExpanded, cursorVisible: cursorVisible, onInteraction: onInteraction, isActive: isActive, reducedMotion: reducedMotion, onEscape: onEscape, onEscapeDismiss: onEscapeDismiss, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, popupMaxHeight: popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, borderColor: borderColor, onDraftChange: onDraftChange, onNotice: onNotice }) }));
60
+ return (_jsx(Box, { ref: measureRef, width: cols, flexDirection: "column", flexShrink: 0, children: _jsx(PromptInput, { deleteDisambiguated: deleteDisambiguated, onCompletionContextChange: onCompletionContextChange, cols: cols, terminalRows: terminalRows, statusExpanded: statusExpanded, cursorVisible: cursorVisible, onInteraction: onInteraction, isActive: isActive, reducedMotion: reducedMotion, onEscape: onEscape, onEscapeDismiss: onEscapeDismiss, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, popupMaxHeight: popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, borderColor: borderColor, onDraftChange: onDraftChange, draftClearNonce: draftClearNonce, onNotice: onNotice }) }));
58
61
  }
@@ -93,6 +93,15 @@ interface PromptInputProps {
93
93
  * component is the only place that holds both facts.
94
94
  */
95
95
  onNotice?: (level: 'warn' | 'error', text: string) => void;
96
+ /**
97
+ * App-owned draft clearing (ctrl-c-clear-draft-exit): the second
98
+ * Ctrl+C of the armed ladder asks the editor to drop the draft. A
99
+ * NUMBER, not a boolean, so repeated requests stay distinguishable
100
+ * and the effect below never re-fires for an unchanged value.
101
+ * `'clear'` returns the editor to INITIAL_EDITOR_STATE, releasing
102
+ * paste payloads with it.
103
+ */
104
+ draftClearNonce?: number;
96
105
  scrolledLines?: number;
97
106
  }
98
107
  export interface ScrollChip {
@@ -159,5 +168,5 @@ export declare function fileTokenAt(buffer: string, cursor: number): {
159
168
  end: number;
160
169
  query: string;
161
170
  } | null;
162
- export declare function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, statusExpanded, cursorVisible, onInteraction, isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, onCompletionContextChange, deleteDisambiguated, agentMode, onSubmit, onHelp, onEscape, onEscapeDismiss, reducedMotion, borderColor, onDraftChange, onNotice, }: PromptInputProps): React.ReactElement;
171
+ export declare function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, statusExpanded, cursorVisible, onInteraction, isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, onCompletionContextChange, deleteDisambiguated, agentMode, onSubmit, onHelp, onEscape, onEscapeDismiss, reducedMotion, borderColor, onDraftChange, draftClearNonce, onNotice, }: PromptInputProps): React.ReactElement;
163
172
  export {};
@@ -244,7 +244,7 @@ function isControlSeq(input) {
244
244
  // ---------------------------------------------------------------------------
245
245
  // Component
246
246
  // ---------------------------------------------------------------------------
247
- export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, statusExpanded = false, cursorVisible = true, onInteraction, isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, onCompletionContextChange, deleteDisambiguated = false, agentMode, onSubmit, onHelp, onEscape, onEscapeDismiss, reducedMotion = false, borderColor, onDraftChange, onNotice, }) {
247
+ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, statusExpanded = false, cursorVisible = true, onInteraction, isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, onCompletionContextChange, deleteDisambiguated = false, agentMode, onSubmit, onHelp, onEscape, onEscapeDismiss, reducedMotion = false, borderColor, onDraftChange, draftClearNonce, onNotice, }) {
248
248
  const { stdout } = useStdout();
249
249
  const [editor, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE);
250
250
  const { buffer, cursor, historyIndex, dismissed, sel } = editor;
@@ -373,6 +373,16 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
373
373
  lastReportedDraft.current = { hasDraft, rows: draftRowCount };
374
374
  onDraftChange({ hasDraft, rows: draftRowCount });
375
375
  }, [hasDraft, draftRowCount, onDraftChange]);
376
+ // --- App-requested draft clear (ctrl-c-clear-draft-exit). --------
377
+ // The NONCE, not the buffer, is the dependency: App cannot reach this
378
+ // reducer directly, and keying on content would miss back-to-back
379
+ // requests that land on the same (empty) buffer. `'clear'` is
380
+ // idempotent, so a StrictMode double-invoked effect is harmless.
381
+ useEffect(() => {
382
+ if (draftClearNonce === undefined || draftClearNonce <= 0)
383
+ return;
384
+ dispatch({ type: 'clear' });
385
+ }, [draftClearNonce]);
376
386
  // --- Mutators. -----------------------------------------------------------
377
387
  // ONE DISPATCH EACH, and that is the rule §3.4 asks a reviewer to check line
378
388
  // by line. Two dispatches from one key would put the legacy root back to two
@@ -160,17 +160,22 @@ export function editorReducer(state, action) {
160
160
  case 'recall':
161
161
  // `historyIndex` is SET, not reset, which is why recall cannot reuse
162
162
  // `replace` — the two differ in exactly the field that keeps the walk
163
- // alive. `dismissed` and `sel` are LEFT ALONE, which is not an oversight:
164
- // the three `setState` calls this replaces did not touch them either, and
165
- // an Esc-dismissed popup that reopened when the user stepped through their
166
- // history would be a behaviour change smuggled in by a refactor whose whole
167
- // claim is that input semantics are identical (K-7).
163
+ // alive. `dismissed` is forced TRUE (history-recall-autocomplete-popup):
164
+ // recalled content is not a query the user is typing, so the suggestion
165
+ // popup must not appear over it - while it is visible it owns Up/Down and
166
+ // the second Up moves the highlight instead of stepping history. Any
167
+ // buffer edit (insert / backspace / replace / input ...) re-arms the
168
+ // popup through DRAFT_FLAGS, so typing over a recall restores them. The
169
+ // same gate covers the `@file` popup, which shares `dismissed`.
170
+ // `sel` is still LEFT ALONE: invisible while dismissed, re-homed by the
171
+ // next buffer change; touching it would only widen the diff (K-7 note).
168
172
  return withPrune({
169
173
  ...state,
170
174
  buffer: action.buffer,
171
175
  cursor: snapGrapheme(action.buffer, action.cursor),
172
176
  preferredVisualColumn: undefined,
173
177
  historyIndex: action.historyIndex,
178
+ dismissed: true,
174
179
  });
175
180
  case 'select':
176
181
  return state.sel === action.sel ? state : { ...state, sel: action.sel };
@@ -68,6 +68,7 @@ export declare const interactionCopy: {
68
68
  readonly confirm: "Enter confirm";
69
69
  readonly stopServices: "Ctrl+C stop services";
70
70
  readonly exit: "Ctrl+C x2 exit";
71
+ readonly exitDraft: "Ctrl+C x2 clear input, x3 exit";
71
72
  readonly help: "/help";
72
73
  readonly shiftNewline: "Shift+Enter newline needs terminal support";
73
74
  readonly mode: "Shift+Tab switch mode";
@@ -26,6 +26,7 @@ export const interactionCopy = {
26
26
  close: 'Esc close', closeCompletion: 'Esc menu', executeCommand: 'Enter command',
27
27
  complete: 'Tab complete', select: 'Up/Down select', scroll: 'PgUp/PgDn scroll',
28
28
  confirm: 'Enter confirm', stopServices: 'Ctrl+C stop services', exit: 'Ctrl+C x2 exit',
29
+ exitDraft: 'Ctrl+C x2 clear input, x3 exit',
29
30
  help: '/help', shiftNewline: 'Shift+Enter newline needs terminal support',
30
31
  mode: 'Shift+Tab switch mode', update: '/update',
31
32
  idlePlaceholder: 'Ask a question or describe a task...', runningPlaceholder: 'Add a follow-up...',
@@ -17,6 +17,8 @@ export interface ActionHintInput {
17
17
  updateAvailable?: boolean;
18
18
  exitHint?: string;
19
19
  modeToggleKey?: string;
20
+ /** Draft present: the exit clause becomes the clear-then-exit ladder. */
21
+ hasDraft?: boolean;
20
22
  }
21
23
  export declare function escapeClause(input: ActionHintInput): string;
22
24
  export declare function clipStatusText(text: string, cells: number): string;
@@ -71,7 +71,7 @@ export function buildActionClauses(input) {
71
71
  if (!feedback && input.hintsEnabled !== false && !input.overlay) {
72
72
  if (input.updateAvailable)
73
73
  add(copy.update);
74
- add(copy.exit);
74
+ add(input.hasDraft ? copy.exitDraft : copy.exit);
75
75
  add((input.modeToggleKey ?? 'Shift+Tab') + ' ' + copy.modeLabel);
76
76
  add(copy.help);
77
77
  }
@@ -83,6 +83,7 @@ function feedbackLabel(input, form) {
83
83
  const labels = {
84
84
  confirm: ['Awaiting confirmation', 'Ask', 'Ask'], stopping: ['Esc stop', 'EscS', 'EscS'],
85
85
  'force-stop': ['Esc force', 'EscF', 'EscF'], exit: ['^C exit', '^Cexit', '^C'],
86
+ 'clear-input': ['^C clear', '^Cclear', '^C'],
86
87
  copying: ['Copying', 'Copy', 'Copy'], copied: ['Copied', 'Copied', 'Copy'],
87
88
  'copy-sent': ['Copy sent', 'Sent', 'Sent'], 'copy-cleanup': ['Copy cleanup', 'Err', 'Err'],
88
89
  'copy-error': ['Copy err', 'Err', 'Err'], notice: ['Notice', 'Notice', 'Note'],
@@ -17,7 +17,10 @@ function keyRows(times) {
17
17
  // either of them is a key the affected user never learns about, which is the
18
18
  // one-shot notice's failure repeated with a different switch.
19
19
  ['Shift+Tab / Ctrl+P', 'Toggle plan mode (same as /plan)'],
20
- [`Ctrl+C ${times}2`, 'Exit'],
20
+ // The ladder clears a draft first (ctrl-c-clear-draft-exit):
21
+ // with a draft x2 clears the input and x3 exits; an empty input
22
+ // keeps the previous x2 exit.
23
+ ['Ctrl+C', `Draft: ${times}2 clear input, ${times}3 exit. Empty: ${times}2 exit`],
21
24
  ['Ctrl+L', 'Redraw the frame'],
22
25
  ['Ctrl+T', 'Show/hide thinking (off by default)'],
23
26
  ['Ctrl+I', 'Build the project index (bundled skill; asks first; needs keyboard enhancement)'],
@@ -128,6 +128,16 @@ function usePickerInput(props, state) {
128
128
  activate();
129
129
  else if (input === '/' && searchable)
130
130
  setSearching(true);
131
+ // `e` edits the focused profile directly (profile-picker-edit-key). The
132
+ // branch is deliberately NOT guarded by `!props.manager`: the manager page
133
+ // gains the same one-keystroke path (there `selected === focused`, see the
134
+ // effect in usePickerState), which spares the Enter-to-the-action-row hop.
135
+ // No-op on the `Current custom` row and the action rows (`focused` is
136
+ // undefined there), and the `searching` early-return above keeps `e` a
137
+ // query character while typing a search.
138
+ else if (!key.ctrl && !key.meta && input.toLowerCase() === 'e' && focused) {
139
+ props.onEdit(focused);
140
+ }
131
141
  });
132
142
  }
133
143
  function profilePickerLabel(props, entry) {
@@ -165,5 +175,6 @@ export function ModelProfilePicker(props) {
165
175
  usePickerInput(props, state);
166
176
  return _jsx(OverlayFrame, { ...props, title: props.error ?? (props.manager ? 'Manage profiles'
167
177
  : `${props.role === 'main' ? 'Main' : 'Fast'} profile`), rows: buildPickerRows(props, state), scrollOffset: state.searching ? 0 : state.offset, hint: state.searching ? 'Type search | Enter done | Esc back'
168
- : 'Enter select/action | Esc back' });
178
+ : props.manager ? 'Enter action | e edit | Esc back'
179
+ : 'Enter select | e edit | Esc back' });
169
180
  }
@@ -1,6 +1,6 @@
1
1
  /** Facts, never translated toast text, determine action ownership and feedback. */
2
2
  export interface StatusFeedback {
3
- kind: 'confirm' | 'stopping' | 'force-stop' | 'exit' | 'copying' | 'copied' | 'copy-sent' | 'copy-cleanup' | 'copy-error' | 'notice';
3
+ kind: 'confirm' | 'stopping' | 'force-stop' | 'exit' | 'clear-input' | 'copying' | 'copied' | 'copy-sent' | 'copy-cleanup' | 'copy-error' | 'notice';
4
4
  text: string;
5
5
  level: 'info' | 'warn' | 'error';
6
6
  }
@@ -19,6 +19,8 @@ export interface StatusFeedbackInput {
19
19
  text: string;
20
20
  };
21
21
  ctrlCArmed?: boolean;
22
+ /** Draft present: the armed feedback names the clear rung, not exit. */
23
+ hasDraft?: boolean;
22
24
  selectionPending?: boolean;
23
25
  liveServices?: number;
24
26
  toast?: {
@@ -7,7 +7,9 @@ export function projectStatusFeedback(input) {
7
7
  let feedback;
8
8
  if (input.ctrlCArmed && !input.copyState?.busy && !input.copyState?.cleanupPending
9
9
  && !input.selectionPending && !input.liveServices) {
10
- feedback = { kind: 'exit', text: 'Press Ctrl+C again to exit', level: 'warn' };
10
+ feedback = input.hasDraft
11
+ ? { kind: 'clear-input', text: 'Press Ctrl+C again to clear input', level: 'warn' }
12
+ : { kind: 'exit', text: 'Press Ctrl+C again to exit', level: 'warn' };
11
13
  }
12
14
  else if (input.copyState?.cleanupPending) {
13
15
  feedback = { kind: 'copy-cleanup', text: 'Copy failed; cleaning up', level: 'error' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.19",
3
+ "version": "0.6.21",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.31",
59
+ "@aragon-agent/core": "^0.2.33",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",