@jx-grxf/patchpilot 1.0.0 → 1.2.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.
Files changed (154) hide show
  1. package/README.md +51 -16
  2. package/dist/cli.js +46 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/agent.d.ts +44 -1
  5. package/dist/core/agent.js +617 -70
  6. package/dist/core/agent.js.map +1 -1
  7. package/dist/core/clipboard.d.ts +14 -0
  8. package/dist/core/clipboard.js +134 -0
  9. package/dist/core/clipboard.js.map +1 -0
  10. package/dist/core/codex.d.ts +8 -0
  11. package/dist/core/codex.js +28 -2
  12. package/dist/core/codex.js.map +1 -1
  13. package/dist/core/compaction.d.ts +23 -0
  14. package/dist/core/compaction.js +145 -0
  15. package/dist/core/compaction.js.map +1 -0
  16. package/dist/core/contextFormat.d.ts +21 -0
  17. package/dist/core/contextFormat.js +87 -0
  18. package/dist/core/contextFormat.js.map +1 -0
  19. package/dist/core/contextItem.d.ts +41 -0
  20. package/dist/core/contextItem.js +93 -0
  21. package/dist/core/contextItem.js.map +1 -0
  22. package/dist/core/contextStore.d.ts +48 -0
  23. package/dist/core/contextStore.js +306 -0
  24. package/dist/core/contextStore.js.map +1 -0
  25. package/dist/core/doctor.js +9 -8
  26. package/dist/core/doctor.js.map +1 -1
  27. package/dist/core/gemini.js +10 -4
  28. package/dist/core/gemini.js.map +1 -1
  29. package/dist/core/geminiWrapper.d.ts +43 -2
  30. package/dist/core/geminiWrapper.js +582 -42
  31. package/dist/core/geminiWrapper.js.map +1 -1
  32. package/dist/core/http.js +70 -6
  33. package/dist/core/http.js.map +1 -1
  34. package/dist/core/json.d.ts +1 -1
  35. package/dist/core/json.js +18 -20
  36. package/dist/core/json.js.map +1 -1
  37. package/dist/core/nvidia.d.ts +1 -1
  38. package/dist/core/nvidia.js +13 -4
  39. package/dist/core/nvidia.js.map +1 -1
  40. package/dist/core/ollama.js +13 -3
  41. package/dist/core/ollama.js.map +1 -1
  42. package/dist/core/openrouter.js +15 -6
  43. package/dist/core/openrouter.js.map +1 -1
  44. package/dist/core/reasoning.js +3 -0
  45. package/dist/core/reasoning.js.map +1 -1
  46. package/dist/core/session.js +9 -3
  47. package/dist/core/session.js.map +1 -1
  48. package/dist/core/tokenAccounting.d.ts +4 -0
  49. package/dist/core/tokenAccounting.js +75 -13
  50. package/dist/core/tokenAccounting.js.map +1 -1
  51. package/dist/core/types.d.ts +58 -3
  52. package/dist/core/types.js +30 -1
  53. package/dist/core/types.js.map +1 -1
  54. package/dist/core/updateCheck.d.ts +19 -0
  55. package/dist/core/updateCheck.js +103 -0
  56. package/dist/core/updateCheck.js.map +1 -0
  57. package/dist/core/workspace.d.ts +29 -0
  58. package/dist/core/workspace.js +1271 -92
  59. package/dist/core/workspace.js.map +1 -1
  60. package/dist/tui/App.d.ts +1 -0
  61. package/dist/tui/App.js +1346 -112
  62. package/dist/tui/App.js.map +1 -1
  63. package/dist/tui/commands.js +109 -6
  64. package/dist/tui/commands.js.map +1 -1
  65. package/dist/tui/components/ApprovalPanel.js +16 -1
  66. package/dist/tui/components/ApprovalPanel.js.map +1 -1
  67. package/dist/tui/components/CommandSuggestions.js +26 -3
  68. package/dist/tui/components/CommandSuggestions.js.map +1 -1
  69. package/dist/tui/components/Composer.d.ts +3 -0
  70. package/dist/tui/components/Composer.js +57 -5
  71. package/dist/tui/components/Composer.js.map +1 -1
  72. package/dist/tui/components/ExperimentalPanel.d.ts +1 -1
  73. package/dist/tui/components/ExperimentalPanel.js +5 -0
  74. package/dist/tui/components/ExperimentalPanel.js.map +1 -1
  75. package/dist/tui/components/OnboardingPanel.d.ts +12 -0
  76. package/dist/tui/components/OnboardingPanel.js +69 -21
  77. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  78. package/dist/tui/components/StartupBanner.d.ts +4 -0
  79. package/dist/tui/components/StartupBanner.js +9 -0
  80. package/dist/tui/components/StartupBanner.js.map +1 -0
  81. package/dist/tui/components/Transcript.d.ts +7 -0
  82. package/dist/tui/components/Transcript.js +86 -16
  83. package/dist/tui/components/Transcript.js.map +1 -1
  84. package/dist/tui/contextCommands.d.ts +8 -0
  85. package/dist/tui/contextCommands.js +205 -0
  86. package/dist/tui/contextCommands.js.map +1 -0
  87. package/dist/tui/experimental/AnimatedText.d.ts +38 -0
  88. package/dist/tui/experimental/AnimatedText.js +55 -0
  89. package/dist/tui/experimental/AnimatedText.js.map +1 -0
  90. package/dist/tui/experimental/Banner.d.ts +10 -0
  91. package/dist/tui/experimental/Banner.js +33 -0
  92. package/dist/tui/experimental/Banner.js.map +1 -0
  93. package/dist/tui/experimental/CommandPalette.d.ts +11 -0
  94. package/dist/tui/experimental/CommandPalette.js +25 -0
  95. package/dist/tui/experimental/CommandPalette.js.map +1 -0
  96. package/dist/tui/experimental/ExperimentalShell.d.ts +58 -0
  97. package/dist/tui/experimental/ExperimentalShell.js +366 -0
  98. package/dist/tui/experimental/ExperimentalShell.js.map +1 -0
  99. package/dist/tui/experimental/ThemePicker.d.ts +13 -0
  100. package/dist/tui/experimental/ThemePicker.js +12 -0
  101. package/dist/tui/experimental/ThemePicker.js.map +1 -0
  102. package/dist/tui/experimental/attachments.d.ts +35 -0
  103. package/dist/tui/experimental/attachments.js +244 -0
  104. package/dist/tui/experimental/attachments.js.map +1 -0
  105. package/dist/tui/experimental/composer.d.ts +24 -0
  106. package/dist/tui/experimental/composer.js +84 -0
  107. package/dist/tui/experimental/composer.js.map +1 -0
  108. package/dist/tui/experimental/geminiPricing.d.ts +16 -0
  109. package/dist/tui/experimental/geminiPricing.js +39 -0
  110. package/dist/tui/experimental/geminiPricing.js.map +1 -0
  111. package/dist/tui/experimental/layout.d.ts +46 -0
  112. package/dist/tui/experimental/layout.js +112 -0
  113. package/dist/tui/experimental/layout.js.map +1 -0
  114. package/dist/tui/experimental/theme.d.ts +35 -0
  115. package/dist/tui/experimental/theme.js +86 -0
  116. package/dist/tui/experimental/theme.js.map +1 -0
  117. package/dist/tui/experimental/transcriptRows.d.ts +20 -0
  118. package/dist/tui/experimental/transcriptRows.js +169 -0
  119. package/dist/tui/experimental/transcriptRows.js.map +1 -0
  120. package/dist/tui/experimental/ultraModes.d.ts +46 -0
  121. package/dist/tui/experimental/ultraModes.js +95 -0
  122. package/dist/tui/experimental/ultraModes.js.map +1 -0
  123. package/dist/tui/experimental/ultramaxx.d.ts +19 -0
  124. package/dist/tui/experimental/ultramaxx.js +43 -0
  125. package/dist/tui/experimental/ultramaxx.js.map +1 -0
  126. package/dist/tui/format.d.ts +4 -2
  127. package/dist/tui/format.js +14 -0
  128. package/dist/tui/format.js.map +1 -1
  129. package/dist/tui/hosts.js +7 -1
  130. package/dist/tui/hosts.js.map +1 -1
  131. package/dist/tui/layout.d.ts +26 -0
  132. package/dist/tui/layout.js +66 -0
  133. package/dist/tui/layout.js.map +1 -0
  134. package/dist/tui/modelSelection.d.ts +1 -1
  135. package/dist/tui/modelSelection.js +8 -6
  136. package/dist/tui/modelSelection.js.map +1 -1
  137. package/dist/tui/modes.d.ts +7 -0
  138. package/dist/tui/modes.js +12 -0
  139. package/dist/tui/modes.js.map +1 -1
  140. package/dist/tui/onboardingPreferences.d.ts +37 -0
  141. package/dist/tui/onboardingPreferences.js +118 -0
  142. package/dist/tui/onboardingPreferences.js.map +1 -0
  143. package/dist/tui/runStatus.d.ts +50 -0
  144. package/dist/tui/runStatus.js +164 -0
  145. package/dist/tui/runStatus.js.map +1 -0
  146. package/dist/tui/types.d.ts +8 -0
  147. package/dist/tui/types.js.map +1 -1
  148. package/docs/architecture.md +115 -0
  149. package/docs/gemini-wrapper.md +23 -0
  150. package/docs/product-context.md +43 -0
  151. package/docs/releases/v1.0.1.md +25 -0
  152. package/docs/releases/v1.1.0.md +30 -0
  153. package/docs/releases/v1.2.0.md +28 -0
  154. package/package.json +4 -2
package/dist/tui/App.js CHANGED
@@ -1,14 +1,16 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import { Box, useApp, useInput, useStdout } from "ink";
3
+ import { statSync } from "node:fs";
4
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
5
  import { AgentRunner } from "../core/agent.js";
5
6
  import { cleanupPatchPilot, readCleanupTarget } from "../core/cleanup.js";
6
7
  import { defaultCodexModel, hasCodexCliOAuth } from "../core/codex.js";
7
8
  import { describeComputeTarget } from "../core/compute.js";
9
+ import { ContextStore } from "../core/contextStore.js";
8
10
  import { runDoctor } from "../core/doctor.js";
9
11
  import { savePatchPilotEnvValues } from "../core/env.js";
10
12
  import { defaultGeminiModel, readGeminiApiKey } from "../core/gemini.js";
11
- import { defaultGeminiWrapperModel, geminiWrapperRequiresApiKey, readGeminiWrapperApiKey, readGeminiWrapperBaseUrl, readGeminiWrapperCookiesJson, readGeminiWrapperMode, readGeminiWrapperPythonCommand, saveGeminiWrapperCookieFile } from "../core/geminiWrapper.js";
13
+ import { defaultGeminiWrapperModel, geminiWrapperCuratedModels, geminiWrapperShortcutModels, geminiWrapperRequiresApiKey, readGeminiWrapperApiKey, readGeminiWrapperBaseUrl, readGeminiWrapperCookiesJson, readGeminiWrapperMode, readGeminiWrapperPythonCommand, importGeminiWrapperBrowserCookies, saveGeminiWrapperCookieFile } from "../core/geminiWrapper.js";
12
14
  import { createModelClient } from "../core/modelClient.js";
13
15
  import { defaultNvidiaModel, readNvidiaApiKey } from "../core/nvidia.js";
14
16
  import { defaultOllamaModel, OllamaClient } from "../core/ollama.js";
@@ -16,12 +18,20 @@ import { defaultOpenRouterModel, isOpenRouterFreeModel, readOpenRouterApiKey } f
16
18
  import { ensurePatchPilotGitignore, patchPilotInitPrompt } from "../core/projectInit.js";
17
19
  import { formatReasoningSupport } from "../core/reasoning.js";
18
20
  import { buildSessionResumeContext, listWorkspaceSessions, loadSessionSummary, SessionStore } from "../core/session.js";
19
- import { addTelemetryToSession, emptySessionTelemetry, estimateTokens } from "../core/tokenAccounting.js";
20
- import { WorkspaceTools } from "../core/workspace.js";
21
+ import { addTelemetryToSession, emptySessionTelemetry, estimateComparableApiCost, estimateTokens } from "../core/tokenAccounting.js";
22
+ import { checkForPatchPilotUpdate, installPatchPilotUpdate } from "../core/updateCheck.js";
23
+ import { getToolSpec, WorkspaceTools } from "../core/workspace.js";
21
24
  import { ApprovalPanel } from "./components/ApprovalPanel.js";
25
+ import { clipboardHasImage, clipboardImageHint, readClipboardImage } from "../core/clipboard.js";
22
26
  import { CommandSuggestions } from "./components/CommandSuggestions.js";
23
27
  import { Composer, FooterHints } from "./components/Composer.js";
24
28
  import { ExperimentalPanel, experimentalFlagAt, experimentalFlagCount } from "./components/ExperimentalPanel.js";
29
+ import { runContextSlashCommand } from "./contextCommands.js";
30
+ import { ExperimentalShell } from "./experimental/ExperimentalShell.js";
31
+ import { ThemePicker } from "./experimental/ThemePicker.js";
32
+ import { attachmentKindForPath, attachmentLabel, attachmentTypeForPath, formatSessionArtifactContext } from "./experimental/attachments.js";
33
+ import { describeUltraModes, parseUltraModes } from "./experimental/ultraModes.js";
34
+ import { formatCompletionSummary } from "./runStatus.js";
25
35
  import { Header } from "./components/Header.js";
26
36
  import { OnboardingPanel } from "./components/OnboardingPanel.js";
27
37
  import { Sidebar } from "./components/Sidebar.js";
@@ -29,20 +39,47 @@ import { Transcript } from "./components/Transcript.js";
29
39
  import { filterSlashCommands, formatCommandDetail, formatCommandHelp } from "./commands.js";
30
40
  import { formatCost, formatSessionTokens, formatTokens, normalizeModelAlias, readToggle } from "./format.js";
31
41
  import { checkOllamaHost, discoverOllamaHosts, normalizeOllamaUrl, readOllamaHostDetails, startLocalOllamaAppAndWait } from "./hosts.js";
32
- import { initialAgentMode, modeDescription, modePermissionLabel, nextAgentMode, permissionsForMode } from "./modes.js";
42
+ import { computeComposerLayout } from "./layout.js";
43
+ import { initialAgentMode, modeDescription, modePermissionLabel, nextAgentMode, permissionsForMode, shouldBypassApproval } from "./modes.js";
33
44
  import { selectableModels } from "./modelSelection.js";
45
+ import { cyclePreference, modePermissions as preferencesModePermissions, preferenceRows, preferencesEnvValues, readOnboardingPreferences } from "./onboardingPreferences.js";
34
46
  import { readGpuStats, readSystemStats } from "./systemStats.js";
35
47
  import { maxTranscriptLines } from "./types.js";
48
+ const themeOptions = [
49
+ {
50
+ value: "new",
51
+ label: "New",
52
+ description: "Experimental fullscreen shell: compact header, scrolling transcript, command palette, animated run status."
53
+ },
54
+ {
55
+ value: "legacy",
56
+ label: "Legacy",
57
+ description: "Original PatchPilot TUI with the sidebar and split-pane layout."
58
+ }
59
+ ];
60
+ function readUiTheme() {
61
+ return process.env.PATCHPILOT_UI_THEME?.trim().toLowerCase() === "legacy" ? "legacy" : "new";
62
+ }
63
+ /** Heuristic: does this Gemini-Wrapper error look like expired/invalid cookies? */
64
+ function isGeminiCookieError(message) {
65
+ return /cookie|secure_1psid|psidts|expired|sign[ -]?in|auth(?:enticat|oriz)|401|403|session.*(?:invalid|expired)/i.test(message);
66
+ }
36
67
  const modelCacheTtlMs = 5 * 60_000;
37
68
  const modelCache = new Map();
