-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathsidebar.ts
More file actions
2680 lines (2533 loc) · 116 KB
/
Copy pathsidebar.ts
File metadata and controls
2680 lines (2533 loc) · 116 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import * as vscode from "vscode";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { AcpClient, EffortLevel, ExitPlanRequest, PermissionRequest, QuestionRequest } from "./acp";
import { Session, SessionStatus } from "./session";
import { selectReapable, computeDot, Dot } from "./session-pool";
import { resolveVoiceKey, parseVoiceCommand, DEFAULT_SEND_PHRASE } from "./voice";
import { VoiceRecorder, transcribeAudio, resolveWindowsAudioDevice } from "./voice-recorder";
import { VoiceStreamer } from "./voice-streamer";
import { MediaRef, isIncompatibleAgentError } from "./acp-dispatch";
import { locateGrokCli, extensionWasUpgraded } from "./cli-locator";
import { TerminalManager } from "./terminal-manager";
import {
FileChip,
clearImplicitChips,
makeExplicitChip,
makeImplicitChip,
removeChip,
toggleChip,
} from "./chips";
import { buildPrompt } from "./prompt-builder";
import { parseFileRef, shouldReadFileInline } from "./file-ref";
import { pickRejectOption, shouldRejectPermission } from "./plan-gate";
import { appendPlanEntry, decideRestoreState } from "./plan-restore";
import { planReviewFileBaseName, sanitizePlanReviewFilePart } from "./plan-review";
import { GROK_PRIMER, isPrimerText } from "./grok-primer";
import {
SessionListEntry,
SessionMetaOverrides,
carrySessionName,
clearSessions,
defaultFs,
deleteSessionDir,
fallbackName,
indexSessions,
readSessionEntries,
resolveGrokHome,
sessionsDirFor,
} from "./sessions";
type WebviewMsg =
| { type: "ready" }
| { type: "send"; text: string; chips: FileChip[] }
| { type: "newSession" }
| { type: "cancel" }
| { type: "pickModel" }
| { type: "setMode"; modeId: "agent" | "plan" | "yolo" }
| { type: "removeChip"; id: string }
| { type: "toggleChip"; id: string }
| { type: "openFile"; path: string }
| { type: "openUrl"; url: string }
| { type: "openDiff"; path: string; oldText: string; newText: string }
| { type: "exportExpr"; action: string; kind: string; current?: string; svg?: string; png?: string; svgDark?: string; svgLight?: string }
| { type: "setEffort"; level: string }
| { type: "openGlobalConfig" }
| { type: "openProjectConfig" }
| { type: "runMcpList" }
| { type: "showLogs" }
| { type: "dropFile"; path: string; shift: boolean }
| { type: "permissionAnswer"; requestId: number | string; optionId: string }
| { type: "exitPlanAnswer"; requestId: number | string; verdict: "approved" | "abandoned" | "rejected"; comment?: string }
| { type: "questionAnswer"; requestId: number | string; answers?: Record<string, string>; annotations?: Record<string, { notes?: string; preview?: string }> }
| { type: "questionCancel"; requestId: number | string }
| { type: "setModel"; modelId: string }
| { type: "runInstallCmd" }
| { type: "runGrokLogin" }
| { type: "logout" }
| { type: "checkGrokUpdate" }
| { type: "updateGrok" }
| { type: "recheckConnection" }
| { type: "listSessions"; offset?: number; limit?: number; query?: string }
| { type: "resumeSession"; id: string }
| { type: "renameSession"; id: string; name: string }
| { type: "deleteSession"; id: string; name?: string }
| { type: "clearAllSessions" }
| { type: "pickFile" }
| { type: "voiceStart" }
| { type: "voiceStop" };
const SESSION_META_KEY = "grok.sessionMeta";
// History pagination: rows fetched per "page" (initial open + each load-more / search page).
const SESSION_PAGE_SIZE = 100;
// Records the extension version at the last grok-CLI auto-update check, so the
// silent `grok update` fires once per extension upgrade and never on a fresh
// install. See maybeUpdateCliOnUpgrade.
const CLI_UPDATE_VERSION_KEY = "grok.cliUpdateExtVersion";
const execFileAsync = promisify(execFile);
// grok's non-plan ("act") mode id on the wire. The CLI reports this via
// current_mode_update after leaving plan mode (verified against grok 0.2.3 —
// see research/plan-mode.md). The UI labels it "Agent"; the wire calls it
// "default".
const ACT_MODE_ID = "default";
/** Best-effort MIME from a file extension, for inlining generated media. */
function guessMediaMime(p: string): string {
const ext = p.toLowerCase().split(".").pop() ?? "";
switch (ext) {
case "jpg":
case "jpeg": return "image/jpeg";
case "gif": return "image/gif";
case "webp": return "image/webp";
case "bmp": return "image/bmp";
case "svg": return "image/svg+xml";
case "mp4":
case "m4v": return "video/mp4";
case "mov": return "video/quicktime";
case "webm": return "video/webm";
default: return "image/png";
}
}
export class GrokSidebar implements vscode.WebviewViewProvider {
public static readonly viewId = "grok.chat";
private view?: vscode.WebviewView;
/** The session currently shown in the chat — one member of {@link pool}. */
private focused = new Session();
/**
* Every live session (each a spawned `grok agent stdio` process), including the
* focused one. Backgrounded members keep streaming into their own buffers, so
* re-focusing one replays its buffer losslessly — no kill, no reload. A session
* is added on its first successful start and removed when its client is disposed
* (switch-away of an empty one, delete, logout, reap, teardown).
*/
private pool = new Set<Session>();
/**
* Cache of parsed session metadata for the history popover, keyed by session id. Each value
* remembers the `summary.json` mtime it was read at, so a cheap `indexSessions` stat pass can
* tell which entries are stale and re-read only those — the rest are reused across popover opens,
* load-more pages, and searches. Invalidated per id on rename/delete; the whole map is disposable
* (it's just a read cache, never a source of truth).
*/
private sessionCache = new Map<string, { mtimeMs: number; entry: SessionListEntry }>();
/**
* Bounds on the live-session pool (see session-pool.ts). A backgrounded session
* idle past {@link IDLE_TTL_MS}, or beyond the {@link MAX_LIVE_SESSIONS} LRU cap,
* is silently reaped (its process killed, its dot going cold) — re-focusing it
* reloads from grok's on-disk history. Working/needs-you and the focused session
* are never reaped.
*/
private static readonly MAX_LIVE_SESSIONS = 8;
private static readonly IDLE_TTL_MS = 60 * 60 * 1000; // 1h
private static readonly REAP_INTERVAL_MS = 5 * 60 * 1000; // sweep every 5 min
private reaper?: ReturnType<typeof setInterval>;
private output: vscode.OutputChannel;
private chips: FileChip[] = [];
private editorWatcher?: vscode.Disposable;
private terminalManager = new TerminalManager();
private voiceRecorder = new VoiceRecorder();
private voiceTempPath?: string;
private voiceStreamer?: VoiceStreamer;
private voiceFinalizing = false;
// Stored so a "grok send" can transparently restart a fresh stream (each
// message = one clean utterance) without re-resolving the mic device.
private voiceStreamCtx?: { key: string; ffmpegPath: string; device?: string; phrase: string; keyterms: string[] };
private configWatcher?: vscode.Disposable;
private cliPath?: string;
// Guards the silent grok-CLI auto-update so it runs at most once per activation.
private cliUpdateChecked = false;
constructor(
private context: vscode.ExtensionContext,
output: vscode.OutputChannel,
) {
this.output = output;
}
resolveWebviewView(view: vscode.WebviewView): void {
this.view = view;
view.webview.options = {
enableScripts: true,
localResourceRoots: [
vscode.Uri.joinPath(this.context.extensionUri, "media"),
vscode.Uri.joinPath(this.context.extensionUri, "resources"),
// grok writes generated media under ~/.grok/sessions/<cwd>/<id>/{images,videos};
// serving it via asWebviewUri (instead of a base64 data: URI) lets the
// webview stream a multi-MB video from disk — see postGeneratedMedia.
vscode.Uri.file(resolveGrokHome()),
],
};
view.webview.html = this.getHtml(view.webview);
view.webview.onDidReceiveMessage((m: WebviewMsg) => this.onMessage(m));
this.watchActiveEditor();
// Periodic idle-TTL sweep over the live-session pool (the LRU cap is enforced
// eagerly on each new start; this catches sessions that simply went stale).
if (!this.reaper) {
this.reaper = setInterval(() => this.reapPool(), GrokSidebar.REAP_INTERVAL_MS);
}
// Re-tell the webview whether voice is set up when the relevant settings
// change, so the mic button's "needs setup" hint updates without a reload.
this.configWatcher?.dispose();
this.configWatcher = vscode.workspace.onDidChangeConfiguration((e) => {
if (
e.affectsConfiguration("grok.voiceApiKey") ||
e.affectsConfiguration("grok.ffmpegPath") ||
e.affectsConfiguration("grok.voiceSendPhrase")
) {
this.postVoiceConfigured();
}
if (e.affectsConfiguration("grok.chatFontScale")) {
this.postFontScale();
}
});
}
insertActiveMention(opts?: { selection?: boolean; uri?: vscode.Uri }): void {
const editor = vscode.window.activeTextEditor;
const uri = opts?.uri ?? editor?.document.uri;
if (!uri) return;
const relPath = vscode.workspace.asRelativePath(uri);
let selStart: number | undefined;
let selEnd: number | undefined;
if (opts?.selection && editor && !editor.selection.isEmpty) {
selStart = editor.selection.start.line + 1;
selEnd = editor.selection.end.line + 1;
}
this.chips.push(makeExplicitChip(uri.fsPath, relPath, selStart, selEnd));
this.postChips();
this.reveal();
}
newSession(): void {
void this.newFocusedSession();
}
async pickModel(): Promise<void> {
if (!this.focused.client || !this.focused.client.availableModels.length) {
vscode.window.showInformationMessage("Start a session first.");
return;
}
const items = this.focused.client.availableModels.map((m) => ({
label: m.name ?? m.modelId,
description: m.modelId === this.focused.client!.currentModelId ? "$(check) current" : "",
detail: m.description,
modelId: m.modelId,
}));
const picked = await vscode.window.showQuickPick(items, {
placeHolder: "Pick a Grok model",
});
if (picked) await this.switchModel(picked.modelId);
}
/**
* Switch the active model. Models belong to "agent types" (e.g. grok-build vs
* cursor for the composer models); the CLI binds the agent at spawn and locks
* it after the first turn, so a live `set_model` only works within the same
* agent. When it's rejected for a cross-agent model we persist the choice and
* restart — `newSession` re-applies it before the primer runs, while the agent
* is still rebindable. Same-agent switches stay live (history intact).
*/
async switchModel(modelId: string): Promise<void> {
const client = this.focused.client;
// Ignore switches fired during the session-start window: the live set_model
// would race the hidden primer (sometimes landing before the agent locks,
// sometimes after — see research/model-switch-race-probe.cjs), making the
// outcome unpredictable. The webview disables the control while busy; this
// is the backstop for a click already in flight.
if (!client || this.focused.priming || modelId === client.currentModelId) return;
const cfg = vscode.workspace.getConfiguration("grok");
try {
await client.setModel(modelId);
await cfg.update("defaultModel", modelId, vscode.ConfigurationTarget.Global);
} catch (e) {
if (!isIncompatibleAgentError(e)) {
vscode.window.showErrorMessage(`Failed to set model: ${(e as Error).message}`);
return;
}
if (!this.focused.hasHistory) {
// Primer-only session (no real conversation): a cross-agent switch restarts it with a fresh
// grok id. There's nothing to summarize, so we never prompt here — and we don't leave the
// abandoned primer-only session cluttering history (repeated switches would pile them up).
// Drop it after the restart, carrying over any rename the user made.
const discardId = this.focused.activeSessionId;
await cfg.update("defaultModel", modelId, vscode.ConfigurationTarget.Global);
await this.startSession();
this.discardRestartedEmptySession(discardId);
return;
}
const mode = await this.pickRestartMode("Switching to this model requires a new session.");
if (!mode) return; // dismissed — keep the current model
await cfg.update("defaultModel", modelId, vscode.ConfigurationTarget.Global);
await this.restartSession(mode);
}
}
openModePopover(): void {
this.post({ type: "openModePopover" });
}
/**
* Development / testing helper. Posts a realistic dummy `exitPlanRequest` so
* the plan-review card (Approve / Reject / Cancel) appears in the webview.
* Lets you exercise the three options, the feedback textarea, the resolved
* state, and the downstream notice/mode logic without a live grok process.
* The "Reject" button is the one labeled "Keep planning" in the real flow.
*/
debugShowDummyPlan(): void {
const dummyPlan = `# Refactor authentication helper
## Summary
Introduce a small \`auth.ts\` module and migrate the two call sites in the API layer. No behavior change for end users.
## Detailed steps
1. Create \`src/lib/auth.ts\` exporting \`getSessionToken()\` and \`isTokenExpired()\`.
2. Update \`src/api/client.ts\` (two call sites) to delegate to the new helper.
3. Add unit tests in \`tests/auth.test.ts\` covering expiry + refresh paths.
4. Run the integration suite to confirm nothing regressed.
## Risk / notes
- Token format is unchanged.
- One new (already-transitive) dependency on \`jsonwebtoken\`.
\`\`\`ts
// proposed addition to src/lib/auth.ts
export async function getSessionToken(): Promise<string> {
const cached = getFromCache();
if (cached && !isTokenExpired(cached)) return cached;
return refresh();
}
\`\`\`
See design doc for the full state machine diagram.`;
this.post({
type: "exitPlanRequest",
req: {
id: "dummy-plan-" + Date.now(),
sessionId: this.focused.activeSessionId || "dummy-session",
plan: dummyPlan,
},
});
// Make the bottom mode button reflect Plan during the manual test.
this.post({ type: "modeChanged", modeId: "plan" });
}
/**
* The mode the UI should show. Plan and YOLO are *client* states that the CLI
* doesn't model (the CLI only knows agent/plan), so we derive the button label
* here rather than echoing the CLI's raw mode id.
*/
private displayMode(): "agent" | "plan" | "yolo" {
if (this.focused.planActive) return "plan";
if (this.focused.autoApprove) return "yolo";
return "agent";
}
private postMode(): void {
this.post({ type: "modeChanged", modeId: this.displayMode() });
}
/** Toggle the client-enforced plan gate and keep the live client in sync. Only
* the focused session drives the mode button — a background session entering
* plan mode raises its own gate silently. */
private setPlanActive(session: Session, v: boolean): void {
session.planActive = v;
if (session.client) session.client.planActive = v;
if (session === this.focused) this.postMode();
}
async setMode(modeId: "agent" | "plan" | "yolo"): Promise<void> {
// Agent/plan/yolo are mutually exclusive. Plan = client write/exec gate;
// YOLO = auto-approve. Both ride on top of the CLI's agent mode, except
// Plan which also tells the CLI to plan instead of act. The mode button only
// ever drives the focused session.
const session = this.focused;
if (modeId === "yolo") {
session.autoApprove = true;
this.setPlanActive(session, false); // posts displayMode → "yolo"
if (session.client) {
try { await session.client.setMode(ACT_MODE_ID); } catch { /* CLI stays put; gate is what matters */ }
}
return;
}
session.autoApprove = false;
if (modeId === "plan") {
this.setPlanActive(session, true); // posts displayMode → "plan"
if (session.client) {
try { await session.client.setMode("plan"); }
catch (e) { vscode.window.showErrorMessage(`Couldn't switch mode: ${(e as Error).message}`); }
}
return;
}
// agent
this.setPlanActive(session, false); // posts displayMode → "agent"
if (session.client) {
try { await session.client.setMode(ACT_MODE_ID); }
catch (e) { vscode.window.showErrorMessage(`Couldn't switch mode: ${(e as Error).message}`); }
}
}
/**
* Resolve a plan-review card. The CLI's `exit_plan_mode` treats *any* response
* as approval, so the protocol verdict is cosmetic — our gate is the real
* decision. Crucially, this fires *during* the planning prompt's turn, so we
* only respond here and defer any new prompt/set_mode to `afterTurn`, which
* runs once that turn completes (handleSend).
*
* Three verdicts:
* - `approved`: drop gate, return CLI to act mode, send "implement now".
* - `rejected`: keep gate up. If the user left a comment, send it as a plain
* user message after the turn ends and let grok decide what to do next
* (re-plan, ask clarifying questions, etc.) — we don't force a specific
* "revise the plan" framing.
* - `abandoned`: drop gate (exit plan mode entirely), no follow-up prompt.
* The user wants to back out and continue freely.
*
* `rejected`/`abandoned` cut off the CLI's false-approval continuation via
* `cancel()` + a content-only suppression flag. Lifecycle events
* (`promptComplete`, `agentEnd`) still reach the webview so `busy` clears and
* the send button re-enables when the cancelled turn finally ends.
*/
private handleExitPlan(
requestId: number | string,
verdict: "approved" | "abandoned" | "rejected",
comment?: string,
): void {
const session = this.focused;
const client = session.client;
if (!client) return;
const gen = session.gen;
client.respondExitPlan(requestId, verdict);
this.persistPlanVerdict(session, verdict);
this.setStatus(session, "working"); // a verdict always triggers a follow-up turn
const feedback = comment?.trim();
if (verdict === "approved") {
// Drop the gate now, then once the planning turn ends, return the CLI to
// act mode and have it implement. The wire-level prompt uses the same
// [Plan approved] marker the primer trained grok to recognize, so all
// three verdicts speak a consistent protocol. If the user attached a
// comment, post it as their user bubble immediately and append it to the
// wire-level prompt — same pattern as reject/cancel.
this.setPlanActive(session, false);
if (feedback) {
session.userMessageCount += 1;
this.emit(session, { type: "userMessage", text: feedback, chips: [] });
}
this.emit(session, { type: "planProcessing" }); // indicator while we wait for grok
const promptToGrok = feedback ? `[Plan approved] ${feedback}` : "[Plan approved]";
session.afterTurn = async () => {
try { await client.setMode(ACT_MODE_ID); } catch { /* CLI usually auto-exits already */ }
this.emit(session, { type: "agentStart" });
this.setStatus(session, "working");
try {
await this.ensurePrimed(client, session, gen);
if (gen !== session.gen) return;
const meta = await client.prompt(promptToGrok);
if (gen !== session.gen) return;
this.emit(session, { type: "agentEnd", meta });
this.setStatus(session, "done");
} catch (err) {
if (gen !== session.gen) return;
const e = err as any;
this.emit(session, { type: "agentError", text: e?.data?.message ?? e?.message ?? String(err) });
this.setStatus(session, "error");
}
};
return;
}
// rejected / abandoned: cancel the in-flight turn and suppress its content
// so the false-approval response doesn't reach the screen.
void client.cancel();
this.emit(session, { type: "agentReset" });
session.suppressPlanReject = true;
// If the user attached a comment, post it as their user bubble IMMEDIATELY
// (not deferred to afterTurn) so it lands in the conversation right after
// the verdict click. Same text gets sent to grok later, verbatim — what the
// user sees IS what grok receives, no wire-level boilerplate prefix.
if (feedback) {
session.userMessageCount += 1;
this.emit(session, { type: "userMessage", text: feedback, chips: [] });
this.emit(session, { type: "planProcessing" }); // grok will process this comment
}
if (verdict === "rejected") {
// Stay in plan mode. The wire-level prompt is always prefixed with the
// [Plan rejected] marker the primer trained grok to recognize — even when
// the user typed a comment, grok needs the unambiguous verdict tag in
// front of it to distinguish "Reject + free-form note" from a regular
// user message. The webview's user bubble (posted earlier in this
// function) still shows just the user's words.
this.setPlanActive(session, true);
if (!feedback) {
this.emit(session, {
type: "planNotice",
text: "Plan rejected — staying in Plan mode. Grok is processing the rejection…",
});
this.emit(session, { type: "planProcessing" });
}
const promptToGrok = feedback ? `[Plan rejected] ${feedback}` : "[Plan rejected]";
session.afterTurn = async () => {
session.suppressPlanReject = false;
try { await client.setMode("plan"); } catch { /* gate still enforces */ }
this.emit(session, { type: "agentStart" });
this.setStatus(session, "working");
try {
await this.ensurePrimed(client, session, gen);
if (gen !== session.gen) return;
const meta = await client.prompt(promptToGrok);
if (gen !== session.gen) return;
this.emit(session, { type: "agentEnd", meta });
this.setStatus(session, "done");
} catch (err) {
if (gen !== session.gen) return;
const e = err as any;
this.emit(session, { type: "agentError", text: e?.data?.message ?? e?.message ?? String(err) });
this.setStatus(session, "error");
}
};
return;
}
// abandoned: drop the gate, return to agent mode. The wire-level prompt is
// always prefixed with the [Plan cancelled] marker (per the primer
// contract). With a comment, the marker precedes the user's words; without
// one, the marker stands alone.
this.setPlanActive(session, false);
if (!feedback) {
this.emit(session, {
type: "planNotice",
text: "Plan abandoned — switched to Agent mode. Grok is processing the cancellation…",
});
this.emit(session, { type: "planProcessing" });
}
const promptToGrok = feedback ? `[Plan cancelled] ${feedback}` : "[Plan cancelled]";
session.afterTurn = async () => {
session.suppressPlanReject = false;
try { await client.setMode(ACT_MODE_ID); } catch { /* best-effort */ }
this.emit(session, { type: "agentStart" });
this.setStatus(session, "working");
try {
const meta = await client.prompt(promptToGrok);
if (gen !== session.gen) return;
this.emit(session, { type: "agentEnd", meta });
this.setStatus(session, "done");
} catch (err) {
if (gen !== session.gen) return;
const e = err as any;
this.emit(session, { type: "agentError", text: e?.data?.message ?? e?.message ?? String(err) });
this.setStatus(session, "error");
}
};
}
/** Send the extension's standing instructions ("primer") to grok exactly once
* per grok session — teaching it the plan-verdict protocol the CLI's buggy
* exit_plan_mode can't convey. It fires EAGERLY and NON-BLOCKING the moment a
* session goes live (startSession kicks this off), so the composer is never
* held: the user can send immediately, and their first real prompt awaits this
* same promise (grok can't run two turns at once) — released the instant the
* silent primer acks. The primer's turn is hidden from live chat
* (suppressContent drops grok's "ok"); the user's own message bubble + the
* Grokking indicator are NOT suppressed (they're not in SUPPRESS_TYPES), so a
* send that overlaps the still-running primer shows as sent right away.
*
* Idempotent: returns the existing in-flight promise so a fast send doesn't
* start a second primer; resolves immediately once primed. Best-effort — a
* failed primer clears the promise so the next send retries, and never throws
* to the caller (the plan-gate, not the primer, is the actual enforcement). */
private ensurePrimed(client: AcpClient, session: Session, gen: number): Promise<void> {
if (session.primed) return Promise.resolve();
if (session.primingPromise) return session.primingPromise;
const promise = (async () => {
session.suppressContent = true;
try {
await client.prompt(GROK_PRIMER);
if (gen === session.gen) session.primed = true;
} catch (e) {
this.output.appendLine(`[primer] failed: ${(e as Error).message}`);
} finally {
if (gen === session.gen) session.suppressContent = false;
// On failure leave the session unprimed and drop the promise so the next
// outbound prompt retries instead of awaiting a dead one.
if (!session.primed) session.primingPromise = undefined;
}
})();
session.primingPromise = promise;
return promise;
}
/** Persist this plan (text + verdict) so the resume view can replay every plan
* the user resolved in this session — grok's on-disk plan.md only retains the
* latest, so we'd otherwise lose plans the agent overwrote later. */
private persistPlanVerdict(session: Session, verdict: "approved" | "abandoned" | "rejected"): void {
const sid = session.activeSessionId ?? session.client?.sessionId;
if (!sid) return;
const overrides = this.context.globalState.get<SessionMetaOverrides>(SESSION_META_KEY, {});
const cur = overrides[sid] ?? {};
const planText = session.pendingPlanText || "";
session.pendingPlanText = "";
const plans = appendPlanEntry(cur.plans, {
text: planText,
verdict,
afterUserMessage: session.userMessageCount,
});
const next: SessionMetaOverrides = {
...overrides,
[sid]: { ...cur, lastPlanVerdict: verdict, plans },
};
void this.context.globalState.update(SESSION_META_KEY, next);
}
/** Run and clear any deferred post-turn action set by `handleExitPlan`. */
private async runAfterTurn(session: Session): Promise<void> {
const fn = session.afterTurn;
if (!fn) return;
session.afterTurn = undefined;
await fn();
}
/**
* Forward generated media (grok's `/imagine` image or `/imagine-video` video)
* to the webview. Remote URLs pass through as a link. File paths — how grok
* writes media into its session dir — are served via `asWebviewUri` when they
* live under a `localResourceRoots` entry (the grok home is one), so the
* webview streams the file straight from disk. That matters for video: a
* multi-MB clip base64-inlined into a single `postMessage` was silently
* dropped, which is why `/imagine-video` never rendered. Files outside the
* served roots fall back to a base64 `data:` URI. Best-effort: a failure just
* drops the media rather than breaking the turn.
*/
private async postGeneratedMedia(m: MediaRef, session: Session, gen: number): Promise<void> {
try {
if (m.kind === "data") {
this.emit(session, { type: "media", media: m.media, src: `data:${m.mimeType};base64,${m.data}` });
return;
}
if (m.kind === "uri") {
this.emit(session, { type: "media", media: m.media, url: m.uri });
return;
}
const mime = m.mimeType || guessMediaMime(m.path);
// Served from disk when the file is under a localResourceRoot (grok home):
// the webview pulls bytes lazily, so even a big video renders.
const webview = this.view?.webview;
if (webview && this.isServableFromDisk(m.path)) {
const src = webview.asWebviewUri(vscode.Uri.file(m.path)).toString();
this.emit(session, { type: "media", media: m.media, src, mimeType: mime, path: m.path });
return;
}
// Outside the served roots — inline as base64 so it still renders.
const bytes = await vscode.workspace.fs.readFile(vscode.Uri.file(m.path));
if (gen !== session.gen) return;
const b64 = Buffer.from(bytes).toString("base64");
this.emit(session, { type: "media", media: m.media, src: `data:${mime};base64,${b64}`, path: m.path });
} catch (e) {
this.output.appendLine(`[media] failed to forward generated media: ${(e as Error).message}`);
}
}
/** True when `p` resolves inside the grok home — the localResourceRoot grok
* generated media lives under, so `asWebviewUri` can serve it from disk. */
private isServableFromDisk(p: string): boolean {
try {
const root = path.resolve(resolveGrokHome());
const rel = path.relative(root, path.resolve(p));
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
} catch {
return false;
}
}
/**
* Save or open a math/diagram export from the webview. "open" writes the WYSIWYG
* PNG into extension storage and opens it in VS Code's image preview. "download"
* offers a quick-pick — PNG (VS Code theme background) or a transparent SVG tuned
* for a dark or light background — then a save dialog. The webview pre-renders all
* variants (the SVG light/dark differ: math recolors, mermaid re-themes).
*/
private async exportExpr(msg: {
action: string;
kind: string;
current?: string;
svg?: string;
png?: string;
svgDark?: string;
svgLight?: string;
}): Promise<void> {
try {
const base = msg.kind === "mermaid" ? "diagram" : "equation";
const toBytes = (png?: string) =>
png ? Buffer.from(png.split(",")[1] ?? "", "base64") : null;
if (msg.action === "open") {
const pngBytes = toBytes(msg.png);
const dir = path.join(this.context.globalStorageUri.fsPath, "exports");
fs.mkdirSync(dir, { recursive: true });
const stamp = Date.now();
const file = path.join(dir, `${base}-${stamp}.${pngBytes ? "png" : "svg"}`);
fs.writeFileSync(file, pngBytes ?? (msg.svg ?? ""), pngBytes ? undefined : "utf8");
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(file));
return;
}
// download: let the user pick the format/variant (two SVG variants share the
// .svg extension, so a save-dialog filter can't distinguish them — quick-pick).
const mark = (which: string) => (msg.current === which ? " (current theme)" : "");
const items = [
{ label: "PNG", description: "raster, VS Code theme background", fmt: "png" },
{ label: `SVG — for dark background${mark("dark")}`, description: "transparent, light ink", fmt: "svgDark" },
{ label: `SVG — for light background${mark("light")}`, description: "transparent, dark ink", fmt: "svgLight" },
];
const pick = await vscode.window.showQuickPick(items, {
placeHolder: `Export ${base} as…`,
});
if (!pick) return;
const ext = pick.fmt === "png" ? "png" : "svg";
const defaultName = `${base}.${ext}`;
const folder = vscode.workspace.workspaceFolders?.[0]?.uri;
const defaultUri = folder
? vscode.Uri.joinPath(folder, defaultName)
: vscode.Uri.file(defaultName);
const filters: Record<string, string[]> =
ext === "png" ? { "PNG image": ["png"] } : { "SVG image": ["svg"] };
const target = await vscode.window.showSaveDialog({ defaultUri, filters });
if (!target) return;
if (pick.fmt === "png") {
const pngBytes = toBytes(msg.png);
fs.writeFileSync(target.fsPath, pngBytes ?? Buffer.from(msg.svgDark ?? "", "utf8"));
} else {
const svg = pick.fmt === "svgDark" ? msg.svgDark : msg.svgLight;
fs.writeFileSync(target.fsPath, svg ?? "", "utf8");
}
} catch (e) {
this.output.appendLine(`[export] failed: ${(e as Error).message}`);
void vscode.window.showErrorMessage(`Export failed: ${(e as Error).message}`);
}
}
/**
* Sign out of the Grok CLI (`grok logout` — clears `~/.grok/auth.json`). The
* CLI owns auth, so we shell out to it, tear down the live session, and drop
* the webview back to the auth-required onboarding state. Resolves issue #13.
*/
async logout(): Promise<void> {
const cliPath = this.cliPath || locateGrokCli(
vscode.workspace.getConfiguration("grok").get<string>("cliPath", ""),
);
if (!cliPath) {
this.post({ type: "onboarding", state: "missing-cli", platform: process.platform });
return;
}
const choice = await vscode.window.showWarningMessage(
"Sign out of Grok? This clears the CLI's cached credentials.",
{ modal: true },
"Sign Out",
);
if (choice !== "Sign Out") return;
// Tear down every live session first so no client's `exit` (or in-flight
// turn) races the onboarding state we're about to show, then reset focus to a
// fresh, unstarted session.
this.disposePool();
this.focused = new Session();
const term = vscode.window.createTerminal("Grok Logout");
term.sendText(`"${cliPath}" logout`);
this.post({ type: "clearMessages" });
this.post({ type: "onboarding", state: "auth-required" });
}
dispose(): void {
if (this.reaper) { clearInterval(this.reaper); this.reaper = undefined; }
this.disposePool();
this.editorWatcher?.dispose();
this.configWatcher?.dispose();
this.terminalManager.disposeAll();
this.voiceRecorder.cancel();
this.voiceStreamer?.cancel();
try { if (this.voiceTempPath) fs.unlinkSync(this.voiceTempPath); } catch { /* best effort */ }
}
// ---------- internals ----------
private async ensureClient(): Promise<AcpClient | undefined> {
if (this.focused.client) return this.focused.client;
return this.startSession();
}
/**
* Silently update the grok CLI when *our extension* was upgraded since the last
* run (the user opted into silent updates). Runs once per activation, before we
* spawn grok — so no grok process holds the binary open (matters on Windows) and
* the next `initialize` reports the new version on the welcome screen. Never on a
* fresh install (no prior version recorded), never blocking: a failed/slow update
* is logged and we proceed with the current binary.
*/
private async maybeUpdateCliOnUpgrade(cliPath: string): Promise<void> {
if (this.cliUpdateChecked) return;
this.cliUpdateChecked = true;
const current = (this.context.extension.packageJSON as { version?: string })?.version ?? "";
const lastSeen = this.context.globalState.get<string>(CLI_UPDATE_VERSION_KEY);
try {
if (extensionWasUpgraded(lastSeen, current)) {
this.output.appendLine(`Extension upgraded ${lastSeen} → ${current}; updating grok CLI (silent).`);
this.post({ type: "cliUpdating" });
try {
const { stdout, stderr } = await execFileAsync(cliPath, ["update"], { timeout: 180_000 });
if (stdout?.trim()) this.output.appendLine(stdout.trim());
if (stderr?.trim()) this.output.appendLine(stderr.trim());
} catch (e) {
this.output.appendLine(`grok update failed (continuing with current binary): ${(e as Error).message}`);
}
}
} finally {
// Record the current version regardless, so a fresh install sets the baseline
// (no update) and the *next* upgrade is the one that triggers.
void this.context.globalState.update(CLI_UPDATE_VERSION_KEY, current);
}
}
/**
* On-demand "is a newer grok available?" check for the gear → About panel.
* Read-only — `grok update --check --json` doesn't touch the binary, so it's
* safe while a session is live. Posts a grokUpdateStatus back to the webview.
*/
private async checkGrokUpdate(): Promise<void> {
const cliPath = this.cliPath || locateGrokCli(
vscode.workspace.getConfiguration("grok").get<string>("cliPath", ""),
);
if (!cliPath) {
this.post({ type: "grokUpdateStatus", error: "grok CLI not found" });
return;
}
try {
const { stdout } = await execFileAsync(cliPath, ["update", "--check", "--json"], { timeout: 30_000 });
const info = JSON.parse(stdout) as { currentVersion?: string; latestVersion?: string; updateAvailable?: boolean };
this.post({
type: "grokUpdateStatus",
current: info.currentVersion ?? null,
latest: info.latestVersion ?? null,
updateAvailable: !!info.updateAvailable,
});
} catch (e) {
this.output.appendLine(`grok update --check failed: ${(e as Error).message}`);
this.post({ type: "grokUpdateStatus", error: (e as Error).message });
}
}
/**
* On-demand "Update Grok Build" from the About panel. grok holds its binary
* open while running (a hard lock on Windows), so we tear the session down,
* run `grok update`, then resume the *same* session on the fresh binary —
* preserving the conversation. The welcome lifecycle (Updating… → Starting… →
* Connected · v<new>) shows progress. cliUpdateChecked is already set, so
* startSession's silent path won't re-run the update.
*/
private async updateGrokCliOnDemand(): Promise<void> {
const cliPath = this.cliPath || locateGrokCli(
vscode.workspace.getConfiguration("grok").get<string>("cliPath", ""),
);
if (!cliPath) {
this.post({ type: "onboarding", state: "missing-cli", platform: process.platform });
return;
}
// The update tears down the whole pool (the binary is locked while any session
// holds it open), so a session that's mid-turn or waiting on you would be
// interrupted. Warn first if any are — now that several can run at once, this
// is no longer a non-event. (The silent startup auto-update skips this: it runs
// before anything is in flight.)
const busy = [...this.pool].filter(
(s) => s.status === "working" || s.status === "needs-you",
).length;
if (busy > 0) {
const choice = await vscode.window.showWarningMessage(
`Updating the Grok Build CLI will stop ${busy} session${busy === 1 ? "" : "s"} currently in progress. Continue?`,
{ modal: true },
"Update Anyway",
);
if (choice !== "Update Anyway") return;
}
const resumeId = this.focused.activeSessionId;
// Free the binary: every pooled session's process holds it open (a hard lock
// on Windows), so tear the whole pool down before the update replaces the
// executable, then resume the focused session on the fresh binary. Other
// backgrounded sessions go cold — re-focusing one reloads it from disk.
this.disposePool();
this.focused = new Session();
this.post({ type: "clearMessages" });
this.post({ type: "cliUpdating" });
try {
const { stdout, stderr } = await execFileAsync(cliPath, ["update"], { timeout: 180_000 });
if (stdout?.trim()) this.output.appendLine(stdout.trim());
if (stderr?.trim()) this.output.appendLine(stderr.trim());
} catch (e) {
this.output.appendLine(`grok update failed: ${(e as Error).message}`);
void vscode.window.showWarningMessage(`Grok Build update failed: ${(e as Error).message}`);
}
// Respawn on the (possibly) updated binary, resuming the same session.
await this.startSession(resumeId);
}
/** Confirm a restart for a setting that only applies on a fresh session
* (reasoning effort, cross-agent model). Returns the chosen restart mode, or
* undefined if the user dismissed the dialog. */
private async pickRestartMode(message: string): Promise<"clear" | "summarize" | undefined> {
const choice = await vscode.window.showInformationMessage(
message,
"Summarize & Restart",
"Just Restart",
);
if (!choice) return undefined;
return choice === "Just Restart" ? "clear" : "summarize";
}
/** Restart the session. "clear" drops the visible history; "summarize" first
* captures a one-paragraph summary of the conversation and re-injects it as
* hidden context after the restart so the new session keeps the thread. */
private async restartSession(mode: "clear" | "summarize"): Promise<void> {
if (mode === "clear") {
this.emit(this.focused, { type: "clearMessages" });
await this.startSession();
return;
}
const currentClient = this.focused.client;
this.emit(this.focused, { type: "summarizing" });
const chunks: string[] = [];
const captureChunk = (t: string) => chunks.push(t);
currentClient?.on("messageChunk", captureChunk);
this.focused.suppressContent = true;
try {
await currentClient?.prompt(
"Summarize our conversation so far in a concise paragraph. Be brief.",
);
} catch { /* best effort */ } finally {
currentClient?.off("messageChunk", captureChunk);
this.focused.suppressContent = false;
}
const summary = chunks.join("").trim();
await this.startSession(); // resets suppressContent + eagerly kicks off the primer
if (summary && this.focused.client) {
// Await the eager primer FIRST (it manages its own suppression and ends with
// suppressContent=false), THEN re-assert suppression for the hidden summary
// injection. Doing it the other way round would let the primer's completion
// clear the flag mid-summary and leak "[Context from previous session]".
await this.ensurePrimed(this.focused.client, this.focused, this.focused.gen);
this.emit(this.focused, { type: "sessionContext" });
this.focused.suppressContent = true;
try {
await this.focused.client.prompt(`[Context from previous session]\n${summary}`);
} catch { /* best effort */ } finally {
this.focused.suppressContent = false;
}
}
}
/** A model/effort switch on a primer-only session (no real conversation) restarts it with a new
* grok session id. grok already persisted the abandoned one, so without this each repeated switch
* would pile another empty session into history. Drop the old session's on-disk dir and carry any
* user rename (`customName`) onto the new session so the chosen name survives the restart. The
* caller must only invoke this when the prior session genuinely had no history. No-op if the ids
* match or the old session was never persisted. */
private discardRestartedEmptySession(oldId: string | undefined): void {
const newId = this.focused.activeSessionId;
if (!oldId || oldId === newId) return;
const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
const grokHome = resolveGrokHome(process.env);
try {
deleteSessionDir({ fs: defaultFs, grokHome, cwd, id: oldId });
} catch (e) {
this.output.appendLine(`[sessions] could not discard empty session ${oldId}: ${(e as Error).message}`);
}
const overrides = this.context.globalState.get<SessionMetaOverrides>(SESSION_META_KEY, {});
void this.context.globalState.update(SESSION_META_KEY, carrySessionName(overrides, oldId, newId));
this.sessionCache.delete(oldId);
this.postSessionsList();
}
private async startSession(resumeId?: string): Promise<AcpClient | undefined> {
// The session this start (re)builds. Today always the focused one (pool-of-1);
// Step D passes a pool member. Its handlers close over `session`/`gen` so a
// backgrounded session's events stay bound to it even after focus moves.
const session = this.focused;
const gen = ++session.gen;
session.buffer = [];
session.status = "idle";
// Stop any in-progress voice capture so listening never carries across a
// new/resumed/restarted session (covers New Session, history resume, and
// model/effort restarts — all of which route through here).
this.stopVoiceInput();
session.client?.dispose();
session.client = undefined;
session.autoApprove = false;
session.planActive = false;
session.afterTurn = undefined;
session.hasHistory = false;
session.primed = false;
session.primingPromise = undefined;