@aragon-agent/cli 0.6.12 → 0.6.13

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.
Files changed (101) hide show
  1. package/README.md +81 -15
  2. package/dist/agent/controller.d.ts +29 -1
  3. package/dist/agent/controller.js +123 -22
  4. package/dist/agent/model-profile-settings.d.ts +31 -0
  5. package/dist/agent/model-profile-settings.js +199 -0
  6. package/dist/agent/queued-messages.d.ts +12 -0
  7. package/dist/agent/queued-messages.js +18 -0
  8. package/dist/agent/reducer.d.ts +13 -1
  9. package/dist/agent/reducer.js +51 -12
  10. package/dist/cli.js +15 -1
  11. package/dist/commands/builtins.js +66 -39
  12. package/dist/commands/queue-command.d.ts +3 -0
  13. package/dist/commands/queue-command.js +5 -0
  14. package/dist/commands/registry.d.ts +3 -0
  15. package/dist/compaction/child.d.ts +4 -2
  16. package/dist/compaction/child.js +4 -1
  17. package/dist/compaction/compactor.d.ts +7 -2
  18. package/dist/compaction/compactor.js +13 -11
  19. package/dist/compaction/context-command.js +5 -0
  20. package/dist/compaction/wiring.d.ts +4 -3
  21. package/dist/compaction/wiring.js +6 -1
  22. package/dist/config/cli-commands.d.ts +2 -0
  23. package/dist/config/cli-commands.js +16 -4
  24. package/dist/config/load.d.ts +2 -1
  25. package/dist/config/load.js +15 -8
  26. package/dist/config/model-profile-resolution.d.ts +20 -0
  27. package/dist/config/model-profile-resolution.js +81 -0
  28. package/dist/config/model-profile-store.d.ts +67 -0
  29. package/dist/config/model-profile-store.js +153 -0
  30. package/dist/config/model-profiles.d.ts +44 -0
  31. package/dist/config/model-profiles.js +121 -0
  32. package/dist/config/schema.d.ts +9 -0
  33. package/dist/config/store.d.ts +11 -1
  34. package/dist/config/store.js +43 -27
  35. package/dist/fast/resolve.d.ts +2 -1
  36. package/dist/fast/resolve.js +6 -4
  37. package/dist/fast/wiring.d.ts +5 -3
  38. package/dist/fast/wiring.js +8 -4
  39. package/dist/input/stdin-filter.js +3 -2
  40. package/dist/logging/install.js +4 -2
  41. package/dist/logging/secret-registry.d.ts +2 -0
  42. package/dist/logging/secret-registry.js +18 -0
  43. package/dist/team/runtime.d.ts +2 -1
  44. package/dist/team/subagent.d.ts +3 -1
  45. package/dist/team/subagent.js +3 -1
  46. package/dist/ui/ActivityLine.d.ts +6 -70
  47. package/dist/ui/ActivityLine.js +12 -34
  48. package/dist/ui/App.d.ts +1 -0
  49. package/dist/ui/App.js +113 -205
  50. package/dist/ui/BottomStatusRow.d.ts +6 -86
  51. package/dist/ui/BottomStatusRow.js +11 -26
  52. package/dist/ui/Composer.d.ts +16 -83
  53. package/dist/ui/Composer.js +9 -100
  54. package/dist/ui/PromptInput.d.ts +4 -1
  55. package/dist/ui/PromptInput.js +41 -34
  56. package/dist/ui/QueuePanel.d.ts +8 -0
  57. package/dist/ui/QueuePanel.js +9 -0
  58. package/dist/ui/QueueStatusRow.d.ts +2 -0
  59. package/dist/ui/QueueStatusRow.js +1 -1
  60. package/dist/ui/StatusBar.d.ts +13 -149
  61. package/dist/ui/StatusBar.js +27 -142
  62. package/dist/ui/clipboard-task.d.ts +29 -0
  63. package/dist/ui/clipboard-task.js +66 -0
  64. package/dist/ui/clipboard.d.ts +29 -50
  65. package/dist/ui/clipboard.js +141 -75
  66. package/dist/ui/composer-limits.d.ts +3 -4
  67. package/dist/ui/composer-limits.js +4 -9
  68. package/dist/ui/composer-rows.d.ts +1 -1
  69. package/dist/ui/composer-rows.js +24 -137
  70. package/dist/ui/editor-navigation.d.ts +45 -0
  71. package/dist/ui/editor-navigation.js +85 -0
  72. package/dist/ui/editor-reducer.d.ts +4 -0
  73. package/dist/ui/editor-reducer.js +29 -15
  74. package/dist/ui/interaction-copy.d.ts +77 -0
  75. package/dist/ui/interaction-copy.js +40 -0
  76. package/dist/ui/interaction-hints.d.ts +18 -0
  77. package/dist/ui/interaction-hints.js +92 -0
  78. package/dist/ui/layout/budget.js +1 -1
  79. package/dist/ui/layout/queue-layout.d.ts +19 -0
  80. package/dist/ui/layout/queue-layout.js +26 -0
  81. package/dist/ui/layout/status-layout.d.ts +55 -0
  82. package/dist/ui/layout/status-layout.js +109 -0
  83. package/dist/ui/layout/todo-layout.d.ts +1 -0
  84. package/dist/ui/layout/todo-layout.js +1 -1
  85. package/dist/ui/overlays/HelpOverlay.js +8 -8
  86. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  87. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  88. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  89. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  90. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  91. package/dist/ui/overlays/QueueOverlay.js +79 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  93. package/dist/ui/overlays/SettingsScreen.js +347 -3
  94. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  95. package/dist/ui/overlays/model-profile-state.js +119 -0
  96. package/dist/ui/run-status-row.d.ts +7 -19
  97. package/dist/ui/run-status-row.js +2 -18
  98. package/dist/ui/selection/selection-controller.d.ts +2 -2
  99. package/dist/ui/selection/selection-controller.js +1 -0
  100. package/dist/ui/use-wheel-routing.js +2 -1
  101. package/package.json +2 -2
