@jx-grxf/patchpilot 0.3.1-beta → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.env.example +17 -1
  2. package/README.md +80 -22
  3. package/SECURITY.md +10 -2
  4. package/dist/cli.js +59 -13
  5. package/dist/cli.js.map +1 -1
  6. package/dist/core/agent.d.ts +3 -0
  7. package/dist/core/agent.js +56 -12
  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/doctor.d.ts +4 -1
  13. package/dist/core/doctor.js +119 -1
  14. package/dist/core/doctor.js.map +1 -1
  15. package/dist/core/gemini.js +27 -14
  16. package/dist/core/gemini.js.map +1 -1
  17. package/dist/core/geminiWrapper.d.ts +51 -0
  18. package/dist/core/geminiWrapper.js +718 -0
  19. package/dist/core/geminiWrapper.js.map +1 -0
  20. package/dist/core/json.js +65 -1
  21. package/dist/core/json.js.map +1 -1
  22. package/dist/core/memory.d.ts +16 -0
  23. package/dist/core/memory.js +108 -0
  24. package/dist/core/memory.js.map +1 -0
  25. package/dist/core/modelClient.js +7 -0
  26. package/dist/core/modelClient.js.map +1 -1
  27. package/dist/core/nvidia.js +20 -2
  28. package/dist/core/nvidia.js.map +1 -1
  29. package/dist/core/openrouter.d.ts +2 -0
  30. package/dist/core/openrouter.js +51 -7
  31. package/dist/core/openrouter.js.map +1 -1
  32. package/dist/core/projectInit.d.ts +6 -0
  33. package/dist/core/projectInit.js +44 -0
  34. package/dist/core/projectInit.js.map +1 -0
  35. package/dist/core/reasoning.js +3 -0
  36. package/dist/core/reasoning.js.map +1 -1
  37. package/dist/core/session.d.ts +1 -0
  38. package/dist/core/session.js +46 -0
  39. package/dist/core/session.js.map +1 -1
  40. package/dist/core/types.d.ts +9 -4
  41. package/dist/core/workspace.d.ts +8 -0
  42. package/dist/core/workspace.js +314 -21
  43. package/dist/core/workspace.js.map +1 -1
  44. package/dist/tui/App.js +571 -81
  45. package/dist/tui/App.js.map +1 -1
  46. package/dist/tui/commands.js +35 -6
  47. package/dist/tui/commands.js.map +1 -1
  48. package/dist/tui/components/ApprovalPanel.d.ts +6 -0
  49. package/dist/tui/components/ApprovalPanel.js +16 -0
  50. package/dist/tui/components/ApprovalPanel.js.map +1 -0
  51. package/dist/tui/components/CommandSuggestions.js +8 -3
  52. package/dist/tui/components/CommandSuggestions.js.map +1 -1
  53. package/dist/tui/components/Composer.d.ts +1 -0
  54. package/dist/tui/components/Composer.js +1 -1
  55. package/dist/tui/components/Composer.js.map +1 -1
  56. package/dist/tui/components/ExperimentalPanel.d.ts +10 -0
  57. package/dist/tui/components/ExperimentalPanel.js +33 -0
  58. package/dist/tui/components/ExperimentalPanel.js.map +1 -0
  59. package/dist/tui/components/Header.js +3 -3
  60. package/dist/tui/components/Header.js.map +1 -1
  61. package/dist/tui/components/OnboardingPanel.d.ts +13 -1
  62. package/dist/tui/components/OnboardingPanel.js +23 -9
  63. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  64. package/dist/tui/components/Sidebar.js +32 -26
  65. package/dist/tui/components/Sidebar.js.map +1 -1
  66. package/dist/tui/components/Transcript.js +4 -3
  67. package/dist/tui/components/Transcript.js.map +1 -1
  68. package/dist/tui/format.js +7 -7
  69. package/dist/tui/format.js.map +1 -1
  70. package/dist/tui/modes.d.ts +1 -1
  71. package/dist/tui/modes.js +8 -2
  72. package/dist/tui/modes.js.map +1 -1
  73. package/docs/gemini-wrapper.md +87 -0
  74. package/docs/releases/v0.1.1-beta.md +18 -0
  75. package/docs/releases/v0.2.1.md +1 -1
  76. package/docs/releases/v0.3.1-beta.md +4 -0
  77. package/docs/releases/v0.4.0.md +27 -0
  78. package/docs/releases/v1.0.0.md +28 -0
  79. package/docs/showcase/patchpilot-banner.png +0 -0
  80. package/docs/showcase/patchpilot-logo.png +0 -0
  81. package/package.json +5 -2
package/dist/tui/App.js CHANGED
@@ -2,21 +2,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { Box, useApp, useInput, useStdout } from "ink";
4
4
  import { AgentRunner } from "../core/agent.js";
5
+ import { cleanupPatchPilot, readCleanupTarget } from "../core/cleanup.js";
5
6
  import { defaultCodexModel, hasCodexCliOAuth } from "../core/codex.js";
6
7
  import { describeComputeTarget } from "../core/compute.js";
7
8
  import { runDoctor } from "../core/doctor.js";
8
9
  import { savePatchPilotEnvValues } from "../core/env.js";
9
10
  import { defaultGeminiModel, readGeminiApiKey } from "../core/gemini.js";
11
+ import { defaultGeminiWrapperModel, geminiWrapperRequiresApiKey, readGeminiWrapperApiKey, readGeminiWrapperBaseUrl, readGeminiWrapperCookiesJson, readGeminiWrapperMode, readGeminiWrapperPythonCommand, saveGeminiWrapperCookieFile } from "../core/geminiWrapper.js";
10
12
  import { createModelClient } from "../core/modelClient.js";
11
13
  import { defaultNvidiaModel, readNvidiaApiKey } from "../core/nvidia.js";
12
14
  import { defaultOllamaModel, OllamaClient } from "../core/ollama.js";
13
15
  import { defaultOpenRouterModel, isOpenRouterFreeModel, readOpenRouterApiKey } from "../core/openrouter.js";
16
+ import { ensurePatchPilotGitignore, patchPilotInitPrompt } from "../core/projectInit.js";
14
17
  import { formatReasoningSupport } from "../core/reasoning.js";
15
- import { listWorkspaceSessions, loadSessionSummary, SessionStore } from "../core/session.js";
18
+ import { buildSessionResumeContext, listWorkspaceSessions, loadSessionSummary, SessionStore } from "../core/session.js";
16
19
  import { addTelemetryToSession, emptySessionTelemetry, estimateTokens } from "../core/tokenAccounting.js";
17
20
  import { WorkspaceTools } from "../core/workspace.js";
21
+ import { ApprovalPanel } from "./components/ApprovalPanel.js";
18
22
  import { CommandSuggestions } from "./components/CommandSuggestions.js";
19
23
  import { Composer, FooterHints } from "./components/Composer.js";
24
+ import { ExperimentalPanel, experimentalFlagAt, experimentalFlagCount } from "./components/ExperimentalPanel.js";
20
25
  import { Header } from "./components/Header.js";
21
26
  import { OnboardingPanel } from "./components/OnboardingPanel.js";
22
27
  import { Sidebar } from "./components/Sidebar.js";
