@jx-grxf/patchpilot 0.4.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 (180) hide show
  1. package/.env.example +17 -1
  2. package/README.md +113 -23
  3. package/SECURITY.md +7 -1
  4. package/dist/cli.js +103 -14
  5. package/dist/cli.js.map +1 -1
  6. package/dist/core/agent.d.ts +47 -1
  7. package/dist/core/agent.js +667 -76
  8. package/dist/core/agent.js.map +1 -1
  9. package/dist/core/cleanup.d.ts +3 -0
  10. package/dist/core/cleanup.js +29 -0
  11. package/dist/core/cleanup.js.map +1 -0
  12. package/dist/core/clipboard.d.ts +14 -0
  13. package/dist/core/clipboard.js +134 -0
  14. package/dist/core/clipboard.js.map +1 -0
  15. package/dist/core/codex.d.ts +8 -0
  16. package/dist/core/codex.js +28 -2
  17. package/dist/core/codex.js.map +1 -1
  18. package/dist/core/compaction.d.ts +23 -0
  19. package/dist/core/compaction.js +145 -0
  20. package/dist/core/compaction.js.map +1 -0
  21. package/dist/core/contextFormat.d.ts +21 -0
  22. package/dist/core/contextFormat.js +87 -0
  23. package/dist/core/contextFormat.js.map +1 -0
  24. package/dist/core/contextItem.d.ts +41 -0
  25. package/dist/core/contextItem.js +93 -0
  26. package/dist/core/contextItem.js.map +1 -0
  27. package/dist/core/contextStore.d.ts +48 -0
  28. package/dist/core/contextStore.js +306 -0
  29. package/dist/core/contextStore.js.map +1 -0
  30. package/dist/core/doctor.d.ts +4 -1
  31. package/dist/core/doctor.js +122 -3
  32. package/dist/core/doctor.js.map +1 -1
  33. package/dist/core/gemini.js +10 -4
  34. package/dist/core/gemini.js.map +1 -1
  35. package/dist/core/geminiWrapper.d.ts +92 -0
  36. package/dist/core/geminiWrapper.js +1258 -0
  37. package/dist/core/geminiWrapper.js.map +1 -0
  38. package/dist/core/http.js +70 -6
  39. package/dist/core/http.js.map +1 -1
  40. package/dist/core/json.d.ts +1 -1
  41. package/dist/core/json.js +81 -19
  42. package/dist/core/json.js.map +1 -1
  43. package/dist/core/memory.d.ts +16 -0
  44. package/dist/core/memory.js +108 -0
  45. package/dist/core/memory.js.map +1 -0
  46. package/dist/core/modelClient.js +7 -0
  47. package/dist/core/modelClient.js.map +1 -1
  48. package/dist/core/nvidia.d.ts +1 -1
  49. package/dist/core/nvidia.js +13 -4
  50. package/dist/core/nvidia.js.map +1 -1
  51. package/dist/core/ollama.js +13 -3
  52. package/dist/core/ollama.js.map +1 -1
  53. package/dist/core/openrouter.js +15 -6
  54. package/dist/core/openrouter.js.map +1 -1
  55. package/dist/core/projectInit.d.ts +6 -0
  56. package/dist/core/projectInit.js +44 -0
  57. package/dist/core/projectInit.js.map +1 -0
  58. package/dist/core/reasoning.js +6 -0
  59. package/dist/core/reasoning.js.map +1 -1
  60. package/dist/core/session.d.ts +1 -0
  61. package/dist/core/session.js +55 -3
  62. package/dist/core/session.js.map +1 -1
  63. package/dist/core/tokenAccounting.d.ts +4 -0
  64. package/dist/core/tokenAccounting.js +75 -13
  65. package/dist/core/tokenAccounting.js.map +1 -1
  66. package/dist/core/types.d.ts +65 -5
  67. package/dist/core/types.js +30 -1
  68. package/dist/core/types.js.map +1 -1
  69. package/dist/core/updateCheck.d.ts +19 -0
  70. package/dist/core/updateCheck.js +103 -0
  71. package/dist/core/updateCheck.js.map +1 -0
  72. package/dist/core/workspace.d.ts +37 -0
  73. package/dist/core/workspace.js +1535 -84
  74. package/dist/core/workspace.js.map +1 -1
  75. package/dist/tui/App.d.ts +1 -0
  76. package/dist/tui/App.js +1841 -140
  77. package/dist/tui/App.js.map +1 -1
  78. package/dist/tui/commands.js +141 -9
  79. package/dist/tui/commands.js.map +1 -1
  80. package/dist/tui/components/ApprovalPanel.js +16 -1
  81. package/dist/tui/components/ApprovalPanel.js.map +1 -1
  82. package/dist/tui/components/CommandSuggestions.js +33 -5
  83. package/dist/tui/components/CommandSuggestions.js.map +1 -1
  84. package/dist/tui/components/Composer.d.ts +3 -0
  85. package/dist/tui/components/Composer.js +57 -5
  86. package/dist/tui/components/Composer.js.map +1 -1
  87. package/dist/tui/components/ExperimentalPanel.d.ts +10 -0
  88. package/dist/tui/components/ExperimentalPanel.js +38 -0
  89. package/dist/tui/components/ExperimentalPanel.js.map +1 -0
  90. package/dist/tui/components/Header.js +3 -3
  91. package/dist/tui/components/Header.js.map +1 -1
  92. package/dist/tui/components/OnboardingPanel.d.ts +25 -1
  93. package/dist/tui/components/OnboardingPanel.js +87 -25
  94. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  95. package/dist/tui/components/Sidebar.js +17 -13
  96. package/dist/tui/components/Sidebar.js.map +1 -1
  97. package/dist/tui/components/StartupBanner.d.ts +4 -0
  98. package/dist/tui/components/StartupBanner.js +9 -0
  99. package/dist/tui/components/StartupBanner.js.map +1 -0
  100. package/dist/tui/components/Transcript.d.ts +7 -0
  101. package/dist/tui/components/Transcript.js +87 -17
  102. package/dist/tui/components/Transcript.js.map +1 -1
  103. package/dist/tui/contextCommands.d.ts +8 -0
  104. package/dist/tui/contextCommands.js +205 -0
  105. package/dist/tui/contextCommands.js.map +1 -0
  106. package/dist/tui/experimental/AnimatedText.d.ts +38 -0
  107. package/dist/tui/experimental/AnimatedText.js +55 -0
  108. package/dist/tui/experimental/AnimatedText.js.map +1 -0
  109. package/dist/tui/experimental/Banner.d.ts +10 -0
  110. package/dist/tui/experimental/Banner.js +33 -0
  111. package/dist/tui/experimental/Banner.js.map +1 -0
  112. package/dist/tui/experimental/CommandPalette.d.ts +11 -0
  113. package/dist/tui/experimental/CommandPalette.js +25 -0
  114. package/dist/tui/experimental/CommandPalette.js.map +1 -0
  115. package/dist/tui/experimental/ExperimentalShell.d.ts +58 -0
  116. package/dist/tui/experimental/ExperimentalShell.js +366 -0
  117. package/dist/tui/experimental/ExperimentalShell.js.map +1 -0
  118. package/dist/tui/experimental/ThemePicker.d.ts +13 -0
  119. package/dist/tui/experimental/ThemePicker.js +12 -0
  120. package/dist/tui/experimental/ThemePicker.js.map +1 -0
  121. package/dist/tui/experimental/attachments.d.ts +35 -0
  122. package/dist/tui/experimental/attachments.js +244 -0
  123. package/dist/tui/experimental/attachments.js.map +1 -0
  124. package/dist/tui/experimental/composer.d.ts +24 -0
  125. package/dist/tui/experimental/composer.js +84 -0
  126. package/dist/tui/experimental/composer.js.map +1 -0
  127. package/dist/tui/experimental/geminiPricing.d.ts +16 -0
  128. package/dist/tui/experimental/geminiPricing.js +39 -0
  129. package/dist/tui/experimental/geminiPricing.js.map +1 -0
  130. package/dist/tui/experimental/layout.d.ts +46 -0
  131. package/dist/tui/experimental/layout.js +112 -0
  132. package/dist/tui/experimental/layout.js.map +1 -0
  133. package/dist/tui/experimental/theme.d.ts +35 -0
  134. package/dist/tui/experimental/theme.js +86 -0
  135. package/dist/tui/experimental/theme.js.map +1 -0
  136. package/dist/tui/experimental/transcriptRows.d.ts +20 -0
  137. package/dist/tui/experimental/transcriptRows.js +169 -0
  138. package/dist/tui/experimental/transcriptRows.js.map +1 -0
  139. package/dist/tui/experimental/ultraModes.d.ts +46 -0
  140. package/dist/tui/experimental/ultraModes.js +95 -0
  141. package/dist/tui/experimental/ultraModes.js.map +1 -0
  142. package/dist/tui/experimental/ultramaxx.d.ts +19 -0
  143. package/dist/tui/experimental/ultramaxx.js +43 -0
  144. package/dist/tui/experimental/ultramaxx.js.map +1 -0
  145. package/dist/tui/format.d.ts +4 -2
  146. package/dist/tui/format.js +21 -7
  147. package/dist/tui/format.js.map +1 -1
  148. package/dist/tui/hosts.js +7 -1
  149. package/dist/tui/hosts.js.map +1 -1
  150. package/dist/tui/layout.d.ts +26 -0
  151. package/dist/tui/layout.js +66 -0
  152. package/dist/tui/layout.js.map +1 -0
  153. package/dist/tui/modelSelection.d.ts +1 -1
  154. package/dist/tui/modelSelection.js +8 -6
  155. package/dist/tui/modelSelection.js.map +1 -1
  156. package/dist/tui/modes.d.ts +8 -1
  157. package/dist/tui/modes.js +20 -2
  158. package/dist/tui/modes.js.map +1 -1
  159. package/dist/tui/onboardingPreferences.d.ts +37 -0
  160. package/dist/tui/onboardingPreferences.js +118 -0
  161. package/dist/tui/onboardingPreferences.js.map +1 -0
  162. package/dist/tui/runStatus.d.ts +50 -0
  163. package/dist/tui/runStatus.js +164 -0
  164. package/dist/tui/runStatus.js.map +1 -0
  165. package/dist/tui/types.d.ts +8 -0
  166. package/dist/tui/types.js.map +1 -1
  167. package/docs/architecture.md +115 -0
  168. package/docs/gemini-wrapper.md +110 -0
  169. package/docs/product-context.md +43 -0
  170. package/docs/releases/v0.1.1-beta.md +18 -0
  171. package/docs/releases/v0.2.1.md +1 -1
  172. package/docs/releases/v0.3.1-beta.md +4 -0
  173. package/docs/releases/v0.4.0.md +1 -1
  174. package/docs/releases/v1.0.0.md +28 -0
  175. package/docs/releases/v1.0.1.md +25 -0
  176. package/docs/releases/v1.1.0.md +30 -0
  177. package/docs/releases/v1.2.0.md +28 -0
  178. package/docs/showcase/patchpilot-banner.png +0 -0
  179. package/docs/showcase/patchpilot-logo.png +0 -0
  180. package/package.json +8 -3
