@namzu/cli 14.3.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -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.js +1 -1
- package/dist/commands/acp.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +45 -7
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +22 -9
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/{stubs.d.ts → serve.d.ts} +1 -10
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/{stubs.js → serve.js} +1 -24
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/skills.d.ts +19 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +107 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/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 +1003 -186
- 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/TextPrompt.d.ts +21 -0
- package/dist/tui/TextPrompt.d.ts.map +1 -0
- package/dist/tui/TextPrompt.js +139 -0
- package/dist/tui/TextPrompt.js.map +1 -0
- package/dist/tui/Transcript.d.ts +3 -3
- package/dist/tui/Transcript.d.ts.map +1 -1
- package/dist/tui/Transcript.js +4 -4
- package/dist/tui/Transcript.js.map +1 -1
- package/dist/tui/agent.d.ts +24 -20
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +98 -74
- 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/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/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 +6 -5
- package/dist/commands/stubs.d.ts.map +0 -1
- package/dist/commands/stubs.js.map +0 -1
package/dist/tui/App.js
CHANGED
|
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
12
12
|
* automatic reuse is not mistaken for a durable operator preference.
|
|
13
13
|
*/
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
|
-
import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
|
|
15
|
+
import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
|
|
16
16
|
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
17
17
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
18
18
|
import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
|
|
@@ -20,8 +20,8 @@ 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,15 +29,18 @@ 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
46
|
import { bottomSpacerRows } from './bottom-spacer.js';
|
|
@@ -47,14 +50,18 @@ import { planTurnPublication } from './conversation-history.js';
|
|
|
47
50
|
import { copyTargetsForResponse } from './copy-targets.js';
|
|
48
51
|
import { editablePrompts } from './edit-prompts.js';
|
|
49
52
|
import { MAX_LIVE_ROWS, liveWindow, transcriptLines } from './live-window.js';
|
|
50
|
-
import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.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;
|
|
@@ -202,7 +215,8 @@ function goalRoundPrompt(authority) {
|
|
|
202
215
|
* until the renderer gives up on incremental repaint. See `live-window.ts`.
|
|
203
216
|
*/
|
|
204
217
|
const LIVE_FURNITURE_ROWS = 10;
|
|
205
|
-
|
|
218
|
+
const defaultExternalEditor = ({ seed, cwd, signal }) => editDraftInExternalEditor(seed, { cwd, signal });
|
|
219
|
+
export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultExternalEditor, }) {
|
|
206
220
|
// Bind approval and every operation it admits to one real directory. A
|
|
207
221
|
// lexical cwd may be a writable symlink; resolving it again after the gate
|
|
208
222
|
// would let it be repointed from the approved project to another.
|
|
@@ -217,6 +231,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
217
231
|
}, [initialCtx]);
|
|
218
232
|
const { exit } = useApp();
|
|
219
233
|
const { stdout, write: writeStdout } = useStdout();
|
|
234
|
+
const hyperlinks = terminalSupportsHyperlinks(process.env, stdout.isTTY === true);
|
|
220
235
|
/**
|
|
221
236
|
* The last assistant message id the run reported, for `/feedback`.
|
|
222
237
|
*
|
|
@@ -251,6 +266,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
251
266
|
* has to pretend it is one.
|
|
252
267
|
*/
|
|
253
268
|
const modelHistoryRef = useRef([]);
|
|
269
|
+
/** Consumed only after the exact durable conversation has loaded successfully. */
|
|
270
|
+
const initialConversationIdRef = useRef(initialCtx.initialConversationId);
|
|
254
271
|
const [history, setHistory] = useState([]);
|
|
255
272
|
const [state, setState] = useState('idle');
|
|
256
273
|
const [phase, setPhase] = useState('probing');
|
|
@@ -284,6 +301,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
284
301
|
*/
|
|
285
302
|
const [pickerNotice, setPickerNotice] = useState(null);
|
|
286
303
|
const [permission, setPermission] = useState(null);
|
|
304
|
+
const [permissionReviewOffset, setPermissionReviewOffsetState] = useState(0);
|
|
305
|
+
const permissionReviewOffsetRef = useRef(0);
|
|
306
|
+
const setPermissionReviewOffset = useCallback((next) => {
|
|
307
|
+
permissionReviewOffsetRef.current = next;
|
|
308
|
+
setPermissionReviewOffsetState(next);
|
|
309
|
+
}, []);
|
|
287
310
|
// The launch bypass is an INITIAL selection, not permanent authority. A
|
|
288
311
|
// typed /permissions command can narrow it back to prompt/strict without
|
|
289
312
|
// rebuilding the provider, tools, plugins, or sandbox.
|
|
@@ -302,6 +325,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
302
325
|
// per keypress is a cost nobody asked for. A file added mid-session is
|
|
303
326
|
// picked up by `/model` (which re-hydrates) or a restart.
|
|
304
327
|
const [userCommands, setUserCommands] = useState([]);
|
|
328
|
+
/** Git-admitted file paths cached once per successful session hydration. */
|
|
329
|
+
const [mentionCandidates, setMentionCandidates] = useState([]);
|
|
330
|
+
const mentionLoadOwnerRef = useRef(null);
|
|
305
331
|
const [usage, setUsage] = useState(null);
|
|
306
332
|
// Context fill, straight from the kernel and held apart from `usage` —
|
|
307
333
|
// they are different quantities and conflating them is what made the
|
|
@@ -371,14 +397,14 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
371
397
|
const compactingRef = useRef(false);
|
|
372
398
|
const [compacting, setCompacting] = useState(false);
|
|
373
399
|
const [resumeList, setResumeList] = useState([]);
|
|
374
|
-
const
|
|
400
|
+
const { selection: selectedResume, selectionRef: selectedResumeRef, setSelection: setSelectedResume, } = useSelectionIndex(0);
|
|
375
401
|
const [editList, setEditList] = useState([]);
|
|
376
|
-
const
|
|
402
|
+
const { selection: selectedEdit, selectionRef: selectedEditRef, setSelection: setSelectedEdit, } = useSelectionIndex(0);
|
|
377
403
|
const editCommittedRef = useRef(false);
|
|
378
404
|
/** `/copy` owns this exact response snapshot until selection or cancellation. */
|
|
379
405
|
const [copyPicker, setCopyPickerState] = useState(null);
|
|
380
406
|
const copyPickerRef = useRef(null);
|
|
381
|
-
const
|
|
407
|
+
const { selection: selectedCopy, selectionRef: selectedCopyRef, setSelection: setSelectedCopy, } = useSelectionIndex(0);
|
|
382
408
|
const setCopyPicker = useCallback((next) => {
|
|
383
409
|
copyPickerRef.current = next;
|
|
384
410
|
setCopyPickerState(next);
|
|
@@ -392,7 +418,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
392
418
|
* that created it before a menu has existed in the rendered tree.
|
|
393
419
|
*/
|
|
394
420
|
const choicePickerCommittedRef = useRef(null);
|
|
395
|
-
const
|
|
421
|
+
const commandPickerSubmitRef = useRef(() => { });
|
|
422
|
+
const { selection: selectedChoice, selectionRef: selectedChoiceRef, setSelection: setSelectedChoice, } = useSelectionIndex(0);
|
|
396
423
|
const setChoicePicker = useCallback((next) => {
|
|
397
424
|
choicePickerRef.current = next;
|
|
398
425
|
if (next === null)
|
|
@@ -402,8 +429,18 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
402
429
|
useEffect(() => {
|
|
403
430
|
choicePickerCommittedRef.current = choicePicker;
|
|
404
431
|
}, [choicePicker]);
|
|
432
|
+
/** Host-owned text decision; its value never enters model prompt history. */
|
|
433
|
+
const [textPrompt, setTextPromptState] = useState(null);
|
|
434
|
+
const textPromptRef = useRef(null);
|
|
435
|
+
const textPromptTokenRef = useRef(0);
|
|
436
|
+
const setTextPrompt = useCallback((next) => {
|
|
437
|
+
textPromptRef.current = next;
|
|
438
|
+
setTextPromptState(next);
|
|
439
|
+
}, []);
|
|
405
440
|
const [composerDraft, setComposerDraft] = useState(null);
|
|
406
441
|
const composerDraftTokenRef = useRef(0);
|
|
442
|
+
/** One git-backed review choice may be resolving while its visible picker remains authoritative. */
|
|
443
|
+
const reviewChoiceInFlightRef = useRef(null);
|
|
407
444
|
const exitArmedRef = useRef(false);
|
|
408
445
|
const abortRef = useRef(null);
|
|
409
446
|
/** Identity of the turn allowed to notify when it settles. */
|
|
@@ -533,6 +570,10 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
533
570
|
*/
|
|
534
571
|
const conversationMutationRef = useRef(null);
|
|
535
572
|
const [conversationMutation, setConversationMutation] = useState(null);
|
|
573
|
+
/** Host editor operation; it owns terminal input until its child settles. */
|
|
574
|
+
const [externalEditorRequest, setExternalEditorRequest] = useState(null);
|
|
575
|
+
const externalEditorRequestRef = useRef(null);
|
|
576
|
+
const externalEditorStartedRef = useRef(null);
|
|
536
577
|
/** Closes the same-tick input window while a verified export reads disk. */
|
|
537
578
|
const exportingRef = useRef(false);
|
|
538
579
|
/** A goal mutation is ordered before any later conversation command. */
|
|
@@ -568,6 +609,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
568
609
|
settledRef.current = 0;
|
|
569
610
|
setResetKey((k) => k + 1);
|
|
570
611
|
}, [stdout.isTTY, writeStdout]);
|
|
612
|
+
const requestExternalEditor = useCallback((seed) => {
|
|
613
|
+
if (phase !== 'ready' ||
|
|
614
|
+
state !== 'idle' ||
|
|
615
|
+
abortRef.current !== null ||
|
|
616
|
+
hasUnsettledTurn() ||
|
|
617
|
+
queuedRef.current.length > 0 ||
|
|
618
|
+
compactingRef.current ||
|
|
619
|
+
conversationMutationRef.current !== null) {
|
|
620
|
+
return Promise.reject(new Error('wait for the active turn, queue, compaction, or conversation change to settle'));
|
|
621
|
+
}
|
|
622
|
+
if (externalEditorRequestRef.current) {
|
|
623
|
+
return Promise.reject(new Error('an external editor is already open'));
|
|
624
|
+
}
|
|
625
|
+
return new Promise((resolve, reject) => {
|
|
626
|
+
const request = {
|
|
627
|
+
token: {},
|
|
628
|
+
seed,
|
|
629
|
+
controller: new AbortController(),
|
|
630
|
+
resolve,
|
|
631
|
+
reject,
|
|
632
|
+
};
|
|
633
|
+
externalEditorRequestRef.current = request;
|
|
634
|
+
setExternalEditorRequest(request);
|
|
635
|
+
});
|
|
636
|
+
}, [hasUnsettledTurn, phase, state]);
|
|
637
|
+
useEffect(() => {
|
|
638
|
+
const request = externalEditorRequest;
|
|
639
|
+
if (!request || externalEditorStartedRef.current === request.token)
|
|
640
|
+
return;
|
|
641
|
+
externalEditorStartedRef.current = request.token;
|
|
642
|
+
void (async () => {
|
|
643
|
+
// Let React deactivate every Ink input hook first. The editor must inherit
|
|
644
|
+
// a cooked terminal, not the raw byte stream the composer consumes.
|
|
645
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
646
|
+
if (request.controller.signal.aborted)
|
|
647
|
+
return;
|
|
648
|
+
if (stdout.isTTY)
|
|
649
|
+
writeStdout('\x1b[2J\x1b[H');
|
|
650
|
+
let edited;
|
|
651
|
+
let failure;
|
|
652
|
+
try {
|
|
653
|
+
edited = await externalEditor({
|
|
654
|
+
seed: request.seed,
|
|
655
|
+
cwd: trustedCwdRef.current,
|
|
656
|
+
signal: request.controller.signal,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
failure = error;
|
|
661
|
+
}
|
|
662
|
+
if (request.controller.signal.aborted)
|
|
663
|
+
return;
|
|
664
|
+
if (externalEditorRequestRef.current === request) {
|
|
665
|
+
externalEditorRequestRef.current = null;
|
|
666
|
+
resetTranscript();
|
|
667
|
+
setExternalEditorRequest(null);
|
|
668
|
+
}
|
|
669
|
+
if (failure !== undefined)
|
|
670
|
+
request.reject(failure);
|
|
671
|
+
else
|
|
672
|
+
request.resolve(edited ?? '');
|
|
673
|
+
})();
|
|
674
|
+
}, [externalEditor, externalEditorRequest, resetTranscript, stdout.isTTY, writeStdout]);
|
|
675
|
+
useEffect(() => () => {
|
|
676
|
+
const request = externalEditorRequestRef.current;
|
|
677
|
+
if (!request)
|
|
678
|
+
return;
|
|
679
|
+
externalEditorRequestRef.current = null;
|
|
680
|
+
request.controller.abort(new Error('the terminal session closed'));
|
|
681
|
+
}, []);
|
|
571
682
|
const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
|
|
572
683
|
const id = nextId();
|
|
573
684
|
setMessages((prev) => [
|
|
@@ -650,6 +761,222 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
650
761
|
setReasoningEffort(effort);
|
|
651
762
|
pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
|
|
652
763
|
}, [hasUnsettledTurn, pushMessage, session, setReasoningEffort, state]);
|
|
764
|
+
const stepReasoningEffort = useCallback((direction) => {
|
|
765
|
+
const levels = session?.reasoningEffortLevels;
|
|
766
|
+
if (!session?.hasProvider || levels === undefined || levels.length === 0) {
|
|
767
|
+
pushMessage('system', 'Reasoning shortcuts are unavailable because the current provider chain has no exact non-empty effort menu.');
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const selected = reasoningEffortRef.current;
|
|
771
|
+
const anchor = selected !== undefined && levels.includes(selected)
|
|
772
|
+
? selected
|
|
773
|
+
: session.reasoningEffortDefault;
|
|
774
|
+
if (anchor === undefined || !levels.includes(anchor)) {
|
|
775
|
+
pushMessage('system', 'Reasoning shortcuts are unavailable because the provider chain does not publish one exact default. Use /effort to choose a level explicitly.');
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const index = levels.indexOf(anchor);
|
|
779
|
+
const next = direction === 'raise' ? levels[index + 1] : levels[index - 1];
|
|
780
|
+
if (next === undefined) {
|
|
781
|
+
pushMessage('system', `Reasoning is already at the ${direction === 'raise' ? 'highest' : 'lowest'} shortcut level (${anchor}).`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (direction === 'raise' && (next === 'max' || next === 'ultra')) {
|
|
785
|
+
pushMessage('system', `${next} is an advanced reasoning level. Choose it explicitly with /effort.`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
applyReasoningEffort(next);
|
|
789
|
+
}, [applyReasoningEffort, pushMessage, session]);
|
|
790
|
+
const recordFeedback = useCallback((runIdValue, messageIdValue, rating, note) => {
|
|
791
|
+
// Written under the same `<cwd>/.namzu` root the runs live in, so a
|
|
792
|
+
// rating and the transcript it judges travel together.
|
|
793
|
+
const store = new DiskMessageFeedbackStore({
|
|
794
|
+
rootDir: join(ctx.cwd, '.namzu', 'feedback'),
|
|
795
|
+
runsDir: join(ctx.cwd, '.namzu', 'runs'),
|
|
796
|
+
});
|
|
797
|
+
const runId = runIdValue;
|
|
798
|
+
const messageId = messageIdValue;
|
|
799
|
+
void (async () => {
|
|
800
|
+
try {
|
|
801
|
+
// A later rating replaces the first. Read-then-write preserves the
|
|
802
|
+
// store's owner-version collision check instead of blind overwrite.
|
|
803
|
+
const current = (await store.listMessageFeedback({ runId })).find((record) => record.messageId === messageId);
|
|
804
|
+
const record = await store.putMessageFeedback({
|
|
805
|
+
runId,
|
|
806
|
+
messageId,
|
|
807
|
+
rating,
|
|
808
|
+
...(note ? { note } : {}),
|
|
809
|
+
expectedVersion: current?.ownerVersion ?? 0,
|
|
810
|
+
});
|
|
811
|
+
pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
pushMessage('system', `Could not record feedback: ${error instanceof Error ? error.message : String(error)}`);
|
|
815
|
+
}
|
|
816
|
+
})();
|
|
817
|
+
}, [ctx.cwd, pushMessage]);
|
|
818
|
+
const activateSkill = useCallback((name) => {
|
|
819
|
+
const info = discoverSkills({ cwd: ctx.cwd }).find((skill) => skill.name === name);
|
|
820
|
+
if (!info) {
|
|
821
|
+
pushMessage('system', `No skill named "${name}". See /skills.`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const body = loadSkillBody(info);
|
|
826
|
+
setActiveSkills((previous) => [
|
|
827
|
+
...previous.filter((skill) => skill.name !== info.name),
|
|
828
|
+
{ name: info.name, body },
|
|
829
|
+
]);
|
|
830
|
+
pushMessage('system', `Activated skill: ${info.name}`);
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
pushMessage('system', `Could not load skill "${name}": ${error instanceof Error ? error.message : String(error)}`);
|
|
834
|
+
}
|
|
835
|
+
}, [ctx.cwd, pushMessage]);
|
|
836
|
+
const removeStoredCredential = useCallback((target) => {
|
|
837
|
+
const path = credentialsPath();
|
|
838
|
+
const hadClaude = readStoredSubscriptionCredential() !== null;
|
|
839
|
+
const hadCodex = readStoredCodexCredential() !== null;
|
|
840
|
+
try {
|
|
841
|
+
if (target === 'anthropic')
|
|
842
|
+
clearStoredSubscriptionCredential();
|
|
843
|
+
else if (target === 'codex')
|
|
844
|
+
clearStoredCodexCredential();
|
|
845
|
+
else
|
|
846
|
+
clearAllStoredCredentials();
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
pushMessage('system', `Could not remove ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (target === 'all') {
|
|
853
|
+
pushMessage('system', describeLogout(path, hadClaude || hadCodex));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
pushMessage('system', describeProviderLogout(path, target, target === 'anthropic' ? hadClaude : hadCodex));
|
|
857
|
+
}, [pushMessage]);
|
|
858
|
+
/** Publish one recoverable, read-only tombstone before leaving the TUI. */
|
|
859
|
+
const archiveCurrentConversation = useCallback(() => {
|
|
860
|
+
if (conversationMutationRef.current)
|
|
861
|
+
return;
|
|
862
|
+
const sessions = sessionsRef.current;
|
|
863
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
864
|
+
if (!sessions || !sessionId) {
|
|
865
|
+
pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (state !== 'idle' ||
|
|
869
|
+
abortRef.current !== null ||
|
|
870
|
+
hasUnsettledTurn() ||
|
|
871
|
+
queuedRef.current.length > 0 ||
|
|
872
|
+
compactingRef.current ||
|
|
873
|
+
exportingRef.current) {
|
|
874
|
+
pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const generation = conversationGenRef.current;
|
|
878
|
+
conversationMutationRef.current = 'archive';
|
|
879
|
+
setConversationMutation('archive');
|
|
880
|
+
void (async () => {
|
|
881
|
+
let archived = false;
|
|
882
|
+
try {
|
|
883
|
+
await persistenceTailRef.current;
|
|
884
|
+
if (conversationGenRef.current !== generation ||
|
|
885
|
+
scopeRef.current?.sessionId !== sessionId ||
|
|
886
|
+
hasUnsettledTurn(generation) ||
|
|
887
|
+
queuedRef.current.length > 0) {
|
|
888
|
+
throw new Error('the active conversation changed before archive publication');
|
|
889
|
+
}
|
|
890
|
+
await archiveConversation(sessions, sessionId);
|
|
891
|
+
archived = true;
|
|
892
|
+
goalActivation.clear();
|
|
893
|
+
onExitSummary?.({});
|
|
894
|
+
exit();
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
pushMessage('system', `Could not archive this conversation: ${error instanceof Error ? error.message : String(error)}`);
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
if (!archived && conversationMutationRef.current === 'archive') {
|
|
901
|
+
conversationMutationRef.current = null;
|
|
902
|
+
setConversationMutation(null);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
})();
|
|
906
|
+
}, [exit, goalActivation, hasUnsettledTurn, onExitSummary, pushMessage, state]);
|
|
907
|
+
/**
|
|
908
|
+
* Resolve the one durable conversation boundary shared by both export
|
|
909
|
+
* destinations. The chooser itself owns input, but it does not weaken this
|
|
910
|
+
* admission check: a direct `/export path` and a later chooser selection
|
|
911
|
+
* must prove the same session and turn stability before reading evidence.
|
|
912
|
+
*/
|
|
913
|
+
const stableExportSource = useCallback(() => {
|
|
914
|
+
const sessions = sessionsRef.current;
|
|
915
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
916
|
+
if (!sessions || !sessionId) {
|
|
917
|
+
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
if (abortRef.current ||
|
|
921
|
+
state !== 'idle' ||
|
|
922
|
+
hasUnsettledTurn() ||
|
|
923
|
+
compactingRef.current ||
|
|
924
|
+
queuedRef.current.length > 0 ||
|
|
925
|
+
exportingRef.current) {
|
|
926
|
+
pushMessage('system', 'A turn, compaction, queued prompt, or export is still running or settling. Export waits for a stable durable boundary; try again when the composer is idle.');
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
return { sessions, sessionId };
|
|
930
|
+
}, [hasUnsettledTurn, pushMessage, state]);
|
|
931
|
+
const runConversationExport = useCallback((destination) => {
|
|
932
|
+
const source = stableExportSource();
|
|
933
|
+
if (!source)
|
|
934
|
+
return;
|
|
935
|
+
exportingRef.current = true;
|
|
936
|
+
setState('thinking');
|
|
937
|
+
const generation = conversationGenRef.current;
|
|
938
|
+
void (async () => {
|
|
939
|
+
try {
|
|
940
|
+
await persistenceTailRef.current;
|
|
941
|
+
if (conversationGenRef.current !== generation ||
|
|
942
|
+
scopeRef.current?.sessionId !== source.sessionId) {
|
|
943
|
+
throw new Error('The active conversation changed before the export reached its durable boundary.');
|
|
944
|
+
}
|
|
945
|
+
const projected = await conversationMarkdown(source.sessions, source.sessionId);
|
|
946
|
+
if (destination.kind === 'file') {
|
|
947
|
+
const written = await writeConversationExport(projected.markdown, destination.path, ctx.cwd);
|
|
948
|
+
pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const result = writeClipboardText(projected.markdown, {
|
|
952
|
+
isTTY: stdout.isTTY,
|
|
953
|
+
write: writeStdout,
|
|
954
|
+
});
|
|
955
|
+
switch (result.kind) {
|
|
956
|
+
case 'request-sent':
|
|
957
|
+
pushMessage('system', `Export copy request sent for ${projected.turns} verified turn${projected.turns === 1 ? '' : 's'} (${result.bytes.toLocaleString()} bytes). Terminal, multiplexer or remote-session policy may ignore OSC 52; if the clipboard did not change, enable terminal clipboard access.`);
|
|
958
|
+
break;
|
|
959
|
+
case 'unavailable':
|
|
960
|
+
pushMessage('system', `Cannot send the verified export to the clipboard here — ${result.detail}. Choose Save to file instead.`);
|
|
961
|
+
break;
|
|
962
|
+
case 'too-large':
|
|
963
|
+
pushMessage('system', `Cannot send the verified export to the terminal clipboard — it is ${result.bytes.toLocaleString()} bytes and the OSC 52 safety limit is ${result.limit.toLocaleString()}. Nothing was truncated; choose Save to file instead.`);
|
|
964
|
+
break;
|
|
965
|
+
case 'write-failed':
|
|
966
|
+
pushMessage('system', `Could not send the verified export copy request: ${result.detail}`);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
exportingRef.current = false;
|
|
975
|
+
if (conversationGenRef.current === generation)
|
|
976
|
+
setState('idle');
|
|
977
|
+
}
|
|
978
|
+
})();
|
|
979
|
+
}, [ctx.cwd, pushMessage, stableExportSource, stdout.isTTY, writeStdout]);
|
|
653
980
|
const applyChoiceSelection = useCallback((index) => {
|
|
654
981
|
const picker = choicePickerRef.current;
|
|
655
982
|
if (!picker)
|
|
@@ -657,13 +984,211 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
657
984
|
const value = picker.values[index];
|
|
658
985
|
if (index < 0 || index >= picker.values.length)
|
|
659
986
|
return;
|
|
987
|
+
if (picker.kind === 'review-preset') {
|
|
988
|
+
if (reviewChoiceInFlightRef.current)
|
|
989
|
+
return;
|
|
990
|
+
if (value === 'custom') {
|
|
991
|
+
setChoicePicker(null);
|
|
992
|
+
composerDraftTokenRef.current += 1;
|
|
993
|
+
setComposerDraft({
|
|
994
|
+
token: composerDraftTokenRef.current,
|
|
995
|
+
text: '/review ',
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const operation = {};
|
|
1000
|
+
reviewChoiceInFlightRef.current = operation;
|
|
1001
|
+
void (async () => {
|
|
1002
|
+
try {
|
|
1003
|
+
if (value === 'base-branch') {
|
|
1004
|
+
const listing = await listReviewBranches(ctx.cwd);
|
|
1005
|
+
if (appLifetime.signal.aborted ||
|
|
1006
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1007
|
+
choicePickerRef.current !== picker)
|
|
1008
|
+
return;
|
|
1009
|
+
if (!listing || listing.branches.length === 0) {
|
|
1010
|
+
setChoicePicker(null);
|
|
1011
|
+
pushMessage('system', 'No local branch is available for comparison in this working directory.');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
setSelectedChoice(0);
|
|
1015
|
+
setChoicePicker({
|
|
1016
|
+
kind: 'review-branch',
|
|
1017
|
+
title: 'Select a base branch',
|
|
1018
|
+
notice: `Current branch: ${listing.current}`,
|
|
1019
|
+
values: listing.branches,
|
|
1020
|
+
options: listing.branches.map((branch) => ({
|
|
1021
|
+
label: branch,
|
|
1022
|
+
description: `${listing.current} → ${branch}`,
|
|
1023
|
+
})),
|
|
1024
|
+
});
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (value === 'commit') {
|
|
1028
|
+
const commits = await listReviewCommits(ctx.cwd);
|
|
1029
|
+
if (appLifetime.signal.aborted ||
|
|
1030
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1031
|
+
choicePickerRef.current !== picker)
|
|
1032
|
+
return;
|
|
1033
|
+
if (!commits || commits.length === 0) {
|
|
1034
|
+
setChoicePicker(null);
|
|
1035
|
+
pushMessage('system', 'No commit is available to review in this working directory.');
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
setSelectedChoice(0);
|
|
1039
|
+
setChoicePicker({
|
|
1040
|
+
kind: 'review-commit',
|
|
1041
|
+
title: 'Select a commit to review',
|
|
1042
|
+
values: commits,
|
|
1043
|
+
options: commits.map((commit) => ({
|
|
1044
|
+
label: commit.title,
|
|
1045
|
+
description: commit.sha.slice(0, 12),
|
|
1046
|
+
})),
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const diff = await workspaceDiff(ctx.cwd);
|
|
1051
|
+
if (appLifetime.signal.aborted ||
|
|
1052
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1053
|
+
choicePickerRef.current !== picker)
|
|
1054
|
+
return;
|
|
1055
|
+
if (diff === null) {
|
|
1056
|
+
setChoicePicker(null);
|
|
1057
|
+
pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (diff.stat.length === 0 && diff.untracked.length === 0) {
|
|
1061
|
+
setChoicePicker(null);
|
|
1062
|
+
pushMessage('system', 'Nothing to review — the working tree is clean.');
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
advanceQueueContinuation();
|
|
1066
|
+
enqueueQueued({
|
|
1067
|
+
kind: 'human',
|
|
1068
|
+
text: reviewPrompt(diff.stat, diff.untracked),
|
|
1069
|
+
});
|
|
1070
|
+
setChoicePicker(null);
|
|
1071
|
+
}
|
|
1072
|
+
finally {
|
|
1073
|
+
if (reviewChoiceInFlightRef.current === operation) {
|
|
1074
|
+
reviewChoiceInFlightRef.current = null;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
})();
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (picker.kind === 'review-branch') {
|
|
1081
|
+
if (reviewChoiceInFlightRef.current)
|
|
1082
|
+
return;
|
|
1083
|
+
const branch = value;
|
|
1084
|
+
const operation = {};
|
|
1085
|
+
reviewChoiceInFlightRef.current = operation;
|
|
1086
|
+
void (async () => {
|
|
1087
|
+
try {
|
|
1088
|
+
const mergeBase = await reviewMergeBase(ctx.cwd, branch);
|
|
1089
|
+
if (appLifetime.signal.aborted ||
|
|
1090
|
+
reviewChoiceInFlightRef.current !== operation ||
|
|
1091
|
+
choicePickerRef.current !== picker)
|
|
1092
|
+
return;
|
|
1093
|
+
if (!mergeBase) {
|
|
1094
|
+
setChoicePicker(null);
|
|
1095
|
+
pushMessage('system', `Could not resolve a merge base for ${branch}.`);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
advanceQueueContinuation();
|
|
1099
|
+
enqueueQueued({
|
|
1100
|
+
kind: 'human',
|
|
1101
|
+
text: baseBranchReviewPrompt(mergeBase),
|
|
1102
|
+
});
|
|
1103
|
+
setChoicePicker(null);
|
|
1104
|
+
}
|
|
1105
|
+
finally {
|
|
1106
|
+
if (reviewChoiceInFlightRef.current === operation) {
|
|
1107
|
+
reviewChoiceInFlightRef.current = null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
})();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (picker.kind === 'review-commit') {
|
|
1114
|
+
advanceQueueContinuation();
|
|
1115
|
+
enqueueQueued({
|
|
1116
|
+
kind: 'human',
|
|
1117
|
+
text: commitReviewPrompt(value.sha),
|
|
1118
|
+
});
|
|
1119
|
+
setChoicePicker(null);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
660
1122
|
setChoicePicker(null);
|
|
1123
|
+
if (picker.kind === 'command') {
|
|
1124
|
+
const command = value;
|
|
1125
|
+
if (command.problem) {
|
|
1126
|
+
pushMessage('system', `Cannot run /${command.name}: ${command.problem}`);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
commandPickerSubmitRef.current(`/${command.name}`);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (picker.kind === 'archive-conversation') {
|
|
1133
|
+
if (value === 'archive')
|
|
1134
|
+
archiveCurrentConversation();
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (picker.kind === 'export-destination') {
|
|
1138
|
+
if (value === 'clipboard') {
|
|
1139
|
+
runConversationExport({ kind: 'clipboard' });
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const sessionId = scopeRef.current?.sessionId;
|
|
1143
|
+
if (!sessionId) {
|
|
1144
|
+
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
textPromptTokenRef.current += 1;
|
|
1148
|
+
setTextPrompt({
|
|
1149
|
+
token: textPromptTokenRef.current,
|
|
1150
|
+
kind: 'export-file',
|
|
1151
|
+
title: 'Save conversation',
|
|
1152
|
+
placeholder: 'Type a Markdown filename and press Enter',
|
|
1153
|
+
emptyNotice: 'A filename is required. Press Esc to cancel.',
|
|
1154
|
+
initialValue: `namzu-conversation-${sessionId}.md`,
|
|
1155
|
+
sessionId,
|
|
1156
|
+
});
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
661
1159
|
if (picker.kind === 'permission-mode') {
|
|
662
1160
|
applyPermissionMode(value);
|
|
663
1161
|
return;
|
|
664
1162
|
}
|
|
1163
|
+
if (picker.kind === 'feedback-rating') {
|
|
1164
|
+
recordFeedback(picker.runId, picker.messageId, value);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (picker.kind === 'skill') {
|
|
1168
|
+
activateSkill(value);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (picker.kind === 'credential-logout') {
|
|
1172
|
+
removeStoredCredential(value);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
665
1175
|
applyReasoningEffort(value);
|
|
666
|
-
}, [
|
|
1176
|
+
}, [
|
|
1177
|
+
activateSkill,
|
|
1178
|
+
advanceQueueContinuation,
|
|
1179
|
+
appLifetime.signal,
|
|
1180
|
+
applyPermissionMode,
|
|
1181
|
+
applyReasoningEffort,
|
|
1182
|
+
archiveCurrentConversation,
|
|
1183
|
+
ctx.cwd,
|
|
1184
|
+
enqueueQueued,
|
|
1185
|
+
pushMessage,
|
|
1186
|
+
recordFeedback,
|
|
1187
|
+
removeStoredCredential,
|
|
1188
|
+
runConversationExport,
|
|
1189
|
+
setChoicePicker,
|
|
1190
|
+
setTextPrompt,
|
|
1191
|
+
]);
|
|
667
1192
|
const sendCopyRequest = useCallback((index) => {
|
|
668
1193
|
const picker = copyPickerRef.current;
|
|
669
1194
|
const target = picker?.targets[index];
|
|
@@ -734,14 +1259,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
734
1259
|
const finalizeMessage = useCallback((id, finalContent) => {
|
|
735
1260
|
setMessages((prev) => prev.map((m) => m.id === id ? { ...m, content: finalContent ?? m.content, pending: false } : m));
|
|
736
1261
|
}, []);
|
|
737
|
-
// Open the SDK session store
|
|
738
|
-
//
|
|
1262
|
+
// Open the SDK session store and select the durable conversation once.
|
|
1263
|
+
// Ordinary startup remains best-effort; an explicit `namzu resume <id>` is
|
|
1264
|
+
// exact and therefore refuses instead of silently widening to a fresh chat.
|
|
739
1265
|
const ensureSessions = useCallback(async () => {
|
|
740
1266
|
if (scopeRef.current)
|
|
741
1267
|
return scopeRef.current;
|
|
1268
|
+
const requestedConversationId = initialConversationIdRef.current;
|
|
742
1269
|
try {
|
|
743
1270
|
const sessions = await openSessions(ctxRef.current.cwd);
|
|
744
|
-
|
|
1271
|
+
let sessionId;
|
|
1272
|
+
if (requestedConversationId) {
|
|
1273
|
+
const restored = await loadResumableConversation(sessions, requestedConversationId);
|
|
1274
|
+
sessionId = asSessionId(requestedConversationId);
|
|
1275
|
+
modelHistoryRef.current = restored;
|
|
1276
|
+
setMessages(projectConversation(restored, nextId));
|
|
1277
|
+
setHistory(promptHistoryFromConversation(restored));
|
|
1278
|
+
const persistedOutput = latestAssistantOutput(restored);
|
|
1279
|
+
lastCompletedOutputRef.current = persistedOutput
|
|
1280
|
+
? { text: persistedOutput, provenance: 'persisted' }
|
|
1281
|
+
: null;
|
|
1282
|
+
initialConversationIdRef.current = undefined;
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
sessionId = await startConversation(sessions);
|
|
1286
|
+
}
|
|
745
1287
|
sessionsRef.current = sessions;
|
|
746
1288
|
scopeRef.current = {
|
|
747
1289
|
sessionId,
|
|
@@ -751,10 +1293,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
751
1293
|
};
|
|
752
1294
|
return scopeRef.current;
|
|
753
1295
|
}
|
|
754
|
-
catch {
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
if (requestedConversationId)
|
|
1298
|
+
throw error;
|
|
755
1299
|
return undefined;
|
|
756
1300
|
}
|
|
757
|
-
}, []);
|
|
1301
|
+
}, [nextId]);
|
|
758
1302
|
const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
|
|
759
1303
|
if (signal?.aborted)
|
|
760
1304
|
return;
|
|
@@ -765,6 +1309,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
765
1309
|
const s = await createAgentSession(prefs, detectedNow, {
|
|
766
1310
|
scope,
|
|
767
1311
|
cwd: activeCtx.cwd,
|
|
1312
|
+
enableComputerUse: true,
|
|
768
1313
|
rules: activeCtx.rules,
|
|
769
1314
|
...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
|
|
770
1315
|
...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
|
|
@@ -805,6 +1350,13 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
805
1350
|
// the file is shadowed instead of leaving it silently unused.
|
|
806
1351
|
reserved: hostCommandNames(),
|
|
807
1352
|
}));
|
|
1353
|
+
const mentionLoadOwner = {};
|
|
1354
|
+
mentionLoadOwnerRef.current = mentionLoadOwner;
|
|
1355
|
+
void listMentionableFiles(activeCtx.cwd, appLifetime.signal).then((files) => {
|
|
1356
|
+
if (!appLifetime.signal.aborted &&
|
|
1357
|
+
mentionLoadOwnerRef.current === mentionLoadOwner)
|
|
1358
|
+
setMentionCandidates(files);
|
|
1359
|
+
});
|
|
808
1360
|
setCurrentProvider(primaryProvider(prefs).id);
|
|
809
1361
|
// A picker-owned, usable provider switch is an explicit recovery from a
|
|
810
1362
|
// paused failed turn. Failed and superseded candidates return above and
|
|
@@ -852,7 +1404,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
852
1404
|
if (s.errorHint)
|
|
853
1405
|
pushMessage('system', s.errorHint);
|
|
854
1406
|
}
|
|
855
|
-
}, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
1407
|
+
}, [appLifetime.signal, advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
856
1408
|
/**
|
|
857
1409
|
* Sign in to a subscription without leaving namzu.
|
|
858
1410
|
*
|
|
@@ -1015,6 +1567,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1015
1567
|
try {
|
|
1016
1568
|
if (signal?.aborted)
|
|
1017
1569
|
return;
|
|
1570
|
+
// An exact shell resume owns conversation admission before provider
|
|
1571
|
+
// discovery or construction. A malformed/missing id must not consume
|
|
1572
|
+
// model work and then quietly continue in a fresh conversation.
|
|
1573
|
+
if (initialConversationIdRef.current) {
|
|
1574
|
+
try {
|
|
1575
|
+
await ensureSessions();
|
|
1576
|
+
}
|
|
1577
|
+
catch (error) {
|
|
1578
|
+
setPhase('unhealthy');
|
|
1579
|
+
pushMessage('system', `Could not resume ${initialConversationIdRef.current}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1018
1583
|
const probe = await probeAgentSession();
|
|
1019
1584
|
if (signal?.aborted)
|
|
1020
1585
|
return;
|
|
@@ -1102,7 +1667,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1102
1667
|
setPhase('unhealthy');
|
|
1103
1668
|
pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
|
|
1104
1669
|
}
|
|
1105
|
-
}, [appLifetime.signal, hydrateSession, pushMessage]);
|
|
1670
|
+
}, [appLifetime.signal, ensureSessions, hydrateSession, pushMessage]);
|
|
1106
1671
|
// `startOrFinishLogin` is declared above `runProbe` and calls it, so it
|
|
1107
1672
|
// reads the current one through a ref rather than closing over a stale
|
|
1108
1673
|
// binding or forcing the two into a declaration order that reads backwards.
|
|
@@ -1169,7 +1734,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1169
1734
|
// the answer is knowable. `liveRows` is the furniture beneath the transcript
|
|
1170
1735
|
// PLUS the live window above it — the window is part of the live region, and
|
|
1171
1736
|
// leaving it out would have the spacer padding room that is already taken.
|
|
1172
|
-
const spacerCandidate = phase === 'ready'
|
|
1737
|
+
const spacerCandidate = phase === 'ready' && permission === null
|
|
1173
1738
|
? bottomSpacerRows({
|
|
1174
1739
|
rows: stdout.rows,
|
|
1175
1740
|
columns: stdout.columns,
|
|
@@ -1186,11 +1751,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1186
1751
|
.slice(-MAX_LIVE_ROWS)
|
|
1187
1752
|
.map(({ id, content, meta, detail }) => ({ id, content, meta, detail })),
|
|
1188
1753
|
};
|
|
1189
|
-
const spacerRows = phase === 'ready' && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
|
|
1754
|
+
const spacerRows = phase === 'ready' && permission === null && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
|
|
1190
1755
|
? spacerLayoutRef.current.spacerRows
|
|
1191
1756
|
: spacerCandidate;
|
|
1192
1757
|
spacerLayoutRef.current =
|
|
1193
|
-
phase === 'ready' ? { ...spacerLayout, spacerRows } : null;
|
|
1758
|
+
phase === 'ready' && permission === null ? { ...spacerLayout, spacerRows } : null;
|
|
1194
1759
|
// One merged vocabulary for the session: this host's own commands plus
|
|
1195
1760
|
// whatever the kernel's registry reports. Built here so `/help`, the
|
|
1196
1761
|
// autocomplete and the dispatcher all answer from the same list — three
|
|
@@ -1198,6 +1763,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1198
1763
|
// disagree the moment a capability added a command.
|
|
1199
1764
|
const hostCommands = mergeHostCommands(kernelCommandDescriptors());
|
|
1200
1765
|
const slashCtx = {
|
|
1766
|
+
cwd: ctx.cwd,
|
|
1201
1767
|
builtins: hostCommands,
|
|
1202
1768
|
lastAssistantMessageId: () => lastAssistantMessage.current?.messageId ?? null,
|
|
1203
1769
|
// Called when `/tools` renders, not read here — the same shape, and the
|
|
@@ -1206,7 +1772,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1206
1772
|
// From the session, not re-resolved: resolving builds a provider, and a
|
|
1207
1773
|
// second one would describe a different sandbox than the run is using.
|
|
1208
1774
|
sandbox: session?.sandbox ?? null,
|
|
1209
|
-
mcp:
|
|
1775
|
+
mcp: () => session
|
|
1776
|
+
? (session.mcpStatus?.() ?? {
|
|
1777
|
+
connected: session.mcpConnected,
|
|
1778
|
+
failed: session.mcpFailed,
|
|
1779
|
+
})
|
|
1780
|
+
: null,
|
|
1210
1781
|
providerSummary: session?.providerSummary ?? null,
|
|
1211
1782
|
modelSummary: session?.modelSummary ?? null,
|
|
1212
1783
|
reasoningEffort: {
|
|
@@ -1269,10 +1840,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1269
1840
|
const resolve = permissionResolveRef.current;
|
|
1270
1841
|
permissionResolveRef.current = null;
|
|
1271
1842
|
permissionOpenedAtRef.current = null;
|
|
1843
|
+
setPermissionReviewOffset(0);
|
|
1272
1844
|
setPermission(null);
|
|
1273
1845
|
if (resolve)
|
|
1274
1846
|
resolve(decision);
|
|
1275
|
-
}, []);
|
|
1847
|
+
}, [setPermissionReviewOffset]);
|
|
1276
1848
|
/**
|
|
1277
1849
|
* Stop the running turn, and hand the screen back in a usable state.
|
|
1278
1850
|
*
|
|
@@ -1297,7 +1869,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1297
1869
|
const ac = abortRef.current;
|
|
1298
1870
|
if (!ac)
|
|
1299
1871
|
return false;
|
|
1300
|
-
ac.abort();
|
|
1872
|
+
ac.abort(new RunCancelled('user'));
|
|
1301
1873
|
const activeSessionId = scopeRef.current?.sessionId;
|
|
1302
1874
|
if (activeSessionId)
|
|
1303
1875
|
goalActivation.disarm(activeSessionId);
|
|
@@ -1390,6 +1962,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1390
1962
|
resetTranscript();
|
|
1391
1963
|
setMessages(restored);
|
|
1392
1964
|
modelHistoryRef.current = msgs;
|
|
1965
|
+
setHistory((previous) => [...previous, ...promptHistoryFromConversation(msgs)]);
|
|
1393
1966
|
const persistedOutput = latestAssistantOutput(msgs);
|
|
1394
1967
|
lastCompletedOutputRef.current = persistedOutput
|
|
1395
1968
|
? { text: persistedOutput, provenance: 'persisted' }
|
|
@@ -1486,11 +2059,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1486
2059
|
}
|
|
1487
2060
|
}, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
|
|
1488
2061
|
/**
|
|
1489
|
-
* `/title
|
|
2062
|
+
* `/rename` (and the older `/title` alias): edit or set the name this
|
|
2063
|
+
* conversation appears under.
|
|
1490
2064
|
*
|
|
1491
|
-
* A bare
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
2065
|
+
* A bare command opens a prefilled host prompt rather than printing usage or
|
|
2066
|
+
* clearing. The result never goes through the model composer, queue, or prompt
|
|
2067
|
+
* history; it is a session-store operation with the session id captured here.
|
|
1494
2068
|
*/
|
|
1495
2069
|
const doTitle = useCallback(async (title, clear) => {
|
|
1496
2070
|
const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
|
|
@@ -1502,9 +2076,16 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1502
2076
|
try {
|
|
1503
2077
|
if (!clear && title === '') {
|
|
1504
2078
|
const current = titleOf(sessions, scope.sessionId);
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
:
|
|
2079
|
+
textPromptTokenRef.current += 1;
|
|
2080
|
+
setTextPrompt({
|
|
2081
|
+
token: textPromptTokenRef.current,
|
|
2082
|
+
kind: 'conversation-title',
|
|
2083
|
+
title: current === undefined ? 'Name conversation' : 'Rename conversation',
|
|
2084
|
+
placeholder: 'Type a name and press Enter',
|
|
2085
|
+
emptyNotice: 'A conversation name cannot be empty. Press Esc to cancel.',
|
|
2086
|
+
initialValue: current ?? '',
|
|
2087
|
+
sessionId: scope.sessionId,
|
|
2088
|
+
});
|
|
1508
2089
|
return;
|
|
1509
2090
|
}
|
|
1510
2091
|
setTitle(sessions, scope.sessionId, clear ? '' : title);
|
|
@@ -1515,7 +2096,46 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1515
2096
|
catch (err) {
|
|
1516
2097
|
pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
|
|
1517
2098
|
}
|
|
1518
|
-
}, [ensureSessions, pushMessage]);
|
|
2099
|
+
}, [ensureSessions, pushMessage, setTextPrompt]);
|
|
2100
|
+
const submitTextPrompt = useCallback((value) => {
|
|
2101
|
+
const prompt = textPromptRef.current;
|
|
2102
|
+
if (!prompt)
|
|
2103
|
+
return;
|
|
2104
|
+
setTextPrompt(null);
|
|
2105
|
+
const scope = scopeRef.current;
|
|
2106
|
+
if (!scope || scope.sessionId !== prompt.sessionId) {
|
|
2107
|
+
pushMessage('system', prompt.kind === 'conversation-title'
|
|
2108
|
+
? 'The conversation changed while its name editor was open. Nothing was renamed; open /rename again.'
|
|
2109
|
+
: 'The conversation changed while its export filename editor was open. Nothing was exported; open /export again.');
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (prompt.kind === 'export-file') {
|
|
2113
|
+
runConversationExport({ kind: 'file', path: value });
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const sessions = sessionsRef.current;
|
|
2117
|
+
if (!sessions) {
|
|
2118
|
+
pushMessage('system', 'Conversation history is unavailable in this folder.');
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
try {
|
|
2122
|
+
setTitle(sessions, prompt.sessionId, value);
|
|
2123
|
+
pushMessage('system', `Named "${value.trim()}".`);
|
|
2124
|
+
}
|
|
2125
|
+
catch (error) {
|
|
2126
|
+
pushMessage('system', `Could not save the name: ${error instanceof Error ? error.message : String(error)}`);
|
|
2127
|
+
}
|
|
2128
|
+
}, [pushMessage, runConversationExport, setTextPrompt]);
|
|
2129
|
+
const cancelTextPrompt = useCallback(() => {
|
|
2130
|
+
const prompt = textPromptRef.current;
|
|
2131
|
+
// TextPrompt and App both receive the same Ink input event. Keep the
|
|
2132
|
+
// synchronous owner until every listener has seen this Esc/Ctrl+C, or App
|
|
2133
|
+
// can read the same key as an interrupt/exit after the child clears the ref.
|
|
2134
|
+
queueMicrotask(() => {
|
|
2135
|
+
if (textPromptRef.current === prompt)
|
|
2136
|
+
setTextPrompt(null);
|
|
2137
|
+
});
|
|
2138
|
+
}, [setTextPrompt]);
|
|
1519
2139
|
/**
|
|
1520
2140
|
* `/fork`: continue in a copy, leaving this conversation where it is.
|
|
1521
2141
|
*
|
|
@@ -1671,13 +2291,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
1671
2291
|
]);
|
|
1672
2292
|
// Bridge passed into session.send(): the agent calls this before a
|
|
1673
2293
|
// 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
|
-
|
|
2294
|
+
const onPermission = useCallback((req) => {
|
|
2295
|
+
const review = buildPermissionReview(req.toolCalls);
|
|
2296
|
+
if (!review.ok) {
|
|
2297
|
+
return Promise.resolve({
|
|
2298
|
+
kind: 'reject',
|
|
2299
|
+
feedback: permissionReviewRefusal(review.reason),
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
return new Promise((resolve) => {
|
|
2303
|
+
permissionResolveRef.current = resolve;
|
|
2304
|
+
permissionOpenedAtRef.current = Date.now();
|
|
2305
|
+
setPermissionReviewOffset(0);
|
|
2306
|
+
setPermission({ ...req, review: review.text });
|
|
2307
|
+
setState('awaiting-permission');
|
|
2308
|
+
sendTerminalNotification({ kind: 'approval-required' });
|
|
2309
|
+
});
|
|
2310
|
+
}, [sendTerminalNotification, setPermissionReviewOffset]);
|
|
1681
2311
|
// Render one agent event onto the transcript. Shared by the local turn loop
|
|
1682
2312
|
// and the daemon-attach poller, so both paths produce identical output.
|
|
1683
2313
|
// `st` carries the streaming-assistant bubble id + accumulated text across
|
|
@@ -2268,7 +2898,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2268
2898
|
? 'forked'
|
|
2269
2899
|
: conversationMutationRef.current === 'edit'
|
|
2270
2900
|
? 'branched for prompt editing'
|
|
2271
|
-
:
|
|
2901
|
+
: conversationMutationRef.current === 'archive'
|
|
2902
|
+
? 'archived'
|
|
2903
|
+
: 'moved to a fresh conversation';
|
|
2272
2904
|
pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
|
|
2273
2905
|
return;
|
|
2274
2906
|
}
|
|
@@ -2284,13 +2916,74 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2284
2916
|
case 'message':
|
|
2285
2917
|
pushMessage(slash.role, slash.content);
|
|
2286
2918
|
return;
|
|
2919
|
+
case 'command-picker': {
|
|
2920
|
+
if (slash.commands.length === 0) {
|
|
2921
|
+
pushMessage('system', 'No slash commands are available in this session.');
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
setSelectedChoice(0);
|
|
2925
|
+
setChoicePicker({
|
|
2926
|
+
kind: 'command',
|
|
2927
|
+
title: 'Choose a command',
|
|
2928
|
+
notice: 'Enter runs the selected command; Esc returns to the composer.',
|
|
2929
|
+
values: slash.commands,
|
|
2930
|
+
options: slash.commands.map((command) => ({
|
|
2931
|
+
label: `/${command.name}`,
|
|
2932
|
+
description: command.problem
|
|
2933
|
+
? `Unavailable: ${command.problem}`
|
|
2934
|
+
: command.description,
|
|
2935
|
+
})),
|
|
2936
|
+
windowSize: suggestionWindowSize(stdout.rows),
|
|
2937
|
+
});
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2287
2940
|
case 'clear-screen':
|
|
2288
2941
|
setMessages([]);
|
|
2289
2942
|
resetTranscript();
|
|
2290
2943
|
return;
|
|
2944
|
+
case 'composer-draft':
|
|
2945
|
+
composerDraftTokenRef.current += 1;
|
|
2946
|
+
setComposerDraft({
|
|
2947
|
+
token: composerDraftTokenRef.current,
|
|
2948
|
+
text: slash.text,
|
|
2949
|
+
});
|
|
2950
|
+
return;
|
|
2291
2951
|
case 'new-conversation':
|
|
2292
2952
|
void startFreshConversation(slash.clearScreen);
|
|
2293
2953
|
return;
|
|
2954
|
+
case 'archive-picker': {
|
|
2955
|
+
if (!sessionsRef.current || !scopeRef.current?.sessionId) {
|
|
2956
|
+
pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
if (state !== 'idle' ||
|
|
2960
|
+
abortRef.current !== null ||
|
|
2961
|
+
hasUnsettledTurn() ||
|
|
2962
|
+
queuedRef.current.length > 0 ||
|
|
2963
|
+
compactingRef.current ||
|
|
2964
|
+
exportingRef.current) {
|
|
2965
|
+
pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
setSelectedChoice(0);
|
|
2969
|
+
setChoicePicker({
|
|
2970
|
+
kind: 'archive-conversation',
|
|
2971
|
+
title: 'Archive this conversation?',
|
|
2972
|
+
notice: 'History remains on disk for inspection, but the conversation becomes read-only and disappears from /resume.',
|
|
2973
|
+
values: ['cancel', 'archive'],
|
|
2974
|
+
options: [
|
|
2975
|
+
{
|
|
2976
|
+
label: 'No, keep conversation',
|
|
2977
|
+
description: 'Return without changing durable history',
|
|
2978
|
+
},
|
|
2979
|
+
{
|
|
2980
|
+
label: 'Yes, archive and exit',
|
|
2981
|
+
description: 'Make this conversation read-only and leave Namzu',
|
|
2982
|
+
},
|
|
2983
|
+
],
|
|
2984
|
+
});
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2294
2987
|
case 'exit':
|
|
2295
2988
|
exitWithSummary();
|
|
2296
2989
|
return;
|
|
@@ -2377,16 +3070,35 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2377
3070
|
void startOrFinishLogin(slash.pasted);
|
|
2378
3071
|
return;
|
|
2379
3072
|
case 'logout': {
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
clearAllStoredCredentials();
|
|
3073
|
+
if (slash.target) {
|
|
3074
|
+
removeStoredCredential(slash.target);
|
|
3075
|
+
return;
|
|
2384
3076
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
3077
|
+
const choices = [
|
|
3078
|
+
...(readStoredSubscriptionCredential() ? ['anthropic'] : []),
|
|
3079
|
+
...(readStoredCodexCredential() ? ['codex'] : []),
|
|
3080
|
+
];
|
|
3081
|
+
if (choices.length === 0) {
|
|
3082
|
+
removeStoredCredential('all');
|
|
2387
3083
|
return;
|
|
2388
3084
|
}
|
|
2389
|
-
|
|
3085
|
+
if (choices.length === 1) {
|
|
3086
|
+
removeStoredCredential(choices[0]);
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
setSelectedChoice(0);
|
|
3090
|
+
setChoicePicker({
|
|
3091
|
+
kind: 'credential-logout',
|
|
3092
|
+
title: 'Choose a stored subscription to remove',
|
|
3093
|
+
notice: 'Only credentials created by Namzu are listed. Device sessions owned by other tools are left alone.',
|
|
3094
|
+
values: choices,
|
|
3095
|
+
options: choices.map((choice) => ({
|
|
3096
|
+
label: choice === 'anthropic' ? 'Claude' : 'Codex',
|
|
3097
|
+
description: choice === 'anthropic'
|
|
3098
|
+
? 'Remove only Namzu’s Claude subscription.'
|
|
3099
|
+
: 'Remove only Namzu’s Codex subscription.',
|
|
3100
|
+
})),
|
|
3101
|
+
});
|
|
2390
3102
|
return;
|
|
2391
3103
|
}
|
|
2392
3104
|
case 'remember':
|
|
@@ -2424,23 +3136,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2424
3136
|
pushMessage('system', `Skills (● active):\n ${lines.join('\n ')}`);
|
|
2425
3137
|
return;
|
|
2426
3138
|
}
|
|
2427
|
-
case '
|
|
2428
|
-
const
|
|
2429
|
-
if (
|
|
2430
|
-
pushMessage('system',
|
|
3139
|
+
case 'skill-picker': {
|
|
3140
|
+
const skills = discoverSkills({ cwd: ctx.cwd });
|
|
3141
|
+
if (skills.length === 0) {
|
|
3142
|
+
pushMessage('system', 'No skills found. Add one at ~/.namzu/skills/<name>/SKILL.md or ./skills/<name>/SKILL.md.');
|
|
2431
3143
|
return;
|
|
2432
3144
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
3145
|
+
const activeNames = new Set(activeSkills.map((skill) => skill.name));
|
|
3146
|
+
setSelectedChoice(0);
|
|
3147
|
+
setChoicePicker({
|
|
3148
|
+
kind: 'skill',
|
|
3149
|
+
title: 'Activate a skill',
|
|
3150
|
+
values: skills.map((skill) => skill.name),
|
|
3151
|
+
options: skills.map((skill) => ({
|
|
3152
|
+
label: skill.name,
|
|
3153
|
+
description: skill.problem
|
|
3154
|
+
? `Unavailable: ${skill.problem}`
|
|
3155
|
+
: `${skill.description} · ${skill.source}`,
|
|
3156
|
+
current: activeNames.has(skill.name),
|
|
3157
|
+
})),
|
|
3158
|
+
});
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
case 'load-skill': {
|
|
3162
|
+
activateSkill(slash.name);
|
|
2444
3163
|
return;
|
|
2445
3164
|
}
|
|
2446
3165
|
case 'resume':
|
|
@@ -2586,67 +3305,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2586
3305
|
})();
|
|
2587
3306
|
return;
|
|
2588
3307
|
}
|
|
2589
|
-
case 'feedback': {
|
|
3308
|
+
case 'feedback-picker': {
|
|
2590
3309
|
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) {
|
|
3310
|
+
if (!target || target.messageId !== slash.messageId) {
|
|
2595
3311
|
pushMessage('system', 'Nothing to rate yet.');
|
|
2596
3312
|
return;
|
|
2597
3313
|
}
|
|
2598
|
-
const
|
|
2599
|
-
|
|
2600
|
-
|
|
3314
|
+
const values = ['good', 'bad'];
|
|
3315
|
+
setSelectedChoice(0);
|
|
3316
|
+
setChoicePicker({
|
|
3317
|
+
kind: 'feedback-rating',
|
|
3318
|
+
title: 'Rate the latest answer',
|
|
3319
|
+
runId: target.runId,
|
|
3320
|
+
messageId: target.messageId,
|
|
3321
|
+
values,
|
|
3322
|
+
options: [
|
|
3323
|
+
{
|
|
3324
|
+
label: 'good',
|
|
3325
|
+
description: 'The answer was useful and correct.',
|
|
3326
|
+
},
|
|
3327
|
+
{ label: 'bad', description: 'The answer needs improvement.' },
|
|
3328
|
+
],
|
|
2601
3329
|
});
|
|
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
|
-
})();
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
case 'feedback': {
|
|
3333
|
+
const target = lastAssistantMessage.current;
|
|
3334
|
+
if (!target || target.messageId !== slash.messageId) {
|
|
3335
|
+
pushMessage('system', 'Nothing to rate yet.');
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
recordFeedback(target.runId, slash.messageId, slash.rating, slash.note);
|
|
2627
3339
|
return;
|
|
2628
3340
|
}
|
|
2629
3341
|
case 'review': {
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3342
|
+
if (slash.instructions) {
|
|
3343
|
+
// Custom instructions are already operator-authored model input.
|
|
3344
|
+
// They take the ordinary FIFO below rather than a second send path.
|
|
3345
|
+
outgoing = slash.instructions;
|
|
3346
|
+
break;
|
|
3347
|
+
}
|
|
3348
|
+
const values = [
|
|
3349
|
+
'base-branch',
|
|
3350
|
+
'uncommitted',
|
|
3351
|
+
'commit',
|
|
3352
|
+
'custom',
|
|
3353
|
+
];
|
|
3354
|
+
setSelectedChoice(0);
|
|
3355
|
+
setChoicePicker({
|
|
3356
|
+
kind: 'review-preset',
|
|
3357
|
+
title: 'Select a review preset',
|
|
3358
|
+
values,
|
|
3359
|
+
options: [
|
|
3360
|
+
{
|
|
3361
|
+
label: 'Base branch',
|
|
3362
|
+
description: 'Review the current work as a branch comparison.',
|
|
3363
|
+
},
|
|
3364
|
+
{
|
|
3365
|
+
label: 'Uncommitted',
|
|
3366
|
+
description: 'Review staged, unstaged, and untracked changes.',
|
|
3367
|
+
},
|
|
3368
|
+
{
|
|
3369
|
+
label: 'Commit',
|
|
3370
|
+
description: 'Review one recent commit.',
|
|
3371
|
+
},
|
|
3372
|
+
{
|
|
3373
|
+
label: 'Custom',
|
|
3374
|
+
description: 'Write exact review instructions in the composer.',
|
|
3375
|
+
},
|
|
3376
|
+
],
|
|
3377
|
+
});
|
|
2650
3378
|
return;
|
|
2651
3379
|
}
|
|
2652
3380
|
case 'diff': {
|
|
@@ -2771,45 +3499,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2771
3499
|
: 'Raw output mode off — rich transcript rendering restored.');
|
|
2772
3500
|
return;
|
|
2773
3501
|
}
|
|
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.');
|
|
3502
|
+
case 'export-picker': {
|
|
3503
|
+
if (!stableExportSource())
|
|
2779
3504
|
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
|
-
})();
|
|
3505
|
+
setSelectedChoice(0);
|
|
3506
|
+
setChoicePicker({
|
|
3507
|
+
kind: 'export-destination',
|
|
3508
|
+
title: 'Export conversation',
|
|
3509
|
+
notice: 'Save the complete verified conversation as Markdown.',
|
|
3510
|
+
values: ['clipboard', 'file'],
|
|
3511
|
+
options: [
|
|
3512
|
+
{
|
|
3513
|
+
label: 'Copy to clipboard',
|
|
3514
|
+
description: 'Send the verified Markdown transcript to the terminal clipboard',
|
|
3515
|
+
},
|
|
3516
|
+
{
|
|
3517
|
+
label: 'Save to file',
|
|
3518
|
+
description: 'Choose a Markdown filename; existing files are never overwritten',
|
|
3519
|
+
},
|
|
3520
|
+
],
|
|
3521
|
+
});
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
case 'export': {
|
|
3525
|
+
runConversationExport({ kind: 'file', path: slash.path });
|
|
2813
3526
|
return;
|
|
2814
3527
|
}
|
|
2815
3528
|
default: {
|
|
@@ -2865,14 +3578,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2865
3578
|
nextId,
|
|
2866
3579
|
pushMessage,
|
|
2867
3580
|
rawOutput,
|
|
3581
|
+
removeStoredCredential,
|
|
2868
3582
|
resetTranscript,
|
|
3583
|
+
runConversationExport,
|
|
2869
3584
|
setChoicePicker,
|
|
2870
3585
|
setCopyPicker,
|
|
2871
3586
|
setReasoningEffort,
|
|
2872
3587
|
slashCtx,
|
|
3588
|
+
stableExportSource,
|
|
2873
3589
|
startFreshConversation,
|
|
2874
3590
|
state,
|
|
3591
|
+
stdout.rows,
|
|
2875
3592
|
]);
|
|
3593
|
+
// `applyChoiceSelection` is declared before `handleSubmit` because the
|
|
3594
|
+
// picker is also used by review/export flows. Keep only this dispatch hop in
|
|
3595
|
+
// a ref so selecting a help row re-enters the one ordinary slash-command
|
|
3596
|
+
// path instead of growing a second command executor.
|
|
3597
|
+
commandPickerSubmitRef.current = handleSubmit;
|
|
2876
3598
|
// Keep the footer on the same durable goal record the driver mutates. A ref
|
|
2877
3599
|
// alone cannot repaint React, so every goal mutation bumps goalDriveVersion;
|
|
2878
3600
|
// generation/session checks stop a late disk read labelling a resumed chat.
|
|
@@ -2925,6 +3647,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2925
3647
|
conversationMutationRef.current ||
|
|
2926
3648
|
exportingRef.current ||
|
|
2927
3649
|
compactingRef.current ||
|
|
3650
|
+
textPromptRef.current ||
|
|
2928
3651
|
choicePickerRef.current ||
|
|
2929
3652
|
copyPickerRef.current ||
|
|
2930
3653
|
goalCommandInFlightRef.current) {
|
|
@@ -2951,6 +3674,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
2951
3674
|
conversationMutationRef.current ||
|
|
2952
3675
|
exportingRef.current ||
|
|
2953
3676
|
compactingRef.current ||
|
|
3677
|
+
textPromptRef.current ||
|
|
2954
3678
|
choicePickerRef.current ||
|
|
2955
3679
|
copyPickerRef.current ||
|
|
2956
3680
|
goalCommandInFlightRef.current ||
|
|
@@ -3010,6 +3734,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3010
3734
|
conversationMutation,
|
|
3011
3735
|
session,
|
|
3012
3736
|
state,
|
|
3737
|
+
textPrompt,
|
|
3013
3738
|
choicePicker,
|
|
3014
3739
|
copyPicker,
|
|
3015
3740
|
wakeGoalDriver,
|
|
@@ -3026,6 +3751,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3026
3751
|
conversationMutationRef.current ||
|
|
3027
3752
|
exportingRef.current ||
|
|
3028
3753
|
compactingRef.current ||
|
|
3754
|
+
textPromptRef.current ||
|
|
3029
3755
|
choicePickerRef.current ||
|
|
3030
3756
|
copyPickerRef.current ||
|
|
3031
3757
|
goalCommandInFlightRef.current)
|
|
@@ -3042,6 +3768,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3042
3768
|
phase,
|
|
3043
3769
|
queued,
|
|
3044
3770
|
queuePause,
|
|
3771
|
+
textPrompt,
|
|
3045
3772
|
choicePicker,
|
|
3046
3773
|
copyPicker,
|
|
3047
3774
|
dequeueQueued,
|
|
@@ -3195,16 +3922,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3195
3922
|
setPhase('ready');
|
|
3196
3923
|
return;
|
|
3197
3924
|
}
|
|
3925
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
3926
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, key.home
|
|
3927
|
+
? 'first'
|
|
3928
|
+
: key.end
|
|
3929
|
+
? 'last'
|
|
3930
|
+
: key.pageUp
|
|
3931
|
+
? 'previous-page'
|
|
3932
|
+
: 'next-page'));
|
|
3933
|
+
return;
|
|
3934
|
+
}
|
|
3198
3935
|
if (key.escape || key.leftArrow || key.upArrow) {
|
|
3199
|
-
setSelectedEdit((index) =>
|
|
3936
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, 'previous'));
|
|
3200
3937
|
return;
|
|
3201
3938
|
}
|
|
3202
3939
|
if (key.rightArrow || key.downArrow) {
|
|
3203
|
-
setSelectedEdit((index) =>
|
|
3940
|
+
setSelectedEdit((index) => moveSelection(index, editList.length, 'next'));
|
|
3204
3941
|
return;
|
|
3205
3942
|
}
|
|
3206
3943
|
if (key.return) {
|
|
3207
|
-
const target = editList[
|
|
3944
|
+
const target = editList[selectedEditRef.current];
|
|
3208
3945
|
if (target)
|
|
3209
3946
|
void confirmPromptEdit(target);
|
|
3210
3947
|
}
|
|
@@ -3218,12 +3955,21 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3218
3955
|
// replaced regardless.
|
|
3219
3956
|
if (resumeCommittedRef.current)
|
|
3220
3957
|
return;
|
|
3221
|
-
if (key.
|
|
3222
|
-
setSelectedResume((
|
|
3958
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
3959
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, key.home
|
|
3960
|
+
? 'first'
|
|
3961
|
+
: key.end
|
|
3962
|
+
? 'last'
|
|
3963
|
+
: key.pageUp
|
|
3964
|
+
? 'previous-page'
|
|
3965
|
+
: 'next-page'));
|
|
3966
|
+
}
|
|
3967
|
+
else if (key.upArrow)
|
|
3968
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, 'previous'));
|
|
3223
3969
|
else if (key.downArrow)
|
|
3224
|
-
setSelectedResume((
|
|
3970
|
+
setSelectedResume((index) => moveSelection(index, resumeList.length, 'next'));
|
|
3225
3971
|
else if (key.return) {
|
|
3226
|
-
const conv = resumeList[
|
|
3972
|
+
const conv = resumeList[selectedResumeRef.current];
|
|
3227
3973
|
if (conv)
|
|
3228
3974
|
void resumeConversation(conv);
|
|
3229
3975
|
}
|
|
@@ -3260,12 +4006,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3260
4006
|
}
|
|
3261
4007
|
// A pending permission prompt owns the keyboard: y/n/a decide it.
|
|
3262
4008
|
if (permissionResolveRef.current) {
|
|
4009
|
+
if (permission &&
|
|
4010
|
+
(key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end)) {
|
|
4011
|
+
const count = permissionReviewRows(permission.review, stdout.columns).length;
|
|
4012
|
+
const maxOffset = Math.max(0, count - PERMISSION_REVIEW_PAGE_ROWS);
|
|
4013
|
+
const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
|
|
4014
|
+
const next = key.home
|
|
4015
|
+
? 0
|
|
4016
|
+
: key.end
|
|
4017
|
+
? maxOffset
|
|
4018
|
+
: key.pageUp
|
|
4019
|
+
? Math.max(0, current - PERMISSION_REVIEW_PAGE_ROWS)
|
|
4020
|
+
: key.pageDown
|
|
4021
|
+
? Math.min(maxOffset, current + PERMISSION_REVIEW_PAGE_ROWS)
|
|
4022
|
+
: key.upArrow
|
|
4023
|
+
? Math.max(0, current - 1)
|
|
4024
|
+
: Math.min(maxOffset, current + 1);
|
|
4025
|
+
setPermissionReviewOffset(next);
|
|
4026
|
+
return;
|
|
4027
|
+
}
|
|
3263
4028
|
const ch = input.toLowerCase();
|
|
3264
4029
|
// Refusing is never deferred or gated — it is the direction a
|
|
3265
4030
|
// mistake is recoverable in, so it answers on the first press.
|
|
3266
4031
|
if (key.ctrl && input === 'c') {
|
|
3267
4032
|
resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
|
|
3268
|
-
abortRef.current?.abort();
|
|
4033
|
+
abortRef.current?.abort(new RunCancelled('user'));
|
|
3269
4034
|
return;
|
|
3270
4035
|
}
|
|
3271
4036
|
if (ch === 'n' || key.escape) {
|
|
@@ -3286,6 +4051,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3286
4051
|
resolvePermission({ kind: 'approve-all' });
|
|
3287
4052
|
return;
|
|
3288
4053
|
}
|
|
4054
|
+
// A host-owned text prompt has its own input hook. This synchronous ref
|
|
4055
|
+
// claims the interval before React paints it too, so App cannot read a
|
|
4056
|
+
// key repeat as an interrupt, exit ladder, or finite-menu choice.
|
|
4057
|
+
if (textPromptRef.current)
|
|
4058
|
+
return;
|
|
3289
4059
|
// Finite slash-command choices own the keyboard below a live permission
|
|
3290
4060
|
// request and above the ordinary composer. Their values are a captured
|
|
3291
4061
|
// menu, so a provider/session change cannot silently reinterpret Enter.
|
|
@@ -3299,19 +4069,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3299
4069
|
return;
|
|
3300
4070
|
const options = picker.options;
|
|
3301
4071
|
if (key.escape || (key.ctrl && input === 'c')) {
|
|
4072
|
+
reviewChoiceInFlightRef.current = null;
|
|
3302
4073
|
setChoicePicker(null);
|
|
3303
4074
|
return;
|
|
3304
4075
|
}
|
|
4076
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
4077
|
+
const pageSize = picker.kind === 'command' ? picker.windowSize : undefined;
|
|
4078
|
+
setSelectedChoice((index) => moveSelection(index, options.length, key.home
|
|
4079
|
+
? 'first'
|
|
4080
|
+
: key.end
|
|
4081
|
+
? 'last'
|
|
4082
|
+
: key.pageUp
|
|
4083
|
+
? 'previous-page'
|
|
4084
|
+
: 'next-page', pageSize));
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
3305
4087
|
if (key.upArrow) {
|
|
3306
|
-
setSelectedChoice((index) =>
|
|
4088
|
+
setSelectedChoice((index) => moveSelection(index, options.length, 'previous'));
|
|
3307
4089
|
return;
|
|
3308
4090
|
}
|
|
3309
4091
|
if (key.downArrow) {
|
|
3310
|
-
setSelectedChoice((index) =>
|
|
4092
|
+
setSelectedChoice((index) => moveSelection(index, options.length, 'next'));
|
|
3311
4093
|
return;
|
|
3312
4094
|
}
|
|
3313
4095
|
if (key.return) {
|
|
3314
|
-
applyChoiceSelection(
|
|
4096
|
+
applyChoiceSelection(selectedChoiceRef.current);
|
|
3315
4097
|
return;
|
|
3316
4098
|
}
|
|
3317
4099
|
if (/^[1-9]$/.test(input)) {
|
|
@@ -3330,16 +4112,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3330
4112
|
setCopyPicker(null);
|
|
3331
4113
|
return;
|
|
3332
4114
|
}
|
|
4115
|
+
if (key.home || key.end || key.pageUp || key.pageDown) {
|
|
4116
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, key.home
|
|
4117
|
+
? 'first'
|
|
4118
|
+
: key.end
|
|
4119
|
+
? 'last'
|
|
4120
|
+
: key.pageUp
|
|
4121
|
+
? 'previous-page'
|
|
4122
|
+
: 'next-page'));
|
|
4123
|
+
return;
|
|
4124
|
+
}
|
|
3333
4125
|
if (key.upArrow) {
|
|
3334
|
-
setSelectedCopy((index) =>
|
|
4126
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, 'previous'));
|
|
3335
4127
|
return;
|
|
3336
4128
|
}
|
|
3337
4129
|
if (key.downArrow) {
|
|
3338
|
-
setSelectedCopy((index) =>
|
|
4130
|
+
setSelectedCopy((index) => moveSelection(index, targets.length, 'next'));
|
|
3339
4131
|
return;
|
|
3340
4132
|
}
|
|
3341
4133
|
if (key.return) {
|
|
3342
|
-
sendCopyRequest(
|
|
4134
|
+
sendCopyRequest(selectedCopyRef.current);
|
|
3343
4135
|
return;
|
|
3344
4136
|
}
|
|
3345
4137
|
if (/^[1-9]$/.test(input)) {
|
|
@@ -3349,6 +4141,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3349
4141
|
}
|
|
3350
4142
|
return;
|
|
3351
4143
|
}
|
|
4144
|
+
// Terminal-native display clear. It deliberately shares `/clear-screen`'s
|
|
4145
|
+
// view-only boundary: model history, durable history and the active
|
|
4146
|
+
// conversation remain untouched. A live turn is refused instead of
|
|
4147
|
+
// erasing the only visible account of work that is still changing.
|
|
4148
|
+
if (key.ctrl && input === 'l') {
|
|
4149
|
+
if (state !== 'idle' || abortRef.current || hasUnsettledTurn()) {
|
|
4150
|
+
pushMessage('system', 'Ctrl+L is unavailable while a turn is still in progress.');
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
setMessages([]);
|
|
4154
|
+
resetTranscript();
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
3352
4157
|
// Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
|
|
3353
4158
|
// the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
|
|
3354
4159
|
if (key.escape && abortRef.current) {
|
|
@@ -3421,33 +4226,41 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3421
4226
|
// Active on every phase. It was gated off during the picker, which is what
|
|
3422
4227
|
// left that screen without a usable exit; the picker branch above returns
|
|
3423
4228
|
// immediately, so the Picker still owns everything except Ctrl+C.
|
|
3424
|
-
{ isActive:
|
|
4229
|
+
{ isActive: externalEditorRequest === null });
|
|
3425
4230
|
// Background is left natural — we inherit the terminal's own background
|
|
3426
4231
|
// and only theme the foreground. Forcing
|
|
3427
4232
|
// 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,
|
|
4233
|
+
return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, reviewOffset: permissionReviewOffset, columns: stdout.columns })) : null, textPrompt ? (_jsx(TextPrompt, { title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null, onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
|
|
3429
4234
|
phase === 'ready' &&
|
|
3430
4235
|
conversationMutation === null &&
|
|
4236
|
+
textPrompt === null &&
|
|
3431
4237
|
choicePicker === null &&
|
|
3432
4238
|
copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
|
|
3433
4239
|
permission === null &&
|
|
4240
|
+
textPrompt === null &&
|
|
3434
4241
|
choicePicker === null &&
|
|
3435
4242
|
copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.accent.user, children: ["\u21B3 ", pendingSteers.length, " message", pendingSteers.length > 1 ? 's' : '', " steering the active turn \u2014 waiting for its next response boundary"] }) })) : null, queued.length > 0 &&
|
|
3436
4243
|
permission === null &&
|
|
4244
|
+
textPrompt === null &&
|
|
3437
4245
|
choicePicker === null &&
|
|
3438
4246
|
copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
|
|
3439
4247
|
? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
|
|
3440
4248
|
: 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
|
|
3441
4249
|
state === 'awaiting-permission' ||
|
|
3442
4250
|
compacting ||
|
|
3443
|
-
conversationMutation !== null
|
|
4251
|
+
conversationMutation !== null ||
|
|
4252
|
+
externalEditorRequest !== null, hidden: permission !== null ||
|
|
4253
|
+
textPrompt !== null ||
|
|
4254
|
+
choicePicker !== null ||
|
|
4255
|
+
copyPicker !== null,
|
|
3444
4256
|
// A turn is running, so Esc is the interrupt and not
|
|
3445
4257
|
// 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' &&
|
|
4258
|
+
escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onStepReasoningEffort: stepReasoningEffort, onExternalEdit: requestExternalEditor, onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), userCommands: userCommands, mentionCandidates: mentionCandidates, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: phase === 'ready' &&
|
|
3447
4259
|
state === 'idle' &&
|
|
3448
4260
|
conversationMutation === null &&
|
|
3449
4261
|
!compacting &&
|
|
3450
4262
|
permission === null &&
|
|
4263
|
+
textPrompt === null &&
|
|
3451
4264
|
choicePicker === null &&
|
|
3452
4265
|
copyPicker === null
|
|
3453
4266
|
? goalStatusLabel(goalStatus)
|
|
@@ -3455,19 +4268,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
|
|
|
3455
4268
|
? 'forking conversation — input is paused'
|
|
3456
4269
|
: conversationMutation === 'edit'
|
|
3457
4270
|
? 'branching before prompt — input is paused'
|
|
3458
|
-
: conversationMutation === '
|
|
3459
|
-
? '
|
|
3460
|
-
:
|
|
3461
|
-
? '
|
|
3462
|
-
:
|
|
3463
|
-
?
|
|
3464
|
-
:
|
|
3465
|
-
?
|
|
3466
|
-
:
|
|
3467
|
-
? '
|
|
3468
|
-
:
|
|
3469
|
-
?
|
|
3470
|
-
:
|
|
4271
|
+
: conversationMutation === 'archive'
|
|
4272
|
+
? 'archiving conversation — input is paused'
|
|
4273
|
+
: conversationMutation === 'new'
|
|
4274
|
+
? 'starting a fresh conversation — input is paused'
|
|
4275
|
+
: compacting
|
|
4276
|
+
? 'compacting conversation — input is paused'
|
|
4277
|
+
: permission
|
|
4278
|
+
? hintForPhase(phase, state, session?.hasProvider === true)
|
|
4279
|
+
: textPrompt
|
|
4280
|
+
? 'name editor open — enter save · esc cancel'
|
|
4281
|
+
: choicePicker
|
|
4282
|
+
? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
|
|
4283
|
+
: copyPicker
|
|
4284
|
+
? 'copy target open — ↑↓ / 1–9 select · esc cancel'
|
|
4285
|
+
: goalStatus && phase === 'ready' && state === 'idle'
|
|
4286
|
+
? undefined
|
|
4287
|
+
: hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
|
|
3471
4288
|
}
|
|
3472
4289
|
function Banner({ version, session, permissionMode, cwd, }) {
|
|
3473
4290
|
const cols = process.stdout.columns ?? 80;
|