From c81cebdc474f5cc6e720fc4b0e5543f7294132ae Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 19:00:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(v0.3):=20H4=20=E2=80=94=20live=20Claude=20?= =?UTF-8?q?status=20badge=20+=20notifications=20+=20setup-hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - terminal-session: handle 'status' frame → claudeStatus + onClaudeStatus - tabs: per-tab Claude glyph (⚙ working / ⏳ waiting / ✓ idle); inactive 'waiting' tab gets an amber highlight + a browser notification (permission requested on first tab switch) - scripts/setup-hooks.mjs + 'npm run setup-hooks': idempotently install/remove command hooks in ~/.claude/settings.json (inline curl to $WEBTERM_HOOK_URL with $WEBTERM_SESSION; no-op outside web-terminal); backs up settings.json - Browser-verified end-to-end: PermissionRequest→⏳, PreToolUse→⚙, Stop→✓; inactive waiting tab shows amber + glyph. 205 tests green. --- package.json | 3 +- public/style.css | 12 ++++++ public/tabs.ts | 36 ++++++++++++++++++ public/terminal-session.ts | 17 ++++++++- scripts/setup-hooks.mjs | 77 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 scripts/setup-hooks.mjs diff --git a/package.json b/package.json index 826aee5..b90c750 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "setup-hooks": "node scripts/setup-hooks.mjs" }, "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/public/style.css b/public/style.css index bef601a..8f71f9d 100644 --- a/public/style.css +++ b/public/style.css @@ -202,6 +202,18 @@ html, body { font-weight: 600; } +/* Claude Code activity glyph (H2/H4) */ +.tab-claude { + margin-right: 6px; + font-size: 12px; + flex: none; +} +/* A background tab that needs approval gets an amber outline. */ +.tab.claude-waiting { + background-color: #3a2a10; + box-shadow: inset 0 -2px 0 #d29922; +} + /* Drag-to-reorder feedback. */ .tab.dragging { opacity: 0.4; diff --git a/public/tabs.ts b/public/tabs.ts index 0c13027..5e2a09e 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -40,6 +40,7 @@ export class TabApp { private activeIndex = -1 private editingIndex = -1 private dragIndex = -1 + private notifyAsked = false private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement @@ -135,6 +136,13 @@ export class TabApp { this.refreshTab(entry) }, onStatus: () => this.refreshTab(entry), + onClaudeStatus: (status) => { + this.refreshTab(entry) + // Notify when a background tab needs approval (H2/H4). + if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { + this.notify(entry) + } + }, }) this.paneHost.appendChild(entry.session.el) this.tabs.push(entry) @@ -151,6 +159,7 @@ export class TabApp { activate(i: number): void { if (i < 0 || i >= this.tabs.length) return + this.maybeAskNotify() // first switch is a user gesture — request notif permission this.activeIndex = i const entry = this.tabs[i] if (entry) entry.hasActivity = false // viewing clears the unread dot @@ -218,6 +227,22 @@ export class TabApp { this.tabs[this.activeIndex]?.session.clearSearch() } + /** Ask for notification permission once, on a user gesture (H2/H4). */ + private maybeAskNotify(): void { + if (this.notifyAsked) return + this.notifyAsked = true + if (typeof Notification !== 'undefined' && Notification.permission === 'default') { + void Notification.requestPermission() + } + } + + /** Browser notification for a background tab needing approval. */ + private notify(entry: TabEntry): void { + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return + const title = this.displayTitle(entry, this.tabs.indexOf(entry)) + new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' }) + } + /* ── rendering ───────────────────────────────────────────────── */ /** In-place update of one tab's classes/label/dot — never destroys the DOM. */ @@ -227,12 +252,19 @@ export class TabApp { const idx = this.tabs.indexOf(entry) el.classList.toggle('active', idx === this.activeIndex) el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex) + const cs = entry.session.claudeStatus + el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex) const title = this.displayTitle(entry, idx) el.title = title const dot = el.querySelector('.tab-dot') if (dot) dot.className = `tab-dot dot-${entry.session.status}` const label = el.querySelector('.tab-label') if (label) label.textContent = title + const claude = el.querySelector('.tab-claude') + if (claude) { + claude.textContent = + cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : '' + } } /** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */ @@ -311,6 +343,10 @@ export class TabApp { }) tabEl.appendChild(label) + const claude = document.createElement('span') + claude.className = 'tab-claude' + tabEl.appendChild(claude) + const close = document.createElement('button') close.className = 'tab-close' close.textContent = '×' diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 1a37ef1..64731c7 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -12,7 +12,7 @@ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' -import type { ClientMessage, ServerMessage } from '../src/types.js' +import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' import { folderFromTitle } from './title-util.js' const RESET = '\x1b[0m' @@ -51,6 +51,8 @@ export interface TerminalSessionOpts { onTitle?: (title: string) => void /** Optional: fired when connection status changes (for the tab status dot). */ onStatus?: (status: SessionStatus) => void + /** Optional: fired when Claude Code activity changes (from a hook, H2). */ + onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void } export class TerminalSession { @@ -64,7 +66,9 @@ export class TerminalSession { private readonly onActivity: (() => void) | undefined private readonly onTitle: ((title: string) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined + private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private statusValue: SessionStatus = 'connecting' + private claudeStatusValue: ClaudeStatus = 'unknown' private ws: WebSocket | null = null private sessionId: string | null @@ -83,6 +87,7 @@ export class TerminalSession { this.onActivity = opts.onActivity this.onTitle = opts.onTitle this.onStatus = opts.onStatus + this.onClaudeStatus = opts.onClaudeStatus this.el = document.createElement('div') this.el.className = 'term-pane' @@ -118,6 +123,11 @@ export class TerminalSession { return this.statusValue } + /** Current Claude Code activity (from hooks, H2). */ + get claudeStatus(): ClaudeStatus { + return this.claudeStatusValue + } + private setStatus(s: SessionStatus): void { this.statusValue = s this.onStatus?.(s) @@ -206,6 +216,11 @@ export class TerminalSession { if (this.el.style.display === 'none') this.onActivity?.() break } + case 'status': { + this.claudeStatusValue = msg.status + this.onClaudeStatus?.(msg.status, msg.detail) + break + } case 'exit': { this.setStatus('exited') const reason = msg.reason ? ` (${msg.reason})` : '' diff --git a/scripts/setup-hooks.mjs b/scripts/setup-hooks.mjs new file mode 100644 index 0000000..3fb02ac --- /dev/null +++ b/scripts/setup-hooks.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * scripts/setup-hooks.mjs — install/remove web-terminal's Claude Code hooks (H2). + * + * Adds `command` hooks to ~/.claude/settings.json that POST each hook event to + * the web-terminal server so it can show live Claude status per tab. The command + * is an inline curl that uses two env vars the server injects into each spawned + * shell: $WEBTERM_HOOK_URL (the server's /hook URL, with the right port) and + * $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so + * curl no-ops (|| true) — the hooks are harmless in normal terminals. + * + * Usage: + * node scripts/setup-hooks.mjs # install (idempotent) + * node scripts/setup-hooks.mjs --remove # uninstall + * + * Backs up settings.json to settings.json.webterm-bak before writing. + */ + +import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' + +const SETTINGS = path.join(homedir(), '.claude', 'settings.json') +const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries +const COMMAND = + 'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' + + ' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true' +// Events we care about (status derives from the event/notification_type server-side). +const EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'PermissionRequest', 'Stop', 'SessionEnd'] + +const remove = process.argv.includes('--remove') + +let settings = {} +if (existsSync(SETTINGS)) { + try { + settings = JSON.parse(readFileSync(SETTINGS, 'utf8')) + } catch { + console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`) + process.exit(1) + } + copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`) +} + +if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {} + +// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove). +for (const ev of Object.keys(settings.hooks)) { + const groups = settings.hooks[ev] + if (!Array.isArray(groups)) continue + settings.hooks[ev] = groups.filter( + (g) => + !( + g && + Array.isArray(g.hooks) && + g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER)) + ), + ) + if (settings.hooks[ev].length === 0) delete settings.hooks[ev] +} + +if (!remove) { + for (const ev of EVENTS) { + if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = [] + settings.hooks[ev].push({ hooks: [{ type: 'command', command: COMMAND }] }) + } +} + +mkdirSync(path.dirname(SETTINGS), { recursive: true }) +writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`) + +console.log( + remove + ? `Removed web-terminal hooks from ${SETTINGS}` + : `Installed web-terminal hooks into ${SETTINGS} (events: ${EVENTS.join(', ')})`, +) +if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) +console.log('Restart any running `claude` sessions for the hooks to take effect.')