pum-agent 0.2.34-beta.2 → 0.2.36-beta.1
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 +12 -4
- package/package.json +3 -4
- package/src/animation.tsx +20 -7
- package/src/app.tsx +786 -40
- package/src/check-mode-prompt.ts +13 -13
- package/src/check-mode.ts +105 -10
- package/src/cli.ts +36 -1
- package/src/commands.ts +37 -1
- package/src/context-estimate.ts +55 -0
- package/src/context-guidance.ts +5 -5
- package/src/context-recovery.ts +22 -0
- package/src/context-window.ts +197 -80
- package/src/conversation-branch-popup.tsx +46 -0
- package/src/conversation-branch.ts +227 -0
- package/src/file-checkpoints.ts +337 -0
- package/src/file-mutation-guard.ts +224 -0
- package/src/filesystem-sandbox.ts +5 -2
- package/src/headless.ts +87 -11
- package/src/help-popup.tsx +6 -0
- package/src/index.tsx +2 -0
- package/src/lsp-files.ts +102 -0
- package/src/lsp-protocol.ts +275 -0
- package/src/lsp.ts +203 -0
- package/src/main.tsx +154 -18
- package/src/mcp-config.ts +100 -0
- package/src/mcp-process.ts +244 -0
- package/src/mcp-protocol.ts +385 -0
- package/src/mcp.ts +230 -0
- package/src/memory-context.ts +91 -0
- package/src/memory.ts +25 -23
- package/src/operational-context.ts +91 -0
- package/src/plan-mode.ts +139 -0
- package/src/project-validation.ts +316 -0
- package/src/replay.ts +6 -0
- package/src/request-diagnostics-access.ts +25 -0
- package/src/request-diagnostics.ts +210 -0
- package/src/runtime-settings.ts +363 -0
- package/src/sandbox/linux.ts +31 -6
- package/src/sandbox/types.ts +5 -0
- package/src/session-lock-runtime.ts +332 -25
- package/src/status-bar.tsx +2 -0
- package/src/status-metadata.ts +8 -1
- package/src/subagents/manager.ts +137 -23
- package/src/subagents/readonly.ts +34 -11
- package/src/theme.ts +10 -9
- package/src/tool-groups.ts +85 -27
- package/src/transcript-history.ts +93 -18
- package/src/web-search.ts +228 -33
- package/src/model-catalog.ts +0 -47
package/src/app.tsx
CHANGED
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
type TextareaRenderable,
|
|
8
8
|
} from "@opentui/core";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { diagnosticsCommandText } from "./request-diagnostics-access";
|
|
11
|
+
import { checkpointControllerForSession, checkpointRecoveryFailureText } from "./file-checkpoints";
|
|
12
|
+
import { VALIDATION_CUSTOM_TYPE, validationForSession } from "./project-validation";
|
|
13
|
+
import type { McpController } from "./mcp";
|
|
14
|
+
import type { LspController } from "./lsp";
|
|
10
15
|
import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
11
16
|
import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
|
12
17
|
import type { AgentSession, BashOperations, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
@@ -161,6 +166,25 @@ import {
|
|
|
161
166
|
} from "./settings-command";
|
|
162
167
|
import { isRejectedToolResult, rejectedToolReason } from "./check-mode";
|
|
163
168
|
import { SessionHistoryPopup } from "./session-history-popup";
|
|
169
|
+
import {
|
|
170
|
+
ConversationBranchPopup,
|
|
171
|
+
CONVERSATION_BRANCH_PAGE_SIZE,
|
|
172
|
+
} from "./conversation-branch-popup";
|
|
173
|
+
import {
|
|
174
|
+
hasConversationBranchPendingInput,
|
|
175
|
+
listConversationBranchPoints,
|
|
176
|
+
type ConversationBranchPage,
|
|
177
|
+
type ConversationBranchSelection,
|
|
178
|
+
} from "./conversation-branch";
|
|
179
|
+
import type { ConversationBranchResult, PlanModeTransitionResult } from "./session-lock-runtime";
|
|
180
|
+
import {
|
|
181
|
+
describePlanRecord,
|
|
182
|
+
loadPlanRecord,
|
|
183
|
+
planModeForSession,
|
|
184
|
+
savePlanRecord,
|
|
185
|
+
validatePlanText,
|
|
186
|
+
type PlanMode,
|
|
187
|
+
} from "./plan-mode";
|
|
164
188
|
import type { SessionHistoryItem } from "./session-history-metadata";
|
|
165
189
|
import { setWritingStyle, WRITING_STYLES } from "./writing-style";
|
|
166
190
|
import {
|
|
@@ -168,6 +192,7 @@ import {
|
|
|
168
192
|
setExplanationStrength,
|
|
169
193
|
} from "./explanation-strength";
|
|
170
194
|
import { setCheckModeConfig } from "./check-mode";
|
|
195
|
+
import { isRuntimeIdle, runtimeSettings, runtimeSettingsPendingNotice } from "./runtime-settings";
|
|
171
196
|
import { applyCheckPathCommand, parseCheckPathCommand } from "./check-paths";
|
|
172
197
|
import { pruneEditedMarkers, reindexMarkers } from "./attachment-markers";
|
|
173
198
|
import {
|
|
@@ -801,6 +826,8 @@ const DEFAULT_PROMPT_STASH_STORE: PromptStashStore = {
|
|
|
801
826
|
remove: removePromptStash,
|
|
802
827
|
};
|
|
803
828
|
|
|
829
|
+
const EMPTY_BRANCH_PAGE: ConversationBranchPage = { points: [], total: 0, nextOffset: null };
|
|
830
|
+
|
|
804
831
|
/** Move any buffered stream into the transcript so later lines land in order. */
|
|
805
832
|
function flushed(t: Transcript): Transcript {
|
|
806
833
|
return flushStream(t);
|
|
@@ -813,6 +840,8 @@ export function App({
|
|
|
813
840
|
loadSessions,
|
|
814
841
|
onSwitchSession,
|
|
815
842
|
onRelocate,
|
|
843
|
+
onBranchConversation,
|
|
844
|
+
onTransitionPlanMode,
|
|
816
845
|
settings: initial,
|
|
817
846
|
searchProviders,
|
|
818
847
|
subagentManager,
|
|
@@ -841,6 +870,8 @@ export function App({
|
|
|
841
870
|
initialRelocation,
|
|
842
871
|
initialCwd,
|
|
843
872
|
userBashOperations,
|
|
873
|
+
mcpForSession,
|
|
874
|
+
lspForSession,
|
|
844
875
|
}: {
|
|
845
876
|
session: AgentSession;
|
|
846
877
|
modelRuntime: ModelRuntime;
|
|
@@ -849,6 +880,24 @@ export function App({
|
|
|
849
880
|
onSwitchSession: (path: string) => Promise<AgentSession | null>;
|
|
850
881
|
/** Move this same session to another directory. Null when it could not move. */
|
|
851
882
|
onRelocate?: (targetCwd: string) => Promise<AgentSession | null>;
|
|
883
|
+
/**
|
|
884
|
+
* Same-session conversation navigation. The host keeps the canonical file,
|
|
885
|
+
* id and ownership; it never restores files or companion state. Null means
|
|
886
|
+
* the host declined without changing the conversation.
|
|
887
|
+
*/
|
|
888
|
+
onBranchConversation?: (
|
|
889
|
+
selection: ConversationBranchSelection,
|
|
890
|
+
options: { validate: () => void; validateRetired: () => void },
|
|
891
|
+
) => Promise<ConversationBranchResult | null>;
|
|
892
|
+
/**
|
|
893
|
+
* Enter or leave the enforced plan-only role (#51). Same canonical session and
|
|
894
|
+
* ownership; the host rebuilds the runtime with the new role. A failed
|
|
895
|
+
* transition reports `plan`, never a half-upgraded runtime.
|
|
896
|
+
*/
|
|
897
|
+
onTransitionPlanMode?: (
|
|
898
|
+
mode: PlanMode,
|
|
899
|
+
options: { validate: () => void; validateRetired: () => void },
|
|
900
|
+
) => Promise<PlanModeTransitionResult | null>;
|
|
852
901
|
settings: PumSettings;
|
|
853
902
|
/** Provider ids that carry the hosted web-search tool; empty means none. */
|
|
854
903
|
searchProviders: string[];
|
|
@@ -886,6 +935,9 @@ export function App({
|
|
|
886
935
|
initialCwd?: string;
|
|
887
936
|
/** User commands bypass Check mode but use this native sandbox execution path. */
|
|
888
937
|
userBashOperations?: BashOperations;
|
|
938
|
+
/** Exact main-runtime lookup; never inherited by workers or restored as trust. */
|
|
939
|
+
mcpForSession?: (session: AgentSession) => McpController | undefined;
|
|
940
|
+
lspForSession?: (session: AgentSession) => LspController | undefined;
|
|
889
941
|
}) {
|
|
890
942
|
// The one authoritative active directory. Everything directory-dependent
|
|
891
943
|
// reads this rather than process.cwd(), so a live move rebinds by re-render
|
|
@@ -980,6 +1032,10 @@ export function App({
|
|
|
980
1032
|
const [settings, setSettings] = useState(
|
|
981
1033
|
() => mergeSessionSettings(globalSettingsRef.current, sessionOverridesRef.current),
|
|
982
1034
|
);
|
|
1035
|
+
const [pendingSettingsNotice, setPendingSettingsNotice] = useState(() => runtimeSettingsPendingNotice(runtimeSettings.snapshot()));
|
|
1036
|
+
useEffect(() => runtimeSettings.subscribe(() => {
|
|
1037
|
+
setPendingSettingsNotice(runtimeSettingsPendingNotice(runtimeSettings.snapshot()));
|
|
1038
|
+
}), []);
|
|
983
1039
|
const [transcriptFocused, setTranscriptFocused] = useState(false);
|
|
984
1040
|
const transcriptFocusedRef = useRef(false);
|
|
985
1041
|
const [transcriptCursor, setTranscriptCursor] = useState(0);
|
|
@@ -1065,11 +1121,30 @@ export function App({
|
|
|
1065
1121
|
const newsCursorRef = useRef(0);
|
|
1066
1122
|
const statsOpenRef = useRef(false);
|
|
1067
1123
|
|
|
1124
|
+
const [branchOpen, setBranchOpen] = useState(false);
|
|
1125
|
+
const branchOpenRef = useRef(false);
|
|
1126
|
+
const [branchPage, setBranchPage] = useState<ConversationBranchPage>(EMPTY_BRANCH_PAGE);
|
|
1127
|
+
const branchPageRef = useRef<ConversationBranchPage>(EMPTY_BRANCH_PAGE);
|
|
1128
|
+
const [branchOffset, setBranchOffset] = useState(0);
|
|
1129
|
+
const branchOffsetRef = useRef(0);
|
|
1130
|
+
const [branchCursor, setBranchCursor] = useState(0);
|
|
1131
|
+
const branchCursorRef = useRef(0);
|
|
1132
|
+
const [branchConfirming, setBranchConfirming] = useState(false);
|
|
1133
|
+
const branchConfirmingRef = useRef(false);
|
|
1134
|
+
/** Set only for the window in which a branch transaction owns the session. */
|
|
1135
|
+
const branchRunningRef = useRef(false);
|
|
1136
|
+
const branchNoticeRef = useRef<string | null>(null);
|
|
1137
|
+
const [branchNotice, setBranchNotice] = useState(0);
|
|
1138
|
+
const [planMode, setPlanMode] = useState(() => planModeForSession(initialSession));
|
|
1139
|
+
const planModeRef = useRef(planMode);
|
|
1140
|
+
/** Armed by a direct `/implement`; only `/implement confirm` may upgrade. */
|
|
1141
|
+
const implementArmedRef = useRef(false);
|
|
1142
|
+
|
|
1068
1143
|
// Preview only the visible autocomplete selection. The committed setting and
|
|
1069
1144
|
// its session/global persistence never participate in this temporary state.
|
|
1070
1145
|
const themeSuggestions = shellMode || stashOpen || commandSuggestionsDismissed || activeAgentId
|
|
1071
1146
|
|| transcriptFocused || settingsOpen || helpOpen || historyOpen || statsOpen || agentSelectorOpen
|
|
1072
|
-
|| triggersOpen || loginOpen || providersOpen || newsOpen || todoOpen
|
|
1147
|
+
|| triggersOpen || loginOpen || providersOpen || newsOpen || todoOpen || branchOpen
|
|
1073
1148
|
|| questionnaireManager?.current() || spawnPreviewManager?.current()
|
|
1074
1149
|
? [] : settingsCompletions(commandInput, inputCursorOffset, cwd);
|
|
1075
1150
|
const previewTheme = themeSuggestions[Math.min(commandCursor, Math.max(0, themeSuggestions.length - 1))]?.previewTheme;
|
|
@@ -1126,7 +1201,8 @@ export function App({
|
|
|
1126
1201
|
// it from each opener means a popup added later cannot forget a line here.
|
|
1127
1202
|
const todoVisible = todoOpen
|
|
1128
1203
|
&& !settingsOpen && !helpOpen && !historyOpen && !statsOpen && !agentSelectorOpen
|
|
1129
|
-
&& !triggersOpen && !loginOpen && !providersOpen && !newsOpen && !
|
|
1204
|
+
&& !triggersOpen && !loginOpen && !providersOpen && !newsOpen && !branchOpen
|
|
1205
|
+
&& !visibleQuestionnaire && !spawnPreview;
|
|
1130
1206
|
// Memoized because the filtered result is a new object every call whenever
|
|
1131
1207
|
// the transcript holds reasoning. That new identity would re-run the dwell
|
|
1132
1208
|
// and projection passes below on every render, keystrokes included.
|
|
@@ -1386,6 +1462,11 @@ export function App({
|
|
|
1386
1462
|
});
|
|
1387
1463
|
/** Cache row currently checked out into the selected transcript input. */
|
|
1388
1464
|
const editingStashIndex = useRef<number | null>(null);
|
|
1465
|
+
// Authority belongs to the draft, never to a mutable cache row index. Edits
|
|
1466
|
+
// retain this origin; only clearing the editor starts a new direct draft.
|
|
1467
|
+
type DraftOrigin = "direct" | "restored";
|
|
1468
|
+
const draftOrigin = useRef<DraftOrigin>("direct");
|
|
1469
|
+
const viewDraftOrigins = useRef(new Map<string, DraftOrigin>());
|
|
1389
1470
|
/**
|
|
1390
1471
|
* Text to install after the cache view closes.
|
|
1391
1472
|
*
|
|
@@ -1416,6 +1497,8 @@ export function App({
|
|
|
1416
1497
|
const streamingRef = useRef(false);
|
|
1417
1498
|
/** True while a session switch or /clear is replacing the session object. */
|
|
1418
1499
|
const sessionSwitchRef = useRef(false);
|
|
1500
|
+
/** User recovery copies must finish before UI actions can replace their runtime. */
|
|
1501
|
+
const checkpointRecoveryRef = useRef(false);
|
|
1419
1502
|
/** True while the running main-agent turn started from a direct user prompt. */
|
|
1420
1503
|
const userTurnActiveRef = useRef(false);
|
|
1421
1504
|
/** Text of the final assistant message; reset at each assistant message start. */
|
|
@@ -1622,7 +1705,9 @@ export function App({
|
|
|
1622
1705
|
value: string,
|
|
1623
1706
|
cursorOffset = value.length,
|
|
1624
1707
|
preserveImages = false,
|
|
1708
|
+
origin: DraftOrigin = "restored",
|
|
1625
1709
|
) => {
|
|
1710
|
+
draftOrigin.current = value ? origin : "direct";
|
|
1626
1711
|
if (!preserveImages && pendingImages.current.length > 0) clearPendingImages();
|
|
1627
1712
|
if (!preserveImages && pendingPastedTexts.current.length > 0) clearPendingPastedTexts();
|
|
1628
1713
|
const input = inputRef.current;
|
|
@@ -1801,7 +1886,7 @@ export function App({
|
|
|
1801
1886
|
const markerStart = value.indexOf(pending.marker);
|
|
1802
1887
|
return { ...pending, start: markerStart, end: markerStart + pending.marker.length };
|
|
1803
1888
|
});
|
|
1804
|
-
setEditorText(value, start + marker.length, true);
|
|
1889
|
+
setEditorText(value, start + marker.length, true, draftOrigin.current);
|
|
1805
1890
|
} catch (error) {
|
|
1806
1891
|
append({ kind: "text", role: "error", text: `image paste failed: ${String(error)}` });
|
|
1807
1892
|
} finally {
|
|
@@ -1835,6 +1920,7 @@ export function App({
|
|
|
1835
1920
|
helpOpen ||
|
|
1836
1921
|
historyOpen ||
|
|
1837
1922
|
newsOpenRef.current ||
|
|
1923
|
+
branchOpenRef.current ||
|
|
1838
1924
|
todoOpenRef.current ||
|
|
1839
1925
|
statsOpenRef.current ||
|
|
1840
1926
|
settingsOpenRef.current
|
|
@@ -1880,7 +1966,7 @@ export function App({
|
|
|
1880
1966
|
start,
|
|
1881
1967
|
end: start + marker.length,
|
|
1882
1968
|
});
|
|
1883
|
-
setEditorText(value, start + marker.length, true);
|
|
1969
|
+
setEditorText(value, start + marker.length, true, draftOrigin.current);
|
|
1884
1970
|
};
|
|
1885
1971
|
|
|
1886
1972
|
const append = (line: Line) =>
|
|
@@ -2019,6 +2105,10 @@ export function App({
|
|
|
2019
2105
|
setLoginOpen(false);
|
|
2020
2106
|
setNewsOpen(false);
|
|
2021
2107
|
newsOpenRef.current = false;
|
|
2108
|
+
setBranchOpen(false);
|
|
2109
|
+
branchOpenRef.current = false;
|
|
2110
|
+
setBranchConfirming(false);
|
|
2111
|
+
branchConfirmingRef.current = false;
|
|
2022
2112
|
setStatsOpen(false);
|
|
2023
2113
|
statsOpenRef.current = false;
|
|
2024
2114
|
}
|
|
@@ -2047,6 +2137,12 @@ export function App({
|
|
|
2047
2137
|
useEffect(() => {
|
|
2048
2138
|
// A replaced session cannot still be running the previous session's turn.
|
|
2049
2139
|
streamingRef.current = false;
|
|
2140
|
+
// The role is read from the session's own durable records, never carried
|
|
2141
|
+
// across from the previous runtime.
|
|
2142
|
+
const activePlanMode = planModeForSession(session);
|
|
2143
|
+
planModeRef.current = activePlanMode;
|
|
2144
|
+
setPlanMode(activePlanMode);
|
|
2145
|
+
implementArmedRef.current = false;
|
|
2050
2146
|
void subagentManager
|
|
2051
2147
|
.bindMainSession(session.sessionManager, cwd)
|
|
2052
2148
|
.catch((error) => append({ kind: "text", role: "error", text: String(error) }));
|
|
@@ -2086,6 +2182,7 @@ export function App({
|
|
|
2086
2182
|
const mergedSettings = mergeSessionSettings(globalSettingsRef.current, loadedOverrides);
|
|
2087
2183
|
settingsRef.current = mergedSettings;
|
|
2088
2184
|
setSettings(mergedSettings);
|
|
2185
|
+
applyRuntimeSettings(mergedSettings, cwd);
|
|
2089
2186
|
const loadedGoal = loadGoal(session.sessionFile);
|
|
2090
2187
|
goalRef.current = loadedGoal;
|
|
2091
2188
|
setGoalState(loadedGoal);
|
|
@@ -2126,6 +2223,12 @@ export function App({
|
|
|
2126
2223
|
case "message_end":
|
|
2127
2224
|
if (event.message.role === "assistant") {
|
|
2128
2225
|
setTx((value) => settleTranscriptMessage(value));
|
|
2226
|
+
} else if (event.message.role === "custom" && event.message.customType === VALIDATION_CUSTOM_TYPE) {
|
|
2227
|
+
append({
|
|
2228
|
+
kind: "text",
|
|
2229
|
+
role: (event.message.details as { outcome?: string } | undefined)?.outcome === "passed" ? "system" : "error",
|
|
2230
|
+
text: String(event.message.content),
|
|
2231
|
+
});
|
|
2129
2232
|
}
|
|
2130
2233
|
break;
|
|
2131
2234
|
case "message_update": {
|
|
@@ -2249,6 +2352,16 @@ export function App({
|
|
|
2249
2352
|
});
|
|
2250
2353
|
}, [session]);
|
|
2251
2354
|
|
|
2355
|
+
// Ordered after the session replay effect above, so a branch disclosure lands
|
|
2356
|
+
// under the newly selected path instead of being wiped by that replay. It
|
|
2357
|
+
// also runs when a host rebuilds runtime state behind the same object.
|
|
2358
|
+
useEffect(() => {
|
|
2359
|
+
const notice = branchNoticeRef.current;
|
|
2360
|
+
if (!notice) return;
|
|
2361
|
+
branchNoticeRef.current = null;
|
|
2362
|
+
append({ kind: "text", role: "system", text: notice });
|
|
2363
|
+
}, [branchNotice, session]);
|
|
2364
|
+
|
|
2252
2365
|
useEffect(
|
|
2253
2366
|
() => () => {
|
|
2254
2367
|
clearTimeout(quitTimer.current);
|
|
@@ -2500,11 +2613,7 @@ export function App({
|
|
|
2500
2613
|
setCwd(target);
|
|
2501
2614
|
// The check-mode roots follow the move immediately: the next tool call must
|
|
2502
2615
|
// not be judged against the directory the session just left.
|
|
2503
|
-
|
|
2504
|
-
profile: settingsRef.current.checkMode,
|
|
2505
|
-
model: settingsRef.current.checkModel,
|
|
2506
|
-
additionalPaths: liveCheckPaths(settingsRef.current, target),
|
|
2507
|
-
});
|
|
2616
|
+
applyRuntimeSettings(settingsRef.current, target);
|
|
2508
2617
|
void subagentManager.bindMainSession(session.sessionManager, target).catch(() => {});
|
|
2509
2618
|
// Both are keyed by project, so the old directory's recall would otherwise
|
|
2510
2619
|
// follow the session into the new one.
|
|
@@ -2707,31 +2816,29 @@ export function App({
|
|
|
2707
2816
|
: { kind: "return" });
|
|
2708
2817
|
};
|
|
2709
2818
|
|
|
2819
|
+
const applyRuntimeSettings = (next: PumSettings, directory: string) => {
|
|
2820
|
+
setWritingStyle(next.writingStyle);
|
|
2821
|
+
setExplanationStrength(next.explanationStrength);
|
|
2822
|
+
setBashOutputSettingsIfPresent(next.bashOutput);
|
|
2823
|
+
subagentManager.setMaxActiveSubagents?.(next.maxActiveSubagents);
|
|
2824
|
+
runtimeSettings.request({
|
|
2825
|
+
checkMode: next.checkMode,
|
|
2826
|
+
checkModel: next.checkModel,
|
|
2827
|
+
sandboxMode: forcedSandboxMode ?? next.sandboxMode ?? "auto",
|
|
2828
|
+
checkPaths: liveCheckPaths(next, directory),
|
|
2829
|
+
webSearch: next.webSearch,
|
|
2830
|
+
}, (effective) => {
|
|
2831
|
+
setCheckModeConfig({ profile: effective.checkMode, model: effective.checkModel, additionalPaths: effective.checkPaths });
|
|
2832
|
+
onSandboxModeChange?.(effective.sandboxMode);
|
|
2833
|
+
webSearch.enabled = effective.webSearch;
|
|
2834
|
+
});
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2710
2837
|
const update = (patch: Partial<PumSettings>) => {
|
|
2711
2838
|
const next = { ...settingsRef.current, ...patch };
|
|
2712
2839
|
settingsRef.current = next;
|
|
2713
2840
|
setSettings(next);
|
|
2714
|
-
|
|
2715
|
-
if (patch.writingStyle !== undefined) setWritingStyle(patch.writingStyle);
|
|
2716
|
-
if (patch.explanationStrength !== undefined) {
|
|
2717
|
-
setExplanationStrength(patch.explanationStrength);
|
|
2718
|
-
}
|
|
2719
|
-
if (patch.sandboxMode !== undefined) {
|
|
2720
|
-
onSandboxModeChange?.(forcedSandboxMode ?? patch.sandboxMode);
|
|
2721
|
-
}
|
|
2722
|
-
if (patch.checkMode !== undefined || patch.checkModel !== undefined || patch.checkPaths !== undefined) {
|
|
2723
|
-
setCheckModeConfig({
|
|
2724
|
-
profile: next.checkMode,
|
|
2725
|
-
model: next.checkModel,
|
|
2726
|
-
additionalPaths: liveCheckPaths(next, cwd),
|
|
2727
|
-
});
|
|
2728
|
-
}
|
|
2729
|
-
if (patch.maxActiveSubagents !== undefined) {
|
|
2730
|
-
subagentManager.setMaxActiveSubagents(patch.maxActiveSubagents);
|
|
2731
|
-
}
|
|
2732
|
-
// main.tsx applies this at startup. /settings can change it mid-session, so
|
|
2733
|
-
// the running bash tool has to see the new policy on the next call.
|
|
2734
|
-
if (patch.bashOutput !== undefined) setBashOutputSettingsIfPresent(patch.bashOutput);
|
|
2841
|
+
applyRuntimeSettings(next, cwd);
|
|
2735
2842
|
// Session-scoped: the popup never writes the global config, which the
|
|
2736
2843
|
// sandboxes keep read-only. Popup `s` and /store explicitly promote.
|
|
2737
2844
|
const overrides = sessionSettingsDiff(globalSettingsRef.current, next);
|
|
@@ -2979,6 +3086,266 @@ export function App({
|
|
|
2979
3086
|
queueMicrotask(() => inputRef.current?.focus());
|
|
2980
3087
|
};
|
|
2981
3088
|
|
|
3089
|
+
// Guards that must still hold when the retired runtime's cleanup has settled
|
|
3090
|
+
// and the selection is about to be published. Refusing here is not a
|
|
3091
|
+
// cancellation: no queued input is dropped and no companion state changes.
|
|
3092
|
+
const sessionTransitionStateBlocker = (noun: string): string | null => {
|
|
3093
|
+
if (activeAgentIdRef.current !== null) {
|
|
3094
|
+
return `${noun} belongs to the main transcript. Select the main transcript first.`;
|
|
3095
|
+
}
|
|
3096
|
+
if (checkpointRecoveryRef.current || relocatingRef.current || pendingRelocationRef.current) {
|
|
3097
|
+
return `Wait for the running session operation to finish before ${noun.toLowerCase()}.`;
|
|
3098
|
+
}
|
|
3099
|
+
if (pendingImages.current.length > 0 || pendingPastedTexts.current.length > 0) {
|
|
3100
|
+
return `Clear attached images and pasted text before ${noun.toLowerCase()}.`;
|
|
3101
|
+
}
|
|
3102
|
+
if (!subagentManager.canBranchMainSession(session.sessionManager)) {
|
|
3103
|
+
return `Worker setup, a retained worker or a pending delivery must finish before ${noun.toLowerCase()}.`;
|
|
3104
|
+
}
|
|
3105
|
+
const goal = goalRef.current;
|
|
3106
|
+
if (goal && (goal.state === "active" || goal.state === "blocked")) {
|
|
3107
|
+
return `Stop the goal with /goal stop before ${noun.toLowerCase()}.`;
|
|
3108
|
+
}
|
|
3109
|
+
if (goal?.pendingContinuation) {
|
|
3110
|
+
return `A goal continuation is still owed. Resolve it before ${noun.toLowerCase()}.`;
|
|
3111
|
+
}
|
|
3112
|
+
return null;
|
|
3113
|
+
};
|
|
3114
|
+
|
|
3115
|
+
/** Full guard for a same-file runtime transition: state plus exact idleness. */
|
|
3116
|
+
const sessionTransitionBlocker = (noun: string): string | null => {
|
|
3117
|
+
if (branchRunningRef.current) return "A session transition is already being applied.";
|
|
3118
|
+
if (sessionSwitchRef.current) {
|
|
3119
|
+
return `Wait for the running session operation to finish before ${noun.toLowerCase()}.`;
|
|
3120
|
+
}
|
|
3121
|
+
const state = sessionTransitionStateBlocker(noun);
|
|
3122
|
+
if (state) return state;
|
|
3123
|
+
if (busyRef.current || !isRuntimeIdle(session) || session.pendingMessageCount !== 0
|
|
3124
|
+
|| session.isBashRunning !== false || session.hasPendingBashMessages !== false
|
|
3125
|
+
|| txRef.current.pending.length > 0 || hasConversationBranchPendingInput(session)) {
|
|
3126
|
+
return "Wait for the main session to become idle with no pending input, delivery or Bash work first.";
|
|
3127
|
+
}
|
|
3128
|
+
return null;
|
|
3129
|
+
};
|
|
3130
|
+
|
|
3131
|
+
const conversationBranchStateBlocker = () =>
|
|
3132
|
+
sessionTransitionStateBlocker("Conversation branching");
|
|
3133
|
+
const conversationBranchBlocker = (): string | null => {
|
|
3134
|
+
if (!onBranchConversation) return "Conversation branching is unavailable for this session runtime.";
|
|
3135
|
+
return sessionTransitionBlocker("Conversation branching");
|
|
3136
|
+
};
|
|
3137
|
+
|
|
3138
|
+
const closeConversationBranch = () => {
|
|
3139
|
+
branchOpenRef.current = false;
|
|
3140
|
+
setBranchOpen(false);
|
|
3141
|
+
branchConfirmingRef.current = false;
|
|
3142
|
+
setBranchConfirming(false);
|
|
3143
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
3144
|
+
};
|
|
3145
|
+
|
|
3146
|
+
/** Bounded, read-only projection. A throw means the transcript is unsafe. */
|
|
3147
|
+
const readConversationBranchPage = (offset: number): ConversationBranchPage | null => {
|
|
3148
|
+
try {
|
|
3149
|
+
return listConversationBranchPoints(session, { offset, limit: CONVERSATION_BRANCH_PAGE_SIZE });
|
|
3150
|
+
} catch {
|
|
3151
|
+
append({
|
|
3152
|
+
kind: "text",
|
|
3153
|
+
role: "error",
|
|
3154
|
+
text: "This conversation cannot be projected safely, so branching is refused. Nothing was changed.",
|
|
3155
|
+
});
|
|
3156
|
+
return null;
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
|
|
3160
|
+
const openConversationBranch = () => {
|
|
3161
|
+
const blocked = conversationBranchBlocker();
|
|
3162
|
+
if (blocked) {
|
|
3163
|
+
append({ kind: "text", role: "error", text: blocked });
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
const page = readConversationBranchPage(0);
|
|
3167
|
+
if (!page) return;
|
|
3168
|
+
if (page.total === 0) {
|
|
3169
|
+
append({
|
|
3170
|
+
kind: "text",
|
|
3171
|
+
role: "system",
|
|
3172
|
+
text: "No safe conversation points are available to branch from.",
|
|
3173
|
+
});
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
settingsOpenRef.current = false;
|
|
3177
|
+
setSettingsOpen(false);
|
|
3178
|
+
setHelpOpen(false);
|
|
3179
|
+
setHistoryOpen(false);
|
|
3180
|
+
setAgentSelectorOpen(false);
|
|
3181
|
+
setTriggerPopup(false, false);
|
|
3182
|
+
setLoginOpen(false);
|
|
3183
|
+
setStatsOpen(false);
|
|
3184
|
+
statsOpenRef.current = false;
|
|
3185
|
+
todoOpenRef.current = false;
|
|
3186
|
+
setTodoOpen(false);
|
|
3187
|
+
newsOpenRef.current = false;
|
|
3188
|
+
setNewsOpen(false);
|
|
3189
|
+
branchOffsetRef.current = 0;
|
|
3190
|
+
setBranchOffset(0);
|
|
3191
|
+
branchCursorRef.current = 0;
|
|
3192
|
+
setBranchCursor(0);
|
|
3193
|
+
branchConfirmingRef.current = false;
|
|
3194
|
+
setBranchConfirming(false);
|
|
3195
|
+
branchPageRef.current = page;
|
|
3196
|
+
setBranchPage(page);
|
|
3197
|
+
branchOpenRef.current = true;
|
|
3198
|
+
setBranchOpen(true);
|
|
3199
|
+
};
|
|
3200
|
+
|
|
3201
|
+
const moveBranchCursor = (direction: number) => {
|
|
3202
|
+
if (branchConfirmingRef.current) return;
|
|
3203
|
+
const count = branchPageRef.current.points.length;
|
|
3204
|
+
if (count === 0) return;
|
|
3205
|
+
const next = Math.max(0, Math.min(count - 1, branchCursorRef.current + direction));
|
|
3206
|
+
branchCursorRef.current = next;
|
|
3207
|
+
setBranchCursor(next);
|
|
3208
|
+
};
|
|
3209
|
+
|
|
3210
|
+
const pageConversationBranch = (direction: number) => {
|
|
3211
|
+
if (branchConfirmingRef.current) return;
|
|
3212
|
+
const current = branchPageRef.current;
|
|
3213
|
+
const offset = direction > 0
|
|
3214
|
+
? current.nextOffset
|
|
3215
|
+
: (branchOffsetRef.current > 0
|
|
3216
|
+
? Math.max(0, branchOffsetRef.current - CONVERSATION_BRANCH_PAGE_SIZE)
|
|
3217
|
+
: null);
|
|
3218
|
+
if (offset === null) return;
|
|
3219
|
+
// Re-project instead of caching pages: a selection must always carry the
|
|
3220
|
+
// transcript state it was actually read from.
|
|
3221
|
+
const page = readConversationBranchPage(offset);
|
|
3222
|
+
if (!page) {
|
|
3223
|
+
closeConversationBranch();
|
|
3224
|
+
return;
|
|
3225
|
+
}
|
|
3226
|
+
branchOffsetRef.current = offset;
|
|
3227
|
+
setBranchOffset(offset);
|
|
3228
|
+
branchPageRef.current = page;
|
|
3229
|
+
setBranchPage(page);
|
|
3230
|
+
branchCursorRef.current = 0;
|
|
3231
|
+
setBranchCursor(0);
|
|
3232
|
+
};
|
|
3233
|
+
|
|
3234
|
+
const confirmConversationBranch = () => {
|
|
3235
|
+
if (!branchPageRef.current.points[branchCursorRef.current]) return;
|
|
3236
|
+
if (!branchConfirmingRef.current) {
|
|
3237
|
+
branchConfirmingRef.current = true;
|
|
3238
|
+
setBranchConfirming(true);
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
applyConversationBranch();
|
|
3242
|
+
};
|
|
3243
|
+
|
|
3244
|
+
const applyConversationBranch = () => {
|
|
3245
|
+
const point = branchPageRef.current.points[branchCursorRef.current];
|
|
3246
|
+
const blocked = conversationBranchBlocker();
|
|
3247
|
+
closeConversationBranch();
|
|
3248
|
+
if (!point || !onBranchConversation) return;
|
|
3249
|
+
if (blocked) {
|
|
3250
|
+
append({ kind: "text", role: "error", text: blocked });
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
// The App owns view/delivery admission; the host owns runtime, ownership and
|
|
3254
|
+
// transcript proof. Both are rechecked while the transaction still holds the
|
|
3255
|
+
// canonical lock, before and after the retired runtime's cleanup.
|
|
3256
|
+
const guard = () => {
|
|
3257
|
+
const reason = conversationBranchStateBlocker();
|
|
3258
|
+
if (reason) throw new Error(reason);
|
|
3259
|
+
};
|
|
3260
|
+
branchRunningRef.current = true;
|
|
3261
|
+
sessionSwitchRef.current = true;
|
|
3262
|
+
setWorking(true);
|
|
3263
|
+
onBranchConversation(point.selection, { validate: guard, validateRetired: guard })
|
|
3264
|
+
.then((result) => {
|
|
3265
|
+
if (!result) {
|
|
3266
|
+
append({
|
|
3267
|
+
kind: "text",
|
|
3268
|
+
role: "system",
|
|
3269
|
+
text: "Conversation branch was not applied; the current path is unchanged.",
|
|
3270
|
+
});
|
|
3271
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
branchNoticeRef.current = result.recovered
|
|
3275
|
+
? "Conversation branch failed and the original conversation path was restored. Nothing else was changed."
|
|
3276
|
+
: "Conversation branch selected in the same session. Files, git state and current goals, todos, settings, News and tool groups were not rewound.";
|
|
3277
|
+
if (activeAgentIdRef.current !== null) selectAgentView(null);
|
|
3278
|
+
focusInputAfterSwitch.current = true;
|
|
3279
|
+
setSession(result.session);
|
|
3280
|
+
setBranchNotice((revision) => revision + 1);
|
|
3281
|
+
const restored = result.recovered ? undefined : result.editorText;
|
|
3282
|
+
queueMicrotask(() => {
|
|
3283
|
+
// A restored earlier prompt is never fresh command authority and is
|
|
3284
|
+
// never submitted for the user.
|
|
3285
|
+
if (restored !== undefined && !inputRef.current?.plainText) {
|
|
3286
|
+
setEditorText(restored, restored.length, false, "restored");
|
|
3287
|
+
}
|
|
3288
|
+
inputRef.current?.focus();
|
|
3289
|
+
});
|
|
3290
|
+
})
|
|
3291
|
+
.catch((error) => {
|
|
3292
|
+
append({ kind: "text", role: "error", text: String(error) });
|
|
3293
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
3294
|
+
})
|
|
3295
|
+
.finally(() => {
|
|
3296
|
+
branchRunningRef.current = false;
|
|
3297
|
+
sessionSwitchRef.current = false;
|
|
3298
|
+
setWorking(false);
|
|
3299
|
+
});
|
|
3300
|
+
};
|
|
3301
|
+
|
|
3302
|
+
const applyPlanModeTransition = (mode: PlanMode) => {
|
|
3303
|
+
if (!onTransitionPlanMode) {
|
|
3304
|
+
append({ kind: "text", role: "error", text: "Plan mode is unavailable for this session runtime." });
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
const noun = "Changing plan mode";
|
|
3308
|
+
const blocked = sessionTransitionBlocker(noun);
|
|
3309
|
+
if (blocked) {
|
|
3310
|
+
append({ kind: "text", role: "error", text: blocked });
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
const guard = () => {
|
|
3314
|
+
const reason = sessionTransitionStateBlocker(noun);
|
|
3315
|
+
if (reason) throw new Error(reason);
|
|
3316
|
+
};
|
|
3317
|
+
implementArmedRef.current = false;
|
|
3318
|
+
branchRunningRef.current = true;
|
|
3319
|
+
sessionSwitchRef.current = true;
|
|
3320
|
+
setWorking(true);
|
|
3321
|
+
onTransitionPlanMode(mode, { validate: guard, validateRetired: guard })
|
|
3322
|
+
.then((result) => {
|
|
3323
|
+
if (!result) {
|
|
3324
|
+
append({ kind: "text", role: "system", text: "Plan mode was not changed." });
|
|
3325
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
branchNoticeRef.current = result.recovered
|
|
3329
|
+
? "Plan mode transition failed. The session stays in plan mode; no capability was restored."
|
|
3330
|
+
: result.mode === "plan"
|
|
3331
|
+
? "Plan mode is ON. File mutation, mutable delegation, managed shells, MCP servers and project validation are blocked; Bash needs native sandbox enforcement. Your own ! commands are unchanged."
|
|
3332
|
+
: "Plan mode is off. Full capability is restored for this session.";
|
|
3333
|
+
focusInputAfterSwitch.current = true;
|
|
3334
|
+
setSession(result.session);
|
|
3335
|
+
setBranchNotice((revision) => revision + 1);
|
|
3336
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
3337
|
+
})
|
|
3338
|
+
.catch((error) => {
|
|
3339
|
+
append({ kind: "text", role: "error", text: String(error) });
|
|
3340
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
3341
|
+
})
|
|
3342
|
+
.finally(() => {
|
|
3343
|
+
branchRunningRef.current = false;
|
|
3344
|
+
sessionSwitchRef.current = false;
|
|
3345
|
+
setWorking(false);
|
|
3346
|
+
});
|
|
3347
|
+
};
|
|
3348
|
+
|
|
2982
3349
|
// Re-read on every transcript change while the popup is open: a todo tool call
|
|
2983
3350
|
// always adds a line, so the list follows the agent without an event bus.
|
|
2984
3351
|
const todoSessionFile = activeAgent?.sessionFile ?? session.sessionFile;
|
|
@@ -3348,6 +3715,7 @@ export function App({
|
|
|
3348
3715
|
? requesterAgentId
|
|
3349
3716
|
: null;
|
|
3350
3717
|
viewDrafts.current.set(targetAgentId ?? "main", quote);
|
|
3718
|
+
viewDraftOrigins.current.set(targetAgentId ?? "main", "restored");
|
|
3351
3719
|
if (activeAgentIdRef.current !== targetAgentId) selectAgentView(targetAgentId);
|
|
3352
3720
|
else setEditorText(quote);
|
|
3353
3721
|
queueMicrotask(() => {
|
|
@@ -3368,6 +3736,7 @@ export function App({
|
|
|
3368
3736
|
};
|
|
3369
3737
|
|
|
3370
3738
|
const selectHistorySession = (path: string) => {
|
|
3739
|
+
if (checkpointRecoveryRef.current) return;
|
|
3371
3740
|
setHistoryOpen(false);
|
|
3372
3741
|
if (path === session.sessionFile) {
|
|
3373
3742
|
queueMicrotask(() => inputRef.current?.focus());
|
|
@@ -3462,6 +3831,8 @@ export function App({
|
|
|
3462
3831
|
histCursor.current = null;
|
|
3463
3832
|
// clearQueue may have dropped a subagent completion notice queued to the
|
|
3464
3833
|
// streaming main agent; re-arm undelivered notices so a merge is not stuck.
|
|
3834
|
+
mcpForSession?.(session)?.cancel();
|
|
3835
|
+
lspForSession?.(session)?.cancel();
|
|
3465
3836
|
session.abort().finally(() => {
|
|
3466
3837
|
setWorking(false);
|
|
3467
3838
|
void subagentManager.resendUndeliveredMainSettlements();
|
|
@@ -3481,6 +3852,7 @@ export function App({
|
|
|
3481
3852
|
}
|
|
3482
3853
|
const key = target ?? "main";
|
|
3483
3854
|
viewDrafts.current.set(key, recalled.text);
|
|
3855
|
+
viewDraftOrigins.current.set(key, "restored");
|
|
3484
3856
|
if (activeAgentIdRef.current === target) {
|
|
3485
3857
|
setEditorText(recalled.text);
|
|
3486
3858
|
histCursor.current = null;
|
|
@@ -3604,6 +3976,7 @@ export function App({
|
|
|
3604
3976
|
};
|
|
3605
3977
|
|
|
3606
3978
|
const runCommand = (text: string): boolean => {
|
|
3979
|
+
if (checkpointRecoveryRef.current) return true;
|
|
3607
3980
|
const trimmed = text.trim();
|
|
3608
3981
|
if (runGoalCommand(trimmed)) return true;
|
|
3609
3982
|
if (trimmed === "/s" || trimmed === "/store") {
|
|
@@ -4213,11 +4586,13 @@ export function App({
|
|
|
4213
4586
|
if (activeAgentIdRef.current !== requesterAgentId) {
|
|
4214
4587
|
if (!(viewDrafts.current.get(requesterKey) ?? "")) {
|
|
4215
4588
|
viewDrafts.current.set(requesterKey, draftText);
|
|
4589
|
+
viewDraftOrigins.current.set(requesterKey, "restored");
|
|
4216
4590
|
}
|
|
4217
4591
|
return;
|
|
4218
4592
|
}
|
|
4219
4593
|
if (!(inputRef.current?.plainText ?? "")) {
|
|
4220
4594
|
viewDrafts.current.set(requesterKey, draftText);
|
|
4595
|
+
viewDraftOrigins.current.set(requesterKey, "restored");
|
|
4221
4596
|
setEditorText(draftText, draftText.length, true);
|
|
4222
4597
|
}
|
|
4223
4598
|
};
|
|
@@ -4288,7 +4663,7 @@ export function App({
|
|
|
4288
4663
|
return true;
|
|
4289
4664
|
};
|
|
4290
4665
|
|
|
4291
|
-
const submitPrompt = (value?: string, stashIndex?: number) => {
|
|
4666
|
+
const submitPrompt = (value?: string, stashIndex?: number, directCompletion = false) => {
|
|
4292
4667
|
// Read the selected agent from the ref, not the state. A view switch updates
|
|
4293
4668
|
// the ref synchronously, but a switch-then-send in one input chunk runs
|
|
4294
4669
|
// before React commits the new state, so the state would still name the
|
|
@@ -4300,6 +4675,10 @@ export function App({
|
|
|
4300
4675
|
const attachments = value === undefined ? [...pendingImages.current] : [];
|
|
4301
4676
|
const pastedTexts = value === undefined ? [...pendingPastedTexts.current] : [];
|
|
4302
4677
|
const submittedEditingIndex = editingStashIndex.current;
|
|
4678
|
+
// Completion transforms a draft; it cannot turn restored/cache content into
|
|
4679
|
+
// user consent. Snapshot before editor cleanup starts the next direct draft.
|
|
4680
|
+
const directSubmission = draftOrigin.current === "direct"
|
|
4681
|
+
&& (value === undefined || directCompletion) && stashIndex === undefined;
|
|
4303
4682
|
const expandedText = expandPastedTexts(rawDisplayText, pastedTexts);
|
|
4304
4683
|
const displayText = pastedTexts.length ? expandedText : expandedText.trim();
|
|
4305
4684
|
const expandedPrompt = expandPastedTexts(rawDisplayText, [
|
|
@@ -4311,6 +4690,7 @@ export function App({
|
|
|
4311
4690
|
const persistedPrompt = displayText;
|
|
4312
4691
|
|
|
4313
4692
|
if (!promptText && attachments.length === 0 && pastedTexts.length === 0) return;
|
|
4693
|
+
if (checkpointRecoveryRef.current) return;
|
|
4314
4694
|
|
|
4315
4695
|
const backgroundCandidate = commandEligible ? parseBackgroundCommand(promptText) : null;
|
|
4316
4696
|
if (backgroundCandidate?.kind === "error") {
|
|
@@ -4385,6 +4765,7 @@ export function App({
|
|
|
4385
4765
|
nextPastedTextId.current = Math.max(1, ...pastedTexts.map((pasted) => pasted.id + 1));
|
|
4386
4766
|
setEditingStash(submittedEditingIndex);
|
|
4387
4767
|
viewDrafts.current.set(targetKey, rawDisplayText);
|
|
4768
|
+
viewDraftOrigins.current.set(targetKey, "restored");
|
|
4388
4769
|
viewEditingStashIndices.current.set(targetKey, submittedEditingIndex);
|
|
4389
4770
|
setEditorText(rawDisplayText, rawDisplayText.length, true);
|
|
4390
4771
|
};
|
|
@@ -4402,6 +4783,324 @@ export function App({
|
|
|
4402
4783
|
return;
|
|
4403
4784
|
}
|
|
4404
4785
|
|
|
4786
|
+
// Diagnostics belong to the selected session, but never enter its durable
|
|
4787
|
+
// transcript or model context. Intercept before ordinary child delivery.
|
|
4788
|
+
if (attachments.length === 0 && commandEligible && /^\/diagnostics(?:\s|$)/.test(promptText.trim())) {
|
|
4789
|
+
const diagnosticSessionId = selectedAgentId
|
|
4790
|
+
? subagentManager.getDiagnosticsSessionId(selectedAgentId)
|
|
4791
|
+
: session.sessionId;
|
|
4792
|
+
const line: Line = { kind: "text", role: "system", text: diagnosticsCommandText(promptText, diagnosticSessionId)! };
|
|
4793
|
+
if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
|
|
4794
|
+
else appendMainLine(line);
|
|
4795
|
+
setEditorText("");
|
|
4796
|
+
setEditingStash(null);
|
|
4797
|
+
histCursor.current = null;
|
|
4798
|
+
draft.current = "";
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
// Every LSP operation requires the same ref-backed draft origin as MCP.
|
|
4803
|
+
// Even cached reads/status/stop cannot become programmatic consent commands.
|
|
4804
|
+
if (attachments.length === 0 && commandEligible && /^\/lsp(?:\s|$)/.test(promptText)) {
|
|
4805
|
+
setEditingStash(null);
|
|
4806
|
+
histCursor.current = null;
|
|
4807
|
+
draft.current = "";
|
|
4808
|
+
const report = (text: string, error = false) => {
|
|
4809
|
+
const line: Line = { kind: "text", role: error ? "error" : "system", text };
|
|
4810
|
+
if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
|
|
4811
|
+
else appendMainLine(line);
|
|
4812
|
+
};
|
|
4813
|
+
if (!directSubmission) {
|
|
4814
|
+
report("LSP commands require direct user input. Clear the draft and type the command anew.", true);
|
|
4815
|
+
return;
|
|
4816
|
+
}
|
|
4817
|
+
if (selectedAgentId) {
|
|
4818
|
+
report("LSP is available only in the main TUI session; workers cannot access servers.", true);
|
|
4819
|
+
return;
|
|
4820
|
+
}
|
|
4821
|
+
const controller = lspForSession?.(session);
|
|
4822
|
+
if (!controller) {
|
|
4823
|
+
report("LSP is unavailable for this session runtime.", true);
|
|
4824
|
+
return;
|
|
4825
|
+
}
|
|
4826
|
+
const action = promptText.trim().split(/\s+/)[1];
|
|
4827
|
+
if ((action === "connect" || action === "check") && (busyRef.current || !isRuntimeIdle(session)
|
|
4828
|
+
|| sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current)) {
|
|
4829
|
+
report("Wait for the main session to become idle and session operations to finish before connecting or checking LSP.", true);
|
|
4830
|
+
return;
|
|
4831
|
+
}
|
|
4832
|
+
void controller.command(promptText).then((text) => {
|
|
4833
|
+
if (sessionRef.current === session && lspForSession?.(session) === controller) report(text);
|
|
4834
|
+
}).catch(() => {
|
|
4835
|
+
if (sessionRef.current === session && lspForSession?.(session) === controller) {
|
|
4836
|
+
report("LSP request failed or was revoked. Native Linux Bubblewrap, a supported server and unchanged proposal/file are required. Preview /lsp before approving again.", true);
|
|
4837
|
+
}
|
|
4838
|
+
});
|
|
4839
|
+
return;
|
|
4840
|
+
}
|
|
4841
|
+
|
|
4842
|
+
// MCP consent exists only at this direct input boundary. It is not an SDK
|
|
4843
|
+
// command, saved prompt, model capability, or selected-worker operation.
|
|
4844
|
+
if (attachments.length === 0 && commandEligible && /^\/mcp(?:\s|$)/.test(promptText)) {
|
|
4845
|
+
setEditingStash(null);
|
|
4846
|
+
histCursor.current = null;
|
|
4847
|
+
draft.current = "";
|
|
4848
|
+
const report = (text: string, error = false) => {
|
|
4849
|
+
const line: Line = { kind: "text", role: error ? "error" : "system", text };
|
|
4850
|
+
if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
|
|
4851
|
+
else appendMainLine(line);
|
|
4852
|
+
};
|
|
4853
|
+
if (!directSubmission) {
|
|
4854
|
+
report("MCP commands require direct user input. Clear the draft and type the command anew.", true);
|
|
4855
|
+
return;
|
|
4856
|
+
}
|
|
4857
|
+
if (selectedAgentId) {
|
|
4858
|
+
report("MCP is available only in the main TUI session; workers cannot access servers.", true);
|
|
4859
|
+
return;
|
|
4860
|
+
}
|
|
4861
|
+
const controller = mcpForSession?.(session);
|
|
4862
|
+
if (!controller) {
|
|
4863
|
+
report("MCP is unavailable for this session runtime.", true);
|
|
4864
|
+
return;
|
|
4865
|
+
}
|
|
4866
|
+
const action = promptText.trim().split(/\s+/)[1];
|
|
4867
|
+
if (planModeRef.current && action !== "status" && action !== undefined) {
|
|
4868
|
+
// PUM cannot certify a server as non-mutating, so plan mode refuses the
|
|
4869
|
+
// whole lifecycle rather than reasoning about one server's intent.
|
|
4870
|
+
report("Plan mode cannot connect, approve or use MCP servers. Leave plan mode with /implement confirm first.", true);
|
|
4871
|
+
return;
|
|
4872
|
+
}
|
|
4873
|
+
if ((action === "connect" || action === "approve") && (busyRef.current || !isRuntimeIdle(session)
|
|
4874
|
+
|| sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current)) {
|
|
4875
|
+
report("Wait for the main session to become idle and session operations to finish before connecting or approving MCP.", true);
|
|
4876
|
+
return;
|
|
4877
|
+
}
|
|
4878
|
+
void controller.command(promptText).then((text) => {
|
|
4879
|
+
if (sessionRef.current === session && mcpForSession?.(session) === controller) report(text);
|
|
4880
|
+
}).catch(() => {
|
|
4881
|
+
if (sessionRef.current === session && mcpForSession?.(session) === controller) {
|
|
4882
|
+
report("MCP request failed. Preview /mcp and check the current proposal and digests.", true);
|
|
4883
|
+
}
|
|
4884
|
+
});
|
|
4885
|
+
return;
|
|
4886
|
+
}
|
|
4887
|
+
|
|
4888
|
+
// Approval is a direct-user capability, never an SDK command or model
|
|
4889
|
+
// message. Reports are transient and belong only to the selected runtime.
|
|
4890
|
+
if (attachments.length === 0 && commandEligible && /^\/validation(?:\s|$)/.test(promptText)) {
|
|
4891
|
+
const report = (text: string, error = false) => {
|
|
4892
|
+
const line: Line = { kind: "text", role: error ? "error" : "system", text };
|
|
4893
|
+
if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
|
|
4894
|
+
else appendMainLine(line);
|
|
4895
|
+
};
|
|
4896
|
+
setEditingStash(null);
|
|
4897
|
+
histCursor.current = null;
|
|
4898
|
+
draft.current = "";
|
|
4899
|
+
if (!directSubmission) {
|
|
4900
|
+
report("Validation commands require direct user input. Clear the draft and type the command anew.", true);
|
|
4901
|
+
return;
|
|
4902
|
+
}
|
|
4903
|
+
const parts = promptText.trim().split(/\s+/);
|
|
4904
|
+
const action = parts[1];
|
|
4905
|
+
if (!(parts.length === 1 || (parts.length === 2 && (action === "status" || action === "disable"))
|
|
4906
|
+
|| (parts.length === 3 && action === "enable" && /^[0-9a-f]{64}$/i.test(parts[2]!)))) {
|
|
4907
|
+
report("Usage: /validation [status|enable <64-hex digest>|disable]", true);
|
|
4908
|
+
return;
|
|
4909
|
+
}
|
|
4910
|
+
// A role refusal does not depend on a runtime controller, so it is
|
|
4911
|
+
// reported before an "unavailable" message can hide the real reason.
|
|
4912
|
+
if (action === "enable" && !selectedAgentId && planModeRef.current) {
|
|
4913
|
+
// Validation runs configured project commands: a mutation path.
|
|
4914
|
+
report("Plan mode cannot enable project validation. Leave plan mode with /implement confirm first.", true);
|
|
4915
|
+
return;
|
|
4916
|
+
}
|
|
4917
|
+
const selected = selectedAgentId ? subagentManager.getAgent(selectedAgentId) : undefined;
|
|
4918
|
+
const controller = validationForSession(selectedAgentId ? subagentManager.getDiagnosticsSessionId(selectedAgentId) ?? "" : session);
|
|
4919
|
+
if (!controller) {
|
|
4920
|
+
report("Project validation is unavailable for this session runtime.", true);
|
|
4921
|
+
return;
|
|
4922
|
+
}
|
|
4923
|
+
try {
|
|
4924
|
+
if (action === "enable") {
|
|
4925
|
+
if (selected?.readonly) {
|
|
4926
|
+
report("Readonly sessions cannot enable project validation.", true);
|
|
4927
|
+
return;
|
|
4928
|
+
}
|
|
4929
|
+
if (!controller.isIdle || sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current
|
|
4930
|
+
|| (selectedAgentId ? !selected || selected.status === "starting" || selected.status === "running" : busyRef.current)) {
|
|
4931
|
+
report("Wait for the selected session to become idle and session operations to finish before enabling validation.", true);
|
|
4932
|
+
return;
|
|
4933
|
+
}
|
|
4934
|
+
controller.enable(parts[2]!);
|
|
4935
|
+
report(controller.status());
|
|
4936
|
+
} else if (action === "disable") {
|
|
4937
|
+
// Deliberately allowed while busy: revoke and cancel in-flight work.
|
|
4938
|
+
controller.disable();
|
|
4939
|
+
report(controller.status());
|
|
4940
|
+
} else report(action === "status" ? controller.status() : controller.preview());
|
|
4941
|
+
} catch {
|
|
4942
|
+
report("Project validation request failed. Preview /validation and check the current proposal and digest before enabling.", true);
|
|
4943
|
+
}
|
|
4944
|
+
return;
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4947
|
+
// Explicit user-only capability gate. Entering plan mode only removes
|
|
4948
|
+
// capability, so it needs no confirmation; leaving it is the upgrade, so it
|
|
4949
|
+
// needs a second directly typed command, never a keypress on a restored draft.
|
|
4950
|
+
if (attachments.length === 0 && commandEligible && /^\/(?:plan|implement)(?:\s|$)/.test(promptText)) {
|
|
4951
|
+
setEditingStash(null);
|
|
4952
|
+
histCursor.current = null;
|
|
4953
|
+
draft.current = "";
|
|
4954
|
+
const report = (text: string, error = false) => {
|
|
4955
|
+
const line: Line = { kind: "text", role: error ? "error" : "system", text };
|
|
4956
|
+
if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
|
|
4957
|
+
else appendMainLine(line);
|
|
4958
|
+
};
|
|
4959
|
+
const parts = promptText.trim().split(/\s+/);
|
|
4960
|
+
const implement = parts[0] === "/implement";
|
|
4961
|
+
if (!directSubmission) {
|
|
4962
|
+
implementArmedRef.current = false;
|
|
4963
|
+
report("Plan mode commands require direct user input. Clear the draft and type the command anew.", true);
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4966
|
+
if (selectedAgentId) {
|
|
4967
|
+
report("Plan mode is a main-session role. Select the main transcript first.", true);
|
|
4968
|
+
return;
|
|
4969
|
+
}
|
|
4970
|
+
const active = planModeRef.current;
|
|
4971
|
+
if (implement) {
|
|
4972
|
+
if (parts.length > 2 || (parts.length === 2 && parts[1] !== "confirm")) {
|
|
4973
|
+
report("Usage: /implement, then /implement confirm to leave plan mode.", true);
|
|
4974
|
+
return;
|
|
4975
|
+
}
|
|
4976
|
+
if (!active) {
|
|
4977
|
+
implementArmedRef.current = false;
|
|
4978
|
+
report("Plan mode is already off; this session has full capability.", true);
|
|
4979
|
+
return;
|
|
4980
|
+
}
|
|
4981
|
+
if (parts.length === 1) {
|
|
4982
|
+
implementArmedRef.current = true;
|
|
4983
|
+
report("Leaving plan mode restores file mutation, mutable delegation, managed shells, MCP and project validation for this session. Files are not changed by the transition itself.\nType /implement confirm to leave plan mode, or keep planning.");
|
|
4984
|
+
return;
|
|
4985
|
+
}
|
|
4986
|
+
if (!implementArmedRef.current) {
|
|
4987
|
+
report("Type /implement first, read what it restores, then /implement confirm.", true);
|
|
4988
|
+
return;
|
|
4989
|
+
}
|
|
4990
|
+
applyPlanModeTransition("implement");
|
|
4991
|
+
return;
|
|
4992
|
+
}
|
|
4993
|
+
implementArmedRef.current = false;
|
|
4994
|
+
const text = promptText.trim().slice("/plan".length).trim();
|
|
4995
|
+
if (text) {
|
|
4996
|
+
try {
|
|
4997
|
+
const file = session.sessionFile;
|
|
4998
|
+
if (!file) throw new Error("Recording a plan requires a saved session.");
|
|
4999
|
+
const existing = loadPlanRecord(file);
|
|
5000
|
+
savePlanRecord(file, {
|
|
5001
|
+
version: 1,
|
|
5002
|
+
mode: existing?.mode ?? (active ? "plan" : "implement"),
|
|
5003
|
+
text: validatePlanText(text),
|
|
5004
|
+
at: Date.now(),
|
|
5005
|
+
});
|
|
5006
|
+
} catch (error) {
|
|
5007
|
+
report(error instanceof Error ? error.message : "The plan could not be recorded.", true);
|
|
5008
|
+
return;
|
|
5009
|
+
}
|
|
5010
|
+
report("Plan recorded.");
|
|
5011
|
+
}
|
|
5012
|
+
if (active) {
|
|
5013
|
+
report(describePlanRecord(loadPlanRecord(session.sessionFile), true));
|
|
5014
|
+
return;
|
|
5015
|
+
}
|
|
5016
|
+
applyPlanModeTransition("plan");
|
|
5017
|
+
return;
|
|
5018
|
+
}
|
|
5019
|
+
|
|
5020
|
+
// Explicit user-only conversation navigation. Never an extension command,
|
|
5021
|
+
// model tool, attachment or pasted marker, and never a filesystem restore.
|
|
5022
|
+
if (attachments.length === 0 && commandEligible && /^\/(?:branch|rewind)(?:\s|$)/.test(promptText)) {
|
|
5023
|
+
setEditingStash(null);
|
|
5024
|
+
histCursor.current = null;
|
|
5025
|
+
draft.current = "";
|
|
5026
|
+
const report = (text: string, error = false) => {
|
|
5027
|
+
const line: Line = { kind: "text", role: error ? "error" : "system", text };
|
|
5028
|
+
if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
|
|
5029
|
+
else appendMainLine(line);
|
|
5030
|
+
};
|
|
5031
|
+
if (!directSubmission) {
|
|
5032
|
+
report("Conversation branch commands require direct user input. Clear the draft and type the command anew.", true);
|
|
5033
|
+
return;
|
|
5034
|
+
}
|
|
5035
|
+
if (selectedAgentId) {
|
|
5036
|
+
report("Conversation branching is available only in the main transcript. Select the main transcript first.", true);
|
|
5037
|
+
return;
|
|
5038
|
+
}
|
|
5039
|
+
if (promptText.trim().split(/\s+/).length !== 1) {
|
|
5040
|
+
report("Usage: /branch (alias /rewind). Choose and confirm a point in the selector.", true);
|
|
5041
|
+
return;
|
|
5042
|
+
}
|
|
5043
|
+
openConversationBranch();
|
|
5044
|
+
return;
|
|
5045
|
+
}
|
|
5046
|
+
|
|
5047
|
+
// Explicit user-only recovery: never registered as an extension command or
|
|
5048
|
+
// model tool, and never interpreted from an attached image or pasted marker.
|
|
5049
|
+
if (attachments.length === 0 && commandEligible && /^\/checkpoint(?:\s|$)/.test(promptText)) {
|
|
5050
|
+
const report = (text: string, error = false) => {
|
|
5051
|
+
const line: Line = { kind: "text", role: error ? "error" : "system", text };
|
|
5052
|
+
if (selectedAgentId) subagentManager.appendAgentLine(selectedAgentId, line, { persist: false });
|
|
5053
|
+
else appendMainLine(line);
|
|
5054
|
+
};
|
|
5055
|
+
setEditingStash(null);
|
|
5056
|
+
histCursor.current = null;
|
|
5057
|
+
draft.current = "";
|
|
5058
|
+
const selected = selectedAgentId ? subagentManager.getAgent(selectedAgentId) : undefined;
|
|
5059
|
+
if (!directSubmission) {
|
|
5060
|
+
report("Checkpoint commands require direct user input. Clear the draft and type the command anew.", true);
|
|
5061
|
+
return;
|
|
5062
|
+
}
|
|
5063
|
+
const controller = checkpointControllerForSession(selectedAgentId ? subagentManager.getDiagnosticsSessionId(selectedAgentId) ?? "" : session.sessionId);
|
|
5064
|
+
const parts = promptText.trim().split(/\s+/);
|
|
5065
|
+
const action = parts[1] ?? "list";
|
|
5066
|
+
if (!((action === "list" || action === "clear") && parts.length <= 2)
|
|
5067
|
+
&& !(action === "recover" && parts.length === 3)) {
|
|
5068
|
+
report("Usage: /checkpoint [list|recover <id>|clear]", true);
|
|
5069
|
+
return;
|
|
5070
|
+
}
|
|
5071
|
+
if (!controller) {
|
|
5072
|
+
report("File checkpoints are unavailable for this session runtime.", true);
|
|
5073
|
+
return;
|
|
5074
|
+
}
|
|
5075
|
+
if (action === "list") {
|
|
5076
|
+
const records = controller.list();
|
|
5077
|
+
report(`${controller.summary()}\n${records.length ? records.map((record) =>
|
|
5078
|
+
`${record.id} | ${record.toolName} | ${record.priorAbsent ? "previously absent" : `${record.bytes} bytes before change`} | ${JSON.stringify(record.path)}`
|
|
5079
|
+
).join("\n") : "No file checkpoints retained."}\nRecovery creates a new copy only; original files are never restored in place.`);
|
|
5080
|
+
} else if (action === "clear") {
|
|
5081
|
+
controller.clear();
|
|
5082
|
+
report("File checkpoints cleared for this session runtime; original files unchanged.");
|
|
5083
|
+
} else {
|
|
5084
|
+
if (sessionSwitchRef.current || relocatingRef.current || pendingRelocationRef.current
|
|
5085
|
+
|| (selectedAgentId ? !selected || selected.status === "starting" || selected.status === "running" : busyRef.current)) {
|
|
5086
|
+
report("Wait for the selected session to become idle and session operations to finish before recovering a checkpoint.", true);
|
|
5087
|
+
return;
|
|
5088
|
+
}
|
|
5089
|
+
checkpointRecoveryRef.current = true;
|
|
5090
|
+
report("Creating a checkpoint recovery copy; wait for it to finish before changing sessions.");
|
|
5091
|
+
void Promise.resolve().then(() => {
|
|
5092
|
+
const liveId = selectedAgentId ? subagentManager.getDiagnosticsSessionId(selectedAgentId) : session.sessionId;
|
|
5093
|
+
if (!liveId || checkpointControllerForSession(liveId) !== controller) throw new Error("Checkpoint runtime changed");
|
|
5094
|
+
return controller.recover(parts[2]!);
|
|
5095
|
+
}).then((path) => {
|
|
5096
|
+
report(`Recovery copy created: ${JSON.stringify(path)}\nOriginal unchanged. Inspect the copy and apply it manually if wanted.`);
|
|
5097
|
+
}).catch((error) => {
|
|
5098
|
+
report(checkpointRecoveryFailureText(error), true);
|
|
5099
|
+
}).finally(() => { checkpointRecoveryRef.current = false; });
|
|
5100
|
+
}
|
|
5101
|
+
return;
|
|
5102
|
+
}
|
|
5103
|
+
|
|
4405
5104
|
// Model controls follow Settings: only main owns model mutations.
|
|
4406
5105
|
if (selectedAgentId && attachments.length === 0 && commandEligible
|
|
4407
5106
|
&& /^\/(?:model|effort|s|store)(?:\s|$)/.test(promptText)) {
|
|
@@ -4474,6 +5173,7 @@ export function App({
|
|
|
4474
5173
|
};
|
|
4475
5174
|
|
|
4476
5175
|
const submitShellCommand = () => {
|
|
5176
|
+
if (checkpointRecoveryRef.current) return;
|
|
4477
5177
|
const command = expandPastedTexts(inputRef.current?.plainText ?? "", pendingPastedTexts.current);
|
|
4478
5178
|
if (!command.trim()) return;
|
|
4479
5179
|
const selectedAgentId = activeAgentIdRef.current;
|
|
@@ -4736,6 +5436,7 @@ export function App({
|
|
|
4736
5436
|
};
|
|
4737
5437
|
|
|
4738
5438
|
const selectAgentView = (target: string | null): boolean => {
|
|
5439
|
+
if (checkpointRecoveryRef.current) return false;
|
|
4739
5440
|
const current = activeAgentIdRef.current;
|
|
4740
5441
|
// Nothing moves between views, so a pending attachment cannot be stranded.
|
|
4741
5442
|
if (target === current) return true;
|
|
@@ -4750,11 +5451,13 @@ export function App({
|
|
|
4750
5451
|
const currentKey = current ?? "main";
|
|
4751
5452
|
const targetKey = target ?? "main";
|
|
4752
5453
|
viewDrafts.current.set(currentKey, inputRef.current?.plainText ?? "");
|
|
5454
|
+
viewDraftOrigins.current.set(currentKey, draftOrigin.current);
|
|
4753
5455
|
viewEditingStashIndices.current.set(currentKey, editingStashIndex.current);
|
|
4754
5456
|
activeAgentIdRef.current = target;
|
|
4755
5457
|
setActiveAgentId(target);
|
|
4756
5458
|
setEditingStash(viewEditingStashIndices.current.get(targetKey) ?? null);
|
|
4757
|
-
setEditorText(viewDrafts.current.get(targetKey) ?? ""
|
|
5459
|
+
setEditorText(viewDrafts.current.get(targetKey) ?? "", undefined, false,
|
|
5460
|
+
viewDraftOrigins.current.get(targetKey) ?? "restored");
|
|
4758
5461
|
setStashMode(false);
|
|
4759
5462
|
histCursor.current = null;
|
|
4760
5463
|
resetCancelArm();
|
|
@@ -4951,6 +5654,13 @@ export function App({
|
|
|
4951
5654
|
});
|
|
4952
5655
|
|
|
4953
5656
|
useKeyboard((key) => {
|
|
5657
|
+
// A recovery is an explicit filesystem copy owned by the selected runtime.
|
|
5658
|
+
// Hold user transitions (including quit, settings, and agent/session switches)
|
|
5659
|
+
// until it settles rather than disposing that runtime underneath the copy.
|
|
5660
|
+
if (checkpointRecoveryRef.current) {
|
|
5661
|
+
key.stopPropagation();
|
|
5662
|
+
return;
|
|
5663
|
+
}
|
|
4954
5664
|
// Match Textarea's printable sequence handling, but leave all modified
|
|
4955
5665
|
// commands (including Option) with their existing owner.
|
|
4956
5666
|
const typedText = !key.ctrl && !key.meta && !key.option && !key.super && !key.hyper
|
|
@@ -5003,7 +5713,8 @@ export function App({
|
|
|
5003
5713
|
helpOpen ||
|
|
5004
5714
|
historyOpen ||
|
|
5005
5715
|
statsOpenRef.current ||
|
|
5006
|
-
newsOpenRef.current
|
|
5716
|
+
newsOpenRef.current ||
|
|
5717
|
+
branchOpenRef.current
|
|
5007
5718
|
)) {
|
|
5008
5719
|
key.stopPropagation();
|
|
5009
5720
|
resetCancelArm();
|
|
@@ -5025,6 +5736,7 @@ export function App({
|
|
|
5025
5736
|
queueMicrotask(() => inputRef.current?.focus());
|
|
5026
5737
|
} else if (statsOpenRef.current) closeStats();
|
|
5027
5738
|
else if (newsOpenRef.current) closeNews();
|
|
5739
|
+
else if (branchOpenRef.current) closeConversationBranch();
|
|
5028
5740
|
return;
|
|
5029
5741
|
}
|
|
5030
5742
|
|
|
@@ -5265,6 +5977,26 @@ export function App({
|
|
|
5265
5977
|
return; // navigation and Enter belong to the focused select
|
|
5266
5978
|
}
|
|
5267
5979
|
|
|
5980
|
+
if (branchOpenRef.current || branchOpen) {
|
|
5981
|
+
key.stopPropagation();
|
|
5982
|
+
if (key.name === "escape") {
|
|
5983
|
+
// Escape backs out of the confirmation first, so a stray key cannot
|
|
5984
|
+
// apply a branch the user only wanted to look at.
|
|
5985
|
+
if (branchConfirmingRef.current) {
|
|
5986
|
+
branchConfirmingRef.current = false;
|
|
5987
|
+
setBranchConfirming(false);
|
|
5988
|
+
} else closeConversationBranch();
|
|
5989
|
+
}
|
|
5990
|
+
else if (key.name === "up") moveBranchCursor(-1);
|
|
5991
|
+
else if (key.name === "down") moveBranchCursor(1);
|
|
5992
|
+
else if (key.name === "left") pageConversationBranch(-1);
|
|
5993
|
+
else if (key.name === "right") pageConversationBranch(1);
|
|
5994
|
+
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
|
|
5995
|
+
confirmConversationBranch();
|
|
5996
|
+
}
|
|
5997
|
+
return;
|
|
5998
|
+
}
|
|
5999
|
+
|
|
5268
6000
|
if (key.ctrl && key.name === "h") {
|
|
5269
6001
|
key.stopPropagation();
|
|
5270
6002
|
openHistory();
|
|
@@ -5620,7 +6352,7 @@ export function App({
|
|
|
5620
6352
|
if (commandMatches.length > 0 && !/\s/.test(inputValue)) {
|
|
5621
6353
|
key.stopPropagation();
|
|
5622
6354
|
const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
|
|
5623
|
-
setEditorText(selected.name);
|
|
6355
|
+
setEditorText(selected.name, undefined, false, draftOrigin.current);
|
|
5624
6356
|
return;
|
|
5625
6357
|
}
|
|
5626
6358
|
|
|
@@ -5638,7 +6370,7 @@ export function App({
|
|
|
5638
6370
|
: Math.min(commandCursorRef.current, completions.length - 1);
|
|
5639
6371
|
const sourceValue = continuing ? previousCycle!.sourceValue : inputValue;
|
|
5640
6372
|
const completed = applyPathCompletion(sourceValue, completions[index]!);
|
|
5641
|
-
setEditorText(completed.value, completed.cursorOffset, true);
|
|
6373
|
+
setEditorText(completed.value, completed.cursorOffset, true, draftOrigin.current);
|
|
5642
6374
|
pathCompletionCycle.current = {
|
|
5643
6375
|
sourceValue,
|
|
5644
6376
|
completions,
|
|
@@ -5678,7 +6410,7 @@ export function App({
|
|
|
5678
6410
|
if (isPlainReturn && commandMatches.length > 0 && !/\s/.test(inputValue)) {
|
|
5679
6411
|
key.stopPropagation();
|
|
5680
6412
|
const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
|
|
5681
|
-
submitPrompt(selected.name);
|
|
6413
|
+
submitPrompt(selected.name, undefined, true);
|
|
5682
6414
|
return;
|
|
5683
6415
|
}
|
|
5684
6416
|
|
|
@@ -5687,10 +6419,10 @@ export function App({
|
|
|
5687
6419
|
const index = Math.min(commandCursorRef.current, visibleArgumentMatches.length - 1);
|
|
5688
6420
|
const completed = applyPathCompletion(inputValue, visibleArgumentMatches[index]!);
|
|
5689
6421
|
if (closedSetMatches.length > 0) {
|
|
5690
|
-
submitPrompt(completed.value);
|
|
6422
|
+
submitPrompt(completed.value, undefined, true);
|
|
5691
6423
|
return;
|
|
5692
6424
|
}
|
|
5693
|
-
setEditorText(completed.value, completed.cursorOffset, true);
|
|
6425
|
+
setEditorText(completed.value, completed.cursorOffset, true, draftOrigin.current);
|
|
5694
6426
|
pathCompletionCycle.current = {
|
|
5695
6427
|
sourceValue: inputValue,
|
|
5696
6428
|
completions: visibleArgumentMatches,
|
|
@@ -5863,14 +6595,14 @@ export function App({
|
|
|
5863
6595
|
|
|
5864
6596
|
const popupResetKey = [
|
|
5865
6597
|
loginOpen, spawnPreview?.id ?? "", Boolean(questionnaire), helpOpen, triggersOpen,
|
|
5866
|
-
agentSelectorOpen, historyOpen, newsOpen, todoVisible, statsOpen, settingsOpen, page,
|
|
6598
|
+
agentSelectorOpen, historyOpen, newsOpen, branchOpen, todoVisible, statsOpen, settingsOpen, page,
|
|
5867
6599
|
].join(":");
|
|
5868
6600
|
const streamGap = visibleTx.stream
|
|
5869
6601
|
? needsTranscriptGap(lastLine, { kind: "text", role: visibleTx.stream.kind, text: visibleTx.stream.text })
|
|
5870
6602
|
: false;
|
|
5871
6603
|
const promptFocused = !transcriptFocused && !settingsOpen && !helpOpen && !historyOpen
|
|
5872
6604
|
&& !statsOpen && !agentSelectorOpen && !triggersOpen && !loginOpen && !providersOpen
|
|
5873
|
-
&& !visibleQuestionnaire && !spawnPreview && !newsOpen && !todoVisible;
|
|
6605
|
+
&& !visibleQuestionnaire && !spawnPreview && !newsOpen && !branchOpen && !todoVisible;
|
|
5874
6606
|
|
|
5875
6607
|
// One element for the whole transcript, rebuilt only when what it shows
|
|
5876
6608
|
// changes. React walks a child list of this size on every render of the app,
|
|
@@ -5941,6 +6673,7 @@ export function App({
|
|
|
5941
6673
|
/>
|
|
5942
6674
|
<StatusBar
|
|
5943
6675
|
theme={theme}
|
|
6676
|
+
planMode={planMode}
|
|
5944
6677
|
modelId={visibleModelId}
|
|
5945
6678
|
thinkingLevel={visibleThinkingLevel}
|
|
5946
6679
|
cwd={cwd}
|
|
@@ -6006,6 +6739,7 @@ export function App({
|
|
|
6006
6739
|
) : null}
|
|
6007
6740
|
</RenderErrorBoundary>
|
|
6008
6741
|
</scrollbox>
|
|
6742
|
+
{pendingSettingsNotice ? <text content={pendingSettingsNotice} fg={theme.dim} flexShrink={0} /> : null}
|
|
6009
6743
|
{ruleLabels.length > 0 ? <Gap /> : null}
|
|
6010
6744
|
<WorkingRule
|
|
6011
6745
|
theme={theme}
|
|
@@ -6218,6 +6952,18 @@ export function App({
|
|
|
6218
6952
|
onSelect={selectHistorySession}
|
|
6219
6953
|
/>
|
|
6220
6954
|
) : null}
|
|
6955
|
+
{branchOpen ? (
|
|
6956
|
+
<ConversationBranchPopup
|
|
6957
|
+
theme={theme}
|
|
6958
|
+
points={branchPage.points}
|
|
6959
|
+
cursor={branchCursor}
|
|
6960
|
+
offset={branchOffset}
|
|
6961
|
+
total={branchPage.total}
|
|
6962
|
+
confirming={branchConfirming}
|
|
6963
|
+
terminalWidth={width}
|
|
6964
|
+
terminalHeight={height}
|
|
6965
|
+
/>
|
|
6966
|
+
) : null}
|
|
6221
6967
|
{newsOpen ? (
|
|
6222
6968
|
<NewsPopup
|
|
6223
6969
|
theme={theme}
|