Repository navigation
Expand file tree
/
Copy pathmain.js
More file actions
1989 lines (1884 loc) · 97.5 KB
/
Copy pathmain.js
File metadata and controls
1989 lines (1884 loc) · 97.5 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
const { app, BrowserWindow, ipcMain, dialog, shell, screen, nativeTheme, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
// One data folder, run from source or installed. Installed, the app is named "mwcode", which would
// otherwise give it a new, empty folder and lose the saved window, drafts and What's new state.
// A source run can be pointed at a throwaway folder (MWCODE_DATA_DIR) so testing never touches real data.
app.setPath('userData', (!app.isPackaged && process.env.MWCODE_DATA_DIR) || path.join(app.getPath('appData'), 'mwcode-desktop'));
// One copy at a time: opening mwcode again (a second click, or a login start on top of a restored
// one) brings the open window forward instead of starting another app on the same data.
if (!app.requestSingleInstanceLock()) process.exit(0);
app.on('second-instance', () => {
const w = BrowserWindow.getAllWindows()[0];
if (!w) return;
if (w.isMinimized()) w.restore();
w.show();
w.focus();
});
// macOS: an app opened from the Dock or Finder gets a bare PATH (/usr/bin:/bin:/usr/sbin:/sbin), not your
// terminal's, so the agent would not find Homebrew's, nvm's or anything else's tools. Your login shell is
// asked for it once, in the background, and what it adds is put in front.
let loginPathReady = Promise.resolve();
if (process.platform === 'darwin') {
loginPathReady = new Promise((done) => require('child_process').execFile(process.env.SHELL || '/bin/zsh', ['-ilc', 'printf "\n__MWPATH__%s__MWPATH__" "$PATH"'],
{ timeout: 5000, env: { ...process.env, DISABLE_AUTO_UPDATE: 'true' } }, (_err, out) => {
done();
const m = /__MWPATH__(.*)__MWPATH__/.exec(String(out || ''));
if (!m) return;
const seen = new Set();
process.env.PATH = [...m[1].split(':'), ...(process.env.PATH || '').split(':')]
.filter((p) => p && !seen.has(p) && seen.add(p)).join(':');
}));
}
// macOS: quitting (Cmd+Q, the menu, the Dock) closes the window for real; the red button only hides it.
let quitting = false;
app.on('before-quit', () => { quitting = true; });
// Which project was open, and what else has been opened before — so closing
// and reopening the app lands back where you left off instead of resetting to
// wherever the app happens to be installed, and switching projects again is a
// click in a list instead of the OS folder dialog every time.
const STATE_FILE = path.join(app.getPath('userData'), 'desktop-state.json');
const { createRecorder } = require('./recordings');
// Everything the window remembers goes through one durable store written at once (see ui-store.js).
const { createUiStore, writeJsonAtomic, readJson: readStateFile } = require('./ui-store');
let uiStore = null;
// The signed news and update feed (see news/): fetched here, checked here, and only clean items
// ever reach the window.
const { createFeedService } = require('./news/feedService');
const { PUBLIC_KEY: NEWS_PUBLIC_KEY } = require('./news/feedKey');
let newsFeed = null;
// The app's own updates: a signed manifest, a checked download, installed on restart (see updater/).
const { createUpdateService } = require('./updater/updateService');
const { PUBLIC_KEY: UPDATE_PUBLIC_KEY } = require('./updater/updateKey');
let updater = null;
// The terminal version that really runs on this computer (see updater/cliUpdate.js). The app carries its
// own copy, but the `mw` a person types is whichever comes first on their PATH, so judging "is there a
// newer one" by the bundled copy was wrong for anyone who installed it with npm.
const cliUpdate = require('./updater/cliUpdate');
let userCli = null;
let cliDetected = Promise.resolve();
let cliUpdating = null;
const cliOpts = () => ({ bundledDir: app.isPackaged ? path.join(process.resourcesPath, 'bin') : null });
function detectCliNow() {
// On a Mac the real PATH arrives a moment after launch, and looking before it would miss an npm install.
cliDetected = loginPathReady.then(() => cliUpdate.detectCli(cliOpts())).then((s) => { userCli = s; }).catch(() => {});
return cliDetected;
}
const cliVersionOr = (fallback) => userCli?.version || fallback;
// What the news feed shows depends on the versions, so it is sent again whenever they change.
function pushFeed() {
if (!newsFeed) return;
Promise.resolve(newsFeed.get()).then((result) => {
for (const w of BrowserWindow.getAllWindows()) if (!w.isDestroyed()) w.webContents.send('feed:updated', result);
}).catch(() => {});
}
// What the app saw while testing an app, kept for replay (see recordings.js).
const recorder = createRecorder(path.join(app.getPath('userData'), 'recordings'));
const MAX_RECENT_PROJECTS = 100;
// Windows 11's SMALL rounded corners on the window: the same few pixels the boxes inside
// have. Electron can only turn the rounding on (large) or off, so this asks Windows
// directly (DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE = DWMWCP_ROUNDSMALL).
// Older Windows has no such setting and simply keeps square corners.
const ROUND_IN_PAGE = process.platform === 'linux';
function roundWindowCorners(win) {
if (process.platform !== 'win32') return;
try {
const koffi = require('koffi');
const setAttribute = koffi
.load('dwmapi.dll')
.func('long __stdcall DwmSetWindowAttribute(intptr_t hwnd, uint32_t attr, _In_ uint32_t *value, uint32_t size)');
const handle = win.getNativeWindowHandle();
const hwnd = handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
const DWMWA_WINDOW_CORNER_PREFERENCE = 33;
const DWMWCP_ROUNDSMALL = 3;
setAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, [DWMWCP_ROUNDSMALL], 4);
} catch {
// Square corners it is: nothing else depends on this.
}
}
// ── Opening a file in the user's editor ──────────────────────────────────
// VS Code or Cursor when installed, at the line (`-g file:line`). Otherwise whatever Windows
// opens that kind of file with, or its "Open with" choice when nothing is set. Not the Windows
// default first: `.ts` is also a video format, and on a real machine it opened in a player.
// The editor is started the way its own `code.cmd` starts it (its Code.exe running its cli.js),
// so no command window flashes and no path goes through a shell.
let editorLauncher; // undefined = not looked for yet, null = none found
function findEditorLauncher() {
if (editorLauncher !== undefined) return editorLauncher;
editorLauncher = null;
if (process.platform !== 'win32') {
const names = ['code', 'cursor', 'codium', 'code-insiders'];
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
for (const name of names) {
for (const d of dirs) {
const exe = path.join(d, name);
try { fs.accessSync(exe, fs.constants.X_OK); editorLauncher = { exe, cli: null }; return editorLauncher; } catch { /* next */ }
}
}
// macOS: the editors' own command inside the app, for when "code" was never added to the PATH.
if (process.platform === 'darwin') {
const apps = ['/Applications', path.join(os.homedir(), 'Applications')];
const inside = [['Visual Studio Code.app', 'code'], ['Cursor.app', 'cursor'], ['VSCodium.app', 'codium']];
for (const dir of apps) {
for (const [bundle, cmd] of inside) {
const exe = path.join(dir, bundle, 'Contents', 'Resources', 'app', 'bin', cmd);
try { fs.accessSync(exe, fs.constants.X_OK); editorLauncher = { exe, cli: null }; return editorLauncher; } catch { /* next */ }
}
}
}
return editorLauncher;
}
const local = process.env.LOCALAPPDATA || '';
const programs = [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean);
const candidates = [
...(process.env.PATH || '').split(path.delimiter).flatMap((d) => [path.join(d, 'code.cmd'), path.join(d, 'cursor.cmd')]),
path.join(local, 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'),
...programs.map((p) => path.join(p, 'Microsoft VS Code', 'bin', 'code.cmd')),
path.join(local, 'Programs', 'cursor', 'resources', 'app', 'bin', 'cursor.cmd'),
];
for (const cmd of candidates) {
try {
if (!fs.existsSync(cmd)) continue;
// "%~dp0..\Code.exe" "%~dp0..\<version>\resources\app\out\cli.js" %*
const m = /"%~dp0([^"]+\.exe)"\s+"%~dp0([^"]+cli\.js)"/i.exec(fs.readFileSync(cmd, 'utf8'));
if (!m) continue;
const dir = path.dirname(cmd);
const exe = path.resolve(dir, m[1]);
const cli = path.resolve(dir, m[2]);
if (fs.existsSync(exe) && fs.existsSync(cli)) { editorLauncher = { exe, cli }; break; }
} catch { /* try the next one */ }
}
return editorLauncher;
}
function loadDesktopState() {
return readStateFile(STATE_FILE) || readStateFile(`${STATE_FILE}.bak`) || { lastProjectCwd: null, recentProjects: [] };
}
// Written whole and at once, never half: a crash cannot leave a damaged file (see ui-store.js).
function saveDesktopState(state) {
try {
writeJsonAtomic(STATE_FILE, state, true);
} catch {
// Best-effort — a project just won't be remembered next launch, not worth surfacing.
}
}
// Where the window was, so it opens there. Only a place that is still on a screen is used.
function windowPlacement(saved) {
const base = { width: 1360, height: 880 };
if (!saved || !Number.isFinite(saved.width) || !Number.isFinite(saved.height)) return base;
const width = Math.max(900, Math.round(saved.width));
const height = Math.max(600, Math.round(saved.height));
if (!Number.isFinite(saved.x) || !Number.isFinite(saved.y)) return { width, height };
const visible = screen.getAllDisplays().some((d) => {
const a = d.workArea;
const w = Math.min(saved.x + width, a.x + a.width) - Math.max(saved.x, a.x);
const h = Math.min(saved.y + height, a.y + a.height) - Math.max(saved.y, a.y);
return w >= 200 && h >= 120;
});
return visible ? { x: Math.round(saved.x), y: Math.round(saved.y), width, height } : { width, height };
}
// The session you were in, per project: after a restart (an update, a crash, a quit) the app
// opens that one, not merely the newest.
function rememberSession(cwd, id) {
const state = loadDesktopState();
state.lastSessionByCwd = { ...(state.lastSessionByCwd || {}), [cwd]: id };
saveDesktopState(state);
}
// The name a project goes by in the app: the one given in the app (right-click, Rename), else its
// folder's name. Only a label: the folder, its sessions and what the agent sees keep the real name.
function projectName(cwd) {
const given = loadDesktopState().projectNames?.[cwd];
return given || path.basename(cwd);
}
// Someone who used the CLI before installing the app already has projects: each session the core saved
// records its folder (`cwd` in <id>.meta.json under ~/.mindweave/projects). On the app's first launch those
// folders become its project list, newest first, so it opens where the CLI left off. Once only: after that
// the list is the app's own, and a project removed from it stays removed. Keys, sessions, rules, MCP
// servers and the rest need nothing: the app reads the same ~/.mindweave as the CLI.
function importCliProjects() {
const state = loadDesktopState();
if (state.cliProjectsImported) return;
const found = new Map(); // cwd -> newest session time
const root = path.join(process.env.MINDWEAVE_STATE_DIR?.trim() || path.join(os.homedir(), '.mindweave'), 'projects');
const tmp = path.resolve(os.tmpdir());
let dirs = [];
try { dirs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()); } catch { /* no CLI state */ }
for (const d of dirs) {
let metas = [];
try { metas = fs.readdirSync(path.join(root, d.name)).filter((f) => f.endsWith('.meta.json')); } catch { continue; }
for (const f of metas) {
try {
const meta = JSON.parse(fs.readFileSync(path.join(root, d.name, f), 'utf8'));
const cwd = typeof meta.cwd === 'string' ? meta.cwd : null;
if (!cwd) continue;
const when = (v) => (typeof v === 'number' ? v : Date.parse(v || '') || 0); // milliseconds, or an old date string
const at = when(meta.updatedAt) || when(meta.createdAt);
if (at > (found.get(cwd) ?? -1)) found.set(cwd, at);
} catch { /* a damaged file: skip it */ }
}
}
const cli = [...found.entries()]
.filter(([cwd]) => !path.resolve(cwd).startsWith(tmp) && fs.existsSync(cwd)) // not test runs, still there
.sort((a, b) => b[1] - a[1])
.map(([cwd]) => cwd);
const own = state.recentProjects || [];
state.recentProjects = [...own, ...cli.filter((p) => !own.includes(p))].slice(0, MAX_RECENT_PROJECTS);
if (!state.lastProjectCwd && cli.length) state.lastProjectCwd = cli[0];
state.cliProjectsImported = true;
saveDesktopState(state);
}
function rememberProject(cwd) {
const state = loadDesktopState();
state.lastProjectCwd = cwd;
state.recentProjects = [cwd, ...(state.recentProjects || []).filter((p) => p !== cwd)].slice(0, MAX_RECENT_PROJECTS);
saveDesktopState(state);
return state.recentProjects;
}
// The real Mindweave engine. `mindweave` is pure ESM, so
// it is reached with a dynamic import from this CommonJS main process; loaded once
// and reused for every send.
let turnRunnerPromise = null;
function turnRunner() {
if (!turnRunnerPromise) {
turnRunnerPromise = import('mindweave/core').then((core) => {
core.loadConfig(); // ~/.mindweave/.env, same key layering the CLI uses
return core;
});
}
return turnRunnerPromise;
}
// ── Run button: what "run this project" means, read from the project itself ──
// Checked in order of how specific the signal is: a desktop-app shell (Tauri,
// Electron) beats a generic dev script. Returns null when nothing is obvious,
// which greys the button out rather than guessing.
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
}
function packageManager(cwd) {
const has = (f) => fs.existsSync(path.join(cwd, f));
if (has('pnpm-lock.yaml')) return 'pnpm';
if (has('yarn.lock')) return 'yarn';
if (has('bun.lockb') || has('bun.lock')) return 'bun';
return 'npm';
}
// Where the runnable app is. The project root first, then deeper folders level
// by level (up to MAX_RUN_DEPTH down), so an app kept in ./apps/desktop/app is
// still found. The SHALLOWEST hit wins, since it's the one that's most likely
// "the project"; a tie at the same depth goes to the stronger signal (a desktop
// app over a dev server over a bare script), then to a conventional app folder
// name, then alphabetically, so the same project always picks the same thing.
const MAX_RUN_DEPTH = 5;
const MAX_RUN_DIRS = 600; // a huge tree stops the walk instead of freezing the window
const SKIP_DIRS = new Set([
'node_modules', 'dist', 'build', 'out', 'target', 'vendor', 'coverage', 'venv', 'env',
'__pycache__', 'bin', 'obj', 'Pods', 'DerivedData', 'tmp', 'temp', 'cache',
]);
const APP_DIR_NAMES = ['app', 'desktop', 'web', 'frontend', 'client', 'ui', 'site', 'www'];
function detectRun(root) {
let level = [root];
let visited = 0;
for (let depth = 0; depth <= MAX_RUN_DEPTH && level.length; depth++) {
const hits = [];
const next = [];
for (const dir of level) {
const run = detectRunIn(dir);
if (run) { hits.push({ dir, run }); continue; } // don't look inside a found project
if (depth === MAX_RUN_DEPTH) continue;
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
if (++visited > MAX_RUN_DIRS) break;
next.push(path.join(dir, e.name));
}
}
if (hits.length) {
const nameRank = (d) => {
const i = APP_DIR_NAMES.indexOf(path.basename(d).toLowerCase());
return i < 0 ? APP_DIR_NAMES.length : i;
};
hits.sort((x, y) => x.run.rank - y.run.rank || nameRank(x.dir) - nameRank(y.dir) || x.dir.localeCompare(y.dir));
const { dir, run } = hits[0];
const { rank, ...target } = run;
if (dir === root) return target;
return { ...target, dir: path.relative(root, dir).split(path.sep).join('/'), cwd: dir };
}
level = next;
}
return null;
}
// What runs THIS folder, or null. `rank` orders the signals from strongest.
function detectRunIn(cwd) {
let names;
try { names = new Set(fs.readdirSync(cwd)); } catch { return null; }
const has = (f) => names.has(f);
const pkg = has('package.json') ? readJson(path.join(cwd, 'package.json')) : null;
if (pkg) {
const scripts = pkg.scripts ?? {};
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const pm = packageManager(cwd);
const run = (s) => (s === 'start' && pm === 'npm' ? 'npm start' : `${pm} run ${s}`);
if (has('src-tauri') && scripts.tauri) return { command: `${pm} run tauri dev`, kind: 'Tauri app', rank: 0 };
if (deps.electron && scripts.start) return { command: run('start'), kind: 'Electron app', rank: 0 };
if (deps.electron && scripts.dev) return { command: run('dev'), kind: 'Electron app', rank: 0 };
if (scripts.dev) return { command: run('dev'), kind: 'dev server', rank: 1 };
if (scripts.start) return { command: run('start'), kind: 'app', rank: 2 };
if (scripts.serve) return { command: run('serve'), kind: 'app', rank: 2 };
}
const deno = readJson(path.join(cwd, 'deno.json')) ?? readJson(path.join(cwd, 'deno.jsonc'));
if (deno?.tasks) {
for (const t of ['dev', 'start']) if (deno.tasks[t]) return { command: `deno task ${t}`, kind: 'Deno app', rank: 1 };
}
if (has('pubspec.yaml') && has('lib')) return { command: 'flutter run', kind: 'Flutter app', rank: 0 };
if (has('Cargo.toml')) return { command: 'cargo run', kind: 'Rust app', rank: 3 };
if (has('go.mod')) return { command: 'go run .', kind: 'Go app', rank: 3 };
const csproj = [...names].find((n) => n.endsWith('.csproj'));
if (csproj) return { command: 'dotnet run', kind: '.NET app', rank: 3 };
// Linux and macOS name it python3; a plain `python` is often missing there.
const py = process.platform === 'win32' ? 'python' : 'python3';
if (has('manage.py')) return { command: `${py} manage.py runserver`, kind: 'Django server', rank: 1 };
for (const f of ['main.py', 'app.py']) if (has(f)) return { command: `${py} ${f}`, kind: 'Python app', rank: 4 };
return null;
}
function clipCmd(cmd, max = 44) {
return cmd.length > max ? `${cmd.slice(0, max - 1)}…` : cmd;
}
function createWindow() {
const placement = windowPlacement(loadDesktopState().window);
const win = new BrowserWindow({
...placement,
// The mwcode logo on the window and the taskbar, not Electron's.
icon: path.join(__dirname, 'assets', process.platform === 'win32' ? 'icon.ico' : 'icon.png'),
minWidth: 900,
minHeight: 600,
// No title bar: the app draws its own. On Windows the native frame stays (only its title bar is
// hidden), because Windows animates maximise, minimise and snapping only for framed windows; a
// frameless one jumps to the new size and its content catches up a beat later.
// macOS: its own red, yellow and green buttons, top left, centred on the header row (22px down).
...(process.platform === 'win32' ? { titleBarStyle: 'hidden' }
: process.platform === 'darwin' ? { titleBarStyle: 'hidden', trafficLightPosition: { x: 16, y: 16 } }
: { frame: false }),
// Windows 11's default rounding is too round for this app; roundWindowCorners below
// asks for its small rounding instead. Should that fail, the corners stay square.
// Windows only: on macOS this option makes the window borderless, which takes away its red, yellow
// and green buttons and its round corners.
...(process.platform === 'win32' ? { roundedCorners: false } : {}),
resizable: true,
// Linux: no window manager offers Windows' small round corners, so the window is see-through and the
// page draws them (styles.css, html.round-window): the same 4px curve, smooth, with Electron's
// invisible resize border around it left as it is.
...(ROUND_IN_PAGE ? { transparent: true, backgroundColor: '#00000000' } : { backgroundColor: windowColour() }),
// Also Linux's resize border: the system resizes the window from it, as Windows does from its own.
hasShadow: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
roundWindowCorners(win);
// macOS: the red button hides the window, as Mac apps do (the app stays in the Dock; clicking it brings
// it back).
if (process.platform === 'darwin') {
win.on('close', (e) => {
if (quitting) return;
e.preventDefault();
if (win.isFullScreen()) { win.once('leave-full-screen', () => win.hide()); win.setFullScreen(false); } else win.hide();
});
}
// Linux: the page rounds its own corners (see-through window, below); it is told when the window is
// maximised or full screen, where corners are square, as on Windows.
if (ROUND_IN_PAGE) {
const square = () => { if (!win.isDestroyed()) win.webContents.send('window:square', win.isMaximized() || win.isFullScreen()); };
for (const e of ['maximize', 'unmaximize', 'enter-full-screen', 'leave-full-screen']) win.on(e, square);
win.webContents.on('did-finish-load', square);
// Some Linux setups (WSL among them) resize past the minimum size the window asks for, down to a
// sliver that can't be grabbed again. Once the resizing has stopped it is put back to the minimum;
// never during it, where it would fight the drag.
let settle = null;
win.on('resize', () => {
clearTimeout(settle);
settle = setTimeout(() => {
if (win.isDestroyed() || win.isMaximized() || win.isFullScreen()) return;
const [minW, minH] = win.getMinimumSize();
const [w, h] = win.getSize();
if (w < minW || h < minH) win.setSize(Math.max(w, minW), Math.max(h, minH));
}, 400);
});
}
// No page in this window may open another window. Nothing does today; this keeps a
// future change from handing a remote page the app's bridge.
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.loadFile('index.html');
// The window's size, place and maximised state are kept as they change, so a restart opens it as it was.
if (loadDesktopState().window?.maximized) win.maximize();
const saveWindow = () => {
if (win.isDestroyed() || win.isMinimized()) return;
const state = loadDesktopState();
state.window = { ...win.getNormalBounds(), maximized: win.isMaximized() };
saveDesktopState(state);
};
let windowTimer = null;
const saveWindowSoon = () => { clearTimeout(windowTimer); windowTimer = setTimeout(saveWindow, 400); };
win.on('resize', saveWindowSoon);
win.on('move', saveWindowSoon);
win.on('maximize', saveWindow);
win.on('unmaximize', saveWindow);
win.on('close', () => { clearTimeout(windowTimer); saveWindow(); });
// ── Per-window state ────────────────────────────────────────────────────
// One project (cwd) and one live session at a time. Switching either disposes
// the outgoing session's live resources (MCP servers, background shells, LSP)
// before moving on — same cleanup the CLI does on a session swap.
importCliProjects();
const savedState = loadDesktopState();
// No project yet (a first launch): the session lives in an empty folder of the app's own until one is
// opened. It is never shown or listed; the window says "Open a project" instead. Never the app's own
// folder or wherever it was started from, which is what a first launch used to open.
const NO_PROJECT = path.join(app.getPath('userData'), 'no-project');
const onDisk = (p) => typeof p === 'string' && p !== NO_PROJECT && fs.existsSync(p);
const restoredCwd = onDisk(savedState.lastProjectCwd) ? savedState.lastProjectCwd
: (savedState.recentProjects || []).find(onDisk) || null;
let currentCwd = restoredCwd;
if (!currentCwd) {
fs.mkdirSync(NO_PROJECT, { recursive: true });
currentCwd = NO_PROJECT;
} else rememberProject(currentCwd);
const hasProject = () => currentCwd !== NO_PROJECT;
// The open project first, then the others: for the pickers in Settings. Never the no-project folder.
const projectsForPicker = () => [currentCwd, ...(loadDesktopState().recentProjects || []).filter((p) => p !== currentCwd)]
.filter((p) => p !== NO_PROJECT)
.map((p) => ({ cwd: p, name: projectName(p), current: p === currentCwd }));
let activeSession = null;
let activeSessionId = null;
let turnRunning = false;
let bgReacting = false;
let steerQueue = [];
let abortController = null;
const pendingApprovals = new Map();
// The mode is a CLIENT concept, not saved with the session (see cli/modes.ts —
// Architect is deliberately never sticky across a fresh launch). It DOES carry
// from session to session within one running window, same as the CLI: switch
// to Architect, then open a different session, and it's still Architect.
let currentModeId = 'lightning';
ipcMain.on('window:close', () => win.close());
ipcMain.on('link:open', (_e, url) => { if (/^https?:\/\//i.test(url)) shell.openExternal(url); });
ipcMain.on('window:minimize', () => win.minimize());
ipcMain.on('window:maximize', () => {
if (win.isMaximized()) win.unmaximize();
else win.maximize();
});
// The agent's words for a step wait here until the engine says whether they lead to anything
// the conversation shows ('narration'). Shown: they go out as text. Not shown (they only led to
// searches or re-reads): they go out as a note for the working line, not the chat. The reply
// that ends a turn gets no verdict and goes out with whatever comes next ('done'). A draft the
// engine threw away ('replyReset') is dropped. The live token count still ticks as they arrive.
let heldWords = '';
// A reply that is being written is only in memory until its step ends. So its words are also
// written to a small file a few times a second (at most a third of a second behind); if the app dies mid-reply, the next start puts
// them into the conversation, marked as cut off (recoverPartial). Cleared the moment the step
// is saved (a tool starts, or the turn ends).
const PARTIAL_FILE = path.join(app.getPath('userData'), 'partial-reply.json');
let partialBuf = '';
let partialTimer = null;
function writePartial() {
partialTimer = null;
if (!partialBuf.trim() || !activeSessionId) return;
// Its own light write (no spare copy): this runs several times a second while a reply streams.
try {
const tmp = `${PARTIAL_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ sessionId: activeSessionId, text: partialBuf.slice(-200000) }));
fs.renameSync(tmp, PARTIAL_FILE);
} catch { /* best effort */ }
}
// Closing the window in the normal way writes whatever has not been written yet.
win.on('close', () => { if (partialTimer) { clearTimeout(partialTimer); writePartial(); } });
function clearPartial() {
partialBuf = '';
clearTimeout(partialTimer);
partialTimer = null;
for (const f of [PARTIAL_FILE, `${PARTIAL_FILE}.bak`, `${PARTIAL_FILE}.tmp`]) { try { fs.rmSync(f, { force: true }); } catch { /* gone */ } }
}
async function recoverPartial(session) {
let p = null;
try { p = JSON.parse(fs.readFileSync(PARTIAL_FILE, 'utf8')); } catch { return; }
if (!p || p.sessionId !== session.id) return;
clearPartial();
try {
const { appendInterruptedReply } = await turnRunner();
await appendInterruptedReply(session, String(p.text || ''));
} catch { /* the conversation opens without it */ }
}
function sendEvent(e) {
if (e.type === 'text') {
heldWords += e.delta;
partialBuf += e.delta;
if (!partialTimer) partialTimer = setTimeout(writePartial, 300);
emitEvent({ type: 'textHeld', n: e.delta.length });
return;
}
if (e.type === 'replyReset') { heldWords = ''; clearPartial(); return; }
if (e.type === 'toolStart' || e.type === 'done' || e.type === 'error' || e.type === 'userMessage') clearPartial();
if (e.type === 'narration') {
const words = heldWords;
heldWords = '';
if (!words.trim()) return;
emitEvent(e.shown ? { type: 'text', delta: words } : { type: 'narrationNote', text: words });
return;
}
if (heldWords && e.type !== 'reasoning' && e.type !== 'usage') {
const words = heldWords;
heldWords = '';
emitEvent({ type: 'text', delta: words });
}
emitEvent(e);
}
function emitEvent(e) {
if (!win.isDestroyed()) win.webContents.send('chat:event', e);
// The context meter follows every call's measured size, a compaction and the
// end of a turn: the moments the engine's own figure changes.
if (e.type === 'usage' || e.type === 'compaction' || e.type === 'done') void sendContext();
// A model call just spent something new, so the cached Spend total is stale —
// dropped rather than recomputed here, since the page reads it far less often
// than every call finishes.
if (e.type === 'usage') {
spendCache = null;
// A finished call moved the usage windows: tell an open Limits tab, at most every couple of seconds.
if (!limitsTick) {
limitsTick = setTimeout(() => {
limitsTick = null;
if (!win.isDestroyed()) win.webContents.send('chat:event', { type: 'limitsChanged' });
}, 1000);
}
}
}
let limitsTick = null;
// How full the context is against the bar auto-compaction fires at.
async function contextView() {
if (!activeSession) return null;
const { contextFill } = await turnRunner();
return contextFill(activeSession);
}
async function sendContext() {
const view = await contextView();
if (view && !win.isDestroyed()) win.webContents.send('chat:event', { type: 'context', ...view });
}
ipcMain.handle('context:get', contextView);
// What this chat has done so far (tool calls, failures, files read again, results stubbed), kept by the core.
ipcMain.handle('session:counters', () => (activeSession && activeSession.counters) || null);
// Compact now (the button in the context meter's card). Refused while a turn is
// running, when the engine is using the transcript. While it runs it holds the
// turn slot, so anything typed meanwhile is queued exactly as during a turn (shown
// at once in the queue box), and goes out as the next turn as soon as it ends.
ipcMain.handle('context:compact', async () => {
await sendChain; // a send already being prepared goes first
if (turnRunning) return { ok: false, busy: true };
const { compactSession } = await turnRunner();
const session = await ensureSession();
turnRunning = true;
let result = { ok: true };
try {
await compactSession(session, sendEvent);
} catch (error) {
sendEvent({ type: 'error', text: `Compaction failed: ${String(error?.message ?? error)}` });
sendEvent({ type: 'compactionEnd' });
result = { ok: false };
} finally {
turnRunning = false;
void sendContext();
}
if (!sendQueued()) checkBackgroundWake();
return result;
});
// ── The queue: messages sent while the agent works ──────────────────────
// Shown in the box above the composer, not in the chat, until they are actually
// sent: taken in at the next step of the running turn (steer), or sent as the next
// turn when it ends however it ended (answered, Esc, Send now). A message queued
// while the agent writes its final answer is never taken in by a step, so the end
// of the turn is the only place it can go; it used to wait there for good.
// Same rules as the CLI (cli/messageQueue.ts): a message is 'next' (steered into the
// running turn at its next step) unless it was typed after Esc, when it is 'now': the
// turn is stopping, so it is not slipped into it, and goes out on its own afterwards,
// framed as having stopped it.
let queueSeq = 0;
let forceNext = false; // Send now: the whole queue goes out as a message that stopped the turn
let interrupting = false; // Esc pressed; cleared when the next turn starts
// Messages typed while the agent works wait here; they are also kept in a file, so a crash cannot
// eat them. At the next start they come back as the message being typed (restoreQueuedMessages).
const QUEUE_FILE = path.join(app.getPath('userData'), 'queued-messages.json');
function persistQueue() {
const items = steerQueue.map((m) => m.raw).filter(Boolean);
try {
if (!items.length) { for (const f of [QUEUE_FILE, `${QUEUE_FILE}.bak`, `${QUEUE_FILE}.tmp`]) fs.rmSync(f, { force: true }); }
else writeJsonAtomic(QUEUE_FILE, { cwd: currentCwd, items });
} catch { /* best effort */ }
}
function sendQueue() {
persistQueue();
sendEvent({
type: 'queue',
items: steerQueue.map((m) => ({ id: m.qid, text: m.displayText, files: m.files ?? [], images: (m.imagePaths ?? []).length })),
});
}
// Everything queued as ONE message, the way the CLI sends consecutive queued
// messages: one turn that reads them together, not one turn per message.
function batchOf(items) {
return {
displayText: items.map((m) => m.displayText).join('\n\n'),
content: items.map((m) => m.content).join('\n\n'),
images: items.flatMap((m) => m.images ?? []),
imagePaths: items.flatMap((m) => m.imagePaths ?? []),
files: items.flatMap((m) => m.files ?? []),
};
}
/** Nothing is running: send what is queued as the next turn. False when empty.
* Send now takes everything; otherwise a 'now' message goes alone, and consecutive
* 'next' ones go together. What is left goes when that turn ends, and so on. */
function sendQueued() {
if (turnRunning || steerQueue.length === 0 || !activeSession) return false;
const session = activeSession;
let take;
let arrival;
if (forceNext) {
take = steerQueue.length;
arrival = 'interrupting';
} else if (steerQueue[0].priority === 'now') {
take = 1;
arrival = 'interrupting';
} else {
take = 0;
while (take < steerQueue.length && steerQueue[take].priority !== 'now') take++;
}
const msg = batchOf(steerQueue.slice(0, take));
steerQueue = steerQueue.slice(take);
forceNext = false;
sendQueue();
turnRunning = true;
sendEvent({ type: 'userMessage', text: msg.displayText, images: msg.imagePaths, files: msg.files });
void turnRunner().then(({ runTurn }) =>
runOneTurn((handlers) => runTurn(session, { content: msg.content, images: msg.images, ...(arrival ? { arrival } : {}) }, handlers)),
);
return true;
}
// ↑ or Edit: the whole queue back into the composer, as typed, to change or drop.
ipcMain.handle('queue:pull', () => {
const items = steerQueue.map((m) => m.raw).filter(Boolean);
steerQueue = [];
forceNext = false;
interrupting = false;
sendQueue();
return items;
});
// Send now: stop the turn; the queue then goes out at once, framed as having
// stopped it. Only the turn: background commands keep running (that is Esc's job).
ipcMain.handle('queue:sendNow', () => {
if (steerQueue.length === 0) return false;
forceNext = true;
abortController?.abort();
if (pendingApprovals.size) {
for (const resolve of pendingApprovals.values()) resolve(undefined);
pendingApprovals.clear();
sendEvent({ type: 'approvalsDismissed' });
}
if (!turnRunning) sendQueued();
return true;
});
function resetTurnState() {
turnRunning = false;
bgReacting = false;
steerQueue = [];
forceNext = false;
sendQueue();
abortController = null;
for (const resolve of pendingApprovals.values()) resolve(undefined);
pendingApprovals.clear();
}
// Runs one turn (a fresh send or a background-shell reaction) with the same
// bookkeeping either way: an abort controller a stop button can reach, and a
// steer queue that drains anything typed while this turn is already running.
// After a stop, how long a turn gets to wind down before it is let go. A call that does not
// honour the stop (a tool stuck on an app that was closed) must not hold the app hostage:
// past this the turn is over as far as the app is concerned, and whatever it still says is
// dropped.
const STOP_GRACE_MS = 2000;
async function runOneTurn(start) {
turnRunning = true;
interrupting = false; // a new turn: what is typed now is for it
const controller = new AbortController();
abortController = controller;
let letGo = false;
let ended = false;
let graceTimer = null;
const letGoAfterStop = new Promise((resolve) => {
controller.signal.addEventListener('abort', () => { graceTimer = setTimeout(resolve, STOP_GRACE_MS); }, { once: true });
}).then(() => {
if (ended) return;
letGo = true;
sendEvent({ type: 'done' });
});
try {
const turn = start({
signal: controller.signal,
onEvent: (e) => { if (!letGo) sendEvent(e); },
steer: async () => {
// Only the leading 'next' messages: one typed after Esc waits for the end.
let n = 0;
while (n < steerQueue.length && steerQueue[n].priority !== 'now') n++;
if (n === 0) return [];
const drained = steerQueue.slice(0, n);
steerQueue = steerQueue.slice(n);
// Each entry is a prepared message (prepareMessage): the same display
// text the queued bubble showed, and the files/images it carries.
for (const m of drained) sendEvent({ type: 'userMessage', text: m.displayText, images: m.imagePaths, files: m.files });
sendQueue(); // taken in: the box empties as they land in the chat
return drained.map((m) => ({ content: m.content, ...(m.images.length ? { images: m.images } : {}) }));
},
});
// A turn that was let go may still fail later, with nobody waiting on it.
turn.catch(() => {});
await Promise.race([letGoAfterStop, turn]);
} catch (error) {
if (!letGo) {
sendEvent({ type: 'error', text: String(error?.message ?? error) });
sendEvent({ type: 'done' });
}
} finally {
ended = true;
clearTimeout(graceTimer);
turnRunning = false;
// What is still queued is the next request; otherwise a shell may have finished
// while this turn was running.
if (!sendQueued()) checkBackgroundWake();
}
}
// A background command finishing while nothing is running wakes the model to
// report it, exactly like the CLI's reactToBackground — same wake rule
// (cli/backgroundWake.ts), just without a picker/dialog state to hold it back
// here (this app has no modal turn-blocking overlay of that kind yet).
function checkBackgroundWake() {
if (!activeSession || bgReacting) return;
void (async () => {
const { shouldReactToBackground, continueTurn, providerSummaries } = await turnRunner();
const mgr = activeSession.toolContext.backgroundShells;
const wake = shouldReactToBackground({
ready: true,
busy: turnRunning,
// No provider connected: nobody to report to. Waking anyway started a turn that could only fail.
needsKey: !providerSummaries().some((p) => p.connected),
reacting: bgReacting,
modalOpen: false,
pending: mgr?.pendingCount() ?? 0,
});
if (!wake) return;
bgReacting = true;
try {
await runOneTurn((handlers) => continueTurn(activeSession, handlers));
} finally {
bgReacting = false;
}
})();
}
// Fired whenever a background shell changes state. Mirrors the CLI's
// handleBgChange: a shell coming up or being killed on request is not news,
// but stalling or dying on its own is worth a line even before any wake turn.
function handleBgChange() {
sendShells();
const mgr = activeSession?.toolContext.backgroundShells;
for (const { info: sh, kind } of mgr?.takeUiEvents() ?? []) {
if (kind === 'ready') continue;
if (kind === 'stalled') {
const why = sh.stallReason === 'prompt' ? 'waiting for input?' : 'no output for a while — stuck?';
sendEvent({ type: 'activity', line: `shell #${sh.id} (${clipCmd(sh.command)}) ${why}`, error: true, shell: { id: sh.id, command: sh.command, state: 'stalled', why } });
continue;
}
if (sh.status === 'killed' && sh.stoppedBy === 'user') continue;
const verb = sh.status === 'killed' ? 'killed' : `finished — exit ${sh.exitCode}`;
sendEvent({ type: 'activity', line: `shell #${sh.id} (${clipCmd(sh.command)}) ${verb}`, error: sh.status !== 'killed' && sh.exitCode !== 0, shell: { id: sh.id, command: sh.command, state: sh.status === 'killed' ? 'killed' : 'exited', exitCode: sh.exitCode } });
}
checkBackgroundWake();
}
async function attach(session) {
const { attachHandlers, applySessionMode, sessionMode, APPROVAL_DISMISSED, PLAN_FEEDBACK } = await turnRunner();
attachHandlers(session, {
interrupt: () => abortController?.abort(),
requestApproval: (question, options, detail, detailTitle, freeText) =>
new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals.set(id, resolve);
// Which kind of decision, for the dock's badge and layout: a titled one is a
// permission (Sentinel), exit_plan's own feedback row marks a plan, and any
// other typed-answer row is a question (ask_user).
const kind = detailTitle ? 'permission' : freeText?.label === PLAN_FEEDBACK.label ? 'plan' : freeText ? 'question' : 'permission';
sendEvent({ type: 'approvalRequest', id, question, options, detail, detailTitle, freeText, kind });
}).then((choice) => (choice === undefined ? APPROVAL_DISMISSED : choice)),
// The engine itself can move planMode/guarded mid-turn (a plan gets
// approved via exit_plan, then restored when the turn ends) — reflect
// that back to the indicator instead of it silently going stale.
// The app the agent is testing, while it tests: shown live in the chat and kept on
// disk so the test can be replayed afterwards.
onLive: (e) => {
recorder.record(e);
sendEvent({ type: 'uiLive', ...e });
},
onModeChange: async () => {
currentModeId = sessionMode(session);
sendEvent({ type: 'modeChanged', mode: await currentMode() });
},
});
// A new/swapped session inherits whatever mode is currently active in this
// window, same as the CLI (App.tsx's attachApproval does the same).
applySessionMode(session, currentModeId);
session.toolContext.backgroundShells?.setOnChange(handleBgChange);
sendShells(session); // runs before it becomes activeSession, so name it
// MCP servers connect in the background; tell the Settings page as each one settles.
const { onMcpChange } = await turnRunner();
onMcpChange(session, () => { if (!win.isDestroyed()) win.webContents.send('mcp:changed'); });
}
async function currentMode() {
const { modeById } = await turnRunner();
const m = modeById(currentModeId);
return { id: m.id, name: m.name, descriptor: m.descriptor };
}
// Ensures a session is live for `currentCwd`, picking up the most recently
// used one so reopening the app finds the conversation already there — no
// separate "resume" step. Only creates a brand-new session if none exist yet.
// The project's chosen starting mode (Settings > Permissions), applied once each time
// a project opens: at launch and on a project switch. Within a project the mode then
// carries from chat to chat as before, so this never overrides a choice made since.
let modeDefaultAppliedFor = null;
// Several things ask for the session while the app starts. They must all get the SAME one: two loads
// at once made two copies, and whichever finished last was the one the app kept, so a save from it
// could overwrite what the other had written.
let ensuring = null;
function ensureSession() {
if (activeSession) return Promise.resolve(activeSession);
if (!ensuring) ensuring = loadActiveSession().finally(() => { ensuring = null; });
return ensuring;
}
async function loadActiveSession() {
if (activeSession) return activeSession;
const { startSession, loadSession, listSessions, defaultModeFor } = await turnRunner();
if (modeDefaultAppliedFor !== currentCwd) {
modeDefaultAppliedFor = currentCwd;
const preferred = await defaultModeFor(currentCwd).catch(() => null);
if (preferred) currentModeId = preferred;
}
const metas = await listSessions(currentCwd);
const savedId = loadDesktopState().lastSessionByCwd?.[currentCwd];
const wanted = metas.find((m) => m.id === savedId) || metas[0];
const session = wanted ? await loadSession(currentCwd, wanted.id) : await startSession(currentCwd);
await recoverPartial(session);
await attach(session);
activeSession = session;
activeSessionId = session.id;
rememberSession(currentCwd, session.id);
return session;
}
// The title the session notes gave it (what the work is about), else what was said first.
function sessionLabel(meta) {
if (meta.title) return meta.title;
const text = (meta.firstPrompt || '').trim();
if (!text) return 'New session';
return text.length > 48 ? `${text.slice(0, 48)}…` : text;
}
// Pins and names you gave sessions yourself: the app's own, kept by session id.
function sessionPrefs() {
return loadDesktopState().sessionPrefs || {};
}
function setSessionPref(id, patch) {
const state = loadDesktopState();
const prefs = state.sessionPrefs || {};
const next = { ...(prefs[id] || {}), ...patch };
for (const k of Object.keys(next)) if (next[k] === undefined || next[k] === false || next[k] === '') delete next[k];
if (Object.keys(next).length) prefs[id] = next; else delete prefs[id];
state.sessionPrefs = prefs;
saveDesktopState(state);
}
async function sessionSummaries() {
const { listSessions } = await turnRunner();
const metas = await listSessions(currentCwd);
const prefs = sessionPrefs();
return metas.map((m) => ({
id: m.id,
label: prefs[m.id]?.name || sessionLabel(m),
pinned: !!prefs[m.id]?.pinned,
updatedAt: m.updatedAt,
entryCount: m.entryCount,
active: m.id === activeSessionId,
}));
}
ipcMain.handle('session:pin', async (_event, id, pinned) => {
setSessionPref(id, { pinned: !!pinned });
return sessionSummaries();
});
ipcMain.handle('session:rename', async (_event, id, name) => {
// Empty gives the session back its own title.
setSessionPref(id, { name: String(name || '').replace(/\s+/g, ' ').trim().slice(0, 80) || undefined });
return sessionSummaries();
});
async function currentModel() {
const { sessionModel } = await turnRunner();
return sessionModel(activeSession);
}
async function currentThinking() {
const { sessionThinkLevels } = await turnRunner();
return sessionThinkLevels(activeSession);
}
// Shared by the OS folder picker AND clicking a remembered recent project —
// one path for "the project changed," so the two can never drift apart.
async function switchProject(newCwd) {
if (activeSession) {
const { disposeSession } = await turnRunner();
await disposeSession(activeSession);
}
currentCwd = newCwd;
activeSession = null;
activeSessionId = null;
resetTurnState();
// The no-project folder is never remembered: it isn't a project.
const recentProjects = hasProject() ? rememberProject(currentCwd) : (loadDesktopState().recentProjects || []);
const session = await ensureSession();
const { replayHistory } = await turnRunner();
return {
cwd: hasProject() ? currentCwd : null,
name: hasProject() ? projectName(currentCwd) : '',
noProject: !hasProject(),
recentProjects: recentProjects.map((p) => ({ cwd: p, name: projectName(p) })),
sessionId: session.id,
sessions: await sessionSummaries(),
model: await currentModel(),
thinking: await currentThinking(),
mode: await currentMode(),
replay: replayHistory(session),
};
}