@aragon-agent/cli 0.6.11 → 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 (115) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +97 -20
  3. package/dist/agent/controller.d.ts +38 -3
  4. package/dist/agent/controller.js +151 -60
  5. package/dist/agent/model-profile-settings.d.ts +31 -0
  6. package/dist/agent/model-profile-settings.js +199 -0
  7. package/dist/agent/queued-messages.d.ts +20 -0
  8. package/dist/agent/queued-messages.js +46 -0
  9. package/dist/agent/reducer.d.ts +21 -2
  10. package/dist/agent/reducer.js +65 -37
  11. package/dist/cli.js +15 -1
  12. package/dist/commands/builtins.js +83 -42
  13. package/dist/commands/queue-command.d.ts +3 -0
  14. package/dist/commands/queue-command.js +5 -0
  15. package/dist/commands/registry.d.ts +3 -0
  16. package/dist/compaction/child.d.ts +4 -2
  17. package/dist/compaction/child.js +4 -1
  18. package/dist/compaction/compactor.d.ts +7 -2
  19. package/dist/compaction/compactor.js +13 -11
  20. package/dist/compaction/context-command.js +5 -0
  21. package/dist/compaction/wiring.d.ts +4 -3
  22. package/dist/compaction/wiring.js +6 -1
  23. package/dist/config/cli-commands.d.ts +2 -0
  24. package/dist/config/cli-commands.js +16 -4
  25. package/dist/config/load.d.ts +2 -1
  26. package/dist/config/load.js +15 -8
  27. package/dist/config/model-profile-resolution.d.ts +20 -0
  28. package/dist/config/model-profile-resolution.js +81 -0
  29. package/dist/config/model-profile-store.d.ts +67 -0
  30. package/dist/config/model-profile-store.js +153 -0
  31. package/dist/config/model-profiles.d.ts +44 -0
  32. package/dist/config/model-profiles.js +121 -0
  33. package/dist/config/schema.d.ts +9 -0
  34. package/dist/config/store.d.ts +11 -1
  35. package/dist/config/store.js +43 -27
  36. package/dist/fast/resolve.d.ts +2 -1
  37. package/dist/fast/resolve.js +6 -4
  38. package/dist/fast/wiring.d.ts +5 -3
  39. package/dist/fast/wiring.js +8 -4
  40. package/dist/input/enter-sequences.d.ts +11 -10
  41. package/dist/input/enter-sequences.js +29 -27
  42. package/dist/input/stdin-filter.js +93 -28
  43. package/dist/logging/install.d.ts +3 -0
  44. package/dist/logging/install.js +4 -2
  45. package/dist/logging/secret-registry.d.ts +2 -0
  46. package/dist/logging/secret-registry.js +18 -0
  47. package/dist/session/persist.d.ts +6 -7
  48. package/dist/session/persist.js +6 -9
  49. package/dist/session/store.d.ts +4 -1
  50. package/dist/session/validate-session.d.ts +3 -0
  51. package/dist/session/validate-session.js +207 -0
  52. package/dist/team/runtime.d.ts +2 -1
  53. package/dist/team/subagent.d.ts +3 -1
  54. package/dist/team/subagent.js +3 -1
  55. package/dist/ui/ActivityLine.d.ts +6 -70
  56. package/dist/ui/ActivityLine.js +12 -34
  57. package/dist/ui/App.d.ts +1 -0
  58. package/dist/ui/App.js +161 -226
  59. package/dist/ui/BottomStatusRow.d.ts +6 -86
  60. package/dist/ui/BottomStatusRow.js +11 -26
  61. package/dist/ui/Composer.d.ts +18 -84
  62. package/dist/ui/Composer.js +9 -100
  63. package/dist/ui/PromptInput.d.ts +6 -2
  64. package/dist/ui/PromptInput.js +91 -105
  65. package/dist/ui/QueuePanel.d.ts +8 -0
  66. package/dist/ui/QueuePanel.js +9 -0
  67. package/dist/ui/QueueStatusRow.d.ts +24 -0
  68. package/dist/ui/QueueStatusRow.js +51 -0
  69. package/dist/ui/StatusBar.d.ts +15 -149
  70. package/dist/ui/StatusBar.js +27 -112
  71. package/dist/ui/clipboard-task.d.ts +29 -0
  72. package/dist/ui/clipboard-task.js +66 -0
  73. package/dist/ui/clipboard.d.ts +29 -50
  74. package/dist/ui/clipboard.js +141 -75
  75. package/dist/ui/composer-input.d.ts +28 -0
  76. package/dist/ui/composer-input.js +46 -0
  77. package/dist/ui/composer-limits.d.ts +3 -4
  78. package/dist/ui/composer-limits.js +4 -9
  79. package/dist/ui/composer-rows.d.ts +1 -1
  80. package/dist/ui/composer-rows.js +24 -137
  81. package/dist/ui/editor-navigation.d.ts +45 -0
  82. package/dist/ui/editor-navigation.js +85 -0
  83. package/dist/ui/editor-reducer.d.ts +9 -0
  84. package/dist/ui/editor-reducer.js +31 -15
  85. package/dist/ui/enter-frames.d.ts +15 -31
  86. package/dist/ui/enter-frames.js +14 -33
  87. package/dist/ui/interaction-copy.d.ts +77 -0
  88. package/dist/ui/interaction-copy.js +40 -0
  89. package/dist/ui/interaction-hints.d.ts +18 -0
  90. package/dist/ui/interaction-hints.js +92 -0
  91. package/dist/ui/layout/budget.js +1 -1
  92. package/dist/ui/layout/queue-layout.d.ts +19 -0
  93. package/dist/ui/layout/queue-layout.js +26 -0
  94. package/dist/ui/layout/status-layout.d.ts +55 -0
  95. package/dist/ui/layout/status-layout.js +109 -0
  96. package/dist/ui/layout/todo-layout.d.ts +1 -0
  97. package/dist/ui/layout/todo-layout.js +1 -1
  98. package/dist/ui/overlays/HelpOverlay.js +11 -3
  99. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  100. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  101. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  102. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  103. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  104. package/dist/ui/overlays/QueueOverlay.js +79 -0
  105. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  106. package/dist/ui/overlays/SettingsScreen.js +347 -3
  107. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  108. package/dist/ui/overlays/model-profile-state.js +119 -0
  109. package/dist/ui/run-status-row.d.ts +7 -19
  110. package/dist/ui/run-status-row.js +2 -18
  111. package/dist/ui/selection/selection-controller.d.ts +2 -2
  112. package/dist/ui/selection/selection-controller.js +58 -20
  113. package/dist/ui/transcript-text.js +20 -12
  114. package/dist/ui/use-wheel-routing.js +2 -1
  115. 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
