@jx-grxf/patchpilot 0.2.1 → 0.3.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 (63) hide show
  1. package/README.md +67 -10
  2. package/SECURITY.md +20 -0
  3. package/dist/cli.js +52 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/core/agent.d.ts +5 -2
  6. package/dist/core/agent.js +167 -24
  7. package/dist/core/agent.js.map +1 -1
  8. package/dist/core/codex.js +1 -1
  9. package/dist/core/codex.js.map +1 -1
  10. package/dist/core/gemini.js +8 -21
  11. package/dist/core/gemini.js.map +1 -1
  12. package/dist/core/http.d.ts +6 -0
  13. package/dist/core/http.js +45 -0
  14. package/dist/core/http.js.map +1 -0
  15. package/dist/core/json.js +9 -0
  16. package/dist/core/json.js.map +1 -1
  17. package/dist/core/nvidia.js +9 -2
  18. package/dist/core/nvidia.js.map +1 -1
  19. package/dist/core/ollama.js +8 -1
  20. package/dist/core/ollama.js.map +1 -1
  21. package/dist/core/openrouter.js +13 -8
  22. package/dist/core/openrouter.js.map +1 -1
  23. package/dist/core/reasoning.d.ts +12 -0
  24. package/dist/core/reasoning.js +108 -0
  25. package/dist/core/reasoning.js.map +1 -0
  26. package/dist/core/session.d.ts +31 -0
  27. package/dist/core/session.js +154 -0
  28. package/dist/core/session.js.map +1 -0
  29. package/dist/core/types.d.ts +103 -2
  30. package/dist/core/workspace.d.ts +17 -1
  31. package/dist/core/workspace.js +495 -13
  32. package/dist/core/workspace.js.map +1 -1
  33. package/dist/tui/App.js +291 -88
  34. package/dist/tui/App.js.map +1 -1
  35. package/dist/tui/commands.js +37 -2
  36. package/dist/tui/commands.js.map +1 -1
  37. package/dist/tui/components/Header.d.ts +2 -2
  38. package/dist/tui/components/Header.js +17 -54
  39. package/dist/tui/components/Header.js.map +1 -1
  40. package/dist/tui/components/OnboardingPanel.d.ts +5 -0
  41. package/dist/tui/components/OnboardingPanel.js +11 -13
  42. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  43. package/dist/tui/components/Sidebar.d.ts +6 -1
  44. package/dist/tui/components/Sidebar.js +15 -6
  45. package/dist/tui/components/Sidebar.js.map +1 -1
  46. package/dist/tui/components/Transcript.js +57 -8
  47. package/dist/tui/components/Transcript.js.map +1 -1
  48. package/dist/tui/hosts.js +7 -1
  49. package/dist/tui/hosts.js.map +1 -1
  50. package/dist/tui/modelSelection.d.ts +1 -0
  51. package/dist/tui/modelSelection.js +29 -0
  52. package/dist/tui/modelSelection.js.map +1 -0
  53. package/dist/tui/types.d.ts +12 -2
  54. package/dist/tui/types.js.map +1 -1
  55. package/docs/releases/v0.1.0.md +26 -0
  56. package/docs/releases/v0.2.0.md +21 -0
  57. package/docs/releases/v0.2.1.md +26 -0
  58. package/docs/releases/v0.3.0.md +26 -0
  59. package/docs/showcase/patchpilot-showcase.svg +83 -38
  60. package/package.json +5 -2
  61. package/dist/tui/inputRouting.d.ts +0 -8
  62. package/dist/tui/inputRouting.js +0 -94
  63. package/dist/tui/inputRouting.js.map +0 -1
package/dist/tui/App.js CHANGED
@@ -11,7 +11,10 @@ import { createModelClient } from "../core/modelClient.js";
11
11
  import { defaultNvidiaModel, readNvidiaApiKey } from "../core/nvidia.js";
12
12
  import { defaultOllamaModel, OllamaClient } from "../core/ollama.js";
13
13
  import { defaultOpenRouterModel, isOpenRouterFreeModel, readOpenRouterApiKey } from "../core/openrouter.js";
14
+ import { formatReasoningSupport } from "../core/reasoning.js";
15
+ import { listWorkspaceSessions, loadSessionSummary, SessionStore } from "../core/session.js";
14
16
  import { addTelemetryToSession, emptySessionTelemetry, estimateTokens } from "../core/tokenAccounting.js";
17
+ import { WorkspaceTools } from "../core/workspace.js";
15
18
  import { CommandSuggestions } from "./components/CommandSuggestions.js";
16
19
  import { Composer, FooterHints } from "./components/Composer.js";
17
20
  import { Header } from "./components/Header.js";
@@ -21,6 +24,7 @@ import { Transcript } from "./components/Transcript.js";
21
24
  import { filterSlashCommands, formatCommandDetail, formatCommandHelp } from "./commands.js";
22
25
  import { formatCost, formatSessionTokens, formatTokens, normalizeModelAlias, readToggle } from "./format.js";
23
26
  import { checkOllamaHost, discoverOllamaHosts, normalizeOllamaUrl, readOllamaHostDetails, startLocalOllamaAppAndWait } from "./hosts.js";
27
+ import { selectableModels } from "./modelSelection.js";
24
28
  import { readGpuStats, readSystemStats } from "./systemStats.js";
25
29
  import { maxTranscriptLines } from "./types.js";
26
30
  const modelCacheTtlMs = 5 * 60_000;