package/dist/tui/App.js CHANGED
@@ -1,23 +1,37 @@
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";
6
+ import { cleanupPatchPilot, readCleanupTarget } from "../core/cleanup.js";
5
7
  import { defaultCodexModel, hasCodexCliOAuth } from "../core/codex.js";
6
8
  import { describeComputeTarget } from "../core/compute.js";
9
+ import { ContextStore } from "../core/contextStore.js";
7
10
  import { runDoctor } from "../core/doctor.js";
8
11
  import { savePatchPilotEnvValues } from "../core/env.js";
9
12
  import { defaultGeminiModel, readGeminiApiKey } from "../core/gemini.js";
13
+ import { defaultGeminiWrapperModel, geminiWrapperCuratedModels, geminiWrapperShortcutModels, geminiWrapperRequiresApiKey, readGeminiWrapperApiKey, readGeminiWrapperBaseUrl, readGeminiWrapperCookiesJson, readGeminiWrapperMode, readGeminiWrapperPythonCommand, importGeminiWrapperBrowserCookies, saveGeminiWrapperCookieFile } from "../core/geminiWrapper.js";
10
14
  import { createModelClient } from "../core/modelClient.js";
11
15
  import { defaultNvidiaModel, readNvidiaApiKey } from "../core/nvidia.js";
12
16
  import { defaultOllamaModel, OllamaClient } from "../core/ollama.js";
13
17
  import { defaultOpenRouterModel, isOpenRouterFreeModel, readOpenRouterApiKey } from "../core/openrouter.js";
18
+ import { ensurePatchPilotGitignore, patchPilotInitPrompt } from "../core/projectInit.js";
14
19
  import { formatReasoningSupport } from "../core/reasoning.js";
15
- import { listWorkspaceSessions, loadSessionSummary, SessionStore } from "../core/session.js";
16
- import { addTelemetryToSession, emptySessionTelemetry, estimateTokens } from "../core/tokenAccounting.js";
17
- import { WorkspaceTools } from "../core/workspace.js";
20
+ import { buildSessionResumeContext, listWorkspaceSessions, loadSessionSummary, SessionStore } from "../core/session.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";
18
24
  import { ApprovalPanel } from "./components/ApprovalPanel.js";
25
+ import { clipboardHasImage, clipboardImageHint, readClipboardImage } from "../core/clipboard.js";
19
26
  import { CommandSuggestions } from "./components/CommandSuggestions.js";
20
27
  import { Composer, FooterHints } from "./components/Composer.js";
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";
21
35
  import { Header } from "./components/Header.js";
22
36
  import { OnboardingPanel } from "./components/OnboardingPanel.js";
23
37
  import { Sidebar } from "./components/Sidebar.js";
@@ -25,21 +39,53 @@ import { Transcript } from "./components/Transcript.js";
25
39
  import { filterSlashCommands, formatCommandDetail, formatCommandHelp } from "./commands.js";
26
40
  import { formatCost, formatSessionTokens, formatTokens, normalizeModelAlias, readToggle } from "./format.js";
27
41
  import { checkOllamaHost, discoverOllamaHosts, normalizeOllamaUrl, readOllamaHostDetails, startLocalOllamaAppAndWait } from "./hosts.js";
28
- 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";
29
44
  import { selectableModels } from "./modelSelection.js";
45
+ import { cyclePreference, modePermissions as preferencesModePermissions, preferenceRows, preferencesEnvValues, readOnboardingPreferences } from "./onboardingPreferences.js";
30
46
  import { readGpuStats, readSystemStats } from "./systemStats.js";
31
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
+ }
32
67
  const modelCacheTtlMs = 5 * 60_000;
33
68
  const modelCache = new Map();