@@ -0,0 +1,199 @@
1
+ import { loadConfig } from '../config/load.js';
2
+ import { DEFAULT_CONFIG } from '../config/schema.js';
3
+ import { mergePersistedConfig } from '../config/store.js';
4
+ import { adaptLegacyConnectionPatch, assertLegacyFastTarget, commitModelSettings, readModelSettingsDisk, } from '../config/model-profile-store.js';
5
+ import { projectModelProfiles, resolveModelProfileState } from '../config/model-profile-resolution.js';
6
+ import { validateModelProfiles } from '../config/model-profiles.js';
7
+ import { registerProfileSecrets } from '../logging/secret-registry.js';
8
+ /** Capture both baselines when the editor opens, without migrating or writing anything. */
9
+ export function createModelSettingsDraft(live, liveRevision) {
10
+ const disk = readModelSettingsDisk();
11
+ return {
12
+ diskRevision: disk.revision, liveRevision,
13
+ baseline: structuredClone(disk.config ?? DEFAULT_CONFIG),
14
+ liveBaseline: structuredClone(live),
15
+ profiles: disk.config?.modelProfiles
16
+ ? structuredClone(disk.config.modelProfiles) : undefined,
17
+ activateRoles: [], patch: {},
18
+ ...(!disk.ok ? { readError: 'Cannot read config. Repair it with aragon config edit.' } : {}),
19
+ };
20
+ }
21
+ /** Map stable failure codes to safe UI text; never include parser or SDK error messages. */
22
+ export function modelSettingsResultMessage(result) {
23
+ if (result.status === 'saved_apply_failed') {
24
+ return result.persisted
25
+ ? 'Saved, but this session could not apply all settings. Restart required.'
26
+ : 'Could not apply the reloaded settings completely. Restart required.';
27
+ }
28
+ if (result.status === 'restart_required') {
29
+ return 'Saved. Fast tier requires a restart; check its model and credentials.';
30
+ }
31
+ const messages = {
32
+ busy: 'Wait for the run, team, review, or compaction to finish before saving.',
33
+ conflict: 'Settings changed elsewhere. Reload before saving.',
34
+ read_failed: 'Cannot read config. Repair it with aragon config edit.',
35
+ write_failed: 'Could not write config. Your draft is preserved.',
36
+ invalid: 'Invalid model settings. Check the highlighted field or aragon config edit.',
37
+ profile_key_required: 'This profile has a dedicated key. Edit or copy it in /settings.',
38
+ legacy_conversion_unsupported: 'This endpoint needs a profile. Edit it in /settings.',
39
+ };
40
+ return result.ok ? 'Settings saved.' : messages[result.code ?? 'invalid'];
41
+ }
42
+ function rejected(code) {
43
+ return { ok: false, persisted: false, status: 'rejected', code };
44
+ }
45
+ function connectionChanged(draft, role) {
46
+ if (draft.profiles === undefined)
47
+ return false;
48
+ const before = draft.baseline.modelProfiles;
49
+ const after = draft.profiles;
50
+ if ((before?.[`${role}Id`] ?? null) !== (after?.[`${role}Id`] ?? null))
51
+ return true;
52
+ const id = after?.[`${role}Id`];
53
+ if (!id)
54
+ return false;
55
+ const a = before?.entries.find((entry) => entry.id === id);
56
+ const b = after?.entries.find((entry) => entry.id === id);
57
+ return ['provider', 'model', 'baseUrl', 'apiKey']
58
+ .some((key) => a?.[key] !== b?.[key]);
59
+ }
60
+ function hasConnectionPatch(patch, role) {
61
+ const section = role === 'main' ? patch : patch.fast;
62
+ return !!section && ['provider', 'model', 'baseUrl'].some((key) => key in section);
63
+ }
64
+ function liveCandidate(live, candidate, draft) {
65
+ const patch = draft.patch;
66
+ const normalizedPatch = {};
67
+ for (const key of Object.keys(patch)) {
68
+ normalizedPatch[key] = candidate[key];
69
+ }
70
+ const next = { ...live, ...normalizedPatch };
71
+ for (const section of ['apiKeys', 'fast', 'compaction', 'log']) {
72
+ const normalized = { ...live[section] };
73
+ for (const key of Object.keys(patch[section] ?? {})) {
74
+ normalized[key] = candidate[section][key];
75
+ }
76
+ Object.assign(next, { [section]: normalized });
77
+ }
78
+ if (patch.apiKeys && Object.hasOwn(patch.apiKeys, live.provider)) {
79
+ next.apiKeyOverride = undefined;
80
+ next.apiKeyOverrideTarget = undefined;
81
+ }
82
+ // These fields have intentionally different disk and runtime representations.
83
+ if ('maxTokens' in patch)
84
+ next.maxTokens = candidate.maxTokens ?? undefined;
85
+ if ('baseUrl' in patch)
86
+ next.baseUrl = candidate.baseUrl ?? undefined;
87
+ const projected = projectModelProfiles(candidate).file;
88
+ if (draft.profiles !== undefined || candidate.modelProfiles !== undefined) {
89
+ next.modelProfiles = structuredClone(projected.modelProfiles);
90
+ }
91
+ next.liveToolOutput = live.liveToolOutput;
92
+ for (const role of ['main', 'fast']) {
93
+ if (!draft.activateRoles.includes(role) && !connectionChanged(draft, role)
94
+ && !hasConnectionPatch(patch, role))
95
+ continue;
96
+ if (role === 'main') {
97
+ next.provider = projected.provider;
98
+ next.model = projected.model;
99
+ next.baseUrl = projected.baseUrl ?? undefined;
100
+ next.apiKeyOverride = undefined;
101
+ next.apiKeyOverrideTarget = undefined;
102
+ }
103
+ else {
104
+ next.fast = { ...next.fast, provider: projected.fast.provider,
105
+ model: projected.fast.model, baseUrl: projected.fast.baseUrl };
106
+ }
107
+ }
108
+ if (next.apiKeyOverride && !next.apiKeyOverrideTarget
109
+ && (next.modelProfiles?.mainId || next.modelProfiles?.fastId)) {
110
+ next.apiKeyOverrideTarget = { provider: live.provider, baseUrl: live.baseUrl ?? null };
111
+ }
112
+ next.modelProfileState = resolveModelProfileState(next);
113
+ return next;
114
+ }
115
+ /** Validate and prepare everything before the one synchronous, atomic disk commit. */
116
+ export function saveModelSettings(input) {
117
+ const { draft, controller } = input;
118
+ if (controller.isModelSettingsBusy())
119
+ return rejected('busy');
120
+ if (draft.liveRevision !== controller.getSettingsRevision())
121
+ return rejected('conflict');
122
+ const disk = readModelSettingsDisk();
123
+ if (!disk.ok || draft.readError)
124
+ return rejected('read_failed');
125
+ if (disk.revision !== draft.diskRevision)
126
+ return rejected('conflict');
127
+ const issues = validateModelProfiles(draft.profiles ?? disk.config?.modelProfiles);
128
+ if (issues.length)
129
+ return { ...rejected('invalid'), issues };
130
+ let snapshot;
131
+ let candidate;
132
+ try {
133
+ const current = disk.config ?? structuredClone(DEFAULT_CONFIG);
134
+ const profilesChanged = draft.activateRoles.length > 0
135
+ || JSON.stringify(draft.profiles) !== JSON.stringify(draft.baseline.modelProfiles);
136
+ const implicitLegacy = draft.profiles === undefined || !profilesChanged;
137
+ const patch = adaptLegacyConnectionPatch({ current, patch: implicitLegacy
138
+ ? draft.patch : { ...draft.patch, modelProfiles: draft.profiles } });
139
+ candidate = mergePersistedConfig(current, patch);
140
+ registerProfileSecrets(candidate);
141
+ const next = liveCandidate(controller.getConfig(), candidate, draft);
142
+ // Disk preferences cannot reveal startup/session gateways retained by the live main role.
143
+ if (implicitLegacy && current.modelProfiles?.fastId && hasConnectionPatch(draft.patch, 'fast')) {
144
+ assertLegacyFastTarget(next.fast, next);
145
+ }
146
+ snapshot = controller.prepareModelSettingsSnapshot(next);
147
+ }
148
+ catch (error) {
149
+ const code = error.code ?? 'invalid';
150
+ return rejected(code);
151
+ }
152
+ const commit = commitModelSettings({ candidate, diskRevision: draft.diskRevision });
153
+ if (!commit.ok)
154
+ return rejected(commit.code ?? 'write_failed');
155
+ return applyPreparedSnapshot(controller, snapshot, true);
156
+ }
157
+ function applyPreparedSnapshot(controller, snapshot, persisted) {
158
+ try {
159
+ controller.applyModelSettingsSnapshot(snapshot);
160
+ }
161
+ catch {
162
+ controller.blockModelSettingsRequests();
163
+ return { ok: false, persisted, status: 'saved_apply_failed', code: 'apply_failed', restartRequired: true };
164
+ }
165
+ try {
166
+ controller.applyModelSettingsEffects();
167
+ }
168
+ catch {
169
+ return { ok: false, persisted, status: 'saved_apply_failed', code: 'apply_failed', restartRequired: true };
170
+ }
171
+ const restartRequired = snapshot.config.fast.enabled && !controller.isFastRegistered();
172
+ return { ok: true, persisted, status: restartRequired ? 'restart_required' : 'applied',
173
+ restartRequired };
174
+ }
175
+ /** Reload the complete model snapshot; invalid disk content never replaces a live session. */
176
+ export function reloadModelSettings(controller) {
177
+ if (controller.isModelSettingsBusy())
178
+ return rejected('busy');
179
+ const disk = readModelSettingsDisk();
180
+ if (!disk.ok)
181
+ return rejected('read_failed');
182
+ if (validateModelProfiles(disk.config?.modelProfiles).length)
183
+ return rejected('invalid');
184
+ try {
185
+ const resolved = loadConfig({ cwd: controller.getConfig().cwd });
186
+ resolved.liveToolOutput = controller.getConfig().liveToolOutput;
187
+ const after = readModelSettingsDisk();
188
+ if (!after.ok)
189
+ return rejected('read_failed');
190
+ if (after.revision !== disk.revision)
191
+ return rejected('conflict');
192
+ if (resolved.modelProfileState?.main.invalid)
193
+ return rejected('invalid');
194
+ return applyPreparedSnapshot(controller, controller.prepareModelSettingsSnapshot(resolved), false);
195
+ }
196
+ catch {
197
+ return rejected('invalid');
198
+ }
199
+ }
@@ -4,5 +4,17 @@ export interface PendingSteering {
4
4
  readonly queueId: string;
5
5
  readonly text: string;
6
6
  }