+ }
@@ -0,0 +1,20 @@
1
+ import type { Entry } from './reducer.js';
2
+ /** Full user text retained until Core acknowledges its opaque ID. */
3
+ export interface PendingSteering {
4
+ readonly queueId: string;
5
+ readonly text: string;
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
+ };
19
+ /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
20
+ export declare function mergePendingEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
@@ -0,0 +1,46 @@
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
+ }
19
+ /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
20
+ export function mergePendingEntries(entries, pending) {
21
+ if (pending.length === 0)
22
+ return entries;
23
+ const positions = new Map(pending.map((item, index) => [item.queueId, index]));
24
+ const visible = new Map(entries.flatMap((entry) => entry.kind === 'queued' && entry.queueId !== undefined
25
+ ? [[entry.queueId, entry]] : []));
26
+ const merged = [];
27
+ let cursor = 0;
28
+ const appendThrough = (last) => {
29
+ while (cursor <= last) {
30
+ const item = pending[cursor++];
31
+ const existing = visible.get(item.queueId);
32
+ merged.push(existing ? { ...existing, text: item.text } :
33
+ { id: `pending:${item.queueId}`, kind: 'queued', ...item });
34
+ }
35
+ };
36
+ for (const entry of entries) {
37
+ const position = entry.kind === 'queued' && entry.queueId !== undefined
38
+ ? positions.get(entry.queueId) : undefined;
39
+ if (position === undefined)
40
+ merged.push(entry);
41
+ else
42
+ appendThrough(position);
43
+ }
44
+ appendThrough(pending.length - 1);
45
+ return merged;
46
+ }
@@ -10,6 +10,7 @@
10
10
  * - `agent_end` after a turn that produced no `turn_end` and no error notice
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
+ import type { PendingSteering } from './queued-messages.js';
13
14
  import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agent/core';
14
15
  import type { AgentMode } from './agent-mode.js';
15
16
  import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
@@ -21,7 +22,7 @@ import type { RetryPhase } from './retry-view.js';
21
22
  import type { FilePatch } from '../tools/patch.js';
22
23
  export type NoticeLevel = 'info' | 'warn' | 'error';
23
24
  export type ToastLevel = NoticeLevel | 'success';
24
- export type Overlay = null | 'settings' | 'model' | 'help' | 'confirm' | 'question' | 'plan';
25
+ export type Overlay = null | 'settings' | 'model' | 'help' | 'queue' | 'confirm' | 'question' | 'plan';
25
26
  export type ToolStatus = 'pending' | 'running' | 'done' | 'error';
26
27
  /** Ephemeral, auto-dismissed acknowledgement (spec §3.9). */
