@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
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.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
package/screens.js
ADDED
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen-sessions domain: what is running detached on this machine, and
|
|
3
|
+
* where a session's log lives when the dashboard knows the convention. The
|
|
4
|
+
* watch/stop actions live in actions/screens.js; the Screens modal, the
|
|
5
|
+
* /settings/screens page and the rail badge all read the same list.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
const { run } = require('./jobs');
|
|
13
|
+
const { ROOT, loadWorkspaces } = require('./workspaces');
|
|
14
|
+
const { esc } = require('./html');
|
|
15
|
+
const { tildePath } = require('./format');
|
|
16
|
+
const { waitingState } = require('./waiting');
|
|
17
|
+
const claudeSessions = require('./claude-sessions');
|
|
18
|
+
const claudeArgs = require('./claude-args');
|
|
19
|
+
|
|
20
|
+
/** Live screen sessions: the host's when its runner is up, this container's
|
|
21
|
+
* otherwise. Each row says where it lives, because `screen -r` in a terminal
|
|
22
|
+
* only reaches the host ones. */
|
|
23
|
+
/** Every session runs on the machine. Two ways to see them, and both are used:
|
|
24
|
+
* screen(1) itself, through the socket directory mounted in here — which is
|
|
25
|
+
* also how the browser terminal attaches — and the runner's published list,
|
|
26
|
+
* which still answers when that mount is missing. */
|
|
27
|
+
async function listScreens() {
|
|
28
|
+
const { hostSessionsFile } = require('./jobs');
|
|
29
|
+
const rows = (await listContainerScreens()).map((s) => ({ ...s, where: 'host' }));
|
|
30
|
+
const seen = new Set(rows.map((s) => s.session));
|
|
31
|
+
const published = hostSessionsFile();
|
|
32
|
+
const pubRows = published && Array.isArray(published.sessions) ? published.sessions : [];
|
|
33
|
+
// The waiting flag is computed by the HOST runner (it reads each session's visible
|
|
34
|
+
// screen), so it only ever arrives on the published rows. It has to be MERGED onto the
|
|
35
|
+
// rows this side already knows about rather than used only for the ones it does not:
|
|
36
|
+
// `screen -ls` here lists the same sessions, those rows won, and the flag was dropped
|
|
37
|
+
// every time — the badge could not appear for any session that was already listed,
|
|
38
|
+
// which is all of them.
|
|
39
|
+
const pubBy = new Map(pubRows.map((s) => [s.session, s]));
|
|
40
|
+
for (const r of rows) {
|
|
41
|
+
const p = pubBy.get(r.session);
|
|
42
|
+
if (p && p.waiting !== undefined) { r.waiting = p.waiting; r.waitingWhy = p.waitingWhy; }
|
|
43
|
+
}
|
|
44
|
+
const extra = pubRows.filter((s) => !seen.has(s.session)).map((s) => ({ ...s, where: 'host' }));
|
|
45
|
+
return rows.concat(extra);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The sessions inside this container. */
|
|
49
|
+
async function listContainerScreens() {
|
|
50
|
+
const out = String((await run('screen', ['-ls'], process.env.HOME, { timeoutMs: 10 * 1000 })).stdout || '');
|
|
51
|
+
const rows = [];
|
|
52
|
+
for (const line of out.split('\n')) {
|
|
53
|
+
// `screen -ls` prints: \t<pid>.<name>\t(<date>)\t(<state>)
|
|
54
|
+
const m = line.match(/^\s+(\d+)\.(\S+)\s+\(([^)]*)\)(?:\s+\(([^)]*)\))?/);
|
|
55
|
+
if (m) rows.push({ pid: m[1], session: m[2], started: m[3], state: m[4] || '' });
|
|
56
|
+
}
|
|
57
|
+
return rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Where a session's log lives, when the dashboard knows the convention:
|
|
61
|
+
// a plugin's sessions log where the plugin says; generic runs log
|
|
62
|
+
// under ~/.workspace-screens/<session>.log. Unknown sessions return null —
|
|
63
|
+
// they can still be stopped or reattached, just not tailed.
|
|
64
|
+
function screenLogFor(session) {
|
|
65
|
+
// This workspace's own log store first; the old shared home location stays
|
|
66
|
+
// readable so sessions started before the move keep their Watch.
|
|
67
|
+
for (const dir of [path.join(ROOT, '.screens'), path.join(process.env.HOME, '.workspace-screens')]) {
|
|
68
|
+
const f = path.join(dir, `${session}.log`);
|
|
69
|
+
if (fs.existsSync(f)) return f;
|
|
70
|
+
}
|
|
71
|
+
// A plugin's convention (a jira-<key> session logs inside the project it works): asked of every
|
|
72
|
+
// project folder, so a session started by hand is found the same as one the dashboard started.
|
|
73
|
+
{
|
|
74
|
+
const dirs = [];
|
|
75
|
+
for (const ws of Object.values(loadWorkspaces())) {
|
|
76
|
+
if (ws.kind === 'files') continue;
|
|
77
|
+
try { for (const p of fs.readdirSync(ws.dir)) dirs.push(path.join(ws.dir, p)); } catch (_) { /* skip */ }
|
|
78
|
+
}
|
|
79
|
+
const f = require('./plugin-loader').pluginSessionLog(session, dirs);
|
|
80
|
+
if (f) return f;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The session list, shared by the Screens modal and /settings/screens. */
|
|
86
|
+
// A session that has stopped to ask something. There is no API for "the program
|
|
87
|
+
// is waiting" — the truth is in what it last printed, so this reads the tail and
|
|
88
|
+
// looks for the shapes a question takes. It is a hint, never a claim: false
|
|
89
|
+
// positives cost a badge, false negatives cost nothing that was not already so.
|
|
90
|
+
const WAITING_PATTERNS = [
|
|
91
|
+
/\?\s*$/, // a question, still on the last line
|
|
92
|
+
/\((?:y\/n|yes\/no|Y\/n|y\/N)\)\s*[:?]?\s*$/i,
|
|
93
|
+
/\b(?:do you want|would you like|proceed\?|continue\?|are you sure)\b/i,
|
|
94
|
+
/\b(?:press|type)\b[^\n]{0,40}\b(?:enter|any key|to continue)\b/i,
|
|
95
|
+
/\b(?:waiting for|awaiting)\b[^\n]{0,30}\b(?:input|confirmation|approval|response)\b/i,
|
|
96
|
+
/^\s*[❯>]\s*\d+\.\s/m, // a numbered choice list, Claude Code style
|
|
97
|
+
/\b(?:allow|approve|permission)\b[^\n]{0,40}\?/i,
|
|
98
|
+
/passphrase|password\s*[:?]\s*$/i,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
function sessionNeedsInput(session) {
|
|
102
|
+
const log = screenLogFor(session);
|
|
103
|
+
if (!log) return false;
|
|
104
|
+
let tail = '';
|
|
105
|
+
try {
|
|
106
|
+
const fd = fs.openSync(log, 'r');
|
|
107
|
+
const size = fs.fstatSync(fd).size;
|
|
108
|
+
const start = Math.max(0, size - 3000);
|
|
109
|
+
const buf = Buffer.alloc(size - start);
|
|
110
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
111
|
+
fs.closeSync(fd);
|
|
112
|
+
tail = buf.toString('utf8');
|
|
113
|
+
} catch (_) { return false; }
|
|
114
|
+
// Only what it printed LAST matters: a question answered ten minutes ago is
|
|
115
|
+
// not a question now.
|
|
116
|
+
const lines = tail.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').split('\n').filter((l) => l.trim());
|
|
117
|
+
const recent = lines.slice(-6).join('\n');
|
|
118
|
+
return WAITING_PATTERNS.some((re) => re.test(recent));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which sessions have an AGENT running in them — Claude Code, rather than a shell or a command.
|
|
123
|
+
*
|
|
124
|
+
* Asked of the process table, not of the session's name and not of a note written when it started:
|
|
125
|
+
* a session can be started from the dashboard, from `cmd-tools-screen.sh`, or by hand in a terminal,
|
|
126
|
+
* and only the first of those could ever have left a marker behind. What is actually running inside it
|
|
127
|
+
* is the same question however it was started.
|
|
128
|
+
*
|
|
129
|
+
* One `ps` for the whole page — a workspace page renders twenty rows and each asks — memoised for a
|
|
130
|
+
* few seconds, which is far shorter than the fifteen the rows poll on. The SNAPSHOT is what is kept,
|
|
131
|
+
* never the answer: see processTable.
|
|
132
|
+
*
|
|
133
|
+
* It walks DOWN from each session's own screen process: `screen` forks the command, and a Claude Code
|
|
134
|
+
* session is `exec claude …`, so the agent is a descendant rather than the screen process itself.
|
|
135
|
+
*/
|
|
136
|
+
let psMemo = { at: 0, kids: new Map(), args: new Map() };
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The process table, as a parent → children map and a pid → command line map.
|
|
140
|
+
*
|
|
141
|
+
* The SNAPSHOT is what is memoised, never the answer computed from it. Caching the answer was wrong in
|
|
142
|
+
* a way that looked right on the page that found it: the first project to ask filled the cache, and
|
|
143
|
+
* every other row within the next few seconds got that project's set back — so on a workspace of
|
|
144
|
+
* eleven projects, ten of them claimed an agent was running that belonged to the first one.
|
|
145
|
+
*/
|
|
146
|
+
function processTable() {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (now - psMemo.at < 5000) return psMemo;
|
|
149
|
+
const kids = new Map();
|
|
150
|
+
const args = new Map();
|
|
151
|
+
try {
|
|
152
|
+
const out = require('child_process').execFileSync('ps', ['-eo', 'pid=,ppid=,args='],
|
|
153
|
+
{ encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024 });
|
|
154
|
+
for (const line of out.split('\n')) {
|
|
155
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
156
|
+
if (!m) continue;
|
|
157
|
+
const [, pid, ppid, cmd] = m;
|
|
158
|
+
if (!kids.has(ppid)) kids.set(ppid, []);
|
|
159
|
+
kids.get(ppid).push(pid);
|
|
160
|
+
args.set(pid, cmd);
|
|
161
|
+
}
|
|
162
|
+
} catch (_) { /* no ps, no claim */ }
|
|
163
|
+
psMemo = { at: now, kids, args };
|
|
164
|
+
return psMemo;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// `claude` the CLI, never a path that merely mentions it: a shell sitting in a folder called
|
|
168
|
+
// claude-something is not an agent, and neither is `tail -F .../claude.log`.
|
|
169
|
+
const isClaudeCmd = (cmd) => /(^|\/)claude(\s|$)/.test(String(cmd || '').trim());
|
|
170
|
+
|
|
171
|
+
function agentSessions(sessions) {
|
|
172
|
+
const { kids, args } = processTable();
|
|
173
|
+
const found = new Set();
|
|
174
|
+
for (const s of sessions || []) {
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
// Never walk from 0: every top-level process has ppid 0, so a row with no pid — a session the
|
|
177
|
+
// runner published that `screen -ls` has not listed yet — would walk the whole machine and claim
|
|
178
|
+
// every agent on it as its own.
|
|
179
|
+
const root = String(s.pid || '');
|
|
180
|
+
if (!root || root === '0') continue;
|
|
181
|
+
const stack = [root];
|
|
182
|
+
while (stack.length) {
|
|
183
|
+
const pid = stack.pop();
|
|
184
|
+
if (!pid || seen.has(pid)) continue;
|
|
185
|
+
seen.add(pid);
|
|
186
|
+
if (isClaudeCmd(args.get(pid))) { found.add(s.session); break; }
|
|
187
|
+
(kids.get(pid) || []).forEach((k) => stack.push(k));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Is this session waiting for a person, and what for?
|
|
195
|
+
*
|
|
196
|
+
* The runner's answer FIRST, and the log's only after it. The runner publishes what it read from the
|
|
197
|
+
* session's own VISIBLE SCREEN, which is the only thing that works for an agent: a Claude Code session
|
|
198
|
+
* runs `exec claude` with no `tee` — piping a full-screen TUI gives garbage — so its `.log` is zero
|
|
199
|
+
* bytes and the log check below can never fire for exactly the sessions where "it stopped to ask you
|
|
200
|
+
* something" matters most.
|
|
201
|
+
*
|
|
202
|
+
* One function because three places ask: the Screens page, the project row on a workspace page, and the
|
|
203
|
+
* count in the top bar. Three copies of this rule would be three answers to one question.
|
|
204
|
+
*/
|
|
205
|
+
function sessionWaitingWhy(s) {
|
|
206
|
+
if (!s) return '';
|
|
207
|
+
if (s.waiting) return s.waitingWhy || 'waiting for you';
|
|
208
|
+
return sessionNeedsInput(s.session) ? 'waiting for you' : '';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The last stretch of a session's log, as a terminal pane body. */
|
|
212
|
+
function screenTailHtml(session) {
|
|
213
|
+
const log = screenLogFor(session);
|
|
214
|
+
if (!log) return '<span class="uk-text-meta">No known log for this session — reattach in a terminal.</span>';
|
|
215
|
+
let text = '';
|
|
216
|
+
try {
|
|
217
|
+
const fd = fs.openSync(log, 'r');
|
|
218
|
+
const size = fs.fstatSync(fd).size;
|
|
219
|
+
const want = 4000;
|
|
220
|
+
const start = Math.max(0, size - want);
|
|
221
|
+
const buf = Buffer.alloc(size - start);
|
|
222
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
223
|
+
fs.closeSync(fd);
|
|
224
|
+
text = buf.toString('utf8');
|
|
225
|
+
if (start > 0) text = text.slice(text.indexOf('\n') + 1);
|
|
226
|
+
} catch (_) { return '<span class="uk-text-meta">The log is not readable yet.</span>'; }
|
|
227
|
+
return esc(text || '(no output yet)');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Where a new session can run: the workspace root, any workspace folder, and
|
|
231
|
+
// any project inside one. The value is a token the action resolves back to a
|
|
232
|
+
// directory — never a path from the browser.
|
|
233
|
+
function screenTargets() {
|
|
234
|
+
const targets = [{ value: 'root', label: 'Workspace root', dir: ROOT }];
|
|
235
|
+
for (const [key, meta] of Object.entries(loadWorkspaces())) {
|
|
236
|
+
targets.push({ value: `ws:${key}`, label: `${key}/`, dir: meta.dir, group: 'Workspace folders' });
|
|
237
|
+
if (meta.kind === 'files') continue;
|
|
238
|
+
let entries = [];
|
|
239
|
+
try { entries = fs.readdirSync(meta.dir, { withFileTypes: true }); } catch (_) { continue; }
|
|
240
|
+
for (const e of entries) {
|
|
241
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue;
|
|
242
|
+
targets.push({ value: `p:${key}:${e.name}`, label: `${key}/${e.name}`, dir: path.join(meta.dir, e.name), group: 'Projects' });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return targets;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The directory a target token means, or null when it names nothing real. */
|
|
249
|
+
function screenTargetDir(value) {
|
|
250
|
+
const t = screenTargets().find((x) => x.value === String(value || ''));
|
|
251
|
+
return t && fs.existsSync(t.dir) ? t.dir : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The sessions a target directory can resume, as a select. Empty when there are none — a first session
|
|
256
|
+
* in a project has nothing to resume, and an empty control there would be a question with no answers.
|
|
257
|
+
*/
|
|
258
|
+
function claudeResumeHtml(target) {
|
|
259
|
+
const dir = screenTargetDir(target);
|
|
260
|
+
// Only what --resume can actually open. An id with no transcript answers "No conversation found with
|
|
261
|
+
// session ID" and the screen dies in the same second — offering one is offering a dead end.
|
|
262
|
+
const sessions = dir ? claudeSessions.sessionsFor(dir, { limit: 25, resumableOnly: true }) : [];
|
|
263
|
+
const openOnes = dir ? claudeSessions.sessionsFor(dir).filter((x) => !x.resumable).length : 0;
|
|
264
|
+
if (!sessions.length) {
|
|
265
|
+
return `<div class="uk-text-meta">No Claude session to resume in ${dir ? `<code>${esc(tildePath(dir))}</code>` : 'that place'} yet — this starts a new one.${
|
|
266
|
+
openOnes ? ` (${openOnes} session${openOnes === 1 ? ' has' : 's have'} no transcript written yet, so <code>--resume</code> cannot open ${openOnes === 1 ? 'it' : 'them'}.)` : ''}</div>`;
|
|
267
|
+
}
|
|
268
|
+
const when = (ms) => {
|
|
269
|
+
const mins = Math.round((Date.now() - ms) / 60000);
|
|
270
|
+
if (mins < 60) return `${mins}m ago`;
|
|
271
|
+
if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago`;
|
|
272
|
+
return `${Math.round(mins / 1440)}d ago`;
|
|
273
|
+
};
|
|
274
|
+
// A checkbox, because resuming is the exception and starting fresh is the default — and because the
|
|
275
|
+
// answer to "which one" is almost always the newest, which is what the list opens on.
|
|
276
|
+
return `<div class="claude-resume">
|
|
277
|
+
<label class="uk-text-meta claude-resume-on">
|
|
278
|
+
<input class="uk-checkbox" type="checkbox" name="resume_on" value="1"
|
|
279
|
+
onchange="var s=this.closest('.claude-resume').querySelector('select[name=resume]');s.disabled=!this.checked;">
|
|
280
|
+
Resume a Claude session in <code>${esc(tildePath(dir))}</code>
|
|
281
|
+
<span class="uk-text-meta">· ${sessions.length} to pick from${openOnes ? `, ${openOnes} with no transcript yet` : ''}</span>
|
|
282
|
+
</label>
|
|
283
|
+
<select class="uk-select uk-form-small" name="resume" disabled aria-label="Claude conversation to resume"
|
|
284
|
+
title="claude --resume <id> — picks that exact conversation back up, in this directory">
|
|
285
|
+
${sessions.map((x, i) => `<option value="${esc(x.id)}"${i === 0 ? ' selected' : ''}>${esc(when(x.at))} · ${esc(x.title || (x.transcript ? 'no first message' : 'no transcript written yet'))} · ${esc(x.id.slice(0, 8))}</option>`).join('')}
|
|
286
|
+
</select>
|
|
287
|
+
</div>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The reusable prompts in the prompts workspace, offered as starting text.
|
|
291
|
+
// ITEM_RESERVED_FILES-style folder docs (AGENTS.md, CLAUDE.md, README.md) are
|
|
292
|
+
// not prompts.
|
|
293
|
+
function promptTemplates() {
|
|
294
|
+
const dir = path.join(ROOT, 'ai-prompts');
|
|
295
|
+
try {
|
|
296
|
+
return fs.readdirSync(dir)
|
|
297
|
+
.filter((f) => f.endsWith('.md') && !/^(AGENTS|CLAUDE|README)\.md$/.test(f))
|
|
298
|
+
.sort();
|
|
299
|
+
} catch (_) { return []; }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The sessions running in a directory: the .cwd note each start leaves, plus
|
|
303
|
+
* whatever convention a plugin's sessions follow (a log inside the project). */
|
|
304
|
+
async function screensForDir(dir) {
|
|
305
|
+
const live = await listScreens();
|
|
306
|
+
const store = path.join(ROOT, '.screens');
|
|
307
|
+
return live.filter((s) => {
|
|
308
|
+
try {
|
|
309
|
+
if (fs.readFileSync(path.join(store, `${s.session}.cwd`), 'utf8').trim() === dir) return true;
|
|
310
|
+
} catch (_) { /* no note */ }
|
|
311
|
+
return !!require('./plugin-loader').pluginSessionLog(s.session, [dir]);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* What is running in each project: how many screen sessions, how many of those have an agent inside,
|
|
317
|
+
* and how many are waiting on a person.
|
|
318
|
+
*
|
|
319
|
+
* One answer for a whole table of rows, asked once — the project list filters and sorts on it, and a
|
|
320
|
+
* per-row `screensForDir` would be one `screen -ls` per project. A session belongs to a project by the
|
|
321
|
+
* same two rules `screensForDir` uses (the .cwd note a start leaves, then the jira log inside the
|
|
322
|
+
* project), and the agent test is `agentSessions`, so a project that passes the filter is one whose
|
|
323
|
+
* row badges say so. A dead session (screen -ls still lists it) is not running anything.
|
|
324
|
+
*
|
|
325
|
+
* `activityFromSessions` is the pure half, so the counting can be tested without screen or ps.
|
|
326
|
+
*/
|
|
327
|
+
function activityFromSessions(sessions, { agents, dirOf }) {
|
|
328
|
+
const out = new Map();
|
|
329
|
+
for (const s of sessions || []) {
|
|
330
|
+
if (/dead/i.test(s.state || '')) continue;
|
|
331
|
+
const dir = dirOf(s.session);
|
|
332
|
+
if (!dir) continue;
|
|
333
|
+
const a = out.get(dir) || { screens: 0, agents: 0, waiting: 0 };
|
|
334
|
+
a.screens += 1;
|
|
335
|
+
if (agents.has(s.session)) a.agents += 1;
|
|
336
|
+
if (sessionWaitingWhy(s)) a.waiting += 1;
|
|
337
|
+
out.set(dir, a);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function activityByDir(dirs) {
|
|
343
|
+
const sessions = await listScreens();
|
|
344
|
+
if (!sessions.length) return new Map();
|
|
345
|
+
const store = path.join(ROOT, '.screens');
|
|
346
|
+
const known = new Set(dirs);
|
|
347
|
+
const dirOf = (session) => {
|
|
348
|
+
try {
|
|
349
|
+
const d = fs.readFileSync(path.join(store, `${session}.cwd`), 'utf8').trim();
|
|
350
|
+
if (known.has(d)) return d;
|
|
351
|
+
} catch (_) { /* no note */ }
|
|
352
|
+
const f = require('./plugin-loader').pluginSessionLog(session, dirs);
|
|
353
|
+
return f ? (dirs.find((d) => f.startsWith(d + path.sep)) || '') : '';
|
|
354
|
+
};
|
|
355
|
+
return activityFromSessions(sessions, { agents: agentSessions(sessions), dirOf });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The claude flags a template's own command carries (kept, not invented). */
|
|
359
|
+
function promptTemplateFlags(file) {
|
|
360
|
+
if (!promptTemplates().includes(file)) return '';
|
|
361
|
+
let raw;
|
|
362
|
+
try { raw = fs.readFileSync(path.join(ROOT, 'ai-prompts', file), 'utf8'); } catch (_) { return ''; }
|
|
363
|
+
const m = raw.match(/^\s*claude\s+([^\n]*?)\s*"""\s*$/m);
|
|
364
|
+
return m ? m[1].trim() : '';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// One template, as the text Claude Code should actually open with. Every
|
|
368
|
+
// prompt in that folder is written as the whole launch command —
|
|
369
|
+
// `claude --permission-mode bypassPermissions """<prompt>"""` inside a fence —
|
|
370
|
+
// so the wrapper is peeled off here: the form runs claude itself, and the
|
|
371
|
+
// user should see the prompt, not the command that would start it.
|
|
372
|
+
function promptTemplateText(file) {
|
|
373
|
+
if (!promptTemplates().includes(file)) return null;
|
|
374
|
+
let raw;
|
|
375
|
+
try { raw = fs.readFileSync(path.join(ROOT, 'ai-prompts', file), 'utf8'); } catch (_) { return null; }
|
|
376
|
+
const m = raw.match(/claude[^\n]*"""\n([\s\S]*?)\n"""/);
|
|
377
|
+
if (m) return m[1];
|
|
378
|
+
// No wrapper: drop YAML frontmatter and the description line, keep the body.
|
|
379
|
+
const noFm = raw.replace(/^---\n[\s\S]*?\n---\n/, '');
|
|
380
|
+
return noFm.trim();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function newScreenButtonHtml() {
|
|
384
|
+
return `<button class="uk-button uk-button-primary uk-button-small"
|
|
385
|
+
hx-get="/fragments/screen-new-form" hx-target="#editor-modal-body" hx-swap="innerHTML"
|
|
386
|
+
hx-on::after:request="UIkit.modal('#editor-modal').show()">
|
|
387
|
+
<span uk-icon="icon: plus-circle; ratio: .8"></span> New screen</button>`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Where a session can start, as two lists instead of one.
|
|
392
|
+
*
|
|
393
|
+
* It used to be a single select of 309 entries — every workspace folder and every project in the tree,
|
|
394
|
+
* one flat list to scroll. Choosing the workspace first turns that into a list of 22 and then a list of
|
|
395
|
+
* the projects in it, which is also how somebody thinks about where they are going.
|
|
396
|
+
*/
|
|
397
|
+
function workspaceOptionsHtml(chosen) {
|
|
398
|
+
const rows = [['root', 'Workspace root']];
|
|
399
|
+
for (const [key, meta] of Object.entries(loadWorkspaces())) rows.push([key, `${key}/`, meta.label]);
|
|
400
|
+
return rows.map(([value, label, title]) =>
|
|
401
|
+
`<option value="${esc(value)}"${value === chosen ? ' selected' : ''} title="${esc(title || label)}">${esc(label)}</option>`).join('');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Which workspace a target token belongs to, so reopening the form lands on the list it was picked from. */
|
|
405
|
+
function workspaceOfTarget(target) {
|
|
406
|
+
const t = String(target || '');
|
|
407
|
+
const m = t.match(/^p:([a-z0-9_-]+):/) || t.match(/^ws:([a-z0-9_-]+)$/);
|
|
408
|
+
return m ? m[1] : 'root';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The places inside one workspace: the folder itself first, then its projects. The folder is a place a
|
|
413
|
+
* session runs in its own right — a whole workspace is what a build, a sweep or a review is about — so
|
|
414
|
+
* it is the first option rather than something to notice is missing.
|
|
415
|
+
*/
|
|
416
|
+
function targetOptionsHtml(workspace, chosen) {
|
|
417
|
+
const key = String(workspace || 'root');
|
|
418
|
+
if (key === 'root') {
|
|
419
|
+
return `<option value="root" data-path="${esc(tildePath(ROOT))}" selected>the workspace root</option>`;
|
|
420
|
+
}
|
|
421
|
+
const inside = screenTargets().filter((t) => t.value === `ws:${key}` || t.value.startsWith(`p:${key}:`));
|
|
422
|
+
return inside.map((t) => {
|
|
423
|
+
const folder = t.value === `ws:${key}`;
|
|
424
|
+
const label = folder ? `the ${key}/ folder itself` : t.label.slice(key.length + 1);
|
|
425
|
+
// Each option carries where it is, so the line under the select follows the choice with no request.
|
|
426
|
+
return `<option value="${esc(t.value)}" data-path="${esc(tildePath(t.dir))}"${t.value === chosen ? ' selected' : ''}>${esc(label)}</option>`;
|
|
427
|
+
}).join('');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The name a session gets by default: where it runs, and a short id so it is its own.
|
|
432
|
+
*
|
|
433
|
+
* The place is what `screen -ls` shows and what `screen -r` takes, so the answer is almost always
|
|
434
|
+
* `dev--ha1x00b1--`; typing that out for every session was busywork with one right answer. The id on the
|
|
435
|
+
* end is what makes it startable twice — a second session in the same project used to be refused with
|
|
436
|
+
* "a session called that is already running", which is a naming collision presented as a rule.
|
|
437
|
+
*
|
|
438
|
+
* Four hex characters: enough that two sessions in one place do not collide, short enough to type after
|
|
439
|
+
* `screen -r`. The caller may pass its own id, which is what makes this testable.
|
|
440
|
+
*/
|
|
441
|
+
function shortId() {
|
|
442
|
+
return crypto.randomUUID().replace(/-/g, '').slice(0, 4);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function defaultSessionName(target, id) {
|
|
446
|
+
const tail = id || shortId();
|
|
447
|
+
const t = String(target || '');
|
|
448
|
+
const pm = t.match(/^p:([a-z0-9_-]+):(.+)$/);
|
|
449
|
+
// The same shape the session gets when it starts, so the workspace and the project are not said twice.
|
|
450
|
+
if (pm) return `${pm[1]}--${pm[2]}--${tail}`.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').slice(0, 60);
|
|
451
|
+
const wm = t.match(/^ws:([a-z0-9_-]+)$/);
|
|
452
|
+
if (wm) return `${wm[1]}--${tail}`;
|
|
453
|
+
// The whole tree, which is a place but not a word anyone would name a session: `screen -ls` showing
|
|
454
|
+
// "root" reads as the user it runs as, which it is not.
|
|
455
|
+
return `workspace-${tail}`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The "where it runs" half of the form, reloaded whole when the workspace changes. */
|
|
459
|
+
function screenWhereHtml(workspace, target) {
|
|
460
|
+
const ws = String(workspace || 'root');
|
|
461
|
+
const options = targetOptionsHtml(ws, target);
|
|
462
|
+
const first = (options.match(/value="([^"]*)"/) || [])[1] || 'root';
|
|
463
|
+
const picked = options.includes(`value="${target}"`) ? target : first;
|
|
464
|
+
const dir = screenTargetDir(picked);
|
|
465
|
+
return `<select class="uk-select uk-form-small" name="target" id="screen-target"
|
|
466
|
+
title="The directory the session starts in"
|
|
467
|
+
hx-get="/fragments/claude-sessions" hx-trigger="change" hx-include="closest form"
|
|
468
|
+
hx-target="#claude-resume-row" hx-swap="innerHTML"
|
|
469
|
+
hx-on::after:request="event.stopPropagation()"
|
|
470
|
+
onchange="window.screenNameFollowsTarget && window.screenNameFollowsTarget(this.form)">${options}</select>
|
|
471
|
+
<div class="uk-text-meta screen-where-path"><code>${dir ? esc(tildePath(dir)) : 'nothing there'}</code></div>`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** One Claude Code option, as the control its own shape asks for. */
|
|
475
|
+
function claudeArgFieldHtml(opt, value, suggestions, asSelect = false) {
|
|
476
|
+
const field = claudeArgs.fieldName(opt.name);
|
|
477
|
+
const id = `arg-${field}`;
|
|
478
|
+
const hint = esc(opt.description);
|
|
479
|
+
if (opt.takes === 'none') {
|
|
480
|
+
return `<label class="claude-arg claude-arg-flag" title="${hint}">
|
|
481
|
+
<input class="uk-checkbox" type="checkbox" name="${field}" value="1"${value ? ' checked' : ''}>
|
|
482
|
+
<code>${esc(opt.name)}</code>
|
|
483
|
+
<span class="claude-arg-help">${hint}</span>
|
|
484
|
+
</label>`;
|
|
485
|
+
}
|
|
486
|
+
// Three shapes, one per kind of answer the option has:
|
|
487
|
+
//
|
|
488
|
+
// - a CLOSED list (--effort: low, medium, high, xhigh, max) is a select. The CLI names all five.
|
|
489
|
+
// - an OPEN list (--model, --agent) is an input with a datalist: a dropdown you can also type
|
|
490
|
+
// into. A select here would refuse every model released after this help text was read, and
|
|
491
|
+
// every agent that comes from a plugin or a directory this does not walk.
|
|
492
|
+
// - anything else is a plain field, as before.
|
|
493
|
+
const list = suggestions && suggestions.length ? `${id}-list` : '';
|
|
494
|
+
// A plain list of what is installed, for a form that wants a pick rather than a type-ahead.
|
|
495
|
+
const pickList = asSelect && suggestions && suggestions.length;
|
|
496
|
+
const control = pickList
|
|
497
|
+
? `<select class="uk-select uk-form-small" name="${field}" id="${id}">
|
|
498
|
+
<option value="">None</option>
|
|
499
|
+
${suggestions.map((c) => `<option value="${esc(c)}"${String(value) === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}
|
|
500
|
+
</select>`
|
|
501
|
+
: opt.choices.length
|
|
502
|
+
? `<select class="uk-select uk-form-small" name="${field}" id="${id}">
|
|
503
|
+
<option value="">—</option>
|
|
504
|
+
${opt.choices.map((c) => `<option value="${esc(c)}"${String(value) === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}
|
|
505
|
+
</select>`
|
|
506
|
+
: `<input class="uk-input uk-form-small" name="${field}" id="${id}" value="${esc(value || '')}"
|
|
507
|
+
${list ? `list="${list}"` : ''} placeholder="${esc(opt.placeholder || '')}${opt.repeatable ? ' — space separated' : ''}">${
|
|
508
|
+
list ? `<datalist id="${list}">${suggestions.map((c) => `<option value="${esc(c)}"></option>`).join('')}</datalist>` : ''}`;
|
|
509
|
+
return `<div class="claude-arg" title="${hint}">
|
|
510
|
+
<label class="claude-arg-name" for="${id}"><code>${esc(opt.name)}</code></label>
|
|
511
|
+
${control}
|
|
512
|
+
<span class="claude-arg-help">${hint}</span>
|
|
513
|
+
</div>`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The Claude Code arguments, from the CLI's own --help rather than from a list kept here by hand.
|
|
518
|
+
*
|
|
519
|
+
* The four reached for every time are laid out first; the rest are behind a disclosure, so the form
|
|
520
|
+
* opens as a few controls rather than as forty. The free-text "more claude options" box this replaces
|
|
521
|
+
* meant knowing the flags by heart and typing them with no check at all until the screen either started
|
|
522
|
+
* or died.
|
|
523
|
+
*/
|
|
524
|
+
/*
|
|
525
|
+
* The Jira checkbox, explained here rather than as an HTML comment inside the markup.
|
|
526
|
+
*
|
|
527
|
+
* A comment that starts on the same line as the backtick opening a nested template literal is exactly
|
|
528
|
+
* where a stray backtick later would be invisible — the repo has a test for the shape, and it caught
|
|
529
|
+
* this one. The rule it enforces is worth keeping: prose about markup belongs in JS, not in the HTML.
|
|
530
|
+
*
|
|
531
|
+
* What the checkbox is: a run started FROM a workitem can say so ON the workitem. Unticked when the
|
|
532
|
+
* form opens, because a Jira comment is visible to everyone on it and a run meant to stay quiet must
|
|
533
|
+
* not become public as a side effect of starting it. The label names exactly what is posted, because
|
|
534
|
+
* "comment the session" could be read as the transcript, and it never is — only where the session is
|
|
535
|
+
* and how to reach it.
|
|
536
|
+
*/
|
|
537
|
+
|
|
538
|
+
async function claudeArgsHtml(pre) {
|
|
539
|
+
const p = pre || {};
|
|
540
|
+
const { options, error } = await claudeArgs.claudeOptions();
|
|
541
|
+
if (error) {
|
|
542
|
+
return `<div class="uk-text-meta">The Claude Code options could not be read from <code>claude --help</code> on this machine (${esc(error.slice(0, 120))}), so only the prompt and the resume list are offered.</div>`;
|
|
543
|
+
}
|
|
544
|
+
const byName = new Map(options.map((o) => [o.name, o]));
|
|
545
|
+
const valueOf = (opt) => {
|
|
546
|
+
const posted = p[claudeArgs.fieldName(opt.name)];
|
|
547
|
+
if (posted !== undefined) return posted;
|
|
548
|
+
// The permission mode used to be its own field, and a prompt template still names one.
|
|
549
|
+
if (opt.name === '--permission-mode') return p.permission_mode || 'bypassPermissions';
|
|
550
|
+
return '';
|
|
551
|
+
};
|
|
552
|
+
// What to suggest for the two options whose values are a list of real things rather than a fixed set:
|
|
553
|
+
// the agents this machine (and this project) actually have, and the model aliases the CLI's own help
|
|
554
|
+
// names. `--agent`'s parsed suggestion is the word "agent" — the quoted setting name in its
|
|
555
|
+
// description — which is why the agents come from the files instead.
|
|
556
|
+
const agents = claudeArgs.installedAgents(screenTargetDir(p.target));
|
|
557
|
+
const suggestionsFor = (opt) => (opt.name === '--agent' ? agents : (opt.suggestions || []));
|
|
558
|
+
const common = claudeArgs.COMMON.map((n) => byName.get(n)).filter(Boolean);
|
|
559
|
+
const rest = options.filter((o) => !claudeArgs.COMMON.includes(o.name));
|
|
560
|
+
return `<div class="claude-args">
|
|
561
|
+
<div class="claude-args-common">${common.map((o) => claudeArgFieldHtml(o, valueOf(o), suggestionsFor(o), !!p.agentSelect && o.name === '--agent')).join('')}</div>
|
|
562
|
+
<details class="claude-args-all">
|
|
563
|
+
<summary>All Claude Code options (${rest.length})</summary>
|
|
564
|
+
<p class="uk-text-meta">Read from <code>claude --help</code> on this machine, so this list is whatever
|
|
565
|
+
the installed CLI actually accepts. The ones that only work with <code>--print</code>, the ones that
|
|
566
|
+
start the session somewhere else, and the ones this form already asks about are not here.</p>
|
|
567
|
+
<div class="claude-args-grid">${rest.map((o) => claudeArgFieldHtml(o, valueOf(o), suggestionsFor(o))).join('')}</div>
|
|
568
|
+
</details>
|
|
569
|
+
</div>`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function newScreenFormHtml(pre) {
|
|
573
|
+
const p = pre || {};
|
|
574
|
+
const ws = p.workspace || workspaceOfTarget(p.target);
|
|
575
|
+
// Opened on a workspace's own address, the workspace is not a question: asking it again is how a screen
|
|
576
|
+
// started on dev.workspace.ddev.site ended up in the root.
|
|
577
|
+
const locked = !!p.hostWorkspace && p.hostWorkspace === ws;
|
|
578
|
+
const isClaude = p.kind === 'claude';
|
|
579
|
+
const prefilled = defaultSessionName(p.target);
|
|
580
|
+
const kind = (value, label, help) => `<label class="screen-kind${(p.kind || 'shell') === value ? ' is-on' : ''}">
|
|
581
|
+
<input type="radio" name="kind" value="${value}"${(p.kind || 'shell') === value ? ' checked' : ''}
|
|
582
|
+
onchange="window.screenKindChanged && window.screenKindChanged(this.form)">
|
|
583
|
+
<strong>${label}</strong><span class="uk-text-meta">${help}</span>
|
|
584
|
+
</label>`;
|
|
585
|
+
return `
|
|
586
|
+
<div class="editor-card new-screen-card">
|
|
587
|
+
<div class="modal-head">
|
|
588
|
+
<h3 class="uk-margin-small-bottom">New screen</h3>
|
|
589
|
+
<p class="uk-text-meta uk-margin-small-bottom">It runs detached on your machine and survives this
|
|
590
|
+
dashboard restarting; <code>screen -r <name></code> in your own terminal reattaches to it.</p>
|
|
591
|
+
</div>
|
|
592
|
+
<form hx-post="/actions/screen-new" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
|
|
593
|
+
hx-on::after:request="UIkit.modal('#editor-modal').hide()">
|
|
594
|
+
|
|
595
|
+
<fieldset class="screen-step">
|
|
596
|
+
<legend>Where it runs${locked ? ` <span class="uk-text-meta">in the <strong>${esc(ws)}/</strong> workspace</span>` : ''}</legend>
|
|
597
|
+
<div class="uk-grid-small uk-child-width-1-2@s" uk-grid>
|
|
598
|
+
${locked ? `<input type="hidden" name="workspace" value="${esc(ws)}">` : `<div>
|
|
599
|
+
<label class="uk-form-label" for="screen-workspace">Workspace</label>
|
|
600
|
+
<select class="uk-select uk-form-small" name="workspace" id="screen-workspace"
|
|
601
|
+
hx-get="/fragments/screen-where" hx-include="closest form"
|
|
602
|
+
hx-target="#screen-where" hx-swap="innerHTML"
|
|
603
|
+
hx-on::after:request="event.stopPropagation()">
|
|
604
|
+
${workspaceOptionsHtml(ws)}
|
|
605
|
+
</select>
|
|
606
|
+
</div>`}
|
|
607
|
+
<div id="screen-where"${locked ? ' class="uk-width-1-1"' : ''}>
|
|
608
|
+
<label class="uk-form-label" for="screen-target">Folder or project</label>
|
|
609
|
+
${screenWhereHtml(ws, p.target)}
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
</fieldset>
|
|
613
|
+
|
|
614
|
+
<fieldset class="screen-step">
|
|
615
|
+
<legend>What it starts</legend>
|
|
616
|
+
<div class="screen-kinds">
|
|
617
|
+
${kind('shell', 'Shell', 'a plain login shell there')}
|
|
618
|
+
${kind('claude', 'Claude Code', 'the claude CLI, with the field below as its opening prompt')}
|
|
619
|
+
${kind('command', 'Command', 'runs what you type, and logs it')}
|
|
620
|
+
</div>
|
|
621
|
+
<div class="uk-margin-small-top screen-name-row">
|
|
622
|
+
<label class="uk-form-label" for="screen-session">Session name</label>
|
|
623
|
+
<input class="uk-input uk-form-small" name="session" id="screen-session"
|
|
624
|
+
value="${esc(p.session || prefilled)}"
|
|
625
|
+
data-derived="${esc(prefilled)}" data-tail="${esc(prefilled.slice(-4))}"
|
|
626
|
+
pattern="[a-z0-9][a-zA-Z0-9_.\-]*" title="lowercase start; letters, digits, - _ . after">
|
|
627
|
+
<span class="uk-text-meta">Where it runs, and a short id so a second session here is not the
|
|
628
|
+
same name. <code>screen -r</code> takes it; leave it empty and it is filled in for you.</span>
|
|
629
|
+
</div>
|
|
630
|
+
</fieldset>
|
|
631
|
+
|
|
632
|
+
<fieldset class="screen-step" data-claude-only${isClaude ? '' : ' hidden'}>
|
|
633
|
+
<legend>Claude Code</legend>
|
|
634
|
+
${p.work ? `
|
|
635
|
+
<input type="hidden" name="work_key" value="${esc(p.work)}">
|
|
636
|
+
<label class="claude-arg claude-arg-flag jira-comment-opt" title="Posts one comment on ${esc(p.work)}">
|
|
637
|
+
<input class="uk-checkbox" type="checkbox" name="comment_session" value="1">
|
|
638
|
+
<span>Comment on <strong>${esc(p.work)}</strong> that this session is running</span>
|
|
639
|
+
<span class="claude-arg-help">Posts the session name, a link to its terminal on this hub, and
|
|
640
|
+
how to resume the conversation on this machine — never the transcript or anything the agent
|
|
641
|
+
says. A Claude Code CLI session has no page on claude.ai; it lives on this machine.</span>
|
|
642
|
+
</label>` : ''}
|
|
643
|
+
<!-- Resume rather than start over. A screen session that died — a reboot, a closed terminal,
|
|
644
|
+
a wipe of the dead ones — did not take the conversation with it: the CLI keeps every
|
|
645
|
+
session's transcript, and claude --resume <id> from the same directory picks it back up.
|
|
646
|
+
By id, not --continue, which takes the most recent session for the directory and may not
|
|
647
|
+
be the one meant. It is per DIRECTORY, so a workspace folder has its own sessions exactly
|
|
648
|
+
as a project does, and the list reloads whenever the place above changes.
|
|
649
|
+
No backticks in this comment: the form is a template literal. -->
|
|
650
|
+
<div id="claude-resume-row">${claudeResumeHtml(p.target)}</div>
|
|
651
|
+
${await claudeArgsHtml(p)}
|
|
652
|
+
<div class="uk-margin-small-top">
|
|
653
|
+
<label class="uk-form-label" for="screen-template">Prompt template</label>
|
|
654
|
+
<select class="uk-select uk-form-small" id="screen-template"
|
|
655
|
+
title="Fill the prompt below with one of the prompts in the ai-prompts workspace, and set the flags that prompt documents"
|
|
656
|
+
onchange="window.screenPromptTemplate && window.screenPromptTemplate(this)">
|
|
657
|
+
<option value="">— write it yourself —</option>
|
|
658
|
+
${promptTemplates().map((f) => `<option value="${esc(f)}" data-flags="${esc(promptTemplateFlags(f))}">${esc(f.replace(/\.md$/, ''))}</option>`).join('')}
|
|
659
|
+
</select>
|
|
660
|
+
</div>
|
|
661
|
+
</fieldset>
|
|
662
|
+
|
|
663
|
+
<fieldset class="screen-step">
|
|
664
|
+
<legend><span data-claude-only${isClaude ? '' : ' hidden'}>The prompt it opens with</span><span data-not-claude${isClaude ? ' hidden' : ''}>The command it runs</span></legend>
|
|
665
|
+
<textarea class="uk-textarea uk-form-small" name="command" rows="12" data-ace="sh"
|
|
666
|
+
data-mentions="/fragments/ai-suggest${p.target && p.target.startsWith('p:') ? `/${esc(p.target.split(':')[1])}/${esc(p.target.split(':').slice(2).join(':'))}` : ''}"
|
|
667
|
+
placeholder="a command to run, or the prompt Claude Code opens with — leave it empty for a plain session">${esc(p.command || '')}</textarea>
|
|
668
|
+
</fieldset>
|
|
669
|
+
|
|
670
|
+
<p class="modal-actions"><button class="uk-button uk-button-primary" type="submit">Start it</button></p>
|
|
671
|
+
</form>
|
|
672
|
+
</div>`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// The page: the New-screen form, a toolbar, then one row per live session —
|
|
676
|
+
// a terminal glyph, what it is, and the real screen(1) verbs behind icons.
|
|
677
|
+
const TERM_SVG = `
|
|
678
|
+
<svg class="term-glyph" viewBox="0 0 96 72" aria-hidden="true" focusable="false">
|
|
679
|
+
<rect x="1" y="1" width="94" height="70" rx="8" fill="#300a24" stroke="#5e2750" stroke-width="1.5"/>
|
|
680
|
+
<path d="M1 9a8 8 0 0 1 8-8h78a8 8 0 0 1 8 8v8H1z" fill="#2c001e"/>
|
|
681
|
+
<circle cx="70" cy="9" r="3.1" fill="#77216f"/><circle cx="79" cy="9" r="3.1" fill="#77216f"/><circle cx="88" cy="9" r="3.4" fill="#e95420"/>
|
|
682
|
+
<rect x="8" y="6" width="22" height="6" rx="3" fill="#aea79f" opacity=".55"/>
|
|
683
|
+
<rect x="8" y="26" width="30" height="5" rx="2.5" fill="#8ae234"/><rect x="40" y="26" width="3" height="5" rx="1.5" fill="#ffffff"/>
|
|
684
|
+
<rect x="45" y="26" width="8" height="5" rx="2.5" fill="#729fcf"/><rect x="55" y="26" width="3" height="5" rx="1.5" fill="#ffffff"/>
|
|
685
|
+
<rect x="62" y="24" width="8" height="9" rx="1" fill="#ffffff" class="term-glyph-cursor"/>
|
|
686
|
+
<rect x="8" y="40" width="52" height="4" rx="2" fill="#d3d7cf" opacity=".6"/>
|
|
687
|
+
<rect x="8" y="49" width="38" height="4" rx="2" fill="#d3d7cf" opacity=".4"/>
|
|
688
|
+
<rect x="8" y="58" width="24" height="4" rx="2" fill="#d3d7cf" opacity=".25"/>
|
|
689
|
+
</svg>`;
|
|
690
|
+
|
|
691
|
+
/** The styles a terminal can wear, in the order the window's menu lists them. terminal.js owns the palettes. */
|
|
692
|
+
const TERM_STYLES = [
|
|
693
|
+
['ubuntu', 'Ubuntu'],
|
|
694
|
+
['tango', 'Tango light'],
|
|
695
|
+
['solarized', 'Solarized dark'],
|
|
696
|
+
['dracula', 'Dracula'],
|
|
697
|
+
['dashboard', 'Dashboard style'],
|
|
698
|
+
];
|
|
699
|
+
|
|
700
|
+
/** What one `ps` line says about an automation browser. Split out from the
|
|
701
|
+
* listing so it can be tested against real command lines rather than only
|
|
702
|
+
* against whatever happens to be running. */
|
|
703
|
+
function parseBrowserLine(line) {
|
|
704
|
+
const m = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
|
705
|
+
if (!m) return null;
|
|
706
|
+
const args = m[3];
|
|
707
|
+
// The program itself has to BE a browser. Matching the arguments alone listed
|
|
708
|
+
// `ddev exec … BROWSER=chromium`, its docker-compose and the bash that runs
|
|
709
|
+
// the suite as three separate "browsers".
|
|
710
|
+
const prog = args.split(/\s+/)[0] || '';
|
|
711
|
+
const exe = prog.split('/').filter(Boolean).pop() || '';
|
|
712
|
+
// A browser in ANOTHER container is listed with its path only: this process
|
|
713
|
+
// cannot read that one's cmdline, and `ps` then prints the executable, often
|
|
714
|
+
// cut at a directory (…/chromium_headless_shell). So the whole path counts as
|
|
715
|
+
// evidence, not just the last segment.
|
|
716
|
+
const isBrowser = /^(google-)?chrome(-headless-shell)?$|^chromium(-browser)?$|^headless_shell$|^firefox$|^msedge$|webkit/i.test(exe)
|
|
717
|
+
|| /ms-playwright|headless[-_]shell/i.test(prog);
|
|
718
|
+
if (!isBrowser) return null;
|
|
719
|
+
if (/ --type=/.test(args)) return null; // a renderer, not the browser
|
|
720
|
+
if (!/remote-debugging-(port|pipe)|ms-playwright|mcp-chrome|playwright/i.test(args)) return null;
|
|
721
|
+
const dir = (args.match(/--user-data-dir=(\S+)/) || [])[1] || '';
|
|
722
|
+
return {
|
|
723
|
+
pid: m[1],
|
|
724
|
+
uptime: m[2],
|
|
725
|
+
profile: (dir.split('/').filter(Boolean).pop() || (/headless[-_]shell/i.test(prog) ? 'test browser' : 'playwright')).slice(0, 40),
|
|
726
|
+
// A headless shell has no window whatever its flags say, so the binary
|
|
727
|
+
// counts as much as --headless does.
|
|
728
|
+
headless: /--headless/.test(args) || /headless[-_]shell/i.test(prog),
|
|
729
|
+
port: (args.match(/--remote-debugging-port=(\d+)/) || [])[1] || '',
|
|
730
|
+
pipe: /--remote-debugging-pipe/.test(args),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Is this process inside a container? The dashboard shares the machine's pid
|
|
735
|
+
* namespace, so a suite's browser in a project's web container shows up in
|
|
736
|
+
* `ps` here exactly like one on the desktop — and it is not on the desktop. */
|
|
737
|
+
function processInContainer(pid) {
|
|
738
|
+
try {
|
|
739
|
+
const cgroup = fs.readFileSync(`/proc/${pid}/cgroup`, 'utf8');
|
|
740
|
+
return /docker|containerd|kubepods|libpod/.test(cgroup);
|
|
741
|
+
} catch (_) { return false; }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** The Playwright MCP browsers running on this machine, as tiles of their own. */
|
|
745
|
+
async function listPlaywrightBrowsers() {
|
|
746
|
+
// An automation browser, not any browser: one driven over a debugging pipe or
|
|
747
|
+
// port, or running from a playwright/mcp profile. The docs tooling's
|
|
748
|
+
// screenshot chromium is short-lived and matches none of those.
|
|
749
|
+
const out = await run('bash', ['-c',
|
|
750
|
+
"ps -eo pid,etime,args | grep -iE 'chrome|chromium|firefox|webkit' | grep -v grep | grep -v ' --type=' "
|
|
751
|
+
+ "| grep -E 'remote-debugging-(port|pipe)|ms-playwright|mcp-chrome|playwright' | head -10"],
|
|
752
|
+
process.env.HOME, { timeoutMs: 10 * 1000 });
|
|
753
|
+
const rows = String(out.stdout || '').split('\n').map(parseBrowserLine).filter(Boolean)
|
|
754
|
+
.map((b) => ({ ...b, inContainer: processInContainer(b.pid) }));
|
|
755
|
+
// One browser is several processes, and the helpers of a browser in another
|
|
756
|
+
// container cannot be told apart from it (their arguments are unreadable, so
|
|
757
|
+
// the usual `--type=` filter never sees them). Group them into the one thing
|
|
758
|
+
// they are, oldest first — that is the browser itself.
|
|
759
|
+
const groups = new Map();
|
|
760
|
+
for (const b of rows) {
|
|
761
|
+
const key = `${b.profile}|${b.headless}|${b.inContainer}`;
|
|
762
|
+
const seen = groups.get(key);
|
|
763
|
+
if (!seen) groups.set(key, { ...b, count: 1 });
|
|
764
|
+
else { seen.count += 1; if (b.uptime > seen.uptime) Object.assign(seen, b, { count: seen.count }); }
|
|
765
|
+
}
|
|
766
|
+
return [...groups.values()];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function screensGridHtml() {
|
|
770
|
+
const sessions = await listScreens();
|
|
771
|
+
const toolbar = `
|
|
772
|
+
<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-small screens-toolbar">
|
|
773
|
+
<span class="uk-text-meta">${sessions.length} session${sessions.length === 1 ? '' : 's'}</span>
|
|
774
|
+
<div>
|
|
775
|
+
<!-- One under another by default: a session row carries its name, its state, its log path and
|
|
776
|
+
five verbs, and two of those side by side on a 1400px screen leaves each one narrower than
|
|
777
|
+
the buttons it holds. Two and three across are for a machine with a dozen sessions on it,
|
|
778
|
+
where the question is "which of these is waiting" rather than "what does this one say". -->
|
|
779
|
+
<span class="screens-cols" role="group" aria-label="How many sessions across">
|
|
780
|
+
${[['1', 'list', 'One under another'], ['2', 'grid', 'Two across'], ['3', 'grid', 'Three across']]
|
|
781
|
+
.map(([n, icon, title]) => `<button type="button" class="uk-button uk-button-default uk-button-small"
|
|
782
|
+
data-screens-cols="${n}" title="${esc(title)}" aria-label="${esc(title)}"
|
|
783
|
+
aria-pressed="false">${n === '1' ? '<span uk-icon="icon: list; ratio: .7"></span>' : `<span uk-icon="icon: ${icon}; ratio: .7"></span> ${n}`}</button>`).join('')}
|
|
784
|
+
</span>
|
|
785
|
+
${newScreenButtonHtml()}
|
|
786
|
+
<button class="uk-button uk-button-default uk-button-small"
|
|
787
|
+
hx-get="/fragments/screens-grid" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
|
|
788
|
+
title="Refresh the list (screen -ls)"><span uk-icon="icon: refresh; ratio: .8"></span></button>
|
|
789
|
+
<button class="uk-button uk-button-default uk-button-small"
|
|
790
|
+
hx-post="/actions/screen-wipe" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
|
|
791
|
+
title="Remove dead sessions (screen -wipe)"><span uk-icon="icon: trash; ratio: .8"></span> Remove ended sessions</button>
|
|
792
|
+
</div>
|
|
793
|
+
</div>`;
|
|
794
|
+
const claudeHere = agentSessions(sessions);
|
|
795
|
+
const rows = sessions.map((s) => {
|
|
796
|
+
const log = screenLogFor(s.session);
|
|
797
|
+
const dead = /dead/i.test(s.state);
|
|
798
|
+
const attached = /attached/i.test(s.state);
|
|
799
|
+
const verb = (icon, title, attrs) => `
|
|
800
|
+
<button class="uk-button uk-button-default uk-button-small" title="${esc(title)}" ${attrs}>
|
|
801
|
+
<span uk-icon="icon: ${icon}; ratio: .8"></span></button>`;
|
|
802
|
+
return `
|
|
803
|
+
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small screen-tile${dead ? ' is-dead' : ''}${sessionWaitingWhy(s) ? ' is-waiting' : ''}">
|
|
804
|
+
<div class="uk-flex uk-flex-middle" style="gap:16px">
|
|
805
|
+
${TERM_SVG}
|
|
806
|
+
<div class="uk-flex-1">
|
|
807
|
+
<div><strong>${esc(s.session)}</strong>
|
|
808
|
+
<span class="uk-label ${dead ? 'uk-label-danger' : attached ? 'screen-state-attached' : 'uk-label-success'}">${esc((s.state || 'detached').toLowerCase())}</span>
|
|
809
|
+
${(() => {
|
|
810
|
+
// The one rule, in sessionWaitingWhy — the runner's reading of the visible screen
|
|
811
|
+
// first, the log only as the fallback for sessions that write one.
|
|
812
|
+
const why = sessionWaitingWhy(s);
|
|
813
|
+
if (!why) return '';
|
|
814
|
+
// "waiting on yes/no" after "Waiting for you" says waiting twice: keep what it is waiting for.
|
|
815
|
+
const ask = String(why).replace(/^waiting (on|for) /, '');
|
|
816
|
+
return `<span class="uk-label uk-label-warning needs-input" title="Read from its screen just now — open the terminal and answer it">✋ Waiting for you · ${esc(ask)}</span>`;
|
|
817
|
+
})()}
|
|
818
|
+
<span class="uk-text-meta">pid ${esc(s.pid || '?')} · since ${esc(s.started)}</span></div>
|
|
819
|
+
<div class="uk-text-meta">${log ? `log: <code>${esc(tildePath(log))}</code>` : 'no known log — the terminal still works'}
|
|
820
|
+
· on this machine — <code>screen -r ${esc(s.session)}</code> in your own terminal</div>
|
|
821
|
+
<div class="uk-margin-small-top uk-button-group-spaced">
|
|
822
|
+
<button class="uk-button uk-button-primary uk-button-small" type="button"
|
|
823
|
+
data-open-terminal="${esc(s.session)}"
|
|
824
|
+
title="Attach an interactive terminal (screen -x)"><span uk-icon="icon: laptop; ratio: .8"></span> Open terminal</button>
|
|
825
|
+
${log ? verb('play-circle', 'Follow the log (tail -F)',
|
|
826
|
+
`hx-post="/actions/screen-watch" hx-target="#editor-modal-body" hx-swap="innerHTML"
|
|
827
|
+
hx-vals='{"session":"${esc(s.session)}"}' hx-on::after:request="UIkit.modal('#editor-modal').show()"`) : ''}
|
|
828
|
+
${verb('sign-out', 'Detach every other client (screen -X detach)',
|
|
829
|
+
`hx-post="/actions/screen-detach" hx-target="#webship-workspace-output" hx-swap="innerHTML"
|
|
830
|
+
hx-vals='{"session":"${esc(s.session)}"}'`)}
|
|
831
|
+
${verb('bolt', 'Interrupt what runs inside it (Ctrl-C)',
|
|
832
|
+
`hx-post="/actions/screen-interrupt" hx-target="#webship-workspace-output" hx-swap="innerHTML"
|
|
833
|
+
hx-vals='{"session":"${esc(s.session)}"}'
|
|
834
|
+
hx-confirm="Send Ctrl-C to ${esc(s.session)}? Whatever runs inside it is interrupted."`)}
|
|
835
|
+
${claudeHere.has(s.session) && !dead ? `<button class="uk-button uk-button-default uk-button-small arm-step" type="button"
|
|
836
|
+
title="Exit Claude Code in ${esc(s.session)} (/exit)" hx-trigger="confirmed-remove"
|
|
837
|
+
hx-post="/actions/claude-exit" hx-target="#webship-workspace-output" hx-swap="innerHTML"
|
|
838
|
+
hx-vals='${JSON.stringify({ session: s.session, confirm: 'yes' })}'><span uk-icon="icon: sign-out; ratio: .8"></span> /exit</button>` : ''}
|
|
839
|
+
${verb('ban', 'Kill the session (screen -X quit)',
|
|
840
|
+
`hx-post="/actions/screen-stop" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
|
|
841
|
+
hx-vals='{"session":"${esc(s.session)}","confirm":"yes"}'
|
|
842
|
+
hx-confirm="Kill the screen session ${esc(s.session)}? Whatever runs inside it is killed."`)}
|
|
843
|
+
</div>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
</div>`;
|
|
847
|
+
}).join('');
|
|
848
|
+
// The browsers, listed the same way: what is open, how long, and how to see it.
|
|
849
|
+
const browsers = await listPlaywrightBrowsers();
|
|
850
|
+
const browserRows = !browsers.length ? '' : `
|
|
851
|
+
<h2 class="uk-h4 uk-margin-medium-top uk-margin-small-bottom" id="screens-browsers-title">Playwright browsers</h2>
|
|
852
|
+
<div class="screens-list" data-cols="1" role="group" aria-labelledby="screens-browsers-title">
|
|
853
|
+
${browsers.map((b) => `
|
|
854
|
+
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small screen-tile">
|
|
855
|
+
<div class="uk-flex uk-flex-middle" style="gap:16px">
|
|
856
|
+
<span uk-icon="icon: world; ratio: 1.6"></span>
|
|
857
|
+
<div class="uk-flex-1">
|
|
858
|
+
<div><strong>${esc(b.profile)}</strong>
|
|
859
|
+
<span class="uk-label uk-label-${b.headless || b.inContainer ? 'warning' : 'success'}">${b.headless ? 'headless' : b.inContainer ? 'in a container' : 'on your desktop'}</span>
|
|
860
|
+
<span class="uk-text-meta">pid ${esc(b.pid)} · up ${esc(b.uptime)}${b.count > 1 ? ` · ${b.count} processes` : ''}</span></div>
|
|
861
|
+
<div class="uk-text-meta">${b.port
|
|
862
|
+
? `DevTools on <a href="http://127.0.0.1:${esc(b.port)}" target="_blank">127.0.0.1:${esc(b.port)}</a>`
|
|
863
|
+
: 'Driven over a pipe, so it has no DevTools url'}${b.headless
|
|
864
|
+
? ' · no window at all; it writes its screenshots to a file'
|
|
865
|
+
: b.inContainer
|
|
866
|
+
? ' · it runs inside a container, so its window is not on the desktop'
|
|
867
|
+
: ' · its window is already open on this machine'}</div>
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
870
|
+
</div>`).join('')}
|
|
871
|
+
</div>`;
|
|
872
|
+
return toolbar
|
|
873
|
+
+ (sessions.length
|
|
874
|
+
? `<div class="screens-list" data-cols="1">${rows}</div>`
|
|
875
|
+
: '<div class="msg assistant">No screen sessions right now — start one above, or anything from cmd-tools-screen.sh and Jira "Work on it" appears here.</div>')
|
|
876
|
+
+ browserRows;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function screensListHtml() {
|
|
880
|
+
const sessions = await listScreens();
|
|
881
|
+
const rows = sessions.map((s) => {
|
|
882
|
+
const log = screenLogFor(s.session);
|
|
883
|
+
return `
|
|
884
|
+
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small">
|
|
885
|
+
<div>📺 <strong>${esc(s.session)}</strong> <span class="uk-label uk-label-success">running</span>
|
|
886
|
+
<span class="uk-text-meta">since ${esc(s.started)}</span></div>
|
|
887
|
+
<div class="uk-margin-small-top">
|
|
888
|
+
${log ? `<button class="uk-button uk-button-primary uk-button-small"
|
|
889
|
+
hx-post="/actions/screen-watch" hx-target="#editor-modal-body" hx-swap="innerHTML"
|
|
890
|
+
hx-vals='{"session":"${esc(s.session)}"}'><span uk-icon="icon: play-circle; ratio: .6"></span> Watch live</button>` : ''}
|
|
891
|
+
<button class="uk-button uk-button-danger uk-button-small"
|
|
892
|
+
hx-post="/actions/screen-stop" hx-target="#webship-workspace-output" hx-swap="innerHTML"
|
|
893
|
+
hx-vals='{"session":"${esc(s.session)}","confirm":"yes"}'
|
|
894
|
+
hx-confirm="Stop the screen session ${esc(s.session)}? Whatever runs inside it is killed."><span uk-icon="icon: close; ratio: .6"></span> Stop</button>
|
|
895
|
+
<span class="uk-text-meta">· reattach in a terminal: <code>ddev ssh</code> then <code>screen -r ${esc(s.session)}</code> (Ctrl-A D detaches, it keeps running)</span>
|
|
896
|
+
</div>
|
|
897
|
+
</div>`;
|
|
898
|
+
}).join('');
|
|
899
|
+
return sessions.length ? rows : '<div class="msg assistant">No screen sessions right now.</div>';
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
/* ------------------------------------------------------------------ *
|
|
904
|
+
* The terminal panes, and the dock that holds them
|
|
905
|
+
* ------------------------------------------------------------------ */
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* The commands that reach THIS session in a real terminal.
|
|
909
|
+
*
|
|
910
|
+
* The session runs on the machine, so these are the plain ones — and the follow command needs the real
|
|
911
|
+
* workspace rather than a placeholder nobody can paste.
|
|
912
|
+
*/
|
|
913
|
+
function termCliHintHtml(session) {
|
|
914
|
+
let ws = '';
|
|
915
|
+
try {
|
|
916
|
+
const dir = fs.readFileSync(path.join(ROOT, '.screens', `${session}.cwd`), 'utf8').trim();
|
|
917
|
+
const rel = path.relative(ROOT, dir);
|
|
918
|
+
if (rel && !rel.startsWith('..')) ws = rel.split(path.sep)[0];
|
|
919
|
+
} catch (_) { /* an unknown owner falls back below */ }
|
|
920
|
+
if (!ws) ws = (session.match(/^([a-z0-9_-]+)--/) || [])[1] || '';
|
|
921
|
+
const followDir = ws ? path.join(ROOT, ws) : ROOT;
|
|
922
|
+
return `<details class="uk-margin-small-bottom term-cli-hint">
|
|
923
|
+
<summary class="uk-text-meta">Same session in a real terminal</summary>
|
|
924
|
+
<div class="uk-text-meta">
|
|
925
|
+
<div><code>screen -r ${esc(session)}</code> <span>— attach it (<code>Ctrl-A D</code> detaches again, it keeps running)</span></div>
|
|
926
|
+
<div><code>screen -x ${esc(session)}</code> <span>— share it: the browser and the terminal see the same screen</span></div>
|
|
927
|
+
${ws ? `<div><code>cd ${esc(tildePath(followDir))} && bash cmd-tools-screen.sh ${esc(session)} --follow</code> <span>— follow its log instead</span></div>` : ''}
|
|
928
|
+
${(() => {
|
|
929
|
+
// The Claude Code session this folder is having, by id.
|
|
930
|
+
//
|
|
931
|
+
// It is LOCAL, and there is deliberately no link on it. A CLI session is a transcript in
|
|
932
|
+
// ~/.claude/projects on this machine: it has no page on claude.ai, and the claude.ai/code
|
|
933
|
+
// session ids that turn up in transcripts belong to cloud sessions, not to these. A link
|
|
934
|
+
// would either go nowhere or suggest the conversation is on the web when it is not — and
|
|
935
|
+
// these are client projects, so the id is something to resume with, never to publish.
|
|
936
|
+
//
|
|
937
|
+
// The NEWEST session for the folder, said as that rather than as "this session's": a screen
|
|
938
|
+
// started with `exec claude` does not record which conversation it ended up in, and claiming
|
|
939
|
+
// an id that might belong to another window would be worse than showing none.
|
|
940
|
+
let dir = '';
|
|
941
|
+
try { dir = fs.readFileSync(path.join(ROOT, '.screens', `${session}.cwd`), 'utf8').trim(); } catch (_) { return ''; }
|
|
942
|
+
let latest;
|
|
943
|
+
try { [latest] = require('./claude-sessions').sessionsFor(dir, { limit: 1 }); } catch (_) { return ''; }
|
|
944
|
+
if (!latest) return '';
|
|
945
|
+
return `<div><code class="claude-session-id">claude --resume ${esc(latest.id)}</code> <span>— the newest
|
|
946
|
+
Claude Code session in this folder, reopened here on this machine. A CLI session is a local
|
|
947
|
+
transcript; it has no page on claude.ai.</span></div>`;
|
|
948
|
+
})()}
|
|
949
|
+
</div>
|
|
950
|
+
</details>`;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* One terminal, as a window of its own.
|
|
955
|
+
*
|
|
956
|
+
* It carries the session's OWN verbs — interrupt, detach, kill — and not only the window's. They were
|
|
957
|
+
* on the screen row alone, which meant that acting on the session you were watching started by closing
|
|
958
|
+
* the terminal you were watching it in. Each goes through the dashboard's one confirm dialog, the way
|
|
959
|
+
* the row's do, and the two destructive ones say what they will end.
|
|
960
|
+
*
|
|
961
|
+
* Every id in here is a CLASS instead: several terminals are open at once, and ids made the second one
|
|
962
|
+
* drive the first one's status line and buttons — which is precisely why only one could ever be open.
|
|
963
|
+
*
|
|
964
|
+
* The geometry is not here. Where a window sits and how big it is belongs to the browser that put it
|
|
965
|
+
* there (public/terminal.js, localStorage), so the server renders the same markup whether this is the
|
|
966
|
+
* first terminal or the fifth.
|
|
967
|
+
*/
|
|
968
|
+
function termPaneHtml(session) {
|
|
969
|
+
const verb = (cls, icon, title, attrs = '', extra = '') => `<button type="button"
|
|
970
|
+
class="uk-button uk-button-default uk-button-small ${cls}" title="${esc(title)}"
|
|
971
|
+
aria-label="${esc(title)}"${attrs ? ` ${attrs}` : ''}>${
|
|
972
|
+
icon ? `<span uk-icon="icon: ${icon}; ratio: .7"></span>` : ''}${extra}</button>`;
|
|
973
|
+
const vals = (extra) => esc(JSON.stringify({ session, ...extra }));
|
|
974
|
+
return `
|
|
975
|
+
<section class="term-window" data-session="${esc(session)}" role="dialog" aria-label="Terminal — ${esc(session)}">
|
|
976
|
+
<header class="term-window-head">
|
|
977
|
+
<span class="term-title" title="${esc(session)}"><span uk-icon="icon: laptop; ratio: .7"></span> ${esc(session)}</span>
|
|
978
|
+
<span class="term-status uk-text-meta">connecting…</span>
|
|
979
|
+
<span class="term-verbs">
|
|
980
|
+
<!-- A button as well as the wheel: scrolling back is screen's copy mode, and nobody should
|
|
981
|
+
have to know that, let alone the chord for it. Pressing q leaves it, which the status
|
|
982
|
+
line says while it is open. No backticks in this comment: the function is one template
|
|
983
|
+
literal, and the repo has a test for exactly this. -->
|
|
984
|
+
${verb('term-scrollback', 'arrow-up', `Scroll back through what ${session} printed (q leaves it)`)}
|
|
985
|
+
<!-- The size, in columns and rows, which is how a terminal is measured: the same four the
|
|
986
|
+
GNOME/Ubuntu terminal offers. Reset — the menu entry and a double-click on the title bar
|
|
987
|
+
— goes to 132x43, which is also what a window opens at, clamped to the display if it is
|
|
988
|
+
too small to hold it. -->
|
|
989
|
+
<span class="uk-inline term-size-pick">
|
|
990
|
+
${verb('term-fit', 'cog', 'Size, text size and style of this window')}
|
|
991
|
+
<div uk-dropdown="mode: click; pos: bottom-right" class="term-look-menu"><ul class="uk-nav uk-dropdown-nav">
|
|
992
|
+
<li class="uk-nav-header">Size</li>
|
|
993
|
+
${[[80, 24], [80, 43], [132, 24], [132, 43]].map(([c, r]) =>
|
|
994
|
+
`<li><a href data-term-size="${c}x${r}">${c} × ${r}</a></li>`).join('')}
|
|
995
|
+
<li><a href data-term-size="fit">Reset to 132 × 43</a></li>
|
|
996
|
+
<li class="uk-nav-divider"></li>
|
|
997
|
+
<li class="uk-nav-header">Text size</li>
|
|
998
|
+
<li class="term-font-row">
|
|
999
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" data-term-font="down" aria-label="Smaller text" title="Smaller (Ctrl and minus)"><span uk-icon="icon: minus; ratio: .7"></span></button>
|
|
1000
|
+
<span class="term-font-size" aria-live="polite">17 px</span>
|
|
1001
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" data-term-font="up" aria-label="Larger text" title="Larger (Ctrl and plus)"><span uk-icon="icon: plus; ratio: .7"></span></button>
|
|
1002
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" data-term-font="reset" title="Back to the default (Ctrl and 0)">Reset</button>
|
|
1003
|
+
</li>
|
|
1004
|
+
<li class="uk-nav-divider"></li>
|
|
1005
|
+
<li class="uk-nav-header">Style</li>
|
|
1006
|
+
${TERM_STYLES.map(([id, label]) =>
|
|
1007
|
+
`<li><a href role="menuitemradio" aria-checked="false" data-term-style="${id}"><span class="term-swatch" aria-hidden="true"></span> ${label}</a></li>`).join('')}
|
|
1008
|
+
</ul></div>
|
|
1009
|
+
</span>
|
|
1010
|
+
${verb('term-reconnect', 'refresh', 'Reconnect this terminal',
|
|
1011
|
+
`hidden hx-get="/fragments/screen-term/${encodeURIComponent(session)}" hx-target="closest .term-window" hx-swap="outerHTML"`)}
|
|
1012
|
+
<!-- Ctrl-C goes down THIS window's own socket, not to /actions/screen-interrupt.
|
|
1013
|
+
onHostScreen says it plainly — "detach and Ctrl-C are screen's own; the runner does not
|
|
1014
|
+
proxy them yet" — so that endpoint answers 500 for a session on the host, which is every
|
|
1015
|
+
session here. A window is already attached to the session, and sending the byte is
|
|
1016
|
+
exactly what typing it would do. terminal.js does it, after the same confirm. -->
|
|
1017
|
+
${verb('term-interrupt arm-step', 'bolt', `Interrupt what runs in ${session} (Ctrl-C)`)}
|
|
1018
|
+
<!-- The wheel writing mouse coordinates into the shell instead of scrolling is a mode a
|
|
1019
|
+
program turned on and did not turn off: screen replays the window's state to every
|
|
1020
|
+
display that attaches, so it follows the session into a real terminal too. Nothing on
|
|
1021
|
+
screen can say so, which is why there is a button for it. It types a printf at the
|
|
1022
|
+
prompt (the only way to reach a window's terminal), so it asks first, like the other
|
|
1023
|
+
two verbs that do. No backticks in this comment: the function is one template literal,
|
|
1024
|
+
and the repo has a test for exactly this. -->
|
|
1025
|
+
${verb('term-reset-modes arm-step', 'crosshairs', `Clear the mouse and paste modes in ${session} — the fix when the wheel writes characters instead of scrolling`,
|
|
1026
|
+
`hx-post="/actions/screen-reset-modes" hx-vals='${vals({ confirm: 'yes' })}' hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-trigger="confirmed-remove"`)}
|
|
1027
|
+
${verb('term-detach arm-step', 'sign-out', `Detach every other client from ${session}`,
|
|
1028
|
+
`hx-post="/actions/screen-detach" hx-vals='${vals({})}' hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-trigger="confirmed-remove"`)}
|
|
1029
|
+
${verb('term-kill arm-step', 'ban', `Kill the session ${session}`,
|
|
1030
|
+
`hx-post="/actions/screen-stop" hx-vals='${vals({ confirm: 'yes' })}' hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-trigger="confirmed-remove"`)}
|
|
1031
|
+
<span class="term-verbs-sep" aria-hidden="true"></span>
|
|
1032
|
+
${verb('term-fullscreen', 'expand', 'Fill the screen (Esc to come back)')}
|
|
1033
|
+
<a class="uk-button uk-button-default uk-button-small term-newtab" title="Open this session in its own tab"
|
|
1034
|
+
aria-label="Open this session in its own tab"
|
|
1035
|
+
href="/terminal/${encodeURIComponent(session)}" target="_blank"><span uk-icon="icon: link-external; ratio: .7"></span></a>
|
|
1036
|
+
${verb('term-close', 'close', `Close this terminal (${session} keeps running)`)}
|
|
1037
|
+
</span>
|
|
1038
|
+
</header>
|
|
1039
|
+
<div class="term-screen"></div>
|
|
1040
|
+
${termCliHintHtml(session)}
|
|
1041
|
+
<!-- The corner grip. The CSS resize property needs a scroll container and drags the element's own
|
|
1042
|
+
box, which fights an absolutely placed window, so the drag is done in terminal.js and this is
|
|
1043
|
+
its handle. No backticks in this comment: the function is one template literal. -->
|
|
1044
|
+
<span class="term-resize" title="Drag to resize" aria-hidden="true"></span>
|
|
1045
|
+
</section>`;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* The layer every terminal window floats in.
|
|
1050
|
+
*
|
|
1051
|
+
* Empty in the page shell: `public/terminal.js` fills it as terminals are opened, and the bar that
|
|
1052
|
+
* counts them and arranges them is drawn there too — it exists only while something is open, and a
|
|
1053
|
+
* server-rendered one would be an empty strip on every page of the dashboard.
|
|
1054
|
+
*/
|
|
1055
|
+
function termLayerHtml(sessions = []) {
|
|
1056
|
+
return `<div id="term-windows">${sessions.map(termPaneHtml).join('')}</div>`;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
module.exports = {
|
|
1060
|
+
TERM_STYLES,
|
|
1061
|
+
termPaneHtml, termLayerHtml, termCliHintHtml,
|
|
1062
|
+
claudeResumeHtml,
|
|
1063
|
+
waitingState, sessionWaitingWhy, agentSessions, activityByDir, activityFromSessions, listScreens, listContainerScreens, listPlaywrightBrowsers, parseBrowserLine, processInContainer, sessionNeedsInput, screensForDir, screenLogFor, screensListHtml, screensGridHtml, screenTailHtml, screenTargets, screenTargetDir, newScreenFormHtml, newScreenButtonHtml, screenWhereHtml, workspaceOptionsHtml, workspaceOfTarget, targetOptionsHtml, defaultSessionName, claudeArgsHtml, promptTemplates, promptTemplateText };
|