@@ -32,11 +36,21 @@ export function App(props) {
32
36
  const didRunInitialTask = useRef(false);
33
37
  const didOpenDefaultOnboarding = useRef(false);
34
38
  const abortControllerRef = useRef(null);
39
+ const sessionStoreRef = useRef(new SessionStore({ workspace: props.workspace }));
40
+ const approvalResolverRef = useRef(null);
41
+ const grantedPermissionsRef = useRef({
42
+ allowWrite: props.allowWrite,
43
+ allowShell: props.allowShell
44
+ });
45
+ const activeHostSyncInFlightRef = useRef(false);
46
+ const autoLoadKeysRef = useRef(new Set());
35
47
  const usedOllamaModelsRef = useRef(new Set());
36
48
  const [lines, setLines] = useState([]);
37
49
  const [advisorNotes, setAdvisorNotes] = useState([]);
38
50
  const [isRunning, setIsRunning] = useState(false);
39
51
  const [status, setStatus] = useState("idle");
52
+ const [workState, setWorkState] = useState("idle");
53
+ const [pendingApproval, setPendingApproval] = useState(null);
40
54
  const [telemetry, setTelemetry] = useState(null);
41
55
  const [sessionTelemetry, setSessionTelemetry] = useState(() => emptySessionTelemetry());
42
56
  const [systemStats, setSystemStats] = useState(() => readSystemStats().stats);
@@ -51,6 +65,7 @@ export function App(props) {
51
65
  const [onboardingIndex, setOnboardingIndex] = useState(0);
52
66
  const [onboardingInput, setOnboardingInput] = useState("");
53
67
  const [onboardingBusyMessage, setOnboardingBusyMessage] = useState(null);
68
+ const [onboardingNotice, setOnboardingNotice] = useState(null);
54
69
  const [paletteIndex, setPaletteIndex] = useState(0);
55
70
  const [activeScrollPane, setActiveScrollPane] = useState("transcript");
56
71
  const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
@@ -82,7 +97,7 @@ export function App(props) {
82
97
  })
83
98
  : [];
84
99
  const rootHeight = Math.max(24, terminalRows);
85
- const headerReservedHeight = 8;
100
+ const headerReservedHeight = 5;
86
101
  const paletteReservedHeight = !onboarding && paletteItems.length > 0 ? Math.min(8, paletteItems.length) + 4 : 0;
87
102
  const composerReservedHeight = onboarding ? 0 : 2;
88
103
  const footerReservedHeight = onboarding ? 0 : 1;
@@ -94,16 +109,34 @@ export function App(props) {
94
109
  ...currentLines.slice(-maxTranscriptLines),
95
110
  {
96
111
  ...line,
112
+ kind: line.kind ?? defaultLogKind(line),
97
113
  id: Date.now() + Math.random()
98
114
  }
99
115
  ]);
100
116
  }, []);
