@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,903 @@
1
+ /**
2
+ * The screen terminals: one xterm per window, several windows at once.
3
+ *
4
+ * This used to be an inline script inside a server template literal, mounting ONE terminal against
5
+ * fixed element ids — `browser-term`, `term-status`, `term-fullscreen`. That is why only one session
6
+ * could be open: a second terminal's script found the first one's elements, wired its socket to them
7
+ * and left the two fighting over one status line. Everything here is scoped to the element it was
8
+ * given, so N windows coexist and none of them knows the others exist.
9
+ *
10
+ * It is a real file for a second reason. The old script travelled through a template literal, where a
11
+ * backtick in a comment ends the literal and takes the whole app down, and every backslash is one more
12
+ * thing to get wrong — the comments in it said so. Here a regex is a regex.
13
+ *
14
+ * What a window owns: an xterm, a WebSocket to /ws/screen/<session>, its own place on the screen, and
15
+ * the cleanup for all of it. A window that leaves the DOM closes its socket — otherwise closing it
16
+ * would leave the PTY and the `screen -x` client behind, one per terminal anybody ever opened.
17
+ *
18
+ * **They are windows, not a modal.** A modal is one thing at a time, in the middle, with the page
19
+ * behind it out of reach — which is the opposite of what watching a terminal is for: you open one to
20
+ * keep half an eye on it while you do something else, and two of them to watch two agents. So each
21
+ * terminal floats, is dragged by its title bar, resizes from its corner, and remembers where it was
22
+ * put. `Arrange` tidies them all at once — down the screen as a list, or tiled as a grid — because
23
+ * free-floating windows are worth exactly nothing once there are four of them and no way to line them
24
+ * up. Geometry is per browser, like the theme, and clamped back into the viewport on every apply, so a
25
+ * window dragged to the edge of a wide screen is still reachable on a narrow one.
26
+ */
27
+
28
+ (function terminals() {
29
+ const XTERM = [
30
+ ['Terminal', '/vendor/xterm/xterm.js'],
31
+ ['FitAddon', '/vendor/xterm/addon-fit.js'],
32
+ ['WebLinksAddon', '/vendor/xterm/addon-web-links.js'],
33
+ ['Unicode11Addon', '/vendor/xterm/addon-unicode11.js'],
34
+ ];
35
+
36
+ /**
37
+ * The vendored xterm bundle, loaded once however many windows ask for it at the same moment.
38
+ *
39
+ * Unversioned paths: these are vendored files that change when the library is upgraded, and the
40
+ * static server gives them a validator, so the cost of not versioning them is one 304.
41
+ */
42
+ function loadXterm() {
43
+ if (window.__xtermLoading) return window.__xtermLoading;
44
+ const need = XTERM.filter(([global]) => !window[global]).map(([, src]) => src);
45
+ // The face is waited for too: xterm measures a character the moment it opens, and measuring a
46
+ // fallback font puts every column in the wrong place until something forces a re-fit.
47
+ const fontsReady = () => (document.fonts && document.fonts.load
48
+ ? Promise.all(['400', '700'].map((w) => document.fonts.load(`${w} ${FONT_DEFAULT}px "Ubuntu Mono"`))).catch(() => {})
49
+ : Promise.resolve());
50
+ if (!need.length) return fontsReady();
51
+ window.__xtermLoading = Promise.all(need.map((src) => new Promise((ok, no) => {
52
+ const existing = document.querySelector('script[src="' + src + '"]');
53
+ if (existing) { existing.addEventListener('load', ok); existing.addEventListener('error', no); return; }
54
+ const s = document.createElement('script');
55
+ s.src = src; s.onload = ok; s.onerror = no;
56
+ document.head.appendChild(s);
57
+ }))).then(fontsReady).catch((err) => { window.__xtermLoading = null; throw err; });
58
+ return window.__xtermLoading;
59
+ }
60
+
61
+ // xterm paints its own canvas and cannot read a CSS variable, so it is handed the value the token
62
+ // resolves to. The fallbacks are xterm's own defaults, used only if the stylesheet has not loaded.
63
+ const cssVar = (name, fallback) =>
64
+ getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
65
+
66
+ /**
67
+ * How a terminal LOOKS: the face, the size and the palette, kept per browser like the theme.
68
+ *
69
+ * Ubuntu is the default because it is the terminal people are used to: aubergine, Ubuntu Mono, the
70
+ * Tango palette. The others are the ones the GNOME terminal's own list offers, and Dashboard follows
71
+ * the dashboard's style. A palette is named here rather than in a stylesheet because xterm paints a
72
+ * canvas and cannot read a CSS variable; the window's own chrome gets the same values as custom
73
+ * properties, set below, so the two cannot disagree.
74
+ */
75
+ const TERM_FONT = '"Ubuntu Mono", "Ubuntu Sans Mono", "DejaVu Sans Mono", "JetBrains Mono", ui-monospace, Menlo, monospace';
76
+ const FONT_KEY = 'ws-term-font';
77
+ const STYLE_KEY = 'ws-term-style';
78
+ const FONT_MIN = 11;
79
+ const FONT_MAX = 32;
80
+ const FONT_DEFAULT = 17;
81
+ const ANSI_KEYS = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
82
+ 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite'];
83
+ const TANGO = ['#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf',
84
+ '#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec'];
85
+ const TERM_STYLES = {
86
+ ubuntu: { bg: '#300a24', fg: '#ffffff', bar: '#2c001e', barInk: '#eeeeec', cursor: '#ffffff', select: 'rgba(255,255,255,.28)', ansi: TANGO },
87
+ tango: {
88
+ bg: '#fdfdfb', fg: '#2e3436', bar: '#e8e6e3', barInk: '#2e3436', cursor: '#2e3436', select: 'rgba(52,101,164,.28)',
89
+ ansi: ['#2e3436', '#cc0000', '#3a7a04', '#9c7a00', '#3465a4', '#75507b', '#06808a', '#8a8f87',
90
+ '#555753', '#d92020', '#4e9a06', '#b58b00', '#3a6fb5', '#8f5f96', '#0a8f95', '#2e3436'],
91
+ },
92
+ solarized: {
93
+ bg: '#002b36', fg: '#839496', bar: '#073642', barInk: '#93a1a1', cursor: '#93a1a1', select: 'rgba(147,161,161,.28)',
94
+ ansi: ['#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5',
95
+ '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3'],
96
+ },
97
+ dracula: {
98
+ bg: '#282a36', fg: '#f8f8f2', bar: '#21222c', barInk: '#f8f8f2', cursor: '#f8f8f2', select: 'rgba(98,114,164,.5)',
99
+ ansi: ['#21222c', '#ff5555', '#50fa7b', '#f1fa8c', '#bd93f9', '#ff79c6', '#8be9fd', '#f8f8f2',
100
+ '#6272a4', '#ff6e6e', '#69ff94', '#ffffa5', '#d6acff', '#ff92df', '#a4ffff', '#ffffff'],
101
+ },
102
+ };
103
+ const STYLE_ORDER = ['ubuntu', 'tango', 'solarized', 'dracula', 'dashboard'];
104
+
105
+ /** The palette of a style. `dashboard` is read from the tokens, so it follows the page's style. */
106
+ function styleOf(name) {
107
+ if (name === 'dashboard') {
108
+ const bg = cssVar('--code-bg', '#1f2437');
109
+ return { bg, fg: cssVar('--code-ink', '#e6e9f5'), bar: cssVar('--surface', bg), barInk: cssVar('--text-default', '#e6e9f5'),
110
+ cursor: cssVar('--terminal-ink', '#c9f7d4'), select: 'rgba(255,255,255,.25)', ansi: TANGO };
111
+ }
112
+ return TERM_STYLES[name] || TERM_STYLES.ubuntu;
113
+ }
114
+
115
+ function lookNow() {
116
+ let style = 'ubuntu';
117
+ let size = FONT_DEFAULT;
118
+ try {
119
+ const s = localStorage.getItem(STYLE_KEY);
120
+ if (STYLE_ORDER.includes(s)) style = s;
121
+ const n = parseInt(localStorage.getItem(FONT_KEY), 10);
122
+ if (n >= FONT_MIN && n <= FONT_MAX) size = n;
123
+ } catch (_) { /* private window: the defaults */ }
124
+ return { style, size };
125
+ }
126
+
127
+ function xtermTheme(name) {
128
+ const s = styleOf(name);
129
+ const t = { background: s.bg, foreground: s.fg, cursor: s.cursor, cursorAccent: s.bg, selectionBackground: s.select };
130
+ ANSI_KEYS.forEach((k, i) => { t[k] = s.ansi[i]; });
131
+ return t;
132
+ }
133
+
134
+ /** Bring up one window: its terminal, its socket, and the cleanup for both. */
135
+ function mount(pane) {
136
+ if (pane.dataset.up) return;
137
+ const session = pane.dataset.session;
138
+ if (!session) return;
139
+ pane.dataset.up = '1';
140
+
141
+ const el = pane.querySelector('.term-screen');
142
+ const status = pane.querySelector('.term-status');
143
+ const again = pane.querySelector('.term-reconnect');
144
+ const say = (text) => { if (status) status.textContent = text; };
145
+
146
+ const look = lookNow();
147
+ const term = new window.Terminal({
148
+ fontSize: look.size,
149
+ cursorBlink: true,
150
+ // Zero, and measured rather than assumed: `screen` keeps its client on the ALTERNATE buffer
151
+ // whatever is running inside it — a plain bash session reports `buffer.active.type` as
152
+ // "alternate" with `baseY: 0` after 120 lines of output. So nothing ever scrolls off into
153
+ // xterm's own scrollback and a buffer here would hold nothing while costing memory. The
154
+ // scrollback that exists is screen's, and copy mode is the way into it — see copyMode below.
155
+ scrollback: 0,
156
+ allowProposedApi: true,
157
+ fontFamily: TERM_FONT,
158
+ fontWeightBold: '700',
159
+ // xterm paints its own canvas, so it is told the palette of the chosen style, which the window's
160
+ // own chrome gets too (paintLook).
161
+ theme: xtermTheme(look.style),
162
+ });
163
+ paintChrome(pane, look.style);
164
+
165
+ const fit = new window.FitAddon.FitAddon();
166
+ term.loadAddon(fit);
167
+ const refit = () => {
168
+ try { fit.fit(); } catch (_) { /* not visible yet */ }
169
+ // The session has to be told, or it goes on painting for the width it was attached at.
170
+ if (pane.__resized) pane.__resized();
171
+ };
172
+
173
+ // Correct widths for the emoji and box drawing a TUI paints with, and clickable URLs — each
174
+ // wrapped, because an addon must never stop the terminal opening.
175
+ try {
176
+ if (window.Unicode11Addon) {
177
+ term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
178
+ term.unicode.activeVersion = '11';
179
+ }
180
+ } catch (_) { /* widths stay at version 6 */ }
181
+ try {
182
+ if (window.WebLinksAddon) {
183
+ term.loadAddon(new window.WebLinksAddon.WebLinksAddon((event, uri) => {
184
+ window.open(uri, '_blank', 'noopener,noreferrer');
185
+ }));
186
+ }
187
+ } catch (_) { /* links stay plain text */ }
188
+
189
+ term.open(el);
190
+ refit();
191
+ // What a character actually costs here, learned once and reused for every window opened after.
192
+ measureCell(term);
193
+ // A window that opened too narrow for 80 columns is widened once, now that the real cell size is
194
+ // known — unless its geometry was REMEMBERED, in which case it is the size somebody chose and not
195
+ // this file's business.
196
+ if (!pane.dataset.geom && term.cols < defaultPreset().cols) {
197
+ const want = fitsSize();
198
+ const r = pane.getBoundingClientRect();
199
+ if (want.w > r.width && want.w <= window.innerWidth) place(pane, r.left, r.top, want.w, Math.max(r.height, want.h));
200
+ }
201
+ window.addEventListener('resize', refit);
202
+ // The terminal follows its box: a window is dragged, resized and tiled, so one that kept its first
203
+ // size would waste half of a wide one and clip a narrow one.
204
+ if (window.ResizeObserver) new ResizeObserver(refit).observe(pane);
205
+
206
+ // Scrolling back, through screen's own scrollback — the only one there is.
207
+ //
208
+ // Four things had to be true at once, and each was found by driving a real session:
209
+ //
210
+ // 1. xterm cannot hold the scrollback itself. screen keeps its client on the ALTERNATE buffer
211
+ // whatever runs inside it, so nothing ever scrolls off into xterm (measured: baseY stays 0
212
+ // after 300 lines). That is why `scrollback: 0` above is right rather than stingy.
213
+ // 2. screen's copy mode is the way in: Ctrl-A then Esc answers
214
+ // "Copy mode - Column 31 Line 24(+1024)" — a thousand lines are sitting there.
215
+ // 3. An ARROW KEY ABORTS IT. The arrow sequence starts with Esc, and Esc in copy mode means
216
+ // abort, so the obvious implementation opens copy mode and closes it again in one gesture —
217
+ // and then types the rest of the sequence into the shell. That is what made this look
218
+ // impossible, and it is why the first attempt was reverted rather than shipped.
219
+ // 4. So the movement keys are screen's own, which are plain characters: Ctrl-U and Ctrl-D for
220
+ // half a page. `k` and `j` exist too, but they walk the cursor down the visible screen first
221
+ // and only scroll once it reaches the edge, which is not what a wheel notch should feel like.
222
+ const HALF_UP = String.fromCharCode(21); // Ctrl-U
223
+ const HALF_DOWN = String.fromCharCode(4); // Ctrl-D
224
+ const enterCopyMode = () => { send(String.fromCharCode(1)); send(String.fromCharCode(27)); };
225
+ pane.__scrollBack = enterCopyMode;
226
+ el.addEventListener('wheel', (e) => {
227
+ e.stopPropagation();
228
+ e.preventDefault();
229
+ // Ctrl and the wheel is the text size, as in every terminal and every browser.
230
+ if (e.ctrlKey) { stepFont(e.deltaY < 0 ? 1 : -1); return; }
231
+ if (!pane.dataset.copyMode) {
232
+ // There is nothing below the last line to reach, so only scrolling UP opens it.
233
+ if (e.deltaY < 0) enterCopyMode();
234
+ return;
235
+ }
236
+ send(e.deltaY < 0 ? HALF_UP : HALF_DOWN);
237
+ }, { capture: true, passive: false });
238
+
239
+ /**
240
+ * The socket, and why it is re-opened when the window changes size.
241
+ *
242
+ * The PTY behind it is sized ONCE, by the `stty cols N rows M` the server runs before `screen -x`
243
+ * — there is no resize channel, and every message on this socket goes straight to the session's
244
+ * stdin, so one cannot be added without inventing a protocol a keystroke could collide with.
245
+ *
246
+ * So a resized window re-attaches: xterm re-fits locally, and the new columns and rows reach the
247
+ * session the same way the first ones did. Debounced until the drag settles, and only when the
248
+ * size in CHARACTERS actually changed — dragging a window two pixels wider is not a reason to
249
+ * reconnect, and a reconnect costs a repaint.
250
+ *
251
+ * The session is untouched by any of this: `screen -x` attaches and detaches, and what runs inside
252
+ * it neither stops nor notices beyond being told its window is a different shape.
253
+ */
254
+ const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
255
+ let ws;
256
+ let attachedAt = { cols: 0, rows: 0 };
257
+
258
+ const wire = () => {
259
+ attachedAt = { cols: term.cols, rows: term.rows };
260
+ ws = new WebSocket(`${proto}${location.host}/ws/screen/${encodeURIComponent(session)}?cols=${term.cols}&rows=${term.rows}`);
261
+ ws.binaryType = 'arraybuffer';
262
+ ws.onmessage = (e) => {
263
+ const data = typeof e.data === 'string' ? e.data : new Uint8Array(e.data);
264
+ // Whether the session is in copy mode is SCREEN's answer, read from what it prints — never a
265
+ // flag set hopefully when a key was sent. The first version set it on sending Ctrl-A Esc, which
266
+ // was wrong whenever copy mode did not open, and then every wheel notch typed into the shell.
267
+ const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
268
+ if (/Copy mode aborted/.test(text)) {
269
+ delete pane.dataset.copyMode;
270
+ say(pane.dataset.live ? 'connected' : 'detached');
271
+ } else if (/Copy mode/.test(text)) {
272
+ pane.dataset.copyMode = '1';
273
+ say('scrollback — the wheel moves, q leaves it');
274
+ }
275
+ if (text.length) {
276
+ pane.dataset.painted = '1';
277
+ clearTimeout(pane.__silence);
278
+ if (pane.dataset.live && !pane.dataset.copyMode) say('connected');
279
+ }
280
+ term.write(data);
281
+ };
282
+ ws.onopen = () => {
283
+ say('connected');
284
+ pane.dataset.live = '1';
285
+ // Connected is not the same as painted. `screen -x` attaches to a window whose program may be
286
+ // sitting idle — an agent waiting on an answer, a shell nobody has typed in — and a window that
287
+ // came up with nothing in it looks exactly like one that stalled. The server asks the session to
288
+ // repaint a few times for us; if after that there is still nothing, the window says which of the
289
+ // two it is instead of leaving a black rectangle to be read as broken.
290
+ clearTimeout(pane.__silence);
291
+ pane.__silence = setTimeout(() => {
292
+ if (pane.dataset.painted || !pane.dataset.live) return;
293
+ say('attached — nothing painted yet, press a key or use ↻');
294
+ if (again) again.hidden = false;
295
+ }, 6000);
296
+ };
297
+ ws.onclose = () => {
298
+ // Never write into the terminal to say so — a TUI owns every cell of its screen. And say WHICH
299
+ // of the two happened: a detach leaves the session running, the end of its command does not.
300
+ delete pane.dataset.live;
301
+ delete pane.dataset.painted;
302
+ clearTimeout(pane.__silence);
303
+ if (again) again.hidden = false;
304
+ say('disconnected — checking…');
305
+ fetch(`/fragments/screen-alive/${encodeURIComponent(session)}`)
306
+ .then((r) => r.text())
307
+ .then((alive) => {
308
+ const live = alive.trim() === '1';
309
+ say(live ? 'detached — the session keeps running' : 'the session ended');
310
+ if (!live && again) again.hidden = true;
311
+ if (!live) pane.dataset.ended = '1';
312
+ })
313
+ .catch(() => say('detached'));
314
+ };
315
+ };
316
+ wire();
317
+
318
+ // Re-attach at the new size once the resizing has settled.
319
+ let resizeTimer = null;
320
+ const resized = () => {
321
+ if (term.cols === attachedAt.cols && term.rows === attachedAt.rows) return;
322
+ clearTimeout(resizeTimer);
323
+ resizeTimer = setTimeout(() => {
324
+ if (term.cols === attachedAt.cols && term.rows === attachedAt.rows) return;
325
+ say(`resizing to ${term.cols}×${term.rows}…`);
326
+ // Detach THIS display with screen's own Ctrl-A d before dropping the socket. Closing alone
327
+ // relies on the server reaping the attach, and a client left behind pins the window's size:
328
+ // screen fits a window to every display attached to it.
329
+ try { ws.send(String.fromCharCode(1)); ws.send('d'); } catch (_) { /* already gone */ }
330
+ try { ws.onclose = null; ws.close(); } catch (_) { /* already gone */ }
331
+ wire();
332
+ }, 400);
333
+ };
334
+ pane.__resized = resized;
335
+
336
+ // A full-screen program inside screen may turn mouse reporting on (DECSET 1000-1003). screen does
337
+ // not forward those reports to it, so every mouse move would arrive as literal text —
338
+ // "[<35;80;12M" typed into the prompt. Nothing here needs the mouse, so they are dropped.
339
+ const ESC = String.fromCharCode(27);
340
+ const isMouseReport = (s) => s.charAt(0) === ESC && s.charAt(1) === '['
341
+ && (s.charAt(2) === '<' || s.charAt(2) === 'M');
342
+ const send = (d) => { if (!isMouseReport(d) && ws.readyState === 1) ws.send(d); };
343
+
344
+
345
+ term.onData(send);
346
+ term.onBinary(send);
347
+
348
+ // Ctrl and plus, minus or zero: the text size, the way the Ubuntu terminal does it. The browser's
349
+ // own zoom is stopped, because it would scale the whole page instead of the terminal.
350
+ term.attachCustomKeyEventHandler((e) => {
351
+ if (e.type !== 'keydown' || !e.ctrlKey || e.altKey || e.metaKey) return true;
352
+ if (e.key === '+' || e.key === '=') { e.preventDefault(); stepFont(1); return false; }
353
+ if (e.key === '-' || e.key === '_') { e.preventDefault(); stepFont(-1); return false; }
354
+ if (e.key === '0') { e.preventDefault(); stepFont(0); return false; }
355
+ return true;
356
+ });
357
+
358
+ // Focus the window you just opened, and the one you click into — with several open, the terminal
359
+ // that takes your keystrokes must be the one you are looking at, and nothing else says which.
360
+ const focus = () => { try { term.focus(); } catch (_) { /* gone */ } };
361
+ pane.addEventListener('mousedown', focus);
362
+ focus();
363
+
364
+ pane.__term = {
365
+ session,
366
+ term,
367
+ refit,
368
+ focus,
369
+ // What the window can put into the session itself, for the controls that are better served by
370
+ // the socket than by an endpoint that cannot reach the host.
371
+ send,
372
+ // Repaint with the current look: the palette, then the face and size, then the grid again.
373
+ look() {
374
+ const now = lookNow();
375
+ term.options.theme = xtermTheme(now.style);
376
+ term.options.fontFamily = TERM_FONT;
377
+ term.options.fontSize = now.size;
378
+ measureCell(term);
379
+ refit();
380
+ },
381
+ close() {
382
+ clearTimeout(resizeTimer);
383
+ try { ws.onclose = null; ws.close(); } catch (_) { /* already gone */ }
384
+ try { term.dispose(); } catch (_) { /* already gone */ }
385
+ window.removeEventListener('resize', refit);
386
+ },
387
+ };
388
+ }
389
+
390
+ /**
391
+ * Is this window actually on the screen, with a box xterm can measure?
392
+ *
393
+ * By its RECTANGLE, never by offsetParent: a window is `position: fixed`, and a fixed element's
394
+ * offsetParent is null by definition — so the obvious test reports every terminal as hidden and
395
+ * none of them ever mounts. Measured, not assumed: an unmeasured box gives xterm zero columns.
396
+ */
397
+ const onScreen = (el) => {
398
+ const r = el.getBoundingClientRect();
399
+ return r.width > 0 && r.height > 0;
400
+ };
401
+
402
+ /** Mount every window that is on screen and not up yet. */
403
+ function mountVisible() {
404
+ const pending = [...document.querySelectorAll('.term-window:not([data-up])')].filter(onScreen);
405
+ if (!pending.length) return;
406
+ loadXterm().then(() => pending.forEach(mount)).catch(() => {});
407
+ }
408
+
409
+ /** Close one window: its socket, its terminal, and the element. The session keeps running. */
410
+ function closeWindow(win) {
411
+ if (win.__term) win.__term.close();
412
+ win.remove();
413
+ paint();
414
+ }
415
+
416
+ /** A window that has left the DOM takes its socket with it, however it went. */
417
+ function reapDetached(root) {
418
+ (root.querySelectorAll ? [...root.querySelectorAll('.term-window')] : [])
419
+ .concat(root.classList && root.classList.contains('term-window') ? [root] : [])
420
+ .forEach((win) => { if (win.__term) win.__term.close(); });
421
+ }
422
+
423
+ /* ------------------------------------------------------- windows and layout */
424
+
425
+ const LAYOUT_KEY = 'ws-term-layout';
426
+ const GEOM_KEY = 'ws-term-windows';
427
+ const MIN_W = 420;
428
+ const MIN_H = 220;
429
+ let topZ = 40;
430
+
431
+ const readJson = (key, fallback) => {
432
+ try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch (_) { return fallback; }
433
+ };
434
+ const writeJson = (key, value) => {
435
+ try { localStorage.setItem(key, JSON.stringify(value)); } catch (_) { /* private window */ }
436
+ };
437
+
438
+ const layoutNow = () => (localStorage.getItem(LAYOUT_KEY) === 'grid' ? 'grid' : 'list');
439
+
440
+ /** The layer every terminal window lives in, made on first use. */
441
+ function layer() {
442
+ let el = document.getElementById('term-windows');
443
+ if (!el) {
444
+ el = document.createElement('div');
445
+ el.id = 'term-windows';
446
+ document.body.appendChild(el);
447
+ }
448
+ return el;
449
+ }
450
+
451
+ const windows = () => [...document.querySelectorAll('.term-window')];
452
+
453
+ /**
454
+ * Put a window somewhere, kept inside the viewport.
455
+ *
456
+ * A strip of the title bar always stays on screen: a window dragged off the bottom of a tall display
457
+ * and reopened on a laptop would otherwise be gone, with its terminal still running.
458
+ */
459
+ function place(win, x, y, w, h) {
460
+ const width = Math.max(MIN_W, Math.round(w || win.offsetWidth || 720));
461
+ const height = Math.max(MIN_H, Math.round(h || win.offsetHeight || 420));
462
+ const maxX = Math.max(0, window.innerWidth - Math.min(width, 120));
463
+ const maxY = Math.max(0, window.innerHeight - 44);
464
+ const top = Math.min(Math.max(0, Math.round(y)), maxY);
465
+ win.style.width = `${Math.min(width, window.innerWidth)}px`;
466
+ // Never taller than the room under its top edge: a larger text size makes the default 43 rows
467
+ // taller than many displays, and a window that opens with its bottom row off the screen hides
468
+ // exactly the line a prompt sits on.
469
+ win.style.height = `${Math.max(MIN_H, Math.min(height, window.innerHeight - top - 8))}px`;
470
+ win.style.left = `${Math.min(Math.max(0, Math.round(x)), maxX)}px`;
471
+ win.style.top = `${top}px`;
472
+ if (win.__term) win.__term.refit();
473
+ }
474
+
475
+ const geomOf = (win) => {
476
+ const r = win.getBoundingClientRect();
477
+ return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
478
+ };
479
+
480
+ function remember(win) {
481
+ win.dataset.geom = '1'; // it has a size somebody chose now
482
+ const all = readJson(GEOM_KEY, {});
483
+ all[win.dataset.session] = geomOf(win);
484
+ writeJson(GEOM_KEY, all);
485
+ }
486
+
487
+ /** Bring one to the front, and mark it as the one taking keystrokes. */
488
+ function raise(win) {
489
+ topZ += 1;
490
+ win.style.zIndex = String(topZ);
491
+ windows().forEach((w) => w.classList.toggle('is-front', w === win));
492
+ }
493
+
494
+ /**
495
+ * Where a newly opened window goes when it has never been placed.
496
+ *
497
+ * Cascaded from the top left of the work area rather than centred: a new window centred on top of
498
+ * the last one hides it completely, and two terminals that look like one is the failure this whole
499
+ * change is about.
500
+ */
501
+ function cascade(n) {
502
+ const want = fitsSize();
503
+ const w = Math.min(Math.max(want.w, Math.round(window.innerWidth * 0.52)), Math.round(window.innerWidth * 0.94));
504
+ const h = Math.min(Math.max(want.h, Math.round(window.innerHeight * 0.55)), Math.round(window.innerHeight * 0.9));
505
+ const step = 28;
506
+ return { x: 40 + (n % 6) * step, y: 70 + (n % 6) * step, w, h };
507
+ }
508
+
509
+ /**
510
+ * The sizes a terminal window can be, in CHARACTERS — the same four the GNOME/Ubuntu terminal offers.
511
+ *
512
+ * A terminal is measured in columns and rows, not pixels: 132x43 means something to anyone who has
513
+ * used a terminal, and "914 by 741" means nothing. These four are what Ubuntu's own profile presets
514
+ * are, so the list is a familiar one rather than a set somebody here invented.
515
+ *
516
+ * 80x24 is the floor, not the default. It is what every TUI assumes and so the smallest size that is
517
+ * not actively broken — but it is cramped for a build log or an agent's output, which is most of what
518
+ * these windows hold.
519
+ */
520
+ const PRESETS = [
521
+ { cols: 80, rows: 24 },
522
+ { cols: 80, rows: 43 },
523
+ { cols: 132, rows: 24 },
524
+ { cols: 132, rows: 43 },
525
+ ];
526
+
527
+ /**
528
+ * What a window must measure to hold that many characters.
529
+ *
530
+ * From a MEASURED cell, because the font is the user's monospace stack at whatever size the browser
531
+ * gives it — a character's width is not something this file can know. `cellSize` is filled in by the
532
+ * first window to render and reused after that; before any has, the numbers measured on this machine
533
+ * stand in, close enough to open the first window sanely and corrected the moment it renders.
534
+ */
535
+ let cellSize = null;
536
+ const CHROME_W = 24; // the window's own padding and border, either side
537
+ const CHROME_H = 96; // title bar, the cli-hints line and the padding around them
538
+
539
+ /** What a character costs at the current face and size. Called again whenever the text size changes. */
540
+ function measureCell(term) {
541
+ try {
542
+ const cell = term._core._renderService.dimensions.css.cell;
543
+ if (cell && cell.width > 0) cellSize = { w: cell.width, h: cell.height };
544
+ } catch (_) { /* the fallback in fitsSize stands */ }
545
+ }
546
+
547
+ /** The window's own chrome gets the palette as custom properties; terminals.css only reads them. */
548
+ function paintChrome(win, name) {
549
+ const s = styleOf(name);
550
+ win.dataset.termStyle = name;
551
+ win.style.setProperty('--term-bg', s.bg);
552
+ win.style.setProperty('--term-fg', s.fg);
553
+ win.style.setProperty('--term-bar', s.bar);
554
+ win.style.setProperty('--term-bar-ink', s.barInk);
555
+ const size = lookNow().size;
556
+ win.querySelectorAll('[data-term-style]').forEach((b) => {
557
+ const on = b.dataset.termStyle === name;
558
+ b.classList.toggle('is-on', on);
559
+ b.setAttribute('aria-checked', String(on));
560
+ const sw = b.querySelector('.term-swatch');
561
+ if (sw) {
562
+ const o = styleOf(b.dataset.termStyle);
563
+ sw.style.setProperty('--sw-bg', o.bg);
564
+ sw.style.setProperty('--sw-fg', o.fg);
565
+ sw.style.setProperty('--sw-ink', o.ansi[2]);
566
+ }
567
+ });
568
+ win.querySelectorAll('.term-font-size').forEach((n) => { n.textContent = `${size} px`; });
569
+ }
570
+
571
+ /** Apply the stored look to every open window: a style is a preference, not a property of one terminal. */
572
+ function paintLook() {
573
+ const now = lookNow();
574
+ windows().forEach((win) => {
575
+ paintChrome(win, now.style);
576
+ if (win.__term) win.__term.look();
577
+ });
578
+ }
579
+
580
+ function storeLook(key, value) {
581
+ try { localStorage.setItem(key, String(value)); } catch (_) { /* private window */ }
582
+ }
583
+
584
+ /** One step of text size: +1, -1, or 0 for the default. */
585
+ function stepFont(by) {
586
+ const now = lookNow().size;
587
+ const next = by === 0 ? FONT_DEFAULT : Math.min(FONT_MAX, Math.max(FONT_MIN, now + by));
588
+ if (next === now) return;
589
+ storeLook(FONT_KEY, next);
590
+ paintLook();
591
+ }
592
+
593
+ function sizeFor(cols, rows) {
594
+ const cell = cellSize || { w: 7.83, h: 15 };
595
+ return { w: Math.ceil(cols * cell.w) + CHROME_W, h: Math.ceil(rows * cell.h) + CHROME_H };
596
+ }
597
+
598
+ /**
599
+ * What a window opens at: 132x43, the largest of the four.
600
+ *
601
+ * Not "the biggest that fits", which is what this did first — stepping down to 80x43 on a 1100px
602
+ * display was tidy and wrong: the window is DRAGGABLE and RESIZABLE, so a few pixels of it past the
603
+ * edge is a thing you move, while a terminal that opened too small is a thing you have to notice and
604
+ * correct. `place` clamps whatever it is given to the viewport, so a display that genuinely cannot
605
+ * hold 132x43 gets as much of it as it has room for rather than a smaller preset.
606
+ */
607
+ const DEFAULT_PRESET = { cols: 132, rows: 43 };
608
+ function defaultPreset() { return DEFAULT_PRESET; }
609
+
610
+ function fitsSize() {
611
+ const p = defaultPreset();
612
+ return sizeFor(p.cols, p.rows);
613
+ }
614
+
615
+ /**
616
+ * Tidy every open window at once.
617
+ *
618
+ * List is one column down the screen, each full width — what one terminal and any long TUI want.
619
+ * Grid tiles them, as many across as fit at a usable width: 80 columns is what every TUI assumes, so
620
+ * the number across is decided by the room there is rather than by a number somebody picked.
621
+ */
622
+ function arrange(how) {
623
+ const wins = windows();
624
+ if (!wins.length) return;
625
+ const pad = 12;
626
+ const top = 64;
627
+ const areaW = window.innerWidth - pad * 2;
628
+ const areaH = window.innerHeight - top - pad;
629
+ if (how === 'grid') {
630
+ const across = Math.max(1, Math.min(wins.length, Math.floor(areaW / 560)));
631
+ const down = Math.ceil(wins.length / across);
632
+ const w = Math.floor((areaW - (across - 1) * pad) / across);
633
+ const h = Math.max(MIN_H, Math.floor((areaH - (down - 1) * pad) / down));
634
+ wins.forEach((win, i) => place(win, pad + (i % across) * (w + pad), top + Math.floor(i / across) * (h + pad), w, h));
635
+ } else {
636
+ const h = Math.max(MIN_H, Math.floor((areaH - (wins.length - 1) * pad) / wins.length));
637
+ wins.forEach((win, i) => place(win, pad, top + i * (h + pad), areaW, h));
638
+ }
639
+ wins.forEach(remember);
640
+ paint();
641
+ }
642
+
643
+ function setLayout(how) {
644
+ writeJson(LAYOUT_KEY, how);
645
+ try { localStorage.setItem(LAYOUT_KEY, how); } catch (_) { /* private window */ }
646
+ arrange(how);
647
+ }
648
+
649
+ /**
650
+ * The bar that says how many terminals are open and arranges them.
651
+ *
652
+ * It is rendered by the server, in the top bar beside Screens, and it is EMPTY and hidden until
653
+ * there is something to say — the count is not the server's to know, since a window exists only in
654
+ * the browser that opened it. This fills it and unhides it; with nothing open it goes away again,
655
+ * so the top bar carries no control for a thing that is not there.
656
+ *
657
+ * A page that does not draw the top bar (the session's own tab at /terminal/<name>) simply has no
658
+ * bar, and nothing here minds: there is one terminal there and nothing to arrange.
659
+ */
660
+ function paint() {
661
+ const bar = document.getElementById('term-bar');
662
+ if (!bar) return;
663
+ const n = windows().length;
664
+ bar.hidden = !n;
665
+ if (!n) return;
666
+ const count = bar.querySelector('.term-count');
667
+ if (count) count.textContent = n === 1 ? '1 terminal' : `${n} terminals`;
668
+ const how = layoutNow();
669
+ bar.querySelectorAll('[data-term-layout]').forEach((b) => {
670
+ const on = b.dataset.termLayout === how;
671
+ b.classList.toggle('uk-button-primary', on);
672
+ b.classList.toggle('uk-button-default', !on);
673
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
674
+ });
675
+ }
676
+
677
+ /* ---------------------------------------------------------------- opening */
678
+
679
+ /**
680
+ * Open a session as its own window, leaving every other one exactly as it is.
681
+ *
682
+ * Asking for one that is already open raises and flashes it instead of opening a second: two
683
+ * windows on one screen session are two `screen -x` clients painting the same thing, which looks
684
+ * like a bug and costs a PTY.
685
+ */
686
+ function openTerminal(session) {
687
+ if (!window.htmx) return;
688
+ const already = document.querySelector(`.term-window[data-session="${CSS.escape(session)}"]`);
689
+ if (already) {
690
+ raise(already);
691
+ if (already.__term && already.__term.focus) already.__term.focus();
692
+ already.classList.add('is-flash');
693
+ setTimeout(() => already.classList.remove('is-flash'), 600);
694
+ return;
695
+ }
696
+ const n = windows().length;
697
+ layer(); // the target has to exist before htmx is pointed at it
698
+ window.htmx.ajax('GET', `/fragments/screen-term/${encodeURIComponent(session)}`,
699
+ { target: '#term-windows', swap: 'beforeend' })
700
+ .then(() => {
701
+ const win = document.querySelector(`.term-window[data-session="${CSS.escape(session)}"]`);
702
+ if (!win) return;
703
+ const saved = readJson(GEOM_KEY, {})[session];
704
+ const remembered = saved && Number.isFinite(saved.w);
705
+ // Marked, so the 80-column correction below leaves a size somebody chose alone. A window they
706
+ // deliberately made narrow is their decision; one that merely opened narrow is this file's.
707
+ if (remembered) win.dataset.geom = '1';
708
+ const g = remembered ? saved : cascade(n);
709
+ place(win, g.x, g.y, g.w, g.h);
710
+ raise(win);
711
+ paint();
712
+ mountVisible();
713
+ // Mounted a moment later, so the keyboard goes to it once it exists.
714
+ setTimeout(() => { if (win.__term && win.__term.focus) win.__term.focus(); }, 150);
715
+ });
716
+ }
717
+
718
+ window.openTerminal = openTerminal;
719
+
720
+ /* -------------------------------------------------------- drag and resize */
721
+
722
+ // The title bar is the handle, minus its buttons: a drag that started on the close button would
723
+ // close the window on release.
724
+ document.addEventListener('pointerdown', (e) => {
725
+ const win = e.target.closest('.term-window');
726
+ if (!win) return;
727
+ raise(win);
728
+ const head = e.target.closest('.term-window-head');
729
+ const grip = e.target.closest('.term-resize');
730
+ if (!head && !grip) return;
731
+ if (head && e.target.closest('button, a, input, select')) return;
732
+ e.preventDefault();
733
+ const r = win.getBoundingClientRect();
734
+ const from = { x: e.clientX, y: e.clientY };
735
+ const start = { x: r.left, y: r.top, w: r.width, h: r.height };
736
+ const target = head || grip;
737
+ target.setPointerCapture(e.pointerId);
738
+ win.classList.add('is-dragging');
739
+
740
+ const move = (ev) => {
741
+ const dx = ev.clientX - from.x;
742
+ const dy = ev.clientY - from.y;
743
+ if (grip) place(win, start.x, start.y, start.w + dx, start.h + dy);
744
+ else place(win, start.x + dx, start.y + dy, start.w, start.h);
745
+ };
746
+ const up = () => {
747
+ target.removeEventListener('pointermove', move);
748
+ target.removeEventListener('pointerup', up);
749
+ win.classList.remove('is-dragging');
750
+ remember(win);
751
+ };
752
+ target.addEventListener('pointermove', move);
753
+ target.addEventListener('pointerup', up);
754
+ });
755
+
756
+ // A display that got smaller must not leave a window off it.
757
+ window.addEventListener('resize', () => {
758
+ windows().forEach((win) => { const g = geomOf(win); place(win, g.x, g.y, g.w, g.h); });
759
+ });
760
+
761
+ /* ---------------------------------------------------------------- wiring */
762
+
763
+ document.addEventListener('click', (e) => {
764
+ const open = e.target.closest('[data-open-terminal]');
765
+ if (open) {
766
+ e.preventDefault();
767
+ const name = open.dataset.openTerminal;
768
+ // Picked from the Screens list (or a workitem): that list is a modal, and a modal holds the keyboard,
769
+ // so a terminal opened under it could be looked at and not typed into. It closes first.
770
+ const modal = document.getElementById('editor-modal');
771
+ if (modal && modal.contains(open) && window.UIkit) {
772
+ const go = () => openTerminal(name);
773
+ try {
774
+ const done = window.UIkit.modal(modal).hide();
775
+ if (done && done.then) done.then(go, go); else go();
776
+ } catch (_) { go(); }
777
+ return;
778
+ }
779
+ openTerminal(name);
780
+ return;
781
+ }
782
+ const fontPick = e.target.closest('.term-look-menu [data-term-font]');
783
+ if (fontPick) {
784
+ e.preventDefault();
785
+ const how = fontPick.dataset.termFont;
786
+ stepFont(how === 'up' ? 1 : how === 'down' ? -1 : 0);
787
+ return;
788
+ }
789
+ // Scoped to the menu: paintLook puts data-term-style on the window itself, so a bare selector matched
790
+ // every click inside a window and cancelled it (a details summary would never open).
791
+ const stylePick = e.target.closest('.term-look-menu [data-term-style]');
792
+ if (stylePick) {
793
+ e.preventDefault();
794
+ if (STYLE_ORDER.includes(stylePick.dataset.termStyle)) {
795
+ storeLook(STYLE_KEY, stylePick.dataset.termStyle);
796
+ paintLook();
797
+ }
798
+ return;
799
+ }
800
+ const layout = e.target.closest('[data-term-layout]');
801
+ if (layout) { e.preventDefault(); setLayout(layout.dataset.termLayout); return; }
802
+ // A size chosen from the list, in columns and rows.
803
+ const sizePick = e.target.closest('[data-term-size]');
804
+ if (sizePick) {
805
+ e.preventDefault();
806
+ const win = sizePick.closest('.term-window');
807
+ const asked = sizePick.dataset.termSize;
808
+ const m = asked.match(/^(\d+)x(\d+)$/);
809
+ const want = m ? sizeFor(Number(m[1]), Number(m[2])) : fitsSize();
810
+ const r = win.getBoundingClientRect();
811
+ place(win, r.left, r.top, want.w, want.h);
812
+ // A size picked from the list IS a chosen size, so it is remembered and the 80-column correction
813
+ // leaves it alone. "Biggest that fits" is the exception: it is the default, so it forgets.
814
+ if (m) { remember(win); } else {
815
+ const all = readJson(GEOM_KEY, {});
816
+ delete all[win.dataset.session];
817
+ writeJson(GEOM_KEY, all);
818
+ delete win.dataset.geom;
819
+ }
820
+ if (window.UIkit) { const d = sizePick.closest('.uk-dropdown'); if (d) window.UIkit.dropdown(d).hide(false); }
821
+ return;
822
+ }
823
+ const back = e.target.closest('.term-scrollback');
824
+ if (back) {
825
+ e.preventDefault();
826
+ const win = back.closest('.term-window');
827
+ if (win && win.__scrollBack) win.__scrollBack();
828
+ return;
829
+ }
830
+ const close = e.target.closest('.term-close');
831
+ if (close) { e.preventDefault(); closeWindow(close.closest('.term-window')); return; }
832
+ const full = e.target.closest('.term-fullscreen');
833
+ if (full) {
834
+ e.preventDefault();
835
+ const win = full.closest('.term-window');
836
+ if (document.fullscreenElement) document.exitFullscreen();
837
+ else if (win.requestFullscreen) win.requestFullscreen().then(() => win.__term && win.__term.refit()).catch(() => {});
838
+ }
839
+ });
840
+
841
+ document.addEventListener('confirmed-remove', (e) => {
842
+ const btn = e.target && e.target.closest ? e.target : null;
843
+ if (!btn) return;
844
+ // Close every terminal at once — through the confirm dialog, because several windows going at one
845
+ // click is worth being asked about even though none of the sessions ends.
846
+ if (btn.closest('.term-close-all')) { windows().forEach(closeWindow); return; }
847
+ // Ctrl-C, down this window's own socket. The action endpoint cannot reach a session on the host
848
+ // (the runner does not proxy screen's own keys), and this window is already attached to it.
849
+ const interrupt = btn.closest('.term-interrupt');
850
+ if (interrupt) {
851
+ const win = interrupt.closest('.term-window');
852
+ if (win && win.__term) win.__term.send(String.fromCharCode(3));
853
+ }
854
+ });
855
+
856
+ // Double-click the title bar puts a window back to the size it opens at — 80 columns by 24 rows —
857
+ // and FORGETS the remembered one, so it opens that way next time too. A window dragged small by
858
+ // accident is otherwise small for good, and a stored geometry is the one thing a reload cannot undo.
859
+ document.addEventListener('dblclick', (e) => {
860
+ const head = e.target.closest('.term-window-head');
861
+ if (!head || e.target.closest('button, a')) return;
862
+ const win = head.closest('.term-window');
863
+ const want = fitsSize();
864
+ const r = win.getBoundingClientRect();
865
+ place(win, r.left, r.top, want.w, want.h);
866
+ const all = readJson(GEOM_KEY, {});
867
+ delete all[win.dataset.session];
868
+ writeJson(GEOM_KEY, all);
869
+ delete win.dataset.geom;
870
+ });
871
+
872
+ document.addEventListener('fullscreenchange', () => {
873
+ const full = !!document.fullscreenElement;
874
+ windows().forEach((w) => {
875
+ w.classList.toggle('is-full', full && w === document.fullscreenElement);
876
+ if (w.__term) setTimeout(w.__term.refit, 120);
877
+ });
878
+ });
879
+
880
+ // A killed session's pane is worth closing on its own: the verb answers into the page, and leaving a
881
+ // dead terminal open invites typing into something that is not there any more.
882
+ document.body.addEventListener('screen-killed', (e) => {
883
+ const session = e.detail && e.detail.session;
884
+ const win = session && document.querySelector(`.term-window[data-session="${CSS.escape(session)}"]`);
885
+ if (win) closeWindow(win);
886
+ });
887
+
888
+ // The Dashboard style is read from the page's tokens, so it follows the light/dark switch.
889
+ if (window.MutationObserver) {
890
+ new MutationObserver(() => { if (lookNow().style === 'dashboard') paintLook(); })
891
+ .observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });
892
+ }
893
+
894
+ document.addEventListener('DOMContentLoaded', () => { mountVisible(); paint(); });
895
+ document.body.addEventListener('htmx:after:swap', () => { mountVisible(); paint(); });
896
+ if (window.MutationObserver) {
897
+ new MutationObserver((records) => {
898
+ records.forEach((r) => r.removedNodes.forEach((n) => { if (n.nodeType === 1) reapDetached(n); }));
899
+ }).observe(document.body, { childList: true, subtree: true });
900
+ }
901
+ mountVisible();
902
+ paint();
903
+ })();