From 062e57c1e0ed46257a23e078a9bf6dfc6b2cbb20 Mon Sep 17 00:00:00 2001 From: wyattb Date: Mon, 12 Oct 2026 15:30:58 -0400 Subject: [PATCH 1/7] /ws --web, or the pane's Open in browser, opens the same cards in a browser tab: web/server.py serves them on 127.0.0.1 for the session (token in the URL, local Host, JSON POST to switch) through switch.sh; tests/e2e.sh runs the real server --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 3 +- README.md | 2 +- docs/design.md | 7 +- .../claude/default/.claude-plugin/plugin.json | 2 +- .../plugins/claude/default/hooks/register.tsx | 77 +++++++- .../plugins/claude/default/tests/ws.test.ts | 82 +++++++- .../plugins/claude/default/web/index.html | 178 ++++++++++++++++++ software/plugins/claude/default/web/server.py | 131 +++++++++++++ .../.claude/skills/switch-workspace/SKILL.md | 2 +- tests/e2e.sh | 25 +++ 11 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 software/plugins/claude/default/web/index.html create mode 100644 software/plugins/claude/default/web/server.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 520b9e9..81be480 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ { "name": "delphi", "source": "./software/plugins/claude/default", - "description": "Delphi's mod for every workspace: /ws opens a pane of workspace cards grouped by project; select one, then Switch workspace (.delphi/switch.sh)." + "description": "Delphi's mod for every workspace: /ws opens a pane of workspace cards grouped by project; select one, then Switch workspace (.delphi/switch.sh). /ws --web opens the same cards in a browser tab." } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 9092c3c..b668684 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,8 @@ source of truth; `docs/goals.md` lists what any change must keep. `.github/workflows/delphi.yml` every workspace carries unchanged. The general `new-worktree`, `switch-workspace`, `setup-workspace`, and `journal` skills are in `software/workspace-inherit/`. - `software/plugins/claude/default/`: the `delphi` plugin set, a mod whose `/ws` pane switches - workspaces (`hooks/register.tsx`, tests in `tests/ws.test.ts`, run by `claude plugin test`). + workspaces (`hooks/register.tsx`, tests in `tests/ws.test.ts`, run by `claude plugin test`); + `/ws --web` serves the same cards in a browser tab (`web/server.py`, `web/index.html`). - `software/…/workspaces//`: the workspaces. Org structure is plain directories. A project folder (the one holding `workspaces/`) may add `README.md`, `AUTHORING.md`, and `workspace-defaults/` (copied once into a new workspace there). Any folder may add `workspace-inherit/` (written onto the diff --git a/README.md b/README.md index 8141ea4..7a9561c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ histories stay joined and edits on either side meet in normal three-way merges. git clone -b ws/software/application-software/argos/argos-dev https://github.com/Northeastern-Electric-Racing/Delphi.git delphi cd delphi && .delphi/setup.sh # repos/ stores + default-branch worktrees in worktrees/ (git-ignored) .delphi/switch.sh nero-experimental # switch freely (or to main; the full name works too): the hook parks them in .delphi/parked/ -claude plugin install delphi@delphi --scope user # then /ws in Claude Code: a pane to keep open that switches workspaces +claude plugin install delphi@delphi --scope user # then /ws in Claude Code: a pane to keep open that switches workspaces (/ws --web: in a browser) git switch -c my-change # edit, commit, push, open a PR into ws/software/application-software/argos/argos-dev git fetch origin && git merge origin/ws/software/application-software/argos/argos-dev # update your branch any time ``` diff --git a/docs/design.md b/docs/design.md index 0c3bb4b..26ed639 100644 --- a/docs/design.md +++ b/docs/design.md @@ -232,7 +232,12 @@ marked current, with a warning when tracked files have changed; pressing a card **Switch workspace** runs `switch.sh` (`/ws ` skips the pane); since a session keeps the `CLAUDE.md` and skills it loaded, it then asks for a restart. All the git work stays in `switch.sh`, which `tests/e2e.sh` covers; the mod's own tests (`tests/ws.test.ts`, `claude plugin test`) stub its processes, and `tests/e2e.sh` runs them when a -real `claude` is installed. The inherited `switch-workspace` skill makes it +real `claude` is installed. Optionally the same cards open in a browser tab: `/ws --web`, or the pane's +**Open in browser**, starts `web/server.py` (Python 3 stdlib) for the session on `127.0.0.1` at a +random port; it answers only requests carrying the token in its URL with a local `Host`, switches +only on a JSON `POST` (so no other page the browser has open can switch the clone), runs the same +`switch.sh`, and prints each switch, which the mod reads to update its pane. `tests/e2e.sh` runs the +real server against a sandbox clone. The inherited `switch-workspace` skill makes it the way to work in another workspace; its `setup-workspace` skill covers `workspace.yml` (repos, references, links) and re-running setup. diff --git a/software/plugins/claude/default/.claude-plugin/plugin.json b/software/plugins/claude/default/.claude-plugin/plugin.json index 369615e..aca013d 100644 --- a/software/plugins/claude/default/.claude-plugin/plugin.json +++ b/software/plugins/claude/default/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "delphi", - "description": "Delphi's mod for every workspace: /ws opens a pane of workspace cards grouped by project; select one, then Switch workspace (.delphi/switch.sh).", + "description": "Delphi's mod for every workspace: /ws opens a pane of workspace cards grouped by project; select one, then Switch workspace (.delphi/switch.sh). /ws --web opens the same cards in a browser tab.", "author": { "name": "Northeastern Electric Racing" } diff --git a/software/plugins/claude/default/hooks/register.tsx b/software/plugins/claude/default/hooks/register.tsx index cfe7f99..f433fd0 100644 --- a/software/plugins/claude/default/hooks/register.tsx +++ b/software/plugins/claude/default/hooks/register.tsx @@ -3,6 +3,8 @@ // runs switch.sh; /ws switches straight away. A switch changes CLAUDE.md and the skills on // disk, but this session keeps the ones it loaded, so after one the pane and the line under the // prompt ask for a restart. All the git work is switch.sh's; outside a Delphi clone /ws says so. +// /ws --web (or the pane's Open in browser) starts web/server.py for the session: the same cards in +// a browser tab, served on 127.0.0.1; a switch made there shows up here too. import type { EngineInterface, Register } from 'claude-code' // One Delphi workspace, or main: its name (switch.sh takes it), last part, and the folders above it @@ -16,6 +18,9 @@ let isDirty = false // tracked files have changes, so switch.sh would refuse let selected = '' let phase: Phase = 'pick' let message = '' +let webUrl = '' // the browser view's address while web/server.py runs +let webError = '' // why it isn't running, when it failed to +let webStart: Promise | null = null function toWs(name: string): Ws { if (name === 'main') return { name, leaf: 'main', group: 'project folders, templates, CI' } @@ -76,6 +81,60 @@ async function doSwitch($: EngineInterface) { await load($) } +// A switch made in the browser view: the pane and the line under the prompt say so too +async function switchedInBrowser($: EngineInterface, name: string) { + phase = 'done' + message = '' + await $.ui.status('switched to ' + leafOf(name) + '; restart Claude Code in this folder') + await load($) +} + +// Starts web/server.py for the rest of the session, once; resolves its URL, or '' with webError set. +// The loop is the server's life: it ends with the server, or kills it when the module unloads. +function startWeb($: EngineInterface): Promise { + if (webStart) return webStart + webError = '' + webStart = new Promise(resolve => { + void (async () => { + let pending = '' + try { + const server = $.process.spawn({ argv: ['python3', $.plugin.root + '/web/server.py', root], cwd: root }) + for await (const { stream, text } of server) { + if (stream === 'stderr') { + $.ui.log(text.trimEnd(), { to: 'debug' }) + webError = 'The browser view stopped: ' + (text.trim().split('\n').pop() ?? '') + continue + } + const lines = (pending + text).split('\n') + pending = lines.pop() ?? '' + for (const line of lines) { + if (!webUrl && line.startsWith('http://')) { + webUrl = line + resolve(line) + $.ui.invalidate('ui.render') + } else if (line.startsWith('switched ')) await switchedInBrowser($, line.slice('switched '.length)) + } + } + } catch (err) { + webError = 'No browser view (it needs python3): ' + String((err as Error)?.message ?? err) + } + webUrl = '' + webStart = null + resolve('') + $.ui.invalidate('ui.render') + })() + }) + return webStart +} + +// Starts the browser view if it isn't running and opens it in the default browser +async function openWeb($: EngineInterface): Promise { + const url = await startWeb($) + if (url) await run($, ['python3', '-m', 'webbrowser', '-t', url], 30_000) + $.ui.invalidate('ui.render') + return url +} + const PANE = 'delphi-workspaces' export const register: Register = on => { @@ -84,7 +143,7 @@ export const register: Register = on => { await $.command.register({ name: 'ws', description: 'Delphi: open the workspace switcher, or switch this clone to a workspace', - argumentHint: '[workspace]', + argumentHint: '[workspace | --web]', }) } catch (err) { await $.ui.log('/ws not added: ' + String((err as Error)?.message ?? err)) @@ -97,6 +156,10 @@ export const register: Register = on => { phase = 'pick' message = '' const name = (e.args ?? '').trim() + if (name === '--web') { + const url = await openWeb($) + return { text: url ? 'Browser view: ' + url + ' (it runs until this session ends)' : webError } + } if (name) { await load($) selected = name @@ -111,7 +174,7 @@ export const register: Register = on => { }) on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { - const { Box, Text, Button } = $.ui.resolve(e) + const { Box, Text, Button, Link } = $.ui.resolve(e) const cur = toWs(here) const sel = all.find(w => w.name === selected) const groups = [...new Set(all.map(w => w.group))] @@ -162,6 +225,9 @@ export const register: Register = on => { + )} @@ -184,6 +250,13 @@ export const register: Register = on => { )} + {webUrl && ( + + Browser view + + + )} + {!webUrl && webError && {webError}} ) }) diff --git a/software/plugins/claude/default/tests/ws.test.ts b/software/plugins/claude/default/tests/ws.test.ts index 8722df4..4db75c5 100644 --- a/software/plugins/claude/default/tests/ws.test.ts +++ b/software/plugins/claude/default/tests/ws.test.ts @@ -1,6 +1,7 @@ // Tests for the delphi mod's /ws (claude plugin test; tests/e2e.sh runs them). A fake clone answers // the mod's processes: git for the clone's top and its changes, .delphi/switch.sh for the list and -// the switch, so no git or switch runs. +// the switch, web/server.py and the browser it opens, so no git, switch or server runs (tests/e2e.sh +// runs the real server). import type { On } from 'claude-code' import { expect, test } from 'claude-code/testing' @@ -30,11 +31,26 @@ const done = (exitCode: number, stdout = '', stderr = '') => ({ value: { exitCode, stdout, stderr, isStdoutTruncated: false, isStderrTruncated: false }, }) -type Clone = { here: string; isDirty: boolean; isDelphi: boolean; switches: { name: string; timeoutMs?: number }[]; status: string[] } +const URL = 'http://127.0.0.1:8765/token/' + +type Clone = { + here: string + isDirty: boolean + isDelphi: boolean + switches: { name: string; timeoutMs?: number }[] + status: string[] + servers: string[][] // each web/server.py started, its argv + opened: string[] // the URLs opened in a browser + browser: (name: string) => void // the browser view switches to name ('' stops the server) + seen: Promise // once the mod has read what the server printed after that +} // Stubs every call the mod makes, answered from a clone on `here`; returns it to inspect -function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true): Clone { - const c: Clone = { here, isDirty, isDelphi, switches: [], status: [] } +function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true, hasPython = true): Clone { + let browse = (name: string) => {} + let seen = () => {} + const switched = new Promise(r => (browse = r)) + const c: Clone = { here, isDirty, isDelphi, switches: [], status: [], servers: [], opened: [], browser: n => browse(n), seen: new Promise(r => (seen = r)) } on('session.start', () => ({ cwd: ROOT })) on('command.register', ($, e) => ({ value: { command: e.name } })) on('ui.open', () => ({ value: { isPlaced: true } })) @@ -52,6 +68,10 @@ function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true): Clon const out = ['main', ARGOS, BMS].map((n) => (n === c.here ? '* ' : ' ') + n).join('\n') + '\n' return done(0, out) } + if (cmd === 'python3' && a1 === '-m' && a2 === 'webbrowser') { + c.opened.push(e.argv[e.argv.length - 1] ?? '') + return done(0) + } if (cmd === 'bash' && a1 === SWITCH) { c.switches.push(e.init?.timeoutMs === undefined ? { name: a2 } : { name: a2, timeoutMs: e.init.timeoutMs }) if (c.isDirty) return done(1, '', 'switch: commit or set aside your changes first (git status)\n') @@ -60,6 +80,18 @@ function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true): Clon } return { deny: 'unexpected process: ' + e.argv.join(' ') } }) + on('process.spawn', async function* ($, e) { + c.servers.push([...e.argv]) + if (!hasPython) throw new Error('spawn python3 ENOENT') + yield { stream: 'stdout' as const, text: URL + '\n' } + const name = await switched + if (name) { + c.here = BMS + yield { stream: 'stdout' as const, text: 'switched ' + name + '\n' } + } + seen() + return { value: { code: 0, signal: null } } + }) return c } @@ -127,3 +159,45 @@ test('outside a Delphi clone, /ws says so and runs nothing', async ($, on) => { expect(answer.text).toMatch(/^Not in a Delphi clone/) expect(c.switches).toEqual([]) }) + +test('Open in browser starts web/server.py once for the clone, opens it, and links it in the pane', async ($, on) => { + const c = fakeClone(on) + await $.command.run(ws('')) + const ui = await $.ui.mount(PANE) + await ui.press({ key: 'web' }) + await ui.press({ key: 'web' }) + expect(c.servers.length).toBe(1) + expect(c.servers[0]?.[0]).toBe('python3') + expect(c.servers[0]?.[1]).toMatch(/\/web\/server\.py$/) + expect(c.servers[0]?.[2]).toBe(ROOT) + expect(c.opened).toEqual([URL, URL]) + expect(await ui.find({ type: 'Link' })).toMatchObject({ props: { href: URL } }) + c.browser('') + await c.seen +}) + +test('a switch made in the browser view shows in the pane and under the prompt', async ($, on) => { + const c = fakeClone(on) + await $.command.run(ws('')) + const ui = await $.ui.mount(PANE) + await ui.press({ key: 'web' }) + c.browser(BMS) + await c.seen + expect(c.status).toEqual(['switched to bms-dev; restart Claude Code in this folder']) + expect(await ui.find({ type: 'Text', text: '✓ Now on bms-dev' })).toBeDefined() +}) + +test('/ws --web answers with the URL', async ($, on) => { + const c = fakeClone(on) + expect((await $.command.run(ws('--web'))).text).toMatch(/^Browser view: http:\/\/127\.0\.0\.1:8765\/token\//) + expect(c.opened).toEqual([URL]) + expect(c.switches).toEqual([]) + c.browser('') + await c.seen +}) + +test('without python3, /ws --web says it needs it and opens nothing', async ($, on) => { + const c = fakeClone(on, ARGOS, false, true, false) + expect((await $.command.run(ws('--web'))).text).toMatch(/^No browser view \(it needs python3\)/) + expect(c.opened).toEqual([]) +}) diff --git a/software/plugins/claude/default/web/index.html b/software/plugins/claude/default/web/index.html new file mode 100644 index 0000000..f1b02e0 --- /dev/null +++ b/software/plugins/claude/default/web/index.html @@ -0,0 +1,178 @@ + + + + + + +Delphi workspaces + + + +
+
+

