Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
2566 lines (2381 loc) · 106 KB
/
Copy pathapp.js
File metadata and controls
2566 lines (2381 loc) · 106 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 { $, $$, api, authError, bus, closeMenu, confirmDialog, dialog, esc, hydrateIcons, icon, menu, promptDialog, store, toast, toastError, topDialog, withEyes } from "./ui.js";
import * as views from "./views.js";
import { startMonitor } from "./monitor.js";
import { searchAllDialog } from "./history.js";
import { showGitGraph } from "./gitgraph.js";
import { openFile } from "./editor.js";
import { scratchChanged } from "./scratch.js";
import { canShare, initShare, setShareState, shareDialog } from "./share.js";
import { radioMenu } from "./radio.js";
// ------------------------------------------------------------------ state
// `/?sid=<id>&embed=1`: the iOS app's WebView shows that one terminal and nothing else
const EMBED = new URLSearchParams(location.search).get("embed") === "1" ? new URLSearchParams(location.search).get("sid") : null;
export const S = {
projects: [],
settings: { default_shell: "pwsh", wsl_distro: "", font_size: "13", theme: "dark" },
shells: { kinds: [], available: [], wsl_distros: [] },
terms: new Map(), // sid -> Term
layout: EMBED ? { sid: EMBED } : store.get("layout", null), // {sid} | {dir, kids, sizes}
free: EMBED ? {} : store.get("free", {}), // free mode: sid -> {x, y, w, h, z}
focused: EMBED || store.get("focused", null),
view: "terminals",
collapsed: new Set(store.get("collapsed", [])),
order: store.get("order", []),
machine: store.get("machine", "local"), // "local" or a remote id: which machine's projects the sidebar shows
workspace: store.get("workspace", ""), // "" or a workspace name: which group of that machine's projects it shows
remotes: [], // saved SSH (Linux) machines (/api/remotes)
desktops: [], // saved RDP (Windows) machines
unread: new Set(),
ports: {}, // sid -> listening TCP ports, from the stats poller
agents: {}, // sid -> {key, label, model, context}: AI coding agent running in it, from the stats poller
agentState: {}, // sid -> working | waiting | approval (updateAgentStates)
serverAgentState: {}, // sid -> working | approval | idle, from the server's agent_state broadcasts
doneUnseen: new Set(), // agents that finished while you weren't looking; cleared when their terminal is shown
serverStatus: {}, // sid -> {state, source, reason, detail, meta}: server-owned lifecycle (agent_state.py)
online: true,
};
const SHELL_LABEL = { pwsh: "pwsh", powershell: "PowerShell", cmd: "cmd", wsl: "WSL" };
const mobile = matchMedia("(max-width: 900px)");
const isFree = () => S.settings.layout_mode === "free" && !mobile.matches && !EMBED;
// Project palette slots (db column projects.color). Tuned to read on dark and light.
const PROJECT_COLORS = ["#60a5fa", "#f472b6", "#34d399", "#fbbf24", "#a78bfa", "#f87171", "#22d3ee", "#fb923c"];
export function projectColor(p) {
return PROJECT_COLORS[(p?.color ?? 0) % PROJECT_COLORS.length];
}
function mix(a, b, t) {
const n = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const [x, y] = [n(a), n(b)];
return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("")}`;
}
export function shellLabel(kind) {
kind = kind || S.settings.default_shell;
return S.shells.labels?.[kind] || SHELL_LABEL[kind] || kind;
}
export function allSessions() {
return S.projects.flatMap((p) => p.sessions.map((s) => ({ ...s, project: p })));
}
export function findSession(sid) {
return allSessions().find((s) => s.id === sid);
}
export function sessionTitle(s) {
return s.name || `${shellLabel(s.shell)} ${s.id.slice(0, 4)}`;
}
function focusedProject() {
const s = S.focused && findSession(S.focused);
return (s && onMachine(s.project) ? s.project : null) || machineProjects()[0] || null;
}
// ------------------------------------------------------------------ machines
/** Does a project belong to the machine (and workspace, if one is picked) shown in the sidebar? */
export function onMachine(p) {
return (p?.remote_id || "local") === S.machine && (!S.workspace || p?.workspace === S.workspace);
}
/** Is this terminal in a project of the picked machine? (agents list, counts) */
export function sessionOnMachine(sid) {
return onMachine(findSession(sid)?.project);
}
export function machineProjects() {
return orderedProjects().filter(onMachine);
}
function machineName(id = S.machine) {
return id === "local" ? "Local" : S.remotes.find((r) => r.id === id)?.name || "Machine";
}
function renderMachine() {
const b = $("#machine");
if (!b) return;
const r = S.remotes.find((x) => x.id === S.machine);
b.querySelector(".m-name").textContent = machineName();
b.querySelector(".m-sub").textContent = r ? `${r.user ? `${r.user}@` : ""}${r.host}` : "this computer";
b.classList.toggle("remote", !!r);
}
export function setMachine(id) {
S.machine = id === "local" || S.remotes.some((r) => r.id === id) ? id : "local";
store.set("machine", S.machine);
renderMachine();
setWorkspace(S.workspace); // workspaces are per machine; renders the sidebar
}
async function machineMenu(anchor) {
const { addMachineDialog, connect } = await import("./remotes.js");
menu(anchor, [
{ label: "Local (this computer)", icon: S.machine === "local" ? "play" : "terminal", onClick: () => setMachine("local") },
...S.remotes.map((r) => ({ label: r.name, hint: `${r.user ? `${r.user}@` : ""}${r.host}`, icon: S.machine === r.id ? "play" : "share", onClick: () => setMachine(r.id) })),
...(S.desktops.length ? ["sep", ...S.desktops.map((r) => ({ label: `${r.name} (remote desktop)`, hint: r.host, icon: "external", onClick: () => connect(r.id) }))] : []),
"sep",
{ label: "Add machine…", icon: "plus", onClick: () => addMachineDialog(async (r) => { await refreshProjects(); setMachine(r.id); toast({ title: `${r.name} added`, body: "Its root folder (/) is a project; New terminal opens a shell there. Add more folders with the folder button." }); }) },
{ label: "Manage machines", icon: "settings", onClick: () => switchView("remotes") },
]);
}
// ---------------------------------------------------------------- workspaces
/** Workspace names used by this machine's projects. A workspace exists while a project is in it. */
function workspaces() {
return [...new Set(S.projects.filter((p) => (p.remote_id || "local") === S.machine && p.workspace).map((p) => p.workspace))].sort((a, b) => a.localeCompare(b));
}
function renderWorkspace() {
const b = $("#workspace");
if (!b) return;
b.querySelector("span").textContent = S.workspace || "All projects";
b.classList.toggle("on", !!S.workspace);
}
export function setWorkspace(name) {
S.workspace = workspaces().includes(name) ? name : "";
store.set("workspace", S.workspace);
renderWorkspace();
renderSidebar();
if (S.view === "agents") views.show("agents", $("#view-agents"));
}
function workspaceMenu(anchor) {
menu(anchor, [
{ label: "All projects", icon: S.workspace ? "folder" : "play", onClick: () => setWorkspace("") },
...workspaces().map((w) => ({ label: w, icon: S.workspace === w ? "play" : "layers", hint: `${S.projects.filter((p) => p.workspace === w && (p.remote_id || "local") === S.machine).length}`, onClick: () => setWorkspace(w) })),
"sep",
{ label: "Add folder or .code-workspace…", icon: "folder-plus", onClick: addProject },
]);
}
async function moveToWorkspace(p) {
const name = await promptDialog("Move to workspace", p.workspace || S.workspace, { label: "Workspace (a new name creates it; empty takes the project out)", ok: "Move", optional: true });
if (name === null) return;
try {
await api(`/api/projects/${p.id}`, { method: "PATCH", body: { workspace: name } });
await refreshProjects();
} catch (e) {
toastError(e);
}
}
/** Add project: a local folder (folder browser), or a folder path on the picked remote machine. */
async function addProject() {
if (S.machine === "local") return views.addProjectDialog();
const path = await promptDialog(`Add a folder on ${machineName()}`, "~/", { label: "Folder on the remote machine (e.g. ~/code/app or /srv/app)", ok: "Add" });
if (!path) return;
try {
const p = await api("/api/projects", { method: "POST", body: { remote_id: S.machine, path } });
if (S.workspace) await api(`/api/projects/${p.id}`, { method: "PATCH", body: { workspace: S.workspace } });
await refreshProjects();
S.collapsed.delete(p.id);
renderSidebar();
} catch (e) {
toastError(e);
}
}
// ------------------------------------------------------------------ theme
const XTERM_THEMES = {
dark: {
background: "#131316", foreground: "#e4e4e7", cursor: "#f4f4f5", cursorAccent: "#131316",
selectionBackground: "rgba(167,139,250,0.32)",
black: "#27272a", red: "#f87171", green: "#4ade80", yellow: "#facc15", blue: "#60a5fa", magenta: "#c084fc", cyan: "#22d3ee", white: "#e4e4e7",
brightBlack: "#71717a", brightRed: "#fca5a5", brightGreen: "#86efac", brightYellow: "#fde047", brightBlue: "#93c5fd", brightMagenta: "#d8b4fe", brightCyan: "#67e8f9", brightWhite: "#fafafa",
},
light: {
background: "#ffffff", foreground: "#27272a", cursor: "#18181b", cursorAccent: "#ffffff",
selectionBackground: "rgba(124,58,237,0.2)",
black: "#18181b", red: "#dc2626", green: "#16a34a", yellow: "#ca8a04", blue: "#2563eb", magenta: "#9333ea", cyan: "#0891b2", white: "#d4d4d8",
brightBlack: "#71717a", brightRed: "#ef4444", brightGreen: "#22c55e", brightYellow: "#eab308", brightBlue: "#3b82f6", brightMagenta: "#a855f7", brightCyan: "#06b6d4", brightWhite: "#f4f4f5",
},
};
// Terminal color presets (setting terminal_theme; keep names in sync with TERMINAL_THEMES in server.py).
// "default" follows the app theme. Backgrounds must be #rrggbb so project tint can mix in.
const pal = (bg, fg, cursor, sel, c) => {
const [black, red, green, yellow, blue, magenta, cyan, white, bBlack, bRed, bGreen, bYellow, bBlue, bMagenta, bCyan, bWhite] = c.split(" ");
return {
background: bg, foreground: fg, cursor, cursorAccent: bg, selectionBackground: sel,
black, red, green, yellow, blue, magenta, cyan, white,
brightBlack: bBlack, brightRed: bRed, brightGreen: bGreen, brightYellow: bYellow, brightBlue: bBlue, brightMagenta: bMagenta, brightCyan: bCyan, brightWhite: bWhite,
};
};
const PRESETS = {
dracula: pal("#282a36", "#f8f8f2", "#f8f8f2", "rgba(68,71,90,0.9)", "#21222c #ff5555 #50fa7b #f1fa8c #bd93f9 #ff79c6 #8be9fd #f8f8f2 #6272a4 #ff6e6e #69ff94 #ffffa5 #d6acff #ff92df #a4ffff #ffffff"),
"one-dark": pal("#282c34", "#abb2bf", "#528bff", "rgba(62,68,81,0.9)", "#3f4451 #e06c75 #98c379 #e5c07b #61afef #c678dd #56b6c2 #abb2bf #4f5666 #ff7b86 #b1e18b #efb074 #67cdff #e48bff #63d4e0 #ffffff"),
nord: pal("#2e3440", "#d8dee9", "#d8dee9", "rgba(67,76,94,0.9)", "#3b4252 #bf616a #a3be8c #ebcb8b #81a1c1 #b48ead #88c0d0 #e5e9f0 #4c566a #bf616a #a3be8c #ebcb8b #81a1c1 #b48ead #8fbcbb #eceff4"),
"gruvbox-dark": pal("#282828", "#ebdbb2", "#ebdbb2", "rgba(80,73,69,0.9)", "#282828 #cc241d #98971a #d79921 #458588 #b16286 #689d6a #a89984 #928374 #fb4934 #b8bb26 #fabd2f #83a598 #d3869b #8ec07c #ebdbb2"),
"solarized-dark": pal("#002b36", "#839496", "#93a1a1", "rgba(7,54,66,0.9)", "#073642 #dc322f #859900 #b58900 #268bd2 #d33682 #2aa198 #eee8d5 #586e75 #cb4b16 #859900 #b58900 #268bd2 #6c71c4 #2aa198 #fdf6e3"),
"solarized-light": pal("#fdf6e3", "#657b83", "#586e75", "rgba(147,161,161,0.3)", "#073642 #dc322f #859900 #b58900 #268bd2 #d33682 #2aa198 #eee8d5 #586e75 #cb4b16 #859900 #b58900 #268bd2 #6c71c4 #2aa198 #002b36"),
"github-light": pal("#ffffff", "#24292f", "#0969da", "rgba(84,174,255,0.3)", "#24292f #cf222e #116329 #4d2d00 #0969da #8250df #1b7c83 #6e7781 #57606a #a40e26 #1a7f37 #633c01 #218bff #a475f9 #3192aa #8c959f"),
};
export const TERMINAL_THEMES = ["default", ...Object.keys(PRESETS)];
const DEFAULT_FONT = '"CaskaydiaCove Nerd Font", "CaskaydiaCove NF", "CaskaydiaMono Nerd Font", "Cascadia Code NF", "Cascadia Code", "Cascadia Mono", Consolas, monospace';
function fontFamily() {
const f = (S.settings.font_family || "").trim();
if (!f) return DEFAULT_FONT;
return `${/[,"']/.test(f) ? f : `"${f}"`}, ${DEFAULT_FONT}`;
}
const prefersDark = matchMedia("(prefers-color-scheme: dark)");
function resolvedTheme() {
const t = S.settings.theme;
return t === "system" ? (prefersDark.matches ? "dark" : "light") : t === "light" ? "light" : "dark";
}
const isDark = (t) => parseInt(t.background.slice(1, 3), 16) < 0x80;
const PAIR = { "solarized-dark": "solarized-light", "solarized-light": "solarized-dark" };
// A preset is one brightness: in the other app theme use its sibling, else that theme's default colors.
function terminalBase() {
const mode = resolvedTheme();
const name = S.settings.terminal_theme;
for (const p of [PRESETS[name], PRESETS[PAIR[name]]]) if (p && isDark(p) === (mode === "dark")) return p;
return XTERM_THEMES[mode];
}
function themeFor(sid) {
const base = terminalBase();
const dark = isDark(base);
const s = findSession(sid);
if (!s || S.settings.project_tint === "off") return base;
const bg = mix(base.background, projectColor(s.project), dark ? 0.09 : 0.07);
return { ...base, background: bg, cursorAccent: bg };
}
let lastMode = null;
export function applySettings() {
document.documentElement.dataset.theme = resolvedTheme();
for (const t of S.terms.values()) {
t.xterm.options.theme = themeFor(t.sid);
t.xterm.options.fontSize = +S.settings.font_size || 13;
t.xterm.options.fontFamily = fontFamily();
t.fit();
}
for (const p of $$(".pane[data-sid]")) paintPane(p, p.dataset.sid);
for (const b of $$("[data-action=split]")) b.hidden = isFree();
$("[data-action=tile]").hidden = !isFree();
const mode = S.settings.layout_mode;
if (lastMode && mode !== lastMode) {
if (mode === "free" && !Object.keys(S.free).length) {
for (const sid of leaves(S.layout)) S.free[sid] = {};
tileAll(false);
}
renderLayout();
}
lastMode = mode;
}
function paintPane(pane, sid) {
const s = findSession(sid);
pane.style.setProperty("--proj", s ? projectColor(s.project) : "transparent");
pane.style.background = themeFor(sid).background;
}
prefersDark.addEventListener("change", applySettings);
// -------------------------------------------------------------- terminals
class Term {
constructor(sid) {
this.sid = sid;
this.el = document.createElement("div");
this.el.className = "term-host";
this.el.style.height = "100%";
this.xterm = new Terminal({
fontFamily: fontFamily(),
fontSize: +S.settings.font_size || 13,
lineHeight: 1.15,
cursorBlink: true,
scrollback: 10000,
allowProposedApi: true,
theme: themeFor(sid),
});
this.fitAddon = new FitAddon.FitAddon();
this.xterm.loadAddon(this.fitAddon);
this.xterm.loadAddon(new WebLinksAddon.WebLinksAddon((e, uri) => window.open(uri, "_blank", "noopener")));
this.search = new SearchAddon.SearchAddon();
this.xterm.loadAddon(this.search);
this.lastSnapshot = "";
this.changed = false; // output since the last snapshot
// shell integration (shelldeck/integration): 133 = prompt/command marks, 633 = command line + cwd
this.integrated = false;
this.prompts = []; // xterm markers at each prompt, for Ctrl+Shift+Up/Down
this.running = null; // {cmd, at} from Enter until the shell reports the command finished
this.xterm.parser.registerOscHandler(133, (data) => this.osc133(data));
this.xterm.parser.registerOscHandler(633, (data) => this.osc633(data));
this.opened = false;
this.ws = null;
this.connectedOnce = false;
this.closed = false;
this.retry = 0;
this.size = null;
this.readyWaiters = [];
// ignore xterm's auto-replies (e.g. to DA queries) while replaying scrollback
this.replaying = 0; // replay writes still being parsed (a restored block and the scrollback can overlap)
this.xterm.onData((data) => {
if (this.replaying) return;
data = withCtrl(data);
if (data === "?" && this.atPrompt()) return inlineCommands(this);
this.track(data);
this.send({ type: "input", data });
});
this.line = "";
this.edited = false; // arrows/tab used: typed buffer no longer matches the line
this.cmd = "";
this.xterm.attachCustomKeyEventHandler((e) => this.keyHandler(e));
}
keyHandler(e) {
if (e.type !== "keydown") return true;
const k = e.key.toLowerCase();
if (isAppShortcut(e)) return false;
if (e.ctrlKey && !e.altKey && !e.shiftKey && k === "i" && this.atPrompt()) {
e.preventDefault();
inlineCommands(this, { askOnly: true }); // Ctrl+I: Ask, like Devin's terminal Command (elsewhere it stays Tab)
return false;
}
if (e.ctrlKey && !e.altKey && !e.shiftKey && k === "f") {
e.preventDefault();
this.openFind();
return false;
}
if (e.ctrlKey && e.shiftKey && !e.altKey && (k === "arrowup" || k === "arrowdown")) {
e.preventDefault();
this.jumpPrompt(k === "arrowup" ? -1 : 1);
return false;
}
if (e.ctrlKey && !e.altKey && (k === "c" || k === "insert") && this.xterm.hasSelection()) {
navigator.clipboard.writeText(this.xterm.getSelection()).catch(() => {});
this.xterm.clearSelection();
e.preventDefault();
return false;
}
if (e.ctrlKey && e.shiftKey && k === "c") {
if (this.xterm.hasSelection()) navigator.clipboard.writeText(this.xterm.getSelection()).catch(() => {});
e.preventDefault();
return false;
}
// let the browser fire a paste event; xterm handles it
if ((e.ctrlKey && !e.altKey && k === "v") || (e.shiftKey && k === "insert")) return false;
return true;
}
/** An empty line at a shell prompt: `?` there opens Ask instead of reaching the shell. Needs shell
* integration (no WSL), so vim, less, ssh and agents' own input always get the key. */
atPrompt() {
return this.integrated && !this.running && !this.line && !this.edited && !S.agents[this.sid] && this.xterm.buffer.active.type === "normal";
}
// ponytail: heuristic command tracking from keystrokes; shell integration (OSC 633) if it proves flaky.
track(data) {
data = data.replace(TERM_REPLIES, ""); // xterm answering the shell (a new ConPTY asks for the cursor), not typing
for (const part of data.split(/(\r)/)) {
if (part === "\r") this.commit();
else if (part === "\x03") (this.line = ""), (this.edited = false);
else if (part === "\x7f" || part === "\b") this.line = this.line.slice(0, -1);
else if (part.startsWith("\x1b") || part.includes("\t")) this.edited = true;
else if (part >= " ") this.line += part;
}
}
commit() {
let cmd = this.line;
if (this.edited || !cmd.trim()) {
const b = this.xterm.buffer.active;
// join soft-wrapped rows so long commands read as one line
let y = b.baseY + b.cursorY;
let text = b.getLine(y)?.translateToString(true) || "";
while (y > 0 && b.getLine(y)?.isWrapped) text = (b.getLine(--y)?.translateToString(true) || "") + text;
cmd = text.match(/[>$#❯➜%]\s+(.+)$/)?.[1] || (this.edited ? "" : cmd);
}
this.line = "";
this.edited = false;
cmd = cmd.trim().split(/\s{3,}/)[0]; // drop right-aligned prompt segments
if (!cmd) return;
this.cmd = cmd;
if (!this.running) this.running = { cmd, at: Date.now() };
if (!this.integrated) this.send({ type: "command", cmd, at: new Date().toISOString() });
this.exit = null;
this.paintCmd();
}
paintCmd() {
const el = document.querySelector(`.pane[data-sid="${this.sid}"] .title .cmd`);
if (!el) return;
el.textContent = shortCmd(this.cmd);
el.title = this.exit ? `${this.cmd} (exit ${this.exit})` : this.cmd;
el.classList.toggle("fail", !!this.exit);
}
osc133(data) {
const [kind, code] = data.split(";");
this.integrated = true;
if (kind === "A") {
this.running = null; // a prompt means nothing runs (an empty Enter sets running but gets no D)
const m = this.xterm.registerMarker(0);
if (m) this.prompts.push(m);
if (this.prompts.length > 500) this.prompts.shift().dispose();
} else if (kind === "D") {
const r = this.running;
this.running = null;
if (!r || this.replaying || r.restored) return true; // restored: started before this page loaded
this.exit = code ? +code || 0 : null;
this.paintCmd();
this.send({ type: "command", cmd: this.cmd || r.cmd, exit: this.exit, ms: Date.now() - r.at, at: new Date(r.at).toISOString() });
if (Date.now() - r.at >= LONG_CMD_MS) notifyDone(this, this.cmd || r.cmd, Date.now() - r.at);
}
return true;
}
osc633(data) {
if (data.startsWith("E;")) {
this.cmd = data.slice(2).trim() || this.cmd;
this.paintCmd();
}
return true; // P;Cwd= is recorded server-side
}
/** Send the terminal's text to the server; it is what comes back after a restart. */
snapshot() {
if (!this.changed || this.replaying || this.closed) return;
this.changed = false;
const data = this.text();
if (data === this.lastSnapshot) return;
this.lastSnapshot = data;
this.send({ type: "snapshot", data });
}
/** Plain lines with wrapped rows joined: scrollback and screen, then an open TUI's screen. */
text() {
const lines = [];
for (const b of new Set([this.xterm.buffer.normal, this.xterm.buffer.active])) {
for (let i = 0; i < b.length; i++) {
const line = b.getLine(i);
const s = line.translateToString(!b.getLine(i + 1)?.isWrapped);
if (line.isWrapped && lines.length) lines[lines.length - 1] += s;
else lines.push(s);
}
}
while (lines.length && !lines.at(-1)) lines.pop();
return lines.join("\r\n");
}
jumpPrompt(dir) {
const top = this.xterm.buffer.active.viewportY;
const lines = this.prompts.filter((m) => !m.isDisposed && m.line >= 0).map((m) => m.line);
const target = dir < 0 ? lines.filter((l) => l < top).pop() : lines.find((l) => l > top);
if (target === undefined) return dir > 0 && this.xterm.scrollToBottom();
this.xterm.scrollToLine(target);
}
openFind(query = "") {
let bar = this.el.querySelector(".find-bar");
if (!bar) {
bar = document.createElement("div");
bar.className = "find-bar";
bar.innerHTML = `<input type="text" placeholder="Find" spellcheck="false" aria-label="Find in terminal" />
<span class="find-count"></span>
<button class="icon-btn sm" data-find="prev" title="Previous (Shift+Enter)" aria-label="Previous match">${icon("chevron-up")}</button>
<button class="icon-btn sm" data-find="next" title="Next (Enter)" aria-label="Next match">${icon("chevron")}</button>
<button class="icon-btn sm" data-find="close" title="Close (Esc)" aria-label="Close find">${icon("x")}</button>`;
const input = bar.querySelector("input");
const count = bar.querySelector(".find-count");
this.search.onDidChangeResults(({ resultIndex, resultCount }) => {
count.textContent = !input.value ? "" : resultCount ? `${resultIndex + 1}/${resultCount}` : "No results";
});
const go = (dir, incremental = false) => {
if (!input.value) return this.search.clearDecorations();
const opts = { incremental, decorations: FIND_DECORATIONS };
dir < 0 ? this.search.findPrevious(input.value, opts) : this.search.findNext(input.value, opts);
};
input.addEventListener("input", () => go(1, true));
input.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") go(e.shiftKey ? -1 : 1);
else if (e.key === "Escape") this.closeFind();
else if (e.key.toLowerCase() === "f" && e.ctrlKey) {
e.preventDefault();
input.select();
}
});
bar.addEventListener("click", (e) => {
const a = e.target.closest("[data-find]")?.dataset.find;
if (a === "close") this.closeFind();
else if (a) go(a === "prev" ? -1 : 1);
});
this.el.append(bar);
}
const input = bar.querySelector("input");
const sel = query || this.xterm.getSelection();
if (sel && !sel.includes("\n")) input.value = sel;
input.focus();
input.select();
if (input.value) this.search.findNext(input.value, { decorations: FIND_DECORATIONS });
}
closeFind() {
this.search.clearDecorations();
this.el.querySelector(".find-bar")?.remove();
this.focus();
}
mount(container) {
container.append(this.el);
if (!this.opened) {
this.xterm.open(this.el);
// phones: WebGL canvases came up blank at high pixel ratios; the DOM renderer is fine there
if (!matchMedia("(pointer: coarse)").matches) try {
const gl = new WebglAddon.WebglAddon();
gl.onContextLoss(() => gl.dispose());
this.xterm.loadAddon(gl);
} catch {
/* DOM renderer */
}
this.xterm.textarea.addEventListener("focus", () => setFocus(this.sid));
this.xterm.registerLinkProvider(pathLinks(this));
this.opened = true;
this.connect();
}
}
ready() {
if (this.ws?.readyState === WebSocket.OPEN) return Promise.resolve();
return new Promise((r) => this.readyWaiters.push(r));
}
connect() {
if (this.closed) return;
const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws/${this.sid}`);
this.ws = ws;
this.status("");
this.heard = Date.now();
ws.onopen = () => {
this.heard = Date.now();
if (this.connectedOnce) this.xterm.reset(); // server replays scrollback
this.connectedOnce = true;
this.retry = 0;
this.size = null;
this.fit(true);
if (!this.gotOutput) this.status("Starting shell…", true);
for (const m of (this.queued || []).splice(0)) this.send(m);
this.readyWaiters.splice(0).forEach((r) => r());
};
ws.onmessage = (ev) => {
this.heard = Date.now();
let msg;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
if (msg.type === "restored") {
// the previous run's text, pushed into scrollback so the new shell paints a blank screen below it
this.replaying++;
const old = msg.data.replace(/\s+$/, "").replace(/\r?\n/g, "\r\n");
this.xterm.write(`\x1b[0m${old}\r\n\x1b[2m--- restored ${msg.when} ---\x1b[0m${"\r\n".repeat(this.xterm.rows)}\x1b[H`, () => this.replaying--);
} else if (msg.type === "output") {
if (!this.gotOutput) {
this.gotOutput = true;
this.status("");
}
if (msg.replay) {
this.replaying++;
this.xterm.write(msg.data, () => this.replaying--);
} else {
this.xterm.write(msg.data);
this.changed = true;
const now = Date.now();
if (now - (this.lastOut || 0) > AGENT_QUIET_MS) this.busySince = now; // a new burst of output
this.lastOut = now;
markUnread(this.sid);
}
}
else if (msg.type === "exit") this.exited();
else if (msg.type === "prompt") {
// the server saw this shell's prompt marks (a replayed screen has none): at a prompt, or running something
this.integrated = true;
this.running = msg.at ? null : this.running || { cmd: "", at: Date.now(), restored: true };
}
else if (msg.type === "snapshot") { // another terminal's agent is reading this screen (sd peek)
this.changed = true;
this.lastSnapshot = null;
this.snapshot();
}
else if (msg.type === "error") this.status(msg.message);
else if (msg.type === "clipboard") this.send({ type: "input", data: msg.data });
};
ws.onclose = (ev) => {
if (this.closed) return;
if (ev.code === 4404 || ev.code === 1000) return this.exited();
if (ev.code === 1008 || ev.code === 4423) return bus.dispatchEvent(new Event("locked")); // logging in reloads the page
this.status("Reconnecting…", true);
const delay = Math.min(10000, 500 * 2 ** this.retry++);
clearTimeout(this.retryTimer);
this.retryTimer = setTimeout(() => this.connect(), delay);
};
}
/** Heartbeat: a socket silent for 45s is dead (sleep, Wi-Fi switch, VPN drop), so replace it. */
heartbeat() {
if (this.closed || this.ws?.readyState !== WebSocket.OPEN) return;
if (Date.now() - this.heard > 45000) return this.ws.close();
this.send({ type: "ping" });
}
/** Reconnect now instead of waiting out the backoff (network back, tab visible again). */
reconnectNow() {
if (this.closed || this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) return;
clearTimeout(this.retryTimer);
this.retry = 0;
this.connect();
}
status(text, loading = false) {
let el = this.el.querySelector(".pane-status");
if (!text) return el?.remove();
if (!el) {
el = document.createElement("div");
el.className = "pane-status";
this.el.append(el);
}
el.innerHTML = loading ? `<span class="spinner sm"></span>` : "";
el.append(text);
}
send(msg) {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
else if (msg.type === "input") (this.queued ||= []).push(msg); // typed while (re)connecting: sent once open
}
fit(force = false) {
if (!this.opened || !this.el.offsetParent) return;
try {
this.fitAddon.fit();
} catch {
return;
}
const { rows, cols } = this.xterm;
if (force || !this.size || this.size.rows !== rows || this.size.cols !== cols) {
this.size = { rows, cols };
this.send({ type: "resize", rows, cols });
}
}
focus() {
this.xterm.focus();
}
exited() {
if (this.closed) return;
this.dispose();
removeSessionLocally(this.sid);
api(`/api/sessions/${this.sid}`, { method: "DELETE" }).catch(() => {});
}
dispose() {
this.closed = true;
this.ws?.close();
this.xterm.dispose();
this.el.remove();
S.terms.delete(this.sid);
}
}
// file paths in output (src/app.py:12:5, C:\x\y.ts(3,4), File "a.py", line 7) that exist become links
const PATH_RE = /(?<![\w/\\.:-])((?:[A-Za-z]:[\\/]|~[\\/]|\.{1,2}[\\/]|[\\/])?(?:[\w.@+-]+[\\/])*[\w@+-][\w.@+-]*\.[A-Za-z][\w]{0,7})(?:(?::|\()(\d+)(?:[:,](\d+))?\)?|", line (\d+))?/g;
// `${sid}\n${raw}` -> {ok, at}; short-lived because relative paths change meaning with `cd`
const pathCache = new Map();
const PATH_TTL = 10000;
async function existing(sid, raws) {
const now = Date.now();
const fresh = (r) => now - (pathCache.get(`${sid}\n${r}`)?.at ?? 0) < PATH_TTL;
const todo = raws.filter((r) => !fresh(r));
if (todo.length) {
try {
const { files } = await api("/api/fs/check", { method: "POST", body: { session_id: sid, paths: todo } });
for (const r of todo) pathCache.set(`${sid}\n${r}`, { ok: files.includes(r), at: now });
} catch {
return new Set();
}
if (pathCache.size > 5000) pathCache.clear();
}
return new Set(raws.filter((r) => pathCache.get(`${sid}\n${r}`)?.ok));
}
function pathLinks(term) {
return {
async provideLinks(y, done) {
const text = term.xterm.buffer.active.getLine(y - 1)?.translateToString(true) || "";
const found = [...text.matchAll(PATH_RE)].filter((m) => !/^https?:/i.test(text.slice(Math.max(0, m.index - 8), m.index + 1))).slice(0, 20);
if (!found.length) return done(undefined);
const ok = await existing(term.sid, [...new Set(found.map((m) => m[1]))]);
const links = found
.filter((m) => ok.has(m[1]))
.map((m) => ({
range: { start: { x: m.index + 1, y }, end: { x: m.index + m[0].length, y } },
text: m[0],
decorations: { underline: true, pointerCursor: true },
activate: () =>
S.settings.editor === "shelldeck"
? openFile(m[1], { sid: term.sid, line: +(m[2] || m[4] || 0) })
: api("/api/open", { method: "POST", body: { session_id: term.sid, path: m[1], line: +(m[2] || m[4] || 0), col: +(m[3] || 0) } })
.catch((e) => toastError(e)),
}));
done(links.length ? links : undefined);
},
};
}
export function portChips(sid) {
return (S.ports[sid] || []).map((p) => `<button class="port-chip" data-port="${p}" title="Open http://localhost:${p}">${icon("external")}${p}</button>`).join("");
}
/** "AI · Claude Code · opus" in the pane header while a coding agent runs in the terminal. */
export function agentChip(sid) {
const a = S.agents[sid];
if (!a) return "";
const model = a.model || a.context?.model;
const state = S.agentState[sid];
const pct = ctxPct(a);
const tip = [
`${a.label} is running in this terminal${model ? ` (model ${model})` : ""}`,
S.serverStatus[sid] ? `Status: ${S.serverStatus[sid].meta?.state_label || S.serverStatus[sid].state} (from ${S.serverStatus[sid].source})` : "",
state === "approval" ? (isSubAgent(sid) ? "It is asking its parent agent (or you, if the parent is a plain shell)." : "It is asking you a question.") : state === "working" ? "It is working." : "",
pct === null ? "" : `Context: ${fmtTokens(a.context.used)} of ${fmtTokens(a.context.window)} tokens (${pct}%)${a.context.estimated ? ", window estimated" : ""}`,
].filter(Boolean).join("\n");
return `<span class="ai-chip ${state || ""}" title="${esc(tip)}">${icon("sparkle")}<b>${state !== "approval" ? "AI" : isSubAgent(sid) ? "ASKING" : "NEEDS YOU"}</b>${esc(a.label)}${model ? `<small>${esc(model)}</small>` : ""}${pct === null ? "" : `<small class="ctx" data-level="${pct >= 85 ? "high" : pct >= 60 ? "mid" : ""}">${pct}%</small>`}</span>`;
}
/** Percent of the agent's context window in use, or null when unknown. */
export function ctxPct(a) {
const c = a?.context;
return c?.window && c.used != null ? Math.min(100, Math.round((100 * c.used) / c.window)) : null;
}
export function fmtTokens(n) {
return n >= 1e6 ? `${+(n / 1e6).toFixed(n % 1e6 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`;
}
// ------------------------------------------------ agent needs you: an actual question on screen
const AGENT_QUIET_MS = 2500; // a prompt counts once the screen stops changing
const AGENT_BUSY_MS = 5000; // output for at least this long counts as working (not a redraw or echo)
// Approval menus and questions of Claude Code, Codex and Devin (strings from their binaries), plus [y/n].
// Keep in sync with agents.QUESTION.
const QUESTION_RE = /do you want to (?:proceed|make this edit|create|run|allow)|would you like to (?:proceed|run|make|grant|continue)|yes, allow once|yes, and don't ask|allow (?:once|for this session)|do you trust the files|yes, i trust|enter to (?:select|confirm|approve)|plan needs changes|\[y\/n\]|\(y\/n\)/i;
const AUTHORITATIVE = new Set(["integration", "custom", "native"]); // lifecycle sources that beat screen detection
const PROMPT_LINES = 20; // agents draw their prompts at the bottom; text higher up is conversation
// a sub-agent's question relayed to its parent (server.py _forward_question): on the parent's screen, not asking you
const RELAYED_RE = /\[shelldeck\]\s+Your\s+sub-agent[\s\S]*?really\s+their\s+call/g;
/** The last non-empty lines of the visible screen, wrapped rows joined, relayed questions left out. */
function screenTail(t) {
const buf = t.xterm.buffer.active;
const out = [];
for (let y = buf.viewportY; y < buf.viewportY + t.xterm.rows; y++) {
const line = buf.getLine(y);
const text = line?.translateToString(true) || "";
if (line?.isWrapped && out.length) out[out.length - 1] += text;
else out.push(text);
}
const lines = out.join("\n").replace(RELAYED_RE, "").split("\n");
return lines.filter((l) => l.trim()).slice(-PROMPT_LINES).join("\n");
}
/** Spawned by another agent: its questions go to that agent (server side), never to you. */
export function isSubAgent(sid) {
return !!findSession(sid)?.parent;
}
/** working | approval | undefined, per agent terminal. Called by the 2s stats poller.
* Only a question on screen alerts; an agent that simply finished stays quiet. */
export function updateAgentStates() {
const now = Date.now();
for (const [sid, a] of Object.entries(S.agents)) {
const t = S.terms.get(sid);
const quiet = !t?.lastOut || now - t.lastOut > AGENT_QUIET_MS;
const was = S.agentState[sid];
let state;
const server = S.serverStatus[sid];
// an integration's report outranks what this tab can see; the screen heuristic is the fallback
if (server && AUTHORITATIVE.has(server.source)) state = server.state === "blocked" ? "approval" : server.state === "working" ? "working" : undefined;
else if (t && quiet && QUESTION_RE.test(screenTail(t))) state = "approval";
else if (a.context?.state === "busy") state = "working"; // Claude Code reports it
else if (t && !quiet) state = now - t.busySince > AGENT_BUSY_MS ? "working" : was === "approval" ? undefined : was;
S.agentState[sid] = state;
if (state === "approval" && was !== "approval" && !isSubAgent(sid)) {
const s = findSession(sid);
if (notify(sid, `${a.label} is asking you`, s ? `${s.nick ? `${s.nick} · ` : ""}${sessionTitle(s)} · ${s.project.name}` : "", "warn")) views.chime();
}
}
for (const sid of Object.keys(S.agentState)) if (!S.agents[sid]) delete S.agentState[sid];
for (const sid of Object.keys(S.serverStatus)) if (!S.agents[sid]) delete S.serverStatus[sid];
}
const CMD_MAX = 32;
// replies xterm sends on its own: cursor position, device attributes, focus in/out, OSC color answers
const TERM_REPLIES = /\x1b\[[?>]?[\d;]*[RcIO]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
const LONG_CMD_MS = 10000; // commands at least this long notify when they finish out of view
// search decorations must be #rrggbb
const FIND_DECORATIONS = { matchBackground: "#3f3f46", activeMatchBackground: "#7c3aed", matchOverviewRuler: "#71717a", activeMatchColorOverviewRuler: "#a78bfa" };
function fmtDur(ms) {
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
function notifyDone(t, cmd, ms) {
const s = findSession(t.sid);
const title = `${t.exit ? "Failed" : "Finished"}: ${shortCmd(cmd) || "command"}`;
const body = `${s ? `${sessionTitle(s)} · ` : ""}${fmtDur(ms)}${t.exit ? ` · exit ${t.exit}` : ""}`;
notify(t.sid, title, body, t.exit ? "error" : "");
}
/** Toast (plus a desktop notification when the window is in the background), unless sid is on screen.
* Returns whether it alerted. */
export function notify(sid, title, body, kind = "", extra = []) {
const away = document.hidden || !document.hasFocus();
if (!away && S.view === "terminals" && S.focused === sid) return false;
const show = () => {
window.focus();
showSession(sid);
};
const canAsk = "Notification" in window && Notification.permission === "default";
toast({
title,
body,
kind,
timeout: 10000,
actions: [{ label: "Show", onClick: show }, ...extra, ...(canAsk ? [{ label: "Enable desktop alerts", onClick: () => Notification.requestPermission() }] : [])],
});
if (away && "Notification" in window && Notification.permission === "granted") {
const n = new Notification(title, { body, icon: "/static/icon-192.png", tag: sid });
n.onclick = () => {
show();
n.close();
};
}
return true;
}
// sessions that printed output while out of view; cleared when shown
function isVisible(sid) {
return S.view === "terminals" && leaves().includes(sid);
}
function markUnread(sid) {
if (S.unread.has(sid) || isVisible(sid)) return;
S.unread.add(sid);
$(`.sess-row[data-sid="${sid}"]`)?.classList.add("unread");
}
function clearUnread() {
for (const sid of [...S.unread]) {
if (!isVisible(sid)) continue;
S.unread.delete(sid);
$(`.sess-row[data-sid="${sid}"]`)?.classList.remove("unread");
}
}
function shortCmd(cmd) {
if (!cmd) return "";
return cmd.length > CMD_MAX ? `${cmd.slice(0, CMD_MAX - 1)}…` : cmd;
}
function getTerm(sid) {
let t = S.terms.get(sid);
if (!t) {
t = new Term(sid);
S.terms.set(sid, t);
}
return t;
}
// ----------------------------------------------------------------- layout
const isLeaf = (n) => n && "sid" in n;
export function leaves(n) {
if (n === undefined) {
if (isFree()) return Object.keys(S.free);
n = S.layout;
}
if (!n) return [];
return isLeaf(n) ? [n.sid] : n.kids.flatMap((k) => leaves(k));
}
function mapTree(n, fn) {
if (!n) return null;
if (isLeaf(n)) return fn(n);
const kids = [];
const sizes = [];
n.kids.forEach((k, i) => {
const r = mapTree(k, fn);
if (r) {
kids.push(r);
sizes.push(n.sizes[i]);
}
});
if (!kids.length) return null;
if (kids.length === 1) return kids[0];
const total = sizes.reduce((a, b) => a + b, 0);
return { dir: n.dir, kids, sizes: sizes.map((s) => s / total) };
}
function removeLeaf(sid) {
delete S.free[sid];
S.layout = mapTree(S.layout, (l) => (l.sid === sid ? null : l));
}
function replaceLeaf(targetSid, sid) {
S.layout = mapTree(S.layout, (l) => (l.sid === targetSid ? { sid } : l));
}
/** Put `sid` next to `targetSid`. side: left|right|top|bottom */
function splitAt(targetSid, sid, side) {
const dir = side === "left" || side === "right" ? "row" : "col";
const before = side === "left" || side === "top";
S.layout = mapTree(S.layout, (l) => {
if (l.sid !== targetSid) return l;
const kids = before ? [{ sid }, l] : [l, { sid }];
return { dir, kids, sizes: [0.5, 0.5] };
});
// flatten same-direction nesting so gutters stay simple
S.layout = flatten(S.layout);
}
function flatten(n) {
if (isLeaf(n)) return n;
const kids = [];
const sizes = [];
n.kids.forEach((k, i) => {
const f = flatten(k);
if (!isLeaf(f) && f.dir === n.dir) {
f.kids.forEach((kk, j) => {
kids.push(kk);
sizes.push(n.sizes[i] * f.sizes[j]);
});
} else {
kids.push(f);
sizes.push(n.sizes[i]);
}
});
return { dir: n.dir, kids, sizes };
}
function saveLayout() {
if (EMBED) return;
store.set("layout", S.layout);
store.set("free", S.free);
store.set("focused", S.focused);
}
export function renderLayout() {
const root = $("#layout");
const hold = $("#term-store");
for (const t of S.terms.values()) hold.append(t.el);
root.innerHTML = "";
if (!leaves().includes(S.focused)) S.focused = leaves()[0] || null;
// small screens and maximize show only the focused pane
const single = mobile.matches || $("#app").classList.contains("maximized");
const free = !single && isFree();
if (free) root.append(buildFree());
else if (S.focused) root.append(single ? buildPane(S.focused) : buildNode(S.layout));
root.classList.toggle("free", free);
$("#empty").hidden = leaves().length > 0;
markFocus();