7
+ /** Hide only active pending entries, using the same projection for measurement and paint. */
8
+ export declare function selectTranscriptEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
9
+ /** Restore trimmed messages in FIFO order before atomically accepting known receipts. */
10
+ export declare function reconcileSteeringReceipt(options: {
11
+ entries: Entry[];
12
+ pending: readonly PendingSteering[];
13
+ ids: readonly string[];
14
+ }): {
15
+ entries: Entry[];
16
+ pending: PendingSteering[];
17
+ changed: boolean;
18
+ };
7
19
  /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
8
20
  export declare function mergePendingEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
@@ -1,3 +1,21 @@
1
+ /** Hide only active pending entries, using the same projection for measurement and paint. */
2
+ export function selectTranscriptEntries(entries, pending) {
3
+ if (pending.length === 0)
4
+ return entries;
5
+ const ids = new Set(pending.map((item) => item.queueId));
6
+ return entries.filter((entry) => entry.kind !== 'queued' || !ids.has(entry.queueId ?? ''));
7
+ }
8
+ /** Restore trimmed messages in FIFO order before atomically accepting known receipts. */
9
+ export function reconcileSteeringReceipt(options) {
10
+ const receipts = new Set(options.ids);
11
+ const ids = new Set(options.pending.filter((item) => receipts.has(item.queueId))
12
+ .map((item) => item.queueId));
13
+ if (!ids.size)
14
+ return { entries: options.entries, pending: [...options.pending], changed: false };
15
+ const entries = mergePendingEntries(options.entries, options.pending).map((entry) => entry.kind === 'queued' && ids.has(entry.queueId ?? '')
16
+ ? { id: entry.id, kind: 'user', text: entry.text } : entry);
17
+ return { entries, pending: options.pending.filter((item) => !ids.has(item.queueId)), changed: true };
18
+ }
1
19
  /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
