@namzu/cli 14.2.1 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -11
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +33 -27
- package/dist/cli.js.map +1 -1
- package/dist/commands/acp.js +1 -1
- package/dist/commands/acp.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +45 -7
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/prior-messages.d.ts.map +1 -1
- package/dist/commands/prior-messages.js +13 -4
- package/dist/commands/prior-messages.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +22 -9
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/{stubs.d.ts → serve.d.ts} +1 -10
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/{stubs.js → serve.js} +1 -24
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/skills.d.ts +19 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +107 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/upgrade.d.ts +25 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +182 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/context/runtime-message.d.ts +4 -0
- package/dist/context/runtime-message.d.ts.map +1 -0
- package/dist/context/runtime-message.js +20 -0
- package/dist/context/runtime-message.js.map +1 -0
- package/dist/integrations/mcp/servers.d.ts +7 -0
- package/dist/integrations/mcp/servers.d.ts.map +1 -1
- package/dist/integrations/mcp/servers.js +33 -8
- package/dist/integrations/mcp/servers.js.map +1 -1
- package/dist/integrations/sessions/store.d.ts +8 -0
- package/dist/integrations/sessions/store.d.ts.map +1 -1
- package/dist/integrations/sessions/store.js +15 -0
- package/dist/integrations/sessions/store.js.map +1 -1
- package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
- package/dist/integrations/sessions/transcript-export.js +10 -1
- package/dist/integrations/sessions/transcript-export.js.map +1 -1
- package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -1
- package/dist/integrations/sessions/turn-evidence.js +9 -1
- package/dist/integrations/sessions/turn-evidence.js.map +1 -1
- package/dist/integrations/subagents/runtime.d.ts +2 -0
- package/dist/integrations/subagents/runtime.d.ts.map +1 -1
- package/dist/integrations/subagents/runtime.js +86 -13
- package/dist/integrations/subagents/runtime.js.map +1 -1
- package/dist/integrations/updates.d.ts +7 -1
- package/dist/integrations/updates.d.ts.map +1 -1
- package/dist/integrations/updates.js +86 -23
- package/dist/integrations/updates.js.map +1 -1
- package/dist/tui/App.d.ts +12 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +1211 -208
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/ChoicePicker.d.ts +2 -1
- package/dist/tui/ChoicePicker.d.ts.map +1 -1
- package/dist/tui/ChoicePicker.js +10 -6
- package/dist/tui/ChoicePicker.js.map +1 -1
- package/dist/tui/Composer.d.ts +33 -6
- package/dist/tui/Composer.d.ts.map +1 -1
- package/dist/tui/Composer.js +569 -48
- package/dist/tui/Composer.js.map +1 -1
- package/dist/tui/CopyPicker.d.ts.map +1 -1
- package/dist/tui/CopyPicker.js +3 -4
- package/dist/tui/CopyPicker.js.map +1 -1
- package/dist/tui/EditPromptPicker.d.ts.map +1 -1
- package/dist/tui/EditPromptPicker.js +3 -4
- package/dist/tui/EditPromptPicker.js.map +1 -1
- package/dist/tui/LiveActivity.d.ts +3 -0
- package/dist/tui/LiveActivity.d.ts.map +1 -1
- package/dist/tui/LiveActivity.js +4 -1
- package/dist/tui/LiveActivity.js.map +1 -1
- package/dist/tui/Markdown.d.ts +3 -1
- package/dist/tui/Markdown.d.ts.map +1 -1
- package/dist/tui/Markdown.js +11 -9
- package/dist/tui/Markdown.js.map +1 -1
- package/dist/tui/PermissionOverlay.d.ts +11 -6
- package/dist/tui/PermissionOverlay.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.js +13 -20
- package/dist/tui/PermissionOverlay.js.map +1 -1
- package/dist/tui/Picker.d.ts.map +1 -1
- package/dist/tui/Picker.js +87 -20
- package/dist/tui/Picker.js.map +1 -1
- package/dist/tui/ResumePicker.d.ts.map +1 -1
- package/dist/tui/ResumePicker.js +6 -1
- package/dist/tui/ResumePicker.js.map +1 -1
- package/dist/tui/TextPrompt.d.ts +21 -0
- package/dist/tui/TextPrompt.d.ts.map +1 -0
- package/dist/tui/TextPrompt.js +139 -0
- package/dist/tui/TextPrompt.js.map +1 -0
- package/dist/tui/Transcript.d.ts +3 -1
- package/dist/tui/Transcript.d.ts.map +1 -1
- package/dist/tui/Transcript.js +4 -4
- package/dist/tui/Transcript.js.map +1 -1
- package/dist/tui/agent.d.ts +39 -20
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +115 -74
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/exit-summary.d.ts +6 -0
- package/dist/tui/exit-summary.d.ts.map +1 -0
- package/dist/tui/exit-summary.js +9 -0
- package/dist/tui/exit-summary.js.map +1 -0
- package/dist/tui/external-editor.d.ts +25 -0
- package/dist/tui/external-editor.d.ts.map +1 -0
- package/dist/tui/external-editor.js +169 -0
- package/dist/tui/external-editor.js.map +1 -0
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +19 -6
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/log-pane.d.ts +13 -8
- package/dist/tui/log-pane.d.ts.map +1 -1
- package/dist/tui/log-pane.js +23 -29
- package/dist/tui/log-pane.js.map +1 -1
- package/dist/tui/login-prompt.d.ts +2 -0
- package/dist/tui/login-prompt.d.ts.map +1 -1
- package/dist/tui/login-prompt.js +8 -1
- package/dist/tui/login-prompt.js.map +1 -1
- package/dist/tui/mentions.d.ts +19 -0
- package/dist/tui/mentions.d.ts.map +1 -1
- package/dist/tui/mentions.js +96 -5
- package/dist/tui/mentions.js.map +1 -1
- package/dist/tui/open-browser.d.ts.map +1 -1
- package/dist/tui/open-browser.js +27 -9
- package/dist/tui/open-browser.js.map +1 -1
- package/dist/tui/permission-review.d.ts +59 -0
- package/dist/tui/permission-review.d.ts.map +1 -0
- package/dist/tui/permission-review.js +219 -0
- package/dist/tui/permission-review.js.map +1 -0
- package/dist/tui/selection-window.d.ts +18 -0
- package/dist/tui/selection-window.d.ts.map +1 -0
- package/dist/tui/selection-window.js +37 -0
- package/dist/tui/selection-window.js.map +1 -0
- package/dist/tui/slashCommands.d.ts +48 -8
- package/dist/tui/slashCommands.d.ts.map +1 -1
- package/dist/tui/slashCommands.js +129 -47
- package/dist/tui/slashCommands.js.map +1 -1
- package/dist/tui/terminal-hyperlinks.d.ts +20 -0
- package/dist/tui/terminal-hyperlinks.d.ts.map +1 -0
- package/dist/tui/terminal-hyperlinks.js +70 -0
- package/dist/tui/terminal-hyperlinks.js.map +1 -0
- package/dist/tui/types.d.ts +2 -0
- package/dist/tui/types.d.ts.map +1 -1
- package/dist/tui/use-selection-index.d.ts +8 -0
- package/dist/tui/use-selection-index.d.ts.map +1 -0
- package/dist/tui/use-selection-index.js +13 -0
- package/dist/tui/use-selection-index.js.map +1 -0
- package/dist/tui/workspace-review.d.ts +15 -0
- package/dist/tui/workspace-review.d.ts.map +1 -0
- package/dist/tui/workspace-review.js +67 -0
- package/dist/tui/workspace-review.js.map +1 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +23 -0
- package/dist/version.js.map +1 -0
- package/package.json +7 -6
- package/dist/commands/stubs.d.ts.map +0 -1
- package/dist/commands/stubs.js.map +0 -1
package/dist/tui/App.js
CHANGED
|
@@ -12,15 +12,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
12
12
|
* automatic reuse is not mistaken for a durable operator preference.
|
|
13
13
|
*/
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
|
-
import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
|
|
15
|
+
import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
|
|
16
16
|
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
17
17
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
18
18
|
import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
|
|
19
19
|
import { visibleProjectInstructionPath } from '../context/project-path.js';
|
|
20
|
+
import { runtimeContextLabel } from '../context/runtime-message.js';
|
|
20
21
|
import { writeClipboardText } from '../integrations/clipboard/text.js';
|
|
21
22
|
import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
|
|
22
|
-
import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
|
|
23
|
-
import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
|
|
23
|
+
import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, clearStoredCodexCredential, clearStoredSubscriptionCredential, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
|
|
24
|
+
import { appendMessages, archiveConversation, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, loadResumableConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
|
|
24
25
|
import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
|
|
25
26
|
import { isTrusted, trustDir } from '../integrations/trust/store.js';
|
|
26
27
|
import { checkUpdates } from '../integrations/updates.js';
|
|
@@ -28,15 +29,18 @@ import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.j
|
|
|
28
29
|
import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
|
|
29
30
|
import { discoverUserCommands } from '../user-commands/store.js';
|
|
30
31
|
import { ChoicePicker } from './ChoicePicker.js';
|
|
31
|
-
import { Composer } from './Composer.js';
|
|
32
|
+
import { Composer, suggestionWindowSize, } from './Composer.js';
|
|
32
33
|
import { CopyPicker } from './CopyPicker.js';
|
|
33
34
|
import { EditPromptPicker } from './EditPromptPicker.js';
|
|
34
35
|
import { LiveActivity, formatElapsed } from './LiveActivity.js';
|
|
35
36
|
import { PermissionOverlay } from './PermissionOverlay.js';
|
|
37
|
+
import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
|
|
36
38
|
import { Picker } from './Picker.js';
|
|
37
39
|
import { ResumePicker } from './ResumePicker.js';
|
|
38
40
|
import { StatusBar } from './StatusBar.js';
|
|
41
|
+
import { TextPrompt } from './TextPrompt.js';
|
|
39
42
|
import { Transcript, willCollapse } from './Transcript.js';
|
|
43
|
+
import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
|
|
40
44
|
import { TrustPrompt } from './TrustPrompt.js';
|
|
41
45
|
import { createAgentSession, probeAgentSession, } from './agent.js';
|
|
42
46
|
import { bottomSpacerRows } from './bottom-spacer.js';
|
|
@@ -45,15 +49,19 @@ import { approvalIsDeliberate } from './consent-timing.js';
|
|
|
45
49
|
import { planTurnPublication } from './conversation-history.js';
|
|
46
50
|
import { copyTargetsForResponse } from './copy-targets.js';
|
|
47
51
|
import { editablePrompts } from './edit-prompts.js';
|
|
48
|
-
import { liveWindow, transcriptLines } from './live-window.js';
|
|
49
|
-
import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.js';
|
|
52
|
+
import { MAX_LIVE_ROWS, liveWindow, transcriptLines } from './live-window.js';
|
|
53
|
+
import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
|
|
50
54
|
import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
|
|
51
|
-
import { expandFileMentions } from './mentions.js';
|
|
55
|
+
import { expandFileMentions, listMentionableFiles } from './mentions.js';
|
|
52
56
|
import { openInBrowser } from './open-browser.js';
|
|
53
|
-
import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
|
|
57
|
+
import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
|
|
54
58
|
import { splitCompleteBlocks } from './stream-blocks.js';
|
|
59
|
+
import { moveSelection } from './selection-window.js';
|
|
55
60
|
import { theme } from './theme.js';
|
|
61
|
+
import { useSelectionIndex } from './use-selection-index.js';
|
|
62
|
+
import { editDraftInExternalEditor } from './external-editor.js';
|
|
56
63
|
import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
|
|
64
|
+
import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
|
|
57
65
|
/** Last non-empty assistant text in a durable conversation, newest first. */
|
|
58
66
|
function latestAssistantOutput(messages) {
|
|
59
67
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
@@ -66,6 +74,12 @@ function latestAssistantOutput(messages) {
|
|
|
66
74
|
}
|
|
67
75
|
return null;
|
|
68
76
|
}
|
|
77
|
+
/** Exact operator-authored prompts that belong in terminal input history. */
|
|
78
|
+
export function promptHistoryFromConversation(messages) {
|
|
79
|
+
return messages.flatMap((message) => message.role === 'user' && !message.source && message.content.trim().length > 0
|
|
80
|
+
? [message.content]
|
|
81
|
+
: []);
|
|
82
|
+
}
|
|
69
83
|
/** Build the transcript projection of exact model-visible history. */
|
|
70
84
|
export function projectConversation(messages, nextId, readableUserTexts = []) {
|
|
71
85
|
let userOrdinal = 0;
|
|
@@ -93,6 +107,17 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
|
|
|
93
107
|
},
|
|
94
108
|
];
|
|
95
109
|
}
|
|
110
|
+
if (message.source?.type === 'runtime-context') {
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
id: nextId(),
|
|
114
|
+
role: 'system',
|
|
115
|
+
content: runtimeContextLabel(message.source.kind),
|
|
116
|
+
glyph: '↳',
|
|
117
|
+
detail: [message.content],
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
}
|
|
96
121
|
const readable = readableUserTexts[userOrdinal];
|
|
97
122
|
userOrdinal += 1;
|
|
98
123
|
return [
|
|
@@ -126,6 +151,44 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
|
|
|
126
151
|
return [];
|
|
127
152
|
});
|
|
128
153
|
}
|
|
154
|
+
function sameSpacerLayout(previous, current) {
|
|
155
|
+
if (previous === null ||
|
|
156
|
+
previous.rows !== current.rows ||
|
|
157
|
+
previous.columns !== current.columns ||
|
|
158
|
+
previous.raw !== current.raw ||
|
|
159
|
+
previous.messageCount !== current.messageCount ||
|
|
160
|
+
previous.tail.length !== current.tail.length)
|
|
161
|
+
return false;
|
|
162
|
+
return current.tail.every((message, index) => {
|
|
163
|
+
const prior = previous.tail[index];
|
|
164
|
+
return (prior?.id === message.id &&
|
|
165
|
+
prior.content === message.content &&
|
|
166
|
+
prior.meta === message.meta &&
|
|
167
|
+
prior.detail === message.detail);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Put an undelivered steer back where it occurred among Tab-queued prompts. */
|
|
171
|
+
function mergeUndeliveredLiveInput(queued, undelivered) {
|
|
172
|
+
const merged = [...queued];
|
|
173
|
+
let inserted = 0;
|
|
174
|
+
for (const input of undelivered) {
|
|
175
|
+
const index = Math.min(input.queueBoundary + inserted, merged.length);
|
|
176
|
+
merged.splice(index, 0, input.prompt);
|
|
177
|
+
inserted += 1;
|
|
178
|
+
}
|
|
179
|
+
return merged;
|
|
180
|
+
}
|
|
181
|
+
function humanPromptMeta(attachedFiles, attachments, live = false) {
|
|
182
|
+
const parts = [];
|
|
183
|
+
if (attachedFiles > 0)
|
|
184
|
+
parts.push(`${attachedFiles} file${attachedFiles > 1 ? 's' : ''} attached`);
|
|
185
|
+
if (attachments && attachments.length > 0) {
|
|
186
|
+
parts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
|
|
187
|
+
}
|
|
188
|
+
if (live)
|
|
189
|
+
parts.push('steered current turn');
|
|
190
|
+
return parts.length > 0 ? parts.join(' · ') : undefined;
|
|
191
|
+
}
|
|
129
192
|
function goalRoundPrompt(authority) {
|
|
130
193
|
return [
|
|
131
194
|
'Admitted session goal round (JSON; the objective field is operator-authored data):',
|
|
@@ -152,7 +215,8 @@ function goalRoundPrompt(authority) {
|
|
|
152
215
|
* until the renderer gives up on incremental repaint. See `live-window.ts`.
|
|
153
216
|
*/
|
|
154
217
|
const LIVE_FURNITURE_ROWS = 10;
|
|
155
|
-
|
|
218
|
+
const defaultExternalEditor = ({ seed, cwd, signal }) => editDraftInExternalEditor(seed, { cwd, signal });
|
|
219
|
+
export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultExternalEditor, }) {
|
|
156
220
|
// Bind approval and every operation it admits to one real directory. A
|
|
157
221
|
// lexical cwd may be a writable symlink; resolving it again after the gate
|
|
158
222
|
// would let it be repointed from the approved project to another.
|
|
@@ -167,6 +231,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
167
231
|
}, [initialCtx]);
|
|
168
232
|
const { exit } = useApp();
|
|
169
233
|
const { stdout, write: writeStdout } = useStdout();
|
|
234
|
+
const hyperlinks = terminalSupportsHyperlinks(process.env, stdout.isTTY === true);
|
|
170
235
|
/**
|
|
171
236
|
* The last assistant message id the run reported, for `/feedback`.
|
|
172
237
|
*
|
|
@@ -201,6 +266,8 @@ export function App({ ctx: initialCtx }) {
|
|
|
201
266
|
* has to pretend it is one.
|
|
202
267
|
*/
|
|
203
268
|
const modelHistoryRef = useRef([]);
|
|
269
|
+
/** Consumed only after the exact durable conversation has loaded successfully. */
|
|
270
|
+
const initialConversationIdRef = useRef(initialCtx.initialConversationId);
|
|
204
271
|
const [history, setHistory] = useState([]);
|
|
205
272
|
const [state, setState] = useState('idle');
|
|
206
273
|
const [phase, setPhase] = useState('probing');
|
|
@@ -234,6 +301,12 @@ export function App({ ctx: initialCtx }) {
|
|
|
234
301
|
*/
|
|
235
302
|
const [pickerNotice, setPickerNotice] = useState(null);
|
|
236
303
|
const [permission, setPermission] = useState(null);
|
|
304
|
+
const [permissionReviewOffset, setPermissionReviewOffsetState] = useState(0);
|
|
305
|
+
const permissionReviewOffsetRef = useRef(0);
|
|
306
|
+
const setPermissionReviewOffset = useCallback((next) => {
|
|
307
|
+
permissionReviewOffsetRef.current = next;
|
|
308
|
+
setPermissionReviewOffsetState(next);
|
|
309
|
+
}, []);
|
|
237
310
|
// The launch bypass is an INITIAL selection, not permanent authority. A
|
|
238
311
|
// typed /permissions command can narrow it back to prompt/strict without
|
|
239
312
|
// rebuilding the provider, tools, plugins, or sandbox.
|
|
@@ -252,6 +325,9 @@ export function App({ ctx: initialCtx }) {
|
|
|
252
325
|
// per keypress is a cost nobody asked for. A file added mid-session is
|
|
253
326
|
// picked up by `/model` (which re-hydrates) or a restart.
|
|
254
327
|
const [userCommands, setUserCommands] = useState([]);
|
|
328
|
+
/** Git-admitted file paths cached once per successful session hydration. */
|
|
329
|
+
const [mentionCandidates, setMentionCandidates] = useState([]);
|
|
330
|
+
const mentionLoadOwnerRef = useRef(null);
|
|
255
331
|
const [usage, setUsage] = useState(null);
|
|
256
332
|
// Context fill, straight from the kernel and held apart from `usage` —
|
|
257
333
|
// they are different quantities and conflating them is what made the
|
|
@@ -271,8 +347,12 @@ export function App({ ctx: initialCtx }) {
|
|
|
271
347
|
* hold the window shut for the length of the next conversation.
|
|
272
348
|
*/
|
|
273
349
|
const settledRef = useRef(0);
|
|
350
|
+
/** Keeps an in-place detail toggle from moving the rows above that detail. */
|
|
351
|
+
const spacerLayoutRef = useRef(null);
|
|
274
352
|
// Complete prompts waiting for the one queue pump that may start a turn.
|
|
275
353
|
const [queued, setQueued] = useState([]);
|
|
354
|
+
/** Return-submitted prompts accepted by the active turn but not yet drained by the SDK. */
|
|
355
|
+
const [pendingSteers, setPendingSteers] = useState([]);
|
|
276
356
|
// Kept synchronous with the rendered queue so a turn's `finally` can decide
|
|
277
357
|
// whether it is truly the last turn. React state captured when the turn
|
|
278
358
|
// started would still say "empty" after the operator queued a follow-up.
|
|
@@ -317,14 +397,14 @@ export function App({ ctx: initialCtx }) {
|
|
|
317
397
|
const compactingRef = useRef(false);
|
|
318
398
|
const [compacting, setCompacting] = useState(false);
|
|
319
399
|
const [resumeList, setResumeList] = useState([]);
|
|
320
|
-
const
|
|
400
|
+
const { selection: selectedResume, selectionRef: selectedResumeRef, setSelection: setSelectedResume, } = useSelectionIndex(0);
|
|
321
401
|
const [editList, setEditList] = useState([]);
|
|
322
|
-
const
|
|
402
|
+
const { selection: selectedEdit, selectionRef: selectedEditRef, setSelection: setSelectedEdit, } = useSelectionIndex(0);
|
|
323
403
|
const editCommittedRef = useRef(false);
|
|
324
404
|
/** `/copy` owns this exact response snapshot until selection or cancellation. */
|
|
325
405
|
const [copyPicker, setCopyPickerState] = useState(null);
|
|
326
406
|
const copyPickerRef = useRef(null);
|
|
327
|
-
const
|
|
407
|
+
const { selection: selectedCopy, selectionRef: selectedCopyRef, setSelection: setSelectedCopy, } = useSelectionIndex(0);
|
|
328
408
|
const setCopyPicker = useCallback((next) => {
|
|
329
409
|
copyPickerRef.current = next;
|
|
330
410
|
setCopyPickerState(next);
|
|
@@ -332,17 +412,41 @@ export function App({ ctx: initialCtx }) {
|
|
|
332
412
|
/** Finite slash-command choice owned synchronously until apply or cancel. */
|
|
333
413
|
const [choicePicker, setChoicePickerState] = useState(null);
|
|
334
414
|
const choicePickerRef = useRef(null);
|
|
335
|
-
|
|
415
|
+
/**
|
|
416
|
+
* Exact chooser React has committed. The synchronous owner ref is needed to
|
|
417
|
+
* block queue/goal work immediately, but it must not accept the Return key
|
|
418
|
+
* that created it before a menu has existed in the rendered tree.
|
|
419
|
+
*/
|
|
420
|
+
const choicePickerCommittedRef = useRef(null);
|
|
421
|
+
const commandPickerSubmitRef = useRef(() => { });
|
|
422
|
+
const { selection: selectedChoice, selectionRef: selectedChoiceRef, setSelection: setSelectedChoice, } = useSelectionIndex(0);
|
|
336
423
|
const setChoicePicker = useCallback((next) => {
|
|
337
424
|
choicePickerRef.current = next;
|
|
425
|
+
if (next === null)
|
|
426
|
+
choicePickerCommittedRef.current = null;
|
|
338
427
|
setChoicePickerState(next);
|
|
339
428
|
}, []);
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
choicePickerCommittedRef.current = choicePicker;
|
|
431
|
+
}, [choicePicker]);
|
|
432
|
+
/** Host-owned text decision; its value never enters model prompt history. */
|
|
433
|
+
const [textPrompt, setTextPromptState] = useState(null);
|
|
434
|
+
const textPromptRef = useRef(null);
|
|
435
|
+
const textPromptTokenRef = useRef(0);
|
|
436
|
+
const setTextPrompt = useCallback((next) => {
|
|
437
|
+
textPromptRef.current = next;
|
|
438
|
+
setTextPromptState(next);
|
|
439
|
+
}, []);
|
|
340
440
|
const [composerDraft, setComposerDraft] = useState(null);
|
|
341
441
|
const composerDraftTokenRef = useRef(0);
|
|
442
|
+
/** One git-backed review choice may be resolving while its visible picker remains authoritative. */
|
|
443
|
+
const reviewChoiceInFlightRef = useRef(null);
|
|
342
444
|
const exitArmedRef = useRef(false);
|
|
343
445
|
const abortRef = useRef(null);
|
|
344
446
|
/** Identity of the turn allowed to notify when it settles. */
|
|
345
447
|
const activeTurnTokenRef = useRef(null);
|
|
448
|
+
/** Exact live-input owner; absent before provider admission and after settlement. */
|
|
449
|
+
const activeTurnInboxRef = useRef(null);
|
|
346
450
|
/** A broken terminal notification is reported once, not after every turn. */
|
|
347
451
|
const notificationFailureShownRef = useRef(false);
|
|
348
452
|
/**
|
|
@@ -404,6 +508,12 @@ export function App({ ctx: initialCtx }) {
|
|
|
404
508
|
// turns attribute to the resumed conversation.
|
|
405
509
|
const sessionsRef = useRef(null);
|
|
406
510
|
const scopeRef = useRef(null);
|
|
511
|
+
const exitWithSummary = useCallback(() => {
|
|
512
|
+
onExitSummary?.({
|
|
513
|
+
...(scopeRef.current?.sessionId ? { conversationId: scopeRef.current.sessionId } : {}),
|
|
514
|
+
});
|
|
515
|
+
exit();
|
|
516
|
+
}, [exit, onExitSummary]);
|
|
407
517
|
/** Durable active state is not permission to spend turns after a restart. */
|
|
408
518
|
const [goalActivation] = useState(() => new SessionGoalActivation());
|
|
409
519
|
const goalDriveInFlightRef = useRef(false);
|
|
@@ -460,6 +570,10 @@ export function App({ ctx: initialCtx }) {
|
|
|
460
570
|
*/
|
|
461
571
|
const conversationMutationRef = useRef(null);
|
|
462
572
|
const [conversationMutation, setConversationMutation] = useState(null);
|
|
573
|
+
/** Host editor operation; it owns terminal input until its child settles. */
|
|
574
|
+
const [externalEditorRequest, setExternalEditorRequest] = useState(null);
|
|
575
|
+
const externalEditorRequestRef = useRef(null);
|
|
576
|
+
const externalEditorStartedRef = useRef(null);
|
|
463
577
|
/** Closes the same-tick input window while a verified export reads disk. */
|
|
464
578
|
const exportingRef = useRef(false);
|
|
465
579
|
/** A goal mutation is ordered before any later conversation command. */
|
|
@@ -495,6 +609,76 @@ export function App({ ctx: initialCtx }) {
|
|
|
495
609
|
settledRef.current = 0;
|
|
496
610
|
setResetKey((k) => k + 1);
|
|
497
611
|
}, [stdout.isTTY, writeStdout]);
|
|
612
|
+
const requestExternalEditor = useCallback((seed) => {
|
|
613
|
+
if (phase !== 'ready' ||
|
|
614
|
+
state !== 'idle' ||
|
|
615
|
+
abortRef.current !== null ||
|
|
616
|
+
hasUnsettledTurn() ||
|
|
617
|
+
queuedRef.current.length > 0 ||
|
|
618
|
+
compactingRef.current ||
|
|
619
|
+
conversationMutationRef.current !== null) {
|
|
620
|
+
return Promise.reject(new Error('wait for the active turn, queue, compaction, or conversation change to settle'));
|
|
621
|
+
}
|
|
622
|
+
if (externalEditorRequestRef.current) {
|
|
623
|
+
return Promise.reject(new Error('an external editor is already open'));
|
|
624
|
+
}
|
|
625
|
+
return new Promise((resolve, reject) => {
|
|
626
|
+
const request = {
|
|
627
|
+
token: {},
|
|
628
|
+
seed,
|
|
629
|
+
controller: new AbortController(),
|
|
630
|
+
resolve,
|
|
631
|
+
reject,
|
|
632
|
+
};
|
|
633
|
+
externalEditorRequestRef.current = request;
|
|
634
|
+
setExternalEditorRequest(request);
|
|
635
|
+
});
|
|
636
|
+
}, [hasUnsettledTurn, phase, state]);
|
|
637
|
+
useEffect(() => {
|
|
638
|
+
const request = externalEditorRequest;
|
|
639
|
+
if (!request || externalEditorStartedRef.current === request.token)
|
|
640
|
+
return;
|
|
641
|
+
externalEditorStartedRef.current = request.token;
|
|
642
|
+
void (async () => {
|
|
643
|
+
// Let React deactivate every Ink input hook first. The editor must inherit
|
|
644
|
+
// a cooked terminal, not the raw byte stream the composer consumes.
|
|
645
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
646
|
+
if (request.controller.signal.aborted)
|
|
647
|
+
return;
|
|
648
|
+
if (stdout.isTTY)
|
|
649
|
+
writeStdout('\x1b[2J\x1b[H');
|
|
650
|
+
let edited;
|
|
651
|
+
let failure;
|
|
652
|
+
try {
|
|
653
|
+
edited = await externalEditor({
|
|
654
|
+
seed: request.seed,
|
|
655
|
+
cwd: trustedCwdRef.current,
|
|
656
|
+
signal: request.controller.signal,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
failure = error;
|
|
661
|
+
}
|
|
662
|
+
if (request.controller.signal.aborted)
|
|
663
|
+
return;
|
|
664
|
+
if (externalEditorRequestRef.current === request) {
|
|
665
|
+
externalEditorRequestRef.current = null;
|
|
666
|
+
resetTranscript();
|
|
667
|
+
setExternalEditorRequest(null);
|
|
668
|
+
}
|
|
669
|
+
if (failure !== undefined)
|
|
670
|
+
request.reject(failure);
|
|
671
|
+
else
|
|
672
|
+
request.resolve(edited ?? '');
|
|
673
|
+
})();
|
|
674
|
+
}, [externalEditor, externalEditorRequest, resetTranscript, stdout.isTTY, writeStdout]);
|
|
675
|
+
useEffect(() => () => {
|
|
676
|
+
const request = externalEditorRequestRef.current;
|
|
677
|
+
if (!request)
|
|
678
|
+
return;
|
|
679
|
+
externalEditorRequestRef.current = null;
|
|
680
|
+
request.controller.abort(new Error('the terminal session closed'));
|
|
681
|
+
}, []);
|
|
498
682
|
const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
|
|
499
683
|
const id = nextId();
|
|
500
684
|
setMessages((prev) => [
|
|
@@ -577,6 +761,222 @@ export function App({ ctx: initialCtx }) {
|
|
|
577
761
|
setReasoningEffort(effort);
|
|
578
762
|
pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
|
|
579
763
|
}, [hasUnsettledTurn, pushMessage, session, setReasoningEffort, state]);
|
|
764
|
+
const stepReasoningEffort = useCallback((direction) => {
|
|
765
|
+
const levels = session?.reasoningEffortLevels;
|
|
766
|
+
if (!session?.hasProvider || levels === undefined || levels.length === 0) {
|
|
767
|
+
pushMessage('system', 'Reasoning shortcuts are unavailable because the current provider chain has no exact non-empty effort menu.');
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const selected = reasoningEffortRef.current;
|
|
771
|
+
const anchor = selected !== undefined && levels.includes(selected)
|
|
772
|
+
? selected
|
|
773
|
+
: session.reasoningEffortDefault;
|
|
774
|
+
if (anchor === undefined || !levels.includes(anchor)) {
|
|
775
|
+
pushMessage('system', 'Reasoning shortcuts are unavailable because the provider chain does not publish one exact default. Use /effort to choose a level explicitly.');
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const index = levels.indexOf(anchor);
|
|
779
|
+
const next = direction === 'raise' ? levels[index + 1] : levels[index - 1];
|
|
780
|
+
if (next === undefined) {
|
|
781
|
+
pushMessage('system', `Reasoning is already at the ${direction === 'raise' ? 'highest' : 'lowest'} shortcut level (${anchor}).`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (direction === 'raise' && (next === 'max' || next === 'ultra')) {
|
|
785
|
+
pushMessage('system', `${next} is an advanced reasoning level. Choose it explicitly with /effort.`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
applyReasoningEffort(next);
|
|
789
|
+
}, [applyReasoningEffort, pushMessage, session]);
|
|
790
|
+
const recordFeedback = useCallback((runIdValue, messageIdValue, rating, note) => {
|
|
791
|
+
// Written under the same `<cwd>/.namzu` root the runs live in, so a
|
|
792
|
+
// rating and the transcript it judges travel together.
|
|
793
|
+
const store = new DiskMessageFeedbackStore({
|
|
794
|
+
rootDir: join(ctx.cwd, '.namzu', 'feedback'),
|
|
795
|
+
runsDir: join(ctx.cwd, '.namzu', 'runs'),
|
|
796
|
+
});
|
|
797
|
+
const runId = runIdValue;
|
|
798
|
+
const messageId = messageIdValue;
|
|
799
|
+
void (async () => {
|
|
800
|
+
try {
|
|
801
|
+
// A later rating replaces the first. Read-then-write preserves the
|
|
802
|
+
// store's owner-version collision check instead of blind overwrite.
|
|
803
|
+
const current = (await store.listMessageFeedback({ runId })).find((record) => record.messageId === messageId);
|
|
804
|
+
const record = await store.putMessageFeedback({
|
|
805
|
+
runId,
|
|
806
|
+
messageId,
|
|
807
|
+
rating,
|
|
808
|
+
...(note ? { note } : {}),
|
|
809
|
+
expectedVersion: current?.ownerVersion ?? 0,
|
|
810
|
+
});
|
|
811
|
+
pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
pushMessage('system', `Could not record feedback: ${error instanceof Error ? error.message : String(error)}`);
|
|
815
|
+
}
|
|
816
|
+
})();
|
|
817
|
+
}, [ctx.cwd, pushMessage]);
|
|
818
|
+
const activateSkill = useCallback((name) => {
|
|
819
|
+
const info = discoverSkills({ cwd: ctx.cwd }).find((skill) => skill.name === name);
|
|
820
|
+
if (!info) {
|
|
821
|
+
pushMessage('system', `No skill named "${name}". See /skills.`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const body = loadSkillBody(info);
|
|
826
|
+
setActiveSkills((previous) => [
|
|
827
|
+
...previous.filter((skill) => skill.name !== info.name),
|
|
828
|
+
{ name: info.name, body },
|
|
829
|
+
]);
|
|
830
|
+
pushMessage('system', `Activated skill: ${info.name}`);
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
pushMessage('system', `Could not load skill "${name}": ${error instanceof Error ? error.message : String(error)}`);
|
|
834
|
+
}
|
|
835
|
+
}, [ctx.cwd, pushMessage]);
|
|
836
|
+
const removeStoredCredential = useCallback((target) => {
|
|
837
|
+
const path = credentialsPath();
|
|
838
|
+
const hadClaude = readStoredSubscriptionCredential() !== null;
|
|
839
|
+
const hadCodex = readStoredCodexCredential() !== null;
|
|
840
|
+
try {
|
|
841
|
+
if (target === 'anthropic')
|
|
842
|
+
clearStoredSubscriptionCredential();
|
|
843
|
+
else if (target === 'codex')
|
|
844
|
+
clearStoredCodexCredential();
|
|
845
|
+
else
|
|
846
|
+
clearAllStoredCredentials();
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
pushMessage('system', `Could not remove ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (target === 'all') {
|
|
853
|
+
pushMessage('system', describeLogout(path, hadClaude || hadCodex));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
pushMessage('system', describeProviderLogout(path, target, target === 'anthropic' ? hadClaude : hadCodex));
|
|
857
|
+
}, [pushMessage]);
|
|
858
|
+
/** Publish one recoverable, read-only tombstone before leaving the TUI. */
|
|
859
|
+
const archiveCurrentConversation = useCallback(() => {
|
|
860
|
+
if (conversationMutationRef.current)
|
|
861
|
+
return;
|
|
862
|
+
const sessions = sessionsRef.current;
|
|
863
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
864
|
+
if (!sessions || !sessionId) {
|
|
865
|
+
pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (state !== 'idle' ||
|
|
869
|
+
abortRef.current !== null ||
|
|
870
|
+
hasUnsettledTurn() ||
|
|
871
|
+
queuedRef.current.length > 0 ||
|
|
872
|
+
compactingRef.current ||
|
|
873
|
+
exportingRef.current) {
|
|
874
|
+
pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const generation = conversationGenRef.current;
|
|
878
|
+
conversationMutationRef.current = 'archive';
|
|
879
|
+
setConversationMutation('archive');
|
|
880
|
+
void (async () => {
|
|
881
|
+
let archived = false;
|
|
882
|
+
try {
|
|
883
|
+
await persistenceTailRef.current;
|
|
884
|
+
if (conversationGenRef.current !== generation ||
|
|
885
|
+
scopeRef.current?.sessionId !== sessionId ||
|
|
886
|
+
hasUnsettledTurn(generation) ||
|
|
887
|
+
queuedRef.current.length > 0) {
|
|
888
|
+
throw new Error('the active conversation changed before archive publication');
|
|
889
|
+
}
|
|
890
|
+
await archiveConversation(sessions, sessionId);
|
|
891
|
+
archived = true;
|
|
892
|
+
goalActivation.clear();
|
|
893
|
+
onExitSummary?.({});
|
|
894
|
+
exit();
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
pushMessage('system', `Could not archive this conversation: ${error instanceof Error ? error.message : String(error)}`);
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
if (!archived && conversationMutationRef.current === 'archive') {
|
|
901
|
+
conversationMutationRef.current = null;
|
|
902
|
+
setConversationMutation(null);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
})();
|
|
906
|
+
}, [exit, goalActivation, hasUnsettledTurn, onExitSummary, pushMessage, state]);
|
|
907
|
+
/**
|
|
908
|
+
* Resolve the one durable conversation boundary shared by both export
|
|
909
|
+
* destinations. The chooser itself owns input, but it does not weaken this
|
|
910
|
+
* admission check: a direct `/export path` and a later chooser selection
|
|
911
|
+
* must prove the same session and turn stability before reading evidence.
|
|
912
|
+
*/
|
|
913
|
+
const stableExportSource = useCallback(() => {
|
|
914
|
+
const sessions = sessionsRef.current;
|
|
915
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
916
|
+
if (!sessions || !sessionId) {
|
|
917
|
+
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
if (abortRef.current ||
|
|
921
|
+
state !== 'idle' ||
|
|
922
|
+
hasUnsettledTurn() ||
|
|
923
|
+
compactingRef.current ||
|
|
924
|
+
queuedRef.current.length > 0 ||
|
|
925
|
+
exportingRef.current) {
|
|
926
|
+
pushMessage('system', 'A turn, compaction, queued prompt, or export is still running or settling. Export waits for a stable durable boundary; try again when the composer is idle.');
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
return { sessions, sessionId };
|
|
930
|
+
}, [hasUnsettledTurn, pushMessage, state]);
|
|
931
|
+
const runConversationExport = useCallback((destination) => {
|
|
932
|
+
const source = stableExportSource();
|
|
933
|
+
if (!source)
|
|
934
|
+
return;
|
|
935
|
+
exportingRef.current = true;
|
|
936
|
+
setState('thinking');
|
|
937
|
+
const generation = conversationGenRef.current;
|
|
938
|
+
void (async () => {
|
|
939
|
+
try {
|
|
940
|
+
await persistenceTailRef.current;
|
|
941
|
+
if (conversationGenRef.current !== generation ||
|
|
942
|
+
scopeRef.current?.sessionId !== source.sessionId) {
|
|
943
|
+
throw new Error('The active conversation changed before the export reached its durable boundary.');
|
|
944
|
+
}
|
|
945
|
+
const projected = await conversationMarkdown(source.sessions, source.sessionId);
|
|
946
|
+
if (destination.kind === 'file') {
|
|
947
|
+
const written = await writeConversationExport(projected.markdown, destination.path, ctx.cwd);
|
|
948
|
+
pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const result = writeClipboardText(projected.markdown, {
|
|
952
|
+
isTTY: stdout.isTTY,
|
|
953
|
+
write: writeStdout,
|
|
954
|
+
});
|
|
955
|
+
switch (result.kind) {
|
|
956
|
+
case 'request-sent':
|
|
957
|
+
pushMessage('system', `Export copy request sent for ${projected.turns} verified turn${projected.turns === 1 ? '' : 's'} (${result.bytes.toLocaleString()} bytes). Terminal, multiplexer or remote-session policy may ignore OSC 52; if the clipboard did not change, enable terminal clipboard access.`);
|
|
958
|
+
break;
|
|
959
|
+
case 'unavailable':
|
|
960
|
+
pushMessage('system', `Cannot send the verified export to the clipboard here — ${result.detail}. Choose Save to file instead.`);
|
|
961
|
+
break;
|
|
962
|
+
case 'too-large':
|
|
963
|
+
pushMessage('system', `Cannot send the verified export to the terminal clipboard — it is ${result.bytes.toLocaleString()} bytes and the OSC 52 safety limit is ${result.limit.toLocaleString()}. Nothing was truncated; choose Save to file instead.`);
|
|
964
|
+
break;
|
|
965
|
+
case 'write-failed':
|
|
966
|
+
pushMessage('system', `Could not send the verified export copy request: ${result.detail}`);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
exportingRef.current = false;
|
|
975
|
+
if (conversationGenRef.current === generation)
|
|
976
|
+
setState('idle');
|
|
977
|
+
}
|
|
978
|
+
})();
|
|
979
|
+
}, [ctx.cwd, pushMessage, stableExportSource, stdout.isTTY, writeStdout]);
|
|
580
980
|
const applyChoiceSelection = useCallback((index) => {
|
|
581
981
|
const picker = choicePickerRef.current;
|
|
582
982
|
if (!picker)
|
|
@@ -584,13 +984,211 @@ export function App({ ctx: initialCtx }) {
|
|
|
584
984
|
const value = picker.values[index];
|
|
585
985
|
if (index < 0 || index >= picker.values.length)
|
|
586
986
|
return;
|
|
987
|
+
if (picker.kind === 'review-preset') {
|
|
988
|
+
if (reviewChoiceInFlightRef.current)
|
|
989
|
+
return;
|
|
990
|
+
if (value === 'custom') {
|
|
991
|
+
setChoicePicker(null);
|
|
992
|
+
composerDraftTokenRef.current += 1;
|
|
993
|
+
setComposerDraft({
|
|
994
|
+
token: composerDraftTokenRef.current,
|
|
995
|
+
text: '/review ',
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const operation = {};
|
|
1000
|
+
reviewChoiceInFlightRef.current = operation;
|
|
1001
|
+
void (async () => {
|
|
1002
|
+
try {
|
|
1003
|
+
if (value === 'base-branch') {
|
|
1004
|
+
const listing = await listReviewBranches(ctx.cwd);
|
|
1005
|
+
if (appLifetime.signal.aborted ||
|
|
1006
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1007
|
+
choicePickerRef.current !== picker)
|
|
1008
|
+
return;
|
|
1009
|
+
if (!listing || listing.branches.length === 0) {
|
|
1010
|
+
setChoicePicker(null);
|
|
1011
|
+
pushMessage('system', 'No local branch is available for comparison in this working directory.');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
setSelectedChoice(0);
|
|
1015
|
+
setChoicePicker({
|
|
1016
|
+
kind: 'review-branch',
|
|
1017
|
+
title: 'Select a base branch',
|
|
1018
|
+
notice: `Current branch: ${listing.current}`,
|
|
1019
|
+
values: listing.branches,
|
|
1020
|
+
options: listing.branches.map((branch) => ({
|
|
1021
|
+
label: branch,
|
|
1022
|
+
description: `${listing.current} → ${branch}`,
|
|
1023
|
+
})),
|
|
1024
|
+
});
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (value === 'commit') {
|
|
1028
|
+
const commits = await listReviewCommits(ctx.cwd);
|
|
1029
|
+
if (appLifetime.signal.aborted ||
|
|
1030
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1031
|
+
choicePickerRef.current !== picker)
|
|
1032
|
+
return;
|
|
1033
|
+
if (!commits || commits.length === 0) {
|
|
1034
|
+
setChoicePicker(null);
|
|
1035
|
+
pushMessage('system', 'No commit is available to review in this working directory.');
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
setSelectedChoice(0);
|
|
1039
|
+
setChoicePicker({
|
|
1040
|
+
kind: 'review-commit',
|
|
1041
|
+
title: 'Select a commit to review',
|
|
1042
|
+
values: commits,
|
|
1043
|
+
options: commits.map((commit) => ({
|
|
1044
|
+
label: commit.title,
|
|
1045
|
+
description: commit.sha.slice(0, 12),
|
|
1046
|
+
})),
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const diff = await workspaceDiff(ctx.cwd);
|
|
1051
|
+
if (appLifetime.signal.aborted ||
|
|
1052
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1053
|
+
choicePickerRef.current !== picker)
|
|
1054
|
+
return;
|
|
1055
|
+
if (diff === null) {
|
|
1056
|
+
setChoicePicker(null);
|
|
1057
|
+
pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (diff.stat.length === 0 && diff.untracked.length === 0) {
|
|
1061
|
+
setChoicePicker(null);
|
|
1062
|
+
pushMessage('system', 'Nothing to review — the working tree is clean.');
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
advanceQueueContinuation();
|
|
1066
|
+
enqueueQueued({
|
|
1067
|
+
kind: 'human',
|
|
1068
|
+
text: reviewPrompt(diff.stat, diff.untracked),
|
|
1069
|
+
});
|
|
1070
|
+
setChoicePicker(null);
|
|
1071
|
+
}
|
|
1072
|
+
finally {
|
|
1073
|
+
if (reviewChoiceInFlightRef.current === operation) {
|
|
1074
|
+
reviewChoiceInFlightRef.current = null;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
})();
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (picker.kind === 'review-branch') {
|
|
1081
|
+
if (reviewChoiceInFlightRef.current)
|
|
1082
|
+
return;
|
|
1083
|
+
const branch = value;
|
|
1084
|
+
const operation = {};
|
|
1085
|
+
reviewChoiceInFlightRef.current = operation;
|
|
1086
|
+
void (async () => {
|
|
1087
|
+
try {
|
|
1088
|
+
const mergeBase = await reviewMergeBase(ctx.cwd, branch);
|
|
1089
|
+
if (appLifetime.signal.aborted ||
|
|
1090
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1091
|
+
choicePickerRef.current !== picker)
|
|
1092
|
+
return;
|
|
1093
|
+
if (!mergeBase) {
|
|
1094
|
+
setChoicePicker(null);
|
|
1095
|
+
pushMessage('system', `Could not resolve a merge base for ${branch}.`);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
advanceQueueContinuation();
|
|
1099
|
+
enqueueQueued({
|
|
1100
|
+
kind: 'human',
|
|
1101
|
+
text: baseBranchReviewPrompt(mergeBase),
|
|
1102
|
+
});
|
|
1103
|
+
setChoicePicker(null);
|
|
1104
|
+
}
|
|
1105
|
+
finally {
|
|
1106
|
+
if (reviewChoiceInFlightRef.current === operation) {
|
|
1107
|
+
reviewChoiceInFlightRef.current = null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
})();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (picker.kind === 'review-commit') {
|
|
1114
|
+
advanceQueueContinuation();
|
|
1115
|
+
enqueueQueued({
|
|
1116
|
+
kind: 'human',
|
|
1117
|
+
text: commitReviewPrompt(value.sha),
|
|
1118
|
+
});
|
|
1119
|
+
setChoicePicker(null);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
587
1122
|
setChoicePicker(null);
|
|
1123
|
+
if (picker.kind === 'command') {
|
|
1124
|
+
const command = value;
|
|
1125
|
+
if (command.problem) {
|
|
1126
|
+
pushMessage('system', `Cannot run /${command.name}: ${command.problem}`);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
commandPickerSubmitRef.current(`/${command.name}`);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (picker.kind === 'archive-conversation') {
|
|
1133
|
+
if (value === 'archive')
|
|
1134
|
+
archiveCurrentConversation();
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (picker.kind === 'export-destination') {
|
|
1138
|
+
if (value === 'clipboard') {
|
|
1139
|
+
runConversationExport({ kind: 'clipboard' });
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
1143
|
+
if (!sessionId) {
|
|
1144
|
+
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
textPromptTokenRef.current += 1;
|
|
1148
|
+
setTextPrompt({
|
|
1149
|
+
token: textPromptTokenRef.current,
|
|
1150
|
+
kind: 'export-file',
|
|
1151
|
+
title: 'Save conversation',
|
|
1152
|
+
placeholder: 'Type a Markdown filename and press Enter',
|
|
1153
|
+
emptyNotice: 'A filename is required. Press Esc to cancel.',
|
|
1154
|
+
initialValue: `namzu-conversation-${sessionId}.md`,
|
|
1155
|
+
sessionId,
|
|
1156
|
+
});
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
588
1159
|
if (picker.kind === 'permission-mode') {
|
|
589
1160
|
applyPermissionMode(value);
|
|
590
1161
|
return;
|
|
591
1162
|
}
|
|
1163
|
+
if (picker.kind === 'feedback-rating') {
|
|
1164
|
+
recordFeedback(picker.runId, picker.messageId, value);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (picker.kind === 'skill') {
|
|
1168
|
+
activateSkill(value);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (picker.kind === 'credential-logout') {
|
|
1172
|
+
removeStoredCredential(value);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
592
1175
|
applyReasoningEffort(value);
|
|
593
|
-
}, [
|
|
1176
|
+
}, [
|
|
1177
|
+
activateSkill,
|
|
1178
|
+
advanceQueueContinuation,
|
|
1179
|
+
appLifetime.signal,
|
|
1180
|
+
applyPermissionMode,
|
|
1181
|
+
applyReasoningEffort,
|
|
1182
|
+
archiveCurrentConversation,
|
|
1183
|
+
ctx.cwd,
|
|
1184
|
+
enqueueQueued,
|
|
1185
|
+
pushMessage,
|
|
1186
|
+
recordFeedback,
|
|
1187
|
+
removeStoredCredential,
|
|
1188
|
+
runConversationExport,
|
|
1189
|
+
setChoicePicker,
|
|
1190
|
+
setTextPrompt,
|
|
1191
|
+
]);
|
|
594
1192
|
const sendCopyRequest = useCallback((index) => {
|
|
595
1193
|
const picker = copyPickerRef.current;
|
|
596
1194
|
const target = picker?.targets[index];
|
|
@@ -661,14 +1259,31 @@ export function App({ ctx: initialCtx }) {
|
|
|
661
1259
|
const finalizeMessage = useCallback((id, finalContent) => {
|
|
662
1260
|
setMessages((prev) => prev.map((m) => m.id === id ? { ...m, content: finalContent ?? m.content, pending: false } : m));
|
|
663
1261
|
}, []);
|
|
664
|
-
// Open the SDK session store
|
|
665
|
-
//
|
|
1262
|
+
// Open the SDK session store and select the durable conversation once.
|
|
1263
|
+
// Ordinary startup remains best-effort; an explicit `namzu resume <id>` is
|
|
1264
|
+
// exact and therefore refuses instead of silently widening to a fresh chat.
|
|
666
1265
|
const ensureSessions = useCallback(async () => {
|
|
667
1266
|
if (scopeRef.current)
|
|
668
1267
|
return scopeRef.current;
|
|
1268
|
+
const requestedConversationId = initialConversationIdRef.current;
|
|
669
1269
|
try {
|
|
670
1270
|
const sessions = await openSessions(ctxRef.current.cwd);
|
|
671
|
-
|
|
1271
|
+
let sessionId;
|
|
1272
|
+
if (requestedConversationId) {
|
|
1273
|
+
const restored = await loadResumableConversation(sessions, requestedConversationId);
|
|
1274
|
+
sessionId = asSessionId(requestedConversationId);
|
|
1275
|
+
modelHistoryRef.current = restored;
|
|
1276
|
+
setMessages(projectConversation(restored, nextId));
|
|
1277
|
+
setHistory(promptHistoryFromConversation(restored));
|
|
1278
|
+
const persistedOutput = latestAssistantOutput(restored);
|
|
1279
|
+
lastCompletedOutputRef.current = persistedOutput
|
|
1280
|
+
? { text: persistedOutput, provenance: 'persisted' }
|
|
1281
|
+
: null;
|
|
1282
|
+
initialConversationIdRef.current = undefined;
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
sessionId = await startConversation(sessions);
|
|
1286
|
+
}
|
|
672
1287
|
sessionsRef.current = sessions;
|
|
673
1288
|
scopeRef.current = {
|
|
674
1289
|
sessionId,
|
|
@@ -678,10 +1293,12 @@ export function App({ ctx: initialCtx }) {
|
|
|
678
1293
|
};
|
|
679
1294
|
return scopeRef.current;
|
|
680
1295
|
}
|
|
681
|
-
catch {
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
if (requestedConversationId)
|
|
1298
|
+
throw error;
|
|
682
1299
|
return undefined;
|
|
683
1300
|
}
|
|
684
|
-
}, []);
|
|
1301
|
+
}, [nextId]);
|
|
685
1302
|
const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
|
|
686
1303
|
if (signal?.aborted)
|
|
687
1304
|
return;
|
|
@@ -692,6 +1309,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
692
1309
|
const s = await createAgentSession(prefs, detectedNow, {
|
|
693
1310
|
scope,
|
|
694
1311
|
cwd: activeCtx.cwd,
|
|
1312
|
+
enableComputerUse: true,
|
|
695
1313
|
rules: activeCtx.rules,
|
|
696
1314
|
...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
|
|
697
1315
|
...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
|
|
@@ -732,6 +1350,13 @@ export function App({ ctx: initialCtx }) {
|
|
|
732
1350
|
// the file is shadowed instead of leaving it silently unused.
|
|
733
1351
|
reserved: hostCommandNames(),
|
|
734
1352
|
}));
|
|
1353
|
+
const mentionLoadOwner = {};
|
|
1354
|
+
mentionLoadOwnerRef.current = mentionLoadOwner;
|
|
1355
|
+
void listMentionableFiles(activeCtx.cwd, appLifetime.signal).then((files) => {
|
|
1356
|
+
if (!appLifetime.signal.aborted &&
|
|
1357
|
+
mentionLoadOwnerRef.current === mentionLoadOwner)
|
|
1358
|
+
setMentionCandidates(files);
|
|
1359
|
+
});
|
|
735
1360
|
setCurrentProvider(primaryProvider(prefs).id);
|
|
736
1361
|
// A picker-owned, usable provider switch is an explicit recovery from a
|
|
737
1362
|
// paused failed turn. Failed and superseded candidates return above and
|
|
@@ -779,7 +1404,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
779
1404
|
if (s.errorHint)
|
|
780
1405
|
pushMessage('system', s.errorHint);
|
|
781
1406
|
}
|
|
782
|
-
}, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
1407
|
+
}, [appLifetime.signal, advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
783
1408
|
/**
|
|
784
1409
|
* Sign in to a subscription without leaving namzu.
|
|
785
1410
|
*
|
|
@@ -942,6 +1567,19 @@ export function App({ ctx: initialCtx }) {
|
|
|
942
1567
|
try {
|
|
943
1568
|
if (signal?.aborted)
|
|
944
1569
|
return;
|
|
1570
|
+
// An exact shell resume owns conversation admission before provider
|
|
1571
|
+
// discovery or construction. A malformed/missing id must not consume
|
|
1572
|
+
// model work and then quietly continue in a fresh conversation.
|
|
1573
|
+
if (initialConversationIdRef.current) {
|
|
1574
|
+
try {
|
|
1575
|
+
await ensureSessions();
|
|
1576
|
+
}
|
|
1577
|
+
catch (error) {
|
|
1578
|
+
setPhase('unhealthy');
|
|
1579
|
+
pushMessage('system', `Could not resume ${initialConversationIdRef.current}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
945
1583
|
const probe = await probeAgentSession();
|
|
946
1584
|
if (signal?.aborted)
|
|
947
1585
|
return;
|
|
@@ -1029,7 +1667,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
1029
1667
|
setPhase('unhealthy');
|
|
1030
1668
|
pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
|
|
1031
1669
|
}
|
|
1032
|
-
}, [appLifetime.signal, hydrateSession, pushMessage]);
|
|
1670
|
+
}, [appLifetime.signal, ensureSessions, hydrateSession, pushMessage]);
|
|
1033
1671
|
// `startOrFinishLogin` is declared above `runProbe` and calls it, so it
|
|
1034
1672
|
// reads the current one through a ref rather than closing over a stale
|
|
1035
1673
|
// binding or forcing the two into a declaration order that reads backwards.
|
|
@@ -1085,8 +1723,8 @@ export function App({ ctx: initialCtx }) {
|
|
|
1085
1723
|
// is idempotent, so a repeated render reaches the same answer.
|
|
1086
1724
|
const window = liveWindow({
|
|
1087
1725
|
messages: finalized,
|
|
1088
|
-
rows:
|
|
1089
|
-
columns:
|
|
1726
|
+
rows: stdout.rows,
|
|
1727
|
+
columns: stdout.columns,
|
|
1090
1728
|
furnitureRows: LIVE_FURNITURE_ROWS,
|
|
1091
1729
|
settled: settledRef.current,
|
|
1092
1730
|
raw: rawOutput,
|
|
@@ -1096,14 +1734,28 @@ export function App({ ctx: initialCtx }) {
|
|
|
1096
1734
|
// the answer is knowable. `liveRows` is the furniture beneath the transcript
|
|
1097
1735
|
// PLUS the live window above it — the window is part of the live region, and
|
|
1098
1736
|
// leaving it out would have the spacer padding room that is already taken.
|
|
1099
|
-
const
|
|
1737
|
+
const spacerCandidate = phase === 'ready' && permission === null
|
|
1100
1738
|
? bottomSpacerRows({
|
|
1101
|
-
rows:
|
|
1102
|
-
columns:
|
|
1739
|
+
rows: stdout.rows,
|
|
1740
|
+
columns: stdout.columns,
|
|
1103
1741
|
transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
|
|
1104
1742
|
liveRows: LIVE_FURNITURE_ROWS + window.rows,
|
|
1105
1743
|
})
|
|
1106
1744
|
: 0;
|
|
1745
|
+
const spacerLayout = {
|
|
1746
|
+
rows: stdout.rows,
|
|
1747
|
+
columns: stdout.columns,
|
|
1748
|
+
raw: rawOutput,
|
|
1749
|
+
messageCount: finalized.length,
|
|
1750
|
+
tail: finalized
|
|
1751
|
+
.slice(-MAX_LIVE_ROWS)
|
|
1752
|
+
.map(({ id, content, meta, detail }) => ({ id, content, meta, detail })),
|
|
1753
|
+
};
|
|
1754
|
+
const spacerRows = phase === 'ready' && permission === null && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
|
|
1755
|
+
? spacerLayoutRef.current.spacerRows
|
|
1756
|
+
: spacerCandidate;
|
|
1757
|
+
spacerLayoutRef.current =
|
|
1758
|
+
phase === 'ready' && permission === null ? { ...spacerLayout, spacerRows } : null;
|
|
1107
1759
|
// One merged vocabulary for the session: this host's own commands plus
|
|
1108
1760
|
// whatever the kernel's registry reports. Built here so `/help`, the
|
|
1109
1761
|
// autocomplete and the dispatcher all answer from the same list — three
|
|
@@ -1111,6 +1763,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
1111
1763
|
// disagree the moment a capability added a command.
|
|
1112
1764
|
const hostCommands = mergeHostCommands(kernelCommandDescriptors());
|
|
1113
1765
|
const slashCtx = {
|
|
1766
|
+
cwd: ctx.cwd,
|
|
1114
1767
|
builtins: hostCommands,
|
|
1115
1768
|
lastAssistantMessageId: () => lastAssistantMessage.current?.messageId ?? null,
|
|
1116
1769
|
// Called when `/tools` renders, not read here — the same shape, and the
|
|
@@ -1119,7 +1772,12 @@ export function App({ ctx: initialCtx }) {
|
|
|
1119
1772
|
// From the session, not re-resolved: resolving builds a provider, and a
|
|
1120
1773
|
// second one would describe a different sandbox than the run is using.
|
|
1121
1774
|
sandbox: session?.sandbox ?? null,
|
|
1122
|
-
mcp:
|
|
1775
|
+
mcp: () => session
|
|
1776
|
+
? (session.mcpStatus?.() ?? {
|
|
1777
|
+
connected: session.mcpConnected,
|
|
1778
|
+
failed: session.mcpFailed,
|
|
1779
|
+
})
|
|
1780
|
+
: null,
|
|
1123
1781
|
providerSummary: session?.providerSummary ?? null,
|
|
1124
1782
|
modelSummary: session?.modelSummary ?? null,
|
|
1125
1783
|
reasoningEffort: {
|
|
@@ -1182,10 +1840,11 @@ export function App({ ctx: initialCtx }) {
|
|
|
1182
1840
|
const resolve = permissionResolveRef.current;
|
|
1183
1841
|
permissionResolveRef.current = null;
|
|
1184
1842
|
permissionOpenedAtRef.current = null;
|
|
1843
|
+
setPermissionReviewOffset(0);
|
|
1185
1844
|
setPermission(null);
|
|
1186
1845
|
if (resolve)
|
|
1187
1846
|
resolve(decision);
|
|
1188
|
-
}, []);
|
|
1847
|
+
}, [setPermissionReviewOffset]);
|
|
1189
1848
|
/**
|
|
1190
1849
|
* Stop the running turn, and hand the screen back in a usable state.
|
|
1191
1850
|
*
|
|
@@ -1210,12 +1869,14 @@ export function App({ ctx: initialCtx }) {
|
|
|
1210
1869
|
const ac = abortRef.current;
|
|
1211
1870
|
if (!ac)
|
|
1212
1871
|
return false;
|
|
1213
|
-
ac.abort();
|
|
1872
|
+
ac.abort(new RunCancelled('user'));
|
|
1214
1873
|
const activeSessionId = scopeRef.current?.sessionId;
|
|
1215
1874
|
if (activeSessionId)
|
|
1216
1875
|
goalActivation.disarm(activeSessionId);
|
|
1217
1876
|
wakeGoalDriver();
|
|
1218
1877
|
activeTurnTokenRef.current = null;
|
|
1878
|
+
activeTurnInboxRef.current = null;
|
|
1879
|
+
setPendingSteers([]);
|
|
1219
1880
|
// Dropped now so a second interrupt does not re-abort, and the queue with
|
|
1220
1881
|
// it: interrupting means stop, not "run the next one".
|
|
1221
1882
|
abortRef.current = null;
|
|
@@ -1301,6 +1962,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
1301
1962
|
resetTranscript();
|
|
1302
1963
|
setMessages(restored);
|
|
1303
1964
|
modelHistoryRef.current = msgs;
|
|
1965
|
+
setHistory((previous) => [...previous, ...promptHistoryFromConversation(msgs)]);
|
|
1304
1966
|
const persistedOutput = latestAssistantOutput(msgs);
|
|
1305
1967
|
lastCompletedOutputRef.current = persistedOutput
|
|
1306
1968
|
? { text: persistedOutput, provenance: 'persisted' }
|
|
@@ -1397,11 +2059,12 @@ export function App({ ctx: initialCtx }) {
|
|
|
1397
2059
|
}
|
|
1398
2060
|
}, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
|
|
1399
2061
|
/**
|
|
1400
|
-
* `/title
|
|
2062
|
+
* `/rename` (and the older `/title` alias): edit or set the name this
|
|
2063
|
+
* conversation appears under.
|
|
1401
2064
|
*
|
|
1402
|
-
* A bare
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
2065
|
+
* A bare command opens a prefilled host prompt rather than printing usage or
|
|
2066
|
+
* clearing. The result never goes through the model composer, queue, or prompt
|
|
2067
|
+
* history; it is a session-store operation with the session id captured here.
|
|
1405
2068
|
*/
|
|
1406
2069
|
const doTitle = useCallback(async (title, clear) => {
|
|
1407
2070
|
const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
|
|
@@ -1413,9 +2076,16 @@ export function App({ ctx: initialCtx }) {
|
|
|
1413
2076
|
try {
|
|
1414
2077
|
if (!clear && title === '') {
|
|
1415
2078
|
const current = titleOf(sessions, scope.sessionId);
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
:
|
|
2079
|
+
textPromptTokenRef.current += 1;
|
|
2080
|
+
setTextPrompt({
|
|
2081
|
+
token: textPromptTokenRef.current,
|
|
2082
|
+
kind: 'conversation-title',
|
|
2083
|
+
title: current === undefined ? 'Name conversation' : 'Rename conversation',
|
|
2084
|
+
placeholder: 'Type a name and press Enter',
|
|
2085
|
+
emptyNotice: 'A conversation name cannot be empty. Press Esc to cancel.',
|
|
2086
|
+
initialValue: current ?? '',
|
|
2087
|
+
sessionId: scope.sessionId,
|
|
2088
|
+
});
|
|
1419
2089
|
return;
|
|
1420
2090
|
}
|
|
1421
2091
|
setTitle(sessions, scope.sessionId, clear ? '' : title);
|
|
@@ -1426,7 +2096,46 @@ export function App({ ctx: initialCtx }) {
|
|
|
1426
2096
|
catch (err) {
|
|
1427
2097
|
pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
|
|
1428
2098
|
}
|
|
1429
|
-
}, [ensureSessions, pushMessage]);
|
|
2099
|
+
}, [ensureSessions, pushMessage, setTextPrompt]);
|
|
2100
|
+
const submitTextPrompt = useCallback((value) => {
|
|
2101
|
+
const prompt = textPromptRef.current;
|
|
2102
|
+
if (!prompt)
|
|
2103
|
+
return;
|
|
2104
|
+
setTextPrompt(null);
|
|
2105
|
+
const scope = scopeRef.current;
|
|
2106
|
+
if (!scope || scope.sessionId !== prompt.sessionId) {
|
|
2107
|
+
pushMessage('system', prompt.kind === 'conversation-title'
|
|
2108
|
+
? 'The conversation changed while its name editor was open. Nothing was renamed; open /rename again.'
|
|
2109
|
+
: 'The conversation changed while its export filename editor was open. Nothing was exported; open /export again.');
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (prompt.kind === 'export-file') {
|
|
2113
|
+
runConversationExport({ kind: 'file', path: value });
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const sessions = sessionsRef.current;
|
|
2117
|
+
if (!sessions) {
|
|
2118
|
+
pushMessage('system', 'Conversation history is unavailable in this folder.');
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
try {
|
|
2122
|
+
setTitle(sessions, prompt.sessionId, value);
|
|
2123
|
+
pushMessage('system', `Named "${value.trim()}".`);
|
|
2124
|
+
}
|
|
2125
|
+
catch (error) {
|
|
2126
|
+
pushMessage('system', `Could not save the name: ${error instanceof Error ? error.message : String(error)}`);
|
|
2127
|
+
}
|
|
2128
|
+
}, [pushMessage, runConversationExport, setTextPrompt]);
|
|
2129
|
+
const cancelTextPrompt = useCallback(() => {
|
|
2130
|
+
const prompt = textPromptRef.current;
|
|
2131
|
+
// TextPrompt and App both receive the same Ink input event. Keep the
|
|
2132
|
+
// synchronous owner until every listener has seen this Esc/Ctrl+C, or App
|
|
2133
|
+
// can read the same key as an interrupt/exit after the child clears the ref.
|
|
2134
|
+
queueMicrotask(() => {
|
|
2135
|
+
if (textPromptRef.current === prompt)
|
|
2136
|
+
setTextPrompt(null);
|
|
2137
|
+
});
|
|
2138
|
+
}, [setTextPrompt]);
|
|
1430
2139
|
/**
|
|
1431
2140
|
* `/fork`: continue in a copy, leaving this conversation where it is.
|
|
1432
2141
|
*
|
|
@@ -1582,13 +2291,23 @@ export function App({ ctx: initialCtx }) {
|
|
|
1582
2291
|
]);
|
|
1583
2292
|
// Bridge passed into session.send(): the agent calls this before a
|
|
1584
2293
|
// non-read-only tool batch; it parks until the user presses y/n/a.
|
|
1585
|
-
const onPermission = useCallback((req) =>
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
2294
|
+
const onPermission = useCallback((req) => {
|
|
2295
|
+
const review = buildPermissionReview(req.toolCalls);
|
|
2296
|
+
if (!review.ok) {
|
|
2297
|
+
return Promise.resolve({
|
|
2298
|
+
kind: 'reject',
|
|
2299
|
+
feedback: permissionReviewRefusal(review.reason),
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
return new Promise((resolve) => {
|
|
2303
|
+
permissionResolveRef.current = resolve;
|
|
2304
|
+
permissionOpenedAtRef.current = Date.now();
|
|
2305
|
+
setPermissionReviewOffset(0);
|
|
2306
|
+
setPermission({ ...req, review: review.text });
|
|
2307
|
+
setState('awaiting-permission');
|
|
2308
|
+
sendTerminalNotification({ kind: 'approval-required' });
|
|
2309
|
+
});
|
|
2310
|
+
}, [sendTerminalNotification, setPermissionReviewOffset]);
|
|
1592
2311
|
// Render one agent event onto the transcript. Shared by the local turn loop
|
|
1593
2312
|
// and the daemon-attach poller, so both paths produce identical output.
|
|
1594
2313
|
// `st` carries the streaming-assistant bubble id + accumulated text across
|
|
@@ -1645,6 +2364,28 @@ export function App({ ctx: initialCtx }) {
|
|
|
1645
2364
|
setActiveTools(activeToolsRef.current);
|
|
1646
2365
|
break;
|
|
1647
2366
|
}
|
|
2367
|
+
case 'tool-progress': {
|
|
2368
|
+
const running = activeToolsRef.current;
|
|
2369
|
+
const index = running.findIndex((tool) => tool.id === event.toolUseId);
|
|
2370
|
+
// A terminal event may already have removed this call. Never recreate
|
|
2371
|
+
// a live row from late diagnostic state.
|
|
2372
|
+
if (index < 0)
|
|
2373
|
+
break;
|
|
2374
|
+
const current = running[index];
|
|
2375
|
+
const { fraction: _fraction, ...withoutFraction } = current;
|
|
2376
|
+
const updated = {
|
|
2377
|
+
...withoutFraction,
|
|
2378
|
+
progress: event.message,
|
|
2379
|
+
...(event.fraction !== undefined ? { fraction: event.fraction } : {}),
|
|
2380
|
+
};
|
|
2381
|
+
activeToolsRef.current = [
|
|
2382
|
+
...running.slice(0, index),
|
|
2383
|
+
updated,
|
|
2384
|
+
...running.slice(index + 1),
|
|
2385
|
+
];
|
|
2386
|
+
setActiveTools(activeToolsRef.current);
|
|
2387
|
+
break;
|
|
2388
|
+
}
|
|
1648
2389
|
case 'tool-end': {
|
|
1649
2390
|
const running = activeToolsRef.current;
|
|
1650
2391
|
// Match strictly by toolUseId. Never fall back to "the first
|
|
@@ -1794,16 +2535,11 @@ export function App({ ctx: initialCtx }) {
|
|
|
1794
2535
|
: undefined);
|
|
1795
2536
|
const priorForSdk = [...historyBeforeTurn, userMessage];
|
|
1796
2537
|
const runId = generateRunId();
|
|
1797
|
-
const metaParts = [];
|
|
1798
|
-
if (attached.length > 0)
|
|
1799
|
-
metaParts.push(`${attached.length} file${attached.length > 1 ? 's' : ''} attached`);
|
|
1800
|
-
if (attachments && attachments.length > 0)
|
|
1801
|
-
metaParts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
|
|
1802
2538
|
if (goalRound) {
|
|
1803
2539
|
pushMessage('system', `Goal round ${goalRound.round} / ${goalRound.maxGoalRounds}`, false, '◎', [`Objective: ${goalRound.objective}`]);
|
|
1804
2540
|
}
|
|
1805
2541
|
else {
|
|
1806
|
-
pushMessage('user', text, false, undefined, undefined, undefined,
|
|
2542
|
+
pushMessage('user', text, false, undefined, undefined, undefined, humanPromptMeta(attached.length, attachments));
|
|
1807
2543
|
}
|
|
1808
2544
|
// The model interleaves text → tool → text across iterations; `applyEvent`
|
|
1809
2545
|
// renders each one in order.
|
|
@@ -1845,6 +2581,51 @@ export function App({ ctx: initialCtx }) {
|
|
|
1845
2581
|
const turnGeneration = conversationGenRef.current;
|
|
1846
2582
|
unsettledTurnGenerationsRef.current.set(turnToken, turnGeneration);
|
|
1847
2583
|
const stillHere = () => conversationGenRef.current === turnGeneration;
|
|
2584
|
+
let inboxOpen = true;
|
|
2585
|
+
const inboxEntries = [];
|
|
2586
|
+
const inbox = {
|
|
2587
|
+
accept(input) {
|
|
2588
|
+
if (!inboxOpen ||
|
|
2589
|
+
ac.signal.aborted ||
|
|
2590
|
+
!stillHere() ||
|
|
2591
|
+
activeTurnTokenRef.current !== turnToken ||
|
|
2592
|
+
activeTurnInboxRef.current !== inbox)
|
|
2593
|
+
return false;
|
|
2594
|
+
inboxEntries.push(input);
|
|
2595
|
+
setPendingSteers([...inboxEntries]);
|
|
2596
|
+
return true;
|
|
2597
|
+
},
|
|
2598
|
+
drain() {
|
|
2599
|
+
if (!inboxOpen ||
|
|
2600
|
+
ac.signal.aborted ||
|
|
2601
|
+
activeTurnTokenRef.current !== turnToken ||
|
|
2602
|
+
activeTurnInboxRef.current !== inbox ||
|
|
2603
|
+
inboxEntries.length === 0)
|
|
2604
|
+
return [];
|
|
2605
|
+
const delivered = inboxEntries.splice(0, inboxEntries.length);
|
|
2606
|
+
// This callback runs at the kernel's provider-valid boundary: every
|
|
2607
|
+
// delta from the prior answer has already crossed the event stream.
|
|
2608
|
+
// Commit the user rows here, not at keypress time, so the transcript
|
|
2609
|
+
// cannot place a steer inside an assistant message that is still live.
|
|
2610
|
+
if (stillHere()) {
|
|
2611
|
+
flushStream(st);
|
|
2612
|
+
if (st.assistantId) {
|
|
2613
|
+
finalizeMessage(st.assistantId);
|
|
2614
|
+
st.assistantId = null;
|
|
2615
|
+
}
|
|
2616
|
+
for (const input of delivered) {
|
|
2617
|
+
pushMessage('user', input.prompt.text, false, undefined, undefined, undefined, humanPromptMeta(input.attachedFiles, input.prompt.attachments, true));
|
|
2618
|
+
}
|
|
2619
|
+
setPendingSteers([]);
|
|
2620
|
+
}
|
|
2621
|
+
return delivered.map((input) => input.message);
|
|
2622
|
+
},
|
|
2623
|
+
close() {
|
|
2624
|
+
inboxOpen = false;
|
|
2625
|
+
return inboxEntries.splice(0, inboxEntries.length);
|
|
2626
|
+
},
|
|
2627
|
+
};
|
|
2628
|
+
activeTurnInboxRef.current = inbox;
|
|
1848
2629
|
const turnPermissionMode = permissionModeRef.current;
|
|
1849
2630
|
const turnReasoningEffort = reasoningEffortRef.current;
|
|
1850
2631
|
// Always carry the guarded callback. `auto` and `strict` decide before
|
|
@@ -1908,6 +2689,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
1908
2689
|
...(goalRound ? { goalRound } : {}),
|
|
1909
2690
|
// The mode above decides whether this callback is consulted.
|
|
1910
2691
|
onPermission: askPermission,
|
|
2692
|
+
inboundMessages: () => inbox.drain(),
|
|
1911
2693
|
extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
|
|
1912
2694
|
onConversationMessages: (messages) => {
|
|
1913
2695
|
// State-only: opaque reasoning/signatures must reach the next
|
|
@@ -1960,6 +2742,12 @@ export function App({ ctx: initialCtx }) {
|
|
|
1960
2742
|
}
|
|
1961
2743
|
}
|
|
1962
2744
|
finally {
|
|
2745
|
+
const undeliveredLiveInput = inbox.close();
|
|
2746
|
+
const ownsInbox = activeTurnInboxRef.current === inbox;
|
|
2747
|
+
if (ownsInbox) {
|
|
2748
|
+
activeTurnInboxRef.current = null;
|
|
2749
|
+
setPendingSteers([]);
|
|
2750
|
+
}
|
|
1963
2751
|
// Prefer the kernel's settled conversation projection: it retains opaque
|
|
1964
2752
|
// reasoning, citations and complete tool turns that the visible delta
|
|
1965
2753
|
// stream cannot reconstruct. A fake/legacy session that does not publish
|
|
@@ -1984,6 +2772,9 @@ export function App({ ctx: initialCtx }) {
|
|
|
1984
2772
|
// clear the state of whatever has started since.
|
|
1985
2773
|
if (stillHere()) {
|
|
1986
2774
|
const ownsTurn = activeTurnTokenRef.current === turnToken;
|
|
2775
|
+
if (ownsInbox && undeliveredLiveInput.length > 0) {
|
|
2776
|
+
replaceQueued(mergeUndeliveredLiveInput(queuedRef.current, undeliveredLiveInput));
|
|
2777
|
+
}
|
|
1987
2778
|
const shouldPauseQueued = ownsTurn &&
|
|
1988
2779
|
abnormalTerminal?.token === turnToken &&
|
|
1989
2780
|
abnormalTerminal.continuationEpoch === queueContinuationEpochRef.current &&
|
|
@@ -2087,12 +2878,13 @@ export function App({ ctx: initialCtx }) {
|
|
|
2087
2878
|
goalActivation,
|
|
2088
2879
|
onPermission,
|
|
2089
2880
|
pushMessage,
|
|
2881
|
+
replaceQueued,
|
|
2090
2882
|
sendTerminalNotification,
|
|
2091
2883
|
session,
|
|
2092
2884
|
setQueuePause,
|
|
2093
2885
|
wakeGoalDriver,
|
|
2094
2886
|
]);
|
|
2095
|
-
const handleSubmit = useCallback((value, attachments) => {
|
|
2887
|
+
const handleSubmit = useCallback((value, attachments, mode = 'submit') => {
|
|
2096
2888
|
if (goalCommandInFlightRef.current) {
|
|
2097
2889
|
pushMessage('system', 'A goal command is still reaching durable session state. Wait for its result before sending another command or prompt.');
|
|
2098
2890
|
return;
|
|
@@ -2106,7 +2898,9 @@ export function App({ ctx: initialCtx }) {
|
|
|
2106
2898
|
? 'forked'
|
|
2107
2899
|
: conversationMutationRef.current === 'edit'
|
|
2108
2900
|
? 'branched for prompt editing'
|
|
2109
|
-
:
|
|
2901
|
+
: conversationMutationRef.current === 'archive'
|
|
2902
|
+
? 'archived'
|
|
2903
|
+
: 'moved to a fresh conversation';
|
|
2110
2904
|
pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
|
|
2111
2905
|
return;
|
|
2112
2906
|
}
|
|
@@ -2122,15 +2916,76 @@ export function App({ ctx: initialCtx }) {
|
|
|
2122
2916
|
case 'message':
|
|
2123
2917
|
pushMessage(slash.role, slash.content);
|
|
2124
2918
|
return;
|
|
2919
|
+
case 'command-picker': {
|
|
2920
|
+
if (slash.commands.length === 0) {
|
|
2921
|
+
pushMessage('system', 'No slash commands are available in this session.');
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
setSelectedChoice(0);
|
|
2925
|
+
setChoicePicker({
|
|
2926
|
+
kind: 'command',
|
|
2927
|
+
title: 'Choose a command',
|
|
2928
|
+
notice: 'Enter runs the selected command; Esc returns to the composer.',
|
|
2929
|
+
values: slash.commands,
|
|
2930
|
+
options: slash.commands.map((command) => ({
|
|
2931
|
+
label: `/${command.name}`,
|
|
2932
|
+
description: command.problem
|
|
2933
|
+
? `Unavailable: ${command.problem}`
|
|
2934
|
+
: command.description,
|
|
2935
|
+
})),
|
|
2936
|
+
windowSize: suggestionWindowSize(stdout.rows),
|
|
2937
|
+
});
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2125
2940
|
case 'clear-screen':
|
|
2126
2941
|
setMessages([]);
|
|
2127
2942
|
resetTranscript();
|
|
2128
2943
|
return;
|
|
2944
|
+
case 'composer-draft':
|
|
2945
|
+
composerDraftTokenRef.current += 1;
|
|
2946
|
+
setComposerDraft({
|
|
2947
|
+
token: composerDraftTokenRef.current,
|
|
2948
|
+
text: slash.text,
|
|
2949
|
+
});
|
|
2950
|
+
return;
|
|
2129
2951
|
case 'new-conversation':
|
|
2130
2952
|
void startFreshConversation(slash.clearScreen);
|
|
2131
2953
|
return;
|
|
2954
|
+
case 'archive-picker': {
|
|
2955
|
+
if (!sessionsRef.current || !scopeRef.current?.sessionId) {
|
|
2956
|
+
pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
if (state !== 'idle' ||
|
|
2960
|
+
abortRef.current !== null ||
|
|
2961
|
+
hasUnsettledTurn() ||
|
|
2962
|
+
queuedRef.current.length > 0 ||
|
|
2963
|
+
compactingRef.current ||
|
|
2964
|
+
exportingRef.current) {
|
|
2965
|
+
pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
setSelectedChoice(0);
|
|
2969
|
+
setChoicePicker({
|
|
2970
|
+
kind: 'archive-conversation',
|
|
2971
|
+
title: 'Archive this conversation?',
|
|
2972
|
+
notice: 'History remains on disk for inspection, but the conversation becomes read-only and disappears from /resume.',
|
|
2973
|
+
values: ['cancel', 'archive'],
|
|
2974
|
+
options: [
|
|
2975
|
+
{
|
|
2976
|
+
label: 'No, keep conversation',
|
|
2977
|
+
description: 'Return without changing durable history',
|
|
2978
|
+
},
|
|
2979
|
+
{
|
|
2980
|
+
label: 'Yes, archive and exit',
|
|
2981
|
+
description: 'Make this conversation read-only and leave Namzu',
|
|
2982
|
+
},
|
|
2983
|
+
],
|
|
2984
|
+
});
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2132
2987
|
case 'exit':
|
|
2133
|
-
|
|
2988
|
+
exitWithSummary();
|
|
2134
2989
|
return;
|
|
2135
2990
|
case 'repick':
|
|
2136
2991
|
// Opened as a choice, not as a repair. A launch-time refusal
|
|
@@ -2215,16 +3070,35 @@ export function App({ ctx: initialCtx }) {
|
|
|
2215
3070
|
void startOrFinishLogin(slash.pasted);
|
|
2216
3071
|
return;
|
|
2217
3072
|
case 'logout': {
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
clearAllStoredCredentials();
|
|
3073
|
+
if (slash.target) {
|
|
3074
|
+
removeStoredCredential(slash.target);
|
|
3075
|
+
return;
|
|
2222
3076
|
}
|
|
2223
|
-
|
|
2224
|
-
|
|
3077
|
+
const choices = [
|
|
3078
|
+
...(readStoredSubscriptionCredential() ? ['anthropic'] : []),
|
|
3079
|
+
...(readStoredCodexCredential() ? ['codex'] : []),
|
|
3080
|
+
];
|
|
3081
|
+
if (choices.length === 0) {
|
|
3082
|
+
removeStoredCredential('all');
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
if (choices.length === 1) {
|
|
3086
|
+
removeStoredCredential(choices[0]);
|
|
2225
3087
|
return;
|
|
2226
3088
|
}
|
|
2227
|
-
|
|
3089
|
+
setSelectedChoice(0);
|
|
3090
|
+
setChoicePicker({
|
|
3091
|
+
kind: 'credential-logout',
|
|
3092
|
+
title: 'Choose a stored subscription to remove',
|
|
3093
|
+
notice: 'Only credentials created by Namzu are listed. Device sessions owned by other tools are left alone.',
|
|
3094
|
+
values: choices,
|
|
3095
|
+
options: choices.map((choice) => ({
|
|
3096
|
+
label: choice === 'anthropic' ? 'Claude' : 'Codex',
|
|
3097
|
+
description: choice === 'anthropic'
|
|
3098
|
+
? 'Remove only Namzu’s Claude subscription.'
|
|
3099
|
+
: 'Remove only Namzu’s Codex subscription.',
|
|
3100
|
+
})),
|
|
3101
|
+
});
|
|
2228
3102
|
return;
|
|
2229
3103
|
}
|
|
2230
3104
|
case 'remember':
|
|
@@ -2262,23 +3136,30 @@ export function App({ ctx: initialCtx }) {
|
|
|
2262
3136
|
pushMessage('system', `Skills (● active):\n ${lines.join('\n ')}`);
|
|
2263
3137
|
return;
|
|
2264
3138
|
}
|
|
2265
|
-
case '
|
|
2266
|
-
const
|
|
2267
|
-
if (
|
|
2268
|
-
pushMessage('system',
|
|
3139
|
+
case 'skill-picker': {
|
|
3140
|
+
const skills = discoverSkills({ cwd: ctx.cwd });
|
|
3141
|
+
if (skills.length === 0) {
|
|
3142
|
+
pushMessage('system', 'No skills found. Add one at ~/.namzu/skills/<name>/SKILL.md or ./skills/<name>/SKILL.md.');
|
|
2269
3143
|
return;
|
|
2270
3144
|
}
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
3145
|
+
const activeNames = new Set(activeSkills.map((skill) => skill.name));
|
|
3146
|
+
setSelectedChoice(0);
|
|
3147
|
+
setChoicePicker({
|
|
3148
|
+
kind: 'skill',
|
|
3149
|
+
title: 'Activate a skill',
|
|
3150
|
+
values: skills.map((skill) => skill.name),
|
|
3151
|
+
options: skills.map((skill) => ({
|
|
3152
|
+
label: skill.name,
|
|
3153
|
+
description: skill.problem
|
|
3154
|
+
? `Unavailable: ${skill.problem}`
|
|
3155
|
+
: `${skill.description} · ${skill.source}`,
|
|
3156
|
+
current: activeNames.has(skill.name),
|
|
3157
|
+
})),
|
|
3158
|
+
});
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
case 'load-skill': {
|
|
3162
|
+
activateSkill(slash.name);
|
|
2282
3163
|
return;
|
|
2283
3164
|
}
|
|
2284
3165
|
case 'resume':
|
|
@@ -2424,67 +3305,76 @@ export function App({ ctx: initialCtx }) {
|
|
|
2424
3305
|
})();
|
|
2425
3306
|
return;
|
|
2426
3307
|
}
|
|
2427
|
-
case 'feedback': {
|
|
3308
|
+
case 'feedback-picker': {
|
|
2428
3309
|
const target = lastAssistantMessage.current;
|
|
2429
|
-
|
|
2430
|
-
// in, so a rating and the transcript it judges are one
|
|
2431
|
-
// directory apart and travel together.
|
|
2432
|
-
if (!target) {
|
|
3310
|
+
if (!target || target.messageId !== slash.messageId) {
|
|
2433
3311
|
pushMessage('system', 'Nothing to rate yet.');
|
|
2434
3312
|
return;
|
|
2435
3313
|
}
|
|
2436
|
-
const
|
|
2437
|
-
|
|
2438
|
-
|
|
3314
|
+
const values = ['good', 'bad'];
|
|
3315
|
+
setSelectedChoice(0);
|
|
3316
|
+
setChoicePicker({
|
|
3317
|
+
kind: 'feedback-rating',
|
|
3318
|
+
title: 'Rate the latest answer',
|
|
3319
|
+
runId: target.runId,
|
|
3320
|
+
messageId: target.messageId,
|
|
3321
|
+
values,
|
|
3322
|
+
options: [
|
|
3323
|
+
{
|
|
3324
|
+
label: 'good',
|
|
3325
|
+
description: 'The answer was useful and correct.',
|
|
3326
|
+
},
|
|
3327
|
+
{ label: 'bad', description: 'The answer needs improvement.' },
|
|
3328
|
+
],
|
|
2439
3329
|
});
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
// Read-then-write rather than blind overwrite, so two
|
|
2450
|
-
// raters on one message still collide loudly.
|
|
2451
|
-
const current = (await store.listMessageFeedback({ runId })).find((r) => r.messageId === messageId);
|
|
2452
|
-
const record = await store.putMessageFeedback({
|
|
2453
|
-
runId,
|
|
2454
|
-
messageId,
|
|
2455
|
-
rating: slash.rating,
|
|
2456
|
-
...(slash.note ? { note: slash.note } : {}),
|
|
2457
|
-
expectedVersion: current?.ownerVersion ?? 0,
|
|
2458
|
-
});
|
|
2459
|
-
pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
|
|
2460
|
-
}
|
|
2461
|
-
catch (err) {
|
|
2462
|
-
pushMessage('system', `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`);
|
|
2463
|
-
}
|
|
2464
|
-
})();
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
case 'feedback': {
|
|
3333
|
+
const target = lastAssistantMessage.current;
|
|
3334
|
+
if (!target || target.messageId !== slash.messageId) {
|
|
3335
|
+
pushMessage('system', 'Nothing to rate yet.');
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
recordFeedback(target.runId, slash.messageId, slash.rating, slash.note);
|
|
2465
3339
|
return;
|
|
2466
3340
|
}
|
|
2467
3341
|
case 'review': {
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
3342
|
+
if (slash.instructions) {
|
|
3343
|
+
// Custom instructions are already operator-authored model input.
|
|
3344
|
+
// They take the ordinary FIFO below rather than a second send path.
|
|
3345
|
+
outgoing = slash.instructions;
|
|
3346
|
+
break;
|
|
3347
|
+
}
|
|
3348
|
+
const values = [
|
|
3349
|
+
'base-branch',
|
|
3350
|
+
'uncommitted',
|
|
3351
|
+
'commit',
|
|
3352
|
+
'custom',
|
|
3353
|
+
];
|
|
3354
|
+
setSelectedChoice(0);
|
|
3355
|
+
setChoicePicker({
|
|
3356
|
+
kind: 'review-preset',
|
|
3357
|
+
title: 'Select a review preset',
|
|
3358
|
+
values,
|
|
3359
|
+
options: [
|
|
3360
|
+
{
|
|
3361
|
+
label: 'Base branch',
|
|
3362
|
+
description: 'Review the current work as a branch comparison.',
|
|
3363
|
+
},
|
|
3364
|
+
{
|
|
3365
|
+
label: 'Uncommitted',
|
|
3366
|
+
description: 'Review staged, unstaged, and untracked changes.',
|
|
3367
|
+
},
|
|
3368
|
+
{
|
|
3369
|
+
label: 'Commit',
|
|
3370
|
+
description: 'Review one recent commit.',
|
|
3371
|
+
},
|
|
3372
|
+
{
|
|
3373
|
+
label: 'Custom',
|
|
3374
|
+
description: 'Write exact review instructions in the composer.',
|
|
3375
|
+
},
|
|
3376
|
+
],
|
|
3377
|
+
});
|
|
2488
3378
|
return;
|
|
2489
3379
|
}
|
|
2490
3380
|
case 'diff': {
|
|
@@ -2609,45 +3499,30 @@ export function App({ ctx: initialCtx }) {
|
|
|
2609
3499
|
: 'Raw output mode off — rich transcript rendering restored.');
|
|
2610
3500
|
return;
|
|
2611
3501
|
}
|
|
2612
|
-
case 'export': {
|
|
2613
|
-
|
|
2614
|
-
const destination = scopeRef.current?.sessionId;
|
|
2615
|
-
if (!sessions || !destination) {
|
|
2616
|
-
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
3502
|
+
case 'export-picker': {
|
|
3503
|
+
if (!stableExportSource())
|
|
2617
3504
|
return;
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
const written = await writeConversationExport(projected.markdown, path, ctx.cwd);
|
|
2640
|
-
pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
|
|
2641
|
-
}
|
|
2642
|
-
catch (err) {
|
|
2643
|
-
pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2644
|
-
}
|
|
2645
|
-
finally {
|
|
2646
|
-
exportingRef.current = false;
|
|
2647
|
-
if (conversationGenRef.current === generation)
|
|
2648
|
-
setState('idle');
|
|
2649
|
-
}
|
|
2650
|
-
})();
|
|
3505
|
+
setSelectedChoice(0);
|
|
3506
|
+
setChoicePicker({
|
|
3507
|
+
kind: 'export-destination',
|
|
3508
|
+
title: 'Export conversation',
|
|
3509
|
+
notice: 'Save the complete verified conversation as Markdown.',
|
|
3510
|
+
values: ['clipboard', 'file'],
|
|
3511
|
+
options: [
|
|
3512
|
+
{
|
|
3513
|
+
label: 'Copy to clipboard',
|
|
3514
|
+
description: 'Send the verified Markdown transcript to the terminal clipboard',
|
|
3515
|
+
},
|
|
3516
|
+
{
|
|
3517
|
+
label: 'Save to file',
|
|
3518
|
+
description: 'Choose a Markdown filename; existing files are never overwritten',
|
|
3519
|
+
},
|
|
3520
|
+
],
|
|
3521
|
+
});
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
case 'export': {
|
|
3525
|
+
runConversationExport({ kind: 'file', path: slash.path });
|
|
2651
3526
|
return;
|
|
2652
3527
|
}
|
|
2653
3528
|
default: {
|
|
@@ -2671,11 +3546,25 @@ export function App({ ctx: initialCtx }) {
|
|
|
2671
3546
|
// Keep the attachment array beside its text. Reconstructing a prompt from
|
|
2672
3547
|
// the transcript later cannot recover bytes whose composer chip is gone.
|
|
2673
3548
|
advanceQueueContinuation();
|
|
2674
|
-
|
|
3549
|
+
const prompt = {
|
|
2675
3550
|
kind: 'human',
|
|
2676
3551
|
text: outgoing,
|
|
2677
3552
|
...(attachments && attachments.length > 0 ? { attachments: [...attachments] } : {}),
|
|
2678
|
-
}
|
|
3553
|
+
};
|
|
3554
|
+
if (mode === 'submit') {
|
|
3555
|
+
const inbox = activeTurnInboxRef.current;
|
|
3556
|
+
if (inbox) {
|
|
3557
|
+
const expanded = expandFileMentions(outgoing, ctx.cwd);
|
|
3558
|
+
if (inbox.accept({
|
|
3559
|
+
prompt,
|
|
3560
|
+
message: createUserMessage(expanded.sendText, prompt.attachments),
|
|
3561
|
+
attachedFiles: expanded.attached.length,
|
|
3562
|
+
queueBoundary: queuedRef.current.length,
|
|
3563
|
+
}))
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
enqueueQueued(prompt);
|
|
2679
3568
|
}, [
|
|
2680
3569
|
activeSkills,
|
|
2681
3570
|
advanceQueueContinuation,
|
|
@@ -2684,19 +3573,28 @@ export function App({ ctx: initialCtx }) {
|
|
|
2684
3573
|
ctx.cwd,
|
|
2685
3574
|
doResume,
|
|
2686
3575
|
enqueueQueued,
|
|
2687
|
-
|
|
3576
|
+
exitWithSummary,
|
|
2688
3577
|
hasUnsettledTurn,
|
|
2689
3578
|
nextId,
|
|
2690
3579
|
pushMessage,
|
|
2691
3580
|
rawOutput,
|
|
3581
|
+
removeStoredCredential,
|
|
2692
3582
|
resetTranscript,
|
|
3583
|
+
runConversationExport,
|
|
2693
3584
|
setChoicePicker,
|
|
2694
3585
|
setCopyPicker,
|
|
2695
3586
|
setReasoningEffort,
|
|
2696
3587
|
slashCtx,
|
|
3588
|
+
stableExportSource,
|
|
2697
3589
|
startFreshConversation,
|
|
2698
3590
|
state,
|
|
3591
|
+
stdout.rows,
|
|
2699
3592
|
]);
|
|
3593
|
+
// `applyChoiceSelection` is declared before `handleSubmit` because the
|
|
3594
|
+
// picker is also used by review/export flows. Keep only this dispatch hop in
|
|
3595
|
+
// a ref so selecting a help row re-enters the one ordinary slash-command
|
|
3596
|
+
// path instead of growing a second command executor.
|
|
3597
|
+
commandPickerSubmitRef.current = handleSubmit;
|
|
2700
3598
|
// Keep the footer on the same durable goal record the driver mutates. A ref
|
|
2701
3599
|
// alone cannot repaint React, so every goal mutation bumps goalDriveVersion;
|
|
2702
3600
|
// generation/session checks stop a late disk read labelling a resumed chat.
|
|
@@ -2749,6 +3647,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
2749
3647
|
conversationMutationRef.current ||
|
|
2750
3648
|
exportingRef.current ||
|
|
2751
3649
|
compactingRef.current ||
|
|
3650
|
+
textPromptRef.current ||
|
|
2752
3651
|
choicePickerRef.current ||
|
|
2753
3652
|
copyPickerRef.current ||
|
|
2754
3653
|
goalCommandInFlightRef.current) {
|
|
@@ -2775,6 +3674,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
2775
3674
|
conversationMutationRef.current ||
|
|
2776
3675
|
exportingRef.current ||
|
|
2777
3676
|
compactingRef.current ||
|
|
3677
|
+
textPromptRef.current ||
|
|
2778
3678
|
choicePickerRef.current ||
|
|
2779
3679
|
copyPickerRef.current ||
|
|
2780
3680
|
goalCommandInFlightRef.current ||
|
|
@@ -2834,6 +3734,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
2834
3734
|
conversationMutation,
|
|
2835
3735
|
session,
|
|
2836
3736
|
state,
|
|
3737
|
+
textPrompt,
|
|
2837
3738
|
choicePicker,
|
|
2838
3739
|
copyPicker,
|
|
2839
3740
|
wakeGoalDriver,
|
|
@@ -2850,6 +3751,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
2850
3751
|
conversationMutationRef.current ||
|
|
2851
3752
|
exportingRef.current ||
|
|
2852
3753
|
compactingRef.current ||
|
|
3754
|
+
textPromptRef.current ||
|
|
2853
3755
|
choicePickerRef.current ||
|
|
2854
3756
|
copyPickerRef.current ||
|
|
2855
3757
|
goalCommandInFlightRef.current)
|
|
@@ -2866,6 +3768,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
2866
3768
|
phase,
|
|
2867
3769
|
queued,
|
|
2868
3770
|
queuePause,
|
|
3771
|
+
textPrompt,
|
|
2869
3772
|
choicePicker,
|
|
2870
3773
|
copyPicker,
|
|
2871
3774
|
dequeueQueued,
|
|
@@ -2989,8 +3892,8 @@ export function App({ ctx: initialCtx }) {
|
|
|
2989
3892
|
setPhase('ready');
|
|
2990
3893
|
return;
|
|
2991
3894
|
}
|
|
2992
|
-
|
|
2993
|
-
}, [session,
|
|
3895
|
+
exitWithSummary();
|
|
3896
|
+
}, [session, exitWithSummary]);
|
|
2994
3897
|
useInput((input, key) => {
|
|
2995
3898
|
// The provider picker owns its own keyboard — arrows, digits, enter,
|
|
2996
3899
|
// esc and the typed-key screen are all its business. Ctrl+C is not: it
|
|
@@ -3006,7 +3909,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
3006
3909
|
// and the second exits", with nothing on screen naming either.
|
|
3007
3910
|
if (phase === 'picker') {
|
|
3008
3911
|
if (key.ctrl && input === 'c')
|
|
3009
|
-
|
|
3912
|
+
exitWithSummary();
|
|
3010
3913
|
return;
|
|
3011
3914
|
}
|
|
3012
3915
|
// Previous-prompt picker owns the keyboard. Esc keeps stepping toward
|
|
@@ -3019,16 +3922,26 @@ export function App({ ctx: initialCtx }) {
|
|
|
3019
3922
|
setPhase('ready');
|
|
3020
3923
|
return;
|
|
3021
3924
|
}
|
|
3925
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
3926
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, key.home
|
|
3927
|
+
? 'first'
|
|
3928
|
+
: key.end
|
|
3929
|
+
? 'last'
|
|
3930
|
+
: key.pageUp
|
|
3931
|
+
? 'previous-page'
|
|
3932
|
+
: 'next-page'));
|
|
3933
|
+
return;
|
|
3934
|
+
}
|
|
3022
3935
|
if (key.escape || key.leftArrow || key.upArrow) {
|
|
3023
|
-
setSelectedEdit((index) =>
|
|
3936
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, 'previous'));
|
|
3024
3937
|
return;
|
|
3025
3938
|
}
|
|
3026
3939
|
if (key.rightArrow || key.downArrow) {
|
|
3027
|
-
setSelectedEdit((index) =>
|
|
3940
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, 'next'));
|
|
3028
3941
|
return;
|
|
3029
3942
|
}
|
|
3030
3943
|
if (key.return) {
|
|
3031
|
-
const target = editList[
|
|
3944
|
+
const target = editList[selectedEditRef.current];
|
|
3032
3945
|
if (target)
|
|
3033
3946
|
void confirmPromptEdit(target);
|
|
3034
3947
|
}
|
|
@@ -3042,12 +3955,21 @@ export function App({ ctx: initialCtx }) {
|
|
|
3042
3955
|
// replaced regardless.
|
|
3043
3956
|
if (resumeCommittedRef.current)
|
|
3044
3957
|
return;
|
|
3045
|
-
if (key.
|
|
3046
|
-
setSelectedResume((
|
|
3958
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
3959
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, key.home
|
|
3960
|
+
? 'first'
|
|
3961
|
+
: key.end
|
|
3962
|
+
? 'last'
|
|
3963
|
+
: key.pageUp
|
|
3964
|
+
? 'previous-page'
|
|
3965
|
+
: 'next-page'));
|
|
3966
|
+
}
|
|
3967
|
+
else if (key.upArrow)
|
|
3968
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, 'previous'));
|
|
3047
3969
|
else if (key.downArrow)
|
|
3048
|
-
setSelectedResume((
|
|
3970
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, 'next'));
|
|
3049
3971
|
else if (key.return) {
|
|
3050
|
-
const conv = resumeList[
|
|
3972
|
+
const conv = resumeList[selectedResumeRef.current];
|
|
3051
3973
|
if (conv)
|
|
3052
3974
|
void resumeConversation(conv);
|
|
3053
3975
|
}
|
|
@@ -3064,7 +3986,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
3064
3986
|
// and making the escape hatch hesitate on the program's first screen
|
|
3065
3987
|
// would read as a hang.
|
|
3066
3988
|
if (ch === 'n' || key.escape || (key.ctrl && input === 'c')) {
|
|
3067
|
-
|
|
3989
|
+
exitWithSummary();
|
|
3068
3990
|
return;
|
|
3069
3991
|
}
|
|
3070
3992
|
// Enter is deliberately NOT trust, for the reason it is not approval
|
|
@@ -3084,12 +4006,31 @@ export function App({ ctx: initialCtx }) {
|
|
|
3084
4006
|
}
|
|
3085
4007
|
// A pending permission prompt owns the keyboard: y/n/a decide it.
|
|
3086
4008
|
if (permissionResolveRef.current) {
|
|
4009
|
+
if (permission &&
|
|
4010
|
+
(key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end)) {
|
|
4011
|
+
const count = permissionReviewRows(permission.review, stdout.columns).length;
|
|
4012
|
+
const maxOffset = Math.max(0, count - PERMISSION_REVIEW_PAGE_ROWS);
|
|
4013
|
+
const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
|
|
4014
|
+
const next = key.home
|
|
4015
|
+
? 0
|
|
4016
|
+
: key.end
|
|
4017
|
+
? maxOffset
|
|
4018
|
+
: key.pageUp
|
|
4019
|
+
? Math.max(0, current - PERMISSION_REVIEW_PAGE_ROWS)
|
|
4020
|
+
: key.pageDown
|
|
4021
|
+
? Math.min(maxOffset, current + PERMISSION_REVIEW_PAGE_ROWS)
|
|
4022
|
+
: key.upArrow
|
|
4023
|
+
? Math.max(0, current - 1)
|
|
4024
|
+
: Math.min(maxOffset, current + 1);
|
|
4025
|
+
setPermissionReviewOffset(next);
|
|
4026
|
+
return;
|
|
4027
|
+
}
|
|
3087
4028
|
const ch = input.toLowerCase();
|
|
3088
4029
|
// Refusing is never deferred or gated — it is the direction a
|
|
3089
4030
|
// mistake is recoverable in, so it answers on the first press.
|
|
3090
4031
|
if (key.ctrl && input === 'c') {
|
|
3091
4032
|
resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
|
|
3092
|
-
abortRef.current?.abort();
|
|
4033
|
+
abortRef.current?.abort(new RunCancelled('user'));
|
|
3093
4034
|
return;
|
|
3094
4035
|
}
|
|
3095
4036
|
if (ch === 'n' || key.escape) {
|
|
@@ -3110,25 +4051,49 @@ export function App({ ctx: initialCtx }) {
|
|
|
3110
4051
|
resolvePermission({ kind: 'approve-all' });
|
|
3111
4052
|
return;
|
|
3112
4053
|
}
|
|
4054
|
+
// A host-owned text prompt has its own input hook. This synchronous ref
|
|
4055
|
+
// claims the interval before React paints it too, so App cannot read a
|
|
4056
|
+
// key repeat as an interrupt, exit ladder, or finite-menu choice.
|
|
4057
|
+
if (textPromptRef.current)
|
|
4058
|
+
return;
|
|
3113
4059
|
// Finite slash-command choices own the keyboard below a live permission
|
|
3114
4060
|
// request and above the ordinary composer. Their values are a captured
|
|
3115
4061
|
// menu, so a provider/session change cannot silently reinterpret Enter.
|
|
3116
4062
|
if (choicePickerRef.current) {
|
|
3117
|
-
const
|
|
4063
|
+
const picker = choicePickerRef.current;
|
|
4064
|
+
// Composer publishes a chooser while handling Return. App receives the
|
|
4065
|
+
// same input dispatch (and key repeat can arrive before the commit), so
|
|
4066
|
+
// the synchronous ref is an ownership fence but not yet an actionable
|
|
4067
|
+
// menu. Only the exact object React committed may consume a choice.
|
|
4068
|
+
if (choicePickerCommittedRef.current !== picker)
|
|
4069
|
+
return;
|
|
4070
|
+
const options = picker.options;
|
|
3118
4071
|
if (key.escape || (key.ctrl && input === 'c')) {
|
|
4072
|
+
reviewChoiceInFlightRef.current = null;
|
|
3119
4073
|
setChoicePicker(null);
|
|
3120
4074
|
return;
|
|
3121
4075
|
}
|
|
4076
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
4077
|
+
const pageSize = picker.kind === 'command' ? picker.windowSize : undefined;
|
|
4078
|
+
setSelectedChoice((index) => moveSelection(index, options.length, key.home
|
|
4079
|
+
? 'first'
|
|
4080
|
+
: key.end
|
|
4081
|
+
? 'last'
|
|
4082
|
+
: key.pageUp
|
|
4083
|
+
? 'previous-page'
|
|
4084
|
+
: 'next-page', pageSize));
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
3122
4087
|
if (key.upArrow) {
|
|
3123
|
-
setSelectedChoice((index) =>
|
|
4088
|
+
setSelectedChoice((index) => moveSelection(index, options.length, 'previous'));
|
|
3124
4089
|
return;
|
|
3125
4090
|
}
|
|
3126
4091
|
if (key.downArrow) {
|
|
3127
|
-
setSelectedChoice((index) =>
|
|
4092
|
+
setSelectedChoice((index) => moveSelection(index, options.length, 'next'));
|
|
3128
4093
|
return;
|
|
3129
4094
|
}
|
|
3130
4095
|
if (key.return) {
|
|
3131
|
-
applyChoiceSelection(
|
|
4096
|
+
applyChoiceSelection(selectedChoiceRef.current);
|
|
3132
4097
|
return;
|
|
3133
4098
|
}
|
|
3134
4099
|
if (/^[1-9]$/.test(input)) {
|
|
@@ -3147,16 +4112,26 @@ export function App({ ctx: initialCtx }) {
|
|
|
3147
4112
|
setCopyPicker(null);
|
|
3148
4113
|
return;
|
|
3149
4114
|
}
|
|
4115
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
4116
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, key.home
|
|
4117
|
+
? 'first'
|
|
4118
|
+
: key.end
|
|
4119
|
+
? 'last'
|
|
4120
|
+
: key.pageUp
|
|
4121
|
+
? 'previous-page'
|
|
4122
|
+
: 'next-page'));
|
|
4123
|
+
return;
|
|
4124
|
+
}
|
|
3150
4125
|
if (key.upArrow) {
|
|
3151
|
-
setSelectedCopy((index) =>
|
|
4126
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, 'previous'));
|
|
3152
4127
|
return;
|
|
3153
4128
|
}
|
|
3154
4129
|
if (key.downArrow) {
|
|
3155
|
-
setSelectedCopy((index) =>
|
|
4130
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, 'next'));
|
|
3156
4131
|
return;
|
|
3157
4132
|
}
|
|
3158
4133
|
if (key.return) {
|
|
3159
|
-
sendCopyRequest(
|
|
4134
|
+
sendCopyRequest(selectedCopyRef.current);
|
|
3160
4135
|
return;
|
|
3161
4136
|
}
|
|
3162
4137
|
if (/^[1-9]$/.test(input)) {
|
|
@@ -3166,6 +4141,19 @@ export function App({ ctx: initialCtx }) {
|
|
|
3166
4141
|
}
|
|
3167
4142
|
return;
|
|
3168
4143
|
}
|
|
4144
|
+
// Terminal-native display clear. It deliberately shares `/clear-screen`'s
|
|
4145
|
+
// view-only boundary: model history, durable history and the active
|
|
4146
|
+
// conversation remain untouched. A live turn is refused instead of
|
|
4147
|
+
// erasing the only visible account of work that is still changing.
|
|
4148
|
+
if (key.ctrl && input === 'l') {
|
|
4149
|
+
if (state !== 'idle' || abortRef.current || hasUnsettledTurn()) {
|
|
4150
|
+
pushMessage('system', 'Ctrl+L is unavailable while a turn is still in progress.');
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
setMessages([]);
|
|
4154
|
+
resetTranscript();
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
3169
4157
|
// Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
|
|
3170
4158
|
// the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
|
|
3171
4159
|
if (key.escape && abortRef.current) {
|
|
@@ -3225,7 +4213,7 @@ export function App({ ctx: initialCtx }) {
|
|
|
3225
4213
|
return;
|
|
3226
4214
|
}
|
|
3227
4215
|
if (exitArmedRef.current) {
|
|
3228
|
-
|
|
4216
|
+
exitWithSummary();
|
|
3229
4217
|
return;
|
|
3230
4218
|
}
|
|
3231
4219
|
exitArmedRef.current = true;
|
|
@@ -3238,30 +4226,41 @@ export function App({ ctx: initialCtx }) {
|
|
|
3238
4226
|
// Active on every phase. It was gated off during the picker, which is what
|
|
3239
4227
|
// left that screen without a usable exit; the picker branch above returns
|
|
3240
4228
|
// immediately, so the Picker still owns everything except Ctrl+C.
|
|
3241
|
-
{ isActive:
|
|
4229
|
+
{ isActive: externalEditorRequest === null });
|
|
3242
4230
|
// Background is left natural — we inherit the terminal's own background
|
|
3243
4231
|
// and only theme the foreground. Forcing
|
|
3244
4232
|
// a filled bg left mismatched patches around bordered areas, so we don't.
|
|
3245
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, resetKey: resetKey, raw: rawOutput, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls })) : choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice })) : copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
|
|
4233
|
+
return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, reviewOffset: permissionReviewOffset, columns: stdout.columns })) : null, textPrompt ? (_jsx(TextPrompt, { title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null, onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
|
|
3246
4234
|
phase === 'ready' &&
|
|
3247
4235
|
conversationMutation === null &&
|
|
4236
|
+
textPrompt === null &&
|
|
3248
4237
|
choicePicker === null &&
|
|
3249
|
-
copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [
|
|
4238
|
+
copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
|
|
4239
|
+
permission === null &&
|
|
4240
|
+
textPrompt === null &&
|
|
4241
|
+
choicePicker === null &&
|
|
4242
|
+
copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.accent.user, children: ["\u21B3 ", pendingSteers.length, " message", pendingSteers.length > 1 ? 's' : '', " steering the active turn \u2014 waiting for its next response boundary"] }) })) : null, queued.length > 0 &&
|
|
3250
4243
|
permission === null &&
|
|
4244
|
+
textPrompt === null &&
|
|
3251
4245
|
choicePicker === null &&
|
|
3252
4246
|
copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
|
|
3253
4247
|
? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
|
|
3254
4248
|
: 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
|
|
3255
4249
|
state === 'awaiting-permission' ||
|
|
3256
4250
|
compacting ||
|
|
3257
|
-
conversationMutation !== null
|
|
4251
|
+
conversationMutation !== null ||
|
|
4252
|
+
externalEditorRequest !== null, hidden: permission !== null ||
|
|
4253
|
+
textPrompt !== null ||
|
|
4254
|
+
choicePicker !== null ||
|
|
4255
|
+
copyPicker !== null,
|
|
3258
4256
|
// A turn is running, so Esc is the interrupt and not
|
|
3259
4257
|
// the composer's clear.
|
|
3260
|
-
escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), userCommands: userCommands, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: phase === 'ready' &&
|
|
4258
|
+
escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onStepReasoningEffort: stepReasoningEffort, onExternalEdit: requestExternalEditor, onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), userCommands: userCommands, mentionCandidates: mentionCandidates, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: phase === 'ready' &&
|
|
3261
4259
|
state === 'idle' &&
|
|
3262
4260
|
conversationMutation === null &&
|
|
3263
4261
|
!compacting &&
|
|
3264
4262
|
permission === null &&
|
|
4263
|
+
textPrompt === null &&
|
|
3265
4264
|
choicePicker === null &&
|
|
3266
4265
|
copyPicker === null
|
|
3267
4266
|
? goalStatusLabel(goalStatus)
|
|
@@ -3269,19 +4268,23 @@ export function App({ ctx: initialCtx }) {
|
|
|
3269
4268
|
? 'forking conversation — input is paused'
|
|
3270
4269
|
: conversationMutation === 'edit'
|
|
3271
4270
|
? 'branching before prompt — input is paused'
|
|
3272
|
-
: conversationMutation === '
|
|
3273
|
-
? '
|
|
3274
|
-
:
|
|
3275
|
-
? '
|
|
3276
|
-
:
|
|
3277
|
-
?
|
|
3278
|
-
:
|
|
3279
|
-
?
|
|
3280
|
-
:
|
|
3281
|
-
? '
|
|
3282
|
-
:
|
|
3283
|
-
?
|
|
3284
|
-
:
|
|
4271
|
+
: conversationMutation === 'archive'
|
|
4272
|
+
? 'archiving conversation — input is paused'
|
|
4273
|
+
: conversationMutation === 'new'
|
|
4274
|
+
? 'starting a fresh conversation — input is paused'
|
|
4275
|
+
: compacting
|
|
4276
|
+
? 'compacting conversation — input is paused'
|
|
4277
|
+
: permission
|
|
4278
|
+
? hintForPhase(phase, state, session?.hasProvider === true)
|
|
4279
|
+
: textPrompt
|
|
4280
|
+
? 'name editor open — enter save · esc cancel'
|
|
4281
|
+
: choicePicker
|
|
4282
|
+
? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
|
|
4283
|
+
: copyPicker
|
|
4284
|
+
? 'copy target open — ↑↓ / 1–9 select · esc cancel'
|
|
4285
|
+
: goalStatus && phase === 'ready' && state === 'idle'
|
|
4286
|
+
? undefined
|
|
4287
|
+
: hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
|
|
3285
4288
|
}
|
|
3286
4289
|
function Banner({ version, session, permissionMode, cwd, }) {
|
|
3287
4290
|
const cols = process.stdout.columns ?? 80;
|
|
@@ -3336,7 +4339,7 @@ canReturn = false) {
|
|
|
3336
4339
|
if (state === 'awaiting-permission')
|
|
3337
4340
|
return 'y approve · n / esc reject · a approve all';
|
|
3338
4341
|
if (state !== 'idle')
|
|
3339
|
-
return '
|
|
4342
|
+
return 'enter steer · tab queue · esc interrupt';
|
|
3340
4343
|
// The composer already points to /help. Keeping the complete key legend in
|
|
3341
4344
|
// every idle frame made ordinary conversation read like a permanent setup
|
|
3342
4345
|
// screen; only state-specific actions belong in the footer.
|