27
28
  export interface Toast {
@@ -40,7 +41,7 @@ export type Entry = {
40
41
  /**
41
42
  * A message submitted while a run was in flight (tui-shift-enter-copy-queue
42
43
  * 5.2). It renders as `Queue: <first line>` and is rewritten IN PLACE, same
43
- * id, into the `user` entry above by the `turnStart` that proves Core
44
+ * id, into the `user` entry above by the matching receipt that proves Core
44
45
  * accepted it -- so the id-based height cache re-measures rather than
45
46
  * freezes, and the settled boundary (positional, monotonic) never moves.
46
47
  *
@@ -51,6 +52,7 @@ export type Entry = {
51
52
  id: string;
52
53
  kind: 'queued';
53
54
  text: string;
55
+ queueId?: string;
54
56
  } | {
55
57
  id: string;
56
58
  kind: 'assistant';
@@ -275,6 +277,14 @@ export interface RetrySnapshot {
275
277
  }
276
278
  export interface ViewState {
277
279
  entries: Entry[];
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';
278
288
  status: 'idle' | 'running';
279
289
  usageTotal: UsageTotal;
280
290
  /**
@@ -402,9 +412,17 @@ export type ViewAction = {
402
412
  */
403
413
  | {
404
414
  type: 'steerQueued';
415
+ queueId: string;
405
416
  text: string;
417
+ } | {
418
+ type: 'steeringAccepted';
419
+ ids: readonly string[];
406
420
  } | {
407
421
  type: 'runStart';
422
+ } | {
423
+ type: 'runStarting';
424
+ } | {
425
+ type: 'runError';
408
426
  } | {
409
427
  type: 'turnStart';
410
428
  } | {
@@ -431,6 +449,7 @@ export type ViewAction = {
431
449
  } | {
432
450
  type: 'toolExecStart';
433
451
  toolCallId: string;
452
+ toolName?: string;
434
453
  } | {
435
454
  type: 'toolExecEnd';
436
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';
@@ -46,6 +47,10 @@ const EMPTY_USAGE_TOTAL = {
46
47
  export function initialViewState(seed = {}) {
47
48
  return {
48
49
  entries: [],
50
+ pendingSteering: [],
51
+ seenSteeringIds: new Set(),
52
+ runPhase: 'idle',
53
+ runOutcome: 'none',
49
54
  status: 'idle',
50
55
  usageTotal: EMPTY_USAGE_TOTAL,
51
56
  context: emptyContextUsage(),
@@ -127,6 +132,8 @@ function firstLine(text) {
127
132
  */
128
133
  export function reduceEvent(event, cost, patches) {
129
134
  switch (event.type) {
135
+ case 'steering_accepted':
136
+ return [{ type: 'steeringAccepted', ids: event.ids }];
130
137
  case 'agent_start':
131
138
  return [{ type: 'runStart' }];
132
139
  case 'turn_start':
@@ -142,7 +149,7 @@ export function reduceEvent(event, cost, patches) {
142
149
  case 'agent_end':
143
150
  return [{ type: 'runEnd' }];
144
151
  case 'tool_execution_start':
145
- return [{ type: 'toolExecStart', toolCallId: event.toolCallId }];
152
+ return [{ type: 'toolExecStart', toolCallId: event.toolCallId, toolName: event.toolName }];
146
153
  case 'tool_execution_end': {
147
154
  const patch = patches?.take(event.toolCallId);
148
155
  const preview = buildToolPreview(event.result);
@@ -197,8 +204,6 @@ function reduceStreamEvent(se) {
197
204
  case 'text_delta':
198
205
  return [{ type: 'textDelta', delta: se.delta }];
199
206
  case 'tool_call_start':
200
- if (SELF_RENDERING_TOOLS.has(se.toolName))
201
- return [];
202
207
  return [
203
208
  { type: 'toolCallStart', toolCallId: se.toolCallId, toolName: se.toolName },
204
209
  ];
@@ -210,6 +215,7 @@ function reduceStreamEvent(se) {
210
215
  return [{ type: 'toolCallEnd', toolCallId: se.toolCallId, args: se.args }];
211
216
  case 'error':
212
217
  return [
218
+ { type: 'runError' },
213
219
  {
214
220
  type: 'notice',
215
221
  level: 'error',
@@ -289,29 +295,6 @@ function nextId(state) {
289
295
  function mapEntry(entries, id, fn) {
290
296
  return entries.map((e) => (e.id === id ? fn(e) : e));
291
297
  }
292
- /**
293
- * Rewrite every `queued` entry in place as the user message it became
294
- * (tui-shift-enter-copy-queue 5.2.3).
295
- *
296
- * SAME ID, NEW OBJECT -- the `mapEntry` discipline for a non-append change:
297
- * the position (and therefore the settled boundary, which advances by
298
- * POSITION and never by identity) is untouched, while the new object
299
- * reference is what lets `React.memo` and the height cache see that this
300
- * one row changed. ALL of them at once, because `drainSteering` is a full
301
- * drain: converting only some would show a partial acceptance that never
302
- * happened. Identity-preserving when nothing is queued -- `turnStart` fires
303
- * every turn, and this must stay free for the common case.
304
- */
305
- function convertQueuedEntries(entries) {
306
- let touched = false;
307
- const next = entries.map((e) => {
308
- if (e.kind !== 'queued')
309
- return e;
310
- touched = true;
311
- return { id: e.id, kind: 'user', text: e.text };
312
- });
313
- return touched ? next : entries;
314
- }
315
298
  /**
316
299
  * Seal `thinkingMs`, once, as a spreadable patch (§3.1.2).
317
300
  *
@@ -425,7 +408,40 @@ function findToolEntryId(state, toolCallId) {
425
408
  }
426
409
  return undefined;
427
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
+ }
428
443
  export function viewReducer(state, action) {
444
+ state = reduceRunFacts(state, action);
429
445
  switch (action.type) {
430
446
  case 'submit': {
431
447
  const { id, seq } = nextId(state);
@@ -449,13 +465,29 @@ export function viewReducer(state, action) {
449
465
  };
450
466
  }
451
467
  case 'steerQueued': {
468
+ if (state.seenSteeringIds.has(action.queueId))
469
+ return state;
452
470
  const { id, seq } = nextId(state);
453
- const entry = { id, kind: 'queued', text: action.text };
471
+ const entry = { id, kind: 'queued', queueId: action.queueId, text: action.text };
454
472
  // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
455
473
  // `errorNoticed` and `aborted` describe the run that is still in
456
474
  // flight, and clearing them here would let the next `runEnd` failure
457
475
  // guard misfire on a turn that produced plenty.
458
- return { ...state, seq, ...appendEntry(state, state.entries, entry) };
476
+ return {
477
+ ...state, seq, ...appendEntry(state, state.entries, entry),
478
+ seenSteeringIds: new Set([...state.seenSteeringIds, action.queueId]),
479
+ pendingSteering: [...state.pendingSteering, { queueId: action.queueId, text: action.text }],
480
+ };
481
+ }
482
+ case 'steeringAccepted': {
483
+ const receipt = reconcileSteeringReceipt({
484
+ entries: state.entries, pending: state.pendingSteering, ids: action.ids,
485
+ });
486
+ if (!receipt.changed)
487
+ return state;
488
+ const trimmed = trimEntries(receipt.entries, entryRetain());
489
+ return { ...state, pendingSteering: receipt.pending, entries: trimmed.entries,
490
+ droppedEntries: state.droppedEntries + trimmed.dropped };
459
491
  }
460
492
  case 'runStart':
461
493
  return {
@@ -467,14 +499,6 @@ export function viewReducer(state, action) {
467
499
  };
468
500
  case 'turnStart': {
469
501
  const { id, seq } = nextId(state);
470
- // CONVERT, THEN APPEND, IN THAT ORDER AND IN THIS ONE ACTION
471
- // (5.2.3): `turn_start` is the authoritative "the steering queue was
472
- // drained into the conversation" signal (the loop drains at its top,
473
- // immediately before emitting it), so the user message and the answer
474
- // that follows it must land in the same commit, in render order, or a
475
- // frame can exist where the message has vanished and the answer has
476
- // not appeared.
477
- const entries = convertQueuedEntries(state.entries);
478
502
  const entry = {
479
503
  id,
480
504
  kind: 'assistant',
@@ -483,7 +507,7 @@ export function viewReducer(state, action) {
483
507
  thinkingOpen: false,
484
508
  streaming: true,
485
509
  };
486
- return { ...state, seq, ...appendEntry(state, entries, entry), streamingId: id };
510
+ return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
487
511
  }
488
512
  case 'thinkingStart': {
489
513
  if (!state.streamingId)
@@ -530,6 +554,8 @@ export function viewReducer(state, action) {
530
554
  };
531
555
  }
532
556
  case 'toolCallStart': {
557
+ if (SELF_RENDERING_TOOLS.has(action.toolName))
558
+ return state;
533
559
  const { id, seq } = nextId(state);
534
560
  const entry = {
535
561
  id,
@@ -777,6 +803,7 @@ export function viewReducer(state, action) {
777
803
  case 'resetConversation':
778
804
  return {
779
805
  ...state,
806
+ pendingSteering: [],
780
807
  entries: [],
781
808
  droppedEntries: 0,
782
809
  streamingId: undefined,
@@ -1295,6 +1322,7 @@ export function viewReducer(state, action) {
1295
1322
  return {
1296
1323
  ...state,
1297
1324
  entries: restored.entries,
1325
+ pendingSteering: [],
1298
1326
  droppedEntries: restored.dropped,
1299
1327
  seq: maxSeq,
1300
1328
  teamEntryId: undefined,
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;