69
+ const modelDescriptorIndex = new Map();
38
70
  export function App(props) {
39
71
  const { exit } = useApp();
40
72
  const { stdout } = useStdout();
41
73
  const [input, setInput] = useState(props.initialTask ?? "");
42
74
  const didRunInitialTask = useRef(false);
43
75
  const didOpenDefaultOnboarding = useRef(false);
76
+ const didCheckForUpdates = useRef(false);
44
77
  const abortControllerRef = useRef(null);
78
+ const softStopRequestedRef = useRef(false);
79
+ const lastEscapeStopAtRef = useRef(0);
80
+ const lastAttachmentWarningRef = useRef("");
45
81
  const sessionStoreRef = useRef(new SessionStore({ workspace: props.workspace }));
82
+ const contextStoreRef = useRef(new ContextStore({ workspace: props.workspace, sessionId: sessionStoreRef.current.sessionId }));
46
83
  const approvalResolverRef = useRef(null);
47
84
  const runtimeStateRef = useRef({
48
85
  isRunning: false,
@@ -56,14 +93,23 @@ export function App(props) {
56
93
  const activeHostSyncInFlightRef = useRef(false);
57
94
  const autoLoadKeysRef = useRef(new Set());
58
95
  const usedOllamaModelsRef = useRef(new Set());
96
+ // Rolling session memory: short digests of earlier turns so a later prompt
97
+ // ("now do X") still knows what the user asked for and where.
98
+ const conversationTurnsRef = useRef([]);
59
99
  const [lines, setLines] = useState([]);
60
100
  const [advisorNotes, setAdvisorNotes] = useState([]);
101
+ const [todos, setTodos] = useState([]);
102
+ const [todoFrame, setTodoFrame] = useState(0);
103
+ const [verbTick, setVerbTick] = useState(0);
61
104
  const [isRunning, setIsRunning] = useState(false);
62
105
  const [status, setStatus] = useState("idle");
63
106
  const [workState, setWorkState] = useState("idle");
64
107
  const [pendingApproval, setPendingApproval] = useState(null);
108
+ const [updatePrompt, setUpdatePrompt] = useState(null);
109
+ const [updateBusy, setUpdateBusy] = useState(false);
65
110
  const [telemetry, setTelemetry] = useState(null);
66
111
  const [sessionTelemetry, setSessionTelemetry] = useState(() => emptySessionTelemetry());
112
+ const [toolTelemetry, setToolTelemetry] = useState(() => emptyToolTelemetry());
67
113
  const [resumeContext, setResumeContext] = useState("");
68
114
  const [systemStats, setSystemStats] = useState(() => readSystemStats().stats);
69
115
  const [gpuStats, setGpuStats] = useState(null);
@@ -80,8 +126,21 @@ export function App(props) {
80
126
  const [experimentalFlags, setExperimentalFlags] = useState({
81
127
  fileAnalysis: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_FILE_ANALYSIS, false),
82
128
  memory: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_MEMORY, false),
83
- subagents: props.subagents
129
+ subagents: props.subagents,
130
+ shellMetacharacters: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_SHELL_METACHARACTERS, false)
84
131
  });
132
+ const [uiTheme, setUiTheme] = useState(() => readUiTheme());
133
+ const [themePickerOpen, setThemePickerOpen] = useState(false);
134
+ const [themePickerIndex, setThemePickerIndex] = useState(0);
135
+ const [ultramaxxRun, setUltramaxxRun] = useState(false);
136
+ const [reauthPrompt, setReauthPrompt] = useState(null);
137
+ const [reauthBusy, setReauthBusy] = useState(false);
138
+ const [artifacts, setArtifacts] = useState([]);
139
+ const artifactsRef = useRef([]);
140
+ const pendingAttachmentsRef = useRef([]);
141
+ // Tracks whether the "image in clipboard" hint was already shown for the
142
+ // current clipboard contents, so the poll does not repeat it every tick.
143
+ const clipboardHintShownRef = useRef(false);
85
144
  const [onboardingIndex, setOnboardingIndex] = useState(0);
86
145
  const [onboardingInput, setOnboardingInput] = useState("");
87
146
  const [onboardingBusyMessage, setOnboardingBusyMessage] = useState(null);
@@ -105,7 +164,11 @@ export function App(props) {
105
164
  const draftTokens = estimateTokens(input);
106
165
  const terminalRows = stdout.rows ?? 40;
107
166
  const terminalColumns = stdout.columns ?? 120;
108
- const paletteItems = !isRunning && !onboarding && !experimentalOpen
167
+ const reauthPromptActive = Boolean(reauthPrompt || reauthBusy);
168
+ const updatePromptActive = !reauthPromptActive && Boolean(updatePrompt || updateBusy);
169
+ const approvalPromptActive = !reauthPromptActive && !updatePromptActive && Boolean(pendingApproval || bypassConfirmation);
170
+ const blockingPromptActive = reauthPromptActive || updatePromptActive || approvalPromptActive;
171
+ const paletteItems = !isRunning && !onboarding && !experimentalOpen && !blockingPromptActive
109
172
  ? buildCommandSuggestionItems({
110
173
  input,
111
174
  provider: settings.provider,
@@ -118,41 +181,234 @@ export function App(props) {
118
181
  : [];
119
182
  const rootHeight = Math.max(24, terminalRows);
120
183
  const headerReservedHeight = 5;
121
- const paletteReservedHeight = !onboarding && paletteItems.length > 0 ? Math.min(8, paletteItems.length) + 4 : 0;
122
- const composerReservedHeight = onboarding || experimentalOpen ? 0 : 2;
123
- const footerReservedHeight = onboarding || experimentalOpen ? 0 : 1;
124
- const approvalReservedHeight = !onboarding && !experimentalOpen && (pendingApproval || bypassConfirmation) ? 6 : 0;
125
- const panelHeight = Math.max(8, rootHeight - headerReservedHeight - composerReservedHeight - paletteReservedHeight - footerReservedHeight - approvalReservedHeight);
126
184
  const transcriptWidth = Math.max(42, terminalColumns - 38);
127
- const scrollStep = Math.max(4, Math.floor(panelHeight * 0.8));
185
+ const paletteReservedHeight = !onboarding && paletteItems.length > 0 ? Math.min(8, paletteItems.length) + 7 : 0;
186
+ const composerReservedHeight = onboarding || experimentalOpen ? 0 : computeComposerLayout({ input, width: transcriptWidth, promptWidth: 8 }).height;
187
+ const footerReservedHeight = onboarding || experimentalOpen ? 0 : 1;
188
+ const approvalReservedHeight = !onboarding && !experimentalOpen && blockingPromptActive ? 7 : 0;
189
+ const bodyHeight = Math.max(8, rootHeight - headerReservedHeight);
190
+ const transcriptHeight = Math.max(4, bodyHeight - composerReservedHeight - paletteReservedHeight - footerReservedHeight - approvalReservedHeight);
191
+ const panelHeight = onboarding || experimentalOpen ? bodyHeight : transcriptHeight;
192
+ const scrollStep = Math.max(4, Math.floor(transcriptHeight * 0.8));
128
193
  const appendLine = useCallback((line) => {
129
194
  setLines((currentLines) => [
130
- ...currentLines.slice(-maxTranscriptLines),
195
+ ...currentLines,
131
196
  {
132
197
  ...line,
133
198
  kind: line.kind ?? defaultLogKind(line),
134
199
  id: Date.now() + Math.random()
135
200
  }
136
- ]);
201
+ ].slice(-maxTranscriptLines));
202
+ }, []);
203
+ const pushArtifact = useCallback((artifact) => {
204
+ artifactsRef.current = [...artifactsRef.current, artifact].slice(-40);
205
+ setArtifacts(artifactsRef.current);
206
+ void contextStoreRef.current.append({
207
+ kind: artifact.origin === "attached" ? "attachment" : "artifact",
208
+ source: artifact.origin === "attached" ? "user" : "tool",
209
+ label: artifact.label,
210
+ path: artifact.path,
211
+ priority: artifact.origin === "attached" ? 85 : 75,
212
+ meta: {
213
+ artifactKind: artifact.kind,
214
+ origin: artifact.origin
215
+ }
216
+ }).catch(() => undefined);
137
217
  }, []);
218
+ // Registers a pasted document as an attachment chip; returns the chip label
219
+ // ("[PNG #1]") for the composer to insert inline.
220
+ const attachFile = useCallback((path) => {
221
+ const kind = attachmentKindForPath(path) ?? "file";
222
+ const type = attachmentTypeForPath(path);
223
+ const sameType = artifactsRef.current.filter((item) => item.origin === "attached" && attachmentTypeForPath(item.path) === type).length;
224
+ const label = attachmentLabel(kind, sameType + 1, path);
225
+ pushArtifact({ id: Date.now() + Math.random(), kind, path, label, origin: "attached" });
226
+ const nextPendingAttachments = [...pendingAttachmentsRef.current, path];
227
+ pendingAttachmentsRef.current = nextPendingAttachments;
228
+ appendLine({
229
+ tone: "accent",
230
+ label: "attach",
231
+ text: `${label} attached`,
232
+ detail: path
233
+ });
234
+ const warning = attachmentLimitWarning(nextPendingAttachments, settings.provider);
235
+ if (warning && warning !== lastAttachmentWarningRef.current) {
236
+ lastAttachmentWarningRef.current = warning;
237
+ appendLine({
238
+ tone: "warning",
239
+ label: "attach",
240
+ text: warning,
241
+ detail: "For Gemini/Gemini-Wrapper, send large batches in smaller prompts or ask PatchPilot to inspect the files in separate calls."
242
+ });
243
+ }
244
+ return label;
245
+ }, [appendLine, pushArtifact, settings.provider]);
246
+ // Ctrl+V: pull an image straight out of the OS clipboard, save it to a temp
247
+ // file, and attach it — no need to save the screenshot to disk first.
248
+ const handleClipboardImagePaste = useCallback(async () => {
249
+ appendLine({ kind: "status", tone: "muted", label: "clipboard", text: "Zwischenablage wird gelesen…" });
250
+ const imagePath = await readClipboardImage();
251
+ if (!imagePath) {
252
+ appendLine({
253
+ kind: "status",
254
+ tone: "warning",
255
+ label: "clipboard",
256
+ text: "Kein Bild in der Zwischenablage gefunden.",
257
+ detail: "Kopiere ein Bild (z. B. einen Screenshot) und drücke erneut Ctrl+V."
258
+ });
259
+ return;
260
+ }
261
+ const label = attachFile(imagePath);
262
+ setInput((current) => {
263
+ if (current.length === 0) {
264
+ return `${label} `;
265
+ }
266
+ return `${current}${current.endsWith(" ") ? "" : " "}${label} `;
267
+ });
268
+ clipboardHintShownRef.current = true;
269
+ }, [appendLine, attachFile]);
270
+ // Watch the OS clipboard while idle: when an image appears, tell the user
271
+ // once that they can attach it with Ctrl+V (reset when the image is gone).
272
+ useEffect(() => {
273
+ if (isRunning) {
274
+ return;
275
+ }
276
+ let cancelled = false;
277
+ const poll = async () => {
278
+ const present = await clipboardHasImage();
279
+ if (cancelled) {
280
+ return;
281
+ }
282
+ if (present && !clipboardHintShownRef.current) {
283
+ clipboardHintShownRef.current = true;
284
+ appendLine({ kind: "status", tone: "accent", label: "clipboard", text: clipboardImageHint() });
285
+ }
286
+ else if (!present) {
287
+ clipboardHintShownRef.current = false;
288
+ }
289
+ };
290
+ void poll();
291
+ const timer = setInterval(() => void poll(), 7000);
292
+ return () => {
293
+ cancelled = true;
294
+ clearInterval(timer);
295
+ };
296
+ }, [isRunning, appendLine]);
297
+ // Best-effort: record a document PatchPilot wrote during a run.
298
+ const registerCreatedArtifact = useCallback((path) => {
299
+ const kind = attachmentKindForPath(path);
300
+ if (!kind || artifactsRef.current.some((item) => item.path === path)) {
301
+ return;
302
+ }
303
+ const type = attachmentTypeForPath(path);
304
+ const sameType = artifactsRef.current.filter((item) => item.origin === "created" && attachmentTypeForPath(item.path) === type).length;
305
+ pushArtifact({
306
+ id: Date.now() + Math.random(),
307
+ kind,
308
+ path,
309
+ label: attachmentLabel(kind, sameType + 1, path),
310
+ origin: "created"
311
+ });
312
+ }, [pushArtifact]);
313
+ useEffect(() => {
314
+ if (!isRunning || todos.every((todo) => todo.status !== "in_progress")) {
315
+ setTodoFrame(0);
316
+ return;
317
+ }
318
+ const timer = setInterval(() => {
319
+ setTodoFrame((currentFrame) => (currentFrame + 1) % 4);
320
+ }, 180);
321
+ return () => {
322
+ clearInterval(timer);
323
+ };
324
+ }, [isRunning, todos]);
325
+ // Slow run-status verb tick: the verb only advances every 10s while the fast
326
+ // spinner glyph keeps animating, so the status line never flickers.
327
+ useEffect(() => {
328
+ if (!isRunning) {
329
+ setVerbTick(randomLegacyVerbIndex());
330
+ return;
331
+ }
332
+ setVerbTick(randomLegacyVerbIndex());
333
+ const timer = setInterval(() => {
334
+ setVerbTick((currentTick) => {
335
+ let nextTick = randomLegacyVerbIndex();
336
+ if (nextTick === currentTick) {
337
+ nextTick += 1;
338
+ }
339
+ return nextTick;
340
+ });
341
+ }, 10_000);
342
+ return () => {
343
+ clearInterval(timer);
344
+ };
345
+ }, [isRunning]);
138
346
  const resolveApproval = useCallback((decision) => {
139
347
  if (!pendingApproval || !approvalResolverRef.current) {
140
348
  return;
141
349
  }
142
350
  approvalResolverRef.current(decision);
143
351
  approvalResolverRef.current = null;
352
+ const nextWorkState = decision === "deny" ? "error" : workStateForApprovalTool(pendingApproval.tool);
144
353
  setInput("");
354
+ setStatus(decision === "deny" ? `${pendingApproval.tool} denied` : `${pendingApproval.tool} approved; running`);
355
+ setWorkState(nextWorkState);
145
356
  appendLine({
146
357
  kind: "approval",
147
358
  tone: decision === "deny" ? "warning" : "success",
148
359
  label: "approval",
149
360
  text: `${pendingApproval.tool} ${decision.replace("_", " ")}`,
150
361
  detail: pendingApproval.preview,
151
- workState: "waiting_approval",
362
+ workState: nextWorkState,
152
363
  tool: pendingApproval.tool
153
364
  });
154
365
  setPendingApproval(null);
155
366
  }, [appendLine, pendingApproval]);
367
+ const resolveUpdatePrompt = useCallback(async (accept) => {
368
+ const pending = updatePrompt;
369
+ if (!pending || updateBusy) {
370
+ return;
371
+ }
372
+ if (!accept) {
373
+ setUpdatePrompt(null);
374
+ setStatus("idle");
375
+ appendLine({
376
+ tone: "muted",
377
+ label: "update",
378
+ text: `Skipped PatchPilot ${pending.latestVersion}.`,
379
+ detail: `Manual command: ${pending.command}`
380
+ });
381
+ return;
382
+ }
383
+ setUpdateBusy(true);
384
+ setStatus(`updating to ${pending.latestVersion}`);
385
+ appendLine({
386
+ tone: "accent",
387
+ label: "update",
388
+ text: `Running ${pending.command}`
389
+ });
390
+ try {
391
+ const result = await installPatchPilotUpdate(pending.latestVersion);
392
+ setUpdatePrompt(null);
393
+ appendLine({
394
+ tone: "success",
395
+ label: "update",
396
+ text: `⚡ Successfully updated to v${result.version}. Please restart PatchPilot.`,
397
+ detail: result.command
398
+ });
399
+ }
400
+ catch (error) {
401
+ appendLine({
402
+ tone: "danger",
403
+ label: "update",
404
+ text: error instanceof Error ? error.message : String(error),
405
+ detail: `Automatic update failed. Manual command: ${pending.command}`
406
+ });
407
+ }
408
+ finally {
409
+ setUpdateBusy(false);
410
+ }
411
+ }, [appendLine, updateBusy, updatePrompt]);
156
412
  const applyMode = useCallback((nextMode, announce = true) => {
157
413
  const permissions = permissionsForMode(nextMode);
158
414
  setAgentMode(nextMode);
@@ -292,6 +548,7 @@ export function App(props) {
292
548
  setModelOptions(details.models);
293
549
  modelCache.set(`ollama:${verifiedHost.url}`, {
294
550
  models: details.models,
551
+ descriptors: details.models.map((model) => ({ id: model, displayName: model })),
295
552
  expiresAt: Date.now() + modelCacheTtlMs
296
553
  });
297
554
  setSettings((currentSettings) => ({
@@ -388,6 +645,14 @@ export function App(props) {
388
645
  setOnboardingNotice(null);
389
646
  setOnboardingIndex(0);
390
647
  switch (onboarding.step) {
648
+ case "welcome":
649
+ setOnboarding(null);
650
+ return;
651
+ case "disclaimer":
652
+ setOnboarding({
653
+ step: "welcome"
654
+ });
655
+ return;
391
656
  case "entry":
392
657
  setOnboarding(null);
393
658
  return;
@@ -412,6 +677,15 @@ export function App(props) {
412
677
  hosts: hostOptions
413
678
  });
414
679
  return;
680
+ case "preferences":
681
+ if (onboarding.provider === "gemini-wrapper") {
682
+ setOnboarding({
683
+ step: "gemini-wrapper-model-mode"
684
+ });
685
+ return;
686
+ }
687
+ void openModelSelection(onboarding.provider, { currentModel: onboarding.model });
688
+ return;
415
689
  case "model":
416
690
  if (onboarding.provider === "ollama" && activeHost?.host.kind !== "local") {
417
691
  setOnboarding({
@@ -448,7 +722,7 @@ export function App(props) {
448
722
  step: "entry"
449
723
  });
450
724
  }
451
- }, [activeHost?.host.kind, hostOptions, onboarding]);
725
+ }, [activeHost?.host.kind, hostOptions, onboarding, openModelSelection]);
452
726
  const handleOnboardingSubmit = useCallback(async (value) => {
453
727
  if (!onboarding) {
454
728
  return;
@@ -457,6 +731,33 @@ export function App(props) {
457
731
  return;
458
732
  }
459
733
  setOnboardingNotice(null);
734
+ if (onboarding.step === "welcome") {
735
+ setOnboarding({
736
+ step: "disclaimer"
737
+ });
738
+ setOnboardingIndex(0);
739
+ return;
740
+ }
741
+ if (onboarding.step === "disclaimer") {
742
+ const normalizedValue = value.trim().toLowerCase();
743
+ if (normalizedValue !== "y" && normalizedValue !== "yes" && normalizedValue !== "1") {
744
+ setOnboardingNotice({
745
+ tone: "warning",
746
+ text: "Accept the use-at-your-own-risk notice to continue.",
747
+ detail: "Press y to continue, or Escape to go back."
748
+ });
749
+ return;
750
+ }
751
+ savePatchPilotEnvValues({
752
+ PATCHPILOT_DISCLAIMER_ACCEPTED: "2026-05-22"
753
+ });
754
+ process.env.PATCHPILOT_DISCLAIMER_ACCEPTED = "2026-05-22";
755
+ setOnboarding({
756
+ step: "entry"
757
+ });
758
+ setOnboardingIndex(0);
759
+ return;
760
+ }
460
761
  if (onboarding.step === "entry") {
461
762
  const selection = readEntrySelection(value, onboardingIndex);
462
763
  if (!selection) {
@@ -587,8 +888,8 @@ export function App(props) {
587
888
  if (choice === null) {
588
889
  return;
589
890
  }
590
- if (choice === 0 && onboarding.hasExistingKey) {
591
- if (onboarding.provider === "gemini-wrapper") {
891
+ if (onboarding.provider === "gemini-wrapper") {
892
+ if (choice === 0 && onboarding.hasExistingKey) {
592
893
  setOnboarding({
593
894
  step: "gemini-wrapper-model-mode"
594
895
  });
@@ -596,12 +897,56 @@ export function App(props) {
596
897
  setOnboardingIndex(0);
597
898
  return;
598
899
  }
900
+ const importChoice = onboarding.hasExistingKey ? 1 : 0;
901
+ if (choice === importChoice) {
902
+ setOnboardingBusyMessage("Importing Gemini browser cookies...");
903
+ try {
904
+ const result = await importGeminiWrapperBrowserCookies();
905
+ process.env.PATCHPILOT_GEMINI_WRAPPER_MODE = "python";
906
+ process.env.PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON = result.cookiesPath;
907
+ savePatchPilotEnvValues({
908
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
909
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
910
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "python",
911
+ PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON: result.cookiesPath
912
+ });
913
+ setOnboardingNotice({
914
+ tone: "success",
915
+ text: `Imported ${result.cookieCount} Gemini browser cookies from ${result.source}.`,
916
+ detail: `${result.cookiesPath} was written with owner-only permissions. Secret values were not printed.`
917
+ });
918
+ setOnboarding({
919
+ step: "gemini-wrapper-model-mode"
920
+ });
921
+ setOnboardingInput("");
922
+ setOnboardingIndex(0);
923
+ }
924
+ catch (error) {
925
+ setOnboardingNotice({
926
+ tone: "warning",
927
+ text: "Gemini browser cookie import failed.",
928
+ detail: error instanceof Error ? error.message : String(error)
929
+ });
930
+ }
931
+ finally {
932
+ setOnboardingBusyMessage(null);
933
+ }
934
+ return;
935
+ }
936
+ setOnboarding({
937
+ step: "gemini-wrapper-psid"
938
+ });
939
+ setOnboardingInput("");
940
+ setOnboardingIndex(0);
941
+ return;
942
+ }
943
+ if (choice === 0 && onboarding.hasExistingKey) {
599
944
  await openModelSelection(onboarding.provider, {
600
945
  currentModel: defaultModelForProvider(onboarding.provider, settings.model)
601
946
  });
602
947
  return;
603
948
  }
604
- setOnboarding(onboarding.provider === "gemini-wrapper" ? { step: "gemini-wrapper-psid" } : {
949
+ setOnboarding({
605
950
  step: `${onboarding.provider}-key`
606
951
  });
607
952
  setOnboardingInput("");
@@ -681,25 +1026,30 @@ export function App(props) {
681
1026
  if (choice === null) {
682
1027
  return;
683
1028
  }
684
- if (choice === 0) {
1029
+ const curatedModel = geminiWrapperShortcutModels[choice];
1030
+ if (curatedModel) {
685
1031
  setTelemetry(null);
686
- setModelOptions([defaultGeminiWrapperModel]);
1032
+ const shortcutDescriptors = geminiWrapperShortcutModels.map((model) => ({ id: model, displayName: model }));
1033
+ rememberModelDescriptors(shortcutDescriptors);
1034
+ setModelOptions([...geminiWrapperShortcutModels]);
687
1035
  setSettings((currentSettings) => ({
688
1036
  ...currentSettings,
689
1037
  provider: "gemini-wrapper",
690
- model: defaultGeminiWrapperModel
1038
+ model: curatedModel
691
1039
  }));
692
1040
  savePatchPilotEnvValues({
693
1041
  PATCHPILOT_PROVIDER: "gemini-wrapper",
694
- PATCHPILOT_MODEL: defaultGeminiWrapperModel,
695
- PATCHPILOT_ONBOARDING_COMPLETE: "1"
1042
+ PATCHPILOT_MODEL: curatedModel,
1043
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "python"
696
1044
  });
697
- appendLine({
698
- tone: "success",
699
- label: "onboarding",
700
- text: `ready: gemini-wrapper using ${defaultGeminiWrapperModel}`
1045
+ setOnboarding({
1046
+ step: "preferences",
1047
+ provider: "gemini-wrapper",
1048
+ model: curatedModel,
1049
+ preferences: readOnboardingPreferences()
701
1050
  });
702
- closeOnboarding();
1051
+ setOnboardingInput("");
1052
+ setOnboardingIndex(preferenceRows.length);
703
1053
  return;
704
1054
  }
705
1055
  await openModelSelection("gemini-wrapper", {
@@ -728,10 +1078,12 @@ export function App(props) {
728
1078
  return;
729
1079
  }
730
1080
  process.env.PATCHPILOT_GEMINI_WRAPPER_BASE_URL = baseUrl;
1081
+ process.env.PATCHPILOT_GEMINI_WRAPPER_MODE = "http";
731
1082
  savePatchPilotEnvValues({
732
1083
  PATCHPILOT_PROVIDER: "gemini-wrapper",
733
1084
  PATCHPILOT_MODEL: defaultGeminiWrapperModel,
734
- PATCHPILOT_GEMINI_WRAPPER_BASE_URL: baseUrl
1085
+ PATCHPILOT_GEMINI_WRAPPER_BASE_URL: baseUrl,
1086
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "http"
735
1087
  });
736
1088
  setOnboardingNotice({
737
1089
  tone: "success",
@@ -766,6 +1118,7 @@ export function App(props) {
766
1118
  PATCHPILOT_PROVIDER: "gemini-wrapper",
767
1119
  PATCHPILOT_MODEL: defaultGeminiWrapperModel,
768
1120
  PATCHPILOT_GEMINI_WRAPPER_BASE_URL: onboarding.baseUrl,
1121
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "http",
769
1122
  ...(apiKey ? { PATCHPILOT_GEMINI_WRAPPER_API_KEY: apiKey } : {})
770
1123
  });
771
1124
  setOnboardingNotice({
@@ -839,7 +1192,54 @@ export function App(props) {
839
1192
  });
840
1193
  return;
841
1194
  }
842
- const visibleModels = selectableModels(onboardingInput, onboarding.models);
1195
+ if (onboarding.step === "preferences") {
1196
+ const confirmIndex = preferenceRows.length;
1197
+ const selection = readIndexedSelection(value, onboardingIndex);
1198
+ if (selection !== confirmIndex) {
1199
+ return;
1200
+ }
1201
+ const prefs = onboarding.preferences;
1202
+ const permissions = preferencesModePermissions(prefs.mode);
1203
+ setTelemetry(null);
1204
+ setAgentMode(prefs.mode);
1205
+ grantedPermissionsRef.current = permissions;
1206
+ setExperimentalFlags((currentFlags) => ({ ...currentFlags, subagents: prefs.subagents }));
1207
+ setSettings((currentSettings) => ({
1208
+ ...currentSettings,
1209
+ provider: onboarding.provider,
1210
+ model: onboarding.model,
1211
+ allowWrite: permissions.allowWrite,
1212
+ allowShell: permissions.allowShell,
1213
+ thinkingMode: prefs.thinking,
1214
+ reasoningEffort: prefs.reasoning,
1215
+ subagents: prefs.subagents
1216
+ }));
1217
+ savePatchPilotEnvValues({
1218
+ PATCHPILOT_PROVIDER: onboarding.provider,
1219
+ PATCHPILOT_MODEL: onboarding.model,
1220
+ PATCHPILOT_ONBOARDING_COMPLETE: "1",
1221
+ ...preferencesEnvValues(prefs),
1222
+ ...(onboarding.provider === "ollama" ? { PATCHPILOT_OLLAMA_URL: activeHost?.host.url ?? settings.ollamaUrl } : {})
1223
+ });
1224
+ process.env.PATCHPILOT_ONBOARDING_COMPLETE = "1";
1225
+ appendLine({
1226
+ tone: "success",
1227
+ label: "onboarding",
1228
+ text: `ready: ${onboarding.provider} using ${onboarding.model}`,
1229
+ detail: `mode ${prefs.mode} · reasoning ${prefs.reasoning} · thinking ${prefs.thinking} · subagents ${prefs.subagents ? "on" : "off"}`
1230
+ });
1231
+ if (onboarding.provider === "openrouter" && isOpenRouterFreeModel(onboarding.model)) {
1232
+ appendLine({
1233
+ tone: "warning",
1234
+ label: "openrouter",
1235
+ text: "Free OpenRouter models are rate-limited.",
1236
+ detail: "OpenRouter documents 20 requests/minute for :free models, plus daily limits depending on account credits."
1237
+ });
1238
+ }
1239
+ closeOnboarding();
1240
+ return;
1241
+ }
1242
+ const visibleModels = selectableModels(onboardingInput, onboarding.models, formatModelLabel);
843
1243
  const selectedModel = visibleModels[onboardingIndex] ?? selectModelFromInput(value, visibleModels, onboardingIndex, {
844
1244
  allowManual: onboarding.provider !== "ollama" && onboarding.provider !== "gemini-wrapper"
845
1245
  });
@@ -850,39 +1250,65 @@ export function App(props) {
850
1250
  });
851
1251
  return;
852
1252
  }
853
- setTelemetry(null);
854
- setSettings((currentSettings) => ({
855
- ...currentSettings,
1253
+ setOnboarding({
1254
+ step: "preferences",
856
1255
  provider: onboarding.provider,
857
- model: selectedModel
858
- }));
859
- savePatchPilotEnvValues({
860
- PATCHPILOT_PROVIDER: onboarding.provider,
861
- PATCHPILOT_MODEL: selectedModel,
862
- PATCHPILOT_ONBOARDING_COMPLETE: "1",
863
- ...(onboarding.provider === "ollama" ? { PATCHPILOT_OLLAMA_URL: activeHost?.host.url ?? settings.ollamaUrl } : {})
864
- });
865
- appendLine({
866
- tone: "success",
867
- label: "onboarding",
868
- text: `ready: ${onboarding.provider} using ${selectedModel}`
1256
+ model: selectedModel,
1257
+ preferences: readOnboardingPreferences()
869
1258
  });
870
- if (onboarding.provider === "openrouter" && isOpenRouterFreeModel(selectedModel)) {
1259
+ setOnboardingInput("");
1260
+ setOnboardingIndex(preferenceRows.length);
1261
+ }, [activeHost?.host.url, appendLine, closeOnboarding, connectToHost, loadHostSuggestions, onboarding, onboardingBusyMessage, onboardingIndex, openModelSelection, settings.ollamaUrl]);
1262
+ const runTask = useCallback(async (task, overrides = {}) => {
1263
+ if (!task.trim() || isRunning) {
1264
+ return;
1265
+ }
1266
+ // Ultra-modes: power-mode keywords (ultramaxx / ultracheap / ultrafocus /
1267
+ // ultraloop) found anywhere in the prompt. Several may combine; an
1268
+ // incompatible pair blocks the send so a contradictory run never starts.
1269
+ const ultra = parseUltraModes(task);
1270
+ if (ultra.conflict) {
1271
+ appendLine({
1272
+ kind: "status",
1273
+ tone: "danger",
1274
+ label: "ultra",
1275
+ text: ultra.conflict,
1276
+ detail: "Remove one of the conflicting keywords, then send again."
1277
+ });
1278
+ return;
1279
+ }
1280
+ const ultramaxx = ultra.modes.includes("maxx");
1281
+ const ultracheap = ultra.modes.includes("cheap");
1282
+ const ultrafast = ultra.modes.includes("fast");
1283
+ const ultrafocus = ultra.modes.includes("focus");
1284
+ const ultraloop = ultra.modes.includes("loop");
1285
+ // ultracheap and ultrafast both run the lean pipeline (low reasoning,
1286
+ // fixed short thinking, no advisors, capped steps).
1287
+ const ultraLean = ultracheap || ultrafast;
1288
+ const effectiveTask = ultra.modes.length > 0 ? ultra.cleaned : task;
1289
+ if (ultra.modes.length > 0 && !effectiveTask) {
871
1290
  appendLine({
872
1291
  tone: "warning",
873
- label: "openrouter",
874
- text: "Free OpenRouter models are rate-limited.",
875
- detail: "OpenRouter documents 20 requests/minute for :free models, plus daily limits depending on account credits."
1292
+ label: "ultra",
1293
+ text: `${describeUltraModes(ultra.modes)} needs an actual task after the keyword.`
876
1294
  });
1295
+ return;
877
1296
  }
878
- closeOnboarding();
879
- }, [activeHost?.host.url, appendLine, closeOnboarding, connectToHost, loadHostSuggestions, onboarding, onboardingBusyMessage, onboardingIndex, openModelSelection, settings.ollamaUrl]);
880
- const runTask = useCallback(async (task, overrides = {}) => {
881
- if (!task.trim() || isRunning) {
1297
+ if (ultrafocus && !ultra.focusPath) {
1298
+ appendLine({
1299
+ tone: "warning",
1300
+ label: "ultra",
1301
+ text: "ultrafocus needs a path — write ultrafocus:src/file.ts or ultrafocus \"my dir\"."
1302
+ });
882
1303
  return;
883
1304
  }
1305
+ const runStartedAt = Date.now();
1306
+ softStopRequestedRef.current = false;
1307
+ lastEscapeStopAtRef.current = 0;
884
1308
  setInput("");
885
1309
  setTranscriptScrollOffset(0);
1310
+ setTodos([]);
1311
+ setUltramaxxRun(ultramaxx);
886
1312
  setIsRunning(true);
887
1313
  appendLine({
888
1314
  kind: "user",
@@ -890,22 +1316,98 @@ export function App(props) {
890
1316
  label: "you",
891
1317
  text: task
892
1318
  });
1319
+ if (ultra.modes.length > 0) {
1320
+ const engagedDetail = [];
1321
+ if (ultramaxx) {
1322
+ engagedDetail.push("ultramaxx: xhigh reasoning, expanded step budget, advisors on.");
1323
+ }
1324
+ if (ultracheap) {
1325
+ engagedDetail.push("ultracheap: low reasoning, terse output, advisors off.");
1326
+ }
1327
+ if (ultrafast) {
1328
+ engagedDetail.push("ultrafast: lowest-latency pipeline — low reasoning, fixed short thinking, advisors off.");
1329
+ }
1330
+ if (ultrafocus) {
1331
+ engagedDetail.push(`ultrafocus: the agent stays inside ${ultra.focusPath}.`);
1332
+ }
1333
+ if (ultraloop) {
1334
+ engagedDetail.push("ultraloop: expanded budget with explicit final self-check before finishing.");
1335
+ }
1336
+ appendLine({
1337
+ tone: "accent",
1338
+ label: "ultra",
1339
+ text: `✻ ${describeUltraModes(ultra.modes).toUpperCase()} engaged`,
1340
+ detail: engagedDetail.join("\n")
1341
+ });
1342
+ }
1343
+ let finalMessage = "";
1344
+ let turnAttachmentPaths = [];
893
1345
  try {
894
- const runnableSettings = await resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions);
1346
+ const runnableSettings = await resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions, (message) => {
1347
+ if (settings.provider === "gemini-wrapper" && isGeminiCookieError(message)) {
1348
+ setReauthPrompt({ task });
1349
+ setStatus("gemini cookies expired");
1350
+ setWorkState("waiting_approval");
1351
+ }
1352
+ });
895
1353
  if (!runnableSettings) {
896
1354
  return;
897
1355
  }
898
1356
  const abortController = new AbortController();
899
1357
  abortControllerRef.current = abortController;
900
1358
  const effectiveMode = overrides.mode ?? agentMode;
1359
+ // Carry earlier-turn context forward so a follow-up prompt still knows
1360
+ // what the user was doing and where. Advisory only — it never changes
1361
+ // the workspace root or restricts the agent.
1362
+ const sessionMemory = conversationTurnsRef.current.length > 0
1363
+ ? `Earlier in this PatchPilot session (most recent last), for continuity only — the request below still takes priority and is not restricted to these paths:\n${conversationTurnsRef.current.join("\n")}`
1364
+ : "";
1365
+ const artifactContext = formatSessionArtifactContext(artifactsRef.current);
1366
+ const persistedContext = await contextStoreRef.current
1367
+ .buildContextBlock({
1368
+ maxItems: 12,
1369
+ title: "Known session context"
1370
+ })
1371
+ .catch(() => "");
1372
+ // Ultra-mode run instructions — injected as advisory context so each
1373
+ // mode shapes the run without changing the workspace root.
1374
+ const ultraInstructions = [];
1375
+ if (ultrafocus && ultra.focusPath) {
1376
+ ultraInstructions.push(`ULTRAFOCUS is active. Restrict every read, edit, and command to \`${ultra.focusPath}\` and the files it directly depends on. Do not modify anything outside that path; if the task genuinely needs other files, stop and say so instead.`);
1377
+ }
1378
+ if (ultraloop) {
1379
+ ultraInstructions.push("ULTRALOOP is active. Do not finish until the user's actual goal is fully achieved and verified — not merely attempted. Before any final answer, restate the goal, list what is done, list any remaining gap, and keep working if something is still missing.");
1380
+ }
1381
+ if (ultracheap) {
1382
+ ultraInstructions.push("ULTRACHEAP is active. Keep output terse, avoid unnecessary tool calls, and take the most direct path to a correct result.");
1383
+ }
1384
+ if (ultrafast) {
1385
+ ultraInstructions.push("ULTRAFAST is active. Optimise for speed: minimal reasoning, the fewest tool calls that still get it right, no exploratory detours. Answer as directly as possible.");
1386
+ }
1387
+ const effectiveResumeContext = [resumeContext, sessionMemory, artifactContext, persistedContext, ultraInstructions.join("\n\n")]
1388
+ .filter(Boolean)
1389
+ .join("\n\n");
901
1390
  const taskRunner = new AgentRunner({
902
1391
  ...runnableSettings,
1392
+ maxSteps: ultraloop
1393
+ ? Math.max(runnableSettings.maxSteps, 60)
1394
+ : ultramaxx
1395
+ ? Math.max(runnableSettings.maxSteps, 40)
1396
+ : ultraLean
1397
+ ? Math.min(runnableSettings.maxSteps, 12)
1398
+ : runnableSettings.maxSteps,
1399
+ reasoningEffort: ultramaxx ? "xhigh" : ultraLean ? "low" : runnableSettings.reasoningEffort,
1400
+ thinkingMode: ultramaxx || ultraloop ? "adaptive" : ultraLean ? "fixed" : runnableSettings.thinkingMode,
1401
+ subagents: ultramaxx || ultraloop ? true : ultraLean ? false : runnableSettings.subagents,
1402
+ ultramaxx,
903
1403
  allowExternalFileAnalysis: experimentalFlags.fileAnalysis,
1404
+ allowShellMetacharacters: experimentalFlags.shellMetacharacters,
904
1405
  memoryEnabled: experimentalFlags.memory,
905
1406
  mode: effectiveMode,
906
1407
  signal: abortController.signal,
1408
+ shouldStopAfterStep: () => softStopRequestedRef.current,
907
1409
  sessionStore: sessionStoreRef.current,
908
- resumeContext,
1410
+ resumeContext: effectiveResumeContext,
909
1411
  approvalHandler: (request) => new Promise((resolve) => {
910
1412
  if (effectiveMode === "plan") {
911
1413
  appendLine({
@@ -921,7 +1423,13 @@ export function App(props) {
921
1423
  resolve("deny");
922
1424
  return;
923
1425
  }
924
- if (effectiveMode === "bypass" && ((request.permission === "write" && runnableSettings.allowWrite) || (request.permission === "shell" && runnableSettings.allowShell))) {
1426
+ if (request.bypassable !== false &&
1427
+ shouldBypassApproval({
1428
+ mode: effectiveMode,
1429
+ permission: request.permission,
1430
+ permissions: runnableSettings,
1431
+ allowExternalFileAnalysis: experimentalFlags.fileAnalysis
1432
+ })) {
925
1433
  resolve("allow_session");
926
1434
  return;
927
1435
  }
@@ -942,7 +1450,15 @@ export function App(props) {
942
1450
  approvalResolverRef.current = resolve;
943
1451
  })
944
1452
  });
945
- for await (const event of taskRunner.run(task)) {
1453
+ // Hand any documents the user attached this turn to the agent so it
1454
+ // can read/analyse them with its file tools.
1455
+ const pendingAttachments = pendingAttachmentsRef.current;
1456
+ turnAttachmentPaths = pendingAttachments;
1457
+ pendingAttachmentsRef.current = [];
1458
+ const taskWithAttachments = pendingAttachments.length > 0
1459
+ ? `${effectiveTask}\n\n[Attached documents for this task — read or analyse them as needed with inspect_document. Paths are JSON-escaped and may contain spaces:\n${formatAttachedDocuments(pendingAttachments)}\n]`
1460
+ : effectiveTask;
1461
+ for await (const event of taskRunner.run(taskWithAttachments)) {
946
1462
  setWorkState(event.workState);
947
1463
  if (event.type === "metrics") {
948
1464
  if (runnableSettings.provider === "ollama") {
@@ -955,31 +1471,154 @@ export function App(props) {
955
1471
  if (event.type === "subagent") {
956
1472
  setTelemetry(event.metrics);
957
1473
  setSessionTelemetry((currentSession) => addTelemetryToSession(currentSession, event.metrics));
1474
+ setToolTelemetry((currentTools) => addToolTelemetry(currentTools, "subagent", true));
958
1475
  setAdvisorNotes((currentNotes) => upsertAdvisorNote(currentNotes, {
959
1476
  role: event.role,
960
1477
  message: event.message
961
1478
  }));
962
1479
  }
1480
+ if (event.type === "todo") {
1481
+ setTodos(event.items);
1482
+ setStatus(event.summary);
1483
+ setToolTelemetry((currentTools) => addToolTelemetry(currentTools, "update_todo", true));
1484
+ continue;
1485
+ }
1486
+ if (event.type === "final") {
1487
+ finalMessage = event.message;
1488
+ }
1489
+ // Best-effort: list documents PatchPilot wrote in the artifacts bar.
1490
+ if (event.type === "tool" && event.ok && /write|create|pdf|save|export/i.test(event.name)) {
1491
+ const created = /((?:\/|~|\.\/|[\w.-]+\/)[\w./-]+\.(?:pdf|docx?|md|txt|jsonl?|csv|ya?ml|toml|xml|html?|css|tsx?|jsx?|mjs|cjs|py|sh|zsh|bash|sql|log|diff|patch|png|jpe?g|gif|webp|bmp|heic|svg))/i.exec(`${event.summary ?? ""} ${event.preview ?? ""}`);
1492
+ if (created?.[1]) {
1493
+ registerCreatedArtifact(created[1]);
1494
+ }
1495
+ }
1496
+ if (event.type === "tool") {
1497
+ setToolTelemetry((currentTools) => addToolTelemetry(currentTools, event.name, event.ok));
1498
+ }
1499
+ if (event.type === "approval") {
1500
+ setToolTelemetry((currentTools) => addApprovalTelemetry(currentTools, event.decision));
1501
+ }
1502
+ // Expired Gemini-Wrapper cookies arrive as an error event (the run
1503
+ // does not throw) — offer the y/n re-auth prompt here too.
1504
+ if (event.type === "error" &&
1505
+ settings.provider === "gemini-wrapper" &&
1506
+ isGeminiCookieError(event.message)) {
1507
+ setReauthPrompt({ task });
1508
+ setWorkState("waiting_approval");
1509
+ }
963
1510
  setStatus(eventToStatus(event));
964
1511
  appendLine(eventToLine(event));
965
1512
  }
966
1513
  }
967
1514
  catch (error) {
1515
+ if (abortControllerRef.current?.signal.aborted) {
1516
+ appendLine({
1517
+ kind: "status",
1518
+ tone: "warning",
1519
+ label: "stop",
1520
+ text: "Stopped by user.",
1521
+ workState: "done"
1522
+ });
1523
+ return;
1524
+ }
1525
+ const message = error instanceof Error ? error.message : String(error);
968
1526
  appendLine({
969
1527
  kind: "error",
970
1528
  tone: "danger",
971
1529
  label: "error",
972
- text: error instanceof Error ? error.message : String(error),
1530
+ text: message,
973
1531
  workState: "error"
974
1532
  });
1533
+ // Expired Gemini-Wrapper cookies: offer a one-key re-auth + retry
1534
+ // instead of making the user restart and re-type the prompt.
1535
+ if (settings.provider === "gemini-wrapper" && isGeminiCookieError(message)) {
1536
+ setReauthPrompt({ task });
1537
+ setStatus("gemini cookies expired");
1538
+ setWorkState("waiting_approval");
1539
+ }
975
1540
  }
976
1541
  finally {
977
1542
  abortControllerRef.current = null;
1543
+ setIsRunning(false);
1544
+ setUltramaxxRun(false);
1545
+ // Record a short digest of this turn for cross-run continuity.
1546
+ conversationTurnsRef.current = [
1547
+ ...conversationTurnsRef.current,
1548
+ `- Asked: "${task.replace(/\s+/g, " ").trim().slice(0, 220)}"${turnAttachmentPaths.length > 0 ? ` attachments: ${turnAttachmentPaths.map(formatAttachmentDigestPath).join(", ")}` : ""}${finalMessage ? ` → outcome: ${finalMessage.replace(/\s+/g, " ").trim().slice(0, 220)}` : ""}`
1549
+ ].slice(-6);
1550
+ void contextStoreRef.current.append({
1551
+ kind: "turn",
1552
+ source: "user",
1553
+ label: task.replace(/\s+/g, " ").trim().slice(0, 120) || "PatchPilot turn",
1554
+ text: [
1555
+ `Asked: ${task.replace(/\s+/g, " ").trim()}`,
1556
+ turnAttachmentPaths.length > 0 ? `Attachments: ${turnAttachmentPaths.join(", ")}` : "",
1557
+ finalMessage ? `Outcome: ${finalMessage.replace(/\s+/g, " ").trim().slice(0, 500)}` : ""
1558
+ ]
1559
+ .filter(Boolean)
1560
+ .join("\n"),
1561
+ priority: turnAttachmentPaths.length > 0 ? 65 : 35
1562
+ }).catch(() => undefined);
1563
+ appendLine({
1564
+ kind: "status",
1565
+ tone: "muted",
1566
+ label: "done",
1567
+ text: `✻ ${formatCompletionSummary(Date.now() - runStartedAt, runStartedAt)}`,
1568
+ workState: "done"
1569
+ });
1570
+ }
1571
+ }, [agentMode, appendLine, experimentalFlags, isRunning, modelOptions, registerCreatedArtifact, resumeContext, settings]);
1572
+ const resolveReauthPrompt = useCallback(async (accept) => {
1573
+ const pending = reauthPrompt;
1574
+ if (!pending || reauthBusy) {
1575
+ return;
1576
+ }
1577
+ if (!accept) {
1578
+ setReauthPrompt(null);
978
1579
  setStatus("idle");
979
1580
  setWorkState("idle");
980
- setIsRunning(false);
1581
+ appendLine({
1582
+ tone: "warning",
1583
+ label: "gemini",
1584
+ text: "Cookie refresh declined.",
1585
+ detail: "Run /onboarding to re-authenticate Gemini-Wrapper when you are ready."
1586
+ });
1587
+ return;
1588
+ }
1589
+ // Keep the panel on screen and show the busy animation while the
1590
+ // browser cookies are imported.
1591
+ setReauthBusy(true);
1592
+ try {
1593
+ const result = await importGeminiWrapperBrowserCookies();
1594
+ process.env.PATCHPILOT_GEMINI_WRAPPER_MODE = "python";
1595
+ process.env.PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON = result.cookiesPath;
1596
+ savePatchPilotEnvValues({
1597
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "python",
1598
+ PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON: result.cookiesPath
1599
+ });
1600
+ setReauthBusy(false);
1601
+ setReauthPrompt(null);
1602
+ appendLine({
1603
+ tone: "success",
1604
+ label: "gemini",
1605
+ text: `Imported ${result.cookieCount} fresh cookies from ${result.source}. Retrying your task...`
1606
+ });
1607
+ await runTask(pending.task);
981
1608
  }
982
- }, [agentMode, appendLine, experimentalFlags, isRunning, modelOptions, resumeContext, settings]);
1609
+ catch (error) {
1610
+ setReauthBusy(false);
1611
+ setReauthPrompt(null);
1612
+ setStatus("idle");
1613
+ setWorkState("idle");
1614
+ appendLine({
1615
+ tone: "danger",
1616
+ label: "gemini",
1617
+ text: error instanceof Error ? error.message : String(error),
1618
+ detail: "Cookie refresh failed. Sign in to Gemini in your browser, then retry the prompt."
1619
+ });
1620
+ }
1621
+ }, [appendLine, reauthBusy, reauthPrompt, runTask]);
983
1622
  const handleSlashCommand = useCallback(async (rawCommand) => {
984
1623
  const [commandName = "", ...args] = rawCommand.slice(1).trim().split(/\s+/);
985
1624
  const command = commandName.toLowerCase();
@@ -1022,7 +1661,7 @@ export function App(props) {
1022
1661
  appendLine({
1023
1662
  tone: "accent",
1024
1663
  label: "permissions",
1025
- text: `mode ${agentMode} | write ${modePermissionLabel(agentMode, "write")} | shell ${modePermissionLabel(agentMode, "shell")} | subagents ${settings.subagents ? "on" : "off"}`,
1664
+ text: `mode ${agentMode} | write ${modePermissionLabel(agentMode, "write", settings)} | shell ${modePermissionLabel(agentMode, "shell", settings)} | subagents ${settings.subagents ? "on" : "off"}`,
1026
1665
  detail: modeDescription(agentMode)
1027
1666
  });
1028
1667
  return;
@@ -1062,7 +1701,7 @@ export function App(props) {
1062
1701
  }
1063
1702
  case "onboarding":
1064
1703
  setOnboarding({
1065
- step: "entry"
1704
+ step: "welcome"
1066
1705
  });
1067
1706
  setOnboardingIndex(0);
1068
1707
  setOnboardingInput("");
@@ -1135,6 +1774,15 @@ export function App(props) {
1135
1774
  case "write":
1136
1775
  case "apply": {
1137
1776
  const writeEnabled = readToggle(args[0], !settings.allowWrite);
1777
+ if (writeEnabled) {
1778
+ requestBypassMode();
1779
+ appendLine({
1780
+ tone: "warning",
1781
+ label: "write",
1782
+ text: "write bypass needs trusted-workspace confirmation"
1783
+ });
1784
+ return;
1785
+ }
1138
1786
  setExplicitPermission("write", writeEnabled);
1139
1787
  appendLine({
1140
1788
  tone: "success",
@@ -1145,6 +1793,15 @@ export function App(props) {
1145
1793
  }
1146
1794
  case "shell": {
1147
1795
  const shellEnabled = readToggle(args[0], !settings.allowShell);
1796
+ if (shellEnabled) {
1797
+ requestBypassMode();
1798
+ appendLine({
1799
+ tone: "warning",
1800
+ label: "shell",
1801
+ text: "shell bypass needs trusted-workspace confirmation"
1802
+ });
1803
+ return;
1804
+ }
1148
1805
  setExplicitPermission("shell", shellEnabled);
1149
1806
  appendLine({
1150
1807
  tone: "success",
@@ -1178,14 +1835,14 @@ export function App(props) {
1178
1835
  return;
1179
1836
  }
1180
1837
  const nextModel = selectModelFromInput(requestedModel, models, undefined, {
1181
- allowManual: settings.provider !== "ollama" && settings.provider !== "gemini-wrapper"
1838
+ allowManual: settings.provider !== "ollama"
1182
1839
  });
1183
1840
  if (!nextModel) {
1184
1841
  appendLine({
1185
1842
  tone: "warning",
1186
1843
  label: "model",
1187
1844
  text: `No unique model match for "${requestedModel}".`,
1188
- detail: formatModelOptions(selectableModels(requestedModel, models).slice(0, 12), settings.model)
1845
+ detail: formatModelOptions(selectableModels(requestedModel, models, formatModelLabel).slice(0, 12), settings.model)
1189
1846
  });
1190
1847
  return;
1191
1848
  }
@@ -1203,7 +1860,7 @@ export function App(props) {
1203
1860
  return;
1204
1861
  }
1205
1862
  const nextModel = selectModelFromInput(requestedModel, installedModels, undefined, {
1206
- allowManual: settings.provider !== "ollama" && settings.provider !== "gemini-wrapper"
1863
+ allowManual: settings.provider !== "ollama"
1207
1864
  });
1208
1865
  if (!nextModel) {
1209
1866
  appendLine({
@@ -1232,7 +1889,13 @@ export function App(props) {
1232
1889
  ? "Pull a model on the selected host first."
1233
1890
  : settings.provider === "gemini"
1234
1891
  ? "Check GEMINI_API_KEY in PatchPilot config."
1235
- : "Run codex login first."
1892
+ : settings.provider === "gemini-wrapper"
1893
+ ? "Check gemini_webapi install and PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON in PatchPilot config."
1894
+ : settings.provider === "openrouter"
1895
+ ? "Check OPENROUTER_API_KEY in PatchPilot config."
1896
+ : settings.provider === "nvidia"
1897
+ ? "Check NVIDIA_API_KEY in PatchPilot config."
1898
+ : "Run codex login first."
1236
1899
  });
1237
1900
  return;
1238
1901
  }
@@ -1256,13 +1919,59 @@ export function App(props) {
1256
1919
  }
1257
1920
  case "status":
1258
1921
  appendLine({
1922
+ kind: "status",
1259
1923
  tone: "accent",
1260
1924
  label: "status",
1261
- text: settings.provider === "ollama"
1262
- ? `provider ollama | model ${settings.model} | host ${activeHost?.host.deviceName ?? settings.ollamaUrl} | route ${activeHost?.host.url ?? settings.ollamaUrl} | compute ${describeComputeTarget(settings.ollamaUrl).kind} | tools local | agents ${settings.subagents ? "on" : "off"} | mode ${agentMode} | write ${modePermissionLabel(agentMode, "write")} | shell ${modePermissionLabel(agentMode, "shell")} | draft ${draftTokens} tok | last ${formatTokens(telemetry)} | session ${formatSessionTokens(sessionTelemetry)} | cost ${formatCost(sessionTelemetry.estimatedCostUsd)}`
1263
- : `provider ${settings.provider} | model ${settings.model} | host ${settings.provider} api | compute cloud | agents ${settings.subagents ? "on" : "off"} | think ${settings.thinkingMode} | reasoning ${formatReasoningSupport(settings.provider, settings.model, settings.reasoningEffort === "adaptive" ? undefined : settings.reasoningEffort)} | mode ${agentMode} | write ${modePermissionLabel(agentMode, "write")} | shell ${modePermissionLabel(agentMode, "shell")} | draft ${draftTokens} tok | last ${formatTokens(telemetry)} | session ${formatSessionTokens(sessionTelemetry)} | cost ${formatCost(sessionTelemetry.estimatedCostUsd)}`
1925
+ text: `mode ${agentMode} · write ${modePermissionLabel(agentMode, "write", settings)} · shell ${modePermissionLabel(agentMode, "shell", settings)} · ${settings.provider}/${settings.model} · subagents ${settings.subagents ? "on" : "off"}`,
1926
+ detail: formatStatusDock({
1927
+ provider: settings.provider,
1928
+ model: settings.model,
1929
+ agentMode,
1930
+ subagents: settings.subagents,
1931
+ thinkingMode: settings.thinkingMode,
1932
+ reasoningEffort: settings.reasoningEffort,
1933
+ workspace: settings.workspace,
1934
+ ollamaUrl: settings.ollamaUrl,
1935
+ sessionId: sessionStoreRef.current.sessionId,
1936
+ activeHost,
1937
+ advisorNotes,
1938
+ toolTelemetry,
1939
+ sessionTelemetry,
1940
+ telemetry,
1941
+ draftTokens
1942
+ })
1943
+ });
1944
+ return;
1945
+ case "usage":
1946
+ appendLine({
1947
+ tone: "accent",
1948
+ label: "usage",
1949
+ text: formatUsageSummary({
1950
+ provider: settings.provider,
1951
+ model: settings.model,
1952
+ telemetry,
1953
+ sessionTelemetry,
1954
+ toolTelemetry
1955
+ }),
1956
+ detail: formatUsageDetail({
1957
+ provider: settings.provider,
1958
+ model: settings.model,
1959
+ sessionTelemetry,
1960
+ toolTelemetry
1961
+ })
1264
1962
  });
1265
1963
  return;
1964
+ case "context":
1965
+ case "ctx":
1966
+ case "compact":
1967
+ case "compress":
1968
+ appendLine(await runContextSlashCommand({
1969
+ workspace: settings.workspace,
1970
+ sessionId: sessionStoreRef.current.sessionId,
1971
+ command: command === "compact" || command === "compress" ? "compact" : "context",
1972
+ args
1973
+ }));
1974
+ return;
1266
1975
  case "sessions": {
1267
1976
  const sessions = await listWorkspaceSessions(settings.workspace);
1268
1977
  appendLine({
@@ -1286,6 +1995,11 @@ export function App(props) {
1286
1995
  workspace: settings.workspace,
1287
1996
  sessionId: selectedSession.sessionId
1288
1997
  });
1998
+ contextStoreRef.current = new ContextStore({
1999
+ workspace: settings.workspace,
2000
+ sessionId: selectedSession.sessionId
2001
+ });
2002
+ await contextStoreRef.current.bootstrapFromSession(await sessionStoreRef.current.loadEvents());
1289
2003
  await sessionStoreRef.current.append({
1290
2004
  type: "session.resumed",
1291
2005
  sessionId: selectedSession.sessionId,
@@ -1465,6 +2179,7 @@ export function App(props) {
1465
2179
  setAdvisorNotes([]);
1466
2180
  setTelemetry(null);
1467
2181
  setSessionTelemetry(emptySessionTelemetry());
2182
+ setToolTelemetry(emptyToolTelemetry());
1468
2183
  }
1469
2184
  appendLine({
1470
2185
  tone: "success",
@@ -1482,33 +2197,60 @@ export function App(props) {
1482
2197
  setInput("");
1483
2198
  return;
1484
2199
  }
2200
+ const normalizedFlag = normalizeExperimentalFlag(requestedFlag);
2201
+ if (!normalizedFlag) {
2202
+ appendLine({
2203
+ tone: "warning",
2204
+ label: "experimental",
2205
+ text: `unknown flag ${requestedFlag}`,
2206
+ detail: "Use file-analysis, memory, subagents, or shell-metacharacters."
2207
+ });
2208
+ return;
2209
+ }
1485
2210
  const enabled = readToggle(requestedValue, true);
1486
- if (requestedFlag === "subagents" || requestedFlag === "agents") {
2211
+ if (normalizedFlag === "subagents") {
1487
2212
  setSettings((currentSettings) => ({
1488
2213
  ...currentSettings,
1489
2214
  subagents: enabled
1490
2215
  }));
1491
2216
  }
1492
2217
  savePatchPilotEnvValues({
1493
- [`PATCHPILOT_EXPERIMENTAL_${requestedFlag.replace(/-/g, "_").toUpperCase()}`]: enabled ? "1" : "0"
2218
+ [experimentalFlagEnvName(normalizedFlag)]: enabled ? "1" : "0"
1494
2219
  });
1495
2220
  setExperimentalFlags((currentFlags) => ({
1496
2221
  ...currentFlags,
1497
- ...(requestedFlag === "file-analysis"
2222
+ ...(normalizedFlag === "fileAnalysis"
1498
2223
  ? { fileAnalysis: enabled }
1499
- : requestedFlag === "memory"
2224
+ : normalizedFlag === "memory"
1500
2225
  ? { memory: enabled }
1501
- : requestedFlag === "subagents" || requestedFlag === "agents"
2226
+ : normalizedFlag === "subagents"
1502
2227
  ? { subagents: enabled }
1503
- : {})
2228
+ : { shellMetacharacters: enabled })
1504
2229
  }));
1505
2230
  appendLine({
1506
2231
  tone: "success",
1507
2232
  label: "experimental",
1508
- text: `${requestedFlag} ${enabled ? "enabled" : "disabled"}`
2233
+ text: `${experimentalFlagCommandName(normalizedFlag)} ${enabled ? "enabled" : "disabled"}`
1509
2234
  });
1510
2235
  return;
1511
2236
  }
2237
+ case "theme": {
2238
+ const requested = args[0]?.toLowerCase();
2239
+ if (requested === "new" || requested === "legacy") {
2240
+ setUiTheme(requested);
2241
+ savePatchPilotEnvValues({ PATCHPILOT_UI_THEME: requested });
2242
+ appendLine({
2243
+ tone: "success",
2244
+ label: "theme",
2245
+ text: `switched to the ${requested} UI`
2246
+ });
2247
+ return;
2248
+ }
2249
+ setThemePickerOpen(true);
2250
+ setThemePickerIndex(themeOptions.findIndex((option) => option.value === uiTheme));
2251
+ setInput("");
2252
+ return;
2253
+ }
1512
2254
  case "init": {
1513
2255
  await ensurePatchPilotGitignore(settings.workspace);
1514
2256
  appendLine({
@@ -1525,11 +2267,17 @@ export function App(props) {
1525
2267
  case "clear":
1526
2268
  setLines([]);
1527
2269
  setAdvisorNotes([]);
2270
+ setTodos([]);
1528
2271
  setTelemetry(null);
1529
2272
  setResumeContext("");
1530
2273
  setSessionTelemetry(emptySessionTelemetry());
2274
+ setToolTelemetry(emptyToolTelemetry());
1531
2275
  setTranscriptScrollOffset(0);
1532
2276
  setSessionScrollOffset(0);
2277
+ conversationTurnsRef.current = [];
2278
+ artifactsRef.current = [];
2279
+ pendingAttachmentsRef.current = [];
2280
+ setArtifacts([]);
1533
2281
  return;
1534
2282
  case "new":
1535
2283
  if (isRunning) {
@@ -1544,11 +2292,17 @@ export function App(props) {
1544
2292
  sessionStoreRef.current = new SessionStore({
1545
2293
  workspace: settings.workspace
1546
2294
  });
2295
+ contextStoreRef.current = new ContextStore({
2296
+ workspace: settings.workspace,
2297
+ sessionId: sessionStoreRef.current.sessionId
2298
+ });
1547
2299
  await sessionStoreRef.current.create();
1548
2300
  setLines([]);
1549
2301
  setAdvisorNotes([]);
2302
+ setTodos([]);
1550
2303
  setTelemetry(null);
1551
2304
  setSessionTelemetry(emptySessionTelemetry());
2305
+ setToolTelemetry(emptyToolTelemetry());
1552
2306
  setPendingApproval(null);
1553
2307
  approvalResolverRef.current = null;
1554
2308
  setBypassConfirmation(false);
@@ -1557,11 +2311,12 @@ export function App(props) {
1557
2311
  setSessionScrollOffset(0);
1558
2312
  setStatus("idle");
1559
2313
  setWorkState("idle");
1560
- appendLine({
1561
- tone: "success",
1562
- label: "new",
1563
- text: `started session ${sessionStoreRef.current.sessionId}`
1564
- });
2314
+ conversationTurnsRef.current = [];
2315
+ artifactsRef.current = [];
2316
+ pendingAttachmentsRef.current = [];
2317
+ setArtifacts([]);
2318
+ // Leave the transcript empty so the startup banner shows again,
2319
+ // exactly like a fresh launch.
1565
2320
  return;
1566
2321
  case "exit":
1567
2322
  case "quit":
@@ -1631,10 +2386,39 @@ export function App(props) {
1631
2386
  useEffect(() => {
1632
2387
  void sessionStoreRef.current.create();
1633
2388
  }, []);
2389
+ useEffect(() => {
2390
+ if (didCheckForUpdates.current || process.env.PATCHPILOT_UPDATE_CHECK === "0") {
2391
+ return;
2392
+ }
2393
+ didCheckForUpdates.current = true;
2394
+ const controller = new AbortController();
2395
+ const timer = setTimeout(() => controller.abort(), 5000);
2396
+ setStatus("checking for updates");
2397
+ void checkForPatchPilotUpdate(props.packageVersion ?? "0.0.0", controller.signal)
2398
+ .then((result) => {
2399
+ if (result.available) {
2400
+ setUpdatePrompt(result);
2401
+ setStatus(`update available ${result.currentVersion} -> ${result.latestVersion}`);
2402
+ }
2403
+ else {
2404
+ setStatus((current) => (current === "checking for updates" ? "idle" : current));
2405
+ }
2406
+ })
2407
+ .catch(() => {
2408
+ setStatus((current) => (current === "checking for updates" ? "idle" : current));
2409
+ })
2410
+ .finally(() => {
2411
+ clearTimeout(timer);
2412
+ });
2413
+ return () => {
2414
+ clearTimeout(timer);
2415
+ controller.abort();
2416
+ };
2417
+ }, [props.packageVersion]);
1634
2418
  useEffect(() => {
1635
2419
  runtimeStateRef.current.isRunning = isRunning;
1636
- runtimeStateRef.current.hasPendingApproval = Boolean(pendingApproval || bypassConfirmation);
1637
- }, [bypassConfirmation, isRunning, pendingApproval]);
2420
+ runtimeStateRef.current.hasPendingApproval = Boolean(pendingApproval || bypassConfirmation || updatePrompt || updateBusy);
2421
+ }, [bypassConfirmation, isRunning, pendingApproval, updateBusy, updatePrompt]);
1638
2422
  useEffect(() => {
1639
2423
  if (!props.initialTask || didRunInitialTask.current || onboarding || process.env.PATCHPILOT_ONBOARDING_COMPLETE !== "1") {
1640
2424
  return;
@@ -1651,7 +2435,7 @@ export function App(props) {
1651
2435
  }
1652
2436
  didOpenDefaultOnboarding.current = true;
1653
2437
  setOnboarding({
1654
- step: "entry"
2438
+ step: "welcome"
1655
2439
  });
1656
2440
  setOnboardingIndex(0);
1657
2441
  setOnboardingInput("");
@@ -1723,6 +2507,35 @@ export function App(props) {
1723
2507
  }
1724
2508
  }, [hostOptions.length, input, isLoadingHosts, isLoadingModels, isRunning, loadHostSuggestions, loadProviderModels, modelOptions.length, onboarding, settings.provider]);
1725
2509
  useInput((inputValue, key) => {
2510
+ if (themePickerOpen) {
2511
+ if (key.upArrow) {
2512
+ setThemePickerIndex((currentIndex) => (currentIndex - 1 + themeOptions.length) % themeOptions.length);
2513
+ return;
2514
+ }
2515
+ if (key.downArrow) {
2516
+ setThemePickerIndex((currentIndex) => (currentIndex + 1) % themeOptions.length);
2517
+ return;
2518
+ }
2519
+ if (key.escape || key.leftArrow) {
2520
+ setThemePickerOpen(false);
2521
+ setInput("");
2522
+ return;
2523
+ }
2524
+ if (key.return) {
2525
+ const chosen = themeOptions[themePickerIndex]?.value ?? "new";
2526
+ setUiTheme(chosen);
2527
+ savePatchPilotEnvValues({ PATCHPILOT_UI_THEME: chosen });
2528
+ setThemePickerOpen(false);
2529
+ setInput("");
2530
+ appendLine({
2531
+ tone: "success",
2532
+ label: "theme",
2533
+ text: `switched to the ${chosen} UI`
2534
+ });
2535
+ return;
2536
+ }
2537
+ return;
2538
+ }
1726
2539
  if (experimentalOpen) {
1727
2540
  if (key.upArrow) {
1728
2541
  setExperimentalIndex((currentIndex) => (currentIndex - 1 + experimentalFlagCount()) % experimentalFlagCount());
@@ -1748,7 +2561,8 @@ export function App(props) {
1748
2561
  savePatchPilotEnvValues({
1749
2562
  PATCHPILOT_EXPERIMENTAL_FILE_ANALYSIS: nextFlags.fileAnalysis ? "1" : "0",
1750
2563
  PATCHPILOT_EXPERIMENTAL_MEMORY: nextFlags.memory ? "1" : "0",
1751
- PATCHPILOT_EXPERIMENTAL_SUBAGENTS: nextFlags.subagents ? "1" : "0"
2564
+ PATCHPILOT_EXPERIMENTAL_SUBAGENTS: nextFlags.subagents ? "1" : "0",
2565
+ PATCHPILOT_EXPERIMENTAL_SHELL_METACHARACTERS: nextFlags.shellMetacharacters ? "1" : "0"
1752
2566
  });
1753
2567
  return nextFlags;
1754
2568
  });
@@ -1763,8 +2577,11 @@ export function App(props) {
1763
2577
  }
1764
2578
  if (bypassConfirmation) {
1765
2579
  const normalizedInput = inputValue.toLowerCase();
2580
+ // While the bypass confirmation is pending, tab continues the mode
2581
+ // cycle straight back to plan — no need to confirm bypass first.
1766
2582
  if (key.tab) {
1767
- cancelBypassMode();
2583
+ setInput("");
2584
+ applyMode("plan");
1768
2585
  return;
1769
2586
  }
1770
2587
  if (normalizedInput === "y") {
@@ -1776,6 +2593,36 @@ export function App(props) {
1776
2593
  return;
1777
2594
  }
1778
2595
  }
2596
+ if (reauthBusy) {
2597
+ return;
2598
+ }
2599
+ if (updateBusy) {
2600
+ return;
2601
+ }
2602
+ if (updatePrompt) {
2603
+ const normalizedInput = inputValue.toLowerCase();
2604
+ if (normalizedInput === "y") {
2605
+ void resolveUpdatePrompt(true);
2606
+ return;
2607
+ }
2608
+ if (normalizedInput === "n" || key.escape) {
2609
+ void resolveUpdatePrompt(false);
2610
+ return;
2611
+ }
2612
+ return;
2613
+ }
2614
+ if (reauthPrompt) {
2615
+ const normalizedInput = inputValue.toLowerCase();
2616
+ if (normalizedInput === "y") {
2617
+ void resolveReauthPrompt(true);
2618
+ return;
2619
+ }
2620
+ if (normalizedInput === "n" || key.escape) {
2621
+ void resolveReauthPrompt(false);
2622
+ return;
2623
+ }
2624
+ return;
2625
+ }
1779
2626
  if (pendingApproval) {
1780
2627
  const normalizedInput = inputValue.toLowerCase();
1781
2628
  if (normalizedInput === "y") {
@@ -1792,25 +2639,77 @@ export function App(props) {
1792
2639
  }
1793
2640
  }
1794
2641
  if (isRunning && key.escape) {
1795
- abortControllerRef.current?.abort();
2642
+ const now = Date.now();
2643
+ const isDoubleEscape = now - lastEscapeStopAtRef.current <= 700;
2644
+ lastEscapeStopAtRef.current = now;
2645
+ if (isDoubleEscape) {
2646
+ abortControllerRef.current?.abort();
2647
+ appendLine({
2648
+ kind: "status",
2649
+ tone: "warning",
2650
+ label: "stop",
2651
+ text: "Force stopping current task now..."
2652
+ });
2653
+ setStatus("force stopping");
2654
+ return;
2655
+ }
2656
+ softStopRequestedRef.current = true;
1796
2657
  appendLine({
1797
2658
  kind: "status",
1798
2659
  tone: "warning",
1799
2660
  label: "stop",
1800
- text: "Stopping current task..."
2661
+ text: "Will stop after the current step. Press esc again quickly to force stop now."
1801
2662
  });
1802
- setStatus("stopping");
2663
+ setStatus("stopping after current step");
2664
+ return;
2665
+ }
2666
+ // Ctrl+V — paste an image from the OS clipboard as an attachment. Bound to
2667
+ // Ctrl+V on every platform because terminals capture ⌘V / the native paste
2668
+ // shortcut for their own text paste.
2669
+ if (key.ctrl && (inputValue === "v" || inputValue === "V") && !onboarding && !isRunning) {
2670
+ void handleClipboardImagePaste();
1803
2671
  return;
1804
2672
  }
1805
2673
  if (onboarding) {
1806
- if (key.escape || key.leftArrow) {
2674
+ if (key.escape) {
1807
2675
  goBackOnboarding();
1808
2676
  return;
1809
2677
  }
1810
2678
  if (onboardingBusyMessage) {
1811
2679
  return;
1812
2680
  }
1813
- const optionCount = onboarding.step === "model" ? selectableModels(onboardingInput, onboarding.models).length : getOnboardingOptionCount(onboarding);
2681
+ // Preferences step: left/right cycle the selected row's value in place;
2682
+ // left only goes back when no row is highlighted (the confirm row).
2683
+ if (onboarding.step === "preferences") {
2684
+ const confirmIndex = preferenceRows.length;
2685
+ if ((key.leftArrow || key.rightArrow) && onboardingIndex < confirmIndex) {
2686
+ const row = preferenceRows[onboardingIndex];
2687
+ if (row) {
2688
+ setOnboarding((current) => current && current.step === "preferences"
2689
+ ? { ...current, preferences: cyclePreference(current.preferences, row.key, key.leftArrow ? -1 : 1) }
2690
+ : current);
2691
+ }
2692
+ return;
2693
+ }
2694
+ if (key.leftArrow) {
2695
+ goBackOnboarding();
2696
+ return;
2697
+ }
2698
+ }
2699
+ else if (key.leftArrow) {
2700
+ goBackOnboarding();
2701
+ return;
2702
+ }
2703
+ if (onboarding.step === "disclaimer") {
2704
+ if (inputValue.toLowerCase() === "y") {
2705
+ void handleOnboardingSubmit("y");
2706
+ }
2707
+ else if (inputValue.toLowerCase() === "n") {
2708
+ goBackOnboarding();
2709
+ }
2710
+ return;
2711
+ }
2712
+ const optionCount = onboarding.step === "model" ? selectableModels(onboardingInput, onboarding.models, formatModelLabel).length : getOnboardingOptionCount(onboarding);
1814
2713
  if (optionCount > 0 && key.upArrow) {
1815
2714
  setOnboardingIndex((currentIndex) => (currentIndex - 1 + optionCount) % optionCount);
1816
2715
  return;
@@ -1882,9 +2781,6 @@ export function App(props) {
1882
2781
  toggleMode();
1883
2782
  return;
1884
2783
  }
1885
- if (!isRunning && input.length === 0 && inputValue === "q") {
1886
- void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(exit);
1887
- }
1888
2784
  });
1889
2785
  useEffect(() => {
1890
2786
  const gracefulStopOrExit = () => {
@@ -1953,21 +2849,37 @@ export function App(props) {
1953
2849
  clearInterval(timer);
1954
2850
  };
1955
2851
  }, []);
1956
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: rootHeight, overflowY: "hidden", children: [_jsx(Header, { model: settings.model, provider: settings.provider, workspace: settings.workspace, status: status, workState: workState, allowWrite: settings.allowWrite, allowShell: settings.allowShell, agentMode: agentMode, subagents: settings.subagents, thinkingMode: settings.thinkingMode, reasoningEffort: settings.reasoningEffort, ollamaUrl: settings.ollamaUrl, telemetry: telemetry, sessionTelemetry: sessionTelemetry, draftTokens: draftTokens, systemStats: systemStats, gpuStats: gpuStats, activeHost: activeHost }), experimentalOpen ? (_jsx(ExperimentalPanel, { flags: experimentalFlags, selectedIndex: experimentalIndex, height: panelHeight })) : onboarding ? (_jsx(OnboardingPanel, { state: onboarding, height: panelHeight, selectedIndex: onboardingIndex, input: onboardingInput, busyMessage: onboardingBusyMessage, notice: onboardingNotice, onInputChange: setOnboardingInput, onInputSubmit: (value) => void handleOnboardingSubmit(value) })) : (_jsxs(Box, { flexDirection: "row", height: panelHeight + approvalReservedHeight + composerReservedHeight + paletteReservedHeight + footerReservedHeight, overflowY: "hidden", children: [_jsx(Sidebar, { workspace: settings.workspace, model: settings.model, provider: settings.provider, ollamaUrl: settings.ollamaUrl, agentMode: agentMode, allowWrite: settings.allowWrite, allowShell: settings.allowShell, subagents: settings.subagents, workState: workState, sessionId: sessionStoreRef.current.sessionId, systemStats: systemStats, gpuStats: gpuStats, telemetry: telemetry, sessionTelemetry: sessionTelemetry, draftTokens: draftTokens, height: panelHeight, scrollOffset: sessionScrollOffset, advisors: advisorNotes, isActive: activeScrollPane === "session", activeHost: activeHost }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: panelHeight + approvalReservedHeight + composerReservedHeight + paletteReservedHeight + footerReservedHeight, overflowY: "hidden", children: [_jsx(Transcript, { lines: lines, isRunning: isRunning, isActive: activeScrollPane === "transcript", height: panelHeight, width: transcriptWidth, scrollOffset: transcriptScrollOffset }), _jsx(ApprovalPanel, { request: pendingApproval, bypassConfirmation: bypassConfirmation }), _jsx(Composer, { input: input, isRunning: isRunning, status: status, draftTokens: draftTokens, isApprovalWaiting: Boolean(pendingApproval || bypassConfirmation), onChange: setInput, onSubmit: (value) => void handleSubmit(value) }), paletteItems.length > 0 ? _jsx(CommandSuggestions, { items: paletteItems, selectedIndex: paletteIndex }) : null, _jsx(FooterHints, { activePane: activeScrollPane })] })] }))] }));
2852
+ if (themePickerOpen) {
2853
+ return (_jsx(Box, { flexDirection: "column", paddingX: 1, height: rootHeight, overflowY: "hidden", children: _jsx(ThemePicker, { options: themeOptions, selectedIndex: themePickerIndex, currentValue: uiTheme, height: rootHeight - 2 }) }));
2854
+ }
2855
+ if (uiTheme === "new" && !experimentalOpen) {
2856
+ if (onboarding) {
2857
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: rootHeight, overflowY: "hidden", children: [_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "\u25C6 PatchPilot" }), _jsx(Text, { color: "gray", children: " \u00B7 guided setup \u00B7 the new shell starts once setup is done" })] }), _jsx(OnboardingPanel, { state: onboarding, height: rootHeight - 3, selectedIndex: onboardingIndex, input: onboardingInput, busyMessage: onboardingBusyMessage, notice: onboardingNotice, formatModelLabel: formatModelLabel, formatModelDescription: formatModelDescription, onInputChange: setOnboardingInput, onInputSubmit: (value) => void handleOnboardingSubmit(value) })] }));
2858
+ }
2859
+ return (_jsx(ExperimentalShell, { provider: settings.provider, model: settings.model, workspace: settings.workspace, sessionId: sessionStoreRef.current.sessionId, agentMode: agentMode, allowWrite: settings.allowWrite, allowShell: settings.allowShell, subagents: settings.subagents, workState: workState, status: status, isRunning: isRunning, ultramaxxRun: ultramaxxRun, telemetry: telemetry, sessionTelemetry: sessionTelemetry, draftTokens: draftTokens, lines: lines, todos: todos, todoFrame: todoFrame, pendingApproval: pendingApproval, bypassConfirmation: bypassConfirmation, updatePrompt: updatePrompt, updateBusy: updateBusy, reauthActive: Boolean(reauthPrompt) || reauthBusy, reauthBusy: reauthBusy, transcriptScrollOffset: transcriptScrollOffset, input: input, paletteItems: paletteItems, paletteIndex: paletteIndex, rows: terminalRows, columns: terminalColumns, activeHost: activeHost, artifacts: artifacts, onChange: setInput, onSubmit: (value) => void handleSubmit(value), onAttach: attachFile }));
2860
+ }
2861
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: rootHeight, overflowY: "hidden", children: [_jsx(Header, { model: settings.model, provider: settings.provider, workspace: settings.workspace, status: status, workState: workState, allowWrite: settings.allowWrite, allowShell: settings.allowShell, agentMode: agentMode, subagents: settings.subagents, thinkingMode: settings.thinkingMode, reasoningEffort: settings.reasoningEffort, ollamaUrl: settings.ollamaUrl, telemetry: telemetry, sessionTelemetry: sessionTelemetry, draftTokens: draftTokens, systemStats: systemStats, gpuStats: gpuStats, activeHost: activeHost }), experimentalOpen ? (_jsx(ExperimentalPanel, { flags: experimentalFlags, selectedIndex: experimentalIndex, height: bodyHeight })) : onboarding ? (_jsx(OnboardingPanel, { state: onboarding, height: bodyHeight, selectedIndex: onboardingIndex, input: onboardingInput, busyMessage: onboardingBusyMessage, notice: onboardingNotice, formatModelLabel: formatModelLabel, formatModelDescription: formatModelDescription, onInputChange: setOnboardingInput, onInputSubmit: (value) => void handleOnboardingSubmit(value) })) : (_jsxs(Box, { flexDirection: "row", height: bodyHeight, overflowY: "hidden", children: [_jsx(Sidebar, { workspace: settings.workspace, model: settings.model, provider: settings.provider, ollamaUrl: settings.ollamaUrl, agentMode: agentMode, allowWrite: settings.allowWrite, allowShell: settings.allowShell, subagents: settings.subagents, workState: workState, sessionId: sessionStoreRef.current.sessionId, systemStats: systemStats, gpuStats: gpuStats, telemetry: telemetry, sessionTelemetry: sessionTelemetry, draftTokens: draftTokens, height: bodyHeight, scrollOffset: sessionScrollOffset, advisors: advisorNotes, isActive: activeScrollPane === "session", activeHost: activeHost }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: bodyHeight, overflowY: "hidden", children: [_jsx(Transcript, { lines: lines, isRunning: isRunning, isActive: activeScrollPane === "transcript", height: transcriptHeight, width: transcriptWidth, scrollOffset: transcriptScrollOffset, todos: todos, todoFrame: todoFrame, verbIndex: verbTick, status: status, workState: workState, isApprovalWaiting: blockingPromptActive }), _jsx(ReauthPromptPanel, { active: reauthPromptActive, busy: reauthBusy }), _jsx(UpdatePromptPanel, { prompt: updatePromptActive ? updatePrompt : null, busy: updatePromptActive && updateBusy }), _jsx(ApprovalPanel, { request: approvalPromptActive ? pendingApproval : null, bypassConfirmation: approvalPromptActive && bypassConfirmation }), _jsx(Composer, { input: input, isRunning: isRunning, status: status, workState: workState, draftTokens: draftTokens, width: transcriptWidth, isApprovalWaiting: blockingPromptActive, onChange: setInput, onSubmit: (value) => void handleSubmit(value) }), paletteItems.length > 0 ? _jsx(CommandSuggestions, { items: paletteItems, selectedIndex: paletteIndex }) : null, _jsx(FooterHints, { activePane: activeScrollPane })] })] }))] }));
1957
2862
  }
1958
2863
  async function loadAvailableModels(provider, ollamaUrl, setModelOptions, refresh = false) {
1959
2864
  const cacheKey = modelCacheKey(provider, ollamaUrl);
1960
2865
  const cachedModels = modelCache.get(cacheKey);
1961
2866
  if (!refresh && cachedModels && cachedModels.expiresAt > Date.now()) {
2867
+ rememberModelDescriptors(cachedModels.descriptors);
1962
2868
  setModelOptions(cachedModels.models);
1963
2869
  return cachedModels.models;
1964
2870
  }
1965
- const models = await createModelClient({
2871
+ const client = createModelClient({
1966
2872
  provider,
1967
2873
  ollamaUrl
1968
- }).listModels();
2874
+ });
2875
+ const descriptors = client.listModelDescriptors
2876
+ ? await client.listModelDescriptors()
2877
+ : (await client.listModels()).map((model) => ({ id: model, displayName: model }));
2878
+ const models = descriptors.map((model) => model.id);
2879
+ rememberModelDescriptors(descriptors);
1969
2880
  modelCache.set(cacheKey, {
1970
2881
  models,
2882
+ descriptors,
1971
2883
  expiresAt: Date.now() + modelCacheTtlMs
1972
2884
  });
1973
2885
  setModelOptions(models);
@@ -1988,6 +2900,17 @@ function modelCacheKey(provider, ollamaUrl) {
1988
2900
  }
1989
2901
  return `${provider}:default`;
1990
2902
  }
2903
+ function rememberModelDescriptors(descriptors) {
2904
+ for (const descriptor of descriptors) {
2905
+ modelDescriptorIndex.set(descriptor.id, descriptor);
2906
+ if (descriptor.modelName) {
2907
+ modelDescriptorIndex.set(descriptor.modelName, descriptor);
2908
+ }
2909
+ if (descriptor.displayName) {
2910
+ modelDescriptorIndex.set(descriptor.displayName, descriptor);
2911
+ }
2912
+ }
2913
+ }
1991
2914
  async function loadKnownOrAvailableModels(provider, ollamaUrl, modelOptions, setModelOptions, appendLine, options = {}) {
1992
2915
  try {
1993
2916
  return !options.refresh && modelOptions.length > 0 ? modelOptions : await loadAvailableModels(provider, ollamaUrl, setModelOptions, options.refresh);
@@ -2045,7 +2968,7 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
2045
2968
  appendLine({
2046
2969
  tone: installedModels.includes(nextModel) ? "success" : "warning",
2047
2970
  label: "model",
2048
- text: installedModels.includes(nextModel) ? `switched to ${nextModel}` : `switched to unverified ${provider} model ${nextModel}`,
2971
+ text: installedModels.includes(nextModel) ? `switched to ${formatModelLabel(nextModel)}` : `switched to unverified ${provider} model ${nextModel}`,
2049
2972
  detail: installedModels.includes(nextModel) ? undefined : "The provider did not list this model in discovery. PatchPilot will try it and surface the provider error if it is unavailable."
2050
2973
  });
2051
2974
  if (provider === "openrouter" && isOpenRouterFreeModel(nextModel)) {
@@ -2057,7 +2980,7 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
2057
2980
  });
2058
2981
  }
2059
2982
  }
2060
- async function resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions) {
2983
+ async function resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions, onProviderError) {
2061
2984
  let installedModels;
2062
2985
  try {
2063
2986
  installedModels = modelOptions.includes(settings.model)
@@ -2065,11 +2988,13 @@ async function resolveRunnableSettings(settings, modelOptions, appendLine, setMo
2065
2988
  : await loadAvailableModels(settings.provider, settings.ollamaUrl, setModelOptions);
2066
2989
  }
2067
2990
  catch (error) {
2991
+ const message = error instanceof Error ? error.message : String(error);
2068
2992
  appendLine({
2069
2993
  tone: "danger",
2070
2994
  label: settings.provider,
2071
- text: error instanceof Error ? error.message : String(error)
2995
+ text: message
2072
2996
  });
2997
+ onProviderError?.(message);
2073
2998
  return null;
2074
2999
  }
2075
3000
  if (installedModels.includes(settings.model) || canUseUnverifiedCloudModel(settings.provider, settings.model)) {
@@ -2154,11 +3079,11 @@ function buildCommandSuggestionItems(options) {
2154
3079
  });
2155
3080
  }
2156
3081
  else {
2157
- items.unshift(...selectableModels(modelQuery, options.modelOptions).slice(0, 8).map((model) => ({
3082
+ items.unshift(...selectableModels(modelQuery, options.modelOptions, formatModelLabel).slice(0, 8).map((model) => ({
2158
3083
  key: `model-${model}`,
2159
3084
  category: "model",
2160
- label: model,
2161
- detail: `${model === options.currentModel ? "current" : "available"} ${options.provider}`,
3085
+ label: formatModelLabel(model),
3086
+ detail: `${model === options.currentModel ? "current" : "available"} ${options.provider}${formatModelDescription(model)}`,
2162
3087
  command: `/model ${model}`,
2163
3088
  execute: true
2164
3089
  })));
@@ -2168,16 +3093,25 @@ function buildCommandSuggestionItems(options) {
2168
3093
  }
2169
3094
  function getOnboardingOptionCount(onboarding) {
2170
3095
  switch (onboarding.step) {
3096
+ case "welcome":
3097
+ return 1;
3098
+ case "disclaimer":
3099
+ return 0;
2171
3100
  case "entry":
2172
3101
  return 7;
2173
3102
  case "host":
2174
3103
  return onboarding.hosts.length + 1;
2175
3104
  case "api-key-choice":
3105
+ if (onboarding.provider === "gemini-wrapper") {
3106
+ return onboarding.hasExistingKey ? 3 : 2;
3107
+ }
2176
3108
  return onboarding.hasExistingKey ? 2 : 1;
2177
3109
  case "gemini-wrapper-model-mode":
2178
- return 2;
3110
+ return geminiWrapperShortcutModels.length + 1;
2179
3111
  case "model":
2180
3112
  return onboarding.models.length;
3113
+ case "preferences":
3114
+ return preferenceRows.length + 1;
2181
3115
  default:
2182
3116
  return 0;
2183
3117
  }
@@ -2223,6 +3157,37 @@ function readBooleanEnv(value, fallback) {
2223
3157
  }
2224
3158
  return fallback;
2225
3159
  }
3160
+ function normalizeExperimentalFlag(value) {
3161
+ switch (value.trim().toLowerCase()) {
3162
+ case "file-analysis":
3163
+ case "fileanalysis":
3164
+ case "files":
3165
+ return "fileAnalysis";
3166
+ case "memory":
3167
+ return "memory";
3168
+ case "subagents":
3169
+ case "agents":
3170
+ return "subagents";
3171
+ case "shell-metacharacters":
3172
+ case "shell-metachars":
3173
+ case "metacharacters":
3174
+ case "metachars":
3175
+ case "shell":
3176
+ return "shellMetacharacters";
3177
+ default:
3178
+ return null;
3179
+ }
3180
+ }
3181
+ function experimentalFlagCommandName(flag) {
3182
+ return flag === "fileAnalysis"
3183
+ ? "file-analysis"
3184
+ : flag === "shellMetacharacters"
3185
+ ? "shell-metacharacters"
3186
+ : flag;
3187
+ }
3188
+ function experimentalFlagEnvName(flag) {
3189
+ return `PATCHPILOT_EXPERIMENTAL_${experimentalFlagCommandName(flag).replace(/-/g, "_").toUpperCase()}`;
3190
+ }
2226
3191
  function readIndexedSelection(value, selectedIndex) {
2227
3192
  const normalizedValue = value.trim();
2228
3193
  if (!normalizedValue) {
@@ -2246,7 +3211,11 @@ function selectModelFromInput(value, models, selectedIndex, options = {}) {
2246
3211
  if (models.includes(normalizedValue)) {
2247
3212
  return normalizedValue;
2248
3213
  }
2249
- const matches = selectableModels(normalizedValue, models);
3214
+ const labelMatch = models.find((model) => formatModelLabel(model).toLowerCase() === normalizedValue.toLowerCase());
3215
+ if (labelMatch) {
3216
+ return labelMatch;
3217
+ }
3218
+ const matches = selectableModels(normalizedValue, models, formatModelLabel);
2250
3219
  if (matches.length === 1) {
2251
3220
  return matches[0] ?? null;
2252
3221
  }
@@ -2256,7 +3225,7 @@ function isPlausibleCloudModelId(value) {
2256
3225
  return /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(value) && value.length >= 3;
2257
3226
  }
2258
3227
  function canUseUnverifiedCloudModel(provider, model) {
2259
- return provider !== "ollama" && provider !== "gemini-wrapper" && isPlausibleCloudModelId(model);
3228
+ return provider !== "ollama" && isPlausibleCloudModelId(model);
2260
3229
  }
2261
3230
  function defaultModelForProvider(provider, currentModel) {
2262
3231
  if (provider === "nvidia") {
@@ -2266,7 +3235,7 @@ function defaultModelForProvider(provider, currentModel) {
2266
3235
  return currentModel.includes("/") ? currentModel : defaultOpenRouterModel;
2267
3236
  }
2268
3237
  if (provider === "gemini-wrapper") {
2269
- return currentModel === defaultGeminiWrapperModel || currentModel.startsWith("gemini-3-") ? currentModel : defaultGeminiWrapperModel;
3238
+ return geminiWrapperCuratedModels.includes(currentModel) || currentModel.startsWith("gemini-") || modelDescriptorIndex.has(currentModel) ? currentModel : defaultGeminiWrapperModel;
2270
3239
  }
2271
3240
  if (provider === "gemini") {
2272
3241
  return currentModel.startsWith("gemini-") ? currentModel : defaultGeminiModel;
@@ -2292,7 +3261,11 @@ function hasApiKey(provider) {
2292
3261
  return Boolean(readGeminiApiKey());
2293
3262
  }
2294
3263
  if (provider === "gemini-wrapper") {
2295
- return Boolean(readGeminiWrapperBaseUrl() || readGeminiWrapperCookiesJson());
3264
+ const baseUrl = readGeminiWrapperBaseUrl();
3265
+ if (readGeminiWrapperMode() === "http") {
3266
+ return !geminiWrapperRequiresApiKey(baseUrl) || Boolean(readGeminiWrapperApiKey());
3267
+ }
3268
+ return Boolean(readGeminiWrapperCookiesJson());
2296
3269
  }
2297
3270
  if (provider === "openrouter") {
2298
3271
  return Boolean(readOpenRouterApiKey());
@@ -2341,6 +3314,219 @@ function upsertAdvisorNote(notes, nextNote) {
2341
3314
  const nextNotes = notes.filter((note) => note.role !== nextNote.role);
2342
3315
  return [...nextNotes, nextNote].slice(-2);
2343
3316
  }
3317
+ function UpdatePromptPanel(props) {
3318
+ if (!props.prompt && !props.busy) {
3319
+ return null;
3320
+ }
3321
+ const command = props.prompt?.command ?? "npm update -g @jx-grxf/patchpilot";
3322
+ return (_jsxs(Box, { borderStyle: "double", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "UPDATE AVAILABLE" }), props.busy ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", children: "Updating PatchPilot..." }), _jsx(Text, { color: "gray", children: command })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "white", children: ["Install PatchPilot ", props.prompt?.latestVersion, " now?"] }), _jsxs(Text, { color: "gray", children: ["Current ", props.prompt?.currentVersion, " \u00B7 source ", props.prompt?.source, " \u00B7 ", command] }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "[y]" }), _jsx(Text, { color: "gray", children: " update " }), _jsx(Text, { color: "red", bold: true, children: "[n / esc]" }), _jsx(Text, { color: "gray", children: " skip" })] })] }))] }));
3323
+ }
3324
+ function ReauthPromptPanel(props) {
3325
+ if (!props.active) {
3326
+ return null;
3327
+ }
3328
+ return (_jsxs(Box, { borderStyle: "double", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "GEMINI COOKIES EXPIRED" }), props.busy ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", children: "Refreshing Gemini browser cookies..." }), _jsx(Text, { color: "gray", children: "PatchPilot will retry the prompt automatically on success." })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "white", children: "Refresh Gemini browser cookies and retry the last prompt?" }), _jsx(Text, { color: "gray", children: "Secret cookie values are imported from your signed-in browser and are not printed." }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "[y]" }), _jsx(Text, { color: "gray", children: " refresh & retry " }), _jsx(Text, { color: "red", bold: true, children: "[n / esc]" }), _jsx(Text, { color: "gray", children: " dismiss" })] })] }))] }));
3329
+ }
3330
+ function emptyToolTelemetry() {
3331
+ return {
3332
+ total: 0,
3333
+ succeeded: 0,
3334
+ failed: 0,
3335
+ approvals: 0,
3336
+ denied: 0,
3337
+ byTool: {}
3338
+ };
3339
+ }
3340
+ function addToolTelemetry(current, tool, ok) {
3341
+ return {
3342
+ ...current,
3343
+ total: current.total + 1,
3344
+ succeeded: current.succeeded + (ok ? 1 : 0),
3345
+ failed: current.failed + (ok ? 0 : 1),
3346
+ byTool: {
3347
+ ...current.byTool,
3348
+ [tool]: (current.byTool[tool] ?? 0) + 1
3349
+ }
3350
+ };
3351
+ }
3352
+ function addApprovalTelemetry(current, decision) {
3353
+ return {
3354
+ ...current,
3355
+ approvals: current.approvals + (decision === "deny" ? 0 : 1),
3356
+ denied: current.denied + (decision === "deny" ? 1 : 0)
3357
+ };
3358
+ }
3359
+ /**
3360
+ * Dense operational status dock for `/status` — restores the always-available
3361
+ * "what mode am I in and what can happen" view the legacy sidebar provided,
3362
+ * without spending fixed screen rows in the new shell's header.
3363
+ */
3364
+ function formatStatusDock(options) {
3365
+ const isOllama = options.provider === "ollama";
3366
+ const hostLine = isOllama
3367
+ ? `${options.activeHost?.host.deviceName ?? "ollama"} ${options.activeHost?.host.url ?? options.ollamaUrl}`
3368
+ : `${options.provider} api`;
3369
+ const computeKind = isOllama ? describeComputeTarget(options.ollamaUrl).kind : "cloud";
3370
+ const reasoning = isOllama
3371
+ ? `think ${options.thinkingMode}`
3372
+ : `think ${options.thinkingMode} · reasoning ${formatReasoningSupport(options.provider, options.model, options.reasoningEffort === "adaptive" ? undefined : options.reasoningEffort)}`;
3373
+ const toolCounters = Object.entries(options.toolTelemetry.byTool)
3374
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
3375
+ .slice(0, 6)
3376
+ .map(([tool, count]) => `${tool} ${count}`)
3377
+ .join(" · ");
3378
+ const advisors = options.advisorNotes.length > 0
3379
+ ? options.advisorNotes.map((note) => ` ${note.role}: ${note.message.replace(/\s+/g, " ").slice(0, 88)}`).join("\n")
3380
+ : " none yet";
3381
+ return [
3382
+ `provider ${options.provider}/${options.model}`,
3383
+ `host ${hostLine} · compute ${computeKind} · tools local`,
3384
+ `mode ${options.agentMode} · write ${modePermissionLabel(options.agentMode, "write")} · shell ${modePermissionLabel(options.agentMode, "shell")}`,
3385
+ `model cfg ${reasoning} · subagents ${options.subagents ? "on" : "off"}`,
3386
+ `workspace ${options.workspace}`,
3387
+ `session ${options.sessionId}`,
3388
+ `tokens draft ${options.draftTokens} · last ${formatTokens(options.telemetry)} · session ${formatSessionTokens(options.sessionTelemetry)} · cost ${formatCost(options.sessionTelemetry.estimatedCostUsd)}`,
3389
+ options.toolTelemetry.total > 0
3390
+ ? `tools ${options.toolTelemetry.total} calls · ${options.toolTelemetry.succeeded} ok · ${options.toolTelemetry.failed} failed · ${options.toolTelemetry.approvals} approved · ${options.toolTelemetry.denied} denied`
3391
+ : "tools none yet",
3392
+ toolCounters ? `counters ${toolCounters}` : "",
3393
+ `advisors\n${advisors}`,
3394
+ ]
3395
+ .filter(Boolean)
3396
+ .join("\n");
3397
+ }
3398
+ function formatUsageSummary(options) {
3399
+ const session = options.sessionTelemetry;
3400
+ const cost = formatCost(session.estimatedCostUsd);
3401
+ const saved = estimateSessionSavings(options.provider, options.model, session);
3402
+ const pricingNote = pricingSourceLabel(session.costSource, saved.source);
3403
+ return [
3404
+ `${session.requests} request${session.requests === 1 ? "" : "s"}`,
3405
+ `${session.promptTokens} in`,
3406
+ `${session.responseTokens} out`,
3407
+ `${session.cachedPromptTokens} cached`,
3408
+ `${options.toolTelemetry.total} tool call${options.toolTelemetry.total === 1 ? "" : "s"}`,
3409
+ `cost ${cost}`,
3410
+ saved.costUsd !== null ? `saved ${formatCost(saved.costUsd)}` : "saved -",
3411
+ pricingNote
3412
+ ].join(" · ");
3413
+ }
3414
+ function formatUsageDetail(options) {
3415
+ const session = options.sessionTelemetry;
3416
+ const saved = estimateSessionSavings(options.provider, options.model, session);
3417
+ const toolRows = Object.entries(options.toolTelemetry.byTool)
3418
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
3419
+ .map(([tool, count]) => `${tool}: ${count}`)
3420
+ .join("\n");
3421
+ return [
3422
+ `model: ${options.provider}/${options.model}`,
3423
+ `tokens: ${session.promptTokens} input, ${session.responseTokens} output, ${session.cachedPromptTokens} cached, ${session.cacheWriteTokens} cache-write, ${session.totalTokens} total`,
3424
+ `cost: ${formatCost(session.estimatedCostUsd)} (${session.costSource})`,
3425
+ saved.costUsd !== null ? `lifetime saved this session: ${formatCost(saved.costUsd)} (${saved.source})` : "lifetime saved this session: -",
3426
+ options.toolTelemetry.total > 0
3427
+ ? `tools: ${options.toolTelemetry.total} total, ${options.toolTelemetry.succeeded} ok, ${options.toolTelemetry.failed} failed, ${options.toolTelemetry.approvals} approved, ${options.toolTelemetry.denied} denied`
3428
+ : "tools: none yet",
3429
+ toolRows ? `tool counters:\n${toolRows}` : "",
3430
+ session.costSource === "fallback-pricing" || saved.source === "fallback-pricing"
3431
+ ? "pricing note: exact model pricing was not available, so PatchPilot used a conservative general cloud-model estimate."
3432
+ : session.costSource === "unknown"
3433
+ ? "pricing note: exact pricing is unavailable for this provider/model."
3434
+ : ""
3435
+ ]
3436
+ .filter(Boolean)
3437
+ .join("\n");
3438
+ }
3439
+ function estimateSessionSavings(provider, model, session) {
3440
+ return estimateComparableApiCost(provider, model, session.promptTokens, session.responseTokens, session.cachedPromptTokens);
3441
+ }
3442
+ function pricingSourceLabel(costSource, savedSource) {
3443
+ if (costSource === "fallback-pricing" || savedSource === "fallback-pricing") {
3444
+ return "fallback pricing";
3445
+ }
3446
+ if (costSource === "unknown" && savedSource === "unknown") {
3447
+ return "pricing unknown";
3448
+ }
3449
+ if (costSource === "free-route") {
3450
+ return "free route";
3451
+ }
3452
+ if (costSource === "mixed") {
3453
+ return "mixed pricing";
3454
+ }
3455
+ return "priced";
3456
+ }
3457
+ const bytesPerMiB = 1024 * 1024;
3458
+ const geminiAppsPromptFileLimit = 10;
3459
+ const geminiNonVideoFileLimitBytes = 100 * bytesPerMiB;
3460
+ const geminiApiPdfLimitBytes = 50 * bytesPerMiB;
3461
+ const geminiPdfCautionBytes = 20 * bytesPerMiB;
3462
+ const geminiInlineRequestWarnBytes = 25 * bytesPerMiB;
3463
+ function attachmentLimitWarning(paths, provider) {
3464
+ if (paths.length === 0 || (provider !== "gemini" && provider !== "gemini-wrapper")) {
3465
+ return null;
3466
+ }
3467
+ const files = paths.map((filePath) => ({
3468
+ path: filePath,
3469
+ type: attachmentTypeForPath(filePath),
3470
+ size: readFileSize(filePath)
3471
+ }));
3472
+ const knownTotalBytes = files.reduce((total, file) => total + (file.size ?? 0), 0);
3473
+ const tooLargePdf = files.find((file) => file.type === "PDF" && typeof file.size === "number" && file.size > geminiApiPdfLimitBytes);
3474
+ const largePdf = files.find((file) => file.type === "PDF" && typeof file.size === "number" && file.size > geminiPdfCautionBytes);
3475
+ const tooLargeFile = files.find((file) => typeof file.size === "number" && file.size > geminiNonVideoFileLimitBytes);
3476
+ if (paths.length > geminiAppsPromptFileLimit) {
3477
+ return `Attached ${paths.length} files; Gemini web-style uploads are capped around ${geminiAppsPromptFileLimit} files per prompt. Split this into smaller batches.`;
3478
+ }
3479
+ if (tooLargePdf) {
3480
+ return `${attachmentTypeForPath(tooLargePdf.path)} file ${attachmentBasename(tooLargePdf.path)} is over 50 MiB; Gemini API PDF input can reject it.`;
3481
+ }
3482
+ if (tooLargeFile) {
3483
+ return `${attachmentTypeForPath(tooLargeFile.path)} file ${attachmentBasename(tooLargeFile.path)} is over 100 MiB; Gemini file prompts may reject it.`;
3484
+ }
3485
+ if (largePdf) {
3486
+ return `${attachmentTypeForPath(largePdf.path)} file ${attachmentBasename(largePdf.path)} is over 20 MiB; Gemini PDF analysis can be slow or incomplete.`;
3487
+ }
3488
+ if (knownTotalBytes > geminiInlineRequestWarnBytes) {
3489
+ return `Attached files total about ${formatMiB(knownTotalBytes)}; Gemini analysis is more reliable in smaller batches.`;
3490
+ }
3491
+ if (paths.length > 3) {
3492
+ return `Attached ${paths.length} files; PatchPilot will reference them, but Gemini/Gemini-Wrapper is more reliable if you split large batches.`;
3493
+ }
3494
+ return null;
3495
+ }
3496
+ function readFileSize(filePath) {
3497
+ try {
3498
+ const stats = statSync(filePath);
3499
+ return stats.isFile() ? stats.size : null;
3500
+ }
3501
+ catch {
3502
+ return null;
3503
+ }
3504
+ }
3505
+ function formatMiB(bytes) {
3506
+ return `${Math.round((bytes / bytesPerMiB) * 10) / 10} MiB`;
3507
+ }
3508
+ function formatAttachedDocuments(paths) {
3509
+ const counts = new Map();
3510
+ return paths
3511
+ .map((filePath) => {
3512
+ const kind = attachmentKindForPath(filePath) ?? "file";
3513
+ const type = attachmentTypeForPath(filePath);
3514
+ const index = (counts.get(type) ?? 0) + 1;
3515
+ counts.set(type, index);
3516
+ return `- ${attachmentLabel(kind, index, filePath)} path=${JSON.stringify(filePath)}`;
3517
+ })
3518
+ .join("\n");
3519
+ }
3520
+ /** Last path segment, splitting on both POSIX and Windows separators. */
3521
+ function attachmentBasename(filePath) {
3522
+ return filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath;
3523
+ }
3524
+ function formatAttachmentDigestPath(filePath) {
3525
+ return JSON.stringify(filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath);
3526
+ }
3527
+ function randomLegacyVerbIndex() {
3528
+ return Math.floor(Math.random() * 1_000_000);
3529
+ }
2344
3530
  function eventToLine(event) {
2345
3531
  switch (event.type) {
2346
3532
  case "status":
@@ -2374,12 +3560,21 @@ function eventToLine(event) {
2374
3560
  tone: event.ok ? "success" : "warning",
2375
3561
  label: event.name,
2376
3562
  text: event.summary,
3563
+ detail: event.ok ? previewToolContent(event.content) : event.content,
2377
3564
  workState: event.workState,
2378
3565
  tool: event.name,
2379
3566
  toolCallId: event.toolCallId,
2380
3567
  category: event.category,
2381
3568
  preview: event.preview
2382
3569
  };
3570
+ case "todo":
3571
+ return {
3572
+ kind: "status",
3573
+ tone: "muted",
3574
+ label: "todo",
3575
+ text: event.summary,
3576
+ workState: event.workState
3577
+ };
2383
3578
  case "approval":
2384
3579
  return {
2385
3580
  kind: "approval",
@@ -2417,6 +3612,16 @@ function eventToLine(event) {
2417
3612
  };
2418
3613
  }
2419
3614
  }
3615
+ function previewToolContent(content) {
3616
+ const value = content?.trim();
3617
+ if (!value) {
3618
+ return undefined;
3619
+ }
3620
+ const lines = value.split(/\r?\n/);
3621
+ const preview = lines.slice(0, 6).join("\n");
3622
+ const suffix = lines.length > 6 ? `\n...[${lines.length - 6} more lines]` : "";
3623
+ return `${preview}${suffix}`;
3624
+ }
2420
3625
  function eventToStatus(event) {
2421
3626
  if (event.type === "status") {
2422
3627
  return event.message;
@@ -2424,6 +3629,9 @@ function eventToStatus(event) {
2424
3629
  if (event.type === "tool") {
2425
3630
  return `${event.name}: ${event.summary}`;
2426
3631
  }
3632
+ if (event.type === "todo") {
3633
+ return event.summary;
3634
+ }
2427
3635
  if (event.type === "subagent") {
2428
3636
  return `${event.role} subagent`;
2429
3637
  }
@@ -2432,6 +3640,19 @@ function eventToStatus(event) {
2432
3640
  }
2433
3641
  return event.type;
2434
3642
  }
3643
+ function workStateForApprovalTool(tool) {
3644
+ const category = getToolSpec(tool).category;
3645
+ if (category === "write") {
3646
+ return "editing";
3647
+ }
3648
+ if (category === "shell" || category === "test") {
3649
+ return "verifying";
3650
+ }
3651
+ if (category === "read" || category === "search" || category === "document" || category === "git") {
3652
+ return "reading";
3653
+ }
3654
+ return "inspecting";
3655
+ }
2435
3656
  function defaultLogKind(line) {
2436
3657
  if (line.kind) {
2437
3658
  return line.kind;
@@ -2459,8 +3680,21 @@ function formatModelOptions(models, currentModel) {
2459
3680
  return models
2460
3681
  .map((model, index) => {
2461
3682
  const currentMarker = model === currentModel ? " current" : "";
2462
- return `${index + 1}. ${model}${currentMarker}`;
3683
+ return `${index + 1}. ${formatModelLabel(model)}${formatModelDescription(model)}${currentMarker}`;
2463
3684
  })
2464
3685
  .join("\n");
2465
3686
  }
3687
+ function formatModelLabel(model) {
3688
+ const descriptor = modelDescriptorIndex.get(model);
3689
+ const label = descriptor?.displayName || descriptor?.modelName || model;
3690
+ return label === model ? model : `${label} (${model})`;
3691
+ }
3692
+ function formatModelDescription(model) {
3693
+ const descriptor = modelDescriptorIndex.get(model);
3694
+ if (!descriptor?.description) {
3695
+ return "";
3696
+ }
3697
+ const legacySuffix = descriptor.legacy ? " legacy" : "";
3698
+ return ` ${descriptor.description}${legacySuffix}`;
3699
+ }
2466
3700
  //# sourceMappingURL=App.js.map