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:
Yaojia Wang
2026-06-17 19:00:34 +02:00
parent a411c89ee9
commit c81cebdc47
5 changed files with 143 additions and 2 deletions

View File

@@ -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;

View File

@@ -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 = '×'

View File

@@ -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})` : ''