@namzu/cli 12.1.0 → 13.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 +25 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +119 -60
- package/dist/cli.js.map +1 -1
- package/dist/commands/acp.d.ts +24 -8
- package/dist/commands/acp.d.ts.map +1 -1
- package/dist/commands/acp.js +221 -80
- package/dist/commands/acp.js.map +1 -1
- package/dist/commands/drain.d.ts.map +1 -1
- package/dist/commands/drain.js +45 -9
- package/dist/commands/drain.js.map +1 -1
- package/dist/commands/prior-messages.d.ts +11 -0
- package/dist/commands/prior-messages.d.ts.map +1 -0
- package/dist/commands/prior-messages.js +280 -0
- package/dist/commands/prior-messages.js.map +1 -0
- package/dist/commands/resume.d.ts.map +1 -1
- package/dist/commands/resume.js +21 -19
- package/dist/commands/resume.js.map +1 -1
- package/dist/commands/run-stream.d.ts +5 -3
- package/dist/commands/run-stream.d.ts.map +1 -1
- package/dist/commands/run-stream.js +66 -62
- package/dist/commands/run-stream.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +50 -37
- package/dist/commands/run.js.map +1 -1
- package/dist/config/debug.d.ts +55 -0
- package/dist/config/debug.d.ts.map +1 -0
- package/dist/config/debug.js +126 -0
- package/dist/config/debug.js.map +1 -0
- package/dist/config/load.d.ts +88 -4
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +386 -56
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +87 -5
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +5 -5
- package/dist/config/schema.js.map +1 -1
- package/dist/config/trusted-project-context.d.ts +11 -0
- package/dist/config/trusted-project-context.d.ts.map +1 -0
- package/dist/config/trusted-project-context.js +28 -0
- package/dist/config/trusted-project-context.js.map +1 -0
- package/dist/context/project-path.d.ts +3 -0
- package/dist/context/project-path.d.ts.map +1 -0
- package/dist/context/project-path.js +16 -0
- package/dist/context/project-path.js.map +1 -0
- package/dist/context/project-tracker.d.ts +30 -0
- package/dist/context/project-tracker.d.ts.map +1 -0
- package/dist/context/project-tracker.js +217 -0
- package/dist/context/project-tracker.js.map +1 -0
- package/dist/context/sandbox.d.ts +29 -9
- package/dist/context/sandbox.d.ts.map +1 -1
- package/dist/context/sandbox.js +8 -0
- package/dist/context/sandbox.js.map +1 -1
- package/dist/exit-codes.d.ts +5 -4
- package/dist/exit-codes.d.ts.map +1 -1
- package/dist/exit-codes.js +5 -4
- package/dist/exit-codes.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/clipboard/text.d.ts +43 -0
- package/dist/integrations/clipboard/text.d.ts.map +1 -0
- package/dist/integrations/clipboard/text.js +43 -0
- package/dist/integrations/clipboard/text.js.map +1 -0
- package/dist/integrations/mcp/servers.d.ts +9 -0
- package/dist/integrations/mcp/servers.d.ts.map +1 -1
- package/dist/integrations/mcp/servers.js +11 -4
- package/dist/integrations/mcp/servers.js.map +1 -1
- package/dist/integrations/notifications/terminal.d.ts +32 -0
- package/dist/integrations/notifications/terminal.d.ts.map +1 -0
- package/dist/integrations/notifications/terminal.js +46 -0
- package/dist/integrations/notifications/terminal.js.map +1 -0
- package/dist/integrations/plugins/runtime.d.ts +17 -0
- package/dist/integrations/plugins/runtime.d.ts.map +1 -0
- package/dist/integrations/plugins/runtime.js +100 -0
- package/dist/integrations/plugins/runtime.js.map +1 -0
- package/dist/integrations/providers/credential-store.d.ts +17 -0
- package/dist/integrations/providers/credential-store.d.ts.map +1 -1
- package/dist/integrations/providers/credential-store.js +114 -2
- package/dist/integrations/providers/credential-store.js.map +1 -1
- package/dist/integrations/providers/index.d.ts +2 -2
- package/dist/integrations/providers/index.d.ts.map +1 -1
- package/dist/integrations/providers/index.js +2 -2
- package/dist/integrations/providers/index.js.map +1 -1
- package/dist/integrations/providers/keychain.d.ts +0 -13
- package/dist/integrations/providers/keychain.d.ts.map +1 -1
- package/dist/integrations/providers/keychain.js +0 -76
- package/dist/integrations/providers/keychain.js.map +1 -1
- package/dist/integrations/providers/oauth.d.ts +45 -16
- package/dist/integrations/providers/oauth.d.ts.map +1 -1
- package/dist/integrations/providers/oauth.js +170 -43
- package/dist/integrations/providers/oauth.js.map +1 -1
- package/dist/integrations/providers/subscription-login.d.ts +2 -0
- package/dist/integrations/providers/subscription-login.d.ts.map +1 -1
- package/dist/integrations/providers/subscription-login.js +117 -14
- package/dist/integrations/providers/subscription-login.js.map +1 -1
- package/dist/integrations/sessions/store.d.ts +111 -1
- package/dist/integrations/sessions/store.d.ts.map +1 -1
- package/dist/integrations/sessions/store.js +400 -7
- package/dist/integrations/sessions/store.js.map +1 -1
- package/dist/integrations/sessions/transcript-export.d.ts +25 -0
- package/dist/integrations/sessions/transcript-export.d.ts.map +1 -0
- package/dist/integrations/sessions/transcript-export.js +441 -0
- package/dist/integrations/sessions/transcript-export.js.map +1 -0
- package/dist/integrations/sessions/turn-evidence.d.ts +138 -0
- package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -0
- package/dist/integrations/sessions/turn-evidence.js +506 -0
- package/dist/integrations/sessions/turn-evidence.js.map +1 -0
- package/dist/integrations/subagents/runtime.d.ts +7 -12
- package/dist/integrations/subagents/runtime.d.ts.map +1 -1
- package/dist/integrations/subagents/runtime.js +19 -10
- package/dist/integrations/subagents/runtime.js.map +1 -1
- package/dist/integrations/trust/store.d.ts.map +1 -1
- package/dist/integrations/trust/store.js +27 -13
- package/dist/integrations/trust/store.js.map +1 -1
- package/dist/permissions/canonical-project.d.ts +9 -0
- package/dist/permissions/canonical-project.d.ts.map +1 -0
- package/dist/permissions/canonical-project.js +13 -0
- package/dist/permissions/canonical-project.js.map +1 -0
- package/dist/permissions/checks.d.ts +65 -0
- package/dist/permissions/checks.d.ts.map +1 -0
- package/dist/permissions/checks.js +122 -0
- package/dist/permissions/checks.js.map +1 -0
- package/dist/permissions/headless-trust.d.ts +9 -5
- package/dist/permissions/headless-trust.d.ts.map +1 -1
- package/dist/permissions/headless-trust.js +18 -4
- package/dist/permissions/headless-trust.js.map +1 -1
- package/dist/permissions/rules.d.ts +16 -7
- package/dist/permissions/rules.d.ts.map +1 -1
- package/dist/permissions/rules.js +78 -13
- package/dist/permissions/rules.js.map +1 -1
- package/dist/tui/App.d.ts +5 -2
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +1555 -143
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/Composer.d.ts +14 -3
- package/dist/tui/Composer.d.ts.map +1 -1
- package/dist/tui/Composer.js +53 -15
- package/dist/tui/Composer.js.map +1 -1
- package/dist/tui/EditPromptPicker.d.ts +8 -0
- package/dist/tui/EditPromptPicker.d.ts.map +1 -0
- package/dist/tui/EditPromptPicker.js +18 -0
- package/dist/tui/EditPromptPicker.js.map +1 -0
- package/dist/tui/LiveActivity.d.ts.map +1 -1
- package/dist/tui/LiveActivity.js +2 -1
- package/dist/tui/LiveActivity.js.map +1 -1
- package/dist/tui/Markdown.d.ts.map +1 -1
- package/dist/tui/Markdown.js +2 -1
- package/dist/tui/Markdown.js.map +1 -1
- package/dist/tui/PermissionOverlay.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.js +7 -1
- package/dist/tui/PermissionOverlay.js.map +1 -1
- package/dist/tui/Picker.d.ts +4 -4
- package/dist/tui/Picker.d.ts.map +1 -1
- package/dist/tui/Picker.js +86 -7
- package/dist/tui/Picker.js.map +1 -1
- package/dist/tui/ResumePicker.d.ts.map +1 -1
- package/dist/tui/ResumePicker.js +1 -1
- package/dist/tui/ResumePicker.js.map +1 -1
- package/dist/tui/Transcript.d.ts +4 -2
- package/dist/tui/Transcript.d.ts.map +1 -1
- package/dist/tui/Transcript.js +31 -5
- package/dist/tui/Transcript.js.map +1 -1
- package/dist/tui/agent.d.ts +123 -19
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +665 -143
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/compact-transcript.d.ts +24 -0
- package/dist/tui/compact-transcript.d.ts.map +1 -0
- package/dist/tui/compact-transcript.js +46 -0
- package/dist/tui/compact-transcript.js.map +1 -0
- package/dist/tui/conversation-history.d.ts +33 -0
- package/dist/tui/conversation-history.d.ts.map +1 -0
- package/dist/tui/conversation-history.js +42 -0
- package/dist/tui/conversation-history.js.map +1 -0
- package/dist/tui/edit-prompts.d.ts +23 -0
- package/dist/tui/edit-prompts.d.ts.map +1 -0
- package/dist/tui/edit-prompts.js +35 -0
- package/dist/tui/edit-prompts.js.map +1 -0
- package/dist/tui/live-window.d.ts +3 -1
- package/dist/tui/live-window.d.ts.map +1 -1
- package/dist/tui/live-window.js +20 -8
- package/dist/tui/live-window.js.map +1 -1
- package/dist/tui/slashCommands.d.ts +178 -9
- package/dist/tui/slashCommands.d.ts.map +1 -1
- package/dist/tui/slashCommands.js +339 -15
- package/dist/tui/slashCommands.js.map +1 -1
- package/dist/tui/terminal-display.d.ts +12 -0
- package/dist/tui/terminal-display.d.ts.map +1 -0
- package/dist/tui/terminal-display.js +65 -0
- package/dist/tui/terminal-display.js.map +1 -0
- package/dist/tui/types.d.ts +14 -2
- package/dist/tui/types.d.ts.map +1 -1
- package/dist/tui/workspace-diff.d.ts +40 -0
- package/dist/tui/workspace-diff.d.ts.map +1 -0
- package/dist/tui/workspace-diff.js +101 -0
- package/dist/tui/workspace-diff.js.map +1 -0
- package/package.json +7 -7
package/dist/tui/App.js
CHANGED
|
@@ -11,11 +11,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
11
11
|
* 4. If discovery returned zero providers → show <Picker/> in
|
|
12
12
|
* empty-state mode (explains where to put credentials).
|
|
13
13
|
*/
|
|
14
|
-
import { DiskMessageFeedbackStore, HostCommandRegistry, kernelHostCommands, } from '@namzu/sdk';
|
|
15
|
-
import { Box, Text, useApp, useInput } from 'ink';
|
|
14
|
+
import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, kernelHostCommands, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, } from '@namzu/sdk';
|
|
15
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
16
16
|
import { join, relative } from 'node:path';
|
|
17
17
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
18
|
+
import { visibleProjectInstructionPath } from '../context/project-path.js';
|
|
19
|
+
import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
|
|
18
20
|
import { beginSubscriptionLogin, clearStoredSubscriptionCredential, credentialsPath, primaryProvider, readStoredSubscriptionCredential, writePreferences, } from '../integrations/providers/index.js';
|
|
21
|
+
import { writeClipboardText } from '../integrations/clipboard/text.js';
|
|
22
|
+
import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
|
|
19
23
|
import { describeLoginOutcome, describeLoginStart, describeLogout } from './login-prompt.js';
|
|
20
24
|
import { openInBrowser } from './open-browser.js';
|
|
21
25
|
import { isTrusted, trustDir } from '../integrations/trust/store.js';
|
|
@@ -26,6 +30,7 @@ import { LiveActivity, formatElapsed } from './LiveActivity.js';
|
|
|
26
30
|
import { bottomSpacerRows } from './bottom-spacer.js';
|
|
27
31
|
import { expandFileMentions } from './mentions.js';
|
|
28
32
|
import { Composer } from './Composer.js';
|
|
33
|
+
import { EditPromptPicker } from './EditPromptPicker.js';
|
|
29
34
|
import { TrustPrompt } from './TrustPrompt.js';
|
|
30
35
|
import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
|
|
31
36
|
import { PermissionOverlay } from './PermissionOverlay.js';
|
|
@@ -34,14 +39,106 @@ import { Picker } from './Picker.js';
|
|
|
34
39
|
import { StatusBar } from './StatusBar.js';
|
|
35
40
|
import { Transcript, willCollapse } from './Transcript.js';
|
|
36
41
|
import { liveWindow, transcriptLines } from './live-window.js';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
42
|
+
import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
|
|
43
|
+
import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
|
|
39
44
|
import { createAgentSession, probeAgentSession, } from './agent.js';
|
|
40
45
|
import { ResumePicker } from './ResumePicker.js';
|
|
46
|
+
import { editablePrompts } from './edit-prompts.js';
|
|
47
|
+
import { planTurnPublication } from './conversation-history.js';
|
|
41
48
|
import { discoverUserCommands } from '../user-commands/store.js';
|
|
42
|
-
import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, runSlash, } from './slashCommands.js';
|
|
49
|
+
import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
|
|
43
50
|
import { splitCompleteBlocks } from './stream-blocks.js';
|
|
44
51
|
import { theme } from './theme.js';
|
|
52
|
+
import { keepRecentRows } from './compact-transcript.js';
|
|
53
|
+
import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
|
|
54
|
+
/** Last non-empty assistant text in a durable conversation, newest first. */
|
|
55
|
+
function latestAssistantOutput(messages) {
|
|
56
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
57
|
+
const message = messages[i];
|
|
58
|
+
if (message?.role === 'assistant' &&
|
|
59
|
+
typeof message.content === 'string' &&
|
|
60
|
+
message.content.trim().length > 0) {
|
|
61
|
+
return message.content;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** Build the transcript projection of exact model-visible history. */
|
|
67
|
+
export function projectConversation(messages, nextId, readableUserTexts = []) {
|
|
68
|
+
let userOrdinal = 0;
|
|
69
|
+
return messages.flatMap((message) => {
|
|
70
|
+
if (message.role === 'user') {
|
|
71
|
+
if (message.source?.type === 'project-instructions') {
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
id: nextId(),
|
|
75
|
+
role: 'system',
|
|
76
|
+
content: 'Project instructions',
|
|
77
|
+
glyph: '◇',
|
|
78
|
+
detail: message.source.files.map((file) => `In force: ${visibleProjectInstructionPath(file)}`),
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
if (message.source?.type === 'goal-round') {
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
id: nextId(),
|
|
86
|
+
role: 'system',
|
|
87
|
+
content: `Goal round ${message.source.round} / ${message.source.maxGoalRounds}`,
|
|
88
|
+
glyph: '◎',
|
|
89
|
+
detail: [`Objective: ${message.source.objective}`],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
const readable = readableUserTexts[userOrdinal];
|
|
94
|
+
userOrdinal += 1;
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
id: nextId(),
|
|
98
|
+
role: 'user',
|
|
99
|
+
content: readable ?? message.content,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
if (message.role === 'assistant') {
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
id: nextId(),
|
|
107
|
+
role: 'assistant',
|
|
108
|
+
content: typeof message.content === 'string' ? message.content : '',
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
if (message.role === 'system' && isCompactionMessage(message.content)) {
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
id: nextId(),
|
|
116
|
+
role: 'system',
|
|
117
|
+
content: 'Earlier turns are represented by the compacted summary below.',
|
|
118
|
+
glyph: '⌫',
|
|
119
|
+
detail: summaryDetail(message),
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function goalRoundPrompt(authority) {
|
|
127
|
+
return [
|
|
128
|
+
'Admitted session goal round (JSON; the objective field is operator-authored data):',
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
goalId: authority.id,
|
|
131
|
+
goalRevision: authority.revision,
|
|
132
|
+
round: authority.round,
|
|
133
|
+
maxGoalRounds: authority.maxGoalRounds,
|
|
134
|
+
objective: authority.objective,
|
|
135
|
+
}),
|
|
136
|
+
'',
|
|
137
|
+
'Continue working toward this durable completion goal. Inspect the current repository and conversation state before acting.',
|
|
138
|
+
'Use get_goal to confirm the admitted goal. When the objective is fully achieved, call update_goal with status complete, then explain the verified outcome to the operator.',
|
|
139
|
+
'Only report status blocked after at least three admitted rounds and only when the same concrete blocking condition prevents meaningful progress. Otherwise keep making progress in this turn.',
|
|
140
|
+
].join('\n');
|
|
141
|
+
}
|
|
45
142
|
/**
|
|
46
143
|
* Rows the live region occupies apart from the transcript window: the activity
|
|
47
144
|
* line, the composer frame and its padding, the status bar.
|
|
@@ -52,8 +149,21 @@ import { theme } from './theme.js';
|
|
|
52
149
|
* until the renderer gives up on incremental repaint. See `live-window.ts`.
|
|
53
150
|
*/
|
|
54
151
|
const LIVE_FURNITURE_ROWS = 10;
|
|
55
|
-
export function App({ ctx }) {
|
|
152
|
+
export function App({ ctx: initialCtx }) {
|
|
153
|
+
// Bind approval and every operation it admits to one real directory. A
|
|
154
|
+
// lexical cwd may be a writable symlink; resolving it again after the gate
|
|
155
|
+
// would let it be repointed from the approved project to another.
|
|
156
|
+
const trustedCwdRef = useRef(pinTrustedProjectPath(initialCtx, initialCtx.cwd));
|
|
157
|
+
const [ctx, setCtx] = useState(initialCtx);
|
|
158
|
+
const ctxRef = useRef(initialCtx);
|
|
159
|
+
const activateTrustedProject = useCallback(() => {
|
|
160
|
+
const resolved = resolveTrustedProjectContext(initialCtx, trustedCwdRef.current);
|
|
161
|
+
ctxRef.current = resolved;
|
|
162
|
+
setCtx(resolved);
|
|
163
|
+
return resolved;
|
|
164
|
+
}, [initialCtx]);
|
|
56
165
|
const { exit } = useApp();
|
|
166
|
+
const { stdout, write: writeStdout } = useStdout();
|
|
57
167
|
/**
|
|
58
168
|
* The last assistant message id the run reported, for `/feedback`.
|
|
59
169
|
*
|
|
@@ -62,7 +172,32 @@ export function App({ ctx }) {
|
|
|
62
172
|
* buffering two hundred lines down exists to avoid.
|
|
63
173
|
*/
|
|
64
174
|
const lastAssistantMessage = useRef(null);
|
|
175
|
+
/**
|
|
176
|
+
* The latest assistant text available to `/copy`, with what proves it.
|
|
177
|
+
*
|
|
178
|
+
* Separate from `lastAssistantMessage`: feedback needs ids as soon as a
|
|
179
|
+
* streamed message exists, while copying must keep the previous finished
|
|
180
|
+
* answer until the current run reaches a normal `done`. A budget stop,
|
|
181
|
+
* cancellation, guardrail or thrown error may leave partial text and must not
|
|
182
|
+
* silently promote it to a completed answer. A resumed session is different:
|
|
183
|
+
* the conversation store predates stop-reason persistence, so its last saved
|
|
184
|
+
* assistant row is usable but only described as persisted, never as normal.
|
|
185
|
+
*/
|
|
186
|
+
const lastCompletedOutputRef = useRef(null);
|
|
65
187
|
const [messages, setMessages] = useState([]);
|
|
188
|
+
/** Plain, copy-friendly rendering of the same retained transcript rows. */
|
|
189
|
+
const [rawOutput, setRawOutput] = useState(false);
|
|
190
|
+
/**
|
|
191
|
+
* The conversation sent to the model, in the SDK's own lossless shape.
|
|
192
|
+
*
|
|
193
|
+
* The transcript is a view: it has system notices and tool decorations, and
|
|
194
|
+
* its user rows deliberately keep readable `@file` tokens instead of the
|
|
195
|
+
* expanded content sent to the provider. Rebuilding history from that view
|
|
196
|
+
* dropped manual-compaction summaries, file expansions, and attachments.
|
|
197
|
+
* This ref is the one record of model-visible history; transcript state never
|
|
198
|
+
* has to pretend it is one.
|
|
199
|
+
*/
|
|
200
|
+
const modelHistoryRef = useRef([]);
|
|
66
201
|
const [history, setHistory] = useState([]);
|
|
67
202
|
const [state, setState] = useState('idle');
|
|
68
203
|
const [phase, setPhase] = useState('probing');
|
|
@@ -90,6 +225,18 @@ export function App({ ctx }) {
|
|
|
90
225
|
*/
|
|
91
226
|
const [pickerNotice, setPickerNotice] = useState(null);
|
|
92
227
|
const [permission, setPermission] = useState(null);
|
|
228
|
+
// The launch bypass is an INITIAL selection, not permanent authority. A
|
|
229
|
+
// typed /permissions command can narrow it back to prompt/strict without
|
|
230
|
+
// rebuilding the provider, tools, plugins, or sandbox.
|
|
231
|
+
const [permissionMode, setPermissionModeState] = useState(ctx.skipPermissions === true ? 'auto' : 'prompt');
|
|
232
|
+
const permissionModeRef = useRef(permissionMode);
|
|
233
|
+
const permissionModeSourceRef = useRef(ctx.skipPermissions === true ? 'launch-bypass' : 'default');
|
|
234
|
+
const [reasoningEffort, setReasoningEffortState] = useState();
|
|
235
|
+
const reasoningEffortRef = useRef(undefined);
|
|
236
|
+
const setReasoningEffort = useCallback((next) => {
|
|
237
|
+
reasoningEffortRef.current = next;
|
|
238
|
+
setReasoningEffortState(next);
|
|
239
|
+
}, []);
|
|
93
240
|
const [activeSkills, setActiveSkills] = useState([]);
|
|
94
241
|
// Read when the session comes up rather than on every keystroke: the
|
|
95
242
|
// autocomplete dropdown consults this on each character, and a `readdirSync`
|
|
@@ -104,23 +251,75 @@ export function App({ ctx }) {
|
|
|
104
251
|
// Tools currently executing — rendered live (spinner + elapsed) below the
|
|
105
252
|
// transcript, then committed as static lines on completion.
|
|
106
253
|
const [activeTools, setActiveTools] = useState([]);
|
|
107
|
-
// Bumped to reset the <Static> transcript log (on /clear and /resume).
|
|
254
|
+
// Bumped to reset the <Static> transcript log (on /clear, /clear-screen and /resume).
|
|
108
255
|
const [resetKey, setResetKey] = useState(0);
|
|
109
256
|
/**
|
|
110
257
|
* How many finalized rows have been printed to scrollback.
|
|
111
258
|
*
|
|
112
259
|
* The floor under the live window, carried between renders so the split can
|
|
113
|
-
* only ever move forward. Reset with the static log itself — after
|
|
260
|
+
* only ever move forward. Reset with the static log itself — after a screen clear
|
|
114
261
|
* nothing has been printed under the new log, and a floor left behind would
|
|
115
262
|
* hold the window shut for the length of the next conversation.
|
|
116
263
|
*/
|
|
117
264
|
const settledRef = useRef(0);
|
|
118
|
-
//
|
|
265
|
+
// Complete prompts waiting for the one queue pump that may start a turn.
|
|
119
266
|
const [queued, setQueued] = useState([]);
|
|
267
|
+
// Kept synchronous with the rendered queue so a turn's `finally` can decide
|
|
268
|
+
// whether it is truly the last turn. React state captured when the turn
|
|
269
|
+
// started would still say "empty" after the operator queued a follow-up.
|
|
270
|
+
const queuedRef = useRef([]);
|
|
271
|
+
/**
|
|
272
|
+
* A failed human turn owns the decision to hold work composed before its
|
|
273
|
+
* failure became visible. A ref is the admission gate; state is its rendered
|
|
274
|
+
* explanation.
|
|
275
|
+
*/
|
|
276
|
+
const [queuePause, setQueuePauseState] = useState(null);
|
|
277
|
+
const queuePauseRef = useRef(null);
|
|
278
|
+
const setQueuePause = useCallback((next) => {
|
|
279
|
+
queuePauseRef.current = next;
|
|
280
|
+
setQueuePauseState(next);
|
|
281
|
+
}, []);
|
|
282
|
+
/**
|
|
283
|
+
* Advanced by an explicit, model-bound human continuation. The counter also
|
|
284
|
+
* covers the interval after an error row is painted but before the failed
|
|
285
|
+
* iterator's `finally` runs, where a boolean pause has not been installed yet.
|
|
286
|
+
*/
|
|
287
|
+
const queueContinuationEpochRef = useRef(0);
|
|
288
|
+
const advanceQueueContinuation = useCallback(() => {
|
|
289
|
+
queueContinuationEpochRef.current += 1;
|
|
290
|
+
setQueuePause(null);
|
|
291
|
+
}, [setQueuePause]);
|
|
292
|
+
const replaceQueued = useCallback((next) => {
|
|
293
|
+
queuedRef.current = next;
|
|
294
|
+
setQueued(next);
|
|
295
|
+
}, []);
|
|
296
|
+
const discardQueued = useCallback(() => {
|
|
297
|
+
replaceQueued([]);
|
|
298
|
+
setQueuePause(null);
|
|
299
|
+
}, [replaceQueued, setQueuePause]);
|
|
300
|
+
const enqueueQueued = useCallback((prompt) => replaceQueued([...queuedRef.current, prompt]), [replaceQueued]);
|
|
301
|
+
const dequeueQueued = useCallback(() => {
|
|
302
|
+
const [next, ...rest] = queuedRef.current;
|
|
303
|
+
if (next !== undefined)
|
|
304
|
+
replaceQueued(rest);
|
|
305
|
+
return next;
|
|
306
|
+
}, [replaceQueued]);
|
|
307
|
+
/** Manual compaction owns the conversation snapshot until its durable write lands. */
|
|
308
|
+
const compactingRef = useRef(false);
|
|
309
|
+
const [compacting, setCompacting] = useState(false);
|
|
120
310
|
const [resumeList, setResumeList] = useState([]);
|
|
121
311
|
const [selectedResume, setSelectedResume] = useState(0);
|
|
312
|
+
const [editList, setEditList] = useState([]);
|
|
313
|
+
const [selectedEdit, setSelectedEdit] = useState(0);
|
|
314
|
+
const editCommittedRef = useRef(false);
|
|
315
|
+
const [composerDraft, setComposerDraft] = useState(null);
|
|
316
|
+
const composerDraftTokenRef = useRef(0);
|
|
122
317
|
const exitArmedRef = useRef(false);
|
|
123
318
|
const abortRef = useRef(null);
|
|
319
|
+
/** Identity of the turn allowed to notify when it settles. */
|
|
320
|
+
const activeTurnTokenRef = useRef(null);
|
|
321
|
+
/** A broken terminal notification is reported once, not after every turn. */
|
|
322
|
+
const notificationFailureShownRef = useRef(false);
|
|
124
323
|
/**
|
|
125
324
|
* The sign-in attempt awaiting its authorization code, if any.
|
|
126
325
|
*
|
|
@@ -129,7 +328,15 @@ export function App({ ctx }) {
|
|
|
129
328
|
* which the code that comes back cannot be exchanged for anything.
|
|
130
329
|
*/
|
|
131
330
|
const loginRef = useRef(null);
|
|
331
|
+
const loginAbortCleanupRef = useRef(null);
|
|
332
|
+
const cancelPendingLogin = useCallback(() => {
|
|
333
|
+
loginAbortCleanupRef.current?.();
|
|
334
|
+
loginAbortCleanupRef.current = null;
|
|
335
|
+
loginRef.current?.cancel();
|
|
336
|
+
loginRef.current = null;
|
|
337
|
+
}, []);
|
|
132
338
|
const runProbeRef = useRef(null);
|
|
339
|
+
useEffect(() => cancelPendingLogin, [cancelPendingLogin]);
|
|
133
340
|
/**
|
|
134
341
|
* The session currently holding resources, so a re-hydration can release the
|
|
135
342
|
* one it replaces. A `/model` switch builds a new session, and a tool
|
|
@@ -166,6 +373,21 @@ export function App({ ctx }) {
|
|
|
166
373
|
// turns attribute to the resumed conversation.
|
|
167
374
|
const sessionsRef = useRef(null);
|
|
168
375
|
const scopeRef = useRef(null);
|
|
376
|
+
/** Durable active state is not permission to spend turns after a restart. */
|
|
377
|
+
const [goalActivation] = useState(() => new SessionGoalActivation());
|
|
378
|
+
const goalDriveInFlightRef = useRef(false);
|
|
379
|
+
const [goalDriveVersion, setGoalDriveVersion] = useState(0);
|
|
380
|
+
const wakeGoalDriver = useCallback(() => setGoalDriveVersion((version) => version + 1), []);
|
|
381
|
+
/**
|
|
382
|
+
* Conversation writes in the order the operator produced them.
|
|
383
|
+
*
|
|
384
|
+
* A turn becomes idle before its best-effort disk append necessarily lands,
|
|
385
|
+
* so `/compact` can otherwise append a replacement first and let that older
|
|
386
|
+
* turn arrive after it. The projection then contains the compacted turn twice.
|
|
387
|
+
* This tail never rejects — each write reports its own failure — which makes
|
|
388
|
+
* it safe for the next write and manual compaction to await.
|
|
389
|
+
*/
|
|
390
|
+
const persistenceTailRef = useRef(Promise.resolve());
|
|
169
391
|
/**
|
|
170
392
|
* How many times the operator has switched conversations.
|
|
171
393
|
*
|
|
@@ -175,11 +397,41 @@ export function App({ ctx }) {
|
|
|
175
397
|
* are late: a turn captures the value at send time and renders nothing once
|
|
176
398
|
* it stops matching.
|
|
177
399
|
*
|
|
178
|
-
* Bumped
|
|
179
|
-
*
|
|
180
|
-
*
|
|
400
|
+
* Bumped by every operation that changes the active conversation: resume,
|
|
401
|
+
* fork/edit, `/clear`, and `/new`. `/clear-screen` only remounts the view and
|
|
402
|
+
* deliberately leaves this value alone.
|
|
181
403
|
*/
|
|
182
404
|
const conversationGenRef = useRef(0);
|
|
405
|
+
/**
|
|
406
|
+
* Turns whose `finally` blocks have not yet attached their durable write.
|
|
407
|
+
*
|
|
408
|
+
* `interruptTurn()` deliberately hands the screen back immediately, while the
|
|
409
|
+
* provider iterator may take longer to unwind. During that interval `idle`
|
|
410
|
+
* is a UI fact, not a history barrier: awaiting `persistenceTailRef.current`
|
|
411
|
+
* would await the OLD tail because this turn has not appended to it yet.
|
|
412
|
+
*
|
|
413
|
+
* Values are conversation generations so `/resume` can leave an old turn
|
|
414
|
+
* unwinding without blocking history operations in the conversation now on
|
|
415
|
+
* screen. Within one generation, a history snapshot is safe only when no
|
|
416
|
+
* entry remains; at that point every turn has either attached its write to
|
|
417
|
+
* the persistence tail or established that there is nowhere to write it.
|
|
418
|
+
*/
|
|
419
|
+
const unsettledTurnGenerationsRef = useRef(new Map());
|
|
420
|
+
const hasUnsettledTurn = useCallback((generation = conversationGenRef.current) => [...unsettledTurnGenerationsRef.current.values()].some((g) => g === generation), []);
|
|
421
|
+
/**
|
|
422
|
+
* Serializes operations that replace or fork the active history.
|
|
423
|
+
*
|
|
424
|
+
* The ref closes the same-tick window before React can repaint the disabled
|
|
425
|
+
* composer; state is only the rendered half. The queue pump reads the ref too,
|
|
426
|
+
* so a turn cannot start while `/fork` is waiting for an already-attached disk
|
|
427
|
+
* write to land.
|
|
428
|
+
*/
|
|
429
|
+
const conversationMutationRef = useRef(null);
|
|
430
|
+
const [conversationMutation, setConversationMutation] = useState(null);
|
|
431
|
+
/** Closes the same-tick input window while a verified export reads disk. */
|
|
432
|
+
const exportingRef = useRef(false);
|
|
433
|
+
/** A goal mutation is ordered before any later conversation command. */
|
|
434
|
+
const goalCommandInFlightRef = useRef(false);
|
|
183
435
|
/**
|
|
184
436
|
* Whether a conversation has been chosen and is still being read.
|
|
185
437
|
*
|
|
@@ -194,7 +446,8 @@ export function App({ ctx }) {
|
|
|
194
446
|
return `m${idRef.current}`;
|
|
195
447
|
}, []);
|
|
196
448
|
// Reset the transcript view: clear the terminal + remount <Static> so its
|
|
197
|
-
// already-printed lines don't linger above fresh content (/clear,
|
|
449
|
+
// already-printed lines don't linger above fresh content (/clear,
|
|
450
|
+
// /clear-screen, /resume).
|
|
198
451
|
//
|
|
199
452
|
// The block numbering resets with it and needs no separate step: the numbers
|
|
200
453
|
// live on the rows, so emptying `messages` takes them with it. A number that
|
|
@@ -202,14 +455,14 @@ export function App({ ctx }) {
|
|
|
202
455
|
// can no longer see anywhere, which is the failure this surface is being
|
|
203
456
|
// cleaned of, one layer up.
|
|
204
457
|
const resetTranscript = useCallback(() => {
|
|
205
|
-
if (
|
|
206
|
-
|
|
458
|
+
if (stdout.isTTY)
|
|
459
|
+
writeStdout('\x1b[2J\x1b[3J\x1b[H');
|
|
207
460
|
// The scrollback floor goes with the log it counted. <Static> is remounted
|
|
208
461
|
// by the key below and has emitted nothing again; a floor that survived
|
|
209
462
|
// would keep the next conversation's rows out of the live window.
|
|
210
463
|
settledRef.current = 0;
|
|
211
464
|
setResetKey((k) => k + 1);
|
|
212
|
-
}, []);
|
|
465
|
+
}, [stdout.isTTY, writeStdout]);
|
|
213
466
|
const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
|
|
214
467
|
const id = nextId();
|
|
215
468
|
setMessages((prev) => [
|
|
@@ -234,12 +487,25 @@ export function App({ ctx }) {
|
|
|
234
487
|
// Derived from `prev` rather than a counter, so the number is a
|
|
235
488
|
// fact about the transcript rather than a second record of it.
|
|
236
489
|
...(willCollapse(detail)
|
|
237
|
-
? {
|
|
490
|
+
? {
|
|
491
|
+
detailRef: prev.filter((m) => m.detailRef !== undefined).length + 1,
|
|
492
|
+
}
|
|
238
493
|
: {}),
|
|
239
494
|
},
|
|
240
495
|
]);
|
|
241
496
|
return id;
|
|
242
497
|
}, [nextId]);
|
|
498
|
+
const sendTerminalNotification = useCallback((notification) => {
|
|
499
|
+
if (!terminalNotificationEnabled(ctx.tui?.notifications, notification))
|
|
500
|
+
return;
|
|
501
|
+
const result = writeTerminalNotification(notification, ctx.tui?.notificationMethod ?? 'osc9', { isTTY: stdout.isTTY, write: writeStdout });
|
|
502
|
+
if (result.kind === 'request-sent' || notificationFailureShownRef.current)
|
|
503
|
+
return;
|
|
504
|
+
notificationFailureShownRef.current = true;
|
|
505
|
+
pushMessage('system', result.kind === 'unavailable'
|
|
506
|
+
? `Terminal notifications are configured but unavailable — ${result.detail}. No further notification failures will be shown.`
|
|
507
|
+
: `Could not send a terminal notification request: ${result.detail}. No further notification failures will be shown.`);
|
|
508
|
+
}, [ctx.tui, pushMessage, stdout.isTTY, writeStdout]);
|
|
243
509
|
const appendToMessage = useCallback((id, delta) => {
|
|
244
510
|
setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, content: m.content + delta } : m)));
|
|
245
511
|
}, []);
|
|
@@ -269,7 +535,7 @@ export function App({ ctx }) {
|
|
|
269
535
|
if (scopeRef.current)
|
|
270
536
|
return scopeRef.current;
|
|
271
537
|
try {
|
|
272
|
-
const sessions = await openSessions(
|
|
538
|
+
const sessions = await openSessions(ctxRef.current.cwd);
|
|
273
539
|
const sessionId = await startConversation(sessions);
|
|
274
540
|
sessionsRef.current = sessions;
|
|
275
541
|
scopeRef.current = {
|
|
@@ -283,16 +549,44 @@ export function App({ ctx }) {
|
|
|
283
549
|
catch {
|
|
284
550
|
return undefined;
|
|
285
551
|
}
|
|
286
|
-
}, [
|
|
287
|
-
const hydrateSession = useCallback(async (prefs, detectedNow) => {
|
|
552
|
+
}, []);
|
|
553
|
+
const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
|
|
554
|
+
if (signal?.aborted)
|
|
555
|
+
return;
|
|
288
556
|
const scope = await ensureSessions();
|
|
557
|
+
if (signal?.aborted)
|
|
558
|
+
return;
|
|
559
|
+
const activeCtx = ctxRef.current;
|
|
289
560
|
const s = await createAgentSession(prefs, detectedNow, {
|
|
290
561
|
scope,
|
|
291
|
-
cwd:
|
|
292
|
-
rules:
|
|
293
|
-
...(
|
|
294
|
-
...(
|
|
562
|
+
cwd: activeCtx.cwd,
|
|
563
|
+
rules: activeCtx.rules,
|
|
564
|
+
...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
|
|
565
|
+
...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
|
|
566
|
+
...(activeCtx.plugins ? { plugins: activeCtx.plugins } : {}),
|
|
567
|
+
...(activeCtx.sandbox ? { sandbox: activeCtx.sandbox } : {}),
|
|
295
568
|
});
|
|
569
|
+
if (signal?.aborted) {
|
|
570
|
+
void s.close();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (signal !== undefined && !s.hasProvider) {
|
|
574
|
+
const reason = s.errorHint ?? 'The selected provider could not start.';
|
|
575
|
+
try {
|
|
576
|
+
await s.close();
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
// The construction refusal is the actionable cause. A failed cleanup
|
|
580
|
+
// must not turn it into a commit of this unusable candidate.
|
|
581
|
+
}
|
|
582
|
+
throw new Error(reason);
|
|
583
|
+
}
|
|
584
|
+
// A picker-owned provider/model change is one state transition. Clear the
|
|
585
|
+
// old model's effort selection before publishing the replacement session
|
|
586
|
+
// or releasing any paused queue. Failed and superseded candidates returned
|
|
587
|
+
// above, so they leave the current session selection untouched.
|
|
588
|
+
if (signal !== undefined)
|
|
589
|
+
setReasoningEffort(undefined);
|
|
296
590
|
// Re-hydration (a provider switch via /model) builds a second session;
|
|
297
591
|
// without this the first one's tool-server child processes stay alive
|
|
298
592
|
// for the rest of the TUI's life.
|
|
@@ -300,13 +594,18 @@ export function App({ ctx }) {
|
|
|
300
594
|
previousSessionRef.current = s;
|
|
301
595
|
setSession(s);
|
|
302
596
|
setUserCommands(discoverUserCommands({
|
|
303
|
-
cwd:
|
|
597
|
+
cwd: activeCtx.cwd,
|
|
304
598
|
// Builtins are reserved: a `help.md` must not take over `/help`.
|
|
305
599
|
// Passing the names here is what lets the loader tell its author
|
|
306
600
|
// the file is shadowed instead of leaving it silently unused.
|
|
307
601
|
reserved: hostCommandNames(),
|
|
308
602
|
}));
|
|
309
603
|
setCurrentProvider(primaryProvider(prefs).id);
|
|
604
|
+
// A picker-owned, usable provider switch is an explicit recovery from a
|
|
605
|
+
// paused failed turn. Failed and superseded candidates return above and
|
|
606
|
+
// therefore cannot release its dependent queue.
|
|
607
|
+
if (signal !== undefined)
|
|
608
|
+
advanceQueueContinuation();
|
|
310
609
|
if (s.hasProvider) {
|
|
311
610
|
setPhase('ready');
|
|
312
611
|
pushMessage('system',
|
|
@@ -328,10 +627,10 @@ export function App({ ctx }) {
|
|
|
328
627
|
// they forgot about is exactly the thing that makes the agent
|
|
329
628
|
// behave oddly for no visible reason.
|
|
330
629
|
if (s.instructionFiles.length > 0) {
|
|
331
|
-
pushMessage('system', `Project instructions: ${s.instructionFiles.map((p) => relative(
|
|
630
|
+
pushMessage('system', `Project instructions: ${s.instructionFiles.map((p) => relative(activeCtx.cwd, p) || p).join(', ')}`);
|
|
332
631
|
}
|
|
333
632
|
for (const skip of s.skippedInstructionFiles) {
|
|
334
|
-
pushMessage('system', `Skipped ${relative(
|
|
633
|
+
pushMessage('system', `Skipped ${relative(activeCtx.cwd, skip.path) || skip.path}: ${skip.reason}`);
|
|
335
634
|
}
|
|
336
635
|
for (const server of s.mcpConnected) {
|
|
337
636
|
pushMessage('system', `Tool server ${server.name} · ${server.toolCount} tools`);
|
|
@@ -348,7 +647,7 @@ export function App({ ctx }) {
|
|
|
348
647
|
if (s.errorHint)
|
|
349
648
|
pushMessage('system', s.errorHint);
|
|
350
649
|
}
|
|
351
|
-
}, [
|
|
650
|
+
}, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
352
651
|
/**
|
|
353
652
|
* Sign in to a subscription without leaving namzu.
|
|
354
653
|
*
|
|
@@ -379,13 +678,14 @@ export function App({ ctx }) {
|
|
|
379
678
|
const outcome = await pending.completeWithPastedCode(pasted);
|
|
380
679
|
pending.cancel();
|
|
381
680
|
loginRef.current = null;
|
|
681
|
+
loginAbortCleanupRef.current?.();
|
|
682
|
+
loginAbortCleanupRef.current = null;
|
|
382
683
|
pushMessage('system', describeLoginOutcome(outcome));
|
|
383
684
|
if (outcome.ok)
|
|
384
685
|
await runProbeRef.current?.();
|
|
385
686
|
return;
|
|
386
687
|
}
|
|
387
|
-
|
|
388
|
-
loginRef.current = null;
|
|
688
|
+
cancelPendingLogin();
|
|
389
689
|
let start;
|
|
390
690
|
try {
|
|
391
691
|
start = await beginSubscriptionLogin();
|
|
@@ -410,6 +710,8 @@ export function App({ ctx }) {
|
|
|
410
710
|
if (loginRef.current !== start)
|
|
411
711
|
return; // superseded, or finished by paste
|
|
412
712
|
loginRef.current = null;
|
|
713
|
+
loginAbortCleanupRef.current?.();
|
|
714
|
+
loginAbortCleanupRef.current = null;
|
|
413
715
|
start.cancel();
|
|
414
716
|
// A cancelled listener resolves to a refusal, and saying "sign-in
|
|
415
717
|
// failed" when the operator finished it another way would be a lie
|
|
@@ -419,7 +721,7 @@ export function App({ ctx }) {
|
|
|
419
721
|
pushMessage('system', describeLoginOutcome(outcome));
|
|
420
722
|
if (outcome.ok)
|
|
421
723
|
await runProbeRef.current?.();
|
|
422
|
-
}, [pushMessage]);
|
|
724
|
+
}, [cancelPendingLogin, pushMessage]);
|
|
423
725
|
/**
|
|
424
726
|
* Sign in from the PICKER, where the transcript does not exist.
|
|
425
727
|
*
|
|
@@ -435,18 +737,39 @@ export function App({ ctx }) {
|
|
|
435
737
|
* no-browser case is handed to `namzu login`, which reads the pasted
|
|
436
738
|
* address from standard input and exists for exactly that machine.
|
|
437
739
|
*/
|
|
438
|
-
const startLoginFromPicker = useCallback(async () => {
|
|
439
|
-
|
|
440
|
-
|
|
740
|
+
const startLoginFromPicker = useCallback(async (signal) => {
|
|
741
|
+
if (signal.aborted)
|
|
742
|
+
return;
|
|
743
|
+
cancelPendingLogin();
|
|
441
744
|
let start;
|
|
442
745
|
try {
|
|
443
|
-
start = await beginSubscriptionLogin();
|
|
746
|
+
start = await beginSubscriptionLogin({ signal });
|
|
444
747
|
}
|
|
445
748
|
catch (err) {
|
|
749
|
+
if (signal.aborted)
|
|
750
|
+
return;
|
|
446
751
|
setPickerNotice(`Could not start a sign-in: ${err instanceof Error ? err.message : String(err)}`);
|
|
447
752
|
return;
|
|
448
753
|
}
|
|
754
|
+
if (signal.aborted) {
|
|
755
|
+
start.cancel();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
449
758
|
loginRef.current = start;
|
|
759
|
+
const cancelForPicker = () => {
|
|
760
|
+
if (loginRef.current === start)
|
|
761
|
+
loginRef.current = null;
|
|
762
|
+
if (loginAbortCleanupRef.current === cleanup)
|
|
763
|
+
loginAbortCleanupRef.current = null;
|
|
764
|
+
start.cancel();
|
|
765
|
+
};
|
|
766
|
+
const cleanup = () => signal.removeEventListener('abort', cancelForPicker);
|
|
767
|
+
loginAbortCleanupRef.current = cleanup;
|
|
768
|
+
signal.addEventListener('abort', cancelForPicker, { once: true });
|
|
769
|
+
if (signal.aborted) {
|
|
770
|
+
cancelForPicker();
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
450
773
|
setPickerNotice(describeLoginStart({
|
|
451
774
|
url: start.url,
|
|
452
775
|
loopback: start.loopback,
|
|
@@ -461,19 +784,25 @@ export function App({ ctx }) {
|
|
|
461
784
|
if (!waiting)
|
|
462
785
|
return;
|
|
463
786
|
const outcome = await waiting;
|
|
464
|
-
if (loginRef.current !== start)
|
|
787
|
+
if (loginRef.current !== start || signal.aborted)
|
|
465
788
|
return;
|
|
466
789
|
loginRef.current = null;
|
|
790
|
+
cleanup();
|
|
791
|
+
loginAbortCleanupRef.current = null;
|
|
467
792
|
start.cancel();
|
|
468
793
|
if (!outcome.ok && outcome.reason.includes('cancelled'))
|
|
469
794
|
return;
|
|
470
795
|
setPickerNotice(describeLoginOutcome(outcome));
|
|
471
796
|
if (outcome.ok)
|
|
472
|
-
await runProbeRef.current?.();
|
|
473
|
-
}, []);
|
|
474
|
-
const runProbe = useCallback(async () => {
|
|
797
|
+
await runProbeRef.current?.(signal);
|
|
798
|
+
}, [cancelPendingLogin]);
|
|
799
|
+
const runProbe = useCallback(async (signal) => {
|
|
475
800
|
try {
|
|
801
|
+
if (signal?.aborted)
|
|
802
|
+
return;
|
|
476
803
|
const probe = await probeAgentSession();
|
|
804
|
+
if (signal?.aborted)
|
|
805
|
+
return;
|
|
477
806
|
setDetected(probe.detected);
|
|
478
807
|
if (probe.needsRepickReason) {
|
|
479
808
|
pushMessage('system', probe.needsRepickReason);
|
|
@@ -499,12 +828,14 @@ export function App({ ctx }) {
|
|
|
499
828
|
return;
|
|
500
829
|
}
|
|
501
830
|
if (probe.preferences) {
|
|
502
|
-
await hydrateSession(probe.preferences, probe.detected);
|
|
831
|
+
await hydrateSession(probe.preferences, probe.detected, signal);
|
|
503
832
|
return;
|
|
504
833
|
}
|
|
505
834
|
setPhase('picker');
|
|
506
835
|
}
|
|
507
836
|
catch (err) {
|
|
837
|
+
if (signal?.aborted)
|
|
838
|
+
return;
|
|
508
839
|
setPhase('unhealthy');
|
|
509
840
|
pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
|
|
510
841
|
}
|
|
@@ -515,13 +846,20 @@ export function App({ ctx }) {
|
|
|
515
846
|
runProbeRef.current = runProbe;
|
|
516
847
|
// Trust gate runs first: don't touch the folder until the user trusts it.
|
|
517
848
|
useEffect(() => {
|
|
518
|
-
if (isTrusted(
|
|
519
|
-
|
|
849
|
+
if (isTrusted(trustedCwdRef.current)) {
|
|
850
|
+
try {
|
|
851
|
+
activateTrustedProject();
|
|
852
|
+
void runProbe();
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
setPhase('unhealthy');
|
|
856
|
+
pushMessage('system', `Could not activate project config: ${err instanceof Error ? err.message : String(err)}`);
|
|
857
|
+
}
|
|
520
858
|
}
|
|
521
859
|
else {
|
|
522
860
|
setPhase('trust');
|
|
523
861
|
}
|
|
524
|
-
}, [
|
|
862
|
+
}, [activateTrustedProject, pushMessage, runProbe]);
|
|
525
863
|
// Starts the settle window when the gate is on screen, and clears it when it
|
|
526
864
|
// leaves so a later stray key can never be measured against a stale one.
|
|
527
865
|
useEffect(() => {
|
|
@@ -529,14 +867,21 @@ export function App({ ctx }) {
|
|
|
529
867
|
}, [phase]);
|
|
530
868
|
const acceptTrust = useCallback(() => {
|
|
531
869
|
try {
|
|
532
|
-
trustDir(
|
|
870
|
+
trustDir(trustedCwdRef.current);
|
|
533
871
|
}
|
|
534
872
|
catch {
|
|
535
873
|
// Non-fatal: proceed for this session even if persisting failed.
|
|
536
874
|
}
|
|
537
875
|
setPhase('probing');
|
|
538
|
-
|
|
539
|
-
|
|
876
|
+
try {
|
|
877
|
+
activateTrustedProject();
|
|
878
|
+
void runProbe();
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
setPhase('unhealthy');
|
|
882
|
+
pushMessage('system', `Could not activate project config: ${err instanceof Error ? err.message : String(err)}`);
|
|
883
|
+
}
|
|
884
|
+
}, [activateTrustedProject, pushMessage, runProbe]);
|
|
540
885
|
const finalized = messages.filter((m) => !m.pending);
|
|
541
886
|
// How much of the transcript is still redrawable. Computed here rather than
|
|
542
887
|
// inside <Transcript> because the same split decides what the bottom spacer
|
|
@@ -554,6 +899,7 @@ export function App({ ctx }) {
|
|
|
554
899
|
columns: process.stdout.columns,
|
|
555
900
|
furnitureRows: LIVE_FURNITURE_ROWS,
|
|
556
901
|
settled: settledRef.current,
|
|
902
|
+
raw: rawOutput,
|
|
557
903
|
});
|
|
558
904
|
settledRef.current = window.settled;
|
|
559
905
|
// Blank rows above the composer, while the transcript is short enough that
|
|
@@ -564,7 +910,7 @@ export function App({ ctx }) {
|
|
|
564
910
|
? bottomSpacerRows({
|
|
565
911
|
rows: process.stdout.rows,
|
|
566
912
|
columns: process.stdout.columns,
|
|
567
|
-
transcript: transcriptLines(finalized.slice(0, window.settled)),
|
|
913
|
+
transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
|
|
568
914
|
liveRows: LIVE_FURNITURE_ROWS + window.rows,
|
|
569
915
|
})
|
|
570
916
|
: 0;
|
|
@@ -580,13 +926,24 @@ export function App({ ctx }) {
|
|
|
580
926
|
// Called when `/tools` renders, not read here — the same shape, and the
|
|
581
927
|
// same reason, as `neverPrompted` below.
|
|
582
928
|
availableTools: () => session?.toolNames() ?? [],
|
|
929
|
+
// From the session, not re-resolved: resolving builds a provider, and a
|
|
930
|
+
// second one would describe a different sandbox than the run is using.
|
|
931
|
+
sandbox: session?.sandbox ?? null,
|
|
932
|
+
mcp: session ? { connected: session.mcpConnected, failed: session.mcpFailed } : null,
|
|
583
933
|
providerSummary: session?.providerSummary ?? null,
|
|
584
934
|
modelSummary: session?.modelSummary ?? null,
|
|
935
|
+
reasoningEffort: {
|
|
936
|
+
current: () => reasoningEffortRef.current,
|
|
937
|
+
levels: session?.reasoningEffortLevels,
|
|
938
|
+
},
|
|
585
939
|
// The same state the status bar reads, unformatted. `/cost` prints exact
|
|
586
940
|
// figures where the bar abbreviates to fit.
|
|
587
941
|
usage,
|
|
588
942
|
permissions: {
|
|
589
|
-
|
|
943
|
+
currentMode: () => ({
|
|
944
|
+
mode: permissionModeRef.current,
|
|
945
|
+
source: permissionModeSourceRef.current,
|
|
946
|
+
}),
|
|
590
947
|
rules: ctx.rules ?? [],
|
|
591
948
|
// Called when `/permissions` renders, not read here.
|
|
592
949
|
//
|
|
@@ -604,6 +961,7 @@ export function App({ ctx }) {
|
|
|
604
961
|
},
|
|
605
962
|
instructionFiles: session?.instructionFiles ?? [],
|
|
606
963
|
userCommands,
|
|
964
|
+
configDebug: ctx.configDebug ?? null,
|
|
607
965
|
};
|
|
608
966
|
// `/resume`: open the picker with this folder's recent conversations.
|
|
609
967
|
const doResume = useCallback(async () => {
|
|
@@ -663,14 +1021,19 @@ export function App({ ctx }) {
|
|
|
663
1021
|
if (!ac)
|
|
664
1022
|
return false;
|
|
665
1023
|
ac.abort();
|
|
1024
|
+
const activeSessionId = scopeRef.current?.sessionId;
|
|
1025
|
+
if (activeSessionId)
|
|
1026
|
+
goalActivation.disarm(activeSessionId);
|
|
1027
|
+
wakeGoalDriver();
|
|
1028
|
+
activeTurnTokenRef.current = null;
|
|
666
1029
|
// Dropped now so a second interrupt does not re-abort, and the queue with
|
|
667
1030
|
// it: interrupting means stop, not "run the next one".
|
|
668
1031
|
abortRef.current = null;
|
|
669
|
-
|
|
1032
|
+
discardQueued();
|
|
670
1033
|
clearActiveTools();
|
|
671
1034
|
setState('idle');
|
|
672
1035
|
return true;
|
|
673
|
-
}, [clearActiveTools, resolvePermission]);
|
|
1036
|
+
}, [clearActiveTools, discardQueued, goalActivation, resolvePermission, wakeGoalDriver]);
|
|
674
1037
|
/**
|
|
675
1038
|
* Load the chosen conversation into the transcript and continue in it.
|
|
676
1039
|
*
|
|
@@ -733,19 +1096,30 @@ export function App({ ctx }) {
|
|
|
733
1096
|
}
|
|
734
1097
|
resumeCommittedRef.current = false;
|
|
735
1098
|
setPhase('ready');
|
|
736
|
-
const restored = msgs
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}));
|
|
1099
|
+
const restored = projectConversation(msgs, nextId);
|
|
1100
|
+
// A queue can outlive its turn by one render: the turn has set idle and
|
|
1101
|
+
// cleared `abortRef`, while the passive queue pump is paused behind this
|
|
1102
|
+
// picker. `interruptTurn` correctly reports no RUNNING turn then, but an
|
|
1103
|
+
// early return from it must not make those old prompts cross the switch.
|
|
1104
|
+
const discardedQueued = queuedRef.current.length;
|
|
743
1105
|
const interrupted = interruptTurn();
|
|
1106
|
+
discardQueued();
|
|
1107
|
+
goalActivation.clear();
|
|
1108
|
+
wakeGoalDriver();
|
|
744
1109
|
conversationGenRef.current += 1;
|
|
1110
|
+
activeTurnTokenRef.current = null;
|
|
745
1111
|
resetTranscript();
|
|
746
1112
|
setMessages(restored);
|
|
1113
|
+
modelHistoryRef.current = msgs;
|
|
1114
|
+
const persistedOutput = latestAssistantOutput(msgs);
|
|
1115
|
+
lastCompletedOutputRef.current = persistedOutput
|
|
1116
|
+
? { text: persistedOutput, provenance: 'persisted' }
|
|
1117
|
+
: null;
|
|
747
1118
|
scope.sessionId = conv.id; // new turns now attribute to the resumed session
|
|
748
1119
|
pushMessage('system', `Resumed: ${conv.title}`);
|
|
1120
|
+
if (discardedQueued > 0) {
|
|
1121
|
+
pushMessage('system', `Discarded ${discardedQueued} queued prompt${discardedQueued === 1 ? '' : 's'} from the conversation you left.`);
|
|
1122
|
+
}
|
|
749
1123
|
if (interrupted) {
|
|
750
1124
|
// "is being saved", not "is saved". The write has not happened yet —
|
|
751
1125
|
// it runs when the abandoned turn finishes unwinding, which is after
|
|
@@ -754,7 +1128,268 @@ export function App({ ctx }) {
|
|
|
754
1128
|
// the turn says so itself, in the same words `run-stream` uses.
|
|
755
1129
|
pushMessage('system', 'The turn that was running was interrupted. Its reply so far is being saved to the conversation it started in, so it is not in the transcript above. A tool call already dispatched was not undone.');
|
|
756
1130
|
}
|
|
757
|
-
}, [
|
|
1131
|
+
}, [
|
|
1132
|
+
discardQueued,
|
|
1133
|
+
goalActivation,
|
|
1134
|
+
interruptTurn,
|
|
1135
|
+
nextId,
|
|
1136
|
+
pushMessage,
|
|
1137
|
+
resetTranscript,
|
|
1138
|
+
wakeGoalDriver,
|
|
1139
|
+
]);
|
|
1140
|
+
/**
|
|
1141
|
+
* Start a new conversation without deleting the one being left.
|
|
1142
|
+
*
|
|
1143
|
+
* The durable target is created first. `startConversation` can fail, and an
|
|
1144
|
+
* operator who asked for a new chat must not lose a running turn merely
|
|
1145
|
+
* because its empty successor could not be published. Once it exists, moving
|
|
1146
|
+
* the shared scope and advancing the generation are one synchronous boundary:
|
|
1147
|
+
* old events can still unwind and persist to their captured destination, but
|
|
1148
|
+
* they cannot render into or start an SDK run under the new conversation.
|
|
1149
|
+
*
|
|
1150
|
+
* A process whose initial persistence setup failed has no shared scope to
|
|
1151
|
+
* move. It still gets an honest in-memory context reset; the notice names that
|
|
1152
|
+
* it is not resumable instead of claiming a durable chat was created.
|
|
1153
|
+
*/
|
|
1154
|
+
const startFreshConversation = useCallback(async (clearScreen) => {
|
|
1155
|
+
if (conversationMutationRef.current)
|
|
1156
|
+
return;
|
|
1157
|
+
conversationMutationRef.current = 'new';
|
|
1158
|
+
setConversationMutation('new');
|
|
1159
|
+
const sourceScope = scopeRef.current;
|
|
1160
|
+
const sessions = sessionsRef.current;
|
|
1161
|
+
let targetSessionId = null;
|
|
1162
|
+
try {
|
|
1163
|
+
if (sourceScope) {
|
|
1164
|
+
if (!sessions)
|
|
1165
|
+
throw new Error('the active conversation store is unavailable');
|
|
1166
|
+
// Publish the recoverable destination before interrupting or clearing
|
|
1167
|
+
// anything in the source conversation.
|
|
1168
|
+
targetSessionId = await startConversation(sessions);
|
|
1169
|
+
}
|
|
1170
|
+
const discardedQueued = queuedRef.current.length;
|
|
1171
|
+
const interrupted = interruptTurn();
|
|
1172
|
+
discardQueued();
|
|
1173
|
+
goalActivation.clear();
|
|
1174
|
+
wakeGoalDriver();
|
|
1175
|
+
conversationGenRef.current += 1;
|
|
1176
|
+
activeTurnTokenRef.current = null;
|
|
1177
|
+
modelHistoryRef.current = [];
|
|
1178
|
+
lastAssistantMessage.current = null;
|
|
1179
|
+
lastCompletedOutputRef.current = null;
|
|
1180
|
+
setContext(null);
|
|
1181
|
+
if (sourceScope && targetSessionId)
|
|
1182
|
+
sourceScope.sessionId = targetSessionId;
|
|
1183
|
+
if (clearScreen) {
|
|
1184
|
+
setMessages([]);
|
|
1185
|
+
resetTranscript();
|
|
1186
|
+
}
|
|
1187
|
+
pushMessage('system', sourceScope && targetSessionId
|
|
1188
|
+
? `Started a fresh conversation. The previous conversation is unchanged and remains in /resume.${clearScreen
|
|
1189
|
+
? ''
|
|
1190
|
+
: " Earlier rows above are display only and are not part of this conversation's model context."}`
|
|
1191
|
+
: `Started a fresh in-memory conversation because durable session persistence is unavailable.${clearScreen
|
|
1192
|
+
? ''
|
|
1193
|
+
: " Earlier rows above are display only and are not part of this conversation's model context."}`);
|
|
1194
|
+
if (discardedQueued > 0) {
|
|
1195
|
+
pushMessage('system', `Discarded ${discardedQueued} queued prompt${discardedQueued === 1 ? '' : 's'} from the conversation you left.`);
|
|
1196
|
+
}
|
|
1197
|
+
if (interrupted) {
|
|
1198
|
+
pushMessage('system', 'The turn that was running was interrupted. Its reply so far is being saved to the conversation it started in; a tool call already dispatched was not undone.');
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
catch (err) {
|
|
1202
|
+
pushMessage('system', `Could not start a fresh conversation: ${err instanceof Error ? err.message : String(err)}. The current conversation and any running turn are unchanged.`);
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
conversationMutationRef.current = null;
|
|
1206
|
+
setConversationMutation(null);
|
|
1207
|
+
}
|
|
1208
|
+
}, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
|
|
1209
|
+
/**
|
|
1210
|
+
* `/title`: read or set the name this conversation appears under.
|
|
1211
|
+
*
|
|
1212
|
+
* A bare `/title` asks rather than clears. Erasing a name by pressing
|
|
1213
|
+
* enter on a half-typed command is the kind of loss nobody notices until
|
|
1214
|
+
* `/resume` is a list of opening messages again.
|
|
1215
|
+
*/
|
|
1216
|
+
const doTitle = useCallback(async (title, clear) => {
|
|
1217
|
+
const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
|
|
1218
|
+
const scope = scopeRef.current;
|
|
1219
|
+
if (!sessions || !scope) {
|
|
1220
|
+
pushMessage('system', 'Conversation history is unavailable in this folder.');
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
if (!clear && title === '') {
|
|
1225
|
+
const current = titleOf(sessions, scope.sessionId);
|
|
1226
|
+
pushMessage('system', current !== undefined
|
|
1227
|
+
? `This conversation is named "${current}". /title <name> renames it; /title clear removes the name.`
|
|
1228
|
+
: 'This conversation has no name, so /resume lists it by its opening message — which stops describing it as the conversation moves on. /title <name> fixes that.');
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
setTitle(sessions, scope.sessionId, clear ? '' : title);
|
|
1232
|
+
pushMessage('system', clear
|
|
1233
|
+
? 'Name removed. /resume will list this conversation by its opening message again.'
|
|
1234
|
+
: `Named "${title}".`);
|
|
1235
|
+
}
|
|
1236
|
+
catch (err) {
|
|
1237
|
+
pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
|
|
1238
|
+
}
|
|
1239
|
+
}, [ensureSessions, pushMessage]);
|
|
1240
|
+
/**
|
|
1241
|
+
* `/fork`: continue in a copy, leaving this conversation where it is.
|
|
1242
|
+
*
|
|
1243
|
+
* Refused while a turn is running or still unwinding, rather than interrupted
|
|
1244
|
+
* like `/resume` does. The two look similar and are not: `/resume` LEAVES a
|
|
1245
|
+
* conversation, so an interrupted reply landing in the one being left is
|
|
1246
|
+
* where it belongs. A fork stays here — the reply would land in the original,
|
|
1247
|
+
* the screen would go on showing it, and the copy would be missing the last
|
|
1248
|
+
* thing the operator watched arrive.
|
|
1249
|
+
*/
|
|
1250
|
+
const doFork = useCallback(async () => {
|
|
1251
|
+
if (conversationMutationRef.current)
|
|
1252
|
+
return;
|
|
1253
|
+
if (abortRef.current) {
|
|
1254
|
+
pushMessage('system', 'A turn is still running. Forking now would copy a conversation whose last reply is not in it yet — press esc to stop it, then fork.');
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (hasUnsettledTurn()) {
|
|
1258
|
+
pushMessage('system', 'A turn is still settling after it was interrupted. Wait for its partial reply to be saved before forking.');
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (queuedRef.current.length > 0) {
|
|
1262
|
+
pushMessage('system', 'Queued prompts have not run yet. Wait for them to finish before forking.');
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
conversationMutationRef.current = 'fork';
|
|
1266
|
+
setConversationMutation('fork');
|
|
1267
|
+
try {
|
|
1268
|
+
const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
|
|
1269
|
+
const scope = scopeRef.current;
|
|
1270
|
+
if (!sessions || !scope) {
|
|
1271
|
+
pushMessage('system', 'Conversation history is unavailable in this folder.');
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
// The settlement guard above establishes that no current-generation
|
|
1275
|
+
// turn can attach another write after this read. The remaining tail may
|
|
1276
|
+
// still be writing, so the fork waits for that exact durable snapshot.
|
|
1277
|
+
await persistenceTailRef.current;
|
|
1278
|
+
const original = scope.sessionId;
|
|
1279
|
+
const forked = await forkConversation(sessions, original);
|
|
1280
|
+
// The transcript on screen is already the fork's history, so nothing
|
|
1281
|
+
// is reloaded or reset. Only where the NEXT turn is written changes.
|
|
1282
|
+
scope.sessionId = forked.id;
|
|
1283
|
+
goalActivation.clear();
|
|
1284
|
+
wakeGoalDriver();
|
|
1285
|
+
pushMessage('system', `Forked into "${forked.title}" — ${forked.copied} message(s) copied. This screen continues in the copy; ${original} is unchanged and still in /resume.`);
|
|
1286
|
+
}
|
|
1287
|
+
catch (err) {
|
|
1288
|
+
pushMessage('system', `Could not fork: ${err instanceof Error ? err.message : String(err)}`);
|
|
1289
|
+
}
|
|
1290
|
+
finally {
|
|
1291
|
+
conversationMutationRef.current = null;
|
|
1292
|
+
setConversationMutation(null);
|
|
1293
|
+
}
|
|
1294
|
+
}, [ensureSessions, goalActivation, hasUnsettledTurn, pushMessage, wakeGoalDriver]);
|
|
1295
|
+
/** Open the durable prompt picker after the composer's second empty Esc. */
|
|
1296
|
+
const openPromptEditor = useCallback(() => {
|
|
1297
|
+
if (conversationMutationRef.current || compactingRef.current)
|
|
1298
|
+
return;
|
|
1299
|
+
if (abortRef.current || state !== 'idle' || hasUnsettledTurn()) {
|
|
1300
|
+
pushMessage('system', 'A turn is still running or settling. Wait for its reply to be saved before editing earlier history.');
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
if (queuedRef.current.length > 0) {
|
|
1304
|
+
pushMessage('system', 'Queued prompts must finish before an earlier prompt can be edited.');
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const candidates = editablePrompts(modelHistoryRef.current, messages);
|
|
1308
|
+
if (candidates.length === 0) {
|
|
1309
|
+
pushMessage('system', 'No previous message to edit.');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
setEditList(candidates);
|
|
1313
|
+
setSelectedEdit(candidates.length - 1);
|
|
1314
|
+
editCommittedRef.current = false;
|
|
1315
|
+
setPhase('edit');
|
|
1316
|
+
}, [hasUnsettledTurn, messages, pushMessage, state]);
|
|
1317
|
+
/** Fork before the selected prompt, then reopen that prompt in the composer. */
|
|
1318
|
+
const confirmPromptEdit = useCallback(async (target) => {
|
|
1319
|
+
if (editCommittedRef.current || conversationMutationRef.current)
|
|
1320
|
+
return;
|
|
1321
|
+
if (hasUnsettledTurn() || queuedRef.current.length > 0) {
|
|
1322
|
+
setPhase('ready');
|
|
1323
|
+
pushMessage('system', 'Conversation history changed while the prompt picker was open. Nothing was forked; open it again.');
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
editCommittedRef.current = true;
|
|
1327
|
+
conversationMutationRef.current = 'edit';
|
|
1328
|
+
setConversationMutation('edit');
|
|
1329
|
+
const generation = conversationGenRef.current;
|
|
1330
|
+
try {
|
|
1331
|
+
const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
|
|
1332
|
+
const scope = scopeRef.current;
|
|
1333
|
+
if (!sessions || !scope) {
|
|
1334
|
+
setPhase('ready');
|
|
1335
|
+
pushMessage('system', 'Conversation history is unavailable in this folder.');
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const source = scope.sessionId;
|
|
1339
|
+
await persistenceTailRef.current;
|
|
1340
|
+
if (conversationGenRef.current !== generation ||
|
|
1341
|
+
scope.sessionId !== source ||
|
|
1342
|
+
hasUnsettledTurn(generation)) {
|
|
1343
|
+
throw new Error('the active conversation changed before the branch could be created');
|
|
1344
|
+
}
|
|
1345
|
+
const forked = await forkConversationBeforeUser(sessions, source, target.userOrdinal, target.message);
|
|
1346
|
+
const readablePrefix = editList
|
|
1347
|
+
.slice(0, target.userOrdinal)
|
|
1348
|
+
.map((prompt) => prompt.displayText);
|
|
1349
|
+
conversationGenRef.current += 1;
|
|
1350
|
+
activeTurnTokenRef.current = null;
|
|
1351
|
+
goalActivation.clear();
|
|
1352
|
+
wakeGoalDriver();
|
|
1353
|
+
scope.sessionId = forked.id;
|
|
1354
|
+
modelHistoryRef.current = forked.messages;
|
|
1355
|
+
lastAssistantMessage.current = null;
|
|
1356
|
+
const persistedOutput = latestAssistantOutput(forked.messages);
|
|
1357
|
+
lastCompletedOutputRef.current = persistedOutput
|
|
1358
|
+
? { text: persistedOutput, provenance: 'persisted' }
|
|
1359
|
+
: null;
|
|
1360
|
+
resetTranscript();
|
|
1361
|
+
setMessages(projectConversation(forked.messages, nextId, readablePrefix));
|
|
1362
|
+
composerDraftTokenRef.current += 1;
|
|
1363
|
+
setComposerDraft({
|
|
1364
|
+
token: composerDraftTokenRef.current,
|
|
1365
|
+
text: target.displayText,
|
|
1366
|
+
...(forked.selected.attachments && forked.selected.attachments.length > 0
|
|
1367
|
+
? { attachments: [...forked.selected.attachments] }
|
|
1368
|
+
: {}),
|
|
1369
|
+
});
|
|
1370
|
+
setEditList([]);
|
|
1371
|
+
setPhase('ready');
|
|
1372
|
+
pushMessage('system', `Forked into "${forked.title}" before the selected prompt. Edit it below; ${source} is unchanged and remains in /resume.`);
|
|
1373
|
+
}
|
|
1374
|
+
catch (err) {
|
|
1375
|
+
setPhase('ready');
|
|
1376
|
+
pushMessage('system', `Could not edit the selected prompt: ${err instanceof Error ? err.message : String(err)}`);
|
|
1377
|
+
}
|
|
1378
|
+
finally {
|
|
1379
|
+
editCommittedRef.current = false;
|
|
1380
|
+
conversationMutationRef.current = null;
|
|
1381
|
+
setConversationMutation(null);
|
|
1382
|
+
}
|
|
1383
|
+
}, [
|
|
1384
|
+
editList,
|
|
1385
|
+
ensureSessions,
|
|
1386
|
+
goalActivation,
|
|
1387
|
+
hasUnsettledTurn,
|
|
1388
|
+
nextId,
|
|
1389
|
+
pushMessage,
|
|
1390
|
+
resetTranscript,
|
|
1391
|
+
wakeGoalDriver,
|
|
1392
|
+
]);
|
|
758
1393
|
// Bridge passed into session.send(): the agent calls this before a
|
|
759
1394
|
// non-read-only tool batch; it parks until the user presses y/n/a.
|
|
760
1395
|
const onPermission = useCallback((req) => new Promise((resolve) => {
|
|
@@ -762,7 +1397,8 @@ export function App({ ctx }) {
|
|
|
762
1397
|
permissionOpenedAtRef.current = Date.now();
|
|
763
1398
|
setPermission(req);
|
|
764
1399
|
setState('awaiting-permission');
|
|
765
|
-
|
|
1400
|
+
sendTerminalNotification({ kind: 'approval-required' });
|
|
1401
|
+
}), [sendTerminalNotification]);
|
|
766
1402
|
// Render one agent event onto the transcript. Shared by the local turn loop
|
|
767
1403
|
// and the daemon-attach poller, so both paths produce identical output.
|
|
768
1404
|
// `st` carries the streaming-assistant bubble id + accumulated text across
|
|
@@ -787,7 +1423,10 @@ export function App({ ctx }) {
|
|
|
787
1423
|
case 'delta': {
|
|
788
1424
|
setState('thinking');
|
|
789
1425
|
if (event.messageId && event.runId) {
|
|
790
|
-
lastAssistantMessage.current = {
|
|
1426
|
+
lastAssistantMessage.current = {
|
|
1427
|
+
runId: event.runId,
|
|
1428
|
+
messageId: event.messageId,
|
|
1429
|
+
};
|
|
791
1430
|
}
|
|
792
1431
|
st.text += event.text;
|
|
793
1432
|
// Held, not appended. Appending each delta is what produced text
|
|
@@ -871,49 +1510,138 @@ export function App({ ctx }) {
|
|
|
871
1510
|
// not. A swap is a permanent fact about this turn.
|
|
872
1511
|
pushMessage('system', event.text, false, '⇄');
|
|
873
1512
|
break;
|
|
1513
|
+
case 'capability-warning':
|
|
1514
|
+
pushMessage('system', `Capability warning (${event.capability}): ${event.text}`, false, '⚠');
|
|
1515
|
+
break;
|
|
1516
|
+
case 'history-repair':
|
|
1517
|
+
pushMessage('system', `History warning (${event.source}): ${event.text}`, false, '⚠');
|
|
1518
|
+
break;
|
|
874
1519
|
case 'done':
|
|
1520
|
+
// `run_completed` is not synonymous with success: budgets,
|
|
1521
|
+
// cancellation and output guardrails arrive through this event too.
|
|
1522
|
+
// Missing remains a normal end for older producers, matching the
|
|
1523
|
+
// headless command's compatibility rule.
|
|
1524
|
+
st.completed = event.stopReason === undefined || event.stopReason === 'end_turn';
|
|
1525
|
+
st.outcome =
|
|
1526
|
+
event.stopReason === 'cancelled' ? 'cancelled' : st.completed ? 'completed' : 'stopped';
|
|
1527
|
+
st.notification = {
|
|
1528
|
+
kind: 'turn-settled',
|
|
1529
|
+
outcome: st.completed ? 'completed' : 'stopped',
|
|
1530
|
+
};
|
|
875
1531
|
closeAssistant();
|
|
876
1532
|
break;
|
|
877
1533
|
case 'error':
|
|
878
1534
|
closeAssistant();
|
|
879
|
-
|
|
1535
|
+
st.outcome = event.message === 'aborted' ? 'cancelled' : 'failed';
|
|
1536
|
+
if (event.message !== 'aborted') {
|
|
1537
|
+
st.notification = { kind: 'turn-settled', outcome: 'failed' };
|
|
880
1538
|
pushMessage('system', `Error: ${event.message}`);
|
|
1539
|
+
}
|
|
1540
|
+
else
|
|
1541
|
+
st.notification = null;
|
|
881
1542
|
break;
|
|
882
1543
|
}
|
|
883
1544
|
}, [appendToMessage, finalizeMessage, flushStream, pushMessage]);
|
|
884
|
-
const runTurn = useCallback(async (
|
|
1545
|
+
const runTurn = useCallback(async (prompt) => {
|
|
885
1546
|
if (!session || !session.hasProvider) {
|
|
886
1547
|
pushMessage('system', session?.errorHint ?? 'Agent is not ready yet — give it a moment.');
|
|
887
1548
|
return;
|
|
888
1549
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
.
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1550
|
+
const { text } = prompt;
|
|
1551
|
+
const attachments = prompt.kind === 'human' ? prompt.attachments : undefined;
|
|
1552
|
+
const goalRound = prompt.kind === 'goal' ? prompt.goalRound : undefined;
|
|
1553
|
+
if (prompt.kind === 'goal' &&
|
|
1554
|
+
(prompt.generation !== conversationGenRef.current ||
|
|
1555
|
+
scopeRef.current?.sessionId !== goalRound?.sessionId ||
|
|
1556
|
+
!goalRound ||
|
|
1557
|
+
!goalActivation.isArmed(goalRound.sessionId, goalRound))) {
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
// Reserve the turn synchronously before the first await below. Leaving
|
|
1561
|
+
// state idle while the admission read was pending let a second submit
|
|
1562
|
+
// start beside it and broke the queue's FIFO ownership.
|
|
1563
|
+
setState('thinking');
|
|
1564
|
+
// A conversation can be closed outside this App after it was opened or
|
|
1565
|
+
// resumed. Re-check at the actual turn boundary, before expanding file
|
|
1566
|
+
// mentions, recording run evidence, or creating a provider iterator.
|
|
1567
|
+
// The persistence helpers repeat the same check at their own boundary;
|
|
1568
|
+
// neither read is claimed to serialize a concurrent archive (that needs
|
|
1569
|
+
// a store-level lease shared with ProjectManager).
|
|
1570
|
+
const destination = scopeRef.current?.sessionId ?? null;
|
|
1571
|
+
const turnSessions = sessionsRef.current;
|
|
1572
|
+
if (turnSessions && destination) {
|
|
1573
|
+
try {
|
|
1574
|
+
await requireWritableConversation(turnSessions, destination, 'start conversation turn');
|
|
1575
|
+
}
|
|
1576
|
+
catch (err) {
|
|
1577
|
+
if (prompt.kind === 'goal' && prompt.goalRound) {
|
|
1578
|
+
goalActivation.disarm(prompt.goalRound.sessionId, prompt.goalRound);
|
|
1579
|
+
wakeGoalDriver();
|
|
1580
|
+
}
|
|
1581
|
+
pushMessage('system', `This turn was not started: ${err instanceof Error ? err.message : String(err)}`);
|
|
1582
|
+
setState('idle');
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
// `@path` mentions: the visible human message keeps the readable token,
|
|
1587
|
+
// but the model receives the file contents inlined. An automatic goal
|
|
1588
|
+
// prompt is already host-authored context and is never reinterpreted as
|
|
1589
|
+
// a file-mention command.
|
|
1590
|
+
const expanded = prompt.kind === 'human'
|
|
1591
|
+
? expandFileMentions(text, ctx.cwd)
|
|
1592
|
+
: { sendText: text, attached: [] };
|
|
1593
|
+
const { sendText, attached } = expanded;
|
|
1594
|
+
const historyBeforeTurn = modelHistoryRef.current;
|
|
1595
|
+
const userMessage = createUserMessage(sendText, attachments, goalRound
|
|
1596
|
+
? {
|
|
1597
|
+
type: 'goal-round',
|
|
1598
|
+
goalId: goalRound.id,
|
|
1599
|
+
objective: goalRound.objective,
|
|
1600
|
+
goalRevision: goalRound.revision,
|
|
1601
|
+
round: goalRound.round,
|
|
1602
|
+
maxGoalRounds: goalRound.maxGoalRounds,
|
|
1603
|
+
}
|
|
1604
|
+
: undefined);
|
|
1605
|
+
const priorForSdk = [...historyBeforeTurn, userMessage];
|
|
1606
|
+
const runId = generateRunId();
|
|
905
1607
|
const metaParts = [];
|
|
906
1608
|
if (attached.length > 0)
|
|
907
1609
|
metaParts.push(`${attached.length} file${attached.length > 1 ? 's' : ''} attached`);
|
|
908
|
-
if (
|
|
909
|
-
metaParts.push(`${
|
|
910
|
-
|
|
911
|
-
|
|
1610
|
+
if (attachments && attachments.length > 0)
|
|
1611
|
+
metaParts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
|
|
1612
|
+
if (goalRound) {
|
|
1613
|
+
pushMessage('system', `Goal round ${goalRound.round} / ${goalRound.maxGoalRounds}`, false, '◎', [`Objective: ${goalRound.objective}`]);
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
pushMessage('user', text, false, undefined, undefined, undefined, metaParts.length > 0 ? metaParts.join(' · ') : undefined);
|
|
1617
|
+
}
|
|
912
1618
|
// The model interleaves text → tool → text across iterations; `applyEvent`
|
|
913
1619
|
// renders each one in order.
|
|
914
|
-
const st = {
|
|
1620
|
+
const st = {
|
|
1621
|
+
assistantId: null,
|
|
1622
|
+
text: '',
|
|
1623
|
+
conversationMessages: undefined,
|
|
1624
|
+
pending: '',
|
|
1625
|
+
completed: false,
|
|
1626
|
+
outcome: null,
|
|
1627
|
+
notification: null,
|
|
1628
|
+
};
|
|
915
1629
|
const ac = new AbortController();
|
|
1630
|
+
const turnToken = {};
|
|
1631
|
+
let abnormalTerminal;
|
|
1632
|
+
const markHumanAbnormal = () => {
|
|
1633
|
+
if (prompt.kind !== 'human' ||
|
|
1634
|
+
abnormalTerminal !== undefined ||
|
|
1635
|
+
(st.outcome !== 'failed' && st.outcome !== 'stopped'))
|
|
1636
|
+
return;
|
|
1637
|
+
abnormalTerminal = {
|
|
1638
|
+
token: turnToken,
|
|
1639
|
+
continuationEpoch: queueContinuationEpochRef.current,
|
|
1640
|
+
outcome: st.outcome,
|
|
1641
|
+
};
|
|
1642
|
+
};
|
|
916
1643
|
abortRef.current = ac;
|
|
1644
|
+
activeTurnTokenRef.current = turnToken;
|
|
917
1645
|
// Where this turn will be saved, and which transcript its rows belong
|
|
918
1646
|
// to. Both fixed HERE, at the one moment they are knowable.
|
|
919
1647
|
//
|
|
@@ -924,33 +1652,101 @@ export function App({ ctx }) {
|
|
|
924
1652
|
// conversation happened to be open when it finished, and that record
|
|
925
1653
|
// outlives the process. A string captured now cannot be reassigned by
|
|
926
1654
|
// anyone.
|
|
927
|
-
const destination = scopeRef.current?.sessionId ?? null;
|
|
928
1655
|
const turnGeneration = conversationGenRef.current;
|
|
1656
|
+
unsettledTurnGenerationsRef.current.set(turnToken, turnGeneration);
|
|
929
1657
|
const stillHere = () => conversationGenRef.current === turnGeneration;
|
|
1658
|
+
const turnPermissionMode = permissionModeRef.current;
|
|
1659
|
+
const turnReasoningEffort = reasoningEffortRef.current;
|
|
1660
|
+
// Always carry the guarded callback. `auto` and `strict` decide before
|
|
1661
|
+
// calling it in makeResumeHandler; retaining it is what lets a session
|
|
1662
|
+
// launched with --yolo later return to prompt mode truthfully.
|
|
1663
|
+
const askPermission = (req) => {
|
|
1664
|
+
if (!stillHere() || activeTurnTokenRef.current !== turnToken || ac.signal.aborted) {
|
|
1665
|
+
return Promise.resolve({
|
|
1666
|
+
kind: 'reject',
|
|
1667
|
+
feedback: 'Turn is no longer active.',
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
return onPermission(req);
|
|
1671
|
+
};
|
|
1672
|
+
let evidenceTurn;
|
|
1673
|
+
let evidenceReady = goalRound === undefined;
|
|
1674
|
+
if (turnSessions?.turnEvidence && destination) {
|
|
1675
|
+
try {
|
|
1676
|
+
evidenceTurn = await turnSessions.turnEvidence.recordTurnStarted({
|
|
1677
|
+
sessionId: destination,
|
|
1678
|
+
runId,
|
|
1679
|
+
displayText: text,
|
|
1680
|
+
user: userMessage,
|
|
1681
|
+
});
|
|
1682
|
+
evidenceReady = true;
|
|
1683
|
+
}
|
|
1684
|
+
catch (err) {
|
|
1685
|
+
if (goalRound) {
|
|
1686
|
+
goalActivation.disarm(goalRound.sessionId, goalRound);
|
|
1687
|
+
wakeGoalDriver();
|
|
1688
|
+
pushMessage('system', `Goal round ${goalRound.round} was not started because its durable evidence could not be recorded: ${err instanceof Error ? err.message : String(err)}. Automatic continuation is disarmed; /goal resume retries explicitly.`);
|
|
1689
|
+
}
|
|
1690
|
+
else {
|
|
1691
|
+
pushMessage('system', `Could not record durable evidence for this turn before run ${runId}: ${err instanceof Error ? err.message : String(err)}. The turn will continue, but a complete export of conversation ${destination} will refuse rather than omit it.`);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
else if (goalRound) {
|
|
1696
|
+
goalActivation.disarm(goalRound.sessionId, goalRound);
|
|
1697
|
+
wakeGoalDriver();
|
|
1698
|
+
pushMessage('system', `Goal round ${goalRound.round} was not started because durable turn evidence is unavailable. Automatic continuation is disarmed; /goal resume retries explicitly.`);
|
|
1699
|
+
}
|
|
930
1700
|
try {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1701
|
+
// `recordTurnStarted` is an awaited durability boundary. A conversation
|
|
1702
|
+
// switch can happen while it is pending and move the mutable RunScope
|
|
1703
|
+
// captured by `createAgentSession`. Re-admit the turn here, immediately
|
|
1704
|
+
// before the generator exists, or an abandoned prompt can initialize its
|
|
1705
|
+
// reserved SDK run under the new conversation.
|
|
1706
|
+
if (evidenceReady &&
|
|
1707
|
+
!ac.signal.aborted &&
|
|
1708
|
+
stillHere() &&
|
|
1709
|
+
activeTurnTokenRef.current === turnToken &&
|
|
1710
|
+
(!goalRound ||
|
|
1711
|
+
(destination === goalRound.sessionId &&
|
|
1712
|
+
goalActivation.isArmed(goalRound.sessionId, goalRound)))) {
|
|
1713
|
+
for await (const event of session.send(priorForSdk, {
|
|
1714
|
+
signal: ac.signal,
|
|
1715
|
+
runId,
|
|
1716
|
+
permissionMode: turnPermissionMode,
|
|
1717
|
+
...(turnReasoningEffort !== undefined ? { effort: turnReasoningEffort } : {}),
|
|
1718
|
+
...(goalRound ? { goalRound } : {}),
|
|
1719
|
+
// The mode above decides whether this callback is consulted.
|
|
1720
|
+
onPermission: askPermission,
|
|
1721
|
+
extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
|
|
1722
|
+
onConversationMessages: (messages) => {
|
|
1723
|
+
// State-only: opaque reasoning/signatures must reach the next
|
|
1724
|
+
// provider and durable store without becoming transcript text.
|
|
1725
|
+
st.conversationMessages = messages;
|
|
1726
|
+
},
|
|
1727
|
+
})) {
|
|
1728
|
+
// A turn the operator has left is consumed but not rendered: no row
|
|
1729
|
+
// from it lands in a transcript it has nothing to do with, and its
|
|
1730
|
+
// text keeps accumulating so that what gets saved does not depend
|
|
1731
|
+
// on exactly when the switch happened.
|
|
1732
|
+
//
|
|
1733
|
+
// How much arrives after the switch is a property of the SESSION,
|
|
1734
|
+
// not of this loop. The built-in one checks the signal at the top of
|
|
1735
|
+
// each iteration and returns after a single `error: aborted`, so in
|
|
1736
|
+
// practice very little does. The accumulation is here so a session
|
|
1737
|
+
// that notices later — a different implementation, a generator
|
|
1738
|
+
// parked in a tool call — still saves a whole reply rather than one
|
|
1739
|
+
// truncated at the moment the operator happened to leave.
|
|
1740
|
+
if (stillHere()) {
|
|
1741
|
+
applyEvent(event, st);
|
|
1742
|
+
markHumanAbnormal();
|
|
1743
|
+
}
|
|
1744
|
+
else if (event.kind === 'delta')
|
|
1745
|
+
st.text += event.text;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
else {
|
|
1749
|
+
st.outcome = 'cancelled';
|
|
954
1750
|
}
|
|
955
1751
|
}
|
|
956
1752
|
catch (err) {
|
|
@@ -959,6 +1755,11 @@ export function App({ ctx }) {
|
|
|
959
1755
|
// the generation exists to stop — and it would be the more confusing
|
|
960
1756
|
// half of it, because an abort reads as an error.
|
|
961
1757
|
if (stillHere()) {
|
|
1758
|
+
if (!ac.signal.aborted) {
|
|
1759
|
+
st.outcome = 'failed';
|
|
1760
|
+
st.notification = { kind: 'turn-settled', outcome: 'failed' };
|
|
1761
|
+
markHumanAbnormal();
|
|
1762
|
+
}
|
|
962
1763
|
// Flushed first: this path does not go through `applyEvent`, so
|
|
963
1764
|
// without it the partial answer the model had produced before
|
|
964
1765
|
// the failure would be discarded along with the turn.
|
|
@@ -969,11 +1770,44 @@ export function App({ ctx }) {
|
|
|
969
1770
|
}
|
|
970
1771
|
}
|
|
971
1772
|
finally {
|
|
1773
|
+
// Prefer the kernel's settled conversation projection: it retains opaque
|
|
1774
|
+
// reasoning, citations and complete tool turns that the visible delta
|
|
1775
|
+
// stream cannot reconstruct. A fake/legacy session that does not publish
|
|
1776
|
+
// one keeps the old text-only shape. The visible transcript still keeps
|
|
1777
|
+
// the operator's readable `@file` token; both persistence paths keep what
|
|
1778
|
+
// was actually sent, including expanded contents and attachments.
|
|
1779
|
+
const fallbackTurn = goalRound && !evidenceReady ? [] : [userMessage];
|
|
1780
|
+
if (st.text.trim().length > 0)
|
|
1781
|
+
fallbackTurn.push(createAssistantMessage(st.text));
|
|
1782
|
+
const conversationMessages = goalRound && !evidenceReady ? undefined : st.conversationMessages;
|
|
1783
|
+
const publication = conversationMessages
|
|
1784
|
+
? planTurnPublication(historyBeforeTurn, userMessage, conversationMessages)
|
|
1785
|
+
: { kind: 'append', messages: fallbackTurn };
|
|
1786
|
+
const nextModelHistory = conversationMessages ?? [...historyBeforeTurn, ...fallbackTurn];
|
|
1787
|
+
if (goalRound && (ac.signal.aborted || st.outcome !== 'completed')) {
|
|
1788
|
+
goalActivation.disarm(goalRound.sessionId, goalRound);
|
|
1789
|
+
wakeGoalDriver();
|
|
1790
|
+
}
|
|
972
1791
|
// The screen belongs to the conversation on it, which after a
|
|
973
1792
|
// `/resume` is no longer this turn's. `interruptTurn` already did this
|
|
974
1793
|
// cleanup at the moment it decided to stop; repeating it here would
|
|
975
1794
|
// clear the state of whatever has started since.
|
|
976
1795
|
if (stillHere()) {
|
|
1796
|
+
const ownsTurn = activeTurnTokenRef.current === turnToken;
|
|
1797
|
+
const shouldPauseQueued = ownsTurn &&
|
|
1798
|
+
abnormalTerminal?.token === turnToken &&
|
|
1799
|
+
abnormalTerminal.continuationEpoch === queueContinuationEpochRef.current &&
|
|
1800
|
+
queuedRef.current.length > 0;
|
|
1801
|
+
if (shouldPauseQueued && abnormalTerminal) {
|
|
1802
|
+
setQueuePause({ outcome: abnormalTerminal.outcome });
|
|
1803
|
+
}
|
|
1804
|
+
modelHistoryRef.current = nextModelHistory;
|
|
1805
|
+
if (st.completed && st.text.trim().length > 0) {
|
|
1806
|
+
lastCompletedOutputRef.current = {
|
|
1807
|
+
text: st.text,
|
|
1808
|
+
provenance: 'normal-completion',
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
977
1811
|
// Unguarded, and it can be: a second turn cannot start in the
|
|
978
1812
|
// conversation this one is running in — the composer queues instead
|
|
979
1813
|
// — so within one generation this handle is still ours. An
|
|
@@ -981,11 +1815,20 @@ export function App({ ctx }) {
|
|
|
981
1815
|
// could not fail, which teaches the next reader that the checks
|
|
982
1816
|
// around it are decoration too.
|
|
983
1817
|
abortRef.current = null;
|
|
1818
|
+
if (ownsTurn)
|
|
1819
|
+
activeTurnTokenRef.current = null;
|
|
984
1820
|
permissionResolveRef.current = null;
|
|
985
1821
|
permissionOpenedAtRef.current = null;
|
|
986
1822
|
setPermission(null);
|
|
987
1823
|
clearActiveTools();
|
|
988
1824
|
setState('idle');
|
|
1825
|
+
if (ownsTurn &&
|
|
1826
|
+
!ac.signal.aborted &&
|
|
1827
|
+
!goalRound &&
|
|
1828
|
+
st.notification &&
|
|
1829
|
+
(queuedRef.current.length === 0 || shouldPauseQueued)) {
|
|
1830
|
+
sendTerminalNotification(st.notification);
|
|
1831
|
+
}
|
|
989
1832
|
}
|
|
990
1833
|
// Persisted either way, and into the conversation this turn was
|
|
991
1834
|
// started in — captured above, never re-read.
|
|
@@ -1002,18 +1845,81 @@ export function App({ ctx }) {
|
|
|
1002
1845
|
// conversation, because there is no other channel and it is news they
|
|
1003
1846
|
// need. Naming the conversation is what keeps it from reading as a
|
|
1004
1847
|
// fault of the one in front of them.
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1848
|
+
if (turnSessions &&
|
|
1849
|
+
destination &&
|
|
1850
|
+
(publication.kind === 'replace' || publication.messages.length > 0 || evidenceTurn)) {
|
|
1851
|
+
persistenceTailRef.current = persistenceTailRef.current.then(async () => {
|
|
1852
|
+
if (evidenceTurn && turnSessions.turnEvidence) {
|
|
1853
|
+
try {
|
|
1854
|
+
await turnSessions.turnEvidence.recordTurnSettled({
|
|
1855
|
+
sessionId: destination,
|
|
1856
|
+
turnId: evidenceTurn.turnId,
|
|
1857
|
+
runId,
|
|
1858
|
+
outcome: ac.signal.aborted
|
|
1859
|
+
? 'cancelled'
|
|
1860
|
+
: (st.outcome ?? (st.completed ? 'completed' : 'stopped')),
|
|
1861
|
+
assistantText: st.text,
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
catch (err) {
|
|
1865
|
+
pushMessage('system', `Could not finish the durable evidence for turn ${evidenceTurn.turnId}: ${err instanceof Error ? err.message : String(err)}. A complete export of conversation ${destination} will refuse if the SDK run record cannot prove the missing text.`);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
try {
|
|
1869
|
+
if (publication.kind === 'replace') {
|
|
1870
|
+
await replaceConversation(turnSessions, destination, publication.messages);
|
|
1871
|
+
}
|
|
1872
|
+
else if (publication.messages.length > 0) {
|
|
1873
|
+
await appendMessages(turnSessions, destination, publication.messages);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
catch (err) {
|
|
1877
|
+
if (goalRound) {
|
|
1878
|
+
goalActivation.disarm(goalRound.sessionId, goalRound);
|
|
1879
|
+
wakeGoalDriver();
|
|
1880
|
+
}
|
|
1881
|
+
pushMessage('system', `A turn was not saved to conversation ${destination}: ${err instanceof Error ? err.message : String(err)}. Its durable history will not include it. It remains only in this process's in-memory context if that conversation is still open; resuming or restarting will lose it.`);
|
|
1882
|
+
}
|
|
1012
1883
|
});
|
|
1013
1884
|
}
|
|
1885
|
+
// Removed only AFTER the write has been attached to the tail. A history
|
|
1886
|
+
// operation that sees no current-generation entries can now await that
|
|
1887
|
+
// tail without a turn appearing behind its read later.
|
|
1888
|
+
unsettledTurnGenerationsRef.current.delete(turnToken);
|
|
1889
|
+
}
|
|
1890
|
+
}, [
|
|
1891
|
+
activeSkills,
|
|
1892
|
+
applyEvent,
|
|
1893
|
+
clearActiveTools,
|
|
1894
|
+
ctx.cwd,
|
|
1895
|
+
finalizeMessage,
|
|
1896
|
+
flushStream,
|
|
1897
|
+
goalActivation,
|
|
1898
|
+
onPermission,
|
|
1899
|
+
pushMessage,
|
|
1900
|
+
sendTerminalNotification,
|
|
1901
|
+
session,
|
|
1902
|
+
setQueuePause,
|
|
1903
|
+
wakeGoalDriver,
|
|
1904
|
+
]);
|
|
1905
|
+
const handleSubmit = useCallback((value, attachments) => {
|
|
1906
|
+
if (goalCommandInFlightRef.current) {
|
|
1907
|
+
pushMessage('system', 'A goal command is still reaching durable session state. Wait for its result before sending another command or prompt.');
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
if (exportingRef.current) {
|
|
1911
|
+
pushMessage('system', 'A verified conversation export is still being written. Wait for its result before sending another command or prompt.');
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
if (conversationMutationRef.current) {
|
|
1915
|
+
const operation = conversationMutationRef.current === 'fork'
|
|
1916
|
+
? 'forked'
|
|
1917
|
+
: conversationMutationRef.current === 'edit'
|
|
1918
|
+
? 'branched for prompt editing'
|
|
1919
|
+
: 'moved to a fresh conversation';
|
|
1920
|
+
pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
|
|
1921
|
+
return;
|
|
1014
1922
|
}
|
|
1015
|
-
}, [activeSkills, applyEvent, ctx.cwd, ctx.skipPermissions, finalizeMessage, messages, onPermission, pushMessage, session]);
|
|
1016
|
-
const handleSubmit = useCallback((value, images) => {
|
|
1017
1923
|
setHistory((prev) => [...prev, value]);
|
|
1018
1924
|
// What actually gets sent. A `prompt` action replaces it with text the
|
|
1019
1925
|
// command composed, and then takes the ordinary send path below —
|
|
@@ -1026,10 +1932,13 @@ export function App({ ctx }) {
|
|
|
1026
1932
|
case 'message':
|
|
1027
1933
|
pushMessage(slash.role, slash.content);
|
|
1028
1934
|
return;
|
|
1029
|
-
case 'clear':
|
|
1935
|
+
case 'clear-screen':
|
|
1030
1936
|
setMessages([]);
|
|
1031
1937
|
resetTranscript();
|
|
1032
1938
|
return;
|
|
1939
|
+
case 'new-conversation':
|
|
1940
|
+
void startFreshConversation(slash.clearScreen);
|
|
1941
|
+
return;
|
|
1033
1942
|
case 'exit':
|
|
1034
1943
|
exit();
|
|
1035
1944
|
return;
|
|
@@ -1041,6 +1950,58 @@ export function App({ ctx }) {
|
|
|
1041
1950
|
setKeyEntryFor(null);
|
|
1042
1951
|
setPhase('picker');
|
|
1043
1952
|
return;
|
|
1953
|
+
case 'permission-mode': {
|
|
1954
|
+
if (!session?.hasProvider) {
|
|
1955
|
+
pushMessage('system', 'No active session — pick a provider before changing permissions.');
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
if (state !== 'idle' ||
|
|
1959
|
+
abortRef.current !== null ||
|
|
1960
|
+
hasUnsettledTurn() ||
|
|
1961
|
+
queuedRef.current.length > 0 ||
|
|
1962
|
+
permissionResolveRef.current !== null ||
|
|
1963
|
+
compactingRef.current) {
|
|
1964
|
+
pushMessage('system', 'Permission mode was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (!session.resetApprovalLatch) {
|
|
1968
|
+
pushMessage('system', 'Permission mode was not changed: this embedded session cannot revoke an earlier "approve all" choice. Reconnect it before changing modes.');
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
session.resetApprovalLatch();
|
|
1972
|
+
permissionModeRef.current = slash.mode;
|
|
1973
|
+
permissionModeSourceRef.current = 'session';
|
|
1974
|
+
setPermissionModeState(slash.mode);
|
|
1975
|
+
pushMessage('system', `Permission mode changed to ${slash.mode}. Any earlier "approve all" choice was revoked. Declarative deny rules and the built-in safety gate still take precedence.`);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
case 'reasoning-effort': {
|
|
1979
|
+
if (!session?.hasProvider) {
|
|
1980
|
+
pushMessage('system', 'No active session — pick a provider before changing reasoning effort.');
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (state !== 'idle' ||
|
|
1984
|
+
abortRef.current !== null ||
|
|
1985
|
+
hasUnsettledTurn() ||
|
|
1986
|
+
queuedRef.current.length > 0 ||
|
|
1987
|
+
permissionResolveRef.current !== null ||
|
|
1988
|
+
compactingRef.current) {
|
|
1989
|
+
pushMessage('system', 'Reasoning effort was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
if (slash.effort === null) {
|
|
1993
|
+
setReasoningEffort(undefined);
|
|
1994
|
+
pushMessage('system', 'Reasoning effort reset to the provider default for future main-query turns.');
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (!session.reasoningEffortLevels?.includes(slash.effort)) {
|
|
1998
|
+
pushMessage('system', `Reasoning effort was not changed: ${slash.effort} is not offered by every usable provider-chain member for this model.`);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
setReasoningEffort(slash.effort);
|
|
2002
|
+
pushMessage('system', `Reasoning effort changed to ${slash.effort} for future main-query turns in this session.`);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
1044
2005
|
case 'login':
|
|
1045
2006
|
void startOrFinishLogin(slash.pasted);
|
|
1046
2007
|
return;
|
|
@@ -1114,6 +2075,12 @@ export function App({ ctx }) {
|
|
|
1114
2075
|
case 'resume':
|
|
1115
2076
|
void doResume();
|
|
1116
2077
|
return;
|
|
2078
|
+
case 'title':
|
|
2079
|
+
void doTitle(slash.title, slash.clear);
|
|
2080
|
+
return;
|
|
2081
|
+
case 'fork':
|
|
2082
|
+
void doFork();
|
|
2083
|
+
return;
|
|
1117
2084
|
case 'expand': {
|
|
1118
2085
|
// Resolved INSIDE the updater, against `prev`.
|
|
1119
2086
|
//
|
|
@@ -1203,13 +2170,48 @@ export function App({ ctx }) {
|
|
|
1203
2170
|
// THIS session can answer from. The descriptors used for
|
|
1204
2171
|
// the merge above carry no store — they are names — so the
|
|
1205
2172
|
// registry is rebuilt here with the live one.
|
|
2173
|
+
const durableSessions = sessionsRef.current;
|
|
2174
|
+
const runScope = scopeRef.current;
|
|
2175
|
+
const generation = conversationGenRef.current;
|
|
2176
|
+
const goalCommand = slash.name === 'goal';
|
|
2177
|
+
if (goalCommand)
|
|
2178
|
+
goalCommandInFlightRef.current = true;
|
|
1206
2179
|
const registry = new HostCommandRegistry();
|
|
1207
|
-
registry.register(kernelHostCommands({
|
|
2180
|
+
registry.register(kernelHostCommands({
|
|
2181
|
+
allowedAgentIds: session?.agentIds ?? [],
|
|
2182
|
+
...(durableSessions && runScope
|
|
2183
|
+
? {
|
|
2184
|
+
goal: {
|
|
2185
|
+
store: durableSessions.goals,
|
|
2186
|
+
sessionId: runScope.sessionId,
|
|
2187
|
+
tenantId: durableSessions.tenantId,
|
|
2188
|
+
activation: goalActivation,
|
|
2189
|
+
},
|
|
2190
|
+
}
|
|
2191
|
+
: {}),
|
|
2192
|
+
}));
|
|
1208
2193
|
void (async () => {
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
2194
|
+
try {
|
|
2195
|
+
const outcome = await registry.dispatch(`/${slash.name} ${slash.args.join(' ')}`.trim());
|
|
2196
|
+
// A late readout from the conversation just left must not be
|
|
2197
|
+
// painted as state of the one now on screen.
|
|
2198
|
+
if (conversationGenRef.current !== generation)
|
|
2199
|
+
return;
|
|
2200
|
+
pushMessage('system', outcome
|
|
2201
|
+
? renderOutcome(outcome)
|
|
2202
|
+
: `/${slash.name} is registered but this session cannot run it.`);
|
|
2203
|
+
}
|
|
2204
|
+
catch (error) {
|
|
2205
|
+
if (conversationGenRef.current !== generation)
|
|
2206
|
+
return;
|
|
2207
|
+
pushMessage('system', `Could not run /${slash.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2208
|
+
}
|
|
2209
|
+
finally {
|
|
2210
|
+
if (goalCommand) {
|
|
2211
|
+
goalCommandInFlightRef.current = false;
|
|
2212
|
+
wakeGoalDriver();
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
1213
2215
|
})();
|
|
1214
2216
|
return;
|
|
1215
2217
|
}
|
|
@@ -1253,6 +2255,211 @@ export function App({ ctx }) {
|
|
|
1253
2255
|
})();
|
|
1254
2256
|
return;
|
|
1255
2257
|
}
|
|
2258
|
+
case 'review': {
|
|
2259
|
+
void (async () => {
|
|
2260
|
+
const diff = await workspaceDiff(ctx.cwd);
|
|
2261
|
+
if (diff === null) {
|
|
2262
|
+
pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
if (diff.stat.length === 0 && diff.untracked.length === 0) {
|
|
2266
|
+
// Refused rather than sent. A review request over an
|
|
2267
|
+
// unchanged tree burns a turn and comes back with a
|
|
2268
|
+
// review of nothing, which reads exactly like a review
|
|
2269
|
+
// of something.
|
|
2270
|
+
pushMessage('system', 'Nothing to review — the working tree is clean.');
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
// The same FIFO a typed message enters. A review composed
|
|
2274
|
+
// while a turn is in flight must not jump the queue, and
|
|
2275
|
+
// must not be dropped either.
|
|
2276
|
+
const text = reviewPrompt(diff.stat, diff.untracked);
|
|
2277
|
+
enqueueQueued({ kind: 'human', text });
|
|
2278
|
+
})();
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
case 'diff': {
|
|
2282
|
+
// Fire-and-forget, like its neighbours: a git invocation is not
|
|
2283
|
+
// something to block a keystroke on.
|
|
2284
|
+
void (async () => {
|
|
2285
|
+
const { summary, detail } = renderWorkspaceDiff(await workspaceDiff(ctx.cwd));
|
|
2286
|
+
setMessages((prev) => [
|
|
2287
|
+
...prev,
|
|
2288
|
+
{
|
|
2289
|
+
id: `diff-${Date.now()}`,
|
|
2290
|
+
role: 'system',
|
|
2291
|
+
content: summary,
|
|
2292
|
+
...(detail.length > 0 ? { detail } : {}),
|
|
2293
|
+
},
|
|
2294
|
+
]);
|
|
2295
|
+
})();
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
case 'compact': {
|
|
2299
|
+
if (!session) {
|
|
2300
|
+
pushMessage('system', 'No session yet — nothing to compact.');
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
if (abortRef.current || state !== 'idle' || hasUnsettledTurn()) {
|
|
2304
|
+
pushMessage('system', 'A turn is still running or settling. Compacting now would summarize a conversation while its next message is being written — wait for it to finish, or press esc to stop it and wait for its partial reply to be saved.');
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
if (compactingRef.current)
|
|
2308
|
+
return;
|
|
2309
|
+
compactingRef.current = true;
|
|
2310
|
+
setCompacting(true);
|
|
2311
|
+
setState('thinking');
|
|
2312
|
+
// Fire-and-forget, the same shape `feedback` above uses: this
|
|
2313
|
+
// switch is synchronous while compaction and its optional
|
|
2314
|
+
// verifier are asynchronous. The transcript reports the outcome.
|
|
2315
|
+
void (async () => {
|
|
2316
|
+
try {
|
|
2317
|
+
// The same lossless record `runTurn` reads — including an earlier
|
|
2318
|
+
// compaction summary and attachments. The transcript is only its
|
|
2319
|
+
// rendered view and is never reverse-engineered into history.
|
|
2320
|
+
const result = await session.compact(modelHistoryRef.current);
|
|
2321
|
+
if (!result) {
|
|
2322
|
+
// Not an error, and not silence: a conversation too short
|
|
2323
|
+
// to shed anything is a real answer to a question the
|
|
2324
|
+
// operator asked on purpose.
|
|
2325
|
+
pushMessage('system', 'Nothing to compact yet — this conversation is short enough that adding a summary would save no messages.');
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
// Durable first, screen second. Reporting success and only then
|
|
2329
|
+
// discovering `/resume` still has the old history is the exact
|
|
2330
|
+
// false-success state this command used to create.
|
|
2331
|
+
const sessions = sessionsRef.current;
|
|
2332
|
+
const destination = scopeRef.current?.sessionId;
|
|
2333
|
+
if (sessions && destination) {
|
|
2334
|
+
await persistenceTailRef.current;
|
|
2335
|
+
await replaceConversation(sessions, destination, result.messages);
|
|
2336
|
+
}
|
|
2337
|
+
modelHistoryRef.current = result.messages;
|
|
2338
|
+
// The old gauge measured the history that was just replaced.
|
|
2339
|
+
// Clear it only after the durable projection and live model
|
|
2340
|
+
// history agree; until then the old measurement is still the
|
|
2341
|
+
// truthful one. The next run publishes a fresh measurement.
|
|
2342
|
+
setContext(null);
|
|
2343
|
+
// How many user/assistant turns survived the pass.
|
|
2344
|
+
// `keepRecentRows` explains why the transcript is trimmed
|
|
2345
|
+
// to match rather than rebuilt from `result.messages`.
|
|
2346
|
+
const keptTurns = result.messages.filter((m) => m.role === 'user' || m.role === 'assistant').length;
|
|
2347
|
+
// The summary belongs before the surviving turns. `<Static>` only
|
|
2348
|
+
// emits items beyond the index it has already printed, so prepending
|
|
2349
|
+
// into the mounted transcript makes the summary invisible. Remount
|
|
2350
|
+
// the projection just as `/resume` does when it replaces the log.
|
|
2351
|
+
resetTranscript();
|
|
2352
|
+
setMessages((prev) => {
|
|
2353
|
+
const summaryRow = {
|
|
2354
|
+
id: `compact-${Date.now()}`,
|
|
2355
|
+
role: 'system',
|
|
2356
|
+
content: `Compacted ${result.shed} earlier message(s) into a summary.${result.usage.totalTokens > 0
|
|
2357
|
+
? ` Verifier used ${result.usage.totalTokens.toLocaleString('en-US')} tokens.`
|
|
2358
|
+
: ''}${sessions && destination
|
|
2359
|
+
? ''
|
|
2360
|
+
: ' Conversation persistence is unavailable, so this compacted history lasts only for this process.'}`,
|
|
2361
|
+
detail: summaryDetail(result.summary),
|
|
2362
|
+
};
|
|
2363
|
+
return [summaryRow, ...keepRecentRows(prev, keptTurns)];
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
catch (err) {
|
|
2367
|
+
pushMessage('system', `Compaction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2368
|
+
}
|
|
2369
|
+
finally {
|
|
2370
|
+
compactingRef.current = false;
|
|
2371
|
+
setCompacting(false);
|
|
2372
|
+
setState('idle');
|
|
2373
|
+
}
|
|
2374
|
+
})();
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
case 'copy': {
|
|
2378
|
+
const target = lastCompletedOutputRef.current;
|
|
2379
|
+
if (!target) {
|
|
2380
|
+
pushMessage('system', 'Nothing to copy yet — /copy applies to the latest assistant answer that finished normally.');
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
const result = writeClipboardText(target.text, {
|
|
2384
|
+
isTTY: stdout.isTTY,
|
|
2385
|
+
write: writeStdout,
|
|
2386
|
+
});
|
|
2387
|
+
switch (result.kind) {
|
|
2388
|
+
case 'request-sent':
|
|
2389
|
+
pushMessage('system', `Copy request sent for the ${target.provenance === 'persisted'
|
|
2390
|
+
? 'latest persisted assistant output'
|
|
2391
|
+
: 'latest normally completed answer'} (${result.bytes.toLocaleString()} bytes). Terminal, multiplexer or remote-session policy may ignore OSC 52; if the clipboard did not change, enable terminal clipboard access.`);
|
|
2392
|
+
break;
|
|
2393
|
+
case 'unavailable':
|
|
2394
|
+
pushMessage('system', `Cannot send a copy request here — ${result.detail}. /copy needs an interactive terminal with OSC 52 clipboard support.`);
|
|
2395
|
+
break;
|
|
2396
|
+
case 'too-large':
|
|
2397
|
+
pushMessage('system', `Cannot send this answer to the terminal clipboard — it is ${result.bytes.toLocaleString()} bytes and the OSC 52 safety limit is ${result.limit.toLocaleString()}. Nothing was truncated.`);
|
|
2398
|
+
break;
|
|
2399
|
+
case 'write-failed':
|
|
2400
|
+
pushMessage('system', `Could not send the terminal copy request: ${result.detail}`);
|
|
2401
|
+
break;
|
|
2402
|
+
default: {
|
|
2403
|
+
const exhaustive = result;
|
|
2404
|
+
void exhaustive;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
case 'raw': {
|
|
2410
|
+
const enabled = slash.enabled === 'toggle' ? !rawOutput : slash.enabled;
|
|
2411
|
+
// Rows already emitted by <Static> cannot observe a new render
|
|
2412
|
+
// function. Clear the terminal, reset its monotone floor and
|
|
2413
|
+
// remount the retained rows so this is a transcript-wide mode,
|
|
2414
|
+
// not a flag that reaches only future output.
|
|
2415
|
+
setRawOutput(enabled);
|
|
2416
|
+
resetTranscript();
|
|
2417
|
+
pushMessage('system', enabled
|
|
2418
|
+
? 'Raw output mode on — transcript source is shown without Markdown styling or collapsed bodies; terminal controls remain visible escapes.'
|
|
2419
|
+
: 'Raw output mode off — rich transcript rendering restored.');
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
case 'export': {
|
|
2423
|
+
const sessions = sessionsRef.current;
|
|
2424
|
+
const destination = scopeRef.current?.sessionId;
|
|
2425
|
+
if (!sessions || !destination) {
|
|
2426
|
+
pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
if (abortRef.current ||
|
|
2430
|
+
state !== 'idle' ||
|
|
2431
|
+
hasUnsettledTurn() ||
|
|
2432
|
+
compactingRef.current ||
|
|
2433
|
+
queuedRef.current.length > 0) {
|
|
2434
|
+
pushMessage('system', 'A turn, compaction, or queued prompt is still running or settling. Export waits for a stable durable boundary; try again when the composer is idle.');
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
exportingRef.current = true;
|
|
2438
|
+
setState('thinking');
|
|
2439
|
+
const generation = conversationGenRef.current;
|
|
2440
|
+
const path = slash.path ?? `namzu-conversation-${destination}.md`;
|
|
2441
|
+
void (async () => {
|
|
2442
|
+
try {
|
|
2443
|
+
await persistenceTailRef.current;
|
|
2444
|
+
if (conversationGenRef.current !== generation ||
|
|
2445
|
+
scopeRef.current?.sessionId !== destination) {
|
|
2446
|
+
throw new Error('The active conversation changed before the export reached its durable boundary.');
|
|
2447
|
+
}
|
|
2448
|
+
const projected = await conversationMarkdown(sessions, destination);
|
|
2449
|
+
const written = await writeConversationExport(projected.markdown, path, ctx.cwd);
|
|
2450
|
+
pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
|
|
2451
|
+
}
|
|
2452
|
+
catch (err) {
|
|
2453
|
+
pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2454
|
+
}
|
|
2455
|
+
finally {
|
|
2456
|
+
exportingRef.current = false;
|
|
2457
|
+
if (conversationGenRef.current === generation)
|
|
2458
|
+
setState('idle');
|
|
2459
|
+
}
|
|
2460
|
+
})();
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
1256
2463
|
default: {
|
|
1257
2464
|
// Exhaustive on purpose. Without it a `SlashAction` kind added
|
|
1258
2465
|
// and not handled here falls out of the switch into the send
|
|
@@ -1266,24 +2473,161 @@ export function App({ ctx }) {
|
|
|
1266
2473
|
}
|
|
1267
2474
|
}
|
|
1268
2475
|
}
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1272
|
-
|
|
2476
|
+
// Every model-bound prompt enters one FIFO, including one submitted while
|
|
2477
|
+
// idle. If idle submissions bypassed it, a prompt arriving after a turn's
|
|
2478
|
+
// `setState('idle')` but before the passive drain effect could start ahead
|
|
2479
|
+
// of an older queued prompt. The drain below is the only turn starter.
|
|
2480
|
+
//
|
|
2481
|
+
// Keep the attachment array beside its text. Reconstructing a prompt from
|
|
2482
|
+
// the transcript later cannot recover bytes whose composer chip is gone.
|
|
2483
|
+
advanceQueueContinuation();
|
|
2484
|
+
enqueueQueued({
|
|
2485
|
+
kind: 'human',
|
|
2486
|
+
text: outgoing,
|
|
2487
|
+
...(attachments && attachments.length > 0 ? { attachments: [...attachments] } : {}),
|
|
2488
|
+
});
|
|
2489
|
+
}, [
|
|
2490
|
+
activeSkills,
|
|
2491
|
+
advanceQueueContinuation,
|
|
2492
|
+
ctx.cwd,
|
|
2493
|
+
doResume,
|
|
2494
|
+
enqueueQueued,
|
|
2495
|
+
exit,
|
|
2496
|
+
hasUnsettledTurn,
|
|
2497
|
+
nextId,
|
|
2498
|
+
pushMessage,
|
|
2499
|
+
rawOutput,
|
|
2500
|
+
resetTranscript,
|
|
2501
|
+
setReasoningEffort,
|
|
2502
|
+
slashCtx,
|
|
2503
|
+
startFreshConversation,
|
|
2504
|
+
state,
|
|
2505
|
+
]);
|
|
2506
|
+
// Admit automatic work only at a whole-App durable boundary. This effect
|
|
2507
|
+
// reserves; it never starts a turn itself. The one queue pump below remains
|
|
2508
|
+
// the sole starter, which is what keeps a human prompt that arrives during
|
|
2509
|
+
// admission ahead of the resulting goal round.
|
|
2510
|
+
useEffect(() => {
|
|
2511
|
+
void goalDriveVersion;
|
|
2512
|
+
// The ref is the synchronous gate; this state read is the wake-up edge.
|
|
2513
|
+
// A failed fork/edit can close its mutation boundary while the screen stays
|
|
2514
|
+
// idle, so no other dependency would necessarily re-run this driver.
|
|
2515
|
+
void conversationMutation;
|
|
2516
|
+
if (goalDriveInFlightRef.current ||
|
|
2517
|
+
state !== 'idle' ||
|
|
2518
|
+
phase !== 'ready' ||
|
|
2519
|
+
!session?.hasProvider ||
|
|
2520
|
+
queuedRef.current.length > 0 ||
|
|
2521
|
+
abortRef.current ||
|
|
2522
|
+
hasUnsettledTurn() ||
|
|
2523
|
+
conversationMutationRef.current ||
|
|
2524
|
+
exportingRef.current ||
|
|
2525
|
+
compactingRef.current ||
|
|
2526
|
+
goalCommandInFlightRef.current) {
|
|
1273
2527
|
return;
|
|
1274
2528
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
2529
|
+
const durableSessions = sessionsRef.current;
|
|
2530
|
+
const scope = scopeRef.current;
|
|
2531
|
+
if (!durableSessions?.turnEvidence || !scope)
|
|
2532
|
+
return;
|
|
2533
|
+
const generation = conversationGenRef.current;
|
|
2534
|
+
const sessionId = scope.sessionId;
|
|
2535
|
+
const armed = goalActivation.get(sessionId);
|
|
2536
|
+
if (!armed)
|
|
2537
|
+
return;
|
|
2538
|
+
goalDriveInFlightRef.current = true;
|
|
2539
|
+
void (async () => {
|
|
2540
|
+
try {
|
|
2541
|
+
await persistenceTailRef.current;
|
|
2542
|
+
if (conversationGenRef.current !== generation ||
|
|
2543
|
+
scopeRef.current?.sessionId !== sessionId ||
|
|
2544
|
+
state !== 'idle' ||
|
|
2545
|
+
abortRef.current ||
|
|
2546
|
+
hasUnsettledTurn(generation) ||
|
|
2547
|
+
conversationMutationRef.current ||
|
|
2548
|
+
exportingRef.current ||
|
|
2549
|
+
compactingRef.current ||
|
|
2550
|
+
goalCommandInFlightRef.current ||
|
|
2551
|
+
queuedRef.current.length > 0 ||
|
|
2552
|
+
!goalActivation.isArmed(sessionId, armed)) {
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const current = await durableSessions.goals.getGoal(sessionId, durableSessions.tenantId);
|
|
2556
|
+
if (!current ||
|
|
2557
|
+
current.phase !== 'active' ||
|
|
2558
|
+
current.id !== armed.id ||
|
|
2559
|
+
current.revision !== armed.revision) {
|
|
2560
|
+
goalActivation.disarm(sessionId, armed);
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
const authority = await durableSessions.goals.admitRound(sessionId, durableSessions.tenantId, armed);
|
|
2564
|
+
// Human input may have entered or even started while admission was on
|
|
2565
|
+
// disk; keep it. The goal round appends after that FIFO. A conversation
|
|
2566
|
+
// or goal command is different: it revoked this scheduling boundary.
|
|
2567
|
+
if (conversationGenRef.current !== generation ||
|
|
2568
|
+
scopeRef.current?.sessionId !== sessionId ||
|
|
2569
|
+
conversationMutationRef.current ||
|
|
2570
|
+
goalCommandInFlightRef.current ||
|
|
2571
|
+
!goalActivation.isArmed(sessionId, armed)) {
|
|
2572
|
+
goalActivation.disarm(sessionId, armed);
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
goalActivation.arm(authority);
|
|
2576
|
+
enqueueQueued({
|
|
2577
|
+
kind: 'goal',
|
|
2578
|
+
text: goalRoundPrompt(authority),
|
|
2579
|
+
goalRound: authority,
|
|
2580
|
+
generation,
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
catch (error) {
|
|
2584
|
+
goalActivation.disarm(sessionId, armed);
|
|
2585
|
+
if (error instanceof GoalRoundLimitError) {
|
|
2586
|
+
pushMessage('system', `Goal blocked after ${error.goal.roundsAdmitted} admitted round${error.goal.roundsAdmitted === 1 ? '' : 's'}: ${error.goal.blockedReason?.message ?? 'the round limit was reached'}`);
|
|
2587
|
+
}
|
|
2588
|
+
else if (!(error instanceof StaleGoalError)) {
|
|
2589
|
+
pushMessage('system', `Automatic goal continuation was disarmed because round admission failed: ${error instanceof Error ? error.message : String(error)}. Run /goal resume to retry explicitly.`);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
finally {
|
|
2593
|
+
goalDriveInFlightRef.current = false;
|
|
2594
|
+
wakeGoalDriver();
|
|
2595
|
+
}
|
|
2596
|
+
})();
|
|
2597
|
+
}, [
|
|
2598
|
+
enqueueQueued,
|
|
2599
|
+
goalActivation,
|
|
2600
|
+
goalDriveVersion,
|
|
2601
|
+
hasUnsettledTurn,
|
|
2602
|
+
phase,
|
|
2603
|
+
pushMessage,
|
|
2604
|
+
conversationMutation,
|
|
2605
|
+
session,
|
|
2606
|
+
state,
|
|
2607
|
+
wakeGoalDriver,
|
|
2608
|
+
]);
|
|
1277
2609
|
// Drain the queue: when a turn settles (idle) and nothing is running,
|
|
1278
2610
|
// send the next queued message automatically.
|
|
1279
2611
|
useEffect(() => {
|
|
1280
|
-
if (state !== 'idle' ||
|
|
2612
|
+
if (state !== 'idle' ||
|
|
2613
|
+
phase !== 'ready' ||
|
|
2614
|
+
queuedRef.current.length === 0 ||
|
|
2615
|
+
queuePauseRef.current !== null ||
|
|
2616
|
+
abortRef.current ||
|
|
2617
|
+
hasUnsettledTurn() ||
|
|
2618
|
+
conversationMutationRef.current ||
|
|
2619
|
+
exportingRef.current ||
|
|
2620
|
+
compactingRef.current ||
|
|
2621
|
+
goalCommandInFlightRef.current)
|
|
1281
2622
|
return;
|
|
1282
|
-
|
|
1283
|
-
|
|
2623
|
+
// Read and remove from the synchronous source of truth. `queued` is the
|
|
2624
|
+
// render snapshot that scheduled this effect; a later input may already
|
|
2625
|
+
// have appended to the ref, and replacing from that old snapshot would
|
|
2626
|
+
// erase it.
|
|
2627
|
+
const next = dequeueQueued();
|
|
1284
2628
|
if (next !== undefined)
|
|
1285
2629
|
void runTurn(next);
|
|
1286
|
-
}, [state, phase, queued, runTurn]);
|
|
2630
|
+
}, [state, phase, queued, queuePause, dequeueQueued, hasUnsettledTurn, runTurn]);
|
|
1287
2631
|
// One-shot update check on launch.
|
|
1288
2632
|
// Best-effort; surfaces a single notice when something newer is out.
|
|
1289
2633
|
const updateCheckedRef = useRef(false);
|
|
@@ -1298,6 +2642,19 @@ export function App({ ctx }) {
|
|
|
1298
2642
|
pushMessage('system', `⬆ Update available:\n${lines.join('\n')}`);
|
|
1299
2643
|
});
|
|
1300
2644
|
}, [phase, ctx.version, pushMessage]);
|
|
2645
|
+
const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal) => {
|
|
2646
|
+
try {
|
|
2647
|
+
await hydrateSession(prefs, detectedNow, signal);
|
|
2648
|
+
}
|
|
2649
|
+
catch (err) {
|
|
2650
|
+
// A superseded choice no longer owns even its failure message. Its
|
|
2651
|
+
// eventual session object is disposed inside `hydrateSession`; a live
|
|
2652
|
+
// choice stays on the picker with the actionable construction error.
|
|
2653
|
+
if (signal.aborted)
|
|
2654
|
+
return;
|
|
2655
|
+
setPickerNotice(`Could not start the selected provider: ${err instanceof Error ? err.message : String(err)}`);
|
|
2656
|
+
}
|
|
2657
|
+
}, [hydrateSession]);
|
|
1301
2658
|
/**
|
|
1302
2659
|
* A credential the operator typed. Held in memory for this process only.
|
|
1303
2660
|
*
|
|
@@ -1306,7 +2663,7 @@ export function App({ ctx }) {
|
|
|
1306
2663
|
* provider choice is not persisted either, because persisting it would leave
|
|
1307
2664
|
* a preference pointing at a credential that will not exist next launch.
|
|
1308
2665
|
*/
|
|
1309
|
-
const handleTypedCredential = useCallback((credential, disposition) => {
|
|
2666
|
+
const handleTypedCredential = useCallback((credential, disposition, signal) => {
|
|
1310
2667
|
const next = [credential, ...detected.filter((d) => d.entry.id !== credential.entry.id)];
|
|
1311
2668
|
setDetected(next);
|
|
1312
2669
|
setKeyEntryFor(null);
|
|
@@ -1327,9 +2684,9 @@ export function App({ ctx }) {
|
|
|
1327
2684
|
providers: [{ id: credential.entry.id }],
|
|
1328
2685
|
subagents: { active: [] },
|
|
1329
2686
|
};
|
|
1330
|
-
void
|
|
1331
|
-
}, [detected,
|
|
1332
|
-
const handlePickerSubmit = useCallback((selection) => {
|
|
2687
|
+
void hydrateFromPicker(prefs, next, signal);
|
|
2688
|
+
}, [detected, hydrateFromPicker, pushMessage]);
|
|
2689
|
+
const handlePickerSubmit = useCallback((selection, signal) => {
|
|
1333
2690
|
// One member for now. The picker builds a longer chain in a later
|
|
1334
2691
|
// change; the shape it writes into is already the chain.
|
|
1335
2692
|
const prefs = {
|
|
@@ -1351,8 +2708,8 @@ export function App({ ctx }) {
|
|
|
1351
2708
|
pushMessage('system', `Could not save preferences: ${err instanceof Error ? err.message : String(err)}`);
|
|
1352
2709
|
return;
|
|
1353
2710
|
}
|
|
1354
|
-
void
|
|
1355
|
-
}, [detected,
|
|
2711
|
+
void hydrateFromPicker(prefs, detected, signal);
|
|
2712
|
+
}, [detected, hydrateFromPicker, pushMessage]);
|
|
1356
2713
|
/**
|
|
1357
2714
|
* Leaving the picker returns to whatever was on screen before it.
|
|
1358
2715
|
*
|
|
@@ -1396,6 +2753,31 @@ export function App({ ctx }) {
|
|
|
1396
2753
|
exit();
|
|
1397
2754
|
return;
|
|
1398
2755
|
}
|
|
2756
|
+
// Previous-prompt picker owns the keyboard. Esc keeps stepping toward
|
|
2757
|
+
// older prompts, matching the second Esc that opened it; q is the cancel.
|
|
2758
|
+
if (phase === 'edit') {
|
|
2759
|
+
if (editCommittedRef.current)
|
|
2760
|
+
return;
|
|
2761
|
+
if ((key.ctrl && input === 'c') || input.toLowerCase() === 'q') {
|
|
2762
|
+
setEditList([]);
|
|
2763
|
+
setPhase('ready');
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
if (key.escape || key.leftArrow || key.upArrow) {
|
|
2767
|
+
setSelectedEdit((index) => Math.max(0, index - 1));
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
if (key.rightArrow || key.downArrow) {
|
|
2771
|
+
setSelectedEdit((index) => Math.min(editList.length - 1, index + 1));
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
if (key.return) {
|
|
2775
|
+
const target = editList[selectedEdit];
|
|
2776
|
+
if (target)
|
|
2777
|
+
void confirmPromptEdit(target);
|
|
2778
|
+
}
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
1399
2781
|
// Resume picker owns the keyboard while open.
|
|
1400
2782
|
if (phase === 'resume') {
|
|
1401
2783
|
// Once a conversation is being read, the keyboard does nothing here.
|
|
@@ -1548,19 +2930,34 @@ export function App({ ctx }) {
|
|
|
1548
2930
|
// Background is left natural — we inherit the terminal's own background
|
|
1549
2931
|
// and only theme the foreground. Forcing
|
|
1550
2932
|
// a filled bg left mismatched patches around bordered areas, so we don't.
|
|
1551
|
-
return (_jsxs(Box, { flexDirection: "column", children: [phase !== 'ready' ? (_jsx(Banner, { version: ctx.version, session: session,
|
|
2933
|
+
return (_jsxs(Box, { flexDirection: "column", children: [phase !== 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : null, _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: detected, currentProvider: currentProvider, currentModel: session?.modelSummary ?? null, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: (signal) => void startLoginFromPicker(signal), keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, resetKey: resetKey, raw: rawOutput, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? _jsx(PermissionOverlay, { toolCalls: permission.toolCalls }) : null, _jsxs(ComposerFrame, { focus: state === 'idle' && phase === 'ready' && conversationMutation === null, hidden: permission !== null, children: [queued.length > 0 && permission === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
|
|
2934
|
+
? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
|
|
2935
|
+
: 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
|
|
2936
|
+
state === 'awaiting-permission' ||
|
|
2937
|
+
compacting ||
|
|
2938
|
+
conversationMutation !== null, hidden: permission !== null,
|
|
1552
2939
|
// A turn is running, so Esc is the interrupt and not
|
|
1553
2940
|
// the composer's clear.
|
|
1554
|
-
escapeInterrupts: state === 'thinking' || state === 'tool', onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), userCommands: userCommands, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary
|
|
2941
|
+
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
|
|
2942
|
+
? `${session.modelSummary}${reasoningEffort ? ` · effort ${reasoningEffort}` : ''}`
|
|
2943
|
+
: null, state: state, hint: conversationMutation === 'fork'
|
|
2944
|
+
? 'forking conversation — input is paused'
|
|
2945
|
+
: conversationMutation === 'edit'
|
|
2946
|
+
? 'branching before prompt — input is paused'
|
|
2947
|
+
: conversationMutation === 'new'
|
|
2948
|
+
? 'starting a fresh conversation — input is paused'
|
|
2949
|
+
: compacting
|
|
2950
|
+
? 'compacting conversation — input is paused'
|
|
2951
|
+
: hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] })] }));
|
|
1555
2952
|
}
|
|
1556
|
-
function Banner({ version, session,
|
|
2953
|
+
function Banner({ version, session, permissionMode, cwd, }) {
|
|
1557
2954
|
const cols = process.stdout.columns ?? 80;
|
|
1558
2955
|
const wide = cols >= NAMZU_WORDMARK_MIN_WIDTH;
|
|
1559
2956
|
const provider = session?.providerSummary;
|
|
1560
2957
|
const model = session?.modelSummary;
|
|
1561
2958
|
const home = process.env.HOME;
|
|
1562
2959
|
const prettyCwd = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
1563
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, paddingBottom: 1, children: [_jsxs(Box, { flexDirection: "row", children: [wide ? (_jsx(Box, { flexDirection: "column", marginRight: 2, children: NAMZU_WORDMARK.map((line, i) => (_jsx(Text, { color: NAMZU_WORDMARK_GRADIENT[i], bold: true, children: line }, `wm-${i}`))) })) : (_jsxs(Text, { color: NAMZU_MARK_COLOR, children: [NAMZU_MARK, " "] })), _jsxs(Box, { flexDirection: "column", marginTop: wide ? 1 : 0, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.text.secondary, children: "Cogitave" }), wide ? null : (_jsxs(Text, { color: NAMZU_MARK_COLOR, bold: true, children: [' ', "Namzu"] })), _jsxs(Text, { color: theme.text.muted, children: [" v", version] })] }), _jsx(Text, { color: theme.text.secondary, children: provider ? `${provider}${model ? ` · ${model}` : ''}` : 'the agent in your terminal' }), _jsx(Text, { color: theme.text.muted, children: prettyCwd })] })] }),
|
|
2960
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, paddingBottom: 1, children: [_jsxs(Box, { flexDirection: "row", children: [wide ? (_jsx(Box, { flexDirection: "column", marginRight: 2, children: NAMZU_WORDMARK.map((line, i) => (_jsx(Text, { color: NAMZU_WORDMARK_GRADIENT[i], bold: true, children: line }, `wm-${i}`))) })) : (_jsxs(Text, { color: NAMZU_MARK_COLOR, children: [NAMZU_MARK, " "] })), _jsxs(Box, { flexDirection: "column", marginTop: wide ? 1 : 0, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.text.secondary, children: "Cogitave" }), wide ? null : (_jsxs(Text, { color: NAMZU_MARK_COLOR, bold: true, children: [' ', "Namzu"] })), _jsxs(Text, { color: theme.text.muted, children: [" v", version] })] }), _jsx(Text, { color: theme.text.secondary, children: provider ? `${provider}${model ? ` · ${model}` : ''}` : 'the agent in your terminal' }), _jsx(Text, { color: theme.text.muted, children: prettyCwd })] })] }), permissionMode === 'auto' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.status.error, bold: true, children: "\u26A0 launched in auto permission mode \u2014 undecided tools run without asking until /permissions changes it" }) })) : null] }));
|
|
1564
2961
|
}
|
|
1565
2962
|
function TranscriptFrame({ children }) {
|
|
1566
2963
|
// Borderless, edge-to-edge — the message glyph gutter provides structure.
|
|
@@ -1587,6 +2984,8 @@ canReturn = false) {
|
|
|
1587
2984
|
return 'y trust this folder · n / esc exit';
|
|
1588
2985
|
if (phase === 'resume')
|
|
1589
2986
|
return '↑↓ navigate · enter resume · esc cancel';
|
|
2987
|
+
if (phase === 'edit')
|
|
2988
|
+
return 'Esc / ← older · → newer · enter fork and edit · q cancel';
|
|
1590
2989
|
if (phase === 'probing')
|
|
1591
2990
|
return 'discovering providers…';
|
|
1592
2991
|
// Esc does two different things here depending on how the picker was reached,
|
|
@@ -1605,6 +3004,19 @@ canReturn = false) {
|
|
|
1605
3004
|
return 'y approve · n / esc reject · a approve all';
|
|
1606
3005
|
if (state !== 'idle')
|
|
1607
3006
|
return 'agent is working — esc to interrupt';
|
|
1608
|
-
return '/help · @file / Ctrl+V
|
|
3007
|
+
return '/help · Esc×2 edit previous · @file / Ctrl+V attach · Ctrl+C ×2 to exit';
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* The summary text, as collapsible detail under the compaction row.
|
|
3011
|
+
*
|
|
3012
|
+
* Collapsed rather than printed: a structured summary is long by design, and a
|
|
3013
|
+
* transcript that dumps it in full turns "I freed up context" into a wall taller
|
|
3014
|
+
* than the thing it replaced. Kept ON the row rather than dropped, because the
|
|
3015
|
+
* summary IS what the model will read from here on — an operator who wants to
|
|
3016
|
+
* check what survived has to be able to.
|
|
3017
|
+
*/
|
|
3018
|
+
function summaryDetail(summary) {
|
|
3019
|
+
const text = typeof summary.content === 'string' ? summary.content : '';
|
|
3020
|
+
return text.length > 0 ? text.split('\n') : ['(the summary was empty)'];
|
|
1609
3021
|
}
|
|
1610
3022
|
//# sourceMappingURL=App.js.map
|