117
+ const resolveApproval = useCallback((decision) => {
118
+ if (!pendingApproval || !approvalResolverRef.current) {
119
+ return;
120
+ }
121
+ approvalResolverRef.current(decision);
122
+ approvalResolverRef.current = null;
123
+ appendLine({
124
+ kind: "approval",
125
+ tone: decision === "deny" ? "warning" : "success",
126
+ label: "approval",
127
+ text: `${pendingApproval.tool} ${decision.replace("_", " ")}`,
128
+ detail: pendingApproval.preview,
129
+ workState: "waiting_approval",
130
+ tool: pendingApproval.tool
131
+ });
132
+ setPendingApproval(null);
133
+ }, [appendLine, pendingApproval]);
101
134
  const applyMode = useCallback((nextMode, announce = true) => {
102
135
  setAgentMode(nextMode);
103
136
  setSettings((currentSettings) => ({
104
137
  ...currentSettings,
105
- allowWrite: nextMode === "build" ? currentSettings.allowWrite : false,
106
- allowShell: nextMode === "build" ? currentSettings.allowShell : false
138
+ allowWrite: nextMode === "build" ? grantedPermissionsRef.current.allowWrite : false,
139
+ allowShell: nextMode === "build" ? grantedPermissionsRef.current.allowShell : false
107
140
  }));
108
141
  if (announce) {
109
142
  appendLine({
@@ -217,6 +250,8 @@ export function App(props) {
217
250
  }, [appendLine, settings.model]);
218
251
  const openModelSelection = useCallback(async (provider, options = {}) => {
219
252
  setTelemetry(null);
253
+ setOnboardingInput("");
254
+ setOnboardingNotice(null);
220
255
  setOnboardingBusyMessage(`Loading ${provider} models...`);
221
256
  const nextModel = defaultModelForProvider(provider, options.currentModel ?? settings.model);
222
257
  setSettings((currentSettings) => ({
@@ -227,16 +262,16 @@ export function App(props) {
227
262
  try {
228
263
  const models = await loadAvailableModels(provider, options.ollamaUrl ?? settings.ollamaUrl, setModelOptions, true);
229
264
  if (models.length === 0) {
230
- appendLine({
265
+ setOnboardingNotice({
231
266
  tone: "warning",
232
- label: "onboarding",
233
267
  text: provider === "ollama"
234
268
  ? "No Ollama models found on that host."
235
269
  : provider === "gemini"
236
270
  ? "No Gemini models listed. Check the API key."
237
271
  : provider === "openrouter"
238
272
  ? "No OpenRouter models listed. Check the API key."
239
- : "No Codex OAuth models listed."
273
+ : "No Codex OAuth models listed.",
274
+ detail: "Use the back key to choose another provider or retry after fixing the provider setup."
240
275
  });
241
276
  return;
242
277
  }
@@ -249,21 +284,22 @@ export function App(props) {
249
284
  setOnboardingIndex(0);
250
285
  }
251
286
  catch (error) {
252
- appendLine({
287
+ setOnboardingNotice({
253
288
  tone: "danger",
254
- label: "onboarding",
255
- text: error instanceof Error ? error.message : String(error)
289
+ text: error instanceof Error ? error.message : String(error),
290
+ detail: "Fix the provider setup, then press Enter or go back and retry."
256
291
  });
257
292
  }
258
293
  finally {
259
294
  setOnboardingBusyMessage(null);
260
295
  }
261
- }, [appendLine, settings.model, settings.ollamaUrl]);
296
+ }, [settings.model, settings.ollamaUrl]);
262
297
  const closeOnboarding = useCallback(() => {
263
298
  setOnboarding(null);
264
299
  setOnboardingIndex(0);
265
300
  setOnboardingInput("");
266
301
  setOnboardingBusyMessage(null);
302
+ setOnboardingNotice(null);
267
303
  }, []);
268
304
  const goBackOnboarding = useCallback(() => {
269
305
  if (!onboarding) {
@@ -271,6 +307,7 @@ export function App(props) {
271
307
  }
272
308
  setOnboardingBusyMessage(null);
273
309
  setOnboardingInput("");
310
+ setOnboardingNotice(null);
274
311
  setOnboardingIndex(0);
275
312
  switch (onboarding.step) {
276
313
  case "entry":
@@ -327,6 +364,10 @@ export function App(props) {
327
364
  if (!onboarding) {
328
365
  return;
329
366
  }
367
+ if (onboardingBusyMessage) {
368
+ return;
369
+ }
370
+ setOnboardingNotice(null);
330
371
  if (onboarding.step === "entry") {
331
372
  const selection = readEntrySelection(value, onboardingIndex);
332
373
  if (!selection) {
@@ -343,7 +384,12 @@ export function App(props) {
343
384
  details = startedHost ? await connectToHost(startedHost, { announce: false }) : null;
344
385
  }
345
386
  if (!details) {
346
- setOnboardingBusyMessage("Local Ollama is not reachable. Start Ollama.app or run `ollama serve`, then press Enter again.");
387
+ setOnboardingBusyMessage(null);
388
+ setOnboardingNotice({
389
+ tone: "warning",
390
+ text: "Local Ollama is not reachable.",
391
+ detail: "Start Ollama.app or run `ollama serve`, then press Enter to retry."
392
+ });
347
393
  return;
348
394
  }
349
395
  await openModelSelection("ollama", {
@@ -394,9 +440,8 @@ export function App(props) {
394
440
  }
395
441
  const selectedHost = onboarding.hosts[selectionIndex - 1];
396
442
  if (!selectedHost) {
397
- appendLine({
443
+ setOnboardingNotice({
398
444
  tone: "warning",
399
- label: "onboarding",
400
445
  text: "Unknown host selection."
401
446
  });
402
447
  return;
@@ -407,6 +452,11 @@ export function App(props) {
407
452
  });
408
453
  if (!details) {
409
454
  setOnboardingBusyMessage(null);
455
+ setOnboardingNotice({
456
+ tone: "warning",
457
+ text: `No Ollama server answered at ${selectedHost.url}.`,
458
+ detail: "Check firewall, MagicDNS/IP, and whether Ollama is listening on that machine."
459
+ });
410
460
  return;
411
461
  }
412
462
  await openModelSelection("ollama", {
@@ -418,9 +468,8 @@ export function App(props) {
418
468
  if (onboarding.step === "host-input") {
419
469
  const hostValue = value.trim();
420
470
  if (!hostValue) {
421
- appendLine({
471
+ setOnboardingNotice({
422
472
  tone: "warning",
423
- label: "onboarding",
424
473
  text: "Host cannot be empty."
425
474
  });
426
475
  return;
@@ -431,6 +480,11 @@ export function App(props) {
431
480
  });
432
481
  if (!details) {
433
482
  setOnboardingBusyMessage(null);
483
+ setOnboardingNotice({
484
+ tone: "warning",
485
+ text: `No Ollama server answered at ${hostValue}.`,
486
+ detail: "Check the IP, MagicDNS name, firewall rules, and whether Ollama is running."
487
+ });
434
488
  return;
435
489
  }
436
490
  await openModelSelection("ollama", {
@@ -460,9 +514,8 @@ export function App(props) {
460
514
  if (onboarding.step === "gemini-key") {
461
515
  const apiKey = value.trim();
462
516
  if (!apiKey) {
463
- appendLine({
517
+ setOnboardingNotice({
464
518
  tone: "warning",
465
- label: "onboarding",
466
519
  text: "Gemini API key cannot be empty."
467
520
  });
468
521
  return;
@@ -473,9 +526,8 @@ export function App(props) {
473
526
  PATCHPILOT_MODEL: defaultGeminiModel,
474
527
  GEMINI_API_KEY: apiKey
475
528
  });
476
- appendLine({
529
+ setOnboardingNotice({
477
530
  tone: "success",
478
- label: "onboarding",
479
531
  text: "Gemini API key saved to PatchPilot config."
480
532
  });
481
533
  await openModelSelection("gemini", {
@@ -486,9 +538,8 @@ export function App(props) {
486
538
  if (onboarding.step === "openrouter-key") {
487
539
  const apiKey = value.trim();
488
540
  if (!apiKey) {
489
- appendLine({
541
+ setOnboardingNotice({
490
542
  tone: "warning",
491
- label: "onboarding",
492
543
  text: "OpenRouter API key cannot be empty."
493
544
  });
494
545
  return;
@@ -499,9 +550,8 @@ export function App(props) {
499
550
  PATCHPILOT_MODEL: defaultOpenRouterModel,
500
551
  OPENROUTER_API_KEY: apiKey
501
552
  });
502
- appendLine({
553
+ setOnboardingNotice({
503
554
  tone: "success",
504
- label: "onboarding",
505
555
  text: "OpenRouter API key saved to PatchPilot config."
506
556
  });
507
557
  await openModelSelection("openrouter", {
@@ -512,9 +562,8 @@ export function App(props) {
512
562
  if (onboarding.step === "nvidia-key") {
513
563
  const apiKey = value.trim();
514
564
  if (!apiKey) {
515
- appendLine({
565
+ setOnboardingNotice({
516
566
  tone: "warning",
517
- label: "onboarding",
518
567
  text: "NVIDIA API key cannot be empty."
519
568
  });
520
569
  return;
@@ -525,9 +574,8 @@ export function App(props) {
525
574
  PATCHPILOT_MODEL: defaultNvidiaModel,
526
575
  NVIDIA_API_KEY: apiKey
527
576
  });
528
- appendLine({
577
+ setOnboardingNotice({
529
578
  tone: "success",
530
- label: "onboarding",
531
579
  text: "NVIDIA API key saved to PatchPilot config."
532
580
  });
533
581
  await openModelSelection("nvidia", {
@@ -537,10 +585,10 @@ export function App(props) {
537
585
  }
538
586
  if (onboarding.step === "codex-login") {
539
587
  if (!hasCodexCliOAuth()) {
540
- appendLine({
588
+ setOnboardingNotice({
541
589
  tone: "warning",
542
- label: "onboarding",
543
- text: "Codex OAuth is still missing. Run `codex login`, then press Enter again."
590
+ text: "Codex OAuth is still missing.",
591
+ detail: "Run `codex login` in another terminal, then press Enter to retry."
544
592
  });
545
593
  return;
546
594
  }
@@ -549,14 +597,13 @@ export function App(props) {
549
597
  });
550
598
  return;
551
599
  }
552
- const selectableModels = filterModelOptions(onboardingInput, onboarding.models);
553
- const selectedModel = selectModelFromInput(value, selectableModels, onboardingIndex, {
600
+ const visibleModels = selectableModels(onboardingInput, onboarding.models);
601
+ const selectedModel = selectModelFromInput(value, visibleModels, onboardingIndex, {
554
602
  allowManual: onboarding.provider !== "ollama"
555
603
  });
556
604
  if (!selectedModel) {
557
- appendLine({
605
+ setOnboardingNotice({
558
606
  tone: "warning",
559
- label: "onboarding",
560
607
  text: "Unknown model selection. Pick a listed model."
561
608
  });
562
609
  return;
@@ -587,7 +634,7 @@ export function App(props) {
587
634
  });
588
635
  }
589
636
  closeOnboarding();
590
- }, [activeHost?.host.url, appendLine, closeOnboarding, connectToHost, loadHostSuggestions, onboarding, onboardingIndex, openModelSelection, settings.ollamaUrl]);
637
+ }, [activeHost?.host.url, appendLine, closeOnboarding, connectToHost, loadHostSuggestions, onboarding, onboardingBusyMessage, onboardingIndex, openModelSelection, settings.ollamaUrl]);
591
638
  const runTask = useCallback(async (task) => {
592
639
  if (!task.trim() || isRunning) {
593
640
  return;
@@ -596,6 +643,7 @@ export function App(props) {
596
643
  setTranscriptScrollOffset(0);
597
644
  setIsRunning(true);
598
645
  appendLine({
646
+ kind: "user",
599
647
  tone: "normal",
600
648
  label: "you",
601
649
  text: task
@@ -609,9 +657,41 @@ export function App(props) {
609
657
  abortControllerRef.current = abortController;
610
658
  const taskRunner = new AgentRunner({
611
659
  ...runnableSettings,
612
- signal: abortController.signal
660
+ signal: abortController.signal,
661
+ sessionStore: sessionStoreRef.current,
662
+ approvalHandler: (request) => new Promise((resolve) => {
663
+ if (agentMode === "plan") {
664
+ appendLine({
665
+ kind: "approval",
666
+ tone: "warning",
667
+ label: "approval",
668
+ text: `${request.tool} blocked in plan mode`,
669
+ detail: "Switch to /mode build before approving write, script, test, or shell tools.",
670
+ workState: "waiting_approval",
671
+ tool: request.tool,
672
+ preview: request.preview
673
+ });
674
+ resolve("deny");
675
+ return;
676
+ }
677
+ setPendingApproval(request);
678
+ setWorkState("waiting_approval");
679
+ setStatus(`approval needed for ${request.tool}`);
680
+ appendLine({
681
+ kind: "approval",
682
+ tone: "warning",
683
+ label: "approval",
684
+ text: `${request.tool} needs ${request.permission} approval`,
685
+ detail: `${request.preview} Press y once, a session, or n deny.`,
686
+ workState: "waiting_approval",
687
+ tool: request.tool,
688
+ preview: request.preview
689
+ });
690
+ approvalResolverRef.current = resolve;
691
+ })
613
692
  });
614
693
  for await (const event of taskRunner.run(task)) {
694
+ setWorkState(event.workState);
615
695
  if (event.type === "metrics") {
616
696
  if (runnableSettings.provider === "ollama") {
617
697
  usedOllamaModelsRef.current.add(`${runnableSettings.ollamaUrl}|${runnableSettings.model}`);
@@ -634,17 +714,20 @@ export function App(props) {
634
714
  }
635
715
  catch (error) {
636
716
  appendLine({
717
+ kind: "error",
637
718
  tone: "danger",
638
719
  label: "error",
639
- text: error instanceof Error ? error.message : String(error)
720
+ text: error instanceof Error ? error.message : String(error),
721
+ workState: "error"
640
722
  });
641
723
  }
642
724
  finally {
643
725
  abortControllerRef.current = null;
644
726
  setStatus("idle");
727
+ setWorkState("idle");
645
728
  setIsRunning(false);
646
729
  }
647
- }, [appendLine, isRunning, modelOptions, settings]);
730
+ }, [agentMode, appendLine, isRunning, modelOptions, settings]);
648
731
  const handleSlashCommand = useCallback(async (rawCommand) => {
649
732
  const [commandName = "", ...args] = rawCommand.slice(1).trim().split(/\s+/);
650
733
  const command = commandName.toLowerCase();
@@ -769,7 +852,7 @@ export function App(props) {
769
852
  appendLine({
770
853
  tone: "accent",
771
854
  label: "reasoning",
772
- text: `current ${settings.reasoningEffort}. Use /reasoning low, medium, high, xhigh, or adaptive.`
855
+ text: `current ${settings.reasoningEffort}. Use /reasoning none, low, medium, high, xhigh, or adaptive.`
773
856
  });
774
857
  return;
775
858
  }
@@ -783,7 +866,7 @@ export function App(props) {
783
866
  appendLine({
784
867
  tone: "success",
785
868
  label: "reasoning",
786
- text: `provider reasoning ${nextEffort}${settings.provider === "ollama" ? " (Ollama ignores common reasoning effort)" : ""}`
869
+ text: formatReasoningSupport(settings.provider, settings.model, nextEffort === "adaptive" ? undefined : nextEffort)
787
870
  });
788
871
  return;
789
872
  }
@@ -793,6 +876,7 @@ export function App(props) {
793
876
  if (writeEnabled) {
794
877
  setAgentMode("build");
795
878
  }
879
+ grantedPermissionsRef.current.allowWrite = writeEnabled;
796
880
  setSettings((currentSettings) => ({
797
881
  ...currentSettings,
798
882
  allowWrite: writeEnabled
@@ -809,6 +893,7 @@ export function App(props) {
809
893
  if (shellEnabled) {
810
894
  setAgentMode("build");
811
895
  }
896
+ grantedPermissionsRef.current.allowShell = shellEnabled;
812
897
  setSettings((currentSettings) => ({
813
898
  ...currentSettings,
814
899
  allowShell: shellEnabled
@@ -848,7 +933,7 @@ export function App(props) {
848
933
  tone: "warning",
849
934
  label: "model",
850
935
  text: `No unique model match for "${requestedModel}".`,
851
- detail: formatModelOptions(filterModelOptions(requestedModel, models).slice(0, 12), settings.model)
936
+ detail: formatModelOptions(selectableModels(requestedModel, models).slice(0, 12), settings.model)
852
937
  });
853
938
  return;
854
939
  }
@@ -897,6 +982,8 @@ export function App(props) {
897
982
  });
898
983
  return;
899
984
  }
985
+ setInput("/models ");
986
+ setPaletteIndex(0);
900
987
  appendLine({
901
988
  tone: "accent",
902
989
  label: "models",
@@ -919,9 +1006,65 @@ export function App(props) {
919
1006
  label: "status",
920
1007
  text: settings.provider === "ollama"
921
1008
  ? `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"} | write ${settings.allowWrite ? "on" : "off"} | shell ${settings.allowShell ? "on" : "off"} | draft ${draftTokens} tok | last ${formatTokens(telemetry)} | session ${formatSessionTokens(sessionTelemetry)} | cost ${formatCost(sessionTelemetry.estimatedCostUsd)}`
922
- : `provider ${settings.provider} | model ${settings.model} | host ${settings.provider} api | compute cloud | agents ${settings.subagents ? "on" : "off"} | think ${settings.thinkingMode} | reasoning ${settings.reasoningEffort} | write ${settings.allowWrite ? "on" : "off"} | shell ${settings.allowShell ? "on" : "off"} | draft ${draftTokens} tok | last ${formatTokens(telemetry)} | session ${formatSessionTokens(sessionTelemetry)} | cost ${formatCost(sessionTelemetry.estimatedCostUsd)}`
1009
+ : `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)} | write ${settings.allowWrite ? "on" : "off"} | shell ${settings.allowShell ? "on" : "off"} | draft ${draftTokens} tok | last ${formatTokens(telemetry)} | session ${formatSessionTokens(sessionTelemetry)} | cost ${formatCost(sessionTelemetry.estimatedCostUsd)}`
1010
+ });
1011
+ return;
1012
+ case "sessions": {
1013
+ const sessions = await listWorkspaceSessions(settings.workspace);
1014
+ appendLine({
1015
+ kind: "status",
1016
+ tone: sessions.length > 0 ? "accent" : "muted",
1017
+ label: "sessions",
1018
+ text: sessions.length > 0 ? `Found ${sessions.length} workspace session${sessions.length === 1 ? "" : "s"}.` : "No workspace sessions yet.",
1019
+ detail: sessions
1020
+ .slice(0, 8)
1021
+ .map((session, index) => `${index + 1}. ${session.sessionId} ${session.updatedAt} ${session.lastTask ?? "no task"}`)
1022
+ .join("\n")
1023
+ });
1024
+ return;
1025
+ }
1026
+ case "resume": {
1027
+ const sessionId = args[0] ?? "";
1028
+ const sessions = await listWorkspaceSessions(settings.workspace);
1029
+ const selectedSession = sessionId ? await loadSessionSummary(settings.workspace, sessionId) : sessions[0] ?? null;
1030
+ appendLine({
1031
+ kind: "status",
1032
+ tone: selectedSession ? "accent" : "warning",
1033
+ label: "resume",
1034
+ text: selectedSession ? `Loaded session ${selectedSession.sessionId}` : "No session available to resume.",
1035
+ detail: selectedSession
1036
+ ? `workspace ${selectedSession.workspace}\nupdated ${selectedSession.updatedAt}\nmodel ${selectedSession.provider ?? "-"} ${selectedSession.model ?? "-"}\nlast task ${selectedSession.lastTask ?? "-"}`
1037
+ : "Run /sessions after at least one PatchPilot run."
923
1038
  });
924
1039
  return;
1040
+ }
1041
+ case "diff": {
1042
+ const result = await new WorkspaceTools({
1043
+ root: settings.workspace,
1044
+ allowWrite: false,
1045
+ allowShell: false
1046
+ }).execute({
1047
+ name: "git_diff",
1048
+ arguments: {}
1049
+ });
1050
+ appendLine({
1051
+ kind: "diff",
1052
+ tone: result.ok ? "accent" : "warning",
1053
+ label: "diff",
1054
+ text: result.summary,
1055
+ detail: result.content,
1056
+ tool: "git_diff"
1057
+ });
1058
+ return;
1059
+ }
1060
+ case "approve": {
1061
+ const decision = args[0] === "session" ? "allow_session" : "allow_once";
1062
+ resolveApproval(decision);
1063
+ return;
1064
+ }
1065
+ case "deny":
1066
+ resolveApproval("deny");
1067
+ return;
925
1068
  case "connect":
926
1069
  case "host":
927
1070
  case "ollama":
@@ -940,6 +1083,8 @@ export function App(props) {
940
1083
  text: "Scanning LAN and Tailscale for Ollama hosts..."
941
1084
  });
942
1085
  await loadHostSuggestions(true, true);
1086
+ setInput("/connect ");
1087
+ setPaletteIndex(0);
943
1088
  return;
944
1089
  }
945
1090
  if (args.join(" ").trim().toLowerCase() === "local") {
@@ -965,6 +1110,8 @@ export function App(props) {
965
1110
  text: "Scanning LAN and Tailscale for Ollama hosts..."
966
1111
  });
967
1112
  await loadHostSuggestions(true, true);
1113
+ setInput("/connect ");
1114
+ setPaletteIndex(0);
968
1115
  return;
969
1116
  case "eject": {
970
1117
  if (settings.provider !== "ollama") {
@@ -1050,6 +1197,7 @@ export function App(props) {
1050
1197
  loadHostSuggestions,
1051
1198
  loadProviderModels,
1052
1199
  modelOptions,
1200
+ resolveApproval,
1053
1201
  sessionTelemetry,
1054
1202
  settings,
1055
1203
  telemetry
@@ -1081,7 +1229,10 @@ export function App(props) {
1081
1229
  await runTask(nextValue);
1082
1230
  }, [handleOnboardingSubmit, handleSlashCommand, isRunning, onboarding, paletteIndex, paletteItems, runTask]);
1083
1231
  useEffect(() => {
1084
- if (!props.initialTask || didRunInitialTask.current || onboarding) {
1232
+ void sessionStoreRef.current.create();
1233
+ }, []);
1234
+ useEffect(() => {
1235
+ if (!props.initialTask || didRunInitialTask.current || onboarding || process.env.PATCHPILOT_ONBOARDING_COMPLETE !== "1") {
1085
1236
  return;
1086
1237
  }
1087
1238
  didRunInitialTask.current = true;
@@ -1091,7 +1242,7 @@ export function App(props) {
1091
1242
  setPaletteIndex(0);
1092
1243
  }, [hostOptions, input, modelOptions, onboarding, settings.model, settings.provider]);
1093
1244
  useEffect(() => {
1094
- if (didOpenDefaultOnboarding.current || props.initialTask || onboarding || process.env.PATCHPILOT_ONBOARDING_COMPLETE === "1") {
1245
+ if (didOpenDefaultOnboarding.current || onboarding || process.env.PATCHPILOT_ONBOARDING_COMPLETE === "1") {
1095
1246
  return;
1096
1247
  }
1097
1248
  didOpenDefaultOnboarding.current = true;
@@ -1109,6 +1260,10 @@ export function App(props) {
1109
1260
  }
1110
1261
  let cancelled = false;
1111
1262
  async function syncActiveHost() {
1263
+ if (activeHostSyncInFlightRef.current) {
1264
+ return;
1265
+ }
1266
+ activeHostSyncInFlightRef.current = true;
1112
1267
  const verifiedHost = await checkOllamaHost(settings.ollamaUrl, {
1113
1268
  timeoutMs: 800
1114
1269
  });
@@ -1116,6 +1271,7 @@ export function App(props) {
1116
1271
  if (!cancelled) {
1117
1272
  setActiveHost((currentHost) => (currentHost?.host.url === settings.ollamaUrl ? currentHost : null));
1118
1273
  }
1274
+ activeHostSyncInFlightRef.current = false;
1119
1275
  return;
1120
1276
  }
1121
1277
  const details = await readOllamaHostDetails(verifiedHost).catch(() => ({
@@ -1124,6 +1280,7 @@ export function App(props) {
1124
1280
  runningModels: [],
1125
1281
  fetchedAt: Date.now()
1126
1282
  }));
1283
+ activeHostSyncInFlightRef.current = false;
1127
1284
  if (cancelled) {
1128
1285
  return;
1129
1286
  }
@@ -1147,16 +1304,40 @@ export function App(props) {
1147
1304
  }
1148
1305
  const trimmedInput = input.trim();
1149
1306
  if (settings.provider === "ollama" && (trimmedInput === "/connect" || trimmedInput === "/hosts") && hostOptions.length === 0 && !isLoadingHosts) {
1150
- void loadHostSuggestions(false, false);
1307
+ const key = `${settings.provider}:${settings.ollamaUrl}:${trimmedInput}:hosts`;
1308
+ if (!autoLoadKeysRef.current.has(key)) {
1309
+ autoLoadKeysRef.current.add(key);
1310
+ void loadHostSuggestions(false, false);
1311
+ }
1151
1312
  }
1152
1313
  if ((trimmedInput === "/models" || trimmedInput === "/model") && modelOptions.length === 0 && !isLoadingModels) {
1153
- void loadProviderModels(false);
1314
+ const key = `${settings.provider}:${settings.ollamaUrl}:${trimmedInput}:models`;
1315
+ if (!autoLoadKeysRef.current.has(key)) {
1316
+ autoLoadKeysRef.current.add(key);
1317
+ void loadProviderModels(false);
1318
+ }
1154
1319
  }
1155
1320
  }, [hostOptions.length, input, isLoadingHosts, isLoadingModels, isRunning, loadHostSuggestions, loadProviderModels, modelOptions.length, onboarding, settings.provider]);
1156
1321
  useInput((inputValue, key) => {
1322
+ if (pendingApproval) {
1323
+ const normalizedInput = inputValue.toLowerCase();
1324
+ if (normalizedInput === "y") {
1325
+ resolveApproval("allow_once");
1326
+ return;
1327
+ }
1328
+ if (normalizedInput === "a") {
1329
+ resolveApproval("allow_session");
1330
+ return;
1331
+ }
1332
+ if (normalizedInput === "n" || key.escape) {
1333
+ resolveApproval("deny");
1334
+ return;
1335
+ }
1336
+ }
1157
1337
  if (isRunning && key.escape) {
1158
1338
  abortControllerRef.current?.abort();
1159
1339
  appendLine({
1340
+ kind: "status",
1160
1341
  tone: "warning",
1161
1342
  label: "stop",
1162
1343
  text: "Stopping current task..."
@@ -1169,7 +1350,10 @@ export function App(props) {
1169
1350
  goBackOnboarding();
1170
1351
  return;
1171
1352
  }
1172
- const optionCount = onboarding.step === "model" ? filterModelOptions(onboardingInput, onboarding.models).length : getOnboardingOptionCount(onboarding);
1353
+ if (onboardingBusyMessage) {
1354
+ return;
1355
+ }
1356
+ const optionCount = onboarding.step === "model" ? selectableModels(onboardingInput, onboarding.models).length : getOnboardingOptionCount(onboarding);
1173
1357
  if (optionCount > 0 && key.upArrow) {
1174
1358
  setOnboardingIndex((currentIndex) => (currentIndex - 1 + optionCount) % optionCount);
1175
1359
  return;
@@ -1277,7 +1461,7 @@ export function App(props) {
1277
1461
  clearInterval(timer);
1278
1462
  };
1279
1463
  }, []);
1280
- 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, 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, 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, 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 })] })] }))] }));
1464
+ 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 })] })] }))] }));
1281
1465
  }
1282
1466
  async function loadAvailableModels(provider, ollamaUrl, setModelOptions, refresh = false) {
1283
1467
  const cacheKey = `${provider}:${provider === "ollama" ? ollamaUrl : "default"}`;
@@ -1451,7 +1635,7 @@ function buildCommandSuggestionItems(options) {
1451
1635
  });
1452
1636
  }
1453
1637
  else {
1454
- items.unshift(...filterModelOptions(modelQuery, options.modelOptions).slice(0, 8).map((model) => ({
1638
+ items.unshift(...selectableModels(modelQuery, options.modelOptions).slice(0, 8).map((model) => ({
1455
1639
  key: `model-${model}`,
1456
1640
  category: "model",
1457
1641
  label: model,
@@ -1470,7 +1654,7 @@ function getOnboardingOptionCount(onboarding) {
1470
1654
  case "host":
1471
1655
  return onboarding.hosts.length + 1;
1472
1656
  case "api-key-choice":
1473
- return 2;
1657
+ return onboarding.hasExistingKey ? 2 : 1;
1474
1658
  case "model":
1475
1659
  return onboarding.models.length;
1476
1660
  default:
@@ -1525,7 +1709,7 @@ function selectModelFromInput(value, models, selectedIndex, options = {}) {
1525
1709
  if (models.includes(normalizedValue)) {
1526
1710
  return normalizedValue;
1527
1711
  }
1528
- const matches = filterModelOptions(normalizedValue, models);
1712
+ const matches = selectableModels(normalizedValue, models);
1529
1713
  if (matches.length === 1) {
1530
1714
  return matches[0] ?? null;
1531
1715
  }
@@ -1534,34 +1718,6 @@ function selectModelFromInput(value, models, selectedIndex, options = {}) {
1534
1718
  function isPlausibleCloudModelId(value) {
1535
1719
  return /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(value) && value.length >= 3;
1536
1720
  }
1537
- function filterModelOptions(query, models) {
1538
- const normalizedQuery = query.trim().toLowerCase();
1539
- if (!normalizedQuery) {
1540
- return models;
1541
- }
1542
- return models
1543
- .map((model) => ({
1544
- model,
1545
- score: scoreModelMatch(model, normalizedQuery)
1546
- }))
1547
- .filter((item) => item.score !== null)
1548
- .sort((left, right) => left.score - right.score || left.model.localeCompare(right.model))
1549
- .map((item) => item.model);
1550
- }
1551
- function scoreModelMatch(model, query) {
1552
- const normalizedModel = model.toLowerCase();
1553
- if (normalizedModel === query) {
1554
- return 0;
1555
- }
1556
- if (normalizedModel.startsWith(query)) {
1557
- return 1;
1558
- }
1559
- if (normalizedModel.includes(query)) {
1560
- return 2 + normalizedModel.indexOf(query) / 1000;
1561
- }
1562
- const tokens = query.split(/[\s/:_-]+/).filter(Boolean);
1563
- return tokens.length > 0 && tokens.every((token) => normalizedModel.includes(token)) ? 10 : null;
1564
- }
1565
1721
  function defaultModelForProvider(provider, currentModel) {
1566
1722
  if (provider === "nvidia") {
1567
1723
  return currentModel.includes("/") && !currentModel.startsWith("openrouter/") ? currentModel : defaultNvidiaModel;
@@ -1633,7 +1789,7 @@ async function ejectOllamaModels(options) {
1633
1789
  return ejected;
1634
1790
  }
1635
1791
  function isReasoningEffort(value) {
1636
- return value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "adaptive";
1792
+ return value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "adaptive";
1637
1793
  }
1638
1794
  function upsertAdvisorNote(notes, nextNote) {
1639
1795
  const nextNotes = notes.filter((note) => note.role !== nextNote.role);
@@ -1643,46 +1799,75 @@ function eventToLine(event) {
1643
1799
  switch (event.type) {
1644
1800
  case "status":
1645
1801
  return {
1802
+ kind: "status",
1646
1803
  tone: "muted",
1647
- label: "thinking",
1648
- text: event.message
1804
+ label: event.workState,
1805
+ text: event.message,
1806
+ workState: event.workState
1649
1807
  };
1650
1808
  case "assistant":
1651
1809
  return {
1810
+ kind: "assistant",
1652
1811
  tone: "accent",
1653
1812
  label: "pilot",
1654
- text: event.message
1813
+ text: event.message,
1814
+ workState: event.workState
1655
1815
  };
1656
1816
  case "subagent":
1657
1817
  return {
1818
+ kind: "assistant",
1658
1819
  tone: "accent",
1659
1820
  label: event.role,
1660
1821
  text: "advisor brief updated",
1661
- detail: event.message
1822
+ detail: event.message,
1823
+ workState: event.workState
1662
1824
  };
1663
1825
  case "tool":
1664
1826
  return {
1827
+ kind: event.name === "git_diff" ? "diff" : "tool",
1665
1828
  tone: event.ok ? "success" : "warning",
1666
1829
  label: event.name,
1667
- text: event.summary
1830
+ text: event.summary,
1831
+ workState: event.workState,
1832
+ tool: event.name,
1833
+ toolCallId: event.toolCallId,
1834
+ category: event.category,
1835
+ preview: event.preview
1836
+ };
1837
+ case "approval":
1838
+ return {
1839
+ kind: "approval",
1840
+ tone: event.decision === "deny" ? "warning" : "success",
1841
+ label: "approval",
1842
+ text: `${event.request.tool} ${event.decision.replace("_", " ")}`,
1843
+ detail: event.request.preview,
1844
+ workState: event.workState,
1845
+ tool: event.request.tool,
1846
+ preview: event.request.preview
1668
1847
  };
1669
1848
  case "final":
1670
1849
  return {
1850
+ kind: "final",
1671
1851
  tone: "success",
1672
1852
  label: "final",
1673
- text: event.message
1853
+ text: event.message,
1854
+ workState: event.workState
1674
1855
  };
1675
1856
  case "error":
1676
1857
  return {
1858
+ kind: "error",
1677
1859
  tone: "danger",
1678
1860
  label: "error",
1679
- text: event.message
1861
+ text: event.message,
1862
+ workState: event.workState
1680
1863
  };
1681
1864
  case "metrics":
1682
1865
  return {
1866
+ kind: "status",
1683
1867
  tone: "muted",
1684
1868
  label: "metrics",
1685
- text: formatTokens(event.metrics)
1869
+ text: formatTokens(event.metrics),
1870
+ workState: event.workState
1686
1871
  };
1687
1872
  }
1688
1873
  }
@@ -1696,8 +1881,26 @@ function eventToStatus(event) {
1696
1881
  if (event.type === "subagent") {
1697
1882
  return `${event.role} subagent`;
1698
1883
  }
1884
+ if (event.type === "approval") {
1885
+ return `${event.request.tool}: ${event.decision.replace("_", " ")}`;
1886
+ }
1699
1887
  return event.type;
1700
1888
  }
1889
+ function defaultLogKind(line) {
1890
+ if (line.kind) {
1891
+ return line.kind;
1892
+ }
1893
+ if (line.label === "you") {
1894
+ return "user";
1895
+ }
1896
+ if (line.label === "error") {
1897
+ return "error";
1898
+ }
1899
+ if (line.label === "final") {
1900
+ return "final";
1901
+ }
1902
+ return "status";
1903
+ }
1701
1904
  function formatHostOptions(hosts) {
1702
1905
  return hosts
1703
1906
  .map((host, index) => {