2
20
  export function mergePendingEntries(entries, pending) {
3
21
  if (pending.length === 0)
@@ -22,7 +22,7 @@ import type { RetryPhase } from './retry-view.js';
22
22
  import type { FilePatch } from '../tools/patch.js';
23
23
  export type NoticeLevel = 'info' | 'warn' | 'error';
24
24
  export type ToastLevel = NoticeLevel | 'success';
25
- export type Overlay = null | 'settings' | 'model' | 'help' | 'confirm' | 'question' | 'plan';
25
+ export type Overlay = null | 'settings' | 'model' | 'help' | 'queue' | 'confirm' | 'question' | 'plan';
26
26
  export type ToolStatus = 'pending' | 'running' | 'done' | 'error';
27
27
  /** Ephemeral, auto-dismissed acknowledgement (spec §3.9). */
28
28
  export interface Toast {
@@ -278,6 +278,13 @@ export interface RetrySnapshot {
278
278
  export interface ViewState {
279
279
  entries: Entry[];
280
280
  pendingSteering: PendingSteering[];
281
+ seenSteeringIds: ReadonlySet<string>;
282
+ runPhase: 'idle' | 'starting' | 'waiting' | 'thinking' | 'generating' | 'preparing-tool' | 'tool';
283
+ activeTool?: {
284
+ toolCallId: string;
285
+ name: string;
286
+ };
287
+ runOutcome: 'none' | 'ended' | 'interrupted' | 'failed';
281
288
  status: 'idle' | 'running';
282
289
  usageTotal: UsageTotal;
283
290
  /**
@@ -412,6 +419,10 @@ export type ViewAction = {
412
419
  ids: readonly string[];
413
420
  } | {
414
421
  type: 'runStart';
422
+ } | {
423
+ type: 'runStarting';
424
+ } | {
425
+ type: 'runError';
415
426
  } | {
416
427
  type: 'turnStart';
417
428
  } | {
@@ -438,6 +449,7 @@ export type ViewAction = {
438
449
  } | {
439
450
  type: 'toolExecStart';
440
451
  toolCallId: string;
452
+ toolName?: string;
441
453
  } | {
442
454
  type: 'toolExecEnd';
443
455
  toolCallId: string;
@@ -11,6 +11,7 @@
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
13
  import process from 'node:process';
14
+ import { reconcileSteeringReceipt } from './queued-messages.js';
14
15
  import { emptyContextUsage } from '../compaction/meter.js';
15
16
  import { addUsage, computeCost } from './usage.js';
16
17
  import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
@@ -47,6 +48,9 @@ export function initialViewState(seed = {}) {
47
48
  return {
48
49
  entries: [],
49
50
  pendingSteering: [],
51
+ seenSteeringIds: new Set(),
52
+ runPhase: 'idle',
53
+ runOutcome: 'none',
50
54
  status: 'idle',
51
55
  usageTotal: EMPTY_USAGE_TOTAL,
52
56
  context: emptyContextUsage(),
@@ -145,7 +149,7 @@ export function reduceEvent(event, cost, patches) {
145
149
  case 'agent_end':
146
150
  return [{ type: 'runEnd' }];
147
151
  case 'tool_execution_start':
148
- return [{ type: 'toolExecStart', toolCallId: event.toolCallId }];
152
+ return [{ type: 'toolExecStart', toolCallId: event.toolCallId, toolName: event.toolName }];
149
153
  case 'tool_execution_end': {
150
154
  const patch = patches?.take(event.toolCallId);
151
155
  const preview = buildToolPreview(event.result);
@@ -200,8 +204,6 @@ function reduceStreamEvent(se) {
200
204
  case 'text_delta':
201
205
  return [{ type: 'textDelta', delta: se.delta }];
202
206
  case 'tool_call_start':
203
- if (SELF_RENDERING_TOOLS.has(se.toolName))
204
- return [];
205
207
  return [
206
208
  { type: 'toolCallStart', toolCallId: se.toolCallId, toolName: se.toolName },
207
209
  ];
@@ -213,6 +215,7 @@ function reduceStreamEvent(se) {
213
215
  return [{ type: 'toolCallEnd', toolCallId: se.toolCallId, args: se.args }];
214
216
  case 'error':
215
217
  return [
218
+ { type: 'runError' },
216
219
  {
217
220
  type: 'notice',
218
221
  level: 'error',
@@ -405,7 +408,40 @@ function findToolEntryId(state, toolCallId) {
405
408
  }
406
409
  return undefined;
407
410
  }
411
+ /** Runtime facts outlive transcript cards and are independent of command/clipboard notices. */
412
+ function reduceRunFacts(state, action) {
413
+ switch (action.type) {
414
+ case 'runStarting': return { ...state, runPhase: 'starting', runOutcome: 'none' };
415
+ case 'runStart': return { ...state, runPhase: 'waiting', runOutcome: 'none', activeTool: undefined };
416
+ case 'turnStart': return { ...state, runPhase: 'waiting' };
417
+ case 'thinkingStart':
418
+ case 'thinkingDelta': return { ...state, runPhase: 'thinking' };
419
+ case 'textDelta': return { ...state, runPhase: 'generating' };
420
+ case 'toolCallStart':
421
+ case 'toolCallDelta':
422
+ case 'toolCallEnd': return { ...state, runPhase: 'preparing-tool' };
423
+ case 'toolExecStart': {
424
+ const card = state.entries.find((entry) => entry.kind === 'tool' && entry.toolCallId === action.toolCallId);
425
+ return { ...state, runPhase: 'tool', activeTool: {
426
+ toolCallId: action.toolCallId,
427
+ name: action.toolName ?? (card?.kind === 'tool' ? card.name : 'tool'),
428
+ } };
429
+ }
430
+ case 'toolExecEnd': return state.activeTool?.toolCallId === action.toolCallId
431
+ ? { ...state, runPhase: 'waiting', activeTool: undefined } : state;
432
+ case 'runError': return { ...state, runOutcome: 'failed' };
433
+ case 'abortMark': return { ...state, runOutcome: 'interrupted' };
434
+ case 'runEnd': return { ...state, runPhase: 'idle', activeTool: undefined,
435
+ runOutcome: state.runOutcome !== 'none' ? state.runOutcome
436
+ : !state.turnProduced && !state.errorNoticed && !state.aborted ? 'failed' : 'ended' };
437
+ case 'resetConversation':
438
+ case 'restoreEntries': return { ...state, runPhase: 'idle', activeTool: undefined,
439
+ runOutcome: 'none', seenSteeringIds: new Set() };
440
+ default: return state;
441
+ }
442
+ }
408
443
  export function viewReducer(state, action) {
444
+ state = reduceRunFacts(state, action);
409
445
  switch (action.type) {
410
446
  case 'submit': {
411
447
  const { id, seq } = nextId(state);
@@ -429,6 +465,8 @@ export function viewReducer(state, action) {
429
465
  };
430
466
  }
431
467
  case 'steerQueued': {
468
+ if (state.seenSteeringIds.has(action.queueId))
469
+ return state;
432
470
  const { id, seq } = nextId(state);
433
471
  const entry = { id, kind: 'queued', queueId: action.queueId, text: action.text };
434
472
  // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
@@ -437,20 +475,19 @@ export function viewReducer(state, action) {
437
475
  // guard misfire on a turn that produced plenty.
438
476
  return {
439
477
  ...state, seq, ...appendEntry(state, state.entries, entry),
478
+ seenSteeringIds: new Set([...state.seenSteeringIds, action.queueId]),
440
479
  pendingSteering: [...state.pendingSteering, { queueId: action.queueId, text: action.text }],
441
480
  };
442
481
  }
443
482
  case 'steeringAccepted': {
444
- const receipts = new Set(action.ids);
445
- const ids = new Set(state.pendingSteering
446
- .filter((item) => receipts.has(item.queueId)).map((item) => item.queueId));
447
- if (ids.size === 0)
483
+ const receipt = reconcileSteeringReceipt({
484
+ entries: state.entries, pending: state.pendingSteering, ids: action.ids,
485
+ });
486
+ if (!receipt.changed)
448
487
  return state;
449
- const pendingSteering = state.pendingSteering.filter((item) => !ids.has(item.queueId));
450
- const entries = state.entries.map((entry) => entry.kind === 'queued' && entry.queueId !== undefined && ids.has(entry.queueId)
451
- ? { id: entry.id, kind: 'user', text: entry.text }
452
- : entry);
453
- return { ...state, pendingSteering, entries };
488
+ const trimmed = trimEntries(receipt.entries, entryRetain());
489
+ return { ...state, pendingSteering: receipt.pending, entries: trimmed.entries,
490
+ droppedEntries: state.droppedEntries + trimmed.dropped };
454
491
  }
455
492
  case 'runStart':
456
493
  return {
@@ -517,6 +554,8 @@ export function viewReducer(state, action) {
517
554
  };
518
555
  }
519
556
  case 'toolCallStart': {
557
+ if (SELF_RENDERING_TOOLS.has(action.toolName))
558
+ return state;
520
559
  const { id, seq } = nextId(state);
521
560
  const entry = {
522
561
  id,
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ import '../runtime/insecure-tls-warning.cjs';
18
18
  // Must precede every other dependency that imports Ink: CI detection is cached.
19
19
  import { render } from './ui/ink-runtime.js';
20
20
  import { Command } from 'commander';
21
+ import { ModelProfileConfigError } from './config/model-profile-store.js';
21
22
  import { loadConfig } from './config/load.js';
22
23
  import { getConfigPath, readConfigFile, updatePersistedConfig } from './config/store.js';
23
24
  import { formatMigrationNotice, migrateLegacyState } from './config/migrate-legacy-state.js';
@@ -458,7 +459,7 @@ function runInteractive(flags, extras = {}) {
458
459
  // advising the user about a mode they just disabled.
459
460
  const wantPaste = config.paste && !!process.stdin.isTTY;
460
461
  const pasteBridge = { notify: null };
461
- const filter = wantMouse || wantPaste
462
+ const filter = process.stdin.isTTY
462
463
  ? tryCreateStdinFilter(process.stdin, { mouse: wantMouse, paste: wantPaste }, (reason) => logger.warn('cli', 'stdin_filter_failed', { reason }), pasteBridge)
463
464
  : null;
464
465
  /**
@@ -648,6 +649,7 @@ function runInteractive(flags, extras = {}) {
648
649
  };
649
650
  const terminalBridge = {
650
651
  mouseSelect: wantSelect,
652
+ deleteDisambiguated: filter !== null,
651
653
  scrollbar,
652
654
  ...(mouseOn
653
655
  ? {
@@ -1009,6 +1011,18 @@ function runConfigSetContextWindow(value) {
1009
1011
  process.stdout.write(`Set contextWindow = ${merged.contextWindow ?? 'auto'}\n`);
1010
1012
  }
1011
1013
  function runConfigSet(key, value) {
1014
+ try {
1015
+ runConfigSetValue(key, value);
1016
+ }
1017
+ catch (error) {
1018
+ const invalid = error instanceof ModelProfileConfigError
1019
+ && !['read_failed', 'write_failed'].includes(error.code);
1020
+ process.stderr.write(error instanceof ModelProfileConfigError
1021
+ ? `${error.message}\n` : 'Could not write config. Check file permissions.\n');
1022
+ process.exitCode = invalid ? 2 : 1;
1023
+ }
1024
+ }
1025
+ function runConfigSetValue(key, value) {
1012
1026
  if (!CONFIG_SET_KEYS.has(key)) {
1013
1027
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
1014
1028
  process.exitCode = 2;