@namzu/cli 14.3.0 → 15.1.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 +117 -17
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -26
- package/dist/cli.js.map +1 -1
- package/dist/commands/acp.d.ts.map +1 -1
- package/dist/commands/acp.js +3 -2
- 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/run.d.ts.map +1 -1
- package/dist/commands/run.js +36 -12
- 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/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/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.map +1 -1
- package/dist/integrations/updates.js +29 -4
- package/dist/integrations/updates.js.map +1 -1
- package/dist/tui/App.d.ts +10 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +1039 -252
- 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 +19 -2
- package/dist/tui/Composer.d.ts.map +1 -1
- package/dist/tui/Composer.js +526 -70
- 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/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 +47 -11
- 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/StatusBar.js +3 -3
- package/dist/tui/StatusBar.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 +9 -10
- package/dist/tui/Transcript.d.ts.map +1 -1
- package/dist/tui/Transcript.js +11 -12
- package/dist/tui/Transcript.js.map +1 -1
- package/dist/tui/agent.d.ts +57 -23
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +136 -86
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/exit-summary.d.ts.map +1 -1
- package/dist/tui/exit-summary.js +2 -1
- package/dist/tui/exit-summary.js.map +1 -1
- 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 +4 -0
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/live-window.d.ts +17 -11
- package/dist/tui/live-window.d.ts.map +1 -1
- package/dist/tui/live-window.js +64 -20
- package/dist/tui/live-window.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/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/run-interruption.d.ts +14 -0
- package/dist/tui/run-interruption.d.ts.map +1 -0
- package/dist/tui/run-interruption.js +67 -0
- package/dist/tui/run-interruption.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 +8 -7
- package/dist/commands/stubs.d.ts.map +0 -1
- package/dist/commands/stubs.js.map +0 -1
- package/dist/tui/bottom-spacer.d.ts +0 -95
- package/dist/tui/bottom-spacer.d.ts.map +0 -1
- package/dist/tui/bottom-spacer.js +0 -83
- package/dist/tui/bottom-spacer.js.map +0 -1
package/dist/tui/App.js
CHANGED
|
@@ -12,16 +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';
|
|
16
|
-
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
15
|
+
import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
|
|
16
|
+
import { Box, Text, useApp, useInput, useStdout, useWindowSize } 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
20
|
import { runtimeContextLabel } from '../context/runtime-message.js';
|
|
21
21
|
import { writeClipboardText } from '../integrations/clipboard/text.js';
|
|
22
22
|
import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
|
|
23
|
-
import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
|
|
24
|
-
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';
|
|
25
25
|
import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
|
|
26
26
|
import { isTrusted, trustDir } from '../integrations/trust/store.js';
|
|
27
27
|
import { checkUpdates } from '../integrations/updates.js';
|
|
@@ -29,32 +29,39 @@ import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.j
|
|
|
29
29
|
import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
|
|
30
30
|
import { discoverUserCommands } from '../user-commands/store.js';
|
|
31
31
|
import { ChoicePicker } from './ChoicePicker.js';
|
|
32
|
-
import { Composer } from './Composer.js';
|
|
32
|
+
import { Composer, suggestionWindowSize, } from './Composer.js';
|
|
33
33
|
import { CopyPicker } from './CopyPicker.js';
|
|
34
34
|
import { EditPromptPicker } from './EditPromptPicker.js';
|
|
35
35
|
import { LiveActivity, formatElapsed } from './LiveActivity.js';
|
|
36
36
|
import { PermissionOverlay } from './PermissionOverlay.js';
|
|
37
|
+
import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
|
|
37
38
|
import { Picker } from './Picker.js';
|
|
38
39
|
import { ResumePicker } from './ResumePicker.js';
|
|
39
40
|
import { StatusBar } from './StatusBar.js';
|
|
41
|
+
import { TextPrompt } from './TextPrompt.js';
|
|
40
42
|
import { Transcript, willCollapse } from './Transcript.js';
|
|
43
|
+
import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
|
|
41
44
|
import { TrustPrompt } from './TrustPrompt.js';
|
|
42
45
|
import { createAgentSession, probeAgentSession, } from './agent.js';
|
|
43
|
-
import { bottomSpacerRows } from './bottom-spacer.js';
|
|
44
46
|
import { keepRecentRows } from './compact-transcript.js';
|
|
45
47
|
import { approvalIsDeliberate } from './consent-timing.js';
|
|
46
48
|
import { planTurnPublication } from './conversation-history.js';
|
|
47
49
|
import { copyTargetsForResponse } from './copy-targets.js';
|
|
48
50
|
import { editablePrompts } from './edit-prompts.js';
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
+
import { liveWindow } from './live-window.js';
|
|
52
|
+
import { describeRunInterruption } from './run-interruption.js';
|
|
53
|
+
import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
|
|
51
54
|
import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
|
|
52
|
-
import { expandFileMentions } from './mentions.js';
|
|
55
|
+
import { expandFileMentions, listMentionableFiles } from './mentions.js';
|
|
53
56
|
import { openInBrowser } from './open-browser.js';
|
|
54
|
-
import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
|
|
57
|
+
import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
|
|
55
58
|
import { splitCompleteBlocks } from './stream-blocks.js';
|
|
59
|
+
import { moveSelection } from './selection-window.js';
|
|
56
60
|
import { theme } from './theme.js';
|
|
61
|
+
import { useSelectionIndex } from './use-selection-index.js';
|
|
62
|
+
import { editDraftInExternalEditor } from './external-editor.js';
|
|
57
63
|
import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
|
|
64
|
+
import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
|
|
58
65
|
/** Last non-empty assistant text in a durable conversation, newest first. */
|
|
59
66
|
function latestAssistantOutput(messages) {
|
|
60
67
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
@@ -67,6 +74,12 @@ function latestAssistantOutput(messages) {
|
|
|
67
74
|
}
|
|
68
75
|
return null;
|
|
69
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
|
+
}
|
|
70
83
|
/** Build the transcript projection of exact model-visible history. */
|
|
71
84
|
export function projectConversation(messages, nextId, readableUserTexts = []) {
|
|
72
85
|
let userOrdinal = 0;
|
|
@@ -138,22 +151,6 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
|
|
|
138
151
|
return [];
|
|
139
152
|
});
|
|
140
153
|
}
|
|
141
|
-
function sameSpacerLayout(previous, current) {
|
|
142
|
-
if (previous === null ||
|
|
143
|
-
previous.rows !== current.rows ||
|
|
144
|
-
previous.columns !== current.columns ||
|
|
145
|
-
previous.raw !== current.raw ||
|
|
146
|
-
previous.messageCount !== current.messageCount ||
|
|
147
|
-
previous.tail.length !== current.tail.length)
|
|
148
|
-
return false;
|
|
149
|
-
return current.tail.every((message, index) => {
|
|
150
|
-
const prior = previous.tail[index];
|
|
151
|
-
return (prior?.id === message.id &&
|
|
152
|
-
prior.content === message.content &&
|
|
153
|
-
prior.meta === message.meta &&
|
|
154
|
-
prior.detail === message.detail);
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
154
|
/** Put an undelivered steer back where it occurred among Tab-queued prompts. */
|
|
158
155
|
function mergeUndeliveredLiveInput(queued, undelivered) {
|
|
159
156
|
const merged = [...queued];
|
|
@@ -196,13 +193,13 @@ function goalRoundPrompt(authority) {
|
|
|
196
193
|
* Rows the live region occupies apart from the transcript window: the activity
|
|
197
194
|
* line, the composer frame and its padding, the status bar.
|
|
198
195
|
*
|
|
199
|
-
* Counted generously
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* until the renderer gives up on incremental repaint. See `live-window.ts`.
|
|
196
|
+
* Counted generously because under-counting lets the redrawable transcript
|
|
197
|
+
* window grow until the renderer gives up on incremental repaint. Over-counting
|
|
198
|
+
* merely settles one more row into native terminal scrollback.
|
|
203
199
|
*/
|
|
204
200
|
const LIVE_FURNITURE_ROWS = 10;
|
|
205
|
-
|
|
201
|
+
const defaultExternalEditor = ({ seed, cwd, signal }) => editDraftInExternalEditor(seed, { cwd, signal });
|
|
202
|
+
export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultExternalEditor, }) {
|
|
206
203
|
// Bind approval and every operation it admits to one real directory. A
|
|
207
204
|
// lexical cwd may be a writable symlink; resolving it again after the gate
|
|
208
205
|
// would let it be repointed from the approved project to another.
|
|
@@ -217,6 +214,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
217
214
|
}, [initialCtx]);
|
|
218
215
|
const { exit } = useApp();
|
|
219
216
|
const { stdout, write: writeStdout } = useStdout();
|
|
217
|
+
const terminal = useWindowSize();
|
|
218
|
+
const hyperlinks = terminalSupportsHyperlinks(process.env, stdout.isTTY === true);
|
|
220
219
|
/**
|
|
221
220
|
* The last assistant message id the run reported, for `/feedback`.
|
|
222
221
|
*
|
|
@@ -251,6 +250,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
251
250
|
* has to pretend it is one.
|
|
252
251
|
*/
|
|
253
252
|
const modelHistoryRef = useRef([]);
|
|
253
|
+
/** Consumed only after the exact durable conversation has loaded successfully. */
|
|
254
|
+
const initialConversationIdRef = useRef(initialCtx.initialConversationId);
|
|
254
255
|
const [history, setHistory] = useState([]);
|
|
255
256
|
const [state, setState] = useState('idle');
|
|
256
257
|
const [phase, setPhase] = useState('probing');
|
|
@@ -284,6 +285,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
284
285
|
*/
|
|
285
286
|
const [pickerNotice, setPickerNotice] = useState(null);
|
|
286
287
|
const [permission, setPermission] = useState(null);
|
|
288
|
+
const [permissionReviewOffset, setPermissionReviewOffsetState] = useState(0);
|
|
289
|
+
const permissionReviewOffsetRef = useRef(0);
|
|
290
|
+
const setPermissionReviewOffset = useCallback((next) => {
|
|
291
|
+
permissionReviewOffsetRef.current = next;
|
|
292
|
+
setPermissionReviewOffsetState(next);
|
|
293
|
+
}, []);
|
|
287
294
|
// The launch bypass is an INITIAL selection, not permanent authority. A
|
|
288
295
|
// typed /permissions command can narrow it back to prompt/strict without
|
|
289
296
|
// rebuilding the provider, tools, plugins, or sandbox.
|
|
@@ -302,6 +309,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
302
309
|
// per keypress is a cost nobody asked for. A file added mid-session is
|
|
303
310
|
// picked up by `/model` (which re-hydrates) or a restart.
|
|
304
311
|
const [userCommands, setUserCommands] = useState([]);
|
|
312
|
+
/** Git-admitted file paths cached once per successful session hydration. */
|
|
313
|
+
const [mentionCandidates, setMentionCandidates] = useState([]);
|
|
314
|
+
const mentionLoadOwnerRef = useRef(null);
|
|
305
315
|
const [usage, setUsage] = useState(null);
|
|
306
316
|
// Context fill, straight from the kernel and held apart from `usage` —
|
|
307
317
|
// they are different quantities and conflating them is what made the
|
|
@@ -321,8 +331,6 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
321
331
|
* hold the window shut for the length of the next conversation.
|
|
322
332
|
*/
|
|
323
333
|
const settledRef = useRef(0);
|
|
324
|
-
/** Keeps an in-place detail toggle from moving the rows above that detail. */
|
|
325
|
-
const spacerLayoutRef = useRef(null);
|
|
326
334
|
// Complete prompts waiting for the one queue pump that may start a turn.
|
|
327
335
|
const [queued, setQueued] = useState([]);
|
|
328
336
|
/** Return-submitted prompts accepted by the active turn but not yet drained by the SDK. */
|
|
@@ -371,14 +379,14 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
371
379
|
const compactingRef = useRef(false);
|
|
372
380
|
const [compacting, setCompacting] = useState(false);
|
|
373
381
|
const [resumeList, setResumeList] = useState([]);
|
|
374
|
-
const
|
|
382
|
+
const { selection: selectedResume, selectionRef: selectedResumeRef, setSelection: setSelectedResume, } = useSelectionIndex(0);
|
|
375
383
|
const [editList, setEditList] = useState([]);
|
|
376
|
-
const
|
|
384
|
+
const { selection: selectedEdit, selectionRef: selectedEditRef, setSelection: setSelectedEdit, } = useSelectionIndex(0);
|
|
377
385
|
const editCommittedRef = useRef(false);
|
|
378
386
|
/** `/copy` owns this exact response snapshot until selection or cancellation. */
|
|
379
387
|
const [copyPicker, setCopyPickerState] = useState(null);
|
|
380
388
|
const copyPickerRef = useRef(null);
|
|
381
|
-
const
|
|
389
|
+
const { selection: selectedCopy, selectionRef: selectedCopyRef, setSelection: setSelectedCopy, } = useSelectionIndex(0);
|
|
382
390
|
const setCopyPicker = useCallback((next) => {
|
|
383
391
|
copyPickerRef.current = next;
|
|
384
392
|
setCopyPickerState(next);
|
|
@@ -392,7 +400,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
392
400
|
* that created it before a menu has existed in the rendered tree.
|
|
393
401
|
*/
|
|
394
402
|
const choicePickerCommittedRef = useRef(null);
|
|
395
|
-
const
|
|
403
|
+
const commandPickerSubmitRef = useRef(() => { });
|
|
404
|
+
const { selection: selectedChoice, selectionRef: selectedChoiceRef, setSelection: setSelectedChoice, } = useSelectionIndex(0);
|
|
396
405
|
const setChoicePicker = useCallback((next) => {
|
|
397
406
|
choicePickerRef.current = next;
|
|
398
407
|
if (next === null)
|
|
@@ -402,8 +411,18 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
402
411
|
useEffect(() => {
|
|
403
412
|
choicePickerCommittedRef.current = choicePicker;
|
|
404
413
|
}, [choicePicker]);
|
|
414
|
+
/** Host-owned text decision; its value never enters model prompt history. */
|
|
415
|
+
const [textPrompt, setTextPromptState] = useState(null);
|
|
416
|
+
const textPromptRef = useRef(null);
|
|
417
|
+
const textPromptTokenRef = useRef(0);
|
|
418
|
+
const setTextPrompt = useCallback((next) => {
|
|
419
|
+
textPromptRef.current = next;
|
|
420
|
+
setTextPromptState(next);
|
|
421
|
+
}, []);
|
|
405
422
|
const [composerDraft, setComposerDraft] = useState(null);
|
|
406
423
|
const composerDraftTokenRef = useRef(0);
|
|
424
|
+
/** One git-backed review choice may be resolving while its visible picker remains authoritative. */
|
|
425
|
+
const reviewChoiceInFlightRef = useRef(null);
|
|
407
426
|
const exitArmedRef = useRef(false);
|
|
408
427
|
const abortRef = useRef(null);
|
|
409
428
|
/** Identity of the turn allowed to notify when it settles. */
|
|
@@ -533,6 +552,10 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
533
552
|
*/
|
|
534
553
|
const conversationMutationRef = useRef(null);
|
|
535
554
|
const [conversationMutation, setConversationMutation] = useState(null);
|
|
555
|
+
/** Host editor operation; it owns terminal input until its child settles. */
|
|
556
|
+
const [externalEditorRequest, setExternalEditorRequest] = useState(null);
|
|
557
|
+
const externalEditorRequestRef = useRef(null);
|
|
558
|
+
const externalEditorStartedRef = useRef(null);
|
|
536
559
|
/** Closes the same-tick input window while a verified export reads disk. */
|
|
537
560
|
const exportingRef = useRef(false);
|
|
538
561
|
/** A goal mutation is ordered before any later conversation command. */
|
|
@@ -568,6 +591,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
568
591
|
settledRef.current = 0;
|
|
569
592
|
setResetKey((k) => k + 1);
|
|
570
593
|
}, [stdout.isTTY, writeStdout]);
|
|
594
|
+
const requestExternalEditor = useCallback((seed) => {
|
|
595
|
+
if (phase !== 'ready' ||
|
|
596
|
+
state !== 'idle' ||
|
|
597
|
+
abortRef.current !== null ||
|
|
598
|
+
hasUnsettledTurn() ||
|
|
599
|
+
queuedRef.current.length > 0 ||
|
|
600
|
+
compactingRef.current ||
|
|
601
|
+
conversationMutationRef.current !== null) {
|
|
602
|
+
return Promise.reject(new Error('wait for the active turn, queue, compaction, or conversation change to settle'));
|
|
603
|
+
}
|
|
604
|
+
if (externalEditorRequestRef.current) {
|
|
605
|
+
return Promise.reject(new Error('an external editor is already open'));
|
|
606
|
+
}
|
|
607
|
+
return new Promise((resolve, reject) => {
|
|
608
|
+
const request = {
|
|
609
|
+
token: {},
|
|
610
|
+
seed,
|
|
611
|
+
controller: new AbortController(),
|
|
612
|
+
resolve,
|
|
613
|
+
reject,
|
|
614
|
+
};
|
|
615
|
+
externalEditorRequestRef.current = request;
|
|
616
|
+
setExternalEditorRequest(request);
|
|
617
|
+
});
|
|
618
|
+
}, [hasUnsettledTurn, phase, state]);
|
|
619
|
+
useEffect(() => {
|
|
620
|
+
const request = externalEditorRequest;
|
|
621
|
+
if (!request || externalEditorStartedRef.current === request.token)
|
|
622
|
+
return;
|
|
623
|
+
externalEditorStartedRef.current = request.token;
|
|
624
|
+
void (async () => {
|
|
625
|
+
// Let React deactivate every Ink input hook first. The editor must inherit
|
|
626
|
+
// a cooked terminal, not the raw byte stream the composer consumes.
|
|
627
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
628
|
+
if (request.controller.signal.aborted)
|
|
629
|
+
return;
|
|
630
|
+
if (stdout.isTTY)
|
|
631
|
+
writeStdout('\x1b[2J\x1b[H');
|
|
632
|
+
let edited;
|
|
633
|
+
let failure;
|
|
634
|
+
try {
|
|
635
|
+
edited = await externalEditor({
|
|
636
|
+
seed: request.seed,
|
|
637
|
+
cwd: trustedCwdRef.current,
|
|
638
|
+
signal: request.controller.signal,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
failure = error;
|
|
643
|
+
}
|
|
644
|
+
if (request.controller.signal.aborted)
|
|
645
|
+
return;
|
|
646
|
+
if (externalEditorRequestRef.current === request) {
|
|
647
|
+
externalEditorRequestRef.current = null;
|
|
648
|
+
resetTranscript();
|
|
649
|
+
setExternalEditorRequest(null);
|
|
650
|
+
}
|
|
651
|
+
if (failure !== undefined)
|
|
652
|
+
request.reject(failure);
|
|
653
|
+
else
|
|
654
|
+
request.resolve(edited ?? '');
|
|
655
|
+
})();
|
|
656
|
+
}, [externalEditor, externalEditorRequest, resetTranscript, stdout.isTTY, writeStdout]);
|
|
657
|
+
useEffect(() => () => {
|
|
658
|
+
const request = externalEditorRequestRef.current;
|
|
659
|
+
if (!request)
|
|
660
|
+
return;
|
|
661
|
+
externalEditorRequestRef.current = null;
|
|
662
|
+
request.controller.abort(new Error('the terminal session closed'));
|
|
663
|
+
}, []);
|
|
571
664
|
const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
|
|
572
665
|
const id = nextId();
|
|
573
666
|
setMessages((prev) => [
|
|
@@ -650,6 +743,222 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
650
743
|
setReasoningEffort(effort);
|
|
651
744
|
pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
|
|
652
745
|
}, [hasUnsettledTurn, pushMessage, session, setReasoningEffort, state]);
|
|
746
|
+
const stepReasoningEffort = useCallback((direction) => {
|
|
747
|
+
const levels = session?.reasoningEffortLevels;
|
|
748
|
+
if (!session?.hasProvider || levels === undefined || levels.length === 0) {
|
|
749
|
+
pushMessage('system', 'Reasoning shortcuts are unavailable because the current provider chain has no exact non-empty effort menu.');
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const selected = reasoningEffortRef.current;
|
|
753
|
+
const anchor = selected !== undefined && levels.includes(selected)
|
|
754
|
+
? selected
|
|
755
|
+
: session.reasoningEffortDefault;
|
|
756
|
+
if (anchor === undefined || !levels.includes(anchor)) {
|
|
757
|
+
pushMessage('system', 'Reasoning shortcuts are unavailable because the provider chain does not publish one exact default. Use /effort to choose a level explicitly.');
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const index = levels.indexOf(anchor);
|
|
761
|
+
const next = direction === 'raise' ? levels[index + 1] : levels[index - 1];
|
|
762
|
+
if (next === undefined) {
|
|
763
|
+
pushMessage('system', `Reasoning is already at the ${direction === 'raise' ? 'highest' : 'lowest'} shortcut level (${anchor}).`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (direction === 'raise' && (next === 'max' || next === 'ultra')) {
|
|
767
|
+
pushMessage('system', `${next} is an advanced reasoning level. Choose it explicitly with /effort.`);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
applyReasoningEffort(next);
|
|
771
|
+
}, [applyReasoningEffort, pushMessage, session]);
|
|
772
|
+
const recordFeedback = useCallback((runIdValue, messageIdValue, rating, note) => {
|
|
773
|
+
// Written under the same `<cwd>/.namzu` root the runs live in, so a
|
|
774
|
+
// rating and the transcript it judges travel together.
|
|
775
|
+
const store = new DiskMessageFeedbackStore({
|
|
776
|
+
rootDir: join(ctx.cwd, '.namzu', 'feedback'),
|
|
777
|
+
runsDir: join(ctx.cwd, '.namzu', 'runs'),
|
|
778
|
+
});
|
|
779
|
+
const runId = runIdValue;
|
|
780
|
+
const messageId = messageIdValue;
|
|
781
|
+
void (async () => {
|
|
782
|
+
try {
|
|
783
|
+
// A later rating replaces the first. Read-then-write preserves the
|
|
784
|
+
// store's owner-version collision check instead of blind overwrite.
|
|
785
|
+
const current = (await store.listMessageFeedback({ runId })).find((record) => record.messageId === messageId);
|
|
786
|
+
const record = await store.putMessageFeedback({
|
|
787
|
+
runId,
|
|
788
|
+
messageId,
|
|
789
|
+
rating,
|
|
790
|
+
...(note ? { note } : {}),
|
|
791
|
+
expectedVersion: current?.ownerVersion ?? 0,
|
|
792
|
+
});
|
|
793
|
+
pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
pushMessage('system', `Could not record feedback: ${error instanceof Error ? error.message : String(error)}`);
|
|
797
|
+
}
|
|
798
|
+
})();
|
|
799
|
+
}, [ctx.cwd, pushMessage]);
|
|
800
|
+
const activateSkill = useCallback((name) => {
|
|
801
|
+
const info = discoverSkills({ cwd: ctx.cwd }).find((skill) => skill.name === name);
|
|
802
|
+
if (!info) {
|
|
803
|
+
pushMessage('system', `No skill named "${name}". See /skills.`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
const body = loadSkillBody(info);
|
|
808
|
+
setActiveSkills((previous) => [
|
|
809
|
+
...previous.filter((skill) => skill.name !== info.name),
|
|
810
|
+
{ name: info.name, body },
|
|
811
|
+
]);
|
|
812
|
+
pushMessage('system', `Activated skill: ${info.name}`);
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
pushMessage('system', `Could not load skill "${name}": ${error instanceof Error ? error.message : String(error)}`);
|
|
816
|
+
}
|
|
817
|
+
}, [ctx.cwd, pushMessage]);
|
|
818
|
+
const removeStoredCredential = useCallback((target) => {
|
|
819
|
+
const path = credentialsPath();
|
|
820
|
+
const hadClaude = readStoredSubscriptionCredential() !== null;
|
|
821
|
+
const hadCodex = readStoredCodexCredential() !== null;
|
|
822
|
+
try {
|
|
823
|
+
if (target === 'anthropic')
|
|
824
|
+
clearStoredSubscriptionCredential();
|
|
825
|
+
else if (target === 'codex')
|
|
826
|
+
clearStoredCodexCredential();
|
|
827
|
+
else
|
|
828
|
+
clearAllStoredCredentials();
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
pushMessage('system', `Could not remove ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (target === 'all') {
|
|
835
|
+
pushMessage('system', describeLogout(path, hadClaude || hadCodex));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
pushMessage('system', describeProviderLogout(path, target, target === 'anthropic' ? hadClaude : hadCodex));
|
|
839
|
+
}, [pushMessage]);
|
|
840
|
+
/** Publish one recoverable, read-only tombstone before leaving the TUI. */
|
|
841
|
+
const archiveCurrentConversation = useCallback(() => {
|
|
842
|
+
if (conversationMutationRef.current)
|
|
843
|
+
return;
|
|
844
|
+
const sessions = sessionsRef.current;
|
|
845
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
846
|
+
if (!sessions || !sessionId) {
|
|
847
|
+
pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (state !== 'idle' ||
|
|
851
|
+
abortRef.current !== null ||
|
|
852
|
+
hasUnsettledTurn() ||
|
|
853
|
+
queuedRef.current.length > 0 ||
|
|
854
|
+
compactingRef.current ||
|
|
855
|
+
exportingRef.current) {
|
|
856
|
+
pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const generation = conversationGenRef.current;
|
|
860
|
+
conversationMutationRef.current = 'archive';
|
|
861
|
+
setConversationMutation('archive');
|
|
862
|
+
void (async () => {
|
|
863
|
+
let archived = false;
|
|
864
|
+
try {
|
|
865
|
+
await persistenceTailRef.current;
|
|
866
|
+
if (conversationGenRef.current !== generation ||
|
|
867
|
+
scopeRef.current?.sessionId !== sessionId ||
|
|
868
|
+
hasUnsettledTurn(generation) ||
|
|
869
|
+
queuedRef.current.length > 0) {
|
|
870
|
+
throw new Error('the active conversation changed before archive publication');
|
|
871
|
+
}
|
|
872
|
+
await archiveConversation(sessions, sessionId);
|
|
873
|
+
archived = true;
|
|
874
|
+
goalActivation.clear();
|
|
875
|
+
onExitSummary?.({});
|
|
876
|
+
exit();
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
pushMessage('system', `Could not archive this conversation: ${error instanceof Error ? error.message : String(error)}`);
|
|
880
|
+
}
|
|
881
|
+
finally {
|
|
882
|
+
if (!archived && conversationMutationRef.current === 'archive') {
|
|
883
|
+
conversationMutationRef.current = null;
|
|
884
|
+
setConversationMutation(null);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
})();
|
|
888
|
+
}, [exit, goalActivation, hasUnsettledTurn, onExitSummary, pushMessage, state]);
|
|
889
|
+
/**
|
|
890
|
+
* Resolve the one durable conversation boundary shared by both export
|
|
891
|
+
* destinations. The chooser itself owns input, but it does not weaken this
|
|
892
|
+
* admission check: a direct `/export path` and a later chooser selection
|
|
893
|
+
* must prove the same session and turn stability before reading evidence.
|
|
894
|
+
*/
|
|
895
|
+
const stableExportSource = useCallback(() => {
|
|
896
|
+
const sessions = sessionsRef.current;
|
|
897
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
898
|
+
if (!sessions || !sessionId) {
|
|
899
|
+
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
if (abortRef.current ||
|
|
903
|
+
state !== 'idle' ||
|
|
904
|
+
hasUnsettledTurn() ||
|
|
905
|
+
compactingRef.current ||
|
|
906
|
+
queuedRef.current.length > 0 ||
|
|
907
|
+
exportingRef.current) {
|
|
908
|
+
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.');
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
return { sessions, sessionId };
|
|
912
|
+
}, [hasUnsettledTurn, pushMessage, state]);
|
|
913
|
+
const runConversationExport = useCallback((destination) => {
|
|
914
|
+
const source = stableExportSource();
|
|
915
|
+
if (!source)
|
|
916
|
+
return;
|
|
917
|
+
exportingRef.current = true;
|
|
918
|
+
setState('thinking');
|
|
919
|
+
const generation = conversationGenRef.current;
|
|
920
|
+
void (async () => {
|
|
921
|
+
try {
|
|
922
|
+
await persistenceTailRef.current;
|
|
923
|
+
if (conversationGenRef.current !== generation ||
|
|
924
|
+
scopeRef.current?.sessionId !== source.sessionId) {
|
|
925
|
+
throw new Error('The active conversation changed before the export reached its durable boundary.');
|
|
926
|
+
}
|
|
927
|
+
const projected = await conversationMarkdown(source.sessions, source.sessionId);
|
|
928
|
+
if (destination.kind === 'file') {
|
|
929
|
+
const written = await writeConversationExport(projected.markdown, destination.path, ctx.cwd);
|
|
930
|
+
pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const result = writeClipboardText(projected.markdown, {
|
|
934
|
+
isTTY: stdout.isTTY,
|
|
935
|
+
write: writeStdout,
|
|
936
|
+
});
|
|
937
|
+
switch (result.kind) {
|
|
938
|
+
case 'request-sent':
|
|
939
|
+
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.`);
|
|
940
|
+
break;
|
|
941
|
+
case 'unavailable':
|
|
942
|
+
pushMessage('system', `Cannot send the verified export to the clipboard here — ${result.detail}. Choose Save to file instead.`);
|
|
943
|
+
break;
|
|
944
|
+
case 'too-large':
|
|
945
|
+
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.`);
|
|
946
|
+
break;
|
|
947
|
+
case 'write-failed':
|
|
948
|
+
pushMessage('system', `Could not send the verified export copy request: ${result.detail}`);
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
exportingRef.current = false;
|
|
957
|
+
if (conversationGenRef.current === generation)
|
|
958
|
+
setState('idle');
|
|
959
|
+
}
|
|
960
|
+
})();
|
|
961
|
+
}, [ctx.cwd, pushMessage, stableExportSource, stdout.isTTY, writeStdout]);
|
|
653
962
|
const applyChoiceSelection = useCallback((index) => {
|
|
654
963
|
const picker = choicePickerRef.current;
|
|
655
964
|
if (!picker)
|
|
@@ -657,13 +966,211 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
657
966
|
const value = picker.values[index];
|
|
658
967
|
if (index < 0 || index >= picker.values.length)
|
|
659
968
|
return;
|
|
969
|
+
if (picker.kind === 'review-preset') {
|
|
970
|
+
if (reviewChoiceInFlightRef.current)
|
|
971
|
+
return;
|
|
972
|
+
if (value === 'custom') {
|
|
973
|
+
setChoicePicker(null);
|
|
974
|
+
composerDraftTokenRef.current += 1;
|
|
975
|
+
setComposerDraft({
|
|
976
|
+
token: composerDraftTokenRef.current,
|
|
977
|
+
text: '/review ',
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const operation = {};
|
|
982
|
+
reviewChoiceInFlightRef.current = operation;
|
|
983
|
+
void (async () => {
|
|
984
|
+
try {
|
|
985
|
+
if (value === 'base-branch') {
|
|
986
|
+
const listing = await listReviewBranches(ctx.cwd);
|
|
987
|
+
if (appLifetime.signal.aborted ||
|
|
988
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
989
|
+
choicePickerRef.current !== picker)
|
|
990
|
+
return;
|
|
991
|
+
if (!listing || listing.branches.length === 0) {
|
|
992
|
+
setChoicePicker(null);
|
|
993
|
+
pushMessage('system', 'No local branch is available for comparison in this working directory.');
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
setSelectedChoice(0);
|
|
997
|
+
setChoicePicker({
|
|
998
|
+
kind: 'review-branch',
|
|
999
|
+
title: 'Select a base branch',
|
|
1000
|
+
notice: `Current branch: ${listing.current}`,
|
|
1001
|
+
values: listing.branches,
|
|
1002
|
+
options: listing.branches.map((branch) => ({
|
|
1003
|
+
label: branch,
|
|
1004
|
+
description: `${listing.current} → ${branch}`,
|
|
1005
|
+
})),
|
|
1006
|
+
});
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (value === 'commit') {
|
|
1010
|
+
const commits = await listReviewCommits(ctx.cwd);
|
|
1011
|
+
if (appLifetime.signal.aborted ||
|
|
1012
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1013
|
+
choicePickerRef.current !== picker)
|
|
1014
|
+
return;
|
|
1015
|
+
if (!commits || commits.length === 0) {
|
|
1016
|
+
setChoicePicker(null);
|
|
1017
|
+
pushMessage('system', 'No commit is available to review in this working directory.');
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
setSelectedChoice(0);
|
|
1021
|
+
setChoicePicker({
|
|
1022
|
+
kind: 'review-commit',
|
|
1023
|
+
title: 'Select a commit to review',
|
|
1024
|
+
values: commits,
|
|
1025
|
+
options: commits.map((commit) => ({
|
|
1026
|
+
label: commit.title,
|
|
1027
|
+
description: commit.sha.slice(0, 12),
|
|
1028
|
+
})),
|
|
1029
|
+
});
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const diff = await workspaceDiff(ctx.cwd);
|
|
1033
|
+
if (appLifetime.signal.aborted ||
|
|
1034
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1035
|
+
choicePickerRef.current !== picker)
|
|
1036
|
+
return;
|
|
1037
|
+
if (diff === null) {
|
|
1038
|
+
setChoicePicker(null);
|
|
1039
|
+
pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (diff.stat.length === 0 && diff.untracked.length === 0) {
|
|
1043
|
+
setChoicePicker(null);
|
|
1044
|
+
pushMessage('system', 'Nothing to review — the working tree is clean.');
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
advanceQueueContinuation();
|
|
1048
|
+
enqueueQueued({
|
|
1049
|
+
kind: 'human',
|
|
1050
|
+
text: reviewPrompt(diff.stat, diff.untracked),
|
|
1051
|
+
});
|
|
1052
|
+
setChoicePicker(null);
|
|
1053
|
+
}
|
|
1054
|
+
finally {
|
|
1055
|
+
if (reviewChoiceInFlightRef.current === operation) {
|
|
1056
|
+
reviewChoiceInFlightRef.current = null;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
})();
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (picker.kind === 'review-branch') {
|
|
1063
|
+
if (reviewChoiceInFlightRef.current)
|
|
1064
|
+
return;
|
|
1065
|
+
const branch = value;
|
|
1066
|
+
const operation = {};
|
|
1067
|
+
reviewChoiceInFlightRef.current = operation;
|
|
1068
|
+
void (async () => {
|
|
1069
|
+
try {
|
|
1070
|
+
const mergeBase = await reviewMergeBase(ctx.cwd, branch);
|
|
1071
|
+
if (appLifetime.signal.aborted ||
|
|
1072
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1073
|
+
choicePickerRef.current !== picker)
|
|
1074
|
+
return;
|
|
1075
|
+
if (!mergeBase) {
|
|
1076
|
+
setChoicePicker(null);
|
|
1077
|
+
pushMessage('system', `Could not resolve a merge base for ${branch}.`);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
advanceQueueContinuation();
|
|
1081
|
+
enqueueQueued({
|
|
1082
|
+
kind: 'human',
|
|
1083
|
+
text: baseBranchReviewPrompt(mergeBase),
|
|
1084
|
+
});
|
|
1085
|
+
setChoicePicker(null);
|
|
1086
|
+
}
|
|
1087
|
+
finally {
|
|
1088
|
+
if (reviewChoiceInFlightRef.current === operation) {
|
|
1089
|
+
reviewChoiceInFlightRef.current = null;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
})();
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (picker.kind === 'review-commit') {
|
|
1096
|
+
advanceQueueContinuation();
|
|
1097
|
+
enqueueQueued({
|
|
1098
|
+
kind: 'human',
|
|
1099
|
+
text: commitReviewPrompt(value.sha),
|
|
1100
|
+
});
|
|
1101
|
+
setChoicePicker(null);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
660
1104
|
setChoicePicker(null);
|
|
1105
|
+
if (picker.kind === 'command') {
|
|
1106
|
+
const command = value;
|
|
1107
|
+
if (command.problem) {
|
|
1108
|
+
pushMessage('system', `Cannot run /${command.name}: ${command.problem}`);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
commandPickerSubmitRef.current(`/${command.name}`);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (picker.kind === 'archive-conversation') {
|
|
1115
|
+
if (value === 'archive')
|
|
1116
|
+
archiveCurrentConversation();
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (picker.kind === 'export-destination') {
|
|
1120
|
+
if (value === 'clipboard') {
|
|
1121
|
+
runConversationExport({ kind: 'clipboard' });
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
1125
|
+
if (!sessionId) {
|
|
1126
|
+
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
textPromptTokenRef.current += 1;
|
|
1130
|
+
setTextPrompt({
|
|
1131
|
+
token: textPromptTokenRef.current,
|
|
1132
|
+
kind: 'export-file',
|
|
1133
|
+
title: 'Save conversation',
|
|
1134
|
+
placeholder: 'Type a Markdown filename and press Enter',
|
|
1135
|
+
emptyNotice: 'A filename is required. Press Esc to cancel.',
|
|
1136
|
+
initialValue: `namzu-conversation-${sessionId}.md`,
|
|
1137
|
+
sessionId,
|
|
1138
|
+
});
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
661
1141
|
if (picker.kind === 'permission-mode') {
|
|
662
1142
|
applyPermissionMode(value);
|
|
663
1143
|
return;
|
|
664
1144
|
}
|
|
1145
|
+
if (picker.kind === 'feedback-rating') {
|
|
1146
|
+
recordFeedback(picker.runId, picker.messageId, value);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (picker.kind === 'skill') {
|
|
1150
|
+
activateSkill(value);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (picker.kind === 'credential-logout') {
|
|
1154
|
+
removeStoredCredential(value);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
665
1157
|
applyReasoningEffort(value);
|
|
666
|
-
}, [
|
|
1158
|
+
}, [
|
|
1159
|
+
activateSkill,
|
|
1160
|
+
advanceQueueContinuation,
|
|
1161
|
+
appLifetime.signal,
|
|
1162
|
+
applyPermissionMode,
|
|
1163
|
+
applyReasoningEffort,
|
|
1164
|
+
archiveCurrentConversation,
|
|
1165
|
+
ctx.cwd,
|
|
1166
|
+
enqueueQueued,
|
|
1167
|
+
pushMessage,
|
|
1168
|
+
recordFeedback,
|
|
1169
|
+
removeStoredCredential,
|
|
1170
|
+
runConversationExport,
|
|
1171
|
+
setChoicePicker,
|
|
1172
|
+
setTextPrompt,
|
|
1173
|
+
]);
|
|
667
1174
|
const sendCopyRequest = useCallback((index) => {
|
|
668
1175
|
const picker = copyPickerRef.current;
|
|
669
1176
|
const target = picker?.targets[index];
|
|
@@ -734,14 +1241,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
734
1241
|
const finalizeMessage = useCallback((id, finalContent) => {
|
|
735
1242
|
setMessages((prev) => prev.map((m) => m.id === id ? { ...m, content: finalContent ?? m.content, pending: false } : m));
|
|
736
1243
|
}, []);
|
|
737
|
-
// Open the SDK session store
|
|
738
|
-
//
|
|
1244
|
+
// Open the SDK session store and select the durable conversation once.
|
|
1245
|
+
// Ordinary startup remains best-effort; an explicit `namzu resume <id>` is
|
|
1246
|
+
// exact and therefore refuses instead of silently widening to a fresh chat.
|
|
739
1247
|
const ensureSessions = useCallback(async () => {
|
|
740
1248
|
if (scopeRef.current)
|
|
741
1249
|
return scopeRef.current;
|
|
1250
|
+
const requestedConversationId = initialConversationIdRef.current;
|
|
742
1251
|
try {
|
|
743
1252
|
const sessions = await openSessions(ctxRef.current.cwd);
|
|
744
|
-
|
|
1253
|
+
let sessionId;
|
|
1254
|
+
if (requestedConversationId) {
|
|
1255
|
+
const restored = await loadResumableConversation(sessions, requestedConversationId);
|
|
1256
|
+
sessionId = asSessionId(requestedConversationId);
|
|
1257
|
+
modelHistoryRef.current = restored;
|
|
1258
|
+
setMessages(projectConversation(restored, nextId));
|
|
1259
|
+
setHistory(promptHistoryFromConversation(restored));
|
|
1260
|
+
const persistedOutput = latestAssistantOutput(restored);
|
|
1261
|
+
lastCompletedOutputRef.current = persistedOutput
|
|
1262
|
+
? { text: persistedOutput, provenance: 'persisted' }
|
|
1263
|
+
: null;
|
|
1264
|
+
initialConversationIdRef.current = undefined;
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
sessionId = await startConversation(sessions);
|
|
1268
|
+
}
|
|
745
1269
|
sessionsRef.current = sessions;
|
|
746
1270
|
scopeRef.current = {
|
|
747
1271
|
sessionId,
|
|
@@ -751,10 +1275,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
751
1275
|
};
|
|
752
1276
|
return scopeRef.current;
|
|
753
1277
|
}
|
|
754
|
-
catch {
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
if (requestedConversationId)
|
|
1280
|
+
throw error;
|
|
755
1281
|
return undefined;
|
|
756
1282
|
}
|
|
757
|
-
}, []);
|
|
1283
|
+
}, [nextId]);
|
|
758
1284
|
const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
|
|
759
1285
|
if (signal?.aborted)
|
|
760
1286
|
return;
|
|
@@ -765,6 +1291,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
765
1291
|
const s = await createAgentSession(prefs, detectedNow, {
|
|
766
1292
|
scope,
|
|
767
1293
|
cwd: activeCtx.cwd,
|
|
1294
|
+
enableComputerUse: true,
|
|
768
1295
|
rules: activeCtx.rules,
|
|
769
1296
|
...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
|
|
770
1297
|
...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
|
|
@@ -805,6 +1332,13 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
805
1332
|
// the file is shadowed instead of leaving it silently unused.
|
|
806
1333
|
reserved: hostCommandNames(),
|
|
807
1334
|
}));
|
|
1335
|
+
const mentionLoadOwner = {};
|
|
1336
|
+
mentionLoadOwnerRef.current = mentionLoadOwner;
|
|
1337
|
+
void listMentionableFiles(activeCtx.cwd, appLifetime.signal).then((files) => {
|
|
1338
|
+
if (!appLifetime.signal.aborted &&
|
|
1339
|
+
mentionLoadOwnerRef.current === mentionLoadOwner)
|
|
1340
|
+
setMentionCandidates(files);
|
|
1341
|
+
});
|
|
808
1342
|
setCurrentProvider(primaryProvider(prefs).id);
|
|
809
1343
|
// A picker-owned, usable provider switch is an explicit recovery from a
|
|
810
1344
|
// paused failed turn. Failed and superseded candidates return above and
|
|
@@ -852,7 +1386,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
852
1386
|
if (s.errorHint)
|
|
853
1387
|
pushMessage('system', s.errorHint);
|
|
854
1388
|
}
|
|
855
|
-
}, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
1389
|
+
}, [appLifetime.signal, advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
856
1390
|
/**
|
|
857
1391
|
* Sign in to a subscription without leaving namzu.
|
|
858
1392
|
*
|
|
@@ -1015,6 +1549,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1015
1549
|
try {
|
|
1016
1550
|
if (signal?.aborted)
|
|
1017
1551
|
return;
|
|
1552
|
+
// An exact shell resume owns conversation admission before provider
|
|
1553
|
+
// discovery or construction. A malformed/missing id must not consume
|
|
1554
|
+
// model work and then quietly continue in a fresh conversation.
|
|
1555
|
+
if (initialConversationIdRef.current) {
|
|
1556
|
+
try {
|
|
1557
|
+
await ensureSessions();
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
setPhase('unhealthy');
|
|
1561
|
+
pushMessage('system', `Could not resume ${initialConversationIdRef.current}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1018
1565
|
const probe = await probeAgentSession();
|
|
1019
1566
|
if (signal?.aborted)
|
|
1020
1567
|
return;
|
|
@@ -1102,7 +1649,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1102
1649
|
setPhase('unhealthy');
|
|
1103
1650
|
pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
|
|
1104
1651
|
}
|
|
1105
|
-
}, [appLifetime.signal, hydrateSession, pushMessage]);
|
|
1652
|
+
}, [appLifetime.signal, ensureSessions, hydrateSession, pushMessage]);
|
|
1106
1653
|
// `startOrFinishLogin` is declared above `runProbe` and calls it, so it
|
|
1107
1654
|
// reads the current one through a ref rather than closing over a stale
|
|
1108
1655
|
// binding or forcing the two into a declaration order that reads backwards.
|
|
@@ -1146,51 +1693,22 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1146
1693
|
}
|
|
1147
1694
|
}, [activateTrustedProject, pushMessage, runProbe]);
|
|
1148
1695
|
const finalized = messages.filter((m) => !m.pending);
|
|
1149
|
-
// How much of the transcript is still redrawable.
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
// the window are part of the live region it is padding above. Two
|
|
1153
|
-
// computations of one split would drift, and the direction they would drift
|
|
1154
|
-
// in is a composer pushed off the screen.
|
|
1696
|
+
// How much of the transcript is still redrawable. The rest belongs to native
|
|
1697
|
+
// terminal scrollback; the live tail stays deliberately small so an activity
|
|
1698
|
+
// tick never repaints the whole conversation.
|
|
1155
1699
|
//
|
|
1156
1700
|
// A ref, and mutated during render, because the split has to be MONOTONIC:
|
|
1157
1701
|
// a row that has been printed to scrollback can never come back, and `max`
|
|
1158
1702
|
// is idempotent, so a repeated render reaches the same answer.
|
|
1159
1703
|
const window = liveWindow({
|
|
1160
1704
|
messages: finalized,
|
|
1161
|
-
rows:
|
|
1162
|
-
columns:
|
|
1705
|
+
rows: terminal.rows,
|
|
1706
|
+
columns: terminal.columns,
|
|
1163
1707
|
furnitureRows: LIVE_FURNITURE_ROWS,
|
|
1164
1708
|
settled: settledRef.current,
|
|
1165
1709
|
raw: rawOutput,
|
|
1166
1710
|
});
|
|
1167
1711
|
settledRef.current = window.settled;
|
|
1168
|
-
// Blank rows above the composer, while the transcript is short enough that
|
|
1169
|
-
// the answer is knowable. `liveRows` is the furniture beneath the transcript
|
|
1170
|
-
// PLUS the live window above it — the window is part of the live region, and
|
|
1171
|
-
// leaving it out would have the spacer padding room that is already taken.
|
|
1172
|
-
const spacerCandidate = phase === 'ready'
|
|
1173
|
-
? bottomSpacerRows({
|
|
1174
|
-
rows: stdout.rows,
|
|
1175
|
-
columns: stdout.columns,
|
|
1176
|
-
transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
|
|
1177
|
-
liveRows: LIVE_FURNITURE_ROWS + window.rows,
|
|
1178
|
-
})
|
|
1179
|
-
: 0;
|
|
1180
|
-
const spacerLayout = {
|
|
1181
|
-
rows: stdout.rows,
|
|
1182
|
-
columns: stdout.columns,
|
|
1183
|
-
raw: rawOutput,
|
|
1184
|
-
messageCount: finalized.length,
|
|
1185
|
-
tail: finalized
|
|
1186
|
-
.slice(-MAX_LIVE_ROWS)
|
|
1187
|
-
.map(({ id, content, meta, detail }) => ({ id, content, meta, detail })),
|
|
1188
|
-
};
|
|
1189
|
-
const spacerRows = phase === 'ready' && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
|
|
1190
|
-
? spacerLayoutRef.current.spacerRows
|
|
1191
|
-
: spacerCandidate;
|
|
1192
|
-
spacerLayoutRef.current =
|
|
1193
|
-
phase === 'ready' ? { ...spacerLayout, spacerRows } : null;
|
|
1194
1712
|
// One merged vocabulary for the session: this host's own commands plus
|
|
1195
1713
|
// whatever the kernel's registry reports. Built here so `/help`, the
|
|
1196
1714
|
// autocomplete and the dispatcher all answer from the same list — three
|
|
@@ -1198,6 +1716,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1198
1716
|
// disagree the moment a capability added a command.
|
|
1199
1717
|
const hostCommands = mergeHostCommands(kernelCommandDescriptors());
|
|
1200
1718
|
const slashCtx = {
|
|
1719
|
+
cwd: ctx.cwd,
|
|
1201
1720
|
builtins: hostCommands,
|
|
1202
1721
|
lastAssistantMessageId: () => lastAssistantMessage.current?.messageId ?? null,
|
|
1203
1722
|
// Called when `/tools` renders, not read here — the same shape, and the
|
|
@@ -1206,7 +1725,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1206
1725
|
// From the session, not re-resolved: resolving builds a provider, and a
|
|
1207
1726
|
// second one would describe a different sandbox than the run is using.
|
|
1208
1727
|
sandbox: session?.sandbox ?? null,
|
|
1209
|
-
mcp:
|
|
1728
|
+
mcp: () => session
|
|
1729
|
+
? (session.mcpStatus?.() ?? {
|
|
1730
|
+
connected: session.mcpConnected,
|
|
1731
|
+
failed: session.mcpFailed,
|
|
1732
|
+
})
|
|
1733
|
+
: null,
|
|
1210
1734
|
providerSummary: session?.providerSummary ?? null,
|
|
1211
1735
|
modelSummary: session?.modelSummary ?? null,
|
|
1212
1736
|
reasoningEffort: {
|
|
@@ -1269,10 +1793,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1269
1793
|
const resolve = permissionResolveRef.current;
|
|
1270
1794
|
permissionResolveRef.current = null;
|
|
1271
1795
|
permissionOpenedAtRef.current = null;
|
|
1796
|
+
setPermissionReviewOffset(0);
|
|
1272
1797
|
setPermission(null);
|
|
1273
1798
|
if (resolve)
|
|
1274
1799
|
resolve(decision);
|
|
1275
|
-
}, []);
|
|
1800
|
+
}, [setPermissionReviewOffset]);
|
|
1276
1801
|
/**
|
|
1277
1802
|
* Stop the running turn, and hand the screen back in a usable state.
|
|
1278
1803
|
*
|
|
@@ -1297,7 +1822,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1297
1822
|
const ac = abortRef.current;
|
|
1298
1823
|
if (!ac)
|
|
1299
1824
|
return false;
|
|
1300
|
-
ac.abort();
|
|
1825
|
+
ac.abort(new RunCancelled('user'));
|
|
1301
1826
|
const activeSessionId = scopeRef.current?.sessionId;
|
|
1302
1827
|
if (activeSessionId)
|
|
1303
1828
|
goalActivation.disarm(activeSessionId);
|
|
@@ -1390,6 +1915,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1390
1915
|
resetTranscript();
|
|
1391
1916
|
setMessages(restored);
|
|
1392
1917
|
modelHistoryRef.current = msgs;
|
|
1918
|
+
setHistory((previous) => [...previous, ...promptHistoryFromConversation(msgs)]);
|
|
1393
1919
|
const persistedOutput = latestAssistantOutput(msgs);
|
|
1394
1920
|
lastCompletedOutputRef.current = persistedOutput
|
|
1395
1921
|
? { text: persistedOutput, provenance: 'persisted' }
|
|
@@ -1486,11 +2012,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1486
2012
|
}
|
|
1487
2013
|
}, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
|
|
1488
2014
|
/**
|
|
1489
|
-
* `/title
|
|
2015
|
+
* `/rename` (and the older `/title` alias): edit or set the name this
|
|
2016
|
+
* conversation appears under.
|
|
1490
2017
|
*
|
|
1491
|
-
* A bare
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
2018
|
+
* A bare command opens a prefilled host prompt rather than printing usage or
|
|
2019
|
+
* clearing. The result never goes through the model composer, queue, or prompt
|
|
2020
|
+
* history; it is a session-store operation with the session id captured here.
|
|
1494
2021
|
*/
|
|
1495
2022
|
const doTitle = useCallback(async (title, clear) => {
|
|
1496
2023
|
const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
|
|
@@ -1502,9 +2029,16 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1502
2029
|
try {
|
|
1503
2030
|
if (!clear && title === '') {
|
|
1504
2031
|
const current = titleOf(sessions, scope.sessionId);
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
:
|
|
2032
|
+
textPromptTokenRef.current += 1;
|
|
2033
|
+
setTextPrompt({
|
|
2034
|
+
token: textPromptTokenRef.current,
|
|
2035
|
+
kind: 'conversation-title',
|
|
2036
|
+
title: current === undefined ? 'Name conversation' : 'Rename conversation',
|
|
2037
|
+
placeholder: 'Type a name and press Enter',
|
|
2038
|
+
emptyNotice: 'A conversation name cannot be empty. Press Esc to cancel.',
|
|
2039
|
+
initialValue: current ?? '',
|
|
2040
|
+
sessionId: scope.sessionId,
|
|
2041
|
+
});
|
|
1508
2042
|
return;
|
|
1509
2043
|
}
|
|
1510
2044
|
setTitle(sessions, scope.sessionId, clear ? '' : title);
|
|
@@ -1515,7 +2049,46 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1515
2049
|
catch (err) {
|
|
1516
2050
|
pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
|
|
1517
2051
|
}
|
|
1518
|
-
}, [ensureSessions, pushMessage]);
|
|
2052
|
+
}, [ensureSessions, pushMessage, setTextPrompt]);
|
|
2053
|
+
const submitTextPrompt = useCallback((value) => {
|
|
2054
|
+
const prompt = textPromptRef.current;
|
|
2055
|
+
if (!prompt)
|
|
2056
|
+
return;
|
|
2057
|
+
setTextPrompt(null);
|
|
2058
|
+
const scope = scopeRef.current;
|
|
2059
|
+
if (!scope || scope.sessionId !== prompt.sessionId) {
|
|
2060
|
+
pushMessage('system', prompt.kind === 'conversation-title'
|
|
2061
|
+
? 'The conversation changed while its name editor was open. Nothing was renamed; open /rename again.'
|
|
2062
|
+
: 'The conversation changed while its export filename editor was open. Nothing was exported; open /export again.');
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (prompt.kind === 'export-file') {
|
|
2066
|
+
runConversationExport({ kind: 'file', path: value });
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
const sessions = sessionsRef.current;
|
|
2070
|
+
if (!sessions) {
|
|
2071
|
+
pushMessage('system', 'Conversation history is unavailable in this folder.');
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
try {
|
|
2075
|
+
setTitle(sessions, prompt.sessionId, value);
|
|
2076
|
+
pushMessage('system', `Named "${value.trim()}".`);
|
|
2077
|
+
}
|
|
2078
|
+
catch (error) {
|
|
2079
|
+
pushMessage('system', `Could not save the name: ${error instanceof Error ? error.message : String(error)}`);
|
|
2080
|
+
}
|
|
2081
|
+
}, [pushMessage, runConversationExport, setTextPrompt]);
|
|
2082
|
+
const cancelTextPrompt = useCallback(() => {
|
|
2083
|
+
const prompt = textPromptRef.current;
|
|
2084
|
+
// TextPrompt and App both receive the same Ink input event. Keep the
|
|
2085
|
+
// synchronous owner until every listener has seen this Esc/Ctrl+C, or App
|
|
2086
|
+
// can read the same key as an interrupt/exit after the child clears the ref.
|
|
2087
|
+
queueMicrotask(() => {
|
|
2088
|
+
if (textPromptRef.current === prompt)
|
|
2089
|
+
setTextPrompt(null);
|
|
2090
|
+
});
|
|
2091
|
+
}, [setTextPrompt]);
|
|
1519
2092
|
/**
|
|
1520
2093
|
* `/fork`: continue in a copy, leaving this conversation where it is.
|
|
1521
2094
|
*
|
|
@@ -1671,13 +2244,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1671
2244
|
]);
|
|
1672
2245
|
// Bridge passed into session.send(): the agent calls this before a
|
|
1673
2246
|
// non-read-only tool batch; it parks until the user presses y/n/a.
|
|
1674
|
-
const onPermission = useCallback((req) =>
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
2247
|
+
const onPermission = useCallback((req) => {
|
|
2248
|
+
const review = buildPermissionReview(req.toolCalls);
|
|
2249
|
+
if (!review.ok) {
|
|
2250
|
+
return Promise.resolve({
|
|
2251
|
+
kind: 'reject',
|
|
2252
|
+
feedback: permissionReviewRefusal(review.reason),
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
return new Promise((resolve) => {
|
|
2256
|
+
permissionResolveRef.current = resolve;
|
|
2257
|
+
permissionOpenedAtRef.current = Date.now();
|
|
2258
|
+
setPermissionReviewOffset(0);
|
|
2259
|
+
setPermission({ ...req, review: review.text });
|
|
2260
|
+
setState('awaiting-permission');
|
|
2261
|
+
sendTerminalNotification({ kind: 'approval-required' });
|
|
2262
|
+
});
|
|
2263
|
+
}, [sendTerminalNotification, setPermissionReviewOffset]);
|
|
1681
2264
|
// Render one agent event onto the transcript. Shared by the local turn loop
|
|
1682
2265
|
// and the daemon-attach poller, so both paths produce identical output.
|
|
1683
2266
|
// `st` carries the streaming-assistant bubble id + accumulated text across
|
|
@@ -1726,7 +2309,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1726
2309
|
const tool = {
|
|
1727
2310
|
id: event.toolUseId,
|
|
1728
2311
|
toolName: event.toolName,
|
|
1729
|
-
label: formatToolCall(event.toolName, event.summary),
|
|
2312
|
+
label: formatToolCall(event.toolName, event.summary, event.standalone),
|
|
1730
2313
|
startedAt: Date.now(),
|
|
1731
2314
|
detail: event.detail,
|
|
1732
2315
|
};
|
|
@@ -1769,7 +2352,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1769
2352
|
setActiveTools(activeToolsRef.current);
|
|
1770
2353
|
}
|
|
1771
2354
|
pushMessage('tool', done?.label ?? formatToolCall(event.toolName, event.summary), false, event.isError ? '✗' : '✓', done?.detail, event.isError ? theme.status.error : theme.status.ok, done ? formatElapsed(Date.now() - done.startedAt) : undefined);
|
|
1772
|
-
if (event.isError ||
|
|
2355
|
+
if (event.isError ||
|
|
2356
|
+
(!event.hidden && (event.summary.length > 0 || (event.detail?.length ?? 0) > 0))) {
|
|
1773
2357
|
pushMessage('tool', event.isError ? `failed: ${event.summary}` : event.summary, false, '⎿', event.detail);
|
|
1774
2358
|
}
|
|
1775
2359
|
setState(activeToolsRef.current.length > 0 ? 'tool' : 'thinking');
|
|
@@ -1812,7 +2396,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1812
2396
|
pushMessage('system', event.text, false, '⇄');
|
|
1813
2397
|
break;
|
|
1814
2398
|
case 'capability-warning':
|
|
1815
|
-
pushMessage('system', `Capability warning (${event.capability}): ${event.text}`, false, '⚠');
|
|
2399
|
+
pushMessage('system', `Capability warning (${event.capability}${event.contentSource === 'tool-result' ? ' tool result' : ''}): ${event.text}`, false, '⚠');
|
|
1816
2400
|
break;
|
|
1817
2401
|
case 'history-repair':
|
|
1818
2402
|
pushMessage('system', `History warning (${event.source}): ${event.text}`, false, '⚠');
|
|
@@ -1831,12 +2415,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1831
2415
|
};
|
|
1832
2416
|
closeAssistant();
|
|
1833
2417
|
break;
|
|
2418
|
+
case 'paused':
|
|
2419
|
+
// Paused is a recoverable SDK verdict, not success and not failure.
|
|
2420
|
+
// Conversation evidence has no paused member, so it records the
|
|
2421
|
+
// truthful coarse outcome (`stopped`) while queue copy retains the
|
|
2422
|
+
// distinction the operator acts on.
|
|
2423
|
+
closeAssistant();
|
|
2424
|
+
st.outcome = 'stopped';
|
|
2425
|
+
st.queuePauseOutcome = 'paused';
|
|
2426
|
+
st.notification = { kind: 'turn-settled', outcome: 'stopped' };
|
|
2427
|
+
pushMessage('system', describeRunInterruption(event), false, '⏸');
|
|
2428
|
+
break;
|
|
1834
2429
|
case 'error':
|
|
1835
2430
|
closeAssistant();
|
|
1836
2431
|
st.outcome = event.message === 'aborted' ? 'cancelled' : 'failed';
|
|
1837
2432
|
if (event.message !== 'aborted') {
|
|
1838
2433
|
st.notification = { kind: 'turn-settled', outcome: 'failed' };
|
|
1839
|
-
pushMessage('system',
|
|
2434
|
+
pushMessage('system', describeRunInterruption(event), false, '⚠');
|
|
1840
2435
|
}
|
|
1841
2436
|
else
|
|
1842
2437
|
st.notification = null;
|
|
@@ -1933,7 +2528,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1933
2528
|
abnormalTerminal = {
|
|
1934
2529
|
token: turnToken,
|
|
1935
2530
|
continuationEpoch: queueContinuationEpochRef.current,
|
|
1936
|
-
outcome: st.outcome,
|
|
2531
|
+
outcome: st.queuePauseOutcome ?? st.outcome,
|
|
1937
2532
|
};
|
|
1938
2533
|
};
|
|
1939
2534
|
abortRef.current = ac;
|
|
@@ -2268,7 +2863,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2268
2863
|
? 'forked'
|
|
2269
2864
|
: conversationMutationRef.current === 'edit'
|
|
2270
2865
|
? 'branched for prompt editing'
|
|
2271
|
-
:
|
|
2866
|
+
: conversationMutationRef.current === 'archive'
|
|
2867
|
+
? 'archived'
|
|
2868
|
+
: 'moved to a fresh conversation';
|
|
2272
2869
|
pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
|
|
2273
2870
|
return;
|
|
2274
2871
|
}
|
|
@@ -2284,13 +2881,74 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2284
2881
|
case 'message':
|
|
2285
2882
|
pushMessage(slash.role, slash.content);
|
|
2286
2883
|
return;
|
|
2884
|
+
case 'command-picker': {
|
|
2885
|
+
if (slash.commands.length === 0) {
|
|
2886
|
+
pushMessage('system', 'No slash commands are available in this session.');
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
setSelectedChoice(0);
|
|
2890
|
+
setChoicePicker({
|
|
2891
|
+
kind: 'command',
|
|
2892
|
+
title: 'Choose a command',
|
|
2893
|
+
notice: 'Enter runs the selected command; Esc returns to the composer.',
|
|
2894
|
+
values: slash.commands,
|
|
2895
|
+
options: slash.commands.map((command) => ({
|
|
2896
|
+
label: `/${command.name}`,
|
|
2897
|
+
description: command.problem
|
|
2898
|
+
? `Unavailable: ${command.problem}`
|
|
2899
|
+
: command.description,
|
|
2900
|
+
})),
|
|
2901
|
+
windowSize: suggestionWindowSize(terminal.rows),
|
|
2902
|
+
});
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2287
2905
|
case 'clear-screen':
|
|
2288
2906
|
setMessages([]);
|
|
2289
2907
|
resetTranscript();
|
|
2290
2908
|
return;
|
|
2909
|
+
case 'composer-draft':
|
|
2910
|
+
composerDraftTokenRef.current += 1;
|
|
2911
|
+
setComposerDraft({
|
|
2912
|
+
token: composerDraftTokenRef.current,
|
|
2913
|
+
text: slash.text,
|
|
2914
|
+
});
|
|
2915
|
+
return;
|
|
2291
2916
|
case 'new-conversation':
|
|
2292
2917
|
void startFreshConversation(slash.clearScreen);
|
|
2293
2918
|
return;
|
|
2919
|
+
case 'archive-picker': {
|
|
2920
|
+
if (!sessionsRef.current || !scopeRef.current?.sessionId) {
|
|
2921
|
+
pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
if (state !== 'idle' ||
|
|
2925
|
+
abortRef.current !== null ||
|
|
2926
|
+
hasUnsettledTurn() ||
|
|
2927
|
+
queuedRef.current.length > 0 ||
|
|
2928
|
+
compactingRef.current ||
|
|
2929
|
+
exportingRef.current) {
|
|
2930
|
+
pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
setSelectedChoice(0);
|
|
2934
|
+
setChoicePicker({
|
|
2935
|
+
kind: 'archive-conversation',
|
|
2936
|
+
title: 'Archive this conversation?',
|
|
2937
|
+
notice: 'History remains on disk for inspection, but the conversation becomes read-only and disappears from /resume.',
|
|
2938
|
+
values: ['cancel', 'archive'],
|
|
2939
|
+
options: [
|
|
2940
|
+
{
|
|
2941
|
+
label: 'No, keep conversation',
|
|
2942
|
+
description: 'Return without changing durable history',
|
|
2943
|
+
},
|
|
2944
|
+
{
|
|
2945
|
+
label: 'Yes, archive and exit',
|
|
2946
|
+
description: 'Make this conversation read-only and leave Namzu',
|
|
2947
|
+
},
|
|
2948
|
+
],
|
|
2949
|
+
});
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2294
2952
|
case 'exit':
|
|
2295
2953
|
exitWithSummary();
|
|
2296
2954
|
return;
|
|
@@ -2377,16 +3035,35 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2377
3035
|
void startOrFinishLogin(slash.pasted);
|
|
2378
3036
|
return;
|
|
2379
3037
|
case 'logout': {
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
clearAllStoredCredentials();
|
|
3038
|
+
if (slash.target) {
|
|
3039
|
+
removeStoredCredential(slash.target);
|
|
3040
|
+
return;
|
|
2384
3041
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
3042
|
+
const choices = [
|
|
3043
|
+
...(readStoredSubscriptionCredential() ? ['anthropic'] : []),
|
|
3044
|
+
...(readStoredCodexCredential() ? ['codex'] : []),
|
|
3045
|
+
];
|
|
3046
|
+
if (choices.length === 0) {
|
|
3047
|
+
removeStoredCredential('all');
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
if (choices.length === 1) {
|
|
3051
|
+
removeStoredCredential(choices[0]);
|
|
2387
3052
|
return;
|
|
2388
3053
|
}
|
|
2389
|
-
|
|
3054
|
+
setSelectedChoice(0);
|
|
3055
|
+
setChoicePicker({
|
|
3056
|
+
kind: 'credential-logout',
|
|
3057
|
+
title: 'Choose a stored subscription to remove',
|
|
3058
|
+
notice: 'Only credentials created by Namzu are listed. Device sessions owned by other tools are left alone.',
|
|
3059
|
+
values: choices,
|
|
3060
|
+
options: choices.map((choice) => ({
|
|
3061
|
+
label: choice === 'anthropic' ? 'Claude' : 'Codex',
|
|
3062
|
+
description: choice === 'anthropic'
|
|
3063
|
+
? 'Remove only Namzu’s Claude subscription.'
|
|
3064
|
+
: 'Remove only Namzu’s Codex subscription.',
|
|
3065
|
+
})),
|
|
3066
|
+
});
|
|
2390
3067
|
return;
|
|
2391
3068
|
}
|
|
2392
3069
|
case 'remember':
|
|
@@ -2424,23 +3101,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2424
3101
|
pushMessage('system', `Skills (● active):\n ${lines.join('\n ')}`);
|
|
2425
3102
|
return;
|
|
2426
3103
|
}
|
|
2427
|
-
case '
|
|
2428
|
-
const
|
|
2429
|
-
if (
|
|
2430
|
-
pushMessage('system',
|
|
3104
|
+
case 'skill-picker': {
|
|
3105
|
+
const skills = discoverSkills({ cwd: ctx.cwd });
|
|
3106
|
+
if (skills.length === 0) {
|
|
3107
|
+
pushMessage('system', 'No skills found. Add one at ~/.namzu/skills/<name>/SKILL.md or ./skills/<name>/SKILL.md.');
|
|
2431
3108
|
return;
|
|
2432
3109
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
3110
|
+
const activeNames = new Set(activeSkills.map((skill) => skill.name));
|
|
3111
|
+
setSelectedChoice(0);
|
|
3112
|
+
setChoicePicker({
|
|
3113
|
+
kind: 'skill',
|
|
3114
|
+
title: 'Activate a skill',
|
|
3115
|
+
values: skills.map((skill) => skill.name),
|
|
3116
|
+
options: skills.map((skill) => ({
|
|
3117
|
+
label: skill.name,
|
|
3118
|
+
description: skill.problem
|
|
3119
|
+
? `Unavailable: ${skill.problem}`
|
|
3120
|
+
: `${skill.description} · ${skill.source}`,
|
|
3121
|
+
current: activeNames.has(skill.name),
|
|
3122
|
+
})),
|
|
3123
|
+
});
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
case 'load-skill': {
|
|
3127
|
+
activateSkill(slash.name);
|
|
2444
3128
|
return;
|
|
2445
3129
|
}
|
|
2446
3130
|
case 'resume':
|
|
@@ -2586,67 +3270,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2586
3270
|
})();
|
|
2587
3271
|
return;
|
|
2588
3272
|
}
|
|
2589
|
-
case 'feedback': {
|
|
3273
|
+
case 'feedback-picker': {
|
|
2590
3274
|
const target = lastAssistantMessage.current;
|
|
2591
|
-
|
|
2592
|
-
// in, so a rating and the transcript it judges are one
|
|
2593
|
-
// directory apart and travel together.
|
|
2594
|
-
if (!target) {
|
|
3275
|
+
if (!target || target.messageId !== slash.messageId) {
|
|
2595
3276
|
pushMessage('system', 'Nothing to rate yet.');
|
|
2596
3277
|
return;
|
|
2597
3278
|
}
|
|
2598
|
-
const
|
|
2599
|
-
|
|
2600
|
-
|
|
3279
|
+
const values = ['good', 'bad'];
|
|
3280
|
+
setSelectedChoice(0);
|
|
3281
|
+
setChoicePicker({
|
|
3282
|
+
kind: 'feedback-rating',
|
|
3283
|
+
title: 'Rate the latest answer',
|
|
3284
|
+
runId: target.runId,
|
|
3285
|
+
messageId: target.messageId,
|
|
3286
|
+
values,
|
|
3287
|
+
options: [
|
|
3288
|
+
{
|
|
3289
|
+
label: 'good',
|
|
3290
|
+
description: 'The answer was useful and correct.',
|
|
3291
|
+
},
|
|
3292
|
+
{ label: 'bad', description: 'The answer needs improvement.' },
|
|
3293
|
+
],
|
|
2601
3294
|
});
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
// Read-then-write rather than blind overwrite, so two
|
|
2612
|
-
// raters on one message still collide loudly.
|
|
2613
|
-
const current = (await store.listMessageFeedback({ runId })).find((r) => r.messageId === messageId);
|
|
2614
|
-
const record = await store.putMessageFeedback({
|
|
2615
|
-
runId,
|
|
2616
|
-
messageId,
|
|
2617
|
-
rating: slash.rating,
|
|
2618
|
-
...(slash.note ? { note: slash.note } : {}),
|
|
2619
|
-
expectedVersion: current?.ownerVersion ?? 0,
|
|
2620
|
-
});
|
|
2621
|
-
pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
|
|
2622
|
-
}
|
|
2623
|
-
catch (err) {
|
|
2624
|
-
pushMessage('system', `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`);
|
|
2625
|
-
}
|
|
2626
|
-
})();
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
case 'feedback': {
|
|
3298
|
+
const target = lastAssistantMessage.current;
|
|
3299
|
+
if (!target || target.messageId !== slash.messageId) {
|
|
3300
|
+
pushMessage('system', 'Nothing to rate yet.');
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
recordFeedback(target.runId, slash.messageId, slash.rating, slash.note);
|
|
2627
3304
|
return;
|
|
2628
3305
|
}
|
|
2629
3306
|
case 'review': {
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3307
|
+
if (slash.instructions) {
|
|
3308
|
+
// Custom instructions are already operator-authored model input.
|
|
3309
|
+
// They take the ordinary FIFO below rather than a second send path.
|
|
3310
|
+
outgoing = slash.instructions;
|
|
3311
|
+
break;
|
|
3312
|
+
}
|
|
3313
|
+
const values = [
|
|
3314
|
+
'base-branch',
|
|
3315
|
+
'uncommitted',
|
|
3316
|
+
'commit',
|
|
3317
|
+
'custom',
|
|
3318
|
+
];
|
|
3319
|
+
setSelectedChoice(0);
|
|
3320
|
+
setChoicePicker({
|
|
3321
|
+
kind: 'review-preset',
|
|
3322
|
+
title: 'Select a review preset',
|
|
3323
|
+
values,
|
|
3324
|
+
options: [
|
|
3325
|
+
{
|
|
3326
|
+
label: 'Base branch',
|
|
3327
|
+
description: 'Review the current work as a branch comparison.',
|
|
3328
|
+
},
|
|
3329
|
+
{
|
|
3330
|
+
label: 'Uncommitted',
|
|
3331
|
+
description: 'Review staged, unstaged, and untracked changes.',
|
|
3332
|
+
},
|
|
3333
|
+
{
|
|
3334
|
+
label: 'Commit',
|
|
3335
|
+
description: 'Review one recent commit.',
|
|
3336
|
+
},
|
|
3337
|
+
{
|
|
3338
|
+
label: 'Custom',
|
|
3339
|
+
description: 'Write exact review instructions in the composer.',
|
|
3340
|
+
},
|
|
3341
|
+
],
|
|
3342
|
+
});
|
|
2650
3343
|
return;
|
|
2651
3344
|
}
|
|
2652
3345
|
case 'diff': {
|
|
@@ -2771,45 +3464,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2771
3464
|
: 'Raw output mode off — rich transcript rendering restored.');
|
|
2772
3465
|
return;
|
|
2773
3466
|
}
|
|
2774
|
-
case 'export': {
|
|
2775
|
-
|
|
2776
|
-
const destination = scopeRef.current?.sessionId;
|
|
2777
|
-
if (!sessions || !destination) {
|
|
2778
|
-
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
3467
|
+
case 'export-picker': {
|
|
3468
|
+
if (!stableExportSource())
|
|
2779
3469
|
return;
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
const written = await writeConversationExport(projected.markdown, path, ctx.cwd);
|
|
2802
|
-
pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
|
|
2803
|
-
}
|
|
2804
|
-
catch (err) {
|
|
2805
|
-
pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2806
|
-
}
|
|
2807
|
-
finally {
|
|
2808
|
-
exportingRef.current = false;
|
|
2809
|
-
if (conversationGenRef.current === generation)
|
|
2810
|
-
setState('idle');
|
|
2811
|
-
}
|
|
2812
|
-
})();
|
|
3470
|
+
setSelectedChoice(0);
|
|
3471
|
+
setChoicePicker({
|
|
3472
|
+
kind: 'export-destination',
|
|
3473
|
+
title: 'Export conversation',
|
|
3474
|
+
notice: 'Save the complete verified conversation as Markdown.',
|
|
3475
|
+
values: ['clipboard', 'file'],
|
|
3476
|
+
options: [
|
|
3477
|
+
{
|
|
3478
|
+
label: 'Copy to clipboard',
|
|
3479
|
+
description: 'Send the verified Markdown transcript to the terminal clipboard',
|
|
3480
|
+
},
|
|
3481
|
+
{
|
|
3482
|
+
label: 'Save to file',
|
|
3483
|
+
description: 'Choose a Markdown filename; existing files are never overwritten',
|
|
3484
|
+
},
|
|
3485
|
+
],
|
|
3486
|
+
});
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
case 'export': {
|
|
3490
|
+
runConversationExport({ kind: 'file', path: slash.path });
|
|
2813
3491
|
return;
|
|
2814
3492
|
}
|
|
2815
3493
|
default: {
|
|
@@ -2865,14 +3543,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2865
3543
|
nextId,
|
|
2866
3544
|
pushMessage,
|
|
2867
3545
|
rawOutput,
|
|
3546
|
+
removeStoredCredential,
|
|
2868
3547
|
resetTranscript,
|
|
3548
|
+
runConversationExport,
|
|
2869
3549
|
setChoicePicker,
|
|
2870
3550
|
setCopyPicker,
|
|
2871
3551
|
setReasoningEffort,
|
|
2872
3552
|
slashCtx,
|
|
3553
|
+
stableExportSource,
|
|
2873
3554
|
startFreshConversation,
|
|
2874
3555
|
state,
|
|
3556
|
+
terminal.rows,
|
|
2875
3557
|
]);
|
|
3558
|
+
// `applyChoiceSelection` is declared before `handleSubmit` because the
|
|
3559
|
+
// picker is also used by review/export flows. Keep only this dispatch hop in
|
|
3560
|
+
// a ref so selecting a help row re-enters the one ordinary slash-command
|
|
3561
|
+
// path instead of growing a second command executor.
|
|
3562
|
+
commandPickerSubmitRef.current = handleSubmit;
|
|
2876
3563
|
// Keep the footer on the same durable goal record the driver mutates. A ref
|
|
2877
3564
|
// alone cannot repaint React, so every goal mutation bumps goalDriveVersion;
|
|
2878
3565
|
// generation/session checks stop a late disk read labelling a resumed chat.
|
|
@@ -2925,6 +3612,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2925
3612
|
conversationMutationRef.current ||
|
|
2926
3613
|
exportingRef.current ||
|
|
2927
3614
|
compactingRef.current ||
|
|
3615
|
+
textPromptRef.current ||
|
|
2928
3616
|
choicePickerRef.current ||
|
|
2929
3617
|
copyPickerRef.current ||
|
|
2930
3618
|
goalCommandInFlightRef.current) {
|
|
@@ -2951,6 +3639,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2951
3639
|
conversationMutationRef.current ||
|
|
2952
3640
|
exportingRef.current ||
|
|
2953
3641
|
compactingRef.current ||
|
|
3642
|
+
textPromptRef.current ||
|
|
2954
3643
|
choicePickerRef.current ||
|
|
2955
3644
|
copyPickerRef.current ||
|
|
2956
3645
|
goalCommandInFlightRef.current ||
|
|
@@ -3010,6 +3699,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3010
3699
|
conversationMutation,
|
|
3011
3700
|
session,
|
|
3012
3701
|
state,
|
|
3702
|
+
textPrompt,
|
|
3013
3703
|
choicePicker,
|
|
3014
3704
|
copyPicker,
|
|
3015
3705
|
wakeGoalDriver,
|
|
@@ -3026,6 +3716,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3026
3716
|
conversationMutationRef.current ||
|
|
3027
3717
|
exportingRef.current ||
|
|
3028
3718
|
compactingRef.current ||
|
|
3719
|
+
textPromptRef.current ||
|
|
3029
3720
|
choicePickerRef.current ||
|
|
3030
3721
|
copyPickerRef.current ||
|
|
3031
3722
|
goalCommandInFlightRef.current)
|
|
@@ -3042,6 +3733,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3042
3733
|
phase,
|
|
3043
3734
|
queued,
|
|
3044
3735
|
queuePause,
|
|
3736
|
+
textPrompt,
|
|
3045
3737
|
choicePicker,
|
|
3046
3738
|
copyPicker,
|
|
3047
3739
|
dequeueQueued,
|
|
@@ -3195,16 +3887,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3195
3887
|
setPhase('ready');
|
|
3196
3888
|
return;
|
|
3197
3889
|
}
|
|
3890
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
3891
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, key.home
|
|
3892
|
+
? 'first'
|
|
3893
|
+
: key.end
|
|
3894
|
+
? 'last'
|
|
3895
|
+
: key.pageUp
|
|
3896
|
+
? 'previous-page'
|
|
3897
|
+
: 'next-page'));
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3198
3900
|
if (key.escape || key.leftArrow || key.upArrow) {
|
|
3199
|
-
setSelectedEdit((index) =>
|
|
3901
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, 'previous'));
|
|
3200
3902
|
return;
|
|
3201
3903
|
}
|
|
3202
3904
|
if (key.rightArrow || key.downArrow) {
|
|
3203
|
-
setSelectedEdit((index) =>
|
|
3905
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, 'next'));
|
|
3204
3906
|
return;
|
|
3205
3907
|
}
|
|
3206
3908
|
if (key.return) {
|
|
3207
|
-
const target = editList[
|
|
3909
|
+
const target = editList[selectedEditRef.current];
|
|
3208
3910
|
if (target)
|
|
3209
3911
|
void confirmPromptEdit(target);
|
|
3210
3912
|
}
|
|
@@ -3218,12 +3920,21 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3218
3920
|
// replaced regardless.
|
|
3219
3921
|
if (resumeCommittedRef.current)
|
|
3220
3922
|
return;
|
|
3221
|
-
if (key.
|
|
3222
|
-
setSelectedResume((
|
|
3923
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
3924
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, key.home
|
|
3925
|
+
? 'first'
|
|
3926
|
+
: key.end
|
|
3927
|
+
? 'last'
|
|
3928
|
+
: key.pageUp
|
|
3929
|
+
? 'previous-page'
|
|
3930
|
+
: 'next-page'));
|
|
3931
|
+
}
|
|
3932
|
+
else if (key.upArrow)
|
|
3933
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, 'previous'));
|
|
3223
3934
|
else if (key.downArrow)
|
|
3224
|
-
setSelectedResume((
|
|
3935
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, 'next'));
|
|
3225
3936
|
else if (key.return) {
|
|
3226
|
-
const conv = resumeList[
|
|
3937
|
+
const conv = resumeList[selectedResumeRef.current];
|
|
3227
3938
|
if (conv)
|
|
3228
3939
|
void resumeConversation(conv);
|
|
3229
3940
|
}
|
|
@@ -3260,12 +3971,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3260
3971
|
}
|
|
3261
3972
|
// A pending permission prompt owns the keyboard: y/n/a decide it.
|
|
3262
3973
|
if (permissionResolveRef.current) {
|
|
3974
|
+
if (permission &&
|
|
3975
|
+
(key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end)) {
|
|
3976
|
+
const count = permissionReviewRows(permission.review, terminal.columns).length;
|
|
3977
|
+
const maxOffset = Math.max(0, count - PERMISSION_REVIEW_PAGE_ROWS);
|
|
3978
|
+
const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
|
|
3979
|
+
const next = key.home
|
|
3980
|
+
? 0
|
|
3981
|
+
: key.end
|
|
3982
|
+
? maxOffset
|
|
3983
|
+
: key.pageUp
|
|
3984
|
+
? Math.max(0, current - PERMISSION_REVIEW_PAGE_ROWS)
|
|
3985
|
+
: key.pageDown
|
|
3986
|
+
? Math.min(maxOffset, current + PERMISSION_REVIEW_PAGE_ROWS)
|
|
3987
|
+
: key.upArrow
|
|
3988
|
+
? Math.max(0, current - 1)
|
|
3989
|
+
: Math.min(maxOffset, current + 1);
|
|
3990
|
+
setPermissionReviewOffset(next);
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3263
3993
|
const ch = input.toLowerCase();
|
|
3264
3994
|
// Refusing is never deferred or gated — it is the direction a
|
|
3265
3995
|
// mistake is recoverable in, so it answers on the first press.
|
|
3266
3996
|
if (key.ctrl && input === 'c') {
|
|
3267
3997
|
resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
|
|
3268
|
-
abortRef.current?.abort();
|
|
3998
|
+
abortRef.current?.abort(new RunCancelled('user'));
|
|
3269
3999
|
return;
|
|
3270
4000
|
}
|
|
3271
4001
|
if (ch === 'n' || key.escape) {
|
|
@@ -3286,6 +4016,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3286
4016
|
resolvePermission({ kind: 'approve-all' });
|
|
3287
4017
|
return;
|
|
3288
4018
|
}
|
|
4019
|
+
// A host-owned text prompt has its own input hook. This synchronous ref
|
|
4020
|
+
// claims the interval before React paints it too, so App cannot read a
|
|
4021
|
+
// key repeat as an interrupt, exit ladder, or finite-menu choice.
|
|
4022
|
+
if (textPromptRef.current)
|
|
4023
|
+
return;
|
|
3289
4024
|
// Finite slash-command choices own the keyboard below a live permission
|
|
3290
4025
|
// request and above the ordinary composer. Their values are a captured
|
|
3291
4026
|
// menu, so a provider/session change cannot silently reinterpret Enter.
|
|
@@ -3299,19 +4034,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3299
4034
|
return;
|
|
3300
4035
|
const options = picker.options;
|
|
3301
4036
|
if (key.escape || (key.ctrl && input === 'c')) {
|
|
4037
|
+
reviewChoiceInFlightRef.current = null;
|
|
3302
4038
|
setChoicePicker(null);
|
|
3303
4039
|
return;
|
|
3304
4040
|
}
|
|
4041
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
4042
|
+
const pageSize = picker.kind === 'command' ? picker.windowSize : undefined;
|
|
4043
|
+
setSelectedChoice((index) => moveSelection(index, options.length, key.home
|
|
4044
|
+
? 'first'
|
|
4045
|
+
: key.end
|
|
4046
|
+
? 'last'
|
|
4047
|
+
: key.pageUp
|
|
4048
|
+
? 'previous-page'
|
|
4049
|
+
: 'next-page', pageSize));
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
3305
4052
|
if (key.upArrow) {
|
|
3306
|
-
setSelectedChoice((index) =>
|
|
4053
|
+
setSelectedChoice((index) => moveSelection(index, options.length, 'previous'));
|
|
3307
4054
|
return;
|
|
3308
4055
|
}
|
|
3309
4056
|
if (key.downArrow) {
|
|
3310
|
-
setSelectedChoice((index) =>
|
|
4057
|
+
setSelectedChoice((index) => moveSelection(index, options.length, 'next'));
|
|
3311
4058
|
return;
|
|
3312
4059
|
}
|
|
3313
4060
|
if (key.return) {
|
|
3314
|
-
applyChoiceSelection(
|
|
4061
|
+
applyChoiceSelection(selectedChoiceRef.current);
|
|
3315
4062
|
return;
|
|
3316
4063
|
}
|
|
3317
4064
|
if (/^[1-9]$/.test(input)) {
|
|
@@ -3330,16 +4077,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3330
4077
|
setCopyPicker(null);
|
|
3331
4078
|
return;
|
|
3332
4079
|
}
|
|
4080
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
4081
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, key.home
|
|
4082
|
+
? 'first'
|
|
4083
|
+
: key.end
|
|
4084
|
+
? 'last'
|
|
4085
|
+
: key.pageUp
|
|
4086
|
+
? 'previous-page'
|
|
4087
|
+
: 'next-page'));
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
3333
4090
|
if (key.upArrow) {
|
|
3334
|
-
setSelectedCopy((index) =>
|
|
4091
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, 'previous'));
|
|
3335
4092
|
return;
|
|
3336
4093
|
}
|
|
3337
4094
|
if (key.downArrow) {
|
|
3338
|
-
setSelectedCopy((index) =>
|
|
4095
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, 'next'));
|
|
3339
4096
|
return;
|
|
3340
4097
|
}
|
|
3341
4098
|
if (key.return) {
|
|
3342
|
-
sendCopyRequest(
|
|
4099
|
+
sendCopyRequest(selectedCopyRef.current);
|
|
3343
4100
|
return;
|
|
3344
4101
|
}
|
|
3345
4102
|
if (/^[1-9]$/.test(input)) {
|
|
@@ -3349,6 +4106,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3349
4106
|
}
|
|
3350
4107
|
return;
|
|
3351
4108
|
}
|
|
4109
|
+
// Terminal-native display clear. It deliberately shares `/clear-screen`'s
|
|
4110
|
+
// view-only boundary: model history, durable history and the active
|
|
4111
|
+
// conversation remain untouched. A live turn is refused instead of
|
|
4112
|
+
// erasing the only visible account of work that is still changing.
|
|
4113
|
+
if (key.ctrl && input === 'l') {
|
|
4114
|
+
if (state !== 'idle' || abortRef.current || hasUnsettledTurn()) {
|
|
4115
|
+
pushMessage('system', 'Ctrl+L is unavailable while a turn is still in progress.');
|
|
4116
|
+
return;
|
|
4117
|
+
}
|
|
4118
|
+
setMessages([]);
|
|
4119
|
+
resetTranscript();
|
|
4120
|
+
return;
|
|
4121
|
+
}
|
|
3352
4122
|
// Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
|
|
3353
4123
|
// the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
|
|
3354
4124
|
if (key.escape && abortRef.current) {
|
|
@@ -3421,33 +4191,43 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3421
4191
|
// Active on every phase. It was gated off during the picker, which is what
|
|
3422
4192
|
// left that screen without a usable exit; the picker branch above returns
|
|
3423
4193
|
// immediately, so the Picker still owns everything except Ctrl+C.
|
|
3424
|
-
{ isActive:
|
|
4194
|
+
{ isActive: externalEditorRequest === null });
|
|
3425
4195
|
// Background is left natural — we inherit the terminal's own background
|
|
3426
4196
|
// and only theme the foreground. Forcing
|
|
3427
4197
|
// a filled bg left mismatched patches around bordered areas, so we don't.
|
|
3428
|
-
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,
|
|
4198
|
+
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 }) }), _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, reviewOffset: permissionReviewOffset, columns: terminal.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' &&
|
|
3429
4199
|
phase === 'ready' &&
|
|
3430
4200
|
conversationMutation === null &&
|
|
4201
|
+
textPrompt === null &&
|
|
3431
4202
|
choicePicker === null &&
|
|
3432
4203
|
copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
|
|
3433
4204
|
permission === null &&
|
|
4205
|
+
textPrompt === null &&
|
|
3434
4206
|
choicePicker === null &&
|
|
3435
4207
|
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 &&
|
|
3436
4208
|
permission === null &&
|
|
4209
|
+
textPrompt === null &&
|
|
3437
4210
|
choicePicker === null &&
|
|
3438
4211
|
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
|
|
3439
|
-
?
|
|
4212
|
+
? queuePause.outcome === 'paused'
|
|
4213
|
+
? 'held after a resumable run paused; wait for recovery, change model, or send a message to release it'
|
|
4214
|
+
: `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
|
|
3440
4215
|
: 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
|
|
3441
4216
|
state === 'awaiting-permission' ||
|
|
3442
4217
|
compacting ||
|
|
3443
|
-
conversationMutation !== null
|
|
4218
|
+
conversationMutation !== null ||
|
|
4219
|
+
externalEditorRequest !== null, hidden: permission !== null ||
|
|
4220
|
+
textPrompt !== null ||
|
|
4221
|
+
choicePicker !== null ||
|
|
4222
|
+
copyPicker !== null,
|
|
3444
4223
|
// A turn is running, so Esc is the interrupt and not
|
|
3445
4224
|
// the composer's clear.
|
|
3446
|
-
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' &&
|
|
4225
|
+
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' &&
|
|
3447
4226
|
state === 'idle' &&
|
|
3448
4227
|
conversationMutation === null &&
|
|
3449
4228
|
!compacting &&
|
|
3450
4229
|
permission === null &&
|
|
4230
|
+
textPrompt === null &&
|
|
3451
4231
|
choicePicker === null &&
|
|
3452
4232
|
copyPicker === null
|
|
3453
4233
|
? goalStatusLabel(goalStatus)
|
|
@@ -3455,23 +4235,27 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3455
4235
|
? 'forking conversation — input is paused'
|
|
3456
4236
|
: conversationMutation === 'edit'
|
|
3457
4237
|
? 'branching before prompt — input is paused'
|
|
3458
|
-
: conversationMutation === '
|
|
3459
|
-
? '
|
|
3460
|
-
:
|
|
3461
|
-
? '
|
|
3462
|
-
:
|
|
3463
|
-
?
|
|
3464
|
-
:
|
|
3465
|
-
?
|
|
3466
|
-
:
|
|
3467
|
-
? '
|
|
3468
|
-
:
|
|
3469
|
-
?
|
|
3470
|
-
:
|
|
4238
|
+
: conversationMutation === 'archive'
|
|
4239
|
+
? 'archiving conversation — input is paused'
|
|
4240
|
+
: conversationMutation === 'new'
|
|
4241
|
+
? 'starting a fresh conversation — input is paused'
|
|
4242
|
+
: compacting
|
|
4243
|
+
? 'compacting conversation — input is paused'
|
|
4244
|
+
: permission
|
|
4245
|
+
? hintForPhase(phase, state, session?.hasProvider === true)
|
|
4246
|
+
: textPrompt
|
|
4247
|
+
? 'name editor open — enter save · esc cancel'
|
|
4248
|
+
: choicePicker
|
|
4249
|
+
? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
|
|
4250
|
+
: copyPicker
|
|
4251
|
+
? 'copy target open — ↑↓ / 1–9 select · esc cancel'
|
|
4252
|
+
: goalStatus && phase === 'ready' && state === 'idle'
|
|
4253
|
+
? undefined
|
|
4254
|
+
: hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
|
|
3471
4255
|
}
|
|
3472
4256
|
function Banner({ version, session, permissionMode, cwd, }) {
|
|
3473
|
-
const
|
|
3474
|
-
const wide =
|
|
4257
|
+
const { columns } = useWindowSize();
|
|
4258
|
+
const wide = columns >= NAMZU_WORDMARK_MIN_WIDTH;
|
|
3475
4259
|
const provider = session?.providerSummary;
|
|
3476
4260
|
const model = session?.modelSummary;
|
|
3477
4261
|
const home = process.env.HOME;
|
|
@@ -3490,8 +4274,11 @@ function ComposerFrame({ focus, hidden = false, children, }) {
|
|
|
3490
4274
|
// Tool call label: the tool name title-cased, then its most identifying
|
|
3491
4275
|
// argument — `Bash(ls -la)`, `Read(file.ts)`. A bare tool name in a transcript
|
|
3492
4276
|
// of forty calls says nothing about which one this was.
|
|
3493
|
-
function formatToolCall(toolName, summary) {
|
|
3494
|
-
|
|
4277
|
+
function formatToolCall(toolName, summary, standalone = false) {
|
|
4278
|
+
if (standalone)
|
|
4279
|
+
return summary;
|
|
4280
|
+
const words = toolName.replace(/[_-]+/g, ' ');
|
|
4281
|
+
const display = words.length > 0 ? words[0]?.toUpperCase() + words.slice(1) : words;
|
|
3495
4282
|
return summary.length > 0 ? `${display}(${summary})` : display;
|
|
3496
4283
|
}
|
|
3497
4284
|
function hintForPhase(phase, state,
|