feat(v0.3): H4 — live Claude status badge + notifications + setup-hooks
- 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.
This commit is contained in:
@@ -16,7 +16,8 @@
|
|||||||
"dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch",
|
"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",
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"setup-hooks": "node scripts/setup-hooks.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|||||||
@@ -202,6 +202,18 @@ html, body {
|
|||||||
font-weight: 600;
|
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. */
|
/* Drag-to-reorder feedback. */
|
||||||
.tab.dragging {
|
.tab.dragging {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class TabApp {
|
|||||||
private activeIndex = -1
|
private activeIndex = -1
|
||||||
private editingIndex = -1
|
private editingIndex = -1
|
||||||
private dragIndex = -1
|
private dragIndex = -1
|
||||||
|
private notifyAsked = false
|
||||||
private readonly paneHost: HTMLElement
|
private readonly paneHost: HTMLElement
|
||||||
private readonly tabBar: HTMLElement
|
private readonly tabBar: HTMLElement
|
||||||
|
|
||||||
@@ -135,6 +136,13 @@ export class TabApp {
|
|||||||
this.refreshTab(entry)
|
this.refreshTab(entry)
|
||||||
},
|
},
|
||||||
onStatus: () => 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.paneHost.appendChild(entry.session.el)
|
||||||
this.tabs.push(entry)
|
this.tabs.push(entry)
|
||||||
@@ -151,6 +159,7 @@ export class TabApp {
|
|||||||
|
|
||||||
activate(i: number): void {
|
activate(i: number): void {
|
||||||
if (i < 0 || i >= this.tabs.length) return
|
if (i < 0 || i >= this.tabs.length) return
|
||||||
|
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
||||||
this.activeIndex = i
|
this.activeIndex = i
|
||||||
const entry = this.tabs[i]
|
const entry = this.tabs[i]
|
||||||
if (entry) entry.hasActivity = false // viewing clears the unread dot
|
if (entry) entry.hasActivity = false // viewing clears the unread dot
|
||||||
@@ -218,6 +227,22 @@ export class TabApp {
|
|||||||
this.tabs[this.activeIndex]?.session.clearSearch()
|
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 ───────────────────────────────────────────────── */
|
/* ── rendering ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
|
/** 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)
|
const idx = this.tabs.indexOf(entry)
|
||||||
el.classList.toggle('active', idx === this.activeIndex)
|
el.classList.toggle('active', idx === this.activeIndex)
|
||||||
el.classList.toggle('unread', entry.hasActivity && 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)
|
const title = this.displayTitle(entry, idx)
|
||||||
el.title = title
|
el.title = title
|
||||||
const dot = el.querySelector('.tab-dot')
|
const dot = el.querySelector('.tab-dot')
|
||||||
if (dot) dot.className = `tab-dot dot-${entry.session.status}`
|
if (dot) dot.className = `tab-dot dot-${entry.session.status}`
|
||||||
const label = el.querySelector('.tab-label')
|
const label = el.querySelector('.tab-label')
|
||||||
if (label) label.textContent = title
|
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). */
|
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
|
||||||
@@ -311,6 +343,10 @@ export class TabApp {
|
|||||||
})
|
})
|
||||||
tabEl.appendChild(label)
|
tabEl.appendChild(label)
|
||||||
|
|
||||||
|
const claude = document.createElement('span')
|
||||||
|
claude.className = 'tab-claude'
|
||||||
|
tabEl.appendChild(claude)
|
||||||
|
|
||||||
const close = document.createElement('button')
|
const close = document.createElement('button')
|
||||||
close.className = 'tab-close'
|
close.className = 'tab-close'
|
||||||
close.textContent = '×'
|
close.textContent = '×'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Terminal } from '@xterm/xterm'
|
|||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { SearchAddon } from '@xterm/addon-search'
|
import { SearchAddon } from '@xterm/addon-search'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
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'
|
import { folderFromTitle } from './title-util.js'
|
||||||
|
|
||||||
const RESET = '\x1b[0m'
|
const RESET = '\x1b[0m'
|
||||||
@@ -51,6 +51,8 @@ export interface TerminalSessionOpts {
|
|||||||
onTitle?: (title: string) => void
|
onTitle?: (title: string) => void
|
||||||
/** Optional: fired when connection status changes (for the tab status dot). */
|
/** Optional: fired when connection status changes (for the tab status dot). */
|
||||||
onStatus?: (status: SessionStatus) => void
|
onStatus?: (status: SessionStatus) => void
|
||||||
|
/** Optional: fired when Claude Code activity changes (from a hook, H2). */
|
||||||
|
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TerminalSession {
|
export class TerminalSession {
|
||||||
@@ -64,7 +66,9 @@ export class TerminalSession {
|
|||||||
private readonly onActivity: (() => void) | undefined
|
private readonly onActivity: (() => void) | undefined
|
||||||
private readonly onTitle: ((title: string) => void) | undefined
|
private readonly onTitle: ((title: string) => void) | undefined
|
||||||
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
||||||
|
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
||||||
private statusValue: SessionStatus = 'connecting'
|
private statusValue: SessionStatus = 'connecting'
|
||||||
|
private claudeStatusValue: ClaudeStatus = 'unknown'
|
||||||
|
|
||||||
private ws: WebSocket | null = null
|
private ws: WebSocket | null = null
|
||||||
private sessionId: string | null
|
private sessionId: string | null
|
||||||
@@ -83,6 +87,7 @@ export class TerminalSession {
|
|||||||
this.onActivity = opts.onActivity
|
this.onActivity = opts.onActivity
|
||||||
this.onTitle = opts.onTitle
|
this.onTitle = opts.onTitle
|
||||||
this.onStatus = opts.onStatus
|
this.onStatus = opts.onStatus
|
||||||
|
this.onClaudeStatus = opts.onClaudeStatus
|
||||||
|
|
||||||
this.el = document.createElement('div')
|
this.el = document.createElement('div')
|
||||||
this.el.className = 'term-pane'
|
this.el.className = 'term-pane'
|
||||||
@@ -118,6 +123,11 @@ export class TerminalSession {
|
|||||||
return this.statusValue
|
return this.statusValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current Claude Code activity (from hooks, H2). */
|
||||||
|
get claudeStatus(): ClaudeStatus {
|
||||||
|
return this.claudeStatusValue
|
||||||
|
}
|
||||||
|
|
||||||
private setStatus(s: SessionStatus): void {
|
private setStatus(s: SessionStatus): void {
|
||||||
this.statusValue = s
|
this.statusValue = s
|
||||||
this.onStatus?.(s)
|
this.onStatus?.(s)
|
||||||
@@ -206,6 +216,11 @@ export class TerminalSession {
|
|||||||
if (this.el.style.display === 'none') this.onActivity?.()
|
if (this.el.style.display === 'none') this.onActivity?.()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'status': {
|
||||||
|
this.claudeStatusValue = msg.status
|
||||||
|
this.onClaudeStatus?.(msg.status, msg.detail)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'exit': {
|
case 'exit': {
|
||||||
this.setStatus('exited')
|
this.setStatus('exited')
|
||||||
const reason = msg.reason ? ` (${msg.reason})` : ''
|
const reason = msg.reason ? ` (${msg.reason})` : ''
|
||||||
|
|||||||
77
scripts/setup-hooks.mjs
Normal file
77
scripts/setup-hooks.mjs
Normal file
@@ -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.')
|
||||||
Reference in New Issue
Block a user