69
+ const modelDescriptorIndex = new Map();
34
70
  export function App(props) {
35
71
  const { exit } = useApp();
36
72
  const { stdout } = useStdout();
37
73
  const [input, setInput] = useState(props.initialTask ?? "");
38
74
  const didRunInitialTask = useRef(false);
39
75
  const didOpenDefaultOnboarding = useRef(false);
76
+ const didCheckForUpdates = useRef(false);
40
77
  const abortControllerRef = useRef(null);
78
+ const softStopRequestedRef = useRef(false);
79
+ const lastEscapeStopAtRef = useRef(0);
80
+ const lastAttachmentWarningRef = useRef("");
41
81
  const sessionStoreRef = useRef(new SessionStore({ workspace: props.workspace }));
82
+ const contextStoreRef = useRef(new ContextStore({ workspace: props.workspace, sessionId: sessionStoreRef.current.sessionId }));
42
83
  const approvalResolverRef = useRef(null);
84
+ const runtimeStateRef = useRef({
85
+ isRunning: false,
86
+ hasPendingApproval: false,
87
+ lastSigintAt: 0
88
+ });
43
89
  const grantedPermissionsRef = useRef({
44
90
  allowWrite: props.allowWrite,
45
91
  allowShell: props.allowShell
@@ -47,14 +93,24 @@ export function App(props) {
47
93
  const activeHostSyncInFlightRef = useRef(false);
48
94
  const autoLoadKeysRef = useRef(new Set());
49
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([]);
50
99
  const [lines, setLines] = useState([]);
51
100
  const [advisorNotes, setAdvisorNotes] = useState([]);
101
+ const [todos, setTodos] = useState([]);
102
+ const [todoFrame, setTodoFrame] = useState(0);
103
+ const [verbTick, setVerbTick] = useState(0);
52
104
  const [isRunning, setIsRunning] = useState(false);
53
105
  const [status, setStatus] = useState("idle");
54
106
  const [workState, setWorkState] = useState("idle");
55
107
  const [pendingApproval, setPendingApproval] = useState(null);
108
+ const [updatePrompt, setUpdatePrompt] = useState(null);
109
+ const [updateBusy, setUpdateBusy] = useState(false);
56
110
  const [telemetry, setTelemetry] = useState(null);
57
111
  const [sessionTelemetry, setSessionTelemetry] = useState(() => emptySessionTelemetry());
112
+ const [toolTelemetry, setToolTelemetry] = useState(() => emptyToolTelemetry());
113
+ const [resumeContext, setResumeContext] = useState("");
58
114
  const [systemStats, setSystemStats] = useState(() => readSystemStats().stats);
59
115
  const [gpuStats, setGpuStats] = useState(null);
60
116
  const [agentMode, setAgentMode] = useState(() => initialAgentMode({ allowWrite: props.allowWrite, allowShell: props.allowShell }));
@@ -65,6 +121,26 @@ export function App(props) {
65
121
  const [modelOptions, setModelOptions] = useState([]);
66
122
  const [isLoadingModels, setIsLoadingModels] = useState(false);
67
123
  const [onboarding, setOnboarding] = useState(null);
124
+ const [experimentalOpen, setExperimentalOpen] = useState(false);
125
+ const [experimentalIndex, setExperimentalIndex] = useState(0);
126
+ const [experimentalFlags, setExperimentalFlags] = useState({
127
+ fileAnalysis: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_FILE_ANALYSIS, false),
128
+ memory: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_MEMORY, false),
129
+ subagents: props.subagents,
130
+ shellMetacharacters: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_SHELL_METACHARACTERS, false)
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);
68
144
  const [onboardingIndex, setOnboardingIndex] = useState(0);
69
145
  const [onboardingInput, setOnboardingInput] = useState("");
70
146
  const [onboardingBusyMessage, setOnboardingBusyMessage] = useState(null);
@@ -88,7 +164,11 @@ export function App(props) {
88
164
  const draftTokens = estimateTokens(input);
89
165
  const terminalRows = stdout.rows ?? 40;
90
166
  const terminalColumns = stdout.columns ?? 120;
91
- const paletteItems = !isRunning && !onboarding
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
92
172
  ? buildCommandSuggestionItems({
93
173
  input,
94
174
  provider: settings.provider,
@@ -101,44 +181,239 @@ export function App(props) {
101
181
  : [];
102
182
  const rootHeight = Math.max(24, terminalRows);
103
183
  const headerReservedHeight = 5;
104
- const paletteReservedHeight = !onboarding && paletteItems.length > 0 ? Math.min(8, paletteItems.length) + 4 : 0;
105
- const composerReservedHeight = onboarding ? 0 : 2;
106
- const footerReservedHeight = onboarding ? 0 : 1;
107
- const approvalReservedHeight = !onboarding && (pendingApproval || bypassConfirmation) ? 6 : 0;
108
- const panelHeight = Math.max(8, rootHeight - headerReservedHeight - composerReservedHeight - paletteReservedHeight - footerReservedHeight - approvalReservedHeight);
109
184
  const transcriptWidth = Math.max(42, terminalColumns - 38);
110
- 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));
111
193
  const appendLine = useCallback((line) => {
112
194
  setLines((currentLines) => [
113
- ...currentLines.slice(-maxTranscriptLines),
195
+ ...currentLines,
114
196
  {
115
197
  ...line,
116
198
  kind: line.kind ?? defaultLogKind(line),
117
199
  id: Date.now() + Math.random()
118
200
  }
119
- ]);
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);
120
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]);
121
346
  const resolveApproval = useCallback((decision) => {
122
347
  if (!pendingApproval || !approvalResolverRef.current) {
123
348
  return;
124
349
  }
125
350
  approvalResolverRef.current(decision);
126
351
  approvalResolverRef.current = null;
352
+ const nextWorkState = decision === "deny" ? "error" : workStateForApprovalTool(pendingApproval.tool);
353
+ setInput("");
354
+ setStatus(decision === "deny" ? `${pendingApproval.tool} denied` : `${pendingApproval.tool} approved; running`);
355
+ setWorkState(nextWorkState);
127
356
  appendLine({
128
357
  kind: "approval",
129
358
  tone: decision === "deny" ? "warning" : "success",
130
359
  label: "approval",
131
360
  text: `${pendingApproval.tool} ${decision.replace("_", " ")}`,
132
361
  detail: pendingApproval.preview,
133
- workState: "waiting_approval",
362
+ workState: nextWorkState,
134
363
  tool: pendingApproval.tool
135
364
  });
136
365
  setPendingApproval(null);
137
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]);
138
412
  const applyMode = useCallback((nextMode, announce = true) => {
139
413
  const permissions = permissionsForMode(nextMode);
140
414
  setAgentMode(nextMode);
141
415
  setBypassConfirmation(false);
416
+ grantedPermissionsRef.current = permissions;
142
417
  setSettings((currentSettings) => ({
143
418
  ...currentSettings,
144
419
  allowWrite: permissions.allowWrite,
@@ -166,9 +441,24 @@ export function App(props) {
166
441
  setWorkState("waiting_approval");
167
442
  }, [bypassConfirmation]);
168
443
  const confirmBypassMode = useCallback(() => {
444
+ setInput("");
169
445
  applyMode("bypass");
170
446
  }, [applyMode]);
447
+ const setExplicitPermission = useCallback((permission, enabled) => {
448
+ const nextPermissions = {
449
+ allowWrite: permission === "write" ? enabled : settings.allowWrite,
450
+ allowShell: permission === "shell" ? enabled : settings.allowShell
451
+ };
452
+ grantedPermissionsRef.current = nextPermissions;
453
+ setBypassConfirmation(false);
454
+ setAgentMode(nextPermissions.allowWrite && nextPermissions.allowShell ? "bypass" : nextPermissions.allowWrite || nextPermissions.allowShell ? "build" : "plan");
455
+ setSettings((currentSettings) => ({
456
+ ...currentSettings,
457
+ ...nextPermissions
458
+ }));
459
+ }, [settings.allowShell, settings.allowWrite]);
171
460
  const cancelBypassMode = useCallback(() => {
461
+ setInput("");
172
462
  setBypassConfirmation(false);
173
463
  setStatus("idle");
174
464
  setWorkState("idle");
@@ -258,6 +548,7 @@ export function App(props) {
258
548
  setModelOptions(details.models);
259
549
  modelCache.set(`ollama:${verifiedHost.url}`, {
260
550
  models: details.models,
551
+ descriptors: details.models.map((model) => ({ id: model, displayName: model })),
261
552
  expiresAt: Date.now() + modelCacheTtlMs
262
553
  });
263
554
  setSettings((currentSettings) => ({
@@ -291,13 +582,14 @@ export function App(props) {
291
582
  setTelemetry(null);
292
583
  setOnboardingInput("");
293
584
  setOnboardingNotice(null);
294
- setOnboardingBusyMessage(`Loading ${provider} models...`);
585
+ setOnboardingBusyMessage(null);
295
586
  const nextModel = defaultModelForProvider(provider, options.currentModel ?? settings.model);
296
587
  setSettings((currentSettings) => ({
297
588
  ...currentSettings,
298
589
  provider,
299
590
  model: nextModel
300
591
  }));
592
+ setOnboardingBusyMessage(`Loading ${provider} models...`);
301
593
  try {
302
594
  const models = await loadAvailableModels(provider, options.ollamaUrl ?? settings.ollamaUrl, setModelOptions, true);
303
595
  if (models.length === 0) {
@@ -307,9 +599,13 @@ export function App(props) {
307
599
  ? "No Ollama models found on that host."
308
600
  : provider === "gemini"
309
601
  ? "No Gemini models listed. Check the API key."
310
- : provider === "openrouter"
311
- ? "No OpenRouter models listed. Check the API key."
312
- : "No Codex OAuth models listed.",
602
+ : provider === "gemini-wrapper"
603
+ ? "No Gemini-Wrapper models listed. Check the bridge install and cookie setup."
604
+ : provider === "openrouter"
605
+ ? "No OpenRouter models listed. Check the API key."
606
+ : provider === "nvidia"
607
+ ? "No NVIDIA models listed. Check the API key."
608
+ : "No Codex OAuth models listed.",
313
609
  detail: "Use the back key to choose another provider or retry after fixing the provider setup."
314
610
  });
315
611
  return;
@@ -349,12 +645,25 @@ export function App(props) {
349
645
  setOnboardingNotice(null);
350
646
  setOnboardingIndex(0);
351
647
  switch (onboarding.step) {
648
+ case "welcome":
649
+ setOnboarding(null);
650
+ return;
651
+ case "disclaimer":
652
+ setOnboarding({
653
+ step: "welcome"
654
+ });
655
+ return;
352
656
  case "entry":
353
657
  setOnboarding(null);
354
658
  return;
355
659
  case "host":
356
660
  case "api-key-choice":
357
661
  case "gemini-key":
662
+ case "gemini-wrapper-url":
663
+ case "gemini-wrapper-psid":
664
+ case "gemini-wrapper-psidts":
665
+ case "gemini-wrapper-model-mode":
666
+ case "gemini-wrapper-key":
358
667
  case "openrouter-key":
359
668
  case "nvidia-key":
360
669
  case "codex-login":
@@ -368,6 +677,15 @@ export function App(props) {
368
677
  hosts: hostOptions
369
678
  });
370
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;
371
689
  case "model":
372
690
  if (onboarding.provider === "ollama" && activeHost?.host.kind !== "local") {
373
691
  setOnboarding({
@@ -380,6 +698,12 @@ export function App(props) {
380
698
  openApiKeyChoice("gemini", setOnboarding, setOnboardingIndex);
381
699
  return;
382
700
  }
701
+ if (onboarding.provider === "gemini-wrapper") {
702
+ setOnboarding({
703
+ step: "gemini-wrapper-model-mode"
704
+ });
705
+ return;
706
+ }
383
707
  if (onboarding.provider === "nvidia") {
384
708
  openApiKeyChoice("nvidia", setOnboarding, setOnboardingIndex);
385
709
  return;
@@ -398,7 +722,7 @@ export function App(props) {
398
722
  step: "entry"
399
723
  });
400
724
  }
401
- }, [activeHost?.host.kind, hostOptions, onboarding]);
725
+ }, [activeHost?.host.kind, hostOptions, onboarding, openModelSelection]);
402
726
  const handleOnboardingSubmit = useCallback(async (value) => {
403
727
  if (!onboarding) {
404
728
  return;
@@ -407,6 +731,33 @@ export function App(props) {
407
731
  return;
408
732
  }
409
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
+ }
410
761
  if (onboarding.step === "entry") {
411
762
  const selection = readEntrySelection(value, onboardingIndex);
412
763
  if (!selection) {
@@ -452,7 +803,7 @@ export function App(props) {
452
803
  }
453
804
  return;
454
805
  }
455
- if (selection === "gemini" || selection === "openrouter" || selection === "nvidia") {
806
+ if (selection === "gemini" || selection === "gemini-wrapper" || selection === "openrouter" || selection === "nvidia") {
456
807
  openApiKeyChoice(selection, setOnboarding, setOnboardingIndex);
457
808
  return;
458
809
  }
@@ -537,6 +888,58 @@ export function App(props) {
537
888
  if (choice === null) {
538
889
  return;
539
890
  }
891
+ if (onboarding.provider === "gemini-wrapper") {
892
+ if (choice === 0 && onboarding.hasExistingKey) {
893
+ setOnboarding({
894
+ step: "gemini-wrapper-model-mode"
895
+ });
896
+ setOnboardingInput("");
897
+ setOnboardingIndex(0);
898
+ return;
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
+ }
540
943
  if (choice === 0 && onboarding.hasExistingKey) {
541
944
  await openModelSelection(onboarding.provider, {
542
945
  currentModel: defaultModelForProvider(onboarding.provider, settings.model)
@@ -574,6 +977,159 @@ export function App(props) {
574
977
  });
575
978
  return;
576
979
  }
980
+ if (onboarding.step === "gemini-wrapper-psid") {
981
+ const secure1psid = value.trim();
982
+ if (!secure1psid) {
983
+ setOnboardingNotice({
984
+ tone: "warning",
985
+ text: "__Secure-1PSID cannot be empty.",
986
+ detail: "Paste the cookie value manually. PatchPilot will not scan browser profiles."
987
+ });
988
+ return;
989
+ }
990
+ setOnboarding({
991
+ step: "gemini-wrapper-psidts",
992
+ secure1psid
993
+ });
994
+ setOnboardingInput("");
995
+ setOnboardingIndex(0);
996
+ return;
997
+ }
998
+ if (onboarding.step === "gemini-wrapper-psidts") {
999
+ const secure1psidts = value.trim();
1000
+ const cookiesPath = saveGeminiWrapperCookieFile({
1001
+ secure1psid: onboarding.secure1psid,
1002
+ secure1psidts
1003
+ });
1004
+ process.env.PATCHPILOT_GEMINI_WRAPPER_MODE = "python";
1005
+ process.env.PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON = cookiesPath;
1006
+ savePatchPilotEnvValues({
1007
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
1008
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
1009
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "python",
1010
+ PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON: cookiesPath
1011
+ });
1012
+ setOnboardingNotice({
1013
+ tone: "success",
1014
+ text: "Gemini-API bridge cookies saved to PatchPilot config.",
1015
+ detail: `${cookiesPath} was written with owner-only permissions. PatchPilot will run gemini_webapi through python3.`
1016
+ });
1017
+ setOnboarding({
1018
+ step: "gemini-wrapper-model-mode"
1019
+ });
1020
+ setOnboardingInput("");
1021
+ setOnboardingIndex(0);
1022
+ return;
1023
+ }
1024
+ if (onboarding.step === "gemini-wrapper-model-mode") {
1025
+ const choice = readIndexedSelection(value, onboardingIndex);
1026
+ if (choice === null) {
1027
+ return;
1028
+ }
1029
+ const curatedModel = geminiWrapperShortcutModels[choice];
1030
+ if (curatedModel) {
1031
+ setTelemetry(null);
1032
+ const shortcutDescriptors = geminiWrapperShortcutModels.map((model) => ({ id: model, displayName: model }));
1033
+ rememberModelDescriptors(shortcutDescriptors);
1034
+ setModelOptions([...geminiWrapperShortcutModels]);
1035
+ setSettings((currentSettings) => ({
1036
+ ...currentSettings,
1037
+ provider: "gemini-wrapper",
1038
+ model: curatedModel
1039
+ }));
1040
+ savePatchPilotEnvValues({
1041
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
1042
+ PATCHPILOT_MODEL: curatedModel,
1043
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "python"
1044
+ });
1045
+ setOnboarding({
1046
+ step: "preferences",
1047
+ provider: "gemini-wrapper",
1048
+ model: curatedModel,
1049
+ preferences: readOnboardingPreferences()
1050
+ });
1051
+ setOnboardingInput("");
1052
+ setOnboardingIndex(preferenceRows.length);
1053
+ return;
1054
+ }
1055
+ await openModelSelection("gemini-wrapper", {
1056
+ currentModel: settings.model
1057
+ });
1058
+ return;
1059
+ }
1060
+ if (onboarding.step === "gemini-wrapper-url") {
1061
+ const baseUrl = value.trim().replace(/\/$/, "");
1062
+ if (!baseUrl) {
1063
+ setOnboardingNotice({
1064
+ tone: "warning",
1065
+ text: "Gemini-Wrapper URL cannot be empty."
1066
+ });
1067
+ return;
1068
+ }
1069
+ try {
1070
+ new URL(baseUrl);
1071
+ }
1072
+ catch {
1073
+ setOnboardingNotice({
1074
+ tone: "warning",
1075
+ text: "Gemini-Wrapper URL must be a valid URL.",
1076
+ detail: "Example: http://localhost:8787/v1"
1077
+ });
1078
+ return;
1079
+ }
1080
+ process.env.PATCHPILOT_GEMINI_WRAPPER_BASE_URL = baseUrl;
1081
+ process.env.PATCHPILOT_GEMINI_WRAPPER_MODE = "http";
1082
+ savePatchPilotEnvValues({
1083
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
1084
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
1085
+ PATCHPILOT_GEMINI_WRAPPER_BASE_URL: baseUrl,
1086
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "http"
1087
+ });
1088
+ setOnboardingNotice({
1089
+ tone: "success",
1090
+ text: "Gemini-Wrapper URL saved to PatchPilot config.",
1091
+ detail: "PatchPilot uses only this explicit URL and never reads browser cookies."
1092
+ });
1093
+ if (geminiWrapperRequiresApiKey(baseUrl) && !readGeminiWrapperApiKey()) {
1094
+ setOnboarding({
1095
+ step: "gemini-wrapper-key",
1096
+ baseUrl
1097
+ });
1098
+ setOnboardingInput("");
1099
+ setOnboardingIndex(0);
1100
+ return;
1101
+ }
1102
+ await openModelSelection("gemini-wrapper", {
1103
+ currentModel: defaultGeminiWrapperModel
1104
+ });
1105
+ return;
1106
+ }
1107
+ if (onboarding.step === "gemini-wrapper-key") {
1108
+ const apiKey = value.trim();
1109
+ if (geminiWrapperRequiresApiKey(onboarding.baseUrl) && !apiKey) {
1110
+ setOnboardingNotice({
1111
+ tone: "warning",
1112
+ text: "Gemini-Wrapper API key cannot be empty for remote wrapper URLs."
1113
+ });
1114
+ return;
1115
+ }
1116
+ process.env.PATCHPILOT_GEMINI_WRAPPER_API_KEY = apiKey;
1117
+ savePatchPilotEnvValues({
1118
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
1119
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
1120
+ PATCHPILOT_GEMINI_WRAPPER_BASE_URL: onboarding.baseUrl,
1121
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "http",
1122
+ ...(apiKey ? { PATCHPILOT_GEMINI_WRAPPER_API_KEY: apiKey } : {})
1123
+ });
1124
+ setOnboardingNotice({
1125
+ tone: "success",
1126
+ text: apiKey ? "Gemini-Wrapper API key saved to PatchPilot config." : "Gemini-Wrapper local URL saved without an API key."
1127
+ });
1128
+ await openModelSelection("gemini-wrapper", {
1129
+ currentModel: defaultGeminiWrapperModel
1130
+ });
1131
+ return;
1132
+ }
577
1133
  if (onboarding.step === "openrouter-key") {
578
1134
  const apiKey = value.trim();
579
1135
  if (!apiKey) {
@@ -636,9 +1192,56 @@ export function App(props) {
636
1192
  });
637
1193
  return;
638
1194
  }
639
- 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);
640
1243
  const selectedModel = visibleModels[onboardingIndex] ?? selectModelFromInput(value, visibleModels, onboardingIndex, {
641
- allowManual: onboarding.provider !== "ollama"
1244
+ allowManual: onboarding.provider !== "ollama" && onboarding.provider !== "gemini-wrapper"
642
1245
  });
643
1246
  if (!selectedModel) {
644
1247
  setOnboardingNotice({
@@ -647,39 +1250,65 @@ export function App(props) {
647
1250
  });
648
1251
  return;
649
1252
  }
650
- setTelemetry(null);
651
- setSettings((currentSettings) => ({
652
- ...currentSettings,
1253
+ setOnboarding({
1254
+ step: "preferences",
653
1255
  provider: onboarding.provider,
654
- model: selectedModel
655
- }));
656
- savePatchPilotEnvValues({
657
- PATCHPILOT_PROVIDER: onboarding.provider,
658
- PATCHPILOT_MODEL: selectedModel,
659
- PATCHPILOT_ONBOARDING_COMPLETE: "1",
660
- ...(onboarding.provider === "ollama" ? { PATCHPILOT_OLLAMA_URL: activeHost?.host.url ?? settings.ollamaUrl } : {})
661
- });
662
- appendLine({
663
- tone: "success",
664
- label: "onboarding",
665
- text: `ready: ${onboarding.provider} using ${selectedModel}`
1256
+ model: selectedModel,
1257
+ preferences: readOnboardingPreferences()
666
1258
  });
667
- 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) {
668
1290
  appendLine({
669
1291
  tone: "warning",
670
- label: "openrouter",
671
- text: "Free OpenRouter models are rate-limited.",
672
- 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.`
673
1294
  });
1295
+ return;
674
1296
  }
675
- closeOnboarding();
676
- }, [activeHost?.host.url, appendLine, closeOnboarding, connectToHost, loadHostSuggestions, onboarding, onboardingBusyMessage, onboardingIndex, openModelSelection, settings.ollamaUrl]);
677
- const runTask = useCallback(async (task) => {
678
- 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
+ });
679
1303
  return;
680
1304
  }
1305
+ const runStartedAt = Date.now();
1306
+ softStopRequestedRef.current = false;
1307
+ lastEscapeStopAtRef.current = 0;
681
1308
  setInput("");
682
1309
  setTranscriptScrollOffset(0);
1310
+ setTodos([]);
1311
+ setUltramaxxRun(ultramaxx);
683
1312
  setIsRunning(true);
684
1313
  appendLine({
685
1314
  kind: "user",
@@ -687,20 +1316,100 @@ export function App(props) {
687
1316
  label: "you",
688
1317
  text: task
689
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 = [];
690
1345
  try {
691
- 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
+ });
692
1353
  if (!runnableSettings) {
693
1354
  return;
694
1355
  }
695
1356
  const abortController = new AbortController();
696
1357
  abortControllerRef.current = abortController;
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");
697
1390
  const taskRunner = new AgentRunner({
698
1391
  ...runnableSettings,
699
- mode: agentMode,
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,
1403
+ allowExternalFileAnalysis: experimentalFlags.fileAnalysis,
1404
+ allowShellMetacharacters: experimentalFlags.shellMetacharacters,
1405
+ memoryEnabled: experimentalFlags.memory,
1406
+ mode: effectiveMode,
700
1407
  signal: abortController.signal,
1408
+ shouldStopAfterStep: () => softStopRequestedRef.current,
701
1409
  sessionStore: sessionStoreRef.current,
1410
+ resumeContext: effectiveResumeContext,
702
1411
  approvalHandler: (request) => new Promise((resolve) => {
703
- if (agentMode === "plan") {
1412
+ if (effectiveMode === "plan") {
704
1413
  appendLine({
705
1414
  kind: "approval",
706
1415
  tone: "warning",
@@ -714,7 +1423,13 @@ export function App(props) {
714
1423
  resolve("deny");
715
1424
  return;
716
1425
  }
717
- if (agentMode === "bypass") {
1426
+ if (request.bypassable !== false &&
1427
+ shouldBypassApproval({
1428
+ mode: effectiveMode,
1429
+ permission: request.permission,
1430
+ permissions: runnableSettings,
1431
+ allowExternalFileAnalysis: experimentalFlags.fileAnalysis
1432
+ })) {
718
1433
  resolve("allow_session");
719
1434
  return;
720
1435
  }
@@ -735,7 +1450,15 @@ export function App(props) {
735
1450
  approvalResolverRef.current = resolve;
736
1451
  })
737
1452
  });
738
- 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)) {
739
1462
  setWorkState(event.workState);
740
1463
  if (event.type === "metrics") {
741
1464
  if (runnableSettings.provider === "ollama") {
@@ -748,31 +1471,154 @@ export function App(props) {
748
1471
  if (event.type === "subagent") {
749
1472
  setTelemetry(event.metrics);
750
1473
  setSessionTelemetry((currentSession) => addTelemetryToSession(currentSession, event.metrics));
1474
+ setToolTelemetry((currentTools) => addToolTelemetry(currentTools, "subagent", true));
751
1475
  setAdvisorNotes((currentNotes) => upsertAdvisorNote(currentNotes, {
752
1476
  role: event.role,
753
1477
  message: event.message
754
1478
  }));
755
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
+ }
756
1510
  setStatus(eventToStatus(event));
757
1511
  appendLine(eventToLine(event));
758
1512
  }
759
1513
  }
760
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);
761
1526
  appendLine({
762
1527
  kind: "error",
763
1528
  tone: "danger",
764
1529
  label: "error",
765
- text: error instanceof Error ? error.message : String(error),
1530
+ text: message,
766
1531
  workState: "error"
767
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
+ }
768
1540
  }
769
1541
  finally {
770
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);
771
1579
  setStatus("idle");
772
1580
  setWorkState("idle");
773
- 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);
1608
+ }
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
+ });
774
1620
  }
775
- }, [agentMode, appendLine, isRunning, modelOptions, settings]);
1621
+ }, [appendLine, reauthBusy, reauthPrompt, runTask]);
776
1622
  const handleSlashCommand = useCallback(async (rawCommand) => {
777
1623
  const [commandName = "", ...args] = rawCommand.slice(1).trim().split(/\s+/);
778
1624
  const command = commandName.toLowerCase();
@@ -815,17 +1661,17 @@ export function App(props) {
815
1661
  appendLine({
816
1662
  tone: "accent",
817
1663
  label: "permissions",
818
- 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"}`,
819
1665
  detail: modeDescription(agentMode)
820
1666
  });
821
1667
  return;
822
1668
  case "provider": {
823
1669
  const nextProvider = args[0]?.toLowerCase();
824
- if (nextProvider !== "ollama" && nextProvider !== "gemini" && nextProvider !== "codex" && nextProvider !== "openrouter" && nextProvider !== "nvidia") {
1670
+ if (nextProvider !== "ollama" && nextProvider !== "gemini" && nextProvider !== "gemini-wrapper" && nextProvider !== "codex" && nextProvider !== "openrouter" && nextProvider !== "nvidia") {
825
1671
  appendLine({
826
1672
  tone: "accent",
827
1673
  label: "provider",
828
- text: `current ${settings.provider}. Use /provider ollama, gemini, openrouter, nvidia, or codex.`
1674
+ text: `current ${settings.provider}. Use /provider ollama, gemini, gemini-wrapper, openrouter, nvidia, or codex.`
829
1675
  });
830
1676
  return;
831
1677
  }
@@ -848,14 +1694,14 @@ export function App(props) {
848
1694
  tone: needsApiKey(nextProvider) && !hasApiKey(nextProvider) ? "warning" : "success",
849
1695
  label: "provider",
850
1696
  text: needsApiKey(nextProvider) && !hasApiKey(nextProvider)
851
- ? `${nextProvider} needs an API key. Setup opened.`
1697
+ ? `${nextProvider} needs setup. Setup opened.`
852
1698
  : `switched to ${nextProvider} using ${nextModel}`
853
1699
  });
854
1700
  return;
855
1701
  }
856
1702
  case "onboarding":
857
1703
  setOnboarding({
858
- step: "entry"
1704
+ step: "welcome"
859
1705
  });
860
1706
  setOnboardingIndex(0);
861
1707
  setOnboardingInput("");
@@ -868,6 +1714,10 @@ export function App(props) {
868
1714
  ...currentSettings,
869
1715
  subagents: subagentsEnabled
870
1716
  }));
1717
+ setExperimentalFlags((currentFlags) => ({
1718
+ ...currentFlags,
1719
+ subagents: subagentsEnabled
1720
+ }));
871
1721
  appendLine({
872
1722
  tone: "success",
873
1723
  label: "agents",
@@ -926,14 +1776,18 @@ export function App(props) {
926
1776
  const writeEnabled = readToggle(args[0], !settings.allowWrite);
927
1777
  if (writeEnabled) {
928
1778
  requestBypassMode();
1779
+ appendLine({
1780
+ tone: "warning",
1781
+ label: "write",
1782
+ text: "write bypass needs trusted-workspace confirmation"
1783
+ });
929
1784
  return;
930
1785
  }
931
- grantedPermissionsRef.current.allowWrite = writeEnabled;
932
- applyMode("build", false);
1786
+ setExplicitPermission("write", writeEnabled);
933
1787
  appendLine({
934
1788
  tone: "success",
935
1789
  label: "write",
936
- text: "workspace writes require approval in build mode"
1790
+ text: writeEnabled ? "workspace writes are allowed; shell remains separately controlled" : "workspace writes disabled"
937
1791
  });
938
1792
  return;
939
1793
  }
@@ -941,21 +1795,27 @@ export function App(props) {
941
1795
  const shellEnabled = readToggle(args[0], !settings.allowShell);
942
1796
  if (shellEnabled) {
943
1797
  requestBypassMode();
1798
+ appendLine({
1799
+ tone: "warning",
1800
+ label: "shell",
1801
+ text: "shell bypass needs trusted-workspace confirmation"
1802
+ });
944
1803
  return;
945
1804
  }
946
- grantedPermissionsRef.current.allowShell = shellEnabled;
947
- applyMode("build", false);
1805
+ setExplicitPermission("shell", shellEnabled);
948
1806
  appendLine({
949
1807
  tone: "success",
950
1808
  label: "shell",
951
- text: "shell commands require approval in build mode"
1809
+ text: shellEnabled ? "shell commands are allowed; writes remain separately controlled" : "shell commands disabled"
952
1810
  });
953
1811
  return;
954
1812
  }
955
1813
  case "model": {
956
1814
  const requestedModel = normalizeModelAlias(args.join(" ").trim());
957
1815
  if (!requestedModel) {
958
- const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
1816
+ const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine, {
1817
+ refresh: settings.provider === "gemini-wrapper"
1818
+ });
959
1819
  if (!models) {
960
1820
  return;
961
1821
  }
@@ -968,7 +1828,9 @@ export function App(props) {
968
1828
  return;
969
1829
  }
970
1830
  {
971
- const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
1831
+ const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine, {
1832
+ refresh: settings.provider === "gemini-wrapper"
1833
+ });
972
1834
  if (!models) {
973
1835
  return;
974
1836
  }
@@ -980,7 +1842,7 @@ export function App(props) {
980
1842
  tone: "warning",
981
1843
  label: "model",
982
1844
  text: `No unique model match for "${requestedModel}".`,
983
- detail: formatModelOptions(selectableModels(requestedModel, models).slice(0, 12), settings.model)
1845
+ detail: formatModelOptions(selectableModels(requestedModel, models, formatModelLabel).slice(0, 12), settings.model)
984
1846
  });
985
1847
  return;
986
1848
  }
@@ -991,7 +1853,9 @@ export function App(props) {
991
1853
  case "models": {
992
1854
  const requestedModel = args.join(" ").trim();
993
1855
  if (requestedModel) {
994
- const installedModels = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
1856
+ const installedModels = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine, {
1857
+ refresh: settings.provider === "gemini-wrapper"
1858
+ });
995
1859
  if (!installedModels) {
996
1860
  return;
997
1861
  }
@@ -1025,7 +1889,13 @@ export function App(props) {
1025
1889
  ? "Pull a model on the selected host first."
1026
1890
  : settings.provider === "gemini"
1027
1891
  ? "Check GEMINI_API_KEY in PatchPilot config."
1028
- : "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."
1029
1899
  });
1030
1900
  return;
1031
1901
  }
@@ -1049,13 +1919,59 @@ export function App(props) {
1049
1919
  }
1050
1920
  case "status":
1051
1921
  appendLine({
1922
+ kind: "status",
1052
1923
  tone: "accent",
1053
1924
  label: "status",
1054
- text: settings.provider === "ollama"
1055
- ? `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)}`
1056
- : `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
+ })
1057
1962
  });
1058
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;
1059
1975
  case "sessions": {
1060
1976
  const sessions = await listWorkspaceSessions(settings.workspace);
1061
1977
  appendLine({
@@ -1074,11 +1990,31 @@ export function App(props) {
1074
1990
  const sessionId = args[0] ?? "";
1075
1991
  const sessions = await listWorkspaceSessions(settings.workspace);
1076
1992
  const selectedSession = sessionId ? await loadSessionSummary(settings.workspace, sessionId) : sessions[0] ?? null;
1993
+ if (selectedSession) {
1994
+ sessionStoreRef.current = new SessionStore({
1995
+ workspace: settings.workspace,
1996
+ sessionId: selectedSession.sessionId
1997
+ });
1998
+ contextStoreRef.current = new ContextStore({
1999
+ workspace: settings.workspace,
2000
+ sessionId: selectedSession.sessionId
2001
+ });
2002
+ await contextStoreRef.current.bootstrapFromSession(await sessionStoreRef.current.loadEvents());
2003
+ await sessionStoreRef.current.append({
2004
+ type: "session.resumed",
2005
+ sessionId: selectedSession.sessionId,
2006
+ workspace: settings.workspace,
2007
+ resumedAt: new Date().toISOString()
2008
+ });
2009
+ setResumeContext(await buildSessionResumeContext(settings.workspace, selectedSession.sessionId));
2010
+ setSessionTelemetry(emptySessionTelemetry());
2011
+ setTelemetry(null);
2012
+ }
1077
2013
  appendLine({
1078
2014
  kind: "status",
1079
2015
  tone: selectedSession ? "accent" : "warning",
1080
2016
  label: "resume",
1081
- text: selectedSession ? `Loaded session ${selectedSession.sessionId}` : "No session available to resume.",
2017
+ text: selectedSession ? `Loaded session ${selectedSession.sessionId} and will inject its summary into the next run.` : "No session available to resume.",
1082
2018
  detail: selectedSession
1083
2019
  ? `workspace ${selectedSession.workspace}\nupdated ${selectedSession.updatedAt}\nmodel ${selectedSession.provider ?? "-"} ${selectedSession.model ?? "-"}\nlast task ${selectedSession.lastTask ?? "-"}`
1084
2020
  : "Run /sessions after at least one PatchPilot run."
@@ -1179,45 +2115,208 @@ export function App(props) {
1179
2115
  if (ejectedModels.length === 0) {
1180
2116
  appendLine({
1181
2117
  tone: "warning",
1182
- label: "eject",
1183
- text: "No Ollama model was ejected."
2118
+ label: "eject",
2119
+ text: "No Ollama model was ejected."
2120
+ });
2121
+ return;
2122
+ }
2123
+ appendLine({
2124
+ tone: "success",
2125
+ label: "eject",
2126
+ text: `ejected ${ejectedModels.join(", ")}`
2127
+ });
2128
+ if (activeHost) {
2129
+ const details = await readOllamaHostDetails(activeHost.host, true).catch(() => activeHost);
2130
+ setActiveHost(details);
2131
+ }
2132
+ return;
2133
+ }
2134
+ case "doctor": {
2135
+ const shouldFix = args.some((arg) => arg.toLowerCase() === "fix" || arg.toLowerCase() === "--fix");
2136
+ appendLine({
2137
+ tone: "muted",
2138
+ label: "doctor",
2139
+ text: shouldFix ? "checking local requirements and applying safe fixes..." : "checking local requirements..."
2140
+ });
2141
+ const doctorResults = await runDoctor(settings.provider, settings.ollamaUrl, settings.model, {
2142
+ fix: shouldFix
2143
+ });
2144
+ for (const result of doctorResults) {
2145
+ appendLine({
2146
+ tone: result.ok ? "success" : "danger",
2147
+ label: result.name,
2148
+ text: result.action && result.action !== "check" ? `${result.action}: ${result.details}` : result.details
2149
+ });
2150
+ }
2151
+ if (!shouldFix && doctorResults.some((result) => result.action === "skipped")) {
2152
+ appendLine({
2153
+ tone: "accent",
2154
+ label: "doctor",
2155
+ text: "Some safe fixes are available. Run /doctor fix to approve them."
2156
+ });
2157
+ }
2158
+ return;
2159
+ }
2160
+ case "cleanup": {
2161
+ const target = readCleanupTarget(args[0]);
2162
+ if (!target) {
2163
+ appendLine({
2164
+ tone: "accent",
2165
+ label: "cleanup",
2166
+ text: "Choose what to clean: /cleanup cache, /cleanup sessions, /cleanup temp, or /cleanup all.",
2167
+ detail: "Sessions deletes saved workspace transcripts. Cache/temp are safe first choices."
2168
+ });
2169
+ return;
2170
+ }
2171
+ const removed = await cleanupPatchPilot(settings.workspace, target);
2172
+ if (target === "sessions" || target === "all") {
2173
+ sessionStoreRef.current = new SessionStore({
2174
+ workspace: settings.workspace
2175
+ });
2176
+ await sessionStoreRef.current.create();
2177
+ setResumeContext("");
2178
+ setLines([]);
2179
+ setAdvisorNotes([]);
2180
+ setTelemetry(null);
2181
+ setSessionTelemetry(emptySessionTelemetry());
2182
+ setToolTelemetry(emptyToolTelemetry());
2183
+ }
2184
+ appendLine({
2185
+ tone: "success",
2186
+ label: "cleanup",
2187
+ text: `cleaned ${removed.join(", ") || target}`
2188
+ });
2189
+ return;
2190
+ }
2191
+ case "experimental": {
2192
+ const requestedFlag = args[0]?.toLowerCase();
2193
+ const requestedValue = args[1]?.toLowerCase();
2194
+ if (!requestedFlag) {
2195
+ setExperimentalOpen(true);
2196
+ setExperimentalIndex(0);
2197
+ setInput("");
2198
+ return;
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."
1184
2207
  });
1185
2208
  return;
1186
2209
  }
2210
+ const enabled = readToggle(requestedValue, true);
2211
+ if (normalizedFlag === "subagents") {
2212
+ setSettings((currentSettings) => ({
2213
+ ...currentSettings,
2214
+ subagents: enabled
2215
+ }));
2216
+ }
2217
+ savePatchPilotEnvValues({
2218
+ [experimentalFlagEnvName(normalizedFlag)]: enabled ? "1" : "0"
2219
+ });
2220
+ setExperimentalFlags((currentFlags) => ({
2221
+ ...currentFlags,
2222
+ ...(normalizedFlag === "fileAnalysis"
2223
+ ? { fileAnalysis: enabled }
2224
+ : normalizedFlag === "memory"
2225
+ ? { memory: enabled }
2226
+ : normalizedFlag === "subagents"
2227
+ ? { subagents: enabled }
2228
+ : { shellMetacharacters: enabled })
2229
+ }));
1187
2230
  appendLine({
1188
2231
  tone: "success",
1189
- label: "eject",
1190
- text: `ejected ${ejectedModels.join(", ")}`
2232
+ label: "experimental",
2233
+ text: `${experimentalFlagCommandName(normalizedFlag)} ${enabled ? "enabled" : "disabled"}`
1191
2234
  });
1192
- if (activeHost) {
1193
- const details = await readOllamaHostDetails(activeHost.host, true).catch(() => activeHost);
1194
- setActiveHost(details);
1195
- }
1196
2235
  return;
1197
2236
  }
1198
- case "doctor": {
1199
- appendLine({
1200
- tone: "muted",
1201
- label: "doctor",
1202
- text: "checking local requirements..."
1203
- });
1204
- const doctorResults = await runDoctor(settings.provider, settings.ollamaUrl, settings.model);
1205
- for (const result of doctorResults) {
2237
+ case "theme": {
2238
+ const requested = args[0]?.toLowerCase();
2239
+ if (requested === "new" || requested === "legacy") {
2240
+ setUiTheme(requested);
2241
+ savePatchPilotEnvValues({ PATCHPILOT_UI_THEME: requested });
1206
2242
  appendLine({
1207
- tone: result.ok ? "success" : "danger",
1208
- label: result.name,
1209
- text: result.details
2243
+ tone: "success",
2244
+ label: "theme",
2245
+ text: `switched to the ${requested} UI`
1210
2246
  });
2247
+ return;
1211
2248
  }
2249
+ setThemePickerOpen(true);
2250
+ setThemePickerIndex(themeOptions.findIndex((option) => option.value === uiTheme));
2251
+ setInput("");
2252
+ return;
2253
+ }
2254
+ case "init": {
2255
+ await ensurePatchPilotGitignore(settings.workspace);
2256
+ appendLine({
2257
+ tone: "accent",
2258
+ label: "init",
2259
+ text: "starting model-driven project init",
2260
+ detail: "PatchPilot will inspect the repository and create or update PATCHPILOT.md with approval-gated writes."
2261
+ });
2262
+ await runTask(patchPilotInitPrompt, {
2263
+ mode: "build"
2264
+ });
1212
2265
  return;
1213
2266
  }
1214
2267
  case "clear":
1215
2268
  setLines([]);
1216
2269
  setAdvisorNotes([]);
2270
+ setTodos([]);
2271
+ setTelemetry(null);
2272
+ setResumeContext("");
2273
+ setSessionTelemetry(emptySessionTelemetry());
2274
+ setToolTelemetry(emptyToolTelemetry());
2275
+ setTranscriptScrollOffset(0);
2276
+ setSessionScrollOffset(0);
2277
+ conversationTurnsRef.current = [];
2278
+ artifactsRef.current = [];
2279
+ pendingAttachmentsRef.current = [];
2280
+ setArtifacts([]);
2281
+ return;
2282
+ case "new":
2283
+ if (isRunning) {
2284
+ appendLine({
2285
+ tone: "warning",
2286
+ label: "new",
2287
+ text: "Cannot start a new session while a run is active.",
2288
+ detail: "Stop the current run first, then use /new again."
2289
+ });
2290
+ return;
2291
+ }
2292
+ sessionStoreRef.current = new SessionStore({
2293
+ workspace: settings.workspace
2294
+ });
2295
+ contextStoreRef.current = new ContextStore({
2296
+ workspace: settings.workspace,
2297
+ sessionId: sessionStoreRef.current.sessionId
2298
+ });
2299
+ await sessionStoreRef.current.create();
2300
+ setLines([]);
2301
+ setAdvisorNotes([]);
2302
+ setTodos([]);
1217
2303
  setTelemetry(null);
1218
2304
  setSessionTelemetry(emptySessionTelemetry());
2305
+ setToolTelemetry(emptyToolTelemetry());
2306
+ setPendingApproval(null);
2307
+ approvalResolverRef.current = null;
2308
+ setBypassConfirmation(false);
2309
+ setInput("");
1219
2310
  setTranscriptScrollOffset(0);
1220
2311
  setSessionScrollOffset(0);
2312
+ setStatus("idle");
2313
+ setWorkState("idle");
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.
1221
2320
  return;
1222
2321
  case "exit":
1223
2322
  case "quit":
@@ -1245,6 +2344,7 @@ export function App(props) {
1245
2344
  loadHostSuggestions,
1246
2345
  loadProviderModels,
1247
2346
  modelOptions,
2347
+ isRunning,
1248
2348
  resolveApproval,
1249
2349
  sessionTelemetry,
1250
2350
  settings,
@@ -1286,6 +2386,39 @@ export function App(props) {
1286
2386
  useEffect(() => {
1287
2387
  void sessionStoreRef.current.create();
1288
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]);
2418
+ useEffect(() => {
2419
+ runtimeStateRef.current.isRunning = isRunning;
2420
+ runtimeStateRef.current.hasPendingApproval = Boolean(pendingApproval || bypassConfirmation || updatePrompt || updateBusy);
2421
+ }, [bypassConfirmation, isRunning, pendingApproval, updateBusy, updatePrompt]);
1289
2422
  useEffect(() => {
1290
2423
  if (!props.initialTask || didRunInitialTask.current || onboarding || process.env.PATCHPILOT_ONBOARDING_COMPLETE !== "1") {
1291
2424
  return;
@@ -1302,7 +2435,7 @@ export function App(props) {
1302
2435
  }
1303
2436
  didOpenDefaultOnboarding.current = true;
1304
2437
  setOnboarding({
1305
- step: "entry"
2438
+ step: "welcome"
1306
2439
  });
1307
2440
  setOnboardingIndex(0);
1308
2441
  setOnboardingInput("");
@@ -1374,10 +2507,81 @@ export function App(props) {
1374
2507
  }
1375
2508
  }, [hostOptions.length, input, isLoadingHosts, isLoadingModels, isRunning, loadHostSuggestions, loadProviderModels, modelOptions.length, onboarding, settings.provider]);
1376
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
+ }
2539
+ if (experimentalOpen) {
2540
+ if (key.upArrow) {
2541
+ setExperimentalIndex((currentIndex) => (currentIndex - 1 + experimentalFlagCount()) % experimentalFlagCount());
2542
+ return;
2543
+ }
2544
+ if (key.downArrow) {
2545
+ setExperimentalIndex((currentIndex) => (currentIndex + 1) % experimentalFlagCount());
2546
+ return;
2547
+ }
2548
+ if (inputValue === " ") {
2549
+ const flag = experimentalFlagAt(experimentalIndex);
2550
+ setExperimentalFlags((currentFlags) => {
2551
+ const nextFlags = {
2552
+ ...currentFlags,
2553
+ [flag]: !currentFlags[flag]
2554
+ };
2555
+ if (flag === "subagents") {
2556
+ setSettings((currentSettings) => ({
2557
+ ...currentSettings,
2558
+ subagents: nextFlags.subagents
2559
+ }));
2560
+ }
2561
+ savePatchPilotEnvValues({
2562
+ PATCHPILOT_EXPERIMENTAL_FILE_ANALYSIS: nextFlags.fileAnalysis ? "1" : "0",
2563
+ PATCHPILOT_EXPERIMENTAL_MEMORY: nextFlags.memory ? "1" : "0",
2564
+ PATCHPILOT_EXPERIMENTAL_SUBAGENTS: nextFlags.subagents ? "1" : "0",
2565
+ PATCHPILOT_EXPERIMENTAL_SHELL_METACHARACTERS: nextFlags.shellMetacharacters ? "1" : "0"
2566
+ });
2567
+ return nextFlags;
2568
+ });
2569
+ return;
2570
+ }
2571
+ if (key.return || key.escape || key.leftArrow) {
2572
+ setExperimentalOpen(false);
2573
+ setInput("");
2574
+ return;
2575
+ }
2576
+ return;
2577
+ }
1377
2578
  if (bypassConfirmation) {
1378
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.
1379
2582
  if (key.tab) {
1380
- cancelBypassMode();
2583
+ setInput("");
2584
+ applyMode("plan");
1381
2585
  return;
1382
2586
  }
1383
2587
  if (normalizedInput === "y") {
@@ -1389,6 +2593,36 @@ export function App(props) {
1389
2593
  return;
1390
2594
  }
1391
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
+ }
1392
2626
  if (pendingApproval) {
1393
2627
  const normalizedInput = inputValue.toLowerCase();
1394
2628
  if (normalizedInput === "y") {
@@ -1405,25 +2639,77 @@ export function App(props) {
1405
2639
  }
1406
2640
  }
1407
2641
  if (isRunning && key.escape) {
1408
- 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;
1409
2657
  appendLine({
1410
2658
  kind: "status",
1411
2659
  tone: "warning",
1412
2660
  label: "stop",
1413
- text: "Stopping current task..."
2661
+ text: "Will stop after the current step. Press esc again quickly to force stop now."
1414
2662
  });
1415
- 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();
1416
2671
  return;
1417
2672
  }
1418
2673
  if (onboarding) {
1419
- if (key.escape || key.leftArrow) {
2674
+ if (key.escape) {
1420
2675
  goBackOnboarding();
1421
2676
  return;
1422
2677
  }
1423
2678
  if (onboardingBusyMessage) {
1424
2679
  return;
1425
2680
  }
1426
- 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);
1427
2713
  if (optionCount > 0 && key.upArrow) {
1428
2714
  setOnboardingIndex((currentIndex) => (currentIndex - 1 + optionCount) % optionCount);
1429
2715
  return;
@@ -1432,7 +2718,7 @@ export function App(props) {
1432
2718
  setOnboardingIndex((currentIndex) => (currentIndex + 1) % optionCount);
1433
2719
  return;
1434
2720
  }
1435
- if (optionCount > 0 && key.return && onboarding.step !== "model") {
2721
+ if (optionCount > 0 && key.return) {
1436
2722
  void handleOnboardingSubmit(String(onboardingIndex + 1));
1437
2723
  return;
1438
2724
  }
@@ -1457,6 +2743,16 @@ export function App(props) {
1457
2743
  }
1458
2744
  }
1459
2745
  const canUsePanelKeys = input.length === 0 || isRunning;
2746
+ if (canUsePanelKeys && key.upArrow && paletteItems.length === 0) {
2747
+ const setOffset = activeScrollPane === "session" ? setSessionScrollOffset : setTranscriptScrollOffset;
2748
+ setOffset((currentOffset) => currentOffset + 1);
2749
+ return;
2750
+ }
2751
+ if (canUsePanelKeys && key.downArrow && paletteItems.length === 0) {
2752
+ const setOffset = activeScrollPane === "session" ? setSessionScrollOffset : setTranscriptScrollOffset;
2753
+ setOffset((currentOffset) => Math.max(0, currentOffset - 1));
2754
+ return;
2755
+ }
1460
2756
  if (canUsePanelKeys && key.leftArrow) {
1461
2757
  setActiveScrollPane("session");
1462
2758
  return;
@@ -1485,24 +2781,46 @@ export function App(props) {
1485
2781
  toggleMode();
1486
2782
  return;
1487
2783
  }
1488
- if (!isRunning && input.length === 0 && inputValue === "q") {
1489
- void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(exit);
1490
- }
1491
2784
  });
1492
2785
  useEffect(() => {
2786
+ const gracefulStopOrExit = () => {
2787
+ const now = Date.now();
2788
+ const state = runtimeStateRef.current;
2789
+ if ((state.isRunning || state.hasPendingApproval) && now - state.lastSigintAt > 1500) {
2790
+ state.lastSigintAt = now;
2791
+ abortControllerRef.current?.abort();
2792
+ approvalResolverRef.current?.("deny");
2793
+ approvalResolverRef.current = null;
2794
+ setPendingApproval(null);
2795
+ setBypassConfirmation(false);
2796
+ setInput("");
2797
+ setStatus("stopping");
2798
+ setWorkState("idle");
2799
+ appendLine({
2800
+ kind: "status",
2801
+ tone: "warning",
2802
+ label: "stop",
2803
+ text: "Stopping current task. Press Ctrl-C again to quit."
2804
+ });
2805
+ return;
2806
+ }
2807
+ void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(() => {
2808
+ process.exit(0);
2809
+ });
2810
+ };
1493
2811
  const unloadAndExit = () => {
1494
2812
  void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(() => {
1495
2813
  process.exit(0);
1496
2814
  });
1497
2815
  };
1498
- process.once("SIGINT", unloadAndExit);
1499
- process.once("SIGTERM", unloadAndExit);
2816
+ process.on("SIGINT", gracefulStopOrExit);
2817
+ process.on("SIGTERM", unloadAndExit);
1500
2818
  return () => {
1501
- process.off("SIGINT", unloadAndExit);
2819
+ process.off("SIGINT", gracefulStopOrExit);
1502
2820
  process.off("SIGTERM", unloadAndExit);
1503
2821
  void unloadUsedOllamaModels(usedOllamaModelsRef.current);
1504
2822
  };
1505
- }, []);
2823
+ }, [appendLine]);
1506
2824
  useEffect(() => {
1507
2825
  let previousSnapshot = readSystemStats().snapshot;
1508
2826
  const timer = setInterval(() => {
@@ -1531,29 +2849,71 @@ export function App(props) {
1531
2849
  clearInterval(timer);
1532
2850
  };
1533
2851
  }, []);
1534
- 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 }), 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 })] })] }))] }));
1535
2862
  }
1536
2863
  async function loadAvailableModels(provider, ollamaUrl, setModelOptions, refresh = false) {
1537
- const cacheKey = `${provider}:${provider === "ollama" ? ollamaUrl : "default"}`;
2864
+ const cacheKey = modelCacheKey(provider, ollamaUrl);
1538
2865
  const cachedModels = modelCache.get(cacheKey);
1539
2866
  if (!refresh && cachedModels && cachedModels.expiresAt > Date.now()) {
2867
+ rememberModelDescriptors(cachedModels.descriptors);
1540
2868
  setModelOptions(cachedModels.models);
1541
2869
  return cachedModels.models;
1542
2870
  }
1543
- const models = await createModelClient({
2871
+ const client = createModelClient({
1544
2872
  provider,
1545
2873
  ollamaUrl
1546
- }).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);
1547
2880
  modelCache.set(cacheKey, {
1548
2881
  models,
2882
+ descriptors,
1549
2883
  expiresAt: Date.now() + modelCacheTtlMs
1550
2884
  });
1551
2885
  setModelOptions(models);
1552
2886
  return models;
1553
2887
  }
1554
- async function loadKnownOrAvailableModels(provider, ollamaUrl, modelOptions, setModelOptions, appendLine) {
2888
+ function modelCacheKey(provider, ollamaUrl) {
2889
+ if (provider === "ollama") {
2890
+ return `${provider}:${ollamaUrl}`;
2891
+ }
2892
+ if (provider === "gemini-wrapper") {
2893
+ return [
2894
+ provider,
2895
+ readGeminiWrapperMode(),
2896
+ readGeminiWrapperBaseUrl() || "python",
2897
+ readGeminiWrapperPythonCommand(),
2898
+ readGeminiWrapperCookiesJson()
2899
+ ].join(":");
2900
+ }
2901
+ return `${provider}:default`;
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
+ }
2914
+ async function loadKnownOrAvailableModels(provider, ollamaUrl, modelOptions, setModelOptions, appendLine, options = {}) {
1555
2915
  try {
1556
- return modelOptions.length > 0 ? modelOptions : await loadAvailableModels(provider, ollamaUrl, setModelOptions);
2916
+ return !options.refresh && modelOptions.length > 0 ? modelOptions : await loadAvailableModels(provider, ollamaUrl, setModelOptions, options.refresh);
1557
2917
  }
1558
2918
  catch (error) {
1559
2919
  appendLine({
@@ -1577,7 +2937,7 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
1577
2937
  if (!installedModels) {
1578
2938
  return;
1579
2939
  }
1580
- if (!installedModels.includes(nextModel) && !(provider !== "ollama" && isPlausibleCloudModelId(nextModel))) {
2940
+ if (!installedModels.includes(nextModel) && !canUseUnverifiedCloudModel(provider, nextModel)) {
1581
2941
  appendLine({
1582
2942
  tone: "warning",
1583
2943
  label: "model",
@@ -1588,9 +2948,11 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
1588
2948
  ? "No models installed on the selected host."
1589
2949
  : provider === "gemini"
1590
2950
  ? "Check GEMINI_API_KEY in PatchPilot config."
1591
- : provider === "openrouter"
1592
- ? "Check OPENROUTER_API_KEY in PatchPilot config."
1593
- : "Run codex login first."
2951
+ : provider === "gemini-wrapper"
2952
+ ? "Check PATCHPILOT_GEMINI_WRAPPER_BASE_URL in PatchPilot config."
2953
+ : provider === "openrouter"
2954
+ ? "Check OPENROUTER_API_KEY in PatchPilot config."
2955
+ : "Run codex login first."
1594
2956
  });
1595
2957
  return;
1596
2958
  }
@@ -1606,7 +2968,7 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
1606
2968
  appendLine({
1607
2969
  tone: installedModels.includes(nextModel) ? "success" : "warning",
1608
2970
  label: "model",
1609
- 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}`,
1610
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."
1611
2973
  });
1612
2974
  if (provider === "openrouter" && isOpenRouterFreeModel(nextModel)) {
@@ -1618,7 +2980,7 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
1618
2980
  });
1619
2981
  }
1620
2982
  }
1621
- async function resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions) {
2983
+ async function resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions, onProviderError) {
1622
2984
  let installedModels;
1623
2985
  try {
1624
2986
  installedModels = modelOptions.includes(settings.model)
@@ -1626,14 +2988,16 @@ async function resolveRunnableSettings(settings, modelOptions, appendLine, setMo
1626
2988
  : await loadAvailableModels(settings.provider, settings.ollamaUrl, setModelOptions);
1627
2989
  }
1628
2990
  catch (error) {
2991
+ const message = error instanceof Error ? error.message : String(error);
1629
2992
  appendLine({
1630
2993
  tone: "danger",
1631
2994
  label: settings.provider,
1632
- text: error instanceof Error ? error.message : String(error)
2995
+ text: message
1633
2996
  });
2997
+ onProviderError?.(message);
1634
2998
  return null;
1635
2999
  }
1636
- if (installedModels.includes(settings.model) || (settings.provider !== "ollama" && isPlausibleCloudModelId(settings.model))) {
3000
+ if (installedModels.includes(settings.model) || canUseUnverifiedCloudModel(settings.provider, settings.model)) {
1637
3001
  if (!installedModels.includes(settings.model)) {
1638
3002
  appendLine({
1639
3003
  tone: "warning",
@@ -1654,9 +3018,11 @@ async function resolveRunnableSettings(settings, modelOptions, appendLine, setMo
1654
3018
  ? "No models installed on the selected host."
1655
3019
  : settings.provider === "gemini"
1656
3020
  ? "No Gemini models listed. Check GEMINI_API_KEY in PatchPilot config."
1657
- : settings.provider === "openrouter"
1658
- ? "No OpenRouter models listed. Check OPENROUTER_API_KEY in PatchPilot config."
1659
- : "Codex OAuth is not ready. Run codex login."
3021
+ : settings.provider === "gemini-wrapper"
3022
+ ? "No Gemini-Wrapper models listed. Check gemini_webapi install and PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON in PatchPilot config."
3023
+ : settings.provider === "openrouter"
3024
+ ? "No OpenRouter models listed. Check OPENROUTER_API_KEY in PatchPilot config."
3025
+ : "Codex OAuth is not ready. Run codex login."
1660
3026
  });
1661
3027
  return null;
1662
3028
  }
@@ -1666,7 +3032,6 @@ function buildCommandSuggestionItems(options) {
1666
3032
  }
1667
3033
  const trimmedInput = options.input.trimStart().toLowerCase();
1668
3034
  const items = filterSlashCommands(options.input)
1669
- .slice(0, 6)
1670
3035
  .map((command) => {
1671
3036
  const baseCommand = `/${command.name}`;
1672
3037
  return {
@@ -1701,7 +3066,7 @@ function buildCommandSuggestionItems(options) {
1701
3066
  })));
1702
3067
  }
1703
3068
  }
1704
- if (trimmedInput === "/models" || trimmedInput.startsWith("/models") || trimmedInput === "/model" || trimmedInput.startsWith("/model")) {
3069
+ if (trimmedInput.startsWith("/models ") || trimmedInput.startsWith("/model ")) {
1705
3070
  const modelQuery = trimmedInput.replace(/^\/models?/, "").trim();
1706
3071
  if (options.isLoadingModels) {
1707
3072
  items.unshift({
@@ -1714,28 +3079,39 @@ function buildCommandSuggestionItems(options) {
1714
3079
  });
1715
3080
  }
1716
3081
  else {
1717
- items.unshift(...selectableModels(modelQuery, options.modelOptions).slice(0, 8).map((model) => ({
3082
+ items.unshift(...selectableModels(modelQuery, options.modelOptions, formatModelLabel).slice(0, 8).map((model) => ({
1718
3083
  key: `model-${model}`,
1719
3084
  category: "model",
1720
- label: model,
1721
- detail: `${model === options.currentModel ? "current" : "available"} ${options.provider}`,
3085
+ label: formatModelLabel(model),
3086
+ detail: `${model === options.currentModel ? "current" : "available"} ${options.provider}${formatModelDescription(model)}`,
1722
3087
  command: `/model ${model}`,
1723
3088
  execute: true
1724
3089
  })));
1725
3090
  }
1726
3091
  }
1727
- return items.slice(0, 8);
3092
+ return items;
1728
3093
  }
1729
3094
  function getOnboardingOptionCount(onboarding) {
1730
3095
  switch (onboarding.step) {
3096
+ case "welcome":
3097
+ return 1;
3098
+ case "disclaimer":
3099
+ return 0;
1731
3100
  case "entry":
1732
- return 6;
3101
+ return 7;
1733
3102
  case "host":
1734
3103
  return onboarding.hosts.length + 1;
1735
3104
  case "api-key-choice":
3105
+ if (onboarding.provider === "gemini-wrapper") {
3106
+ return onboarding.hasExistingKey ? 3 : 2;
3107
+ }
1736
3108
  return onboarding.hasExistingKey ? 2 : 1;
3109
+ case "gemini-wrapper-model-mode":
3110
+ return geminiWrapperShortcutModels.length + 1;
1737
3111
  case "model":
1738
3112
  return onboarding.models.length;
3113
+ case "preferences":
3114
+ return preferenceRows.length + 1;
1739
3115
  default:
1740
3116
  return 0;
1741
3117
  }
@@ -1743,7 +3119,7 @@ function getOnboardingOptionCount(onboarding) {
1743
3119
  function readEntrySelection(value, selectedIndex) {
1744
3120
  const normalizedValue = value.trim().toLowerCase();
1745
3121
  if (!normalizedValue) {
1746
- return ["local", "host", "gemini", "openrouter", "nvidia", "codex"][selectedIndex];
3122
+ return ["local", "host", "gemini", "gemini-wrapper", "openrouter", "nvidia", "codex"][selectedIndex];
1747
3123
  }
1748
3124
  if (normalizedValue === "1" || normalizedValue === "local" || normalizedValue === "this device") {
1749
3125
  return "local";
@@ -1754,17 +3130,64 @@ function readEntrySelection(value, selectedIndex) {
1754
3130
  if (normalizedValue === "3" || normalizedValue === "gemini" || normalizedValue === "google") {
1755
3131
  return "gemini";
1756
3132
  }
1757
- if (normalizedValue === "4" || normalizedValue === "openrouter" || normalizedValue === "open-router") {
3133
+ if (normalizedValue === "4" || normalizedValue === "gemini-wrapper" || normalizedValue === "geminiwrapper" || normalizedValue === "google-wrapper") {
3134
+ return "gemini-wrapper";
3135
+ }
3136
+ if (normalizedValue === "5" || normalizedValue === "openrouter" || normalizedValue === "open-router") {
1758
3137
  return "openrouter";
1759
3138
  }
1760
- if (normalizedValue === "5" || normalizedValue === "nvidia" || normalizedValue === "nim") {
3139
+ if (normalizedValue === "6" || normalizedValue === "nvidia" || normalizedValue === "nim") {
1761
3140
  return "nvidia";
1762
3141
  }
1763
- if (normalizedValue === "6" || normalizedValue === "codex") {
3142
+ if (normalizedValue === "7" || normalizedValue === "codex") {
1764
3143
  return "codex";
1765
3144
  }
1766
3145
  return null;
1767
3146
  }
3147
+ function readBooleanEnv(value, fallback) {
3148
+ if (!value) {
3149
+ return fallback;
3150
+ }
3151
+ const normalizedValue = value.trim().toLowerCase();
3152
+ if (["1", "true", "yes", "on", "enabled"].includes(normalizedValue)) {
3153
+ return true;
3154
+ }
3155
+ if (["0", "false", "no", "off", "disabled"].includes(normalizedValue)) {
3156
+ return false;
3157
+ }
3158
+ return fallback;
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
+ }
1768
3191
  function readIndexedSelection(value, selectedIndex) {
1769
3192
  const normalizedValue = value.trim();
1770
3193
  if (!normalizedValue) {
@@ -1788,7 +3211,11 @@ function selectModelFromInput(value, models, selectedIndex, options = {}) {
1788
3211
  if (models.includes(normalizedValue)) {
1789
3212
  return normalizedValue;
1790
3213
  }
1791
- 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);
1792
3219
  if (matches.length === 1) {
1793
3220
  return matches[0] ?? null;
1794
3221
  }
@@ -1797,6 +3224,9 @@ function selectModelFromInput(value, models, selectedIndex, options = {}) {
1797
3224
  function isPlausibleCloudModelId(value) {
1798
3225
  return /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(value) && value.length >= 3;
1799
3226
  }
3227
+ function canUseUnverifiedCloudModel(provider, model) {
3228
+ return provider !== "ollama" && isPlausibleCloudModelId(model);
3229
+ }
1800
3230
  function defaultModelForProvider(provider, currentModel) {
1801
3231
  if (provider === "nvidia") {
1802
3232
  return currentModel.includes("/") && !currentModel.startsWith("openrouter/") ? currentModel : defaultNvidiaModel;
@@ -1804,6 +3234,9 @@ function defaultModelForProvider(provider, currentModel) {
1804
3234
  if (provider === "openrouter") {
1805
3235
  return currentModel.includes("/") ? currentModel : defaultOpenRouterModel;
1806
3236
  }
3237
+ if (provider === "gemini-wrapper") {
3238
+ return geminiWrapperCuratedModels.includes(currentModel) || currentModel.startsWith("gemini-") || modelDescriptorIndex.has(currentModel) ? currentModel : defaultGeminiWrapperModel;
3239
+ }
1807
3240
  if (provider === "gemini") {
1808
3241
  return currentModel.startsWith("gemini-") ? currentModel : defaultGeminiModel;
1809
3242
  }
@@ -1821,12 +3254,19 @@ function openApiKeyChoice(provider, setOnboarding, setOnboardingIndex) {
1821
3254
  setOnboardingIndex(0);
1822
3255
  }
1823
3256
  function needsApiKey(provider) {
1824
- return provider === "gemini" || provider === "openrouter" || provider === "nvidia";
3257
+ return provider === "gemini" || provider === "gemini-wrapper" || provider === "openrouter" || provider === "nvidia";
1825
3258
  }
1826
3259
  function hasApiKey(provider) {
1827
3260
  if (provider === "gemini") {
1828
3261
  return Boolean(readGeminiApiKey());
1829
3262
  }
3263
+ if (provider === "gemini-wrapper") {
3264
+ const baseUrl = readGeminiWrapperBaseUrl();
3265
+ if (readGeminiWrapperMode() === "http") {
3266
+ return !geminiWrapperRequiresApiKey(baseUrl) || Boolean(readGeminiWrapperApiKey());
3267
+ }
3268
+ return Boolean(readGeminiWrapperCookiesJson());
3269
+ }
1830
3270
  if (provider === "openrouter") {
1831
3271
  return Boolean(readOpenRouterApiKey());
1832
3272
  }
@@ -1874,6 +3314,219 @@ function upsertAdvisorNote(notes, nextNote) {
1874
3314
  const nextNotes = notes.filter((note) => note.role !== nextNote.role);
1875
3315
  return [...nextNotes, nextNote].slice(-2);
1876
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
+ }
1877
3530
  function eventToLine(event) {
1878
3531
  switch (event.type) {
1879
3532
  case "status":
@@ -1907,12 +3560,21 @@ function eventToLine(event) {
1907
3560
  tone: event.ok ? "success" : "warning",
1908
3561
  label: event.name,
1909
3562
  text: event.summary,
3563
+ detail: event.ok ? previewToolContent(event.content) : event.content,
1910
3564
  workState: event.workState,
1911
3565
  tool: event.name,
1912
3566
  toolCallId: event.toolCallId,
1913
3567
  category: event.category,
1914
3568
  preview: event.preview
1915
3569
  };
3570
+ case "todo":
3571
+ return {
3572
+ kind: "status",
3573
+ tone: "muted",
3574
+ label: "todo",
3575
+ text: event.summary,
3576
+ workState: event.workState
3577
+ };
1916
3578
  case "approval":
1917
3579
  return {
1918
3580
  kind: "approval",
@@ -1950,6 +3612,16 @@ function eventToLine(event) {
1950
3612
  };
1951
3613
  }
1952
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
+ }
1953
3625
  function eventToStatus(event) {
1954
3626
  if (event.type === "status") {
1955
3627
  return event.message;
@@ -1957,6 +3629,9 @@ function eventToStatus(event) {
1957
3629
  if (event.type === "tool") {
1958
3630
  return `${event.name}: ${event.summary}`;
1959
3631
  }
3632
+ if (event.type === "todo") {
3633
+ return event.summary;
3634
+ }
1960
3635
  if (event.type === "subagent") {
1961
3636
  return `${event.role} subagent`;
1962
3637
  }
@@ -1965,6 +3640,19 @@ function eventToStatus(event) {
1965
3640
  }
1966
3641
  return event.type;
1967
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
+ }
1968
3656
  function defaultLogKind(line) {
1969
3657
  if (line.kind) {
1970
3658
  return line.kind;
@@ -1992,8 +3680,21 @@ function formatModelOptions(models, currentModel) {
1992
3680
  return models
1993
3681
  .map((model, index) => {
1994
3682
  const currentMarker = model === currentModel ? " current" : "";
1995
- return `${index + 1}. ${model}${currentMarker}`;
3683
+ return `${index + 1}. ${formatModelLabel(model)}${formatModelDescription(model)}${currentMarker}`;
1996
3684
  })
1997
3685
  .join("\n");
1998
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
+ }
1999
3700
  //# sourceMappingURL=App.js.map