@@ -39,6 +44,11 @@ export function App(props) {
39
44
  const abortControllerRef = useRef(null);
40
45
  const sessionStoreRef = useRef(new SessionStore({ workspace: props.workspace }));
41
46
  const approvalResolverRef = useRef(null);
47
+ const runtimeStateRef = useRef({
48
+ isRunning: false,
49
+ hasPendingApproval: false,
50
+ lastSigintAt: 0
51
+ });
42
52
  const grantedPermissionsRef = useRef({
43
53
  allowWrite: props.allowWrite,
44
54
  allowShell: props.allowShell
@@ -54,6 +64,7 @@ export function App(props) {
54
64
  const [pendingApproval, setPendingApproval] = useState(null);
55
65
  const [telemetry, setTelemetry] = useState(null);
56
66
  const [sessionTelemetry, setSessionTelemetry] = useState(() => emptySessionTelemetry());
67
+ const [resumeContext, setResumeContext] = useState("");
57
68
  const [systemStats, setSystemStats] = useState(() => readSystemStats().stats);
58
69
  const [gpuStats, setGpuStats] = useState(null);
59
70
  const [agentMode, setAgentMode] = useState(() => initialAgentMode({ allowWrite: props.allowWrite, allowShell: props.allowShell }));
@@ -64,6 +75,13 @@ export function App(props) {
64
75
  const [modelOptions, setModelOptions] = useState([]);
65
76
  const [isLoadingModels, setIsLoadingModels] = useState(false);
66
77
  const [onboarding, setOnboarding] = useState(null);
78
+ const [experimentalOpen, setExperimentalOpen] = useState(false);
79
+ const [experimentalIndex, setExperimentalIndex] = useState(0);
80
+ const [experimentalFlags, setExperimentalFlags] = useState({
81
+ fileAnalysis: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_FILE_ANALYSIS, false),
82
+ memory: readBooleanEnv(process.env.PATCHPILOT_EXPERIMENTAL_MEMORY, false),
83
+ subagents: props.subagents
84
+ });
67
85
  const [onboardingIndex, setOnboardingIndex] = useState(0);
68
86
  const [onboardingInput, setOnboardingInput] = useState("");
69
87
  const [onboardingBusyMessage, setOnboardingBusyMessage] = useState(null);
@@ -87,7 +105,7 @@ export function App(props) {
87
105
  const draftTokens = estimateTokens(input);
88
106
  const terminalRows = stdout.rows ?? 40;
89
107
  const terminalColumns = stdout.columns ?? 120;
90
- const paletteItems = !isRunning && !onboarding
108
+ const paletteItems = !isRunning && !onboarding && !experimentalOpen
91
109
  ? buildCommandSuggestionItems({
92
110
  input,
93
111
  provider: settings.provider,
@@ -101,9 +119,10 @@ export function App(props) {
101
119
  const rootHeight = Math.max(24, terminalRows);
102
120
  const headerReservedHeight = 5;
103
121
  const paletteReservedHeight = !onboarding && paletteItems.length > 0 ? Math.min(8, paletteItems.length) + 4 : 0;
104
- const composerReservedHeight = onboarding ? 0 : 2;
105
- const footerReservedHeight = onboarding ? 0 : 1;
106
- const panelHeight = Math.max(8, rootHeight - headerReservedHeight - composerReservedHeight - paletteReservedHeight - footerReservedHeight);
122
+ const composerReservedHeight = onboarding || experimentalOpen ? 0 : 2;
123
+ const footerReservedHeight = onboarding || experimentalOpen ? 0 : 1;
124
+ const approvalReservedHeight = !onboarding && !experimentalOpen && (pendingApproval || bypassConfirmation) ? 6 : 0;
125
+ const panelHeight = Math.max(8, rootHeight - headerReservedHeight - composerReservedHeight - paletteReservedHeight - footerReservedHeight - approvalReservedHeight);
107
126
  const transcriptWidth = Math.max(42, terminalColumns - 38);
108
127
  const scrollStep = Math.max(4, Math.floor(panelHeight * 0.8));
109
128
  const appendLine = useCallback((line) => {
@@ -122,6 +141,7 @@ export function App(props) {
122
141
  }
123
142
  approvalResolverRef.current(decision);
124
143
  approvalResolverRef.current = null;
144
+ setInput("");
125
145
  appendLine({
126
146
  kind: "approval",
127
147
  tone: decision === "deny" ? "warning" : "success",
@@ -137,6 +157,7 @@ export function App(props) {
137
157
  const permissions = permissionsForMode(nextMode);
138
158
  setAgentMode(nextMode);
139
159
  setBypassConfirmation(false);
160
+ grantedPermissionsRef.current = permissions;
140
161
  setSettings((currentSettings) => ({
141
162
  ...currentSettings,
142
163
  allowWrite: permissions.allowWrite,
@@ -156,20 +177,35 @@ export function App(props) {
156
177
  }
157
178
  }, [appendLine]);
158
179
  const requestBypassMode = useCallback(() => {
180
+ if (bypassConfirmation) {
181
+ return;
182
+ }
159
183
  setBypassConfirmation(true);
160
- appendLine({
161
- kind: "approval",
162
- tone: "danger",
163
- label: "bypass",
164
- text: "Build + bypass will enable write and shell permissions without per-tool prompts.",
165
- detail: "Press y to accept for this session, or n/Esc to stay in build mode. Use this only in a trusted workspace."
166
- });
167
- }, [appendLine]);
184
+ setStatus("bypass confirmation needed");
185
+ setWorkState("waiting_approval");
186
+ }, [bypassConfirmation]);
168
187
  const confirmBypassMode = useCallback(() => {
188
+ setInput("");
169
189
  applyMode("bypass");
170
190
  }, [applyMode]);
191
+ const setExplicitPermission = useCallback((permission, enabled) => {
192
+ const nextPermissions = {
193
+ allowWrite: permission === "write" ? enabled : settings.allowWrite,
194
+ allowShell: permission === "shell" ? enabled : settings.allowShell
195
+ };
196
+ grantedPermissionsRef.current = nextPermissions;
197
+ setBypassConfirmation(false);
198
+ setAgentMode(nextPermissions.allowWrite && nextPermissions.allowShell ? "bypass" : nextPermissions.allowWrite || nextPermissions.allowShell ? "build" : "plan");
199
+ setSettings((currentSettings) => ({
200
+ ...currentSettings,
201
+ ...nextPermissions
202
+ }));
203
+ }, [settings.allowShell, settings.allowWrite]);
171
204
  const cancelBypassMode = useCallback(() => {
205
+ setInput("");
172
206
  setBypassConfirmation(false);
207
+ setStatus("idle");
208
+ setWorkState("idle");
173
209
  applyMode("build", false);
174
210
  appendLine({
175
211
  kind: "approval",
@@ -289,13 +325,14 @@ export function App(props) {
289
325
  setTelemetry(null);
290
326
  setOnboardingInput("");
291
327
  setOnboardingNotice(null);
292
- setOnboardingBusyMessage(`Loading ${provider} models...`);
328
+ setOnboardingBusyMessage(null);
293
329
  const nextModel = defaultModelForProvider(provider, options.currentModel ?? settings.model);
294
330
  setSettings((currentSettings) => ({
295
331
  ...currentSettings,
296
332
  provider,
297
333
  model: nextModel
298
334
  }));
335
+ setOnboardingBusyMessage(`Loading ${provider} models...`);
299
336
  try {
300
337
  const models = await loadAvailableModels(provider, options.ollamaUrl ?? settings.ollamaUrl, setModelOptions, true);
301
338
  if (models.length === 0) {
@@ -305,9 +342,13 @@ export function App(props) {
305
342
  ? "No Ollama models found on that host."
306
343
  : provider === "gemini"
307
344
  ? "No Gemini models listed. Check the API key."
308
- : provider === "openrouter"
309
- ? "No OpenRouter models listed. Check the API key."
310
- : "No Codex OAuth models listed.",
345
+ : provider === "gemini-wrapper"
346
+ ? "No Gemini-Wrapper models listed. Check the bridge install and cookie setup."
347
+ : provider === "openrouter"
348
+ ? "No OpenRouter models listed. Check the API key."
349
+ : provider === "nvidia"
350
+ ? "No NVIDIA models listed. Check the API key."
351
+ : "No Codex OAuth models listed.",
311
352
  detail: "Use the back key to choose another provider or retry after fixing the provider setup."
312
353
  });
313
354
  return;
@@ -353,6 +394,11 @@ export function App(props) {
353
394
  case "host":
354
395
  case "api-key-choice":
355
396
  case "gemini-key":
397
+ case "gemini-wrapper-url":
398
+ case "gemini-wrapper-psid":
399
+ case "gemini-wrapper-psidts":
400
+ case "gemini-wrapper-model-mode":
401
+ case "gemini-wrapper-key":
356
402
  case "openrouter-key":
357
403
  case "nvidia-key":
358
404
  case "codex-login":
@@ -378,6 +424,12 @@ export function App(props) {
378
424
  openApiKeyChoice("gemini", setOnboarding, setOnboardingIndex);
379
425
  return;
380
426
  }
427
+ if (onboarding.provider === "gemini-wrapper") {
428
+ setOnboarding({
429
+ step: "gemini-wrapper-model-mode"
430
+ });
431
+ return;
432
+ }
381
433
  if (onboarding.provider === "nvidia") {
382
434
  openApiKeyChoice("nvidia", setOnboarding, setOnboardingIndex);
383
435
  return;
@@ -450,7 +502,7 @@ export function App(props) {
450
502
  }
451
503
  return;
452
504
  }
453
- if (selection === "gemini" || selection === "openrouter" || selection === "nvidia") {
505
+ if (selection === "gemini" || selection === "gemini-wrapper" || selection === "openrouter" || selection === "nvidia") {
454
506
  openApiKeyChoice(selection, setOnboarding, setOnboardingIndex);
455
507
  return;
456
508
  }
@@ -536,12 +588,20 @@ export function App(props) {
536
588
  return;
537
589
  }
538
590
  if (choice === 0 && onboarding.hasExistingKey) {
591
+ if (onboarding.provider === "gemini-wrapper") {
592
+ setOnboarding({
593
+ step: "gemini-wrapper-model-mode"
594
+ });
595
+ setOnboardingInput("");
596
+ setOnboardingIndex(0);
597
+ return;
598
+ }
539
599
  await openModelSelection(onboarding.provider, {
540
600
  currentModel: defaultModelForProvider(onboarding.provider, settings.model)
541
601
  });
542
602
  return;
543
603
  }
544
- setOnboarding({
604
+ setOnboarding(onboarding.provider === "gemini-wrapper" ? { step: "gemini-wrapper-psid" } : {
545
605
  step: `${onboarding.provider}-key`
546
606
  });
547
607
  setOnboardingInput("");
@@ -572,6 +632,151 @@ export function App(props) {
572
632
  });
573
633
  return;
574
634
  }
635
+ if (onboarding.step === "gemini-wrapper-psid") {
636
+ const secure1psid = value.trim();
637
+ if (!secure1psid) {
638
+ setOnboardingNotice({
639
+ tone: "warning",
640
+ text: "__Secure-1PSID cannot be empty.",
641
+ detail: "Paste the cookie value manually. PatchPilot will not scan browser profiles."
642
+ });
643
+ return;
644
+ }
645
+ setOnboarding({
646
+ step: "gemini-wrapper-psidts",
647
+ secure1psid
648
+ });
649
+ setOnboardingInput("");
650
+ setOnboardingIndex(0);
651
+ return;
652
+ }
653
+ if (onboarding.step === "gemini-wrapper-psidts") {
654
+ const secure1psidts = value.trim();
655
+ const cookiesPath = saveGeminiWrapperCookieFile({
656
+ secure1psid: onboarding.secure1psid,
657
+ secure1psidts
658
+ });
659
+ process.env.PATCHPILOT_GEMINI_WRAPPER_MODE = "python";
660
+ process.env.PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON = cookiesPath;
661
+ savePatchPilotEnvValues({
662
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
663
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
664
+ PATCHPILOT_GEMINI_WRAPPER_MODE: "python",
665
+ PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON: cookiesPath
666
+ });
667
+ setOnboardingNotice({
668
+ tone: "success",
669
+ text: "Gemini-API bridge cookies saved to PatchPilot config.",
670
+ detail: `${cookiesPath} was written with owner-only permissions. PatchPilot will run gemini_webapi through python3.`
671
+ });
672
+ setOnboarding({
673
+ step: "gemini-wrapper-model-mode"
674
+ });
675
+ setOnboardingInput("");
676
+ setOnboardingIndex(0);
677
+ return;
678
+ }
679
+ if (onboarding.step === "gemini-wrapper-model-mode") {
680
+ const choice = readIndexedSelection(value, onboardingIndex);
681
+ if (choice === null) {
682
+ return;
683
+ }
684
+ if (choice === 0) {
685
+ setTelemetry(null);
686
+ setModelOptions([defaultGeminiWrapperModel]);
687
+ setSettings((currentSettings) => ({
688
+ ...currentSettings,
689
+ provider: "gemini-wrapper",
690
+ model: defaultGeminiWrapperModel
691
+ }));
692
+ savePatchPilotEnvValues({
693
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
694
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
695
+ PATCHPILOT_ONBOARDING_COMPLETE: "1"
696
+ });
697
+ appendLine({
698
+ tone: "success",
699
+ label: "onboarding",
700
+ text: `ready: gemini-wrapper using ${defaultGeminiWrapperModel}`
701
+ });
702
+ closeOnboarding();
703
+ return;
704
+ }
705
+ await openModelSelection("gemini-wrapper", {
706
+ currentModel: settings.model
707
+ });
708
+ return;
709
+ }
710
+ if (onboarding.step === "gemini-wrapper-url") {
711
+ const baseUrl = value.trim().replace(/\/$/, "");
712
+ if (!baseUrl) {
713
+ setOnboardingNotice({
714
+ tone: "warning",
715
+ text: "Gemini-Wrapper URL cannot be empty."
716
+ });
717
+ return;
718
+ }
719
+ try {
720
+ new URL(baseUrl);
721
+ }
722
+ catch {
723
+ setOnboardingNotice({
724
+ tone: "warning",
725
+ text: "Gemini-Wrapper URL must be a valid URL.",
726
+ detail: "Example: http://localhost:8787/v1"
727
+ });
728
+ return;
729
+ }
730
+ process.env.PATCHPILOT_GEMINI_WRAPPER_BASE_URL = baseUrl;
731
+ savePatchPilotEnvValues({
732
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
733
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
734
+ PATCHPILOT_GEMINI_WRAPPER_BASE_URL: baseUrl
735
+ });
736
+ setOnboardingNotice({
737
+ tone: "success",
738
+ text: "Gemini-Wrapper URL saved to PatchPilot config.",
739
+ detail: "PatchPilot uses only this explicit URL and never reads browser cookies."
740
+ });
741
+ if (geminiWrapperRequiresApiKey(baseUrl) && !readGeminiWrapperApiKey()) {
742
+ setOnboarding({
743
+ step: "gemini-wrapper-key",
744
+ baseUrl
745
+ });
746
+ setOnboardingInput("");
747
+ setOnboardingIndex(0);
748
+ return;
749
+ }
750
+ await openModelSelection("gemini-wrapper", {
751
+ currentModel: defaultGeminiWrapperModel
752
+ });
753
+ return;
754
+ }
755
+ if (onboarding.step === "gemini-wrapper-key") {
756
+ const apiKey = value.trim();
757
+ if (geminiWrapperRequiresApiKey(onboarding.baseUrl) && !apiKey) {
758
+ setOnboardingNotice({
759
+ tone: "warning",
760
+ text: "Gemini-Wrapper API key cannot be empty for remote wrapper URLs."
761
+ });
762
+ return;
763
+ }
764
+ process.env.PATCHPILOT_GEMINI_WRAPPER_API_KEY = apiKey;
765
+ savePatchPilotEnvValues({
766
+ PATCHPILOT_PROVIDER: "gemini-wrapper",
767
+ PATCHPILOT_MODEL: defaultGeminiWrapperModel,
768
+ PATCHPILOT_GEMINI_WRAPPER_BASE_URL: onboarding.baseUrl,
769
+ ...(apiKey ? { PATCHPILOT_GEMINI_WRAPPER_API_KEY: apiKey } : {})
770
+ });
771
+ setOnboardingNotice({
772
+ tone: "success",
773
+ text: apiKey ? "Gemini-Wrapper API key saved to PatchPilot config." : "Gemini-Wrapper local URL saved without an API key."
774
+ });
775
+ await openModelSelection("gemini-wrapper", {
776
+ currentModel: defaultGeminiWrapperModel
777
+ });
778
+ return;
779
+ }
575
780
  if (onboarding.step === "openrouter-key") {
576
781
  const apiKey = value.trim();
577
782
  if (!apiKey) {
@@ -635,8 +840,8 @@ export function App(props) {
635
840
  return;
636
841
  }
637
842
  const visibleModels = selectableModels(onboardingInput, onboarding.models);
638
- const selectedModel = selectModelFromInput(value, visibleModels, onboardingIndex, {
639
- allowManual: onboarding.provider !== "ollama"
843
+ const selectedModel = visibleModels[onboardingIndex] ?? selectModelFromInput(value, visibleModels, onboardingIndex, {
844
+ allowManual: onboarding.provider !== "ollama" && onboarding.provider !== "gemini-wrapper"
640
845
  });
641
846
  if (!selectedModel) {
642
847
  setOnboardingNotice({
@@ -672,7 +877,7 @@ export function App(props) {
672
877
  }
673
878
  closeOnboarding();
674
879
  }, [activeHost?.host.url, appendLine, closeOnboarding, connectToHost, loadHostSuggestions, onboarding, onboardingBusyMessage, onboardingIndex, openModelSelection, settings.ollamaUrl]);
675
- const runTask = useCallback(async (task) => {
880
+ const runTask = useCallback(async (task, overrides = {}) => {
676
881
  if (!task.trim() || isRunning) {
677
882
  return;
678
883
  }
@@ -692,13 +897,17 @@ export function App(props) {
692
897
  }
693
898
  const abortController = new AbortController();
694
899
  abortControllerRef.current = abortController;
900
+ const effectiveMode = overrides.mode ?? agentMode;
695
901
  const taskRunner = new AgentRunner({
696
902
  ...runnableSettings,
697
- mode: agentMode,
903
+ allowExternalFileAnalysis: experimentalFlags.fileAnalysis,
904
+ memoryEnabled: experimentalFlags.memory,
905
+ mode: effectiveMode,
698
906
  signal: abortController.signal,
699
907
  sessionStore: sessionStoreRef.current,
908
+ resumeContext,
700
909
  approvalHandler: (request) => new Promise((resolve) => {
701
- if (agentMode === "plan") {
910
+ if (effectiveMode === "plan") {
702
911
  appendLine({
703
912
  kind: "approval",
704
913
  tone: "warning",
@@ -712,13 +921,14 @@ export function App(props) {
712
921
  resolve("deny");
713
922
  return;
714
923
  }
715
- if (agentMode === "bypass") {
924
+ if (effectiveMode === "bypass" && ((request.permission === "write" && runnableSettings.allowWrite) || (request.permission === "shell" && runnableSettings.allowShell))) {
716
925
  resolve("allow_session");
717
926
  return;
718
927
  }
719
928
  setPendingApproval(request);
720
929
  setWorkState("waiting_approval");
721
930
  setStatus(`approval needed for ${request.tool}`);
931
+ setTranscriptScrollOffset(0);
722
932
  appendLine({
723
933
  kind: "approval",
724
934
  tone: "warning",
@@ -769,7 +979,7 @@ export function App(props) {
769
979
  setWorkState("idle");
770
980
  setIsRunning(false);
771
981
  }
772
- }, [agentMode, appendLine, isRunning, modelOptions, settings]);
982
+ }, [agentMode, appendLine, experimentalFlags, isRunning, modelOptions, resumeContext, settings]);
773
983
  const handleSlashCommand = useCallback(async (rawCommand) => {
774
984
  const [commandName = "", ...args] = rawCommand.slice(1).trim().split(/\s+/);
775
985
  const command = commandName.toLowerCase();
@@ -818,11 +1028,11 @@ export function App(props) {
818
1028
  return;
819
1029
  case "provider": {
820
1030
  const nextProvider = args[0]?.toLowerCase();
821
- if (nextProvider !== "ollama" && nextProvider !== "gemini" && nextProvider !== "codex" && nextProvider !== "openrouter" && nextProvider !== "nvidia") {
1031
+ if (nextProvider !== "ollama" && nextProvider !== "gemini" && nextProvider !== "gemini-wrapper" && nextProvider !== "codex" && nextProvider !== "openrouter" && nextProvider !== "nvidia") {
822
1032
  appendLine({
823
1033
  tone: "accent",
824
1034
  label: "provider",
825
- text: `current ${settings.provider}. Use /provider ollama, gemini, openrouter, nvidia, or codex.`
1035
+ text: `current ${settings.provider}. Use /provider ollama, gemini, gemini-wrapper, openrouter, nvidia, or codex.`
826
1036
  });
827
1037
  return;
828
1038
  }
@@ -845,7 +1055,7 @@ export function App(props) {
845
1055
  tone: needsApiKey(nextProvider) && !hasApiKey(nextProvider) ? "warning" : "success",
846
1056
  label: "provider",
847
1057
  text: needsApiKey(nextProvider) && !hasApiKey(nextProvider)
848
- ? `${nextProvider} needs an API key. Setup opened.`
1058
+ ? `${nextProvider} needs setup. Setup opened.`
849
1059
  : `switched to ${nextProvider} using ${nextModel}`
850
1060
  });
851
1061
  return;
@@ -865,6 +1075,10 @@ export function App(props) {
865
1075
  ...currentSettings,
866
1076
  subagents: subagentsEnabled
867
1077
  }));
1078
+ setExperimentalFlags((currentFlags) => ({
1079
+ ...currentFlags,
1080
+ subagents: subagentsEnabled
1081
+ }));
868
1082
  appendLine({
869
1083
  tone: "success",
870
1084
  label: "agents",
@@ -921,38 +1135,30 @@ export function App(props) {
921
1135
  case "write":
922
1136
  case "apply": {
923
1137
  const writeEnabled = readToggle(args[0], !settings.allowWrite);
924
- if (writeEnabled) {
925
- requestBypassMode();
926
- return;
927
- }
928
- grantedPermissionsRef.current.allowWrite = writeEnabled;
929
- applyMode("build", false);
1138
+ setExplicitPermission("write", writeEnabled);
930
1139
  appendLine({
931
1140
  tone: "success",
932
1141
  label: "write",
933
- text: "workspace writes require approval in build mode"
1142
+ text: writeEnabled ? "workspace writes are allowed; shell remains separately controlled" : "workspace writes disabled"
934
1143
  });
935
1144
  return;
936
1145
  }
937
1146
  case "shell": {
938
1147
  const shellEnabled = readToggle(args[0], !settings.allowShell);
939
- if (shellEnabled) {
940
- requestBypassMode();
941
- return;
942
- }
943
- grantedPermissionsRef.current.allowShell = shellEnabled;
944
- applyMode("build", false);
1148
+ setExplicitPermission("shell", shellEnabled);
945
1149
  appendLine({
946
1150
  tone: "success",
947
1151
  label: "shell",
948
- text: "shell commands require approval in build mode"
1152
+ text: shellEnabled ? "shell commands are allowed; writes remain separately controlled" : "shell commands disabled"
949
1153
  });
950
1154
  return;
951
1155
  }
952
1156
  case "model": {
953
1157
  const requestedModel = normalizeModelAlias(args.join(" ").trim());
954
1158
  if (!requestedModel) {
955
- const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
1159
+ const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine, {
1160
+ refresh: settings.provider === "gemini-wrapper"
1161
+ });
956
1162
  if (!models) {
957
1163
  return;
958
1164
  }
@@ -965,12 +1171,14 @@ export function App(props) {
965
1171
  return;
966
1172
  }
967
1173
  {
968
- const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
1174
+ const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine, {
1175
+ refresh: settings.provider === "gemini-wrapper"
1176
+ });
969
1177
  if (!models) {
970
1178
  return;
971
1179
  }
972
1180
  const nextModel = selectModelFromInput(requestedModel, models, undefined, {
973
- allowManual: settings.provider !== "ollama"
1181
+ allowManual: settings.provider !== "ollama" && settings.provider !== "gemini-wrapper"
974
1182
  });
975
1183
  if (!nextModel) {
976
1184
  appendLine({
@@ -988,12 +1196,14 @@ export function App(props) {
988
1196
  case "models": {
989
1197
  const requestedModel = args.join(" ").trim();
990
1198
  if (requestedModel) {
991
- const installedModels = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
1199
+ const installedModels = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine, {
1200
+ refresh: settings.provider === "gemini-wrapper"
1201
+ });
992
1202
  if (!installedModels) {
993
1203
  return;
994
1204
  }
995
1205
  const nextModel = selectModelFromInput(requestedModel, installedModels, undefined, {
996
- allowManual: settings.provider !== "ollama"
1206
+ allowManual: settings.provider !== "ollama" && settings.provider !== "gemini-wrapper"
997
1207
  });
998
1208
  if (!nextModel) {
999
1209
  appendLine({
@@ -1071,11 +1281,26 @@ export function App(props) {
1071
1281
  const sessionId = args[0] ?? "";
1072
1282
  const sessions = await listWorkspaceSessions(settings.workspace);
1073
1283
  const selectedSession = sessionId ? await loadSessionSummary(settings.workspace, sessionId) : sessions[0] ?? null;
1284
+ if (selectedSession) {
1285
+ sessionStoreRef.current = new SessionStore({
1286
+ workspace: settings.workspace,
1287
+ sessionId: selectedSession.sessionId
1288
+ });
1289
+ await sessionStoreRef.current.append({
1290
+ type: "session.resumed",
1291
+ sessionId: selectedSession.sessionId,
1292
+ workspace: settings.workspace,
1293
+ resumedAt: new Date().toISOString()
1294
+ });
1295
+ setResumeContext(await buildSessionResumeContext(settings.workspace, selectedSession.sessionId));
1296
+ setSessionTelemetry(emptySessionTelemetry());
1297
+ setTelemetry(null);
1298
+ }
1074
1299
  appendLine({
1075
1300
  kind: "status",
1076
1301
  tone: selectedSession ? "accent" : "warning",
1077
1302
  label: "resume",
1078
- text: selectedSession ? `Loaded session ${selectedSession.sessionId}` : "No session available to resume.",
1303
+ text: selectedSession ? `Loaded session ${selectedSession.sessionId} and will inject its summary into the next run.` : "No session available to resume.",
1079
1304
  detail: selectedSession
1080
1305
  ? `workspace ${selectedSession.workspace}\nupdated ${selectedSession.updatedAt}\nmodel ${selectedSession.provider ?? "-"} ${selectedSession.model ?? "-"}\nlast task ${selectedSession.lastTask ?? "-"}`
1081
1306
  : "Run /sessions after at least one PatchPilot run."
@@ -1193,28 +1418,150 @@ export function App(props) {
1193
1418
  return;
1194
1419
  }
1195
1420
  case "doctor": {
1421
+ const shouldFix = args.some((arg) => arg.toLowerCase() === "fix" || arg.toLowerCase() === "--fix");
1196
1422
  appendLine({
1197
1423
  tone: "muted",
1198
1424
  label: "doctor",
1199
- text: "checking local requirements..."
1425
+ text: shouldFix ? "checking local requirements and applying safe fixes..." : "checking local requirements..."
1426
+ });
1427
+ const doctorResults = await runDoctor(settings.provider, settings.ollamaUrl, settings.model, {
1428
+ fix: shouldFix
1200
1429
  });
1201
- const doctorResults = await runDoctor(settings.provider, settings.ollamaUrl, settings.model);
1202
1430
  for (const result of doctorResults) {
1203
1431
  appendLine({
1204
1432
  tone: result.ok ? "success" : "danger",
1205
1433
  label: result.name,
1206
- text: result.details
1434
+ text: result.action && result.action !== "check" ? `${result.action}: ${result.details}` : result.details
1435
+ });
1436
+ }
1437
+ if (!shouldFix && doctorResults.some((result) => result.action === "skipped")) {
1438
+ appendLine({
1439
+ tone: "accent",
1440
+ label: "doctor",
1441
+ text: "Some safe fixes are available. Run /doctor fix to approve them."
1442
+ });
1443
+ }
1444
+ return;
1445
+ }
1446
+ case "cleanup": {
1447
+ const target = readCleanupTarget(args[0]);
1448
+ if (!target) {
1449
+ appendLine({
1450
+ tone: "accent",
1451
+ label: "cleanup",
1452
+ text: "Choose what to clean: /cleanup cache, /cleanup sessions, /cleanup temp, or /cleanup all.",
1453
+ detail: "Sessions deletes saved workspace transcripts. Cache/temp are safe first choices."
1207
1454
  });
1455
+ return;
1456
+ }
1457
+ const removed = await cleanupPatchPilot(settings.workspace, target);
1458
+ if (target === "sessions" || target === "all") {
1459
+ sessionStoreRef.current = new SessionStore({
1460
+ workspace: settings.workspace
1461
+ });
1462
+ await sessionStoreRef.current.create();
1463
+ setResumeContext("");
1464
+ setLines([]);
1465
+ setAdvisorNotes([]);
1466
+ setTelemetry(null);
1467
+ setSessionTelemetry(emptySessionTelemetry());
1468
+ }
1469
+ appendLine({
1470
+ tone: "success",
1471
+ label: "cleanup",
1472
+ text: `cleaned ${removed.join(", ") || target}`
1473
+ });
1474
+ return;
1475
+ }
1476
+ case "experimental": {
1477
+ const requestedFlag = args[0]?.toLowerCase();
1478
+ const requestedValue = args[1]?.toLowerCase();
1479
+ if (!requestedFlag) {
1480
+ setExperimentalOpen(true);
1481
+ setExperimentalIndex(0);
1482
+ setInput("");
1483
+ return;
1484
+ }
1485
+ const enabled = readToggle(requestedValue, true);
1486
+ if (requestedFlag === "subagents" || requestedFlag === "agents") {
1487
+ setSettings((currentSettings) => ({
1488
+ ...currentSettings,
1489
+ subagents: enabled
1490
+ }));
1208
1491
  }
1492
+ savePatchPilotEnvValues({
1493
+ [`PATCHPILOT_EXPERIMENTAL_${requestedFlag.replace(/-/g, "_").toUpperCase()}`]: enabled ? "1" : "0"
1494
+ });
1495
+ setExperimentalFlags((currentFlags) => ({
1496
+ ...currentFlags,
1497
+ ...(requestedFlag === "file-analysis"
1498
+ ? { fileAnalysis: enabled }
1499
+ : requestedFlag === "memory"
1500
+ ? { memory: enabled }
1501
+ : requestedFlag === "subagents" || requestedFlag === "agents"
1502
+ ? { subagents: enabled }
1503
+ : {})
1504
+ }));
1505
+ appendLine({
1506
+ tone: "success",
1507
+ label: "experimental",
1508
+ text: `${requestedFlag} ${enabled ? "enabled" : "disabled"}`
1509
+ });
1510
+ return;
1511
+ }
1512
+ case "init": {
1513
+ await ensurePatchPilotGitignore(settings.workspace);
1514
+ appendLine({
1515
+ tone: "accent",
1516
+ label: "init",
1517
+ text: "starting model-driven project init",
1518
+ detail: "PatchPilot will inspect the repository and create or update PATCHPILOT.md with approval-gated writes."
1519
+ });
1520
+ await runTask(patchPilotInitPrompt, {
1521
+ mode: "build"
1522
+ });
1209
1523
  return;
1210
1524
  }
1211
1525
  case "clear":
1526
+ setLines([]);
1527
+ setAdvisorNotes([]);
1528
+ setTelemetry(null);
1529
+ setResumeContext("");
1530
+ setSessionTelemetry(emptySessionTelemetry());
1531
+ setTranscriptScrollOffset(0);
1532
+ setSessionScrollOffset(0);
1533
+ return;
1534
+ case "new":
1535
+ if (isRunning) {
1536
+ appendLine({
1537
+ tone: "warning",
1538
+ label: "new",
1539
+ text: "Cannot start a new session while a run is active.",
1540
+ detail: "Stop the current run first, then use /new again."
1541
+ });
1542
+ return;
1543
+ }
1544
+ sessionStoreRef.current = new SessionStore({
1545
+ workspace: settings.workspace
1546
+ });
1547
+ await sessionStoreRef.current.create();
1212
1548
  setLines([]);
1213
1549
  setAdvisorNotes([]);
1214
1550
  setTelemetry(null);
1215
1551
  setSessionTelemetry(emptySessionTelemetry());
1552
+ setPendingApproval(null);
1553
+ approvalResolverRef.current = null;
1554
+ setBypassConfirmation(false);
1555
+ setInput("");
1216
1556
  setTranscriptScrollOffset(0);
1217
1557
  setSessionScrollOffset(0);
1558
+ setStatus("idle");
1559
+ setWorkState("idle");
1560
+ appendLine({
1561
+ tone: "success",
1562
+ label: "new",
1563
+ text: `started session ${sessionStoreRef.current.sessionId}`
1564
+ });
1218
1565
  return;
1219
1566
  case "exit":
1220
1567
  case "quit":
@@ -1242,6 +1589,7 @@ export function App(props) {
1242
1589
  loadHostSuggestions,
1243
1590
  loadProviderModels,
1244
1591
  modelOptions,
1592
+ isRunning,
1245
1593
  resolveApproval,
1246
1594
  sessionTelemetry,
1247
1595
  settings,
@@ -1249,7 +1597,14 @@ export function App(props) {
1249
1597
  ]);
1250
1598
  const handleSubmit = useCallback(async (value) => {
1251
1599
  const nextValue = value.trim();
1252
- if (!nextValue || isRunning) {
1600
+ if (!nextValue) {
1601
+ return;
1602
+ }
1603
+ if (isRunning && nextValue.startsWith("/")) {
1604
+ await handleSlashCommand(nextValue);
1605
+ return;
1606
+ }
1607
+ if (isRunning) {
1253
1608
  return;
1254
1609
  }
1255
1610
  if (onboarding) {
@@ -1276,6 +1631,10 @@ export function App(props) {
1276
1631
  useEffect(() => {
1277
1632
  void sessionStoreRef.current.create();
1278
1633
  }, []);
1634
+ useEffect(() => {
1635
+ runtimeStateRef.current.isRunning = isRunning;
1636
+ runtimeStateRef.current.hasPendingApproval = Boolean(pendingApproval || bypassConfirmation);
1637
+ }, [bypassConfirmation, isRunning, pendingApproval]);
1279
1638
  useEffect(() => {
1280
1639
  if (!props.initialTask || didRunInitialTask.current || onboarding || process.env.PATCHPILOT_ONBOARDING_COMPLETE !== "1") {
1281
1640
  return;
@@ -1364,8 +1723,50 @@ export function App(props) {
1364
1723
  }
1365
1724
  }, [hostOptions.length, input, isLoadingHosts, isLoadingModels, isRunning, loadHostSuggestions, loadProviderModels, modelOptions.length, onboarding, settings.provider]);
1366
1725
  useInput((inputValue, key) => {
1726
+ if (experimentalOpen) {
1727
+ if (key.upArrow) {
1728
+ setExperimentalIndex((currentIndex) => (currentIndex - 1 + experimentalFlagCount()) % experimentalFlagCount());
1729
+ return;
1730
+ }
1731
+ if (key.downArrow) {
1732
+ setExperimentalIndex((currentIndex) => (currentIndex + 1) % experimentalFlagCount());
1733
+ return;
1734
+ }
1735
+ if (inputValue === " ") {
1736
+ const flag = experimentalFlagAt(experimentalIndex);
1737
+ setExperimentalFlags((currentFlags) => {
1738
+ const nextFlags = {
1739
+ ...currentFlags,
1740
+ [flag]: !currentFlags[flag]
1741
+ };
1742
+ if (flag === "subagents") {
1743
+ setSettings((currentSettings) => ({
1744
+ ...currentSettings,
1745
+ subagents: nextFlags.subagents
1746
+ }));
1747
+ }
1748
+ savePatchPilotEnvValues({
1749
+ PATCHPILOT_EXPERIMENTAL_FILE_ANALYSIS: nextFlags.fileAnalysis ? "1" : "0",
1750
+ PATCHPILOT_EXPERIMENTAL_MEMORY: nextFlags.memory ? "1" : "0",
1751
+ PATCHPILOT_EXPERIMENTAL_SUBAGENTS: nextFlags.subagents ? "1" : "0"
1752
+ });
1753
+ return nextFlags;
1754
+ });
1755
+ return;
1756
+ }
1757
+ if (key.return || key.escape || key.leftArrow) {
1758
+ setExperimentalOpen(false);
1759
+ setInput("");
1760
+ return;
1761
+ }
1762
+ return;
1763
+ }
1367
1764
  if (bypassConfirmation) {
1368
1765
  const normalizedInput = inputValue.toLowerCase();
1766
+ if (key.tab) {
1767
+ cancelBypassMode();
1768
+ return;
1769
+ }
1369
1770
  if (normalizedInput === "y") {
1370
1771
  confirmBypassMode();
1371
1772
  return;
@@ -1418,7 +1819,7 @@ export function App(props) {
1418
1819
  setOnboardingIndex((currentIndex) => (currentIndex + 1) % optionCount);
1419
1820
  return;
1420
1821
  }
1421
- if (optionCount > 0 && key.return && onboarding.step !== "model") {
1822
+ if (optionCount > 0 && key.return) {
1422
1823
  void handleOnboardingSubmit(String(onboardingIndex + 1));
1423
1824
  return;
1424
1825
  }
@@ -1443,6 +1844,16 @@ export function App(props) {
1443
1844
  }
1444
1845
  }
1445
1846
  const canUsePanelKeys = input.length === 0 || isRunning;
1847
+ if (canUsePanelKeys && key.upArrow && paletteItems.length === 0) {
1848
+ const setOffset = activeScrollPane === "session" ? setSessionScrollOffset : setTranscriptScrollOffset;
1849
+ setOffset((currentOffset) => currentOffset + 1);
1850
+ return;
1851
+ }
1852
+ if (canUsePanelKeys && key.downArrow && paletteItems.length === 0) {
1853
+ const setOffset = activeScrollPane === "session" ? setSessionScrollOffset : setTranscriptScrollOffset;
1854
+ setOffset((currentOffset) => Math.max(0, currentOffset - 1));
1855
+ return;
1856
+ }
1446
1857
  if (canUsePanelKeys && key.leftArrow) {
1447
1858
  setActiveScrollPane("session");
1448
1859
  return;
@@ -1476,19 +1887,44 @@ export function App(props) {
1476
1887
  }
1477
1888
  });
1478
1889
  useEffect(() => {
1890
+ const gracefulStopOrExit = () => {
1891
+ const now = Date.now();
1892
+ const state = runtimeStateRef.current;
1893
+ if ((state.isRunning || state.hasPendingApproval) && now - state.lastSigintAt > 1500) {
1894
+ state.lastSigintAt = now;
1895
+ abortControllerRef.current?.abort();
1896
+ approvalResolverRef.current?.("deny");
1897
+ approvalResolverRef.current = null;
1898
+ setPendingApproval(null);
1899
+ setBypassConfirmation(false);
1900
+ setInput("");
1901
+ setStatus("stopping");
1902
+ setWorkState("idle");
1903
+ appendLine({
1904
+ kind: "status",
1905
+ tone: "warning",
1906
+ label: "stop",
1907
+ text: "Stopping current task. Press Ctrl-C again to quit."
1908
+ });
1909
+ return;
1910
+ }
1911
+ void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(() => {
1912
+ process.exit(0);
1913
+ });
1914
+ };
1479
1915
  const unloadAndExit = () => {
1480
1916
  void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(() => {
1481
1917
  process.exit(0);
1482
1918
  });
1483
1919
  };
1484
- process.once("SIGINT", unloadAndExit);
1485
- process.once("SIGTERM", unloadAndExit);
1920
+ process.on("SIGINT", gracefulStopOrExit);
1921
+ process.on("SIGTERM", unloadAndExit);
1486
1922
  return () => {
1487
- process.off("SIGINT", unloadAndExit);
1923
+ process.off("SIGINT", gracefulStopOrExit);
1488
1924
  process.off("SIGTERM", unloadAndExit);
1489
1925
  void unloadUsedOllamaModels(usedOllamaModelsRef.current);
1490
1926
  };
1491
- }, []);
1927
+ }, [appendLine]);
1492
1928
  useEffect(() => {
1493
1929
  let previousSnapshot = readSystemStats().snapshot;
1494
1930
  const timer = setInterval(() => {
@@ -1517,10 +1953,10 @@ export function App(props) {
1517
1953
  clearInterval(timer);
1518
1954
  };
1519
1955
  }, []);
1520
- 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 + 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 + composerReservedHeight + paletteReservedHeight + footerReservedHeight, overflowY: "hidden", children: [_jsx(Transcript, { lines: lines, isRunning: isRunning, isActive: activeScrollPane === "transcript", height: panelHeight, width: transcriptWidth, scrollOffset: transcriptScrollOffset }), _jsx(Composer, { input: input, isRunning: isRunning, status: status, draftTokens: draftTokens, onChange: setInput, onSubmit: (value) => void handleSubmit(value) }), paletteItems.length > 0 ? _jsx(CommandSuggestions, { items: paletteItems, selectedIndex: paletteIndex }) : null, _jsx(FooterHints, { activePane: activeScrollPane })] })] }))] }));
1956
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: rootHeight, overflowY: "hidden", children: [_jsx(Header, { model: settings.model, provider: settings.provider, workspace: settings.workspace, status: status, workState: workState, allowWrite: settings.allowWrite, allowShell: settings.allowShell, agentMode: agentMode, subagents: settings.subagents, thinkingMode: settings.thinkingMode, reasoningEffort: settings.reasoningEffort, ollamaUrl: settings.ollamaUrl, telemetry: telemetry, sessionTelemetry: sessionTelemetry, draftTokens: draftTokens, systemStats: systemStats, gpuStats: gpuStats, activeHost: activeHost }), experimentalOpen ? (_jsx(ExperimentalPanel, { flags: experimentalFlags, selectedIndex: experimentalIndex, height: panelHeight })) : onboarding ? (_jsx(OnboardingPanel, { state: onboarding, height: panelHeight, selectedIndex: onboardingIndex, input: onboardingInput, busyMessage: onboardingBusyMessage, notice: onboardingNotice, onInputChange: setOnboardingInput, onInputSubmit: (value) => void handleOnboardingSubmit(value) })) : (_jsxs(Box, { flexDirection: "row", height: panelHeight + approvalReservedHeight + composerReservedHeight + paletteReservedHeight + footerReservedHeight, overflowY: "hidden", children: [_jsx(Sidebar, { workspace: settings.workspace, model: settings.model, provider: settings.provider, ollamaUrl: settings.ollamaUrl, agentMode: agentMode, allowWrite: settings.allowWrite, allowShell: settings.allowShell, subagents: settings.subagents, workState: workState, sessionId: sessionStoreRef.current.sessionId, systemStats: systemStats, gpuStats: gpuStats, telemetry: telemetry, sessionTelemetry: sessionTelemetry, draftTokens: draftTokens, height: panelHeight, scrollOffset: sessionScrollOffset, advisors: advisorNotes, isActive: activeScrollPane === "session", activeHost: activeHost }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: panelHeight + approvalReservedHeight + composerReservedHeight + paletteReservedHeight + footerReservedHeight, overflowY: "hidden", children: [_jsx(Transcript, { lines: lines, isRunning: isRunning, isActive: activeScrollPane === "transcript", height: panelHeight, width: transcriptWidth, scrollOffset: transcriptScrollOffset }), _jsx(ApprovalPanel, { request: pendingApproval, bypassConfirmation: bypassConfirmation }), _jsx(Composer, { input: input, isRunning: isRunning, status: status, draftTokens: draftTokens, isApprovalWaiting: Boolean(pendingApproval || bypassConfirmation), onChange: setInput, onSubmit: (value) => void handleSubmit(value) }), paletteItems.length > 0 ? _jsx(CommandSuggestions, { items: paletteItems, selectedIndex: paletteIndex }) : null, _jsx(FooterHints, { activePane: activeScrollPane })] })] }))] }));
1521
1957
  }
1522
1958
  async function loadAvailableModels(provider, ollamaUrl, setModelOptions, refresh = false) {
1523
- const cacheKey = `${provider}:${provider === "ollama" ? ollamaUrl : "default"}`;
1959
+ const cacheKey = modelCacheKey(provider, ollamaUrl);
1524
1960
  const cachedModels = modelCache.get(cacheKey);
1525
1961
  if (!refresh && cachedModels && cachedModels.expiresAt > Date.now()) {
1526
1962
  setModelOptions(cachedModels.models);
@@ -1537,9 +1973,24 @@ async function loadAvailableModels(provider, ollamaUrl, setModelOptions, refresh
1537
1973
  setModelOptions(models);
1538
1974
  return models;
1539
1975
  }
1540
- async function loadKnownOrAvailableModels(provider, ollamaUrl, modelOptions, setModelOptions, appendLine) {
1976
+ function modelCacheKey(provider, ollamaUrl) {
1977
+ if (provider === "ollama") {
1978
+ return `${provider}:${ollamaUrl}`;
1979
+ }
1980
+ if (provider === "gemini-wrapper") {
1981
+ return [
1982
+ provider,
1983
+ readGeminiWrapperMode(),
1984
+ readGeminiWrapperBaseUrl() || "python",
1985
+ readGeminiWrapperPythonCommand(),
1986
+ readGeminiWrapperCookiesJson()
1987
+ ].join(":");
1988
+ }
1989
+ return `${provider}:default`;
1990
+ }
1991
+ async function loadKnownOrAvailableModels(provider, ollamaUrl, modelOptions, setModelOptions, appendLine, options = {}) {
1541
1992
  try {
1542
- return modelOptions.length > 0 ? modelOptions : await loadAvailableModels(provider, ollamaUrl, setModelOptions);
1993
+ return !options.refresh && modelOptions.length > 0 ? modelOptions : await loadAvailableModels(provider, ollamaUrl, setModelOptions, options.refresh);
1543
1994
  }
1544
1995
  catch (error) {
1545
1996
  appendLine({
@@ -1563,7 +2014,7 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
1563
2014
  if (!installedModels) {
1564
2015
  return;
1565
2016
  }
1566
- if (!installedModels.includes(nextModel)) {
2017
+ if (!installedModels.includes(nextModel) && !canUseUnverifiedCloudModel(provider, nextModel)) {
1567
2018
  appendLine({
1568
2019
  tone: "warning",
1569
2020
  label: "model",
@@ -1574,9 +2025,11 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
1574
2025
  ? "No models installed on the selected host."
1575
2026
  : provider === "gemini"
1576
2027
  ? "Check GEMINI_API_KEY in PatchPilot config."
1577
- : provider === "openrouter"
1578
- ? "Check OPENROUTER_API_KEY in PatchPilot config."
1579
- : "Run codex login first."
2028
+ : provider === "gemini-wrapper"
2029
+ ? "Check PATCHPILOT_GEMINI_WRAPPER_BASE_URL in PatchPilot config."
2030
+ : provider === "openrouter"
2031
+ ? "Check OPENROUTER_API_KEY in PatchPilot config."
2032
+ : "Run codex login first."
1580
2033
  });
1581
2034
  return;
1582
2035
  }
@@ -1590,9 +2043,10 @@ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendL
1590
2043
  PATCHPILOT_MODEL: nextModel
1591
2044
  });
1592
2045
  appendLine({
1593
- tone: "success",
2046
+ tone: installedModels.includes(nextModel) ? "success" : "warning",
1594
2047
  label: "model",
1595
- text: `switched to ${nextModel}`
2048
+ text: installedModels.includes(nextModel) ? `switched to ${nextModel}` : `switched to unverified ${provider} model ${nextModel}`,
2049
+ 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."
1596
2050
  });
1597
2051
  if (provider === "openrouter" && isOpenRouterFreeModel(nextModel)) {
1598
2052
  appendLine({
@@ -1618,7 +2072,15 @@ async function resolveRunnableSettings(settings, modelOptions, appendLine, setMo
1618
2072
  });
1619
2073
  return null;
1620
2074
  }
1621
- if (installedModels.includes(settings.model)) {
2075
+ if (installedModels.includes(settings.model) || canUseUnverifiedCloudModel(settings.provider, settings.model)) {
2076
+ if (!installedModels.includes(settings.model)) {
2077
+ appendLine({
2078
+ tone: "warning",
2079
+ label: "model",
2080
+ text: `using unverified ${settings.provider} model ${settings.model}`,
2081
+ detail: "Model discovery did not list it; the next provider request will be the compatibility check."
2082
+ });
2083
+ }
1622
2084
  return settings;
1623
2085
  }
1624
2086
  appendLine({
@@ -1631,9 +2093,11 @@ async function resolveRunnableSettings(settings, modelOptions, appendLine, setMo
1631
2093
  ? "No models installed on the selected host."
1632
2094
  : settings.provider === "gemini"
1633
2095
  ? "No Gemini models listed. Check GEMINI_API_KEY in PatchPilot config."
1634
- : settings.provider === "openrouter"
1635
- ? "No OpenRouter models listed. Check OPENROUTER_API_KEY in PatchPilot config."
1636
- : "Codex OAuth is not ready. Run codex login."
2096
+ : settings.provider === "gemini-wrapper"
2097
+ ? "No Gemini-Wrapper models listed. Check gemini_webapi install and PATCHPILOT_GEMINI_WRAPPER_COOKIES_JSON in PatchPilot config."
2098
+ : settings.provider === "openrouter"
2099
+ ? "No OpenRouter models listed. Check OPENROUTER_API_KEY in PatchPilot config."
2100
+ : "Codex OAuth is not ready. Run codex login."
1637
2101
  });
1638
2102
  return null;
1639
2103
  }
@@ -1643,7 +2107,6 @@ function buildCommandSuggestionItems(options) {
1643
2107
  }
1644
2108
  const trimmedInput = options.input.trimStart().toLowerCase();
1645
2109
  const items = filterSlashCommands(options.input)
1646
- .slice(0, 6)
1647
2110
  .map((command) => {
1648
2111
  const baseCommand = `/${command.name}`;
1649
2112
  return {
@@ -1678,7 +2141,7 @@ function buildCommandSuggestionItems(options) {
1678
2141
  })));
1679
2142
  }
1680
2143
  }
1681
- if (trimmedInput === "/models" || trimmedInput.startsWith("/models") || trimmedInput === "/model" || trimmedInput.startsWith("/model")) {
2144
+ if (trimmedInput.startsWith("/models ") || trimmedInput.startsWith("/model ")) {
1682
2145
  const modelQuery = trimmedInput.replace(/^\/models?/, "").trim();
1683
2146
  if (options.isLoadingModels) {
1684
2147
  items.unshift({
@@ -1701,16 +2164,18 @@ function buildCommandSuggestionItems(options) {
1701
2164
  })));
1702
2165
  }
1703
2166
  }
1704
- return items.slice(0, 8);
2167
+ return items;
1705
2168
  }
1706
2169
  function getOnboardingOptionCount(onboarding) {
1707
2170
  switch (onboarding.step) {
1708
2171
  case "entry":
1709
- return 6;
2172
+ return 7;
1710
2173
  case "host":
1711
2174
  return onboarding.hosts.length + 1;
1712
2175
  case "api-key-choice":
1713
2176
  return onboarding.hasExistingKey ? 2 : 1;
2177
+ case "gemini-wrapper-model-mode":
2178
+ return 2;
1714
2179
  case "model":
1715
2180
  return onboarding.models.length;
1716
2181
  default:
@@ -1720,7 +2185,7 @@ function getOnboardingOptionCount(onboarding) {
1720
2185
  function readEntrySelection(value, selectedIndex) {
1721
2186
  const normalizedValue = value.trim().toLowerCase();
1722
2187
  if (!normalizedValue) {
1723
- return ["local", "host", "gemini", "openrouter", "nvidia", "codex"][selectedIndex];
2188
+ return ["local", "host", "gemini", "gemini-wrapper", "openrouter", "nvidia", "codex"][selectedIndex];
1724
2189
  }
1725
2190
  if (normalizedValue === "1" || normalizedValue === "local" || normalizedValue === "this device") {
1726
2191
  return "local";
@@ -1731,17 +2196,33 @@ function readEntrySelection(value, selectedIndex) {
1731
2196
  if (normalizedValue === "3" || normalizedValue === "gemini" || normalizedValue === "google") {
1732
2197
  return "gemini";
1733
2198
  }
1734
- if (normalizedValue === "4" || normalizedValue === "openrouter" || normalizedValue === "open-router") {
2199
+ if (normalizedValue === "4" || normalizedValue === "gemini-wrapper" || normalizedValue === "geminiwrapper" || normalizedValue === "google-wrapper") {
2200
+ return "gemini-wrapper";
2201
+ }
2202
+ if (normalizedValue === "5" || normalizedValue === "openrouter" || normalizedValue === "open-router") {
1735
2203
  return "openrouter";
1736
2204
  }
1737
- if (normalizedValue === "5" || normalizedValue === "nvidia" || normalizedValue === "nim") {
2205
+ if (normalizedValue === "6" || normalizedValue === "nvidia" || normalizedValue === "nim") {
1738
2206
  return "nvidia";
1739
2207
  }
1740
- if (normalizedValue === "6" || normalizedValue === "codex") {
2208
+ if (normalizedValue === "7" || normalizedValue === "codex") {
1741
2209
  return "codex";
1742
2210
  }
1743
2211
  return null;
1744
2212
  }
2213
+ function readBooleanEnv(value, fallback) {
2214
+ if (!value) {
2215
+ return fallback;
2216
+ }
2217
+ const normalizedValue = value.trim().toLowerCase();
2218
+ if (["1", "true", "yes", "on", "enabled"].includes(normalizedValue)) {
2219
+ return true;
2220
+ }
2221
+ if (["0", "false", "no", "off", "disabled"].includes(normalizedValue)) {
2222
+ return false;
2223
+ }
2224
+ return fallback;
2225
+ }
1745
2226
  function readIndexedSelection(value, selectedIndex) {
1746
2227
  const normalizedValue = value.trim();
1747
2228
  if (!normalizedValue) {
@@ -1774,6 +2255,9 @@ function selectModelFromInput(value, models, selectedIndex, options = {}) {
1774
2255
  function isPlausibleCloudModelId(value) {
1775
2256
  return /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(value) && value.length >= 3;
1776
2257
  }
2258
+ function canUseUnverifiedCloudModel(provider, model) {
2259
+ return provider !== "ollama" && provider !== "gemini-wrapper" && isPlausibleCloudModelId(model);
2260
+ }
1777
2261
  function defaultModelForProvider(provider, currentModel) {
1778
2262
  if (provider === "nvidia") {
1779
2263
  return currentModel.includes("/") && !currentModel.startsWith("openrouter/") ? currentModel : defaultNvidiaModel;
@@ -1781,6 +2265,9 @@ function defaultModelForProvider(provider, currentModel) {
1781
2265
  if (provider === "openrouter") {
1782
2266
  return currentModel.includes("/") ? currentModel : defaultOpenRouterModel;
1783
2267
  }
2268
+ if (provider === "gemini-wrapper") {
2269
+ return currentModel === defaultGeminiWrapperModel || currentModel.startsWith("gemini-3-") ? currentModel : defaultGeminiWrapperModel;
2270
+ }
1784
2271
  if (provider === "gemini") {
1785
2272
  return currentModel.startsWith("gemini-") ? currentModel : defaultGeminiModel;
1786
2273
  }
@@ -1798,12 +2285,15 @@ function openApiKeyChoice(provider, setOnboarding, setOnboardingIndex) {
1798
2285
  setOnboardingIndex(0);
1799
2286
  }
1800
2287
  function needsApiKey(provider) {
1801
- return provider === "gemini" || provider === "openrouter" || provider === "nvidia";
2288
+ return provider === "gemini" || provider === "gemini-wrapper" || provider === "openrouter" || provider === "nvidia";
1802
2289
  }
1803
2290
  function hasApiKey(provider) {
1804
2291
  if (provider === "gemini") {
1805
2292
  return Boolean(readGeminiApiKey());
1806
2293
  }
2294
+ if (provider === "gemini-wrapper") {
2295
+ return Boolean(readGeminiWrapperBaseUrl() || readGeminiWrapperCookiesJson());
2296
+ }
1807
2297
  if (provider === "openrouter") {
1808
2298
  return Boolean(readOpenRouterApiKey());
1809
2299
  }