@jx-grxf/patchpilot 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +314 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +71 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/agent.d.ts +21 -0
  8. package/dist/core/agent.js +346 -0
  9. package/dist/core/agent.js.map +1 -0
  10. package/dist/core/codex.d.ts +22 -0
  11. package/dist/core/codex.js +242 -0
  12. package/dist/core/codex.js.map +1 -0
  13. package/dist/core/compute.d.ts +9 -0
  14. package/dist/core/compute.js +18 -0
  15. package/dist/core/compute.js.map +1 -0
  16. package/dist/core/doctor.d.ts +7 -0
  17. package/dist/core/doctor.js +226 -0
  18. package/dist/core/doctor.js.map +1 -0
  19. package/dist/core/env.d.ts +6 -0
  20. package/dist/core/env.js +103 -0
  21. package/dist/core/env.js.map +1 -0
  22. package/dist/core/gemini.d.ts +20 -0
  23. package/dist/core/gemini.js +177 -0
  24. package/dist/core/gemini.js.map +1 -0
  25. package/dist/core/json.d.ts +3 -0
  26. package/dist/core/json.js +95 -0
  27. package/dist/core/json.js.map +1 -0
  28. package/dist/core/modelClient.d.ts +8 -0
  29. package/dist/core/modelClient.js +42 -0
  30. package/dist/core/modelClient.js.map +1 -0
  31. package/dist/core/nvidia.d.ts +19 -0
  32. package/dist/core/nvidia.js +160 -0
  33. package/dist/core/nvidia.js.map +1 -0
  34. package/dist/core/ollama.d.ts +31 -0
  35. package/dist/core/ollama.js +176 -0
  36. package/dist/core/ollama.js.map +1 -0
  37. package/dist/core/openrouter.d.ts +27 -0
  38. package/dist/core/openrouter.js +168 -0
  39. package/dist/core/openrouter.js.map +1 -0
  40. package/dist/core/subagents.d.ts +14 -0
  41. package/dist/core/subagents.js +89 -0
  42. package/dist/core/subagents.js.map +1 -0
  43. package/dist/core/tokenAccounting.d.ts +6 -0
  44. package/dist/core/tokenAccounting.js +134 -0
  45. package/dist/core/tokenAccounting.js.map +1 -0
  46. package/dist/core/types.d.ts +90 -0
  47. package/dist/core/types.js +2 -0
  48. package/dist/core/types.js.map +1 -0
  49. package/dist/core/workspace.d.ts +28 -0
  50. package/dist/core/workspace.js +616 -0
  51. package/dist/core/workspace.js.map +1 -0
  52. package/dist/tui/App.d.ts +6 -0
  53. package/dist/tui/App.js +1717 -0
  54. package/dist/tui/App.js.map +1 -0
  55. package/dist/tui/commands.d.ts +14 -0
  56. package/dist/tui/commands.js +210 -0
  57. package/dist/tui/commands.js.map +1 -0
  58. package/dist/tui/components/CommandSuggestions.d.ts +12 -0
  59. package/dist/tui/components/CommandSuggestions.js +12 -0
  60. package/dist/tui/components/CommandSuggestions.js.map +1 -0
  61. package/dist/tui/components/Composer.d.ts +13 -0
  62. package/dist/tui/components/Composer.js +29 -0
  63. package/dist/tui/components/Composer.js.map +1 -0
  64. package/dist/tui/components/Header.d.ts +25 -0
  65. package/dist/tui/components/Header.js +62 -0
  66. package/dist/tui/components/Header.js.map +1 -0
  67. package/dist/tui/components/OnboardingPanel.d.ts +38 -0
  68. package/dist/tui/components/OnboardingPanel.js +85 -0
  69. package/dist/tui/components/OnboardingPanel.js.map +1 -0
  70. package/dist/tui/components/Sidebar.d.ts +22 -0
  71. package/dist/tui/components/Sidebar.js +133 -0
  72. package/dist/tui/components/Sidebar.js.map +1 -0
  73. package/dist/tui/components/Transcript.d.ts +10 -0
  74. package/dist/tui/components/Transcript.js +111 -0
  75. package/dist/tui/components/Transcript.js.map +1 -0
  76. package/dist/tui/format.d.ts +29 -0
  77. package/dist/tui/format.js +202 -0
  78. package/dist/tui/format.js.map +1 -0
  79. package/dist/tui/hosts.d.ts +34 -0
  80. package/dist/tui/hosts.js +338 -0
  81. package/dist/tui/hosts.js.map +1 -0
  82. package/dist/tui/inputRouting.d.ts +8 -0
  83. package/dist/tui/inputRouting.js +94 -0
  84. package/dist/tui/inputRouting.js.map +1 -0
  85. package/dist/tui/platform.d.ts +2 -0
  86. package/dist/tui/platform.js +13 -0
  87. package/dist/tui/platform.js.map +1 -0
  88. package/dist/tui/systemStats.d.ts +25 -0
  89. package/dist/tui/systemStats.js +88 -0
  90. package/dist/tui/systemStats.js.map +1 -0
  91. package/dist/tui/types.d.ts +16 -0
  92. package/dist/tui/types.js +2 -0
  93. package/dist/tui/types.js.map +1 -0
  94. package/docs/showcase/patchpilot-showcase.svg +39 -0
  95. package/package.json +63 -0
@@ -0,0 +1,1717 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { Box, useApp, useInput, useStdout } from "ink";
4
+ import { AgentRunner } from "../core/agent.js";
5
+ import { defaultCodexModel, hasCodexCliOAuth } from "../core/codex.js";
6
+ import { describeComputeTarget } from "../core/compute.js";
7
+ import { runDoctor } from "../core/doctor.js";
8
+ import { savePatchPilotEnvValues } from "../core/env.js";
9
+ import { defaultGeminiModel, readGeminiApiKey } from "../core/gemini.js";
10
+ import { createModelClient } from "../core/modelClient.js";
11
+ import { defaultNvidiaModel, readNvidiaApiKey } from "../core/nvidia.js";
12
+ import { defaultOllamaModel, OllamaClient } from "../core/ollama.js";
13
+ import { defaultOpenRouterModel, isOpenRouterFreeModel, readOpenRouterApiKey } from "../core/openrouter.js";
14
+ import { addTelemetryToSession, emptySessionTelemetry, estimateTokens } from "../core/tokenAccounting.js";
15
+ import { CommandSuggestions } from "./components/CommandSuggestions.js";
16
+ import { Composer, FooterHints } from "./components/Composer.js";
17
+ import { Header } from "./components/Header.js";
18
+ import { OnboardingPanel } from "./components/OnboardingPanel.js";
19
+ import { Sidebar } from "./components/Sidebar.js";
20
+ import { Transcript } from "./components/Transcript.js";
21
+ import { filterSlashCommands, formatCommandDetail, formatCommandHelp } from "./commands.js";
22
+ import { formatCost, formatSessionTokens, formatTokens, normalizeModelAlias, readToggle } from "./format.js";
23
+ import { checkOllamaHost, discoverOllamaHosts, normalizeOllamaUrl, readOllamaHostDetails, startLocalOllamaAppAndWait } from "./hosts.js";
24
+ import { readGpuStats, readSystemStats } from "./systemStats.js";
25
+ import { maxTranscriptLines } from "./types.js";
26
+ const modelCacheTtlMs = 5 * 60_000;
27
+ const modelCache = new Map();
28
+ export function App(props) {
29
+ const { exit } = useApp();
30
+ const { stdout } = useStdout();
31
+ const [input, setInput] = useState(props.initialTask ?? "");
32
+ const didRunInitialTask = useRef(false);
33
+ const didOpenDefaultOnboarding = useRef(false);
34
+ const abortControllerRef = useRef(null);
35
+ const usedOllamaModelsRef = useRef(new Set());
36
+ const [lines, setLines] = useState([]);
37
+ const [advisorNotes, setAdvisorNotes] = useState([]);
38
+ const [isRunning, setIsRunning] = useState(false);
39
+ const [status, setStatus] = useState("idle");
40
+ const [telemetry, setTelemetry] = useState(null);
41
+ const [sessionTelemetry, setSessionTelemetry] = useState(() => emptySessionTelemetry());
42
+ const [systemStats, setSystemStats] = useState(() => readSystemStats().stats);
43
+ const [gpuStats, setGpuStats] = useState(null);
44
+ const [agentMode, setAgentMode] = useState(props.allowWrite || props.allowShell ? "build" : "plan");
45
+ const [hostOptions, setHostOptions] = useState([]);
46
+ const [activeHost, setActiveHost] = useState(null);
47
+ const [isLoadingHosts, setIsLoadingHosts] = useState(false);
48
+ const [modelOptions, setModelOptions] = useState([]);
49
+ const [isLoadingModels, setIsLoadingModels] = useState(false);
50
+ const [onboarding, setOnboarding] = useState(null);
51
+ const [onboardingIndex, setOnboardingIndex] = useState(0);
52
+ const [onboardingInput, setOnboardingInput] = useState("");
53
+ const [onboardingBusyMessage, setOnboardingBusyMessage] = useState(null);
54
+ const [paletteIndex, setPaletteIndex] = useState(0);
55
+ const [activeScrollPane, setActiveScrollPane] = useState("transcript");
56
+ const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
57
+ const [sessionScrollOffset, setSessionScrollOffset] = useState(0);
58
+ const [settings, setSettings] = useState({
59
+ provider: props.provider,
60
+ model: props.model,
61
+ ollamaUrl: props.ollamaUrl,
62
+ workspace: props.workspace,
63
+ allowWrite: props.allowWrite,
64
+ allowShell: props.allowShell,
65
+ maxSteps: props.maxSteps,
66
+ thinkingMode: props.thinkingMode,
67
+ reasoningEffort: props.reasoningEffort,
68
+ subagents: props.subagents
69
+ });
70
+ const draftTokens = estimateTokens(input);
71
+ const terminalRows = stdout.rows ?? 40;
72
+ const terminalColumns = stdout.columns ?? 120;
73
+ const paletteItems = !isRunning && !onboarding
74
+ ? buildCommandSuggestionItems({
75
+ input,
76
+ provider: settings.provider,
77
+ hostOptions,
78
+ modelOptions,
79
+ currentModel: settings.model,
80
+ isLoadingHosts,
81
+ isLoadingModels
82
+ })
83
+ : [];
84
+ const rootHeight = Math.max(24, terminalRows);
85
+ const headerReservedHeight = 8;
86
+ const paletteReservedHeight = !onboarding && paletteItems.length > 0 ? Math.min(8, paletteItems.length) + 4 : 0;
87
+ const composerReservedHeight = onboarding ? 0 : 2;
88
+ const footerReservedHeight = onboarding ? 0 : 1;
89
+ const panelHeight = Math.max(8, rootHeight - headerReservedHeight - composerReservedHeight - paletteReservedHeight - footerReservedHeight);
90
+ const transcriptWidth = Math.max(42, terminalColumns - 38);
91
+ const scrollStep = Math.max(4, Math.floor(panelHeight * 0.8));
92
+ const appendLine = useCallback((line) => {
93
+ setLines((currentLines) => [
94
+ ...currentLines.slice(-maxTranscriptLines),
95
+ {
96
+ ...line,
97
+ id: Date.now() + Math.random()
98
+ }
99
+ ]);
100
+ }, []);
101
+ const applyMode = useCallback((nextMode, announce = true) => {
102
+ setAgentMode(nextMode);
103
+ setSettings((currentSettings) => ({
104
+ ...currentSettings,
105
+ allowWrite: nextMode === "build" ? currentSettings.allowWrite : false,
106
+ allowShell: nextMode === "build" ? currentSettings.allowShell : false
107
+ }));
108
+ if (announce) {
109
+ appendLine({
110
+ tone: "success",
111
+ label: "mode",
112
+ text: `${nextMode} mode ${nextMode === "plan" ? "keeps tools read-only" : "uses enabled write/shell permissions"}`
113
+ });
114
+ }
115
+ }, [appendLine]);
116
+ const toggleMode = useCallback(() => {
117
+ applyMode(agentMode === "plan" ? "build" : "plan");
118
+ }, [agentMode, applyMode]);
119
+ const loadHostSuggestions = useCallback(async (refresh = false, announce = false) => {
120
+ if (isLoadingHosts) {
121
+ return hostOptions;
122
+ }
123
+ setIsLoadingHosts(true);
124
+ try {
125
+ const hosts = await discoverOllamaHosts(settings.ollamaUrl, {
126
+ refresh
127
+ });
128
+ setHostOptions(hosts);
129
+ if (announce) {
130
+ appendLine({
131
+ tone: hosts.length > 0 ? "accent" : "warning",
132
+ label: "hosts",
133
+ text: hosts.length > 0
134
+ ? `Found ${hosts.length} Ollama host${hosts.length === 1 ? "" : "s"}. Pick one with /connect or the command palette.`
135
+ : "No reachable Ollama hosts found.",
136
+ detail: hosts.length > 0
137
+ ? formatHostOptions(hosts)
138
+ : "PatchPilot scanned the local LAN and Tailscale peers. Try /connect <host> for a manual URL or MagicDNS name."
139
+ });
140
+ }
141
+ return hosts;
142
+ }
143
+ finally {
144
+ setIsLoadingHosts(false);
145
+ }
146
+ }, [appendLine, hostOptions, isLoadingHosts, settings.ollamaUrl]);
147
+ const loadProviderModels = useCallback(async (refresh = false) => {
148
+ if (isLoadingModels) {
149
+ return modelOptions;
150
+ }
151
+ setIsLoadingModels(true);
152
+ try {
153
+ return await loadAvailableModels(settings.provider, settings.ollamaUrl, setModelOptions, refresh);
154
+ }
155
+ finally {
156
+ setIsLoadingModels(false);
157
+ }
158
+ }, [isLoadingModels, modelOptions, settings.ollamaUrl, settings.provider]);
159
+ const connectToHost = useCallback(async (value, options = {}) => {
160
+ const candidate = typeof value === "string" ? null : value;
161
+ const nextUrl = typeof value === "string" ? normalizeOllamaUrl(value) : value.url;
162
+ const verifiedHost = await checkOllamaHost(nextUrl, {
163
+ ...candidate,
164
+ timeoutMs: 1200
165
+ });
166
+ if (!verifiedHost) {
167
+ if (options.announce !== false) {
168
+ appendLine({
169
+ tone: "warning",
170
+ label: "ollama",
171
+ text: `No Ollama server answered at ${nextUrl}.`,
172
+ detail: "Check the IP, MagicDNS name, firewall rules, and whether Ollama is listening on the remote machine."
173
+ });
174
+ }
175
+ return null;
176
+ }
177
+ const details = await readOllamaHostDetails(verifiedHost, true).catch(() => ({
178
+ host: verifiedHost,
179
+ models: [],
180
+ runningModels: [],
181
+ fetchedAt: Date.now()
182
+ }));
183
+ setTelemetry(null);
184
+ setActiveHost(details);
185
+ setHostOptions((currentHosts) => [verifiedHost, ...currentHosts.filter((host) => host.url !== verifiedHost.url)]);
186
+ setModelOptions(details.models);
187
+ modelCache.set(`ollama:${verifiedHost.url}`, {
188
+ models: details.models,
189
+ expiresAt: Date.now() + modelCacheTtlMs
190
+ });
191
+ setSettings((currentSettings) => ({
192
+ ...currentSettings,
193
+ provider: "ollama",
194
+ ollamaUrl: verifiedHost.url
195
+ }));
196
+ savePatchPilotEnvValues({
197
+ PATCHPILOT_PROVIDER: "ollama",
198
+ PATCHPILOT_OLLAMA_URL: verifiedHost.url
199
+ });
200
+ if (options.announce !== false) {
201
+ appendLine({
202
+ tone: "success",
203
+ label: "ollama",
204
+ text: `connected to ${verifiedHost.deviceName}`,
205
+ detail: `Ollama ${verifiedHost.version ?? "unknown version"} at ${verifiedHost.url}. Only inference runs on this host; file reads, writes, shell, Git, and tests stay on this device.`
206
+ });
207
+ if (details.models.length > 0 && !details.models.includes(settings.model)) {
208
+ appendLine({
209
+ tone: "warning",
210
+ label: "model",
211
+ text: `${settings.model} is not available on ${verifiedHost.deviceName}.`,
212
+ detail: `Pick a host model with /models. Available:\n${formatModelOptions(details.models, settings.model)}`
213
+ });
214
+ }
215
+ }
216
+ return details;
217
+ }, [appendLine, settings.model]);
218
+ const openModelSelection = useCallback(async (provider, options = {}) => {
219
+ setTelemetry(null);
220
+ setOnboardingBusyMessage(`Loading ${provider} models...`);
221
+ const nextModel = defaultModelForProvider(provider, options.currentModel ?? settings.model);
222
+ setSettings((currentSettings) => ({
223
+ ...currentSettings,
224
+ provider,
225
+ model: nextModel
226
+ }));
227
+ try {
228
+ const models = await loadAvailableModels(provider, options.ollamaUrl ?? settings.ollamaUrl, setModelOptions, true);
229
+ if (models.length === 0) {
230
+ appendLine({
231
+ tone: "warning",
232
+ label: "onboarding",
233
+ text: provider === "ollama"
234
+ ? "No Ollama models found on that host."
235
+ : provider === "gemini"
236
+ ? "No Gemini models listed. Check the API key."
237
+ : provider === "openrouter"
238
+ ? "No OpenRouter models listed. Check the API key."
239
+ : "No Codex OAuth models listed."
240
+ });
241
+ return;
242
+ }
243
+ setOnboarding({
244
+ step: "model",
245
+ provider,
246
+ models,
247
+ deviceName: options.deviceName
248
+ });
249
+ setOnboardingIndex(0);
250
+ }
251
+ catch (error) {
252
+ appendLine({
253
+ tone: "danger",
254
+ label: "onboarding",
255
+ text: error instanceof Error ? error.message : String(error)
256
+ });
257
+ }
258
+ finally {
259
+ setOnboardingBusyMessage(null);
260
+ }
261
+ }, [appendLine, settings.model, settings.ollamaUrl]);
262
+ const closeOnboarding = useCallback(() => {
263
+ setOnboarding(null);
264
+ setOnboardingIndex(0);
265
+ setOnboardingInput("");
266
+ setOnboardingBusyMessage(null);
267
+ }, []);
268
+ const goBackOnboarding = useCallback(() => {
269
+ if (!onboarding) {
270
+ return;
271
+ }
272
+ setOnboardingBusyMessage(null);
273
+ setOnboardingInput("");
274
+ setOnboardingIndex(0);
275
+ switch (onboarding.step) {
276
+ case "entry":
277
+ setOnboarding(null);
278
+ return;
279
+ case "host":
280
+ case "api-key-choice":
281
+ case "gemini-key":
282
+ case "openrouter-key":
283
+ case "nvidia-key":
284
+ case "codex-login":
285
+ setOnboarding({
286
+ step: "entry"
287
+ });
288
+ return;
289
+ case "host-input":
290
+ setOnboarding({
291
+ step: "host",
292
+ hosts: hostOptions
293
+ });
294
+ return;
295
+ case "model":
296
+ if (onboarding.provider === "ollama" && activeHost?.host.kind !== "local") {
297
+ setOnboarding({
298
+ step: "host",
299
+ hosts: hostOptions
300
+ });
301
+ return;
302
+ }
303
+ if (onboarding.provider === "gemini") {
304
+ openApiKeyChoice("gemini", setOnboarding, setOnboardingIndex);
305
+ return;
306
+ }
307
+ if (onboarding.provider === "nvidia") {
308
+ openApiKeyChoice("nvidia", setOnboarding, setOnboardingIndex);
309
+ return;
310
+ }
311
+ if (onboarding.provider === "openrouter") {
312
+ openApiKeyChoice("openrouter", setOnboarding, setOnboardingIndex);
313
+ return;
314
+ }
315
+ if (onboarding.provider === "codex" && !hasCodexCliOAuth()) {
316
+ setOnboarding({
317
+ step: "codex-login"
318
+ });
319
+ return;
320
+ }
321
+ setOnboarding({
322
+ step: "entry"
323
+ });
324
+ }
325
+ }, [activeHost?.host.kind, hostOptions, onboarding]);
326
+ const handleOnboardingSubmit = useCallback(async (value) => {
327
+ if (!onboarding) {
328
+ return;
329
+ }
330
+ if (onboarding.step === "entry") {
331
+ const selection = readEntrySelection(value, onboardingIndex);
332
+ if (!selection) {
333
+ return;
334
+ }
335
+ if (selection === "local") {
336
+ setOnboardingBusyMessage("Checking local Ollama...");
337
+ let details = await connectToHost("local", {
338
+ announce: false
339
+ });
340
+ if (!details && process.platform === "darwin") {
341
+ setOnboardingBusyMessage("Starting Ollama.app and waiting for the local server...");
342
+ const startedHost = await startLocalOllamaAppAndWait();
343
+ details = startedHost ? await connectToHost(startedHost, { announce: false }) : null;
344
+ }
345
+ if (!details) {
346
+ setOnboardingBusyMessage("Local Ollama is not reachable. Start Ollama.app or run `ollama serve`, then press Enter again.");
347
+ return;
348
+ }
349
+ await openModelSelection("ollama", {
350
+ deviceName: details.host.deviceName,
351
+ ollamaUrl: details.host.url
352
+ });
353
+ return;
354
+ }
355
+ if (selection === "host") {
356
+ setOnboardingBusyMessage("Scanning LAN and Tailscale for Ollama hosts...");
357
+ try {
358
+ const hosts = await loadHostSuggestions(true, false);
359
+ setOnboarding({
360
+ step: "host",
361
+ hosts
362
+ });
363
+ setOnboardingIndex(0);
364
+ }
365
+ finally {
366
+ setOnboardingBusyMessage(null);
367
+ }
368
+ return;
369
+ }
370
+ if (selection === "gemini" || selection === "openrouter" || selection === "nvidia") {
371
+ openApiKeyChoice(selection, setOnboarding, setOnboardingIndex);
372
+ return;
373
+ }
374
+ if (!hasCodexCliOAuth()) {
375
+ setOnboarding({
376
+ step: "codex-login"
377
+ });
378
+ return;
379
+ }
380
+ await openModelSelection("codex");
381
+ return;
382
+ }
383
+ if (onboarding.step === "host") {
384
+ const selectionIndex = readIndexedSelection(value, onboardingIndex);
385
+ if (selectionIndex === null) {
386
+ return;
387
+ }
388
+ if (selectionIndex === 0) {
389
+ setOnboarding({
390
+ step: "host-input"
391
+ });
392
+ setOnboardingInput("");
393
+ return;
394
+ }
395
+ const selectedHost = onboarding.hosts[selectionIndex - 1];
396
+ if (!selectedHost) {
397
+ appendLine({
398
+ tone: "warning",
399
+ label: "onboarding",
400
+ text: "Unknown host selection."
401
+ });
402
+ return;
403
+ }
404
+ setOnboardingBusyMessage(`Connecting to ${selectedHost.deviceName}...`);
405
+ const details = await connectToHost(selectedHost, {
406
+ announce: false
407
+ });
408
+ if (!details) {
409
+ setOnboardingBusyMessage(null);
410
+ return;
411
+ }
412
+ await openModelSelection("ollama", {
413
+ deviceName: details.host.deviceName,
414
+ ollamaUrl: details.host.url
415
+ });
416
+ return;
417
+ }
418
+ if (onboarding.step === "host-input") {
419
+ const hostValue = value.trim();
420
+ if (!hostValue) {
421
+ appendLine({
422
+ tone: "warning",
423
+ label: "onboarding",
424
+ text: "Host cannot be empty."
425
+ });
426
+ return;
427
+ }
428
+ setOnboardingBusyMessage(`Connecting to ${hostValue}...`);
429
+ const details = await connectToHost(hostValue, {
430
+ announce: false
431
+ });
432
+ if (!details) {
433
+ setOnboardingBusyMessage(null);
434
+ return;
435
+ }
436
+ await openModelSelection("ollama", {
437
+ deviceName: details.host.deviceName,
438
+ ollamaUrl: details.host.url
439
+ });
440
+ return;
441
+ }
442
+ if (onboarding.step === "api-key-choice") {
443
+ const choice = readIndexedSelection(value, onboardingIndex);
444
+ if (choice === null) {
445
+ return;
446
+ }
447
+ if (choice === 0 && onboarding.hasExistingKey) {
448
+ await openModelSelection(onboarding.provider, {
449
+ currentModel: defaultModelForProvider(onboarding.provider, settings.model)
450
+ });
451
+ return;
452
+ }
453
+ setOnboarding({
454
+ step: `${onboarding.provider}-key`
455
+ });
456
+ setOnboardingInput("");
457
+ setOnboardingIndex(0);
458
+ return;
459
+ }
460
+ if (onboarding.step === "gemini-key") {
461
+ const apiKey = value.trim();
462
+ if (!apiKey) {
463
+ appendLine({
464
+ tone: "warning",
465
+ label: "onboarding",
466
+ text: "Gemini API key cannot be empty."
467
+ });
468
+ return;
469
+ }
470
+ process.env.GEMINI_API_KEY = apiKey;
471
+ savePatchPilotEnvValues({
472
+ PATCHPILOT_PROVIDER: "gemini",
473
+ PATCHPILOT_MODEL: defaultGeminiModel,
474
+ GEMINI_API_KEY: apiKey
475
+ });
476
+ appendLine({
477
+ tone: "success",
478
+ label: "onboarding",
479
+ text: "Gemini API key saved to PatchPilot config."
480
+ });
481
+ await openModelSelection("gemini", {
482
+ currentModel: defaultGeminiModel
483
+ });
484
+ return;
485
+ }
486
+ if (onboarding.step === "openrouter-key") {
487
+ const apiKey = value.trim();
488
+ if (!apiKey) {
489
+ appendLine({
490
+ tone: "warning",
491
+ label: "onboarding",
492
+ text: "OpenRouter API key cannot be empty."
493
+ });
494
+ return;
495
+ }
496
+ process.env.OPENROUTER_API_KEY = apiKey;
497
+ savePatchPilotEnvValues({
498
+ PATCHPILOT_PROVIDER: "openrouter",
499
+ PATCHPILOT_MODEL: defaultOpenRouterModel,
500
+ OPENROUTER_API_KEY: apiKey
501
+ });
502
+ appendLine({
503
+ tone: "success",
504
+ label: "onboarding",
505
+ text: "OpenRouter API key saved to PatchPilot config."
506
+ });
507
+ await openModelSelection("openrouter", {
508
+ currentModel: defaultOpenRouterModel
509
+ });
510
+ return;
511
+ }
512
+ if (onboarding.step === "nvidia-key") {
513
+ const apiKey = value.trim();
514
+ if (!apiKey) {
515
+ appendLine({
516
+ tone: "warning",
517
+ label: "onboarding",
518
+ text: "NVIDIA API key cannot be empty."
519
+ });
520
+ return;
521
+ }
522
+ process.env.NVIDIA_API_KEY = apiKey;
523
+ savePatchPilotEnvValues({
524
+ PATCHPILOT_PROVIDER: "nvidia",
525
+ PATCHPILOT_MODEL: defaultNvidiaModel,
526
+ NVIDIA_API_KEY: apiKey
527
+ });
528
+ appendLine({
529
+ tone: "success",
530
+ label: "onboarding",
531
+ text: "NVIDIA API key saved to PatchPilot config."
532
+ });
533
+ await openModelSelection("nvidia", {
534
+ currentModel: defaultNvidiaModel
535
+ });
536
+ return;
537
+ }
538
+ if (onboarding.step === "codex-login") {
539
+ if (!hasCodexCliOAuth()) {
540
+ appendLine({
541
+ tone: "warning",
542
+ label: "onboarding",
543
+ text: "Codex OAuth is still missing. Run `codex login`, then press Enter again."
544
+ });
545
+ return;
546
+ }
547
+ await openModelSelection("codex", {
548
+ currentModel: defaultCodexModel
549
+ });
550
+ return;
551
+ }
552
+ const selectableModels = filterModelOptions(onboardingInput, onboarding.models);
553
+ const selectedModel = selectModelFromInput(value, selectableModels, onboardingIndex, {
554
+ allowManual: onboarding.provider !== "ollama"
555
+ });
556
+ if (!selectedModel) {
557
+ appendLine({
558
+ tone: "warning",
559
+ label: "onboarding",
560
+ text: "Unknown model selection. Pick a listed model."
561
+ });
562
+ return;
563
+ }
564
+ setTelemetry(null);
565
+ setSettings((currentSettings) => ({
566
+ ...currentSettings,
567
+ provider: onboarding.provider,
568
+ model: selectedModel
569
+ }));
570
+ savePatchPilotEnvValues({
571
+ PATCHPILOT_PROVIDER: onboarding.provider,
572
+ PATCHPILOT_MODEL: selectedModel,
573
+ PATCHPILOT_ONBOARDING_COMPLETE: "1",
574
+ ...(onboarding.provider === "ollama" ? { PATCHPILOT_OLLAMA_URL: activeHost?.host.url ?? settings.ollamaUrl } : {})
575
+ });
576
+ appendLine({
577
+ tone: "success",
578
+ label: "onboarding",
579
+ text: `ready: ${onboarding.provider} using ${selectedModel}`
580
+ });
581
+ if (onboarding.provider === "openrouter" && isOpenRouterFreeModel(selectedModel)) {
582
+ appendLine({
583
+ tone: "warning",
584
+ label: "openrouter",
585
+ text: "Free OpenRouter models are rate-limited.",
586
+ detail: "OpenRouter documents 20 requests/minute for :free models, plus daily limits depending on account credits."
587
+ });
588
+ }
589
+ closeOnboarding();
590
+ }, [activeHost?.host.url, appendLine, closeOnboarding, connectToHost, loadHostSuggestions, onboarding, onboardingIndex, openModelSelection, settings.ollamaUrl]);
591
+ const runTask = useCallback(async (task) => {
592
+ if (!task.trim() || isRunning) {
593
+ return;
594
+ }
595
+ setInput("");
596
+ setTranscriptScrollOffset(0);
597
+ setIsRunning(true);
598
+ appendLine({
599
+ tone: "normal",
600
+ label: "you",
601
+ text: task
602
+ });
603
+ try {
604
+ const runnableSettings = await resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions);
605
+ if (!runnableSettings) {
606
+ return;
607
+ }
608
+ const abortController = new AbortController();
609
+ abortControllerRef.current = abortController;
610
+ const taskRunner = new AgentRunner({
611
+ ...runnableSettings,
612
+ signal: abortController.signal
613
+ });
614
+ for await (const event of taskRunner.run(task)) {
615
+ if (event.type === "metrics") {
616
+ if (runnableSettings.provider === "ollama") {
617
+ usedOllamaModelsRef.current.add(`${runnableSettings.ollamaUrl}|${runnableSettings.model}`);
618
+ }
619
+ setTelemetry(event.metrics);
620
+ setSessionTelemetry((currentSession) => addTelemetryToSession(currentSession, event.metrics));
621
+ continue;
622
+ }
623
+ if (event.type === "subagent") {
624
+ setTelemetry(event.metrics);
625
+ setSessionTelemetry((currentSession) => addTelemetryToSession(currentSession, event.metrics));
626
+ setAdvisorNotes((currentNotes) => upsertAdvisorNote(currentNotes, {
627
+ role: event.role,
628
+ message: event.message
629
+ }));
630
+ }
631
+ setStatus(eventToStatus(event));
632
+ appendLine(eventToLine(event));
633
+ }
634
+ }
635
+ catch (error) {
636
+ appendLine({
637
+ tone: "danger",
638
+ label: "error",
639
+ text: error instanceof Error ? error.message : String(error)
640
+ });
641
+ }
642
+ finally {
643
+ abortControllerRef.current = null;
644
+ setStatus("idle");
645
+ setIsRunning(false);
646
+ }
647
+ }, [appendLine, isRunning, modelOptions, settings]);
648
+ const handleSlashCommand = useCallback(async (rawCommand) => {
649
+ const [commandName = "", ...args] = rawCommand.slice(1).trim().split(/\s+/);
650
+ const command = commandName.toLowerCase();
651
+ switch (command) {
652
+ case "":
653
+ case "help":
654
+ {
655
+ const helpTopic = args.join(" ").trim();
656
+ const detail = helpTopic ? formatCommandHelp(helpTopic) : formatCommandDetail();
657
+ appendLine({
658
+ tone: detail ? "accent" : "warning",
659
+ label: "commands",
660
+ text: helpTopic ? (detail ? `Help for /${helpTopic.replace(/^\//, "")}` : `No help topic for /${helpTopic.replace(/^\//, "")}.`) : "Slash commands. Type / plus a few letters to filter.",
661
+ detail: detail ?? "Use /help to list commands."
662
+ });
663
+ }
664
+ return;
665
+ case "build":
666
+ case "plan":
667
+ case "mode": {
668
+ const nextMode = command === "mode" ? args[0]?.toLowerCase() : command;
669
+ if (nextMode !== "plan" && nextMode !== "build") {
670
+ appendLine({
671
+ tone: "accent",
672
+ label: "mode",
673
+ text: `current ${agentMode}. Use /mode plan, /mode build, or press tab.`
674
+ });
675
+ return;
676
+ }
677
+ applyMode(nextMode);
678
+ return;
679
+ }
680
+ case "permissions":
681
+ case "perms":
682
+ appendLine({
683
+ tone: "accent",
684
+ label: "permissions",
685
+ text: `write ${settings.allowWrite ? "on" : "off"} | shell ${settings.allowShell ? "on" : "off"} | subagents ${settings.subagents ? "on" : "off"}`
686
+ });
687
+ return;
688
+ case "provider": {
689
+ const nextProvider = args[0]?.toLowerCase();
690
+ if (nextProvider !== "ollama" && nextProvider !== "gemini" && nextProvider !== "codex" && nextProvider !== "openrouter" && nextProvider !== "nvidia") {
691
+ appendLine({
692
+ tone: "accent",
693
+ label: "provider",
694
+ text: `current ${settings.provider}. Use /provider ollama, gemini, openrouter, nvidia, or codex.`
695
+ });
696
+ return;
697
+ }
698
+ const nextModel = defaultModelForProvider(nextProvider, settings.model);
699
+ setTelemetry(null);
700
+ setModelOptions([]);
701
+ setSettings((currentSettings) => ({
702
+ ...currentSettings,
703
+ provider: nextProvider,
704
+ model: nextModel
705
+ }));
706
+ savePatchPilotEnvValues({
707
+ PATCHPILOT_PROVIDER: nextProvider,
708
+ PATCHPILOT_MODEL: nextModel
709
+ });
710
+ if (needsApiKey(nextProvider) && !hasApiKey(nextProvider)) {
711
+ openApiKeyChoice(nextProvider, setOnboarding, setOnboardingIndex);
712
+ }
713
+ appendLine({
714
+ tone: needsApiKey(nextProvider) && !hasApiKey(nextProvider) ? "warning" : "success",
715
+ label: "provider",
716
+ text: needsApiKey(nextProvider) && !hasApiKey(nextProvider)
717
+ ? `${nextProvider} needs an API key. Setup opened.`
718
+ : `switched to ${nextProvider} using ${nextModel}`
719
+ });
720
+ return;
721
+ }
722
+ case "onboarding":
723
+ setOnboarding({
724
+ step: "entry"
725
+ });
726
+ setOnboardingIndex(0);
727
+ setOnboardingInput("");
728
+ setOnboardingBusyMessage(null);
729
+ return;
730
+ case "agents":
731
+ case "subagents": {
732
+ const subagentsEnabled = readToggle(args[0], !settings.subagents);
733
+ setSettings((currentSettings) => ({
734
+ ...currentSettings,
735
+ subagents: subagentsEnabled
736
+ }));
737
+ appendLine({
738
+ tone: "success",
739
+ label: "agents",
740
+ text: `planner/reviewer subagents ${subagentsEnabled ? "enabled" : "disabled"}`
741
+ });
742
+ return;
743
+ }
744
+ case "think":
745
+ case "thinking": {
746
+ const nextMode = args[0]?.toLowerCase();
747
+ if (nextMode !== "fixed" && nextMode !== "adaptive") {
748
+ appendLine({
749
+ tone: "accent",
750
+ label: "think",
751
+ text: `current ${settings.thinkingMode}. Use /think fixed or /think adaptive.`
752
+ });
753
+ return;
754
+ }
755
+ setSettings((currentSettings) => ({
756
+ ...currentSettings,
757
+ thinkingMode: nextMode
758
+ }));
759
+ appendLine({
760
+ tone: "success",
761
+ label: "think",
762
+ text: `thinking mode ${nextMode}`
763
+ });
764
+ return;
765
+ }
766
+ case "reasoning": {
767
+ const nextEffort = args[0]?.toLowerCase();
768
+ if (!isReasoningEffort(nextEffort)) {
769
+ appendLine({
770
+ tone: "accent",
771
+ label: "reasoning",
772
+ text: `current ${settings.reasoningEffort}. Use /reasoning low, medium, high, xhigh, or adaptive.`
773
+ });
774
+ return;
775
+ }
776
+ setSettings((currentSettings) => ({
777
+ ...currentSettings,
778
+ reasoningEffort: nextEffort
779
+ }));
780
+ savePatchPilotEnvValues({
781
+ PATCHPILOT_REASONING_EFFORT: nextEffort
782
+ });
783
+ appendLine({
784
+ tone: "success",
785
+ label: "reasoning",
786
+ text: `provider reasoning ${nextEffort}${settings.provider === "ollama" ? " (Ollama ignores common reasoning effort)" : ""}`
787
+ });
788
+ return;
789
+ }
790
+ case "write":
791
+ case "apply": {
792
+ const writeEnabled = readToggle(args[0], !settings.allowWrite);
793
+ if (writeEnabled) {
794
+ setAgentMode("build");
795
+ }
796
+ setSettings((currentSettings) => ({
797
+ ...currentSettings,
798
+ allowWrite: writeEnabled
799
+ }));
800
+ appendLine({
801
+ tone: "success",
802
+ label: "write",
803
+ text: `workspace writes ${writeEnabled ? "enabled" : "disabled"}`
804
+ });
805
+ return;
806
+ }
807
+ case "shell": {
808
+ const shellEnabled = readToggle(args[0], !settings.allowShell);
809
+ if (shellEnabled) {
810
+ setAgentMode("build");
811
+ }
812
+ setSettings((currentSettings) => ({
813
+ ...currentSettings,
814
+ allowShell: shellEnabled
815
+ }));
816
+ appendLine({
817
+ tone: "success",
818
+ label: "shell",
819
+ text: `shell commands ${shellEnabled ? "enabled" : "disabled"}`
820
+ });
821
+ return;
822
+ }
823
+ case "model": {
824
+ const requestedModel = normalizeModelAlias(args.join(" ").trim());
825
+ if (!requestedModel) {
826
+ const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
827
+ if (!models) {
828
+ return;
829
+ }
830
+ appendLine({
831
+ tone: "accent",
832
+ label: "model",
833
+ text: settings.model,
834
+ detail: models.length > 0 ? formatModelOptions(models, settings.model) : "Use /models to load available models."
835
+ });
836
+ return;
837
+ }
838
+ {
839
+ const models = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
840
+ if (!models) {
841
+ return;
842
+ }
843
+ const nextModel = selectModelFromInput(requestedModel, models, undefined, {
844
+ allowManual: settings.provider !== "ollama"
845
+ });
846
+ if (!nextModel) {
847
+ appendLine({
848
+ tone: "warning",
849
+ label: "model",
850
+ text: `No unique model match for "${requestedModel}".`,
851
+ detail: formatModelOptions(filterModelOptions(requestedModel, models).slice(0, 12), settings.model)
852
+ });
853
+ return;
854
+ }
855
+ await switchModel(settings.provider, nextModel, settings.ollamaUrl, settings.model, appendLine, setModelOptions, setSettings, setTelemetry, models);
856
+ }
857
+ return;
858
+ }
859
+ case "models": {
860
+ const requestedModel = args.join(" ").trim();
861
+ if (requestedModel) {
862
+ const installedModels = await loadKnownOrAvailableModels(settings.provider, settings.ollamaUrl, modelOptions, setModelOptions, appendLine);
863
+ if (!installedModels) {
864
+ return;
865
+ }
866
+ const nextModel = selectModelFromInput(requestedModel, installedModels, undefined, {
867
+ allowManual: settings.provider !== "ollama"
868
+ });
869
+ if (!nextModel) {
870
+ appendLine({
871
+ tone: "warning",
872
+ label: "models",
873
+ text: "No model selected. Use /models to fetch available models, then choose one from the palette."
874
+ });
875
+ return;
876
+ }
877
+ await switchModel(settings.provider, nextModel, settings.ollamaUrl, settings.model, appendLine, setModelOptions, setSettings, setTelemetry, installedModels);
878
+ return;
879
+ }
880
+ appendLine({
881
+ tone: "muted",
882
+ label: "models",
883
+ text: `loading ${settings.provider} models...`
884
+ });
885
+ try {
886
+ const models = await loadProviderModels(true);
887
+ if (models.length === 0) {
888
+ appendLine({
889
+ tone: "warning",
890
+ label: "models",
891
+ text: `No ${settings.provider} models found.`,
892
+ detail: settings.provider === "ollama"
893
+ ? "Pull a model on the selected host first."
894
+ : settings.provider === "gemini"
895
+ ? "Check GEMINI_API_KEY in PatchPilot config."
896
+ : "Run codex login first."
897
+ });
898
+ return;
899
+ }
900
+ appendLine({
901
+ tone: "accent",
902
+ label: "models",
903
+ text: `Loaded ${models.length} model${models.length === 1 ? "" : "s"} from ${settings.provider}.`,
904
+ detail: formatModelOptions(models, settings.model)
905
+ });
906
+ }
907
+ catch (error) {
908
+ appendLine({
909
+ tone: "danger",
910
+ label: "models",
911
+ text: error instanceof Error ? error.message : String(error)
912
+ });
913
+ }
914
+ return;
915
+ }
916
+ case "status":
917
+ appendLine({
918
+ tone: "accent",
919
+ label: "status",
920
+ text: settings.provider === "ollama"
921
+ ? `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)}`
923
+ });
924
+ return;
925
+ case "connect":
926
+ case "host":
927
+ case "ollama":
928
+ if (settings.provider !== "ollama") {
929
+ appendLine({
930
+ tone: "warning",
931
+ label: "provider",
932
+ text: "Ollama host switching is only available with /provider ollama."
933
+ });
934
+ return;
935
+ }
936
+ if (args.length === 0) {
937
+ appendLine({
938
+ tone: "muted",
939
+ label: "hosts",
940
+ text: "Scanning LAN and Tailscale for Ollama hosts..."
941
+ });
942
+ await loadHostSuggestions(true, true);
943
+ return;
944
+ }
945
+ if (args.join(" ").trim().toLowerCase() === "local") {
946
+ await connectToHost("local");
947
+ return;
948
+ }
949
+ {
950
+ const requestedHost = args.join(" ").trim();
951
+ const hostIndex = Number.parseInt(requestedHost, 10);
952
+ const selectedHost = Number.isInteger(hostIndex) ? hostOptions[hostIndex - 1] : undefined;
953
+ if (selectedHost) {
954
+ await connectToHost(selectedHost);
955
+ }
956
+ else {
957
+ await connectToHost(requestedHost);
958
+ }
959
+ }
960
+ return;
961
+ case "hosts":
962
+ appendLine({
963
+ tone: "muted",
964
+ label: "hosts",
965
+ text: "Scanning LAN and Tailscale for Ollama hosts..."
966
+ });
967
+ await loadHostSuggestions(true, true);
968
+ return;
969
+ case "eject": {
970
+ if (settings.provider !== "ollama") {
971
+ appendLine({
972
+ tone: "warning",
973
+ label: "eject",
974
+ text: "Eject is only available for Ollama models."
975
+ });
976
+ return;
977
+ }
978
+ const target = args.join(" ").trim();
979
+ const ejectedModels = await ejectOllamaModels({
980
+ target,
981
+ settings,
982
+ activeHost,
983
+ usedModels: usedOllamaModelsRef.current
984
+ });
985
+ if (ejectedModels.length === 0) {
986
+ appendLine({
987
+ tone: "warning",
988
+ label: "eject",
989
+ text: "No Ollama model was ejected."
990
+ });
991
+ return;
992
+ }
993
+ appendLine({
994
+ tone: "success",
995
+ label: "eject",
996
+ text: `ejected ${ejectedModels.join(", ")}`
997
+ });
998
+ if (activeHost) {
999
+ const details = await readOllamaHostDetails(activeHost.host, true).catch(() => activeHost);
1000
+ setActiveHost(details);
1001
+ }
1002
+ return;
1003
+ }
1004
+ case "doctor": {
1005
+ appendLine({
1006
+ tone: "muted",
1007
+ label: "doctor",
1008
+ text: "checking local requirements..."
1009
+ });
1010
+ const doctorResults = await runDoctor(settings.provider, settings.ollamaUrl, settings.model);
1011
+ for (const result of doctorResults) {
1012
+ appendLine({
1013
+ tone: result.ok ? "success" : "danger",
1014
+ label: result.name,
1015
+ text: result.details
1016
+ });
1017
+ }
1018
+ return;
1019
+ }
1020
+ case "clear":
1021
+ setLines([]);
1022
+ setAdvisorNotes([]);
1023
+ setTelemetry(null);
1024
+ setSessionTelemetry(emptySessionTelemetry());
1025
+ setTranscriptScrollOffset(0);
1026
+ setSessionScrollOffset(0);
1027
+ return;
1028
+ case "exit":
1029
+ case "quit":
1030
+ case "q":
1031
+ void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(exit);
1032
+ return;
1033
+ default:
1034
+ appendLine({
1035
+ tone: "warning",
1036
+ label: "unknown",
1037
+ text: `/${command} is not a PatchPilot command. Type /help.`
1038
+ });
1039
+ }
1040
+ }, [
1041
+ activeHost?.host.deviceName,
1042
+ activeHost?.host.url,
1043
+ agentMode,
1044
+ appendLine,
1045
+ applyMode,
1046
+ connectToHost,
1047
+ draftTokens,
1048
+ exit,
1049
+ hostOptions,
1050
+ loadHostSuggestions,
1051
+ loadProviderModels,
1052
+ modelOptions,
1053
+ sessionTelemetry,
1054
+ settings,
1055
+ telemetry
1056
+ ]);
1057
+ const handleSubmit = useCallback(async (value) => {
1058
+ const nextValue = value.trim();
1059
+ if (!nextValue || isRunning) {
1060
+ return;
1061
+ }
1062
+ if (onboarding) {
1063
+ await handleOnboardingSubmit(nextValue);
1064
+ return;
1065
+ }
1066
+ if (nextValue.startsWith("/")) {
1067
+ const selectedItem = paletteItems[paletteIndex];
1068
+ const commandHasArgs = /^\/\S+\s+\S/.test(nextValue);
1069
+ const shouldApplySuggestion = selectedItem &&
1070
+ (!commandHasArgs || selectedItem.command !== selectedItem.label) &&
1071
+ (selectedItem.execute || selectedItem.command === nextValue || nextValue === "/" || nextValue.endsWith(" "));
1072
+ const commandToRun = shouldApplySuggestion ? selectedItem.command : nextValue;
1073
+ if (selectedItem && !selectedItem.execute && commandToRun !== nextValue) {
1074
+ setInput(commandToRun);
1075
+ return;
1076
+ }
1077
+ setInput("");
1078
+ await handleSlashCommand(commandToRun);
1079
+ return;
1080
+ }
1081
+ await runTask(nextValue);
1082
+ }, [handleOnboardingSubmit, handleSlashCommand, isRunning, onboarding, paletteIndex, paletteItems, runTask]);
1083
+ useEffect(() => {
1084
+ if (!props.initialTask || didRunInitialTask.current || onboarding) {
1085
+ return;
1086
+ }
1087
+ didRunInitialTask.current = true;
1088
+ void runTask(props.initialTask);
1089
+ }, [onboarding, props.initialTask, runTask]);
1090
+ useEffect(() => {
1091
+ setPaletteIndex(0);
1092
+ }, [hostOptions, input, modelOptions, onboarding, settings.model, settings.provider]);
1093
+ useEffect(() => {
1094
+ if (didOpenDefaultOnboarding.current || props.initialTask || onboarding || process.env.PATCHPILOT_ONBOARDING_COMPLETE === "1") {
1095
+ return;
1096
+ }
1097
+ didOpenDefaultOnboarding.current = true;
1098
+ setOnboarding({
1099
+ step: "entry"
1100
+ });
1101
+ setOnboardingIndex(0);
1102
+ setOnboardingInput("");
1103
+ setOnboardingBusyMessage(null);
1104
+ }, [onboarding, props.initialTask]);
1105
+ useEffect(() => {
1106
+ if (settings.provider !== "ollama") {
1107
+ setActiveHost(null);
1108
+ return;
1109
+ }
1110
+ let cancelled = false;
1111
+ async function syncActiveHost() {
1112
+ const verifiedHost = await checkOllamaHost(settings.ollamaUrl, {
1113
+ timeoutMs: 800
1114
+ });
1115
+ if (!verifiedHost) {
1116
+ if (!cancelled) {
1117
+ setActiveHost((currentHost) => (currentHost?.host.url === settings.ollamaUrl ? currentHost : null));
1118
+ }
1119
+ return;
1120
+ }
1121
+ const details = await readOllamaHostDetails(verifiedHost).catch(() => ({
1122
+ host: verifiedHost,
1123
+ models: [],
1124
+ runningModels: [],
1125
+ fetchedAt: Date.now()
1126
+ }));
1127
+ if (cancelled) {
1128
+ return;
1129
+ }
1130
+ setActiveHost(details);
1131
+ if (details.models.length > 0) {
1132
+ setModelOptions((currentModels) => (currentModels.length > 0 && currentModels.join("\n") === details.models.join("\n") ? currentModels : details.models));
1133
+ }
1134
+ }
1135
+ void syncActiveHost();
1136
+ const timer = setInterval(() => {
1137
+ void syncActiveHost();
1138
+ }, 5000);
1139
+ return () => {
1140
+ cancelled = true;
1141
+ clearInterval(timer);
1142
+ };
1143
+ }, [settings.ollamaUrl, settings.provider]);
1144
+ useEffect(() => {
1145
+ if (onboarding || isRunning) {
1146
+ return;
1147
+ }
1148
+ const trimmedInput = input.trim();
1149
+ if (settings.provider === "ollama" && (trimmedInput === "/connect" || trimmedInput === "/hosts") && hostOptions.length === 0 && !isLoadingHosts) {
1150
+ void loadHostSuggestions(false, false);
1151
+ }
1152
+ if ((trimmedInput === "/models" || trimmedInput === "/model") && modelOptions.length === 0 && !isLoadingModels) {
1153
+ void loadProviderModels(false);
1154
+ }
1155
+ }, [hostOptions.length, input, isLoadingHosts, isLoadingModels, isRunning, loadHostSuggestions, loadProviderModels, modelOptions.length, onboarding, settings.provider]);
1156
+ useInput((inputValue, key) => {
1157
+ if (isRunning && key.escape) {
1158
+ abortControllerRef.current?.abort();
1159
+ appendLine({
1160
+ tone: "warning",
1161
+ label: "stop",
1162
+ text: "Stopping current task..."
1163
+ });
1164
+ setStatus("stopping");
1165
+ return;
1166
+ }
1167
+ if (onboarding) {
1168
+ if (key.escape || key.leftArrow) {
1169
+ goBackOnboarding();
1170
+ return;
1171
+ }
1172
+ const optionCount = onboarding.step === "model" ? filterModelOptions(onboardingInput, onboarding.models).length : getOnboardingOptionCount(onboarding);
1173
+ if (optionCount > 0 && key.upArrow) {
1174
+ setOnboardingIndex((currentIndex) => (currentIndex - 1 + optionCount) % optionCount);
1175
+ return;
1176
+ }
1177
+ if (optionCount > 0 && key.downArrow) {
1178
+ setOnboardingIndex((currentIndex) => (currentIndex + 1) % optionCount);
1179
+ return;
1180
+ }
1181
+ if (optionCount > 0 && key.return && onboarding.step !== "model") {
1182
+ void handleOnboardingSubmit(String(onboardingIndex + 1));
1183
+ return;
1184
+ }
1185
+ if (onboarding.step === "codex-login" && key.return) {
1186
+ void handleOnboardingSubmit("");
1187
+ return;
1188
+ }
1189
+ return;
1190
+ }
1191
+ if (paletteItems.length > 0) {
1192
+ if (key.upArrow) {
1193
+ setPaletteIndex((currentIndex) => (currentIndex - 1 + paletteItems.length) % paletteItems.length);
1194
+ return;
1195
+ }
1196
+ if (key.downArrow) {
1197
+ setPaletteIndex((currentIndex) => (currentIndex + 1) % paletteItems.length);
1198
+ return;
1199
+ }
1200
+ if (key.escape) {
1201
+ setInput("");
1202
+ return;
1203
+ }
1204
+ }
1205
+ const canUsePanelKeys = input.length === 0 || isRunning;
1206
+ if (canUsePanelKeys && key.leftArrow) {
1207
+ setActiveScrollPane("session");
1208
+ return;
1209
+ }
1210
+ if (canUsePanelKeys && key.rightArrow) {
1211
+ setActiveScrollPane("transcript");
1212
+ return;
1213
+ }
1214
+ if (canUsePanelKeys && (key.pageUp || key.pageDown || key.home || key.end)) {
1215
+ const setOffset = activeScrollPane === "session" ? setSessionScrollOffset : setTranscriptScrollOffset;
1216
+ if (key.pageUp) {
1217
+ setOffset((currentOffset) => currentOffset + scrollStep);
1218
+ }
1219
+ else if (key.pageDown) {
1220
+ setOffset((currentOffset) => Math.max(0, currentOffset - scrollStep));
1221
+ }
1222
+ else if (key.home) {
1223
+ setOffset(1_000_000);
1224
+ }
1225
+ else {
1226
+ setOffset(0);
1227
+ }
1228
+ return;
1229
+ }
1230
+ if (!isRunning && key.tab) {
1231
+ toggleMode();
1232
+ return;
1233
+ }
1234
+ if (!isRunning && input.length === 0 && inputValue === "q") {
1235
+ void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(exit);
1236
+ }
1237
+ });
1238
+ useEffect(() => {
1239
+ const unloadAndExit = () => {
1240
+ void unloadUsedOllamaModels(usedOllamaModelsRef.current).finally(() => {
1241
+ process.exit(0);
1242
+ });
1243
+ };
1244
+ process.once("SIGINT", unloadAndExit);
1245
+ process.once("SIGTERM", unloadAndExit);
1246
+ return () => {
1247
+ process.off("SIGINT", unloadAndExit);
1248
+ process.off("SIGTERM", unloadAndExit);
1249
+ void unloadUsedOllamaModels(usedOllamaModelsRef.current);
1250
+ };
1251
+ }, []);
1252
+ useEffect(() => {
1253
+ let previousSnapshot = readSystemStats().snapshot;
1254
+ const timer = setInterval(() => {
1255
+ const nextReading = readSystemStats(previousSnapshot);
1256
+ previousSnapshot = nextReading.snapshot;
1257
+ setSystemStats(nextReading.stats);
1258
+ }, 1000);
1259
+ return () => {
1260
+ clearInterval(timer);
1261
+ };
1262
+ }, []);
1263
+ useEffect(() => {
1264
+ let isMounted = true;
1265
+ async function updateGpuStats() {
1266
+ const nextGpuStats = await readGpuStats();
1267
+ if (isMounted) {
1268
+ setGpuStats(nextGpuStats);
1269
+ }
1270
+ }
1271
+ void updateGpuStats();
1272
+ const timer = setInterval(() => {
1273
+ void updateGpuStats();
1274
+ }, 2500);
1275
+ return () => {
1276
+ isMounted = false;
1277
+ clearInterval(timer);
1278
+ };
1279
+ }, []);
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 })] })] }))] }));
1281
+ }
1282
+ async function loadAvailableModels(provider, ollamaUrl, setModelOptions, refresh = false) {
1283
+ const cacheKey = `${provider}:${provider === "ollama" ? ollamaUrl : "default"}`;
1284
+ const cachedModels = modelCache.get(cacheKey);
1285
+ if (!refresh && cachedModels && cachedModels.expiresAt > Date.now()) {
1286
+ setModelOptions(cachedModels.models);
1287
+ return cachedModels.models;
1288
+ }
1289
+ const models = await createModelClient({
1290
+ provider,
1291
+ ollamaUrl
1292
+ }).listModels();
1293
+ modelCache.set(cacheKey, {
1294
+ models,
1295
+ expiresAt: Date.now() + modelCacheTtlMs
1296
+ });
1297
+ setModelOptions(models);
1298
+ return models;
1299
+ }
1300
+ async function loadKnownOrAvailableModels(provider, ollamaUrl, modelOptions, setModelOptions, appendLine) {
1301
+ try {
1302
+ return modelOptions.length > 0 ? modelOptions : await loadAvailableModels(provider, ollamaUrl, setModelOptions);
1303
+ }
1304
+ catch (error) {
1305
+ appendLine({
1306
+ tone: "danger",
1307
+ label: "models",
1308
+ text: error instanceof Error ? error.message : String(error)
1309
+ });
1310
+ return null;
1311
+ }
1312
+ }
1313
+ async function switchModel(provider, nextModel, ollamaUrl, currentModel, appendLine, setModelOptions, setSettings, setTelemetry, knownModels) {
1314
+ const installedModels = knownModels ??
1315
+ (await loadAvailableModels(provider, ollamaUrl, setModelOptions).catch((error) => {
1316
+ appendLine({
1317
+ tone: "danger",
1318
+ label: "models",
1319
+ text: error instanceof Error ? error.message : String(error)
1320
+ });
1321
+ return null;
1322
+ }));
1323
+ if (!installedModels) {
1324
+ return;
1325
+ }
1326
+ if (!installedModels.includes(nextModel)) {
1327
+ appendLine({
1328
+ tone: "warning",
1329
+ label: "model",
1330
+ text: `${nextModel} is not available for ${provider}.`,
1331
+ detail: installedModels.length > 0
1332
+ ? `Use /models and pick one of:\n${formatModelOptions(installedModels, currentModel)}`
1333
+ : provider === "ollama"
1334
+ ? "No models installed on the selected host."
1335
+ : provider === "gemini"
1336
+ ? "Check GEMINI_API_KEY in PatchPilot config."
1337
+ : provider === "openrouter"
1338
+ ? "Check OPENROUTER_API_KEY in PatchPilot config."
1339
+ : "Run codex login first."
1340
+ });
1341
+ return;
1342
+ }
1343
+ setTelemetry(null);
1344
+ setSettings((currentSettings) => ({
1345
+ ...currentSettings,
1346
+ model: nextModel
1347
+ }));
1348
+ savePatchPilotEnvValues({
1349
+ PATCHPILOT_PROVIDER: provider,
1350
+ PATCHPILOT_MODEL: nextModel
1351
+ });
1352
+ appendLine({
1353
+ tone: "success",
1354
+ label: "model",
1355
+ text: `switched to ${nextModel}`
1356
+ });
1357
+ if (provider === "openrouter" && isOpenRouterFreeModel(nextModel)) {
1358
+ appendLine({
1359
+ tone: "warning",
1360
+ label: "openrouter",
1361
+ text: "Free OpenRouter models are rate-limited.",
1362
+ detail: "OpenRouter documents 20 requests/minute for :free models, plus daily limits depending on account credits."
1363
+ });
1364
+ }
1365
+ }
1366
+ async function resolveRunnableSettings(settings, modelOptions, appendLine, setModelOptions) {
1367
+ let installedModels;
1368
+ try {
1369
+ installedModels = modelOptions.includes(settings.model)
1370
+ ? modelOptions
1371
+ : await loadAvailableModels(settings.provider, settings.ollamaUrl, setModelOptions);
1372
+ }
1373
+ catch (error) {
1374
+ appendLine({
1375
+ tone: "danger",
1376
+ label: settings.provider,
1377
+ text: error instanceof Error ? error.message : String(error)
1378
+ });
1379
+ return null;
1380
+ }
1381
+ if (installedModels.includes(settings.model)) {
1382
+ return settings;
1383
+ }
1384
+ appendLine({
1385
+ tone: "warning",
1386
+ label: "model",
1387
+ text: `${settings.model} is not available for ${settings.provider}.`,
1388
+ detail: installedModels.length > 0
1389
+ ? `Pick an installed model first:\n${formatModelOptions(installedModels, settings.model)}`
1390
+ : settings.provider === "ollama"
1391
+ ? "No models installed on the selected host."
1392
+ : settings.provider === "gemini"
1393
+ ? "No Gemini models listed. Check GEMINI_API_KEY in PatchPilot config."
1394
+ : settings.provider === "openrouter"
1395
+ ? "No OpenRouter models listed. Check OPENROUTER_API_KEY in PatchPilot config."
1396
+ : "Codex OAuth is not ready. Run codex login."
1397
+ });
1398
+ return null;
1399
+ }
1400
+ function buildCommandSuggestionItems(options) {
1401
+ if (!options.input.startsWith("/")) {
1402
+ return [];
1403
+ }
1404
+ const trimmedInput = options.input.trimStart().toLowerCase();
1405
+ const items = filterSlashCommands(options.input)
1406
+ .slice(0, 6)
1407
+ .map((command) => {
1408
+ const baseCommand = `/${command.name}`;
1409
+ return {
1410
+ key: `command-${command.name}`,
1411
+ category: command.category,
1412
+ label: baseCommand,
1413
+ detail: command.description,
1414
+ hint: command.usage.includes("<") || command.usage.includes("[") ? "fill" : "run",
1415
+ command: baseCommand,
1416
+ execute: !command.usage.includes("<") && !command.usage.includes("[")
1417
+ };
1418
+ });
1419
+ if (options.provider === "ollama" && (trimmedInput === "/connect" || trimmedInput.startsWith("/connect ") || trimmedInput.startsWith("/host"))) {
1420
+ if (options.isLoadingHosts) {
1421
+ items.unshift({
1422
+ key: "hosts-loading",
1423
+ category: "host",
1424
+ label: "Loading Hosts",
1425
+ detail: "Scanning LAN and Tailscale peers...",
1426
+ command: "/connect",
1427
+ execute: false
1428
+ });
1429
+ }
1430
+ else {
1431
+ items.unshift(...options.hostOptions.slice(0, 5).map((host) => ({
1432
+ key: `host-${host.url}`,
1433
+ category: "host",
1434
+ label: host.deviceName,
1435
+ detail: `${host.kind} ${host.url}${host.version ? ` Ollama ${host.version}` : ""}`,
1436
+ command: `/connect ${host.url}`,
1437
+ execute: true
1438
+ })));
1439
+ }
1440
+ }
1441
+ if (trimmedInput === "/models" || trimmedInput.startsWith("/models") || trimmedInput === "/model" || trimmedInput.startsWith("/model")) {
1442
+ const modelQuery = trimmedInput.replace(/^\/models?/, "").trim();
1443
+ if (options.isLoadingModels) {
1444
+ items.unshift({
1445
+ key: "models-loading",
1446
+ category: "model",
1447
+ label: "Loading Models",
1448
+ detail: `Fetching ${options.provider} models...`,
1449
+ command: "/models",
1450
+ execute: false
1451
+ });
1452
+ }
1453
+ else {
1454
+ items.unshift(...filterModelOptions(modelQuery, options.modelOptions).slice(0, 8).map((model) => ({
1455
+ key: `model-${model}`,
1456
+ category: "model",
1457
+ label: model,
1458
+ detail: `${model === options.currentModel ? "current" : "available"} ${options.provider}`,
1459
+ command: `/model ${model}`,
1460
+ execute: true
1461
+ })));
1462
+ }
1463
+ }
1464
+ return items.slice(0, 8);
1465
+ }
1466
+ function getOnboardingOptionCount(onboarding) {
1467
+ switch (onboarding.step) {
1468
+ case "entry":
1469
+ return 6;
1470
+ case "host":
1471
+ return onboarding.hosts.length + 1;
1472
+ case "api-key-choice":
1473
+ return 2;
1474
+ case "model":
1475
+ return onboarding.models.length;
1476
+ default:
1477
+ return 0;
1478
+ }
1479
+ }
1480
+ function readEntrySelection(value, selectedIndex) {
1481
+ const normalizedValue = value.trim().toLowerCase();
1482
+ if (!normalizedValue) {
1483
+ return ["local", "host", "gemini", "openrouter", "nvidia", "codex"][selectedIndex];
1484
+ }
1485
+ if (normalizedValue === "1" || normalizedValue === "local" || normalizedValue === "this device") {
1486
+ return "local";
1487
+ }
1488
+ if (normalizedValue === "2" || normalizedValue === "host" || normalizedValue === "remote host" || normalizedValue === "remote") {
1489
+ return "host";
1490
+ }
1491
+ if (normalizedValue === "3" || normalizedValue === "gemini" || normalizedValue === "google") {
1492
+ return "gemini";
1493
+ }
1494
+ if (normalizedValue === "4" || normalizedValue === "openrouter" || normalizedValue === "open-router") {
1495
+ return "openrouter";
1496
+ }
1497
+ if (normalizedValue === "5" || normalizedValue === "nvidia" || normalizedValue === "nim") {
1498
+ return "nvidia";
1499
+ }
1500
+ if (normalizedValue === "6" || normalizedValue === "codex") {
1501
+ return "codex";
1502
+ }
1503
+ return null;
1504
+ }
1505
+ function readIndexedSelection(value, selectedIndex) {
1506
+ const normalizedValue = value.trim();
1507
+ if (!normalizedValue) {
1508
+ return selectedIndex;
1509
+ }
1510
+ const parsedIndex = Number.parseInt(normalizedValue, 10);
1511
+ return Number.isInteger(parsedIndex) ? parsedIndex - 1 : null;
1512
+ }
1513
+ function selectModelFromInput(value, models, selectedIndex, options = {}) {
1514
+ const normalizedValue = normalizeModelAlias(value.trim());
1515
+ if (!normalizedValue && selectedIndex !== undefined) {
1516
+ return models[selectedIndex] ?? null;
1517
+ }
1518
+ if (!normalizedValue) {
1519
+ return null;
1520
+ }
1521
+ const modelIndex = Number.parseInt(normalizedValue, 10);
1522
+ if (Number.isInteger(modelIndex)) {
1523
+ return models[modelIndex - 1] ?? null;
1524
+ }
1525
+ if (models.includes(normalizedValue)) {
1526
+ return normalizedValue;
1527
+ }
1528
+ const matches = filterModelOptions(normalizedValue, models);
1529
+ if (matches.length === 1) {
1530
+ return matches[0] ?? null;
1531
+ }
1532
+ return options.allowManual && isPlausibleCloudModelId(normalizedValue) ? normalizedValue : null;
1533
+ }
1534
+ function isPlausibleCloudModelId(value) {
1535
+ return /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(value) && value.length >= 3;
1536
+ }
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
+ function defaultModelForProvider(provider, currentModel) {
1566
+ if (provider === "nvidia") {
1567
+ return currentModel.includes("/") && !currentModel.startsWith("openrouter/") ? currentModel : defaultNvidiaModel;
1568
+ }
1569
+ if (provider === "openrouter") {
1570
+ return currentModel.includes("/") ? currentModel : defaultOpenRouterModel;
1571
+ }
1572
+ if (provider === "gemini") {
1573
+ return currentModel.startsWith("gemini-") ? currentModel : defaultGeminiModel;
1574
+ }
1575
+ if (provider === "codex") {
1576
+ return currentModel.includes("codex") || currentModel === "codex-mini-latest" ? currentModel : defaultCodexModel;
1577
+ }
1578
+ return currentModel.startsWith("gemini-") || currentModel.includes("codex") || currentModel.includes("/") ? defaultOllamaModel : currentModel;
1579
+ }
1580
+ function openApiKeyChoice(provider, setOnboarding, setOnboardingIndex) {
1581
+ setOnboarding({
1582
+ step: "api-key-choice",
1583
+ provider,
1584
+ hasExistingKey: hasApiKey(provider)
1585
+ });
1586
+ setOnboardingIndex(0);
1587
+ }
1588
+ function needsApiKey(provider) {
1589
+ return provider === "gemini" || provider === "openrouter" || provider === "nvidia";
1590
+ }
1591
+ function hasApiKey(provider) {
1592
+ if (provider === "gemini") {
1593
+ return Boolean(readGeminiApiKey());
1594
+ }
1595
+ if (provider === "openrouter") {
1596
+ return Boolean(readOpenRouterApiKey());
1597
+ }
1598
+ return Boolean(readNvidiaApiKey());
1599
+ }
1600
+ async function unloadUsedOllamaModels(usedModels) {
1601
+ const entries = [...usedModels];
1602
+ usedModels.clear();
1603
+ await Promise.allSettled(entries.map(async (entry) => {
1604
+ const [url, model] = entry.split("|");
1605
+ if (!url || !model) {
1606
+ return;
1607
+ }
1608
+ await new OllamaClient(url).unloadModel(model);
1609
+ }));
1610
+ }
1611
+ async function ejectOllamaModels(options) {
1612
+ const target = options.target.trim();
1613
+ const client = new OllamaClient(options.settings.ollamaUrl);
1614
+ const models = target === "all"
1615
+ ? [
1616
+ ...new Set([
1617
+ ...[...options.usedModels]
1618
+ .map((entry) => entry.split("|"))
1619
+ .filter(([url]) => url === options.settings.ollamaUrl)
1620
+ .map(([, model]) => model)
1621
+ .filter((model) => Boolean(model)),
1622
+ ...(options.activeHost?.runningModels.map((model) => model.name) ?? [])
1623
+ ])
1624
+ ]
1625
+ : [target || options.settings.model];
1626
+ const ejected = [];
1627
+ for (const model of models) {
1628
+ await client.unloadModel(model).then(() => {
1629
+ ejected.push(model);
1630
+ options.usedModels.delete(`${options.settings.ollamaUrl}|${model}`);
1631
+ }, () => undefined);
1632
+ }
1633
+ return ejected;
1634
+ }
1635
+ function isReasoningEffort(value) {
1636
+ return value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "adaptive";
1637
+ }
1638
+ function upsertAdvisorNote(notes, nextNote) {
1639
+ const nextNotes = notes.filter((note) => note.role !== nextNote.role);
1640
+ return [...nextNotes, nextNote].slice(-2);
1641
+ }
1642
+ function eventToLine(event) {
1643
+ switch (event.type) {
1644
+ case "status":
1645
+ return {
1646
+ tone: "muted",
1647
+ label: "thinking",
1648
+ text: event.message
1649
+ };
1650
+ case "assistant":
1651
+ return {
1652
+ tone: "accent",
1653
+ label: "pilot",
1654
+ text: event.message
1655
+ };
1656
+ case "subagent":
1657
+ return {
1658
+ tone: "accent",
1659
+ label: event.role,
1660
+ text: "advisor brief updated",
1661
+ detail: event.message
1662
+ };
1663
+ case "tool":
1664
+ return {
1665
+ tone: event.ok ? "success" : "warning",
1666
+ label: event.name,
1667
+ text: event.summary
1668
+ };
1669
+ case "final":
1670
+ return {
1671
+ tone: "success",
1672
+ label: "final",
1673
+ text: event.message
1674
+ };
1675
+ case "error":
1676
+ return {
1677
+ tone: "danger",
1678
+ label: "error",
1679
+ text: event.message
1680
+ };
1681
+ case "metrics":
1682
+ return {
1683
+ tone: "muted",
1684
+ label: "metrics",
1685
+ text: formatTokens(event.metrics)
1686
+ };
1687
+ }
1688
+ }
1689
+ function eventToStatus(event) {
1690
+ if (event.type === "status") {
1691
+ return event.message;
1692
+ }
1693
+ if (event.type === "tool") {
1694
+ return `${event.name}: ${event.summary}`;
1695
+ }
1696
+ if (event.type === "subagent") {
1697
+ return `${event.role} subagent`;
1698
+ }
1699
+ return event.type;
1700
+ }
1701
+ function formatHostOptions(hosts) {
1702
+ return hosts
1703
+ .map((host, index) => {
1704
+ const version = host.version ? ` Ollama ${host.version}` : "";
1705
+ return `${index + 1}. ${host.deviceName} ${host.kind} ${host.url}${version}`;
1706
+ })
1707
+ .join("\n");
1708
+ }
1709
+ function formatModelOptions(models, currentModel) {
1710
+ return models
1711
+ .map((model, index) => {
1712
+ const currentMarker = model === currentModel ? " current" : "";
1713
+ return `${index + 1}. ${model}${currentMarker}`;
1714
+ })
1715
+ .join("\n");
1716
+ }
1717
+ //# sourceMappingURL=App.js.map