Delphi workspaces

+
+ + + +
+
+

Loading…

+
+
+ + + diff --git a/software/plugins/claude/default/web/server.py b/software/plugins/claude/default/web/server.py new file mode 100644 index 0000000..aa587e7 --- /dev/null +++ b/software/plugins/claude/default/web/server.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""The delphi mod's browser view of /ws: python3 web/server.py serves index.html (the same +cards: select one, then Switch workspace) on 127.0.0.1 at a random port, and prints its URL, the +first line on stdout. The page asks it for .delphi/switch.sh --list and runs switch.sh ; each +switch prints `switched `, which the mod reads to update its pane. The mod starts it with +/ws --web (or the pane's Open in browser) and stops it with the session; by hand, Ctrl-C ends it. + +Only this machine reaches it, and only a page holding the URL's token: any web page the browser has +open can send requests to 127.0.0.1, so every request needs the token in its path and a Host of +127.0.0.1 or localhost (against DNS rebinding), and a switch needs a JSON body (which a page of +another origin can't send without a preflight, which this answers with nothing). Python 3.7+, stdlib. +""" +import http.server +import json +import secrets +import subprocess +import sys +import threading +from pathlib import Path + +root = Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve() +switch = root / '.delphi' / 'switch.sh' +if not switch.is_file(): + sys.exit('server: no .delphi/switch.sh in ' + str(root) + '; give a Delphi clone') +token = secrets.token_urlsafe(18) +page = (Path(__file__).parent / 'index.html').read_bytes() +busy = threading.Lock() # one switch at a time +switching = '' # the workspace being switched to, while one runs + + +def run(argv, timeout): + try: + return subprocess.run(argv, cwd=root, capture_output=True, text=True, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as err: + return subprocess.CompletedProcess(argv, -1, '', str(err)) + + +def last_line(text, otherwise): + lines = [line for line in text.strip().splitlines() if line.strip()] + return lines[-1] if lines else otherwise + + +def state(): + listed = run(['bash', str(switch), '--list'], 60) + lines = [line for line in listed.stdout.splitlines() if line.strip()] + return { + 'here': next((line[2:] for line in lines if line.startswith('* ')), ''), + 'workspaces': [line[2:] for line in lines], + 'isDirty': run(['git', 'diff', '--quiet', 'HEAD', '--'], 10).returncode != 0, + 'switching': switching, + 'error': '' if listed.returncode == 0 else 'Could not list the workspaces: ' + last_line(listed.stderr, 'switch.sh --list failed'), + } + + +def do_switch(name): + global switching + if not busy.acquire(blocking=False): + return 409, {'ok': False, 'message': 'a switch to ' + switching + ' is running'} + try: + switching = name + done = run(['bash', str(switch), name], 600) + finally: + switching = '' + busy.release() + if done.returncode != 0: + return 200, {'ok': False, 'message': last_line(done.stderr, 'switch.sh failed')} + print('switched ' + name, flush=True) + return 200, {'ok': True, 'message': last_line(done.stdout, 'switched')} + + +class Handler(http.server.BaseHTTPRequestHandler): + def send(self, status, body, kind): + self.send_response(status) + self.send_header('Content-Type', kind) + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-store') + self.send_header('Referrer-Policy', 'no-referrer') # the token stays in this page + self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'") + self.end_headers() + self.wfile.write(body) + + def json(self, status, value): + self.send(status, json.dumps(value).encode(), 'application/json') + + def route(self): + """The path after the token, or None (and the refusal sent) for a request not from the page.""" + port = self.server.server_address[1] + if self.headers.get('Host') not in ('127.0.0.1:%d' % port, 'localhost:%d' % port): + self.send(403, b'forbidden\n', 'text/plain') + return None + prefix = '/' + token + '/' + if not self.path.split('?')[0].startswith(prefix): + self.send(404, b'not found\n', 'text/plain') + return None + return self.path.split('?')[0][len(prefix):] + + def do_GET(self): + path = self.route() + if path == '': + self.send(200, page, 'text/html; charset=utf-8') + elif path == 'api/state': + self.json(200, state()) + elif path is not None: + self.send(404, b'not found\n', 'text/plain') + + def do_POST(self): + path = self.route() + if path is None: + return + if path != 'api/switch': + return self.send(404, b'not found\n', 'text/plain') + if self.headers.get('Content-Type', '').split(';')[0].strip() != 'application/json': + return self.json(415, {'ok': False, 'message': 'send JSON'}) + try: + name = json.loads(self.rfile.read(int(self.headers.get('Content-Length') or 0)) or b'{}').get('name', '') + except (ValueError, AttributeError): + name = '' + if not isinstance(name, str) or not name: + return self.json(400, {'ok': False, 'message': 'no workspace named'}) + self.json(*do_switch(name)) + + def log_message(self, format, *args): # quiet: stderr is the mod's debug log + pass + + +server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) +print('http://127.0.0.1:%d/%s/' % (server.server_address[1], token), flush=True) +try: + server.serve_forever() +except KeyboardInterrupt: + pass diff --git a/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md b/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md index 9b87678..a93b590 100644 --- a/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md +++ b/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md @@ -11,7 +11,7 @@ bash .delphi/switch.sh argos-dev # its last part alone, if only one workspac bash .delphi/switch.sh main # Delphi's main: project folders, templates, CI ``` -- The user can also switch from the `/ws` pane (the `delphi` plugin's mod), which shows them as cards (`bash .delphi/switch.sh --list`): press one, then Switch workspace; `/ws ` switches straight away. +- The user can also switch from the `/ws` pane (the `delphi` plugin's mod), which shows them as cards (`bash .delphi/switch.sh --list`): press one, then Switch workspace; `/ws ` switches straight away, and `/ws --web` opens the same cards in a browser tab. - List workspaces: `git branch -r --list 'origin/ws/*'`. A workspace's name is its folder path on main without `workspaces/`. - It refuses while tracked files have changes: commit them on a branch (and PR them) first. Then it fetches, switches, fast-forwards, and runs the new workspace's `.delphi/setup.sh`. - The post-checkout hook parks this workspace's `repos/`, `worktrees/`, `references/` and `linked/` in `.delphi/parked//` (git-ignored; `` as in its `workspace.yml`) and brings the other's back at the same paths, so worktrees and the work in them return as they were. Switch back the same way. Never run `git clean -X` or `git stash --all` at the workspace root: they take ignored files, parked work included. diff --git a/tests/e2e.sh b/tests/e2e.sh index 7dc5017..cab97fc 100644 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -608,6 +608,31 @@ else echo "skip - the delphi mod's tests: no claude on PATH" fi +# --- 12d. the mod's browser view (web/server.py): serves the page and the list only with its token and a local +# Host, switches only on a JSON POST, through switch.sh, and says so on stdout for the mod +if command -v python3 >/dev/null && command -v curl >/dev/null; then + python3 "$mod/web/server.py" "$w" >"$t/web.out" 2>"$t/web.err" & + web=$! + for _ in $(seq 50); do [ -s "$t/web.out" ] && break; sleep 0.1; done + url=$(head -1 "$t/web.out") port=${url#http://127.0.0.1:} port=${port%%/*} + get() { curl -s -o /dev/null -w '%{http_code}' "$@"; } + { [ "$(get "$url")" = 200 ] && curl -s "$url" | grep -q "Delphi workspaces" && + [ "$(get "http://127.0.0.1:$port/")" = 404 ] && [ "$(get "http://127.0.0.1:$port/wrong/api/state")" = 404 ] && + [ "$(get -H "Host: evil.example:$port" "${url}api/state")" = 403 ]; } || fail "web/server.py let a request without its token or a local Host through" + curl -s "${url}api/state" | python3 -c 'import json, sys; s = json.load(sys.stdin); sys.exit(not (s["here"] == sys.argv[1] and s["workspaces"] == ["main", sys.argv[1], sys.argv[2]] and not s["isDirty"]))' "$AN" "$BN" || + fail "web/server.py api/state: $(curl -s "${url}api/state")" + { [ "$(get -X POST -d '{"name":"bms-dev"}' "${url}api/switch")" = 415 ] && [ "$(git -C "$w" branch --show-current)" = "ws/$AN" ]; } || + fail "web/server.py switched without a JSON body" + { curl -s -X POST -H 'Content-Type: application/json' -d '{"name":"bms-dev"}' "${url}api/switch" | grep -q '"ok": true' && + [ "$(git -C "$w" branch --show-current)" = "ws/$BN" ] && grep -qx "switched bms-dev" "$t/web.out"; } || + fail "web/server.py switch to bms-dev: $(cat "$t/web.out" "$t/web.err")" + kill "$web" && wait "$web" 2>/dev/null || true + git -C "$w" switch -q "ws/$AN" + ok "the browser view answers only its own page and switches through switch.sh, printing what it switched to" +else + echo "skip - the delphi mod's browser view: no python3 or curl" +fi + # --- 14. named by path: a workspace's old ws/ branch carries over into ws/; moving a clone onto the # renamed branch moves nothing; 0.1.0 renames what the clone keeps under old names C=software/electrical/workspaces/vcu-dev CN=software/electrical/vcu-dev D=software/electrical/workspaces/hv-dev DN=software/electrical/hv-dev From 6e6d5fe57e71252513dce126abaa99d69b9b5c9d Mon Sep 17 00:00:00 2001 From: wyattb Date: Mon, 12 Oct 2026 15:55:52 -0400 Subject: [PATCH 2/7] /ws pane and browser view: Refresh sits at the bottom in every state but switching and says when it last fetched from origin; a failed refresh keeps the last list --- .../plugins/claude/default/hooks/register.tsx | 36 ++++++++++++++----- .../plugins/claude/default/tests/ws.test.ts | 23 +++++++++++- .../plugins/claude/default/web/index.html | 17 +++++++-- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/software/plugins/claude/default/hooks/register.tsx b/software/plugins/claude/default/hooks/register.tsx index f433fd0..e7d3444 100644 --- a/software/plugins/claude/default/hooks/register.tsx +++ b/software/plugins/claude/default/hooks/register.tsx @@ -18,6 +18,9 @@ let isDirty = false // tracked files have changes, so switch.sh would refuse let selected = '' let phase: Phase = 'pick' let message = '' +let isRefreshing = false // switch.sh --list is fetching +let fetchedAt = '' // when it last listed, HH:MM +let listError = '' // why it couldn't, if it couldn't let webUrl = '' // the browser view's address while web/server.py runs let webError = '' // why it isn't running, when it failed to let webStart: Promise | null = null @@ -52,12 +55,20 @@ async function findRoot($: EngineInterface): Promise { return root !== '' } +const hhmm = (d: Date) => String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + async function load($: EngineInterface) { + isRefreshing = true + $.ui.invalidate('ui.render') const list = await run($, ['bash', root + '/.delphi/switch.sh', '--list'], 60_000) const lines = list.stdout.split('\n').filter(l => l.trim() !== '') - if (list.exitCode !== 0) message = 'Could not list the workspaces: ' + list.stderr.trim() - all = lines.map(l => toWs(l.slice(2))) - here = lines.find(l => l.startsWith('* '))?.slice(2) ?? '' + listError = list.exitCode === 0 ? '' : 'Could not list the workspaces: ' + (list.stderr.trim().split('\n').pop() ?? '') + fetchedAt = hhmm(new Date()) + isRefreshing = false + if (list.exitCode === 0 || all.length === 0) { // a failed refresh keeps the last list + all = lines.map(l => toWs(l.slice(2))) + here = lines.find(l => l.startsWith('* '))?.slice(2) ?? '' + } isDirty = (await run($, ['git', 'diff', '--quiet', 'HEAD', '--'], 10_000)).exitCode !== 0 if (!all.some(w => w.name === selected) || (selected === here && phase === 'pick')) { selected = all.find(w => w.name !== here)?.name ?? '' @@ -222,12 +233,6 @@ export const register: Register = on => { - - )} @@ -250,6 +255,19 @@ export const register: Register = on => { )} + {phase !== 'switching' && ( + + + + {isRefreshing && Fetching the workspaces from origin…} + {!isRefreshing && listError && {listError}} + {!isRefreshing && !listError && fetchedAt && {'Fetched from origin at ' + fetchedAt}} + + )} {webUrl && ( Browser view diff --git a/software/plugins/claude/default/tests/ws.test.ts b/software/plugins/claude/default/tests/ws.test.ts index 4db75c5..351f8f0 100644 --- a/software/plugins/claude/default/tests/ws.test.ts +++ b/software/plugins/claude/default/tests/ws.test.ts @@ -38,6 +38,8 @@ type Clone = { isDirty: boolean isDelphi: boolean switches: { name: string; timeoutMs?: number }[] + lists: number // switch.sh --list runs + isOffline: boolean // --list can't fetch, so it fails status: string[] servers: string[][] // each web/server.py started, its argv opened: string[] // the URLs opened in a browser @@ -50,7 +52,7 @@ function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true, hasPy let browse = (name: string) => {} let seen = () => {} const switched = new Promise(r => (browse = r)) - const c: Clone = { here, isDirty, isDelphi, switches: [], status: [], servers: [], opened: [], browser: n => browse(n), seen: new Promise(r => (seen = r)) } + const c: Clone = { here, isDirty, isDelphi, switches: [], lists: 0, isOffline: false, status: [], servers: [], opened: [], browser: n => browse(n), seen: new Promise(r => (seen = r)) } on('session.start', () => ({ cwd: ROOT })) on('command.register', ($, e) => ({ value: { command: e.name } })) on('ui.open', () => ({ value: { isPlaced: true } })) @@ -65,6 +67,8 @@ function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true, hasPy if (cmd === 'git' && a1 === 'rev-parse') return done(0, ROOT + '\n') if (cmd === 'git' && a1 === 'diff') return done(c.isDirty ? 1 : 0) if (cmd === 'bash' && a1 === SWITCH && a2 === '--list') { + c.lists++ + if (c.isOffline) return done(128, '', 'fatal: unable to access origin\n') const out = ['main', ARGOS, BMS].map((n) => (n === c.here ? '* ' : ' ') + n).join('\n') + '\n' return done(0, out) } @@ -201,3 +205,20 @@ test('without python3, /ws --web says it needs it and opens nothing', async ($, expect((await $.command.run(ws('--web'))).text).toMatch(/^No browser view \(it needs python3\)/) expect(c.opened).toEqual([]) }) + +test('Refresh sits under the action card in every state but switching, and says when it last fetched', async ($, on) => { + const c = fakeClone(on) + await $.command.run(ws('')) + const ui = await $.ui.mount(PANE) + expect(await ui.find({ type: 'Text', text: /^Fetched from origin at \d\d:\d\d$/ })).toBeDefined() + await ui.press({ key: 'ws:' + BMS }) + await ui.press({ key: 'switch' }) + expect(await ui.find({ key: 'close' })).toBeDefined() + const before = c.lists + await ui.press({ key: 'refresh' }) + expect(c.lists).toBe(before + 1) + c.isOffline = true + await ui.press({ key: 'refresh' }) + expect(await ui.find({ type: 'Text', text: 'Could not list the workspaces: fatal: unable to access origin' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '✓ Now on bms-dev' })).toBeDefined() +}) diff --git a/software/plugins/claude/default/web/index.html b/software/plugins/claude/default/web/index.html index f1b02e0..8705c06 100644 --- a/software/plugins/claude/default/web/index.html +++ b/software/plugins/claude/default/web/index.html @@ -46,6 +46,7 @@ .bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 16px 24px; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); border-top: 1px solid var(--line); } .bar .inner { max-width: 980px; margin: 0 auto; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .bar .what { flex: 1; min-width: 240px; } + .bar .tools { display: flex; align-items: center; gap: 10px; padding-left: 16px; border-left: 1px solid var(--line); font-size: 13px; } .bar.done { border-top-color: var(--ok); } .bar.done .what { color: var(--ok); } .bar.failed { border-top-color: var(--err); } .bar.failed .what { color: var(--err); } .bar.switching { border-top-color: var(--warn); } @@ -60,7 +61,6 @@

Delphi workspaces

-

Loading…

@@ -69,6 +69,7 @@

Delphi workspaces