feat(v0.3): M3 themes/font settings + M7 session dashboard

- M3: settings.ts (⚙ panel: dark/light/solarized theme + font size A-/A+),
  persisted to localStorage; terminal-session.applyTheme; TabApp.applySettings
  applies to all + new tabs
- M7: dashboard.ts (▦ overlay listing every tab with connection dot + folder +
  Claude status; click a row to focus; live re-render while open);
  TabApp.snapshot()/focusTab()
- Browser-verified: light theme renders + persists; dashboard lists tabs and
  focuses on click. 208 tests green, typechecks + build ok.
This commit is contained in:
Yaojia Wang
2026-06-18 05:40:35 +02:00
parent dcb1bf4845
commit f79f14b89d
6 changed files with 384 additions and 0 deletions

94
public/dashboard.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* public/dashboard.ts — all-sessions overview (M7).
*
* A ▦ toolbar button opens an overlay listing every tab with its connection
* dot, folder name, and Claude status; click a row to focus that tab. While
* open it re-renders every second so statuses stay live.
*/
export interface TabInfo {
idx: number
title: string
conn: string // SessionStatus
claude: string // ClaudeStatus
active: boolean
pending: boolean
}
export interface DashboardHooks {
snapshot: () => TabInfo[]
focus: (idx: number) => void
}
function claudeText(claude: string): string {
if (claude === 'working') return '⚙ working'
if (claude === 'waiting') return '⏳ needs approval'
if (claude === 'idle') return '✓ idle'
return ''
}
export function mountDashboard(toolbar: HTMLElement, hooks: DashboardHooks): void {
const overlay = document.createElement('div')
overlay.id = 'dashboard'
overlay.style.display = 'none'
const card = document.createElement('div')
card.className = 'dash-card'
overlay.appendChild(card)
document.body.appendChild(overlay)
let timer: ReturnType<typeof setInterval> | null = null
const render = (): void => {
card.replaceChildren()
const title = document.createElement('div')
title.className = 'dash-title'
title.textContent = 'Sessions'
card.appendChild(title)
for (const t of hooks.snapshot()) {
const row = document.createElement('div')
row.className = t.active ? 'dash-row active' : 'dash-row'
const dot = document.createElement('span')
dot.className = `tab-dot dot-${t.conn}`
const name = document.createElement('span')
name.className = 'dash-name'
name.textContent = t.title
const claude = document.createElement('span')
claude.className = t.pending ? 'dash-claude pending' : 'dash-claude'
claude.textContent = claudeText(t.claude)
row.append(dot, name, claude)
row.addEventListener('click', () => {
hooks.focus(t.idx)
hide()
})
card.appendChild(row)
}
}
const open = (): void => {
render()
overlay.style.display = 'flex'
timer = setInterval(render, 1000)
}
const hide = (): void => {
overlay.style.display = 'none'
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
overlay.addEventListener('click', (e) => {
if (e.target === overlay) hide()
})
const btn = document.createElement('button')
btn.className = 'toolbtn'
btn.textContent = '▦'
btn.title = 'All sessions'
btn.setAttribute('aria-label', 'All sessions')
btn.addEventListener('click', () => (overlay.style.display === 'none' ? open() : hide()))
toolbar.appendChild(btn)
}

View File

@@ -15,6 +15,8 @@ import { mountKeybar } from './keybar.js'
import { TabApp } from './tabs.js' import { TabApp } from './tabs.js'
import { mountSearch } from './search.js' import { mountSearch } from './search.js'
import { mountQrConnect } from './qr.js' import { mountQrConnect } from './qr.js'
import { mountSettings, loadSettings, type Settings } from './settings.js'
import { mountDashboard } from './dashboard.js'
const paneHost = document.getElementById('term') const paneHost = document.getElementById('term')
const tabs = document.getElementById('tabs') const tabs = document.getElementById('tabs')
@@ -25,6 +27,10 @@ if (!toolbar) throw new Error('#toolbar element not found in DOM')
const app = new TabApp(paneHost, tabs) const app = new TabApp(paneHost, tabs)
// Apply saved theme/font (M3) to the restored tabs.
let settings: Settings = loadSettings()
app.applySettings(settings)
// Key bar (mobile + desktop) sends to whichever tab is active. // Key bar (mobile + desktop) sends to whichever tab is active.
mountKeybar((data) => app.sendToActive(data)) mountKeybar((data) => app.sendToActive(data))
@@ -33,6 +39,18 @@ mountSearch(toolbar, {
find: (query, dir) => app.findInActive(query, dir), find: (query, dir) => app.findInActive(query, dir),
clear: () => app.clearActiveSearch(), clear: () => app.clearActiveSearch(),
}) })
mountSettings(
toolbar,
() => settings,
(s) => {
settings = s
app.applySettings(s)
},
)
mountDashboard(toolbar, {
snapshot: () => app.snapshot(),
focus: (idx) => app.focusTab(idx),
})
mountQrConnect(toolbar) mountQrConnect(toolbar)
// PWA: register the service worker (installable + offline shell, M4). // PWA: register the service worker (installable + offline shell, M4).

128
public/settings.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* public/settings.ts — theme + font-size settings (M3).
*
* A ⚙ toolbar button opens a small panel; choices persist to localStorage and
* apply to every terminal via the onChange callback.
*/
import type { ITheme } from '@xterm/xterm'
export interface Settings {
theme: string
fontSize: number
}
export const THEMES: Record<string, ITheme> = {
dark: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8' },
light: {
background: '#f5f5f5',
foreground: '#1a1a1a',
cursor: '#1a1a1a',
selectionBackground: '#bcd3f5',
},
solarized: { background: '#002b36', foreground: '#93a1a1', cursor: '#93a1a1' },
}
const KEY = 'web-terminal:settings'
export const DEFAULT_SETTINGS: Settings = { theme: 'dark', fontSize: 14 }
export function loadSettings(): Settings {
try {
const raw = localStorage.getItem(KEY)
if (raw) {
const s = JSON.parse(raw) as Partial<Settings>
return {
theme: typeof s.theme === 'string' && THEMES[s.theme] ? s.theme : DEFAULT_SETTINGS.theme,
fontSize:
typeof s.fontSize === 'number' && s.fontSize >= 10 && s.fontSize <= 24
? s.fontSize
: DEFAULT_SETTINGS.fontSize,
}
}
} catch {
// ignore
}
return { ...DEFAULT_SETTINGS }
}
function saveSettings(s: Settings): void {
try {
localStorage.setItem(KEY, JSON.stringify(s))
} catch {
// ignore
}
}
export function mountSettings(
toolbar: HTMLElement,
current: () => Settings,
onChange: (s: Settings) => void,
): void {
const panel = document.createElement('div')
panel.id = 'settingspanel'
panel.style.display = 'none'
const apply = (next: Settings): void => {
saveSettings(next)
onChange(next)
render()
}
function render(): void {
const s = current()
panel.replaceChildren()
const themeRow = document.createElement('div')
themeRow.className = 'settings-row'
themeRow.append(label('Theme'))
for (const key of Object.keys(THEMES)) {
const b = document.createElement('button')
b.textContent = key
b.className = key === s.theme ? 'settings-opt active' : 'settings-opt'
b.addEventListener('click', () => apply({ ...s, theme: key }))
themeRow.appendChild(b)
}
const fontRow = document.createElement('div')
fontRow.className = 'settings-row'
fontRow.append(label('Font'))
const minus = document.createElement('button')
minus.className = 'settings-opt'
minus.textContent = 'A'
minus.addEventListener('click', () => apply({ ...s, fontSize: Math.max(10, s.fontSize - 1) }))
const size = document.createElement('span')
size.className = 'settings-size'
size.textContent = String(s.fontSize)
const plus = document.createElement('button')
plus.className = 'settings-opt'
plus.textContent = 'A+'
plus.addEventListener('click', () => apply({ ...s, fontSize: Math.min(24, s.fontSize + 1) }))
fontRow.append(minus, size, plus)
panel.append(themeRow, fontRow)
}
function label(text: string): HTMLElement {
const el = document.createElement('span')
el.className = 'settings-label'
el.textContent = text
return el
}
document.body.appendChild(panel)
const toggle = document.createElement('button')
toggle.className = 'toolbtn'
toggle.textContent = '⚙'
toggle.title = 'Settings (theme, font)'
toggle.setAttribute('aria-label', 'Settings')
toggle.addEventListener('click', () => {
if (panel.style.display === 'none') {
render()
panel.style.display = 'block'
} else {
panel.style.display = 'none'
}
})
toolbar.appendChild(toggle)
}

View File

@@ -153,6 +153,106 @@ html, body {
font: inherit; font: inherit;
} }
/* Settings panel (M3) */
#settingspanel {
position: fixed;
top: calc(var(--tabbar-h) + 6px);
right: 8px;
z-index: 1100;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 10px;
min-width: 210px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
color: #ddd;
}
.settings-row {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0;
}
.settings-label {
width: 48px;
color: #aaa;
font-size: 13px;
}
.settings-opt {
background: #333;
border: 1px solid #444;
color: #ddd;
border-radius: 4px;
padding: 4px 9px;
cursor: pointer;
font: inherit;
font-size: 12px;
}
.settings-opt.active {
background: #4a9eff;
color: #fff;
border-color: #4a9eff;
}
.settings-size {
min-width: 22px;
text-align: center;
}
/* Dashboard (M7) */
#dashboard {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 56px;
background: rgba(0, 0, 0, 0.6);
}
.dash-card {
background: #222;
border: 1px solid #3a3a3a;
border-radius: 10px;
min-width: 320px;
max-width: 90vw;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.dash-title {
padding: 12px 16px;
font-weight: 600;
color: #fff;
border-bottom: 1px solid #3a3a3a;
}
.dash-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
color: #ddd;
border-bottom: 1px solid #2a2a2a;
}
.dash-row:hover {
background: #2a2a2a;
}
.dash-row.active {
background: #1a1a1a;
box-shadow: inset 3px 0 0 #4a9eff;
}
.dash-name {
flex: 1;
}
.dash-claude {
color: #aaa;
font-size: 13px;
}
.dash-claude.pending {
color: #ffcf8f;
font-weight: 600;
}
.tab { .tab {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -17,6 +17,7 @@
*/ */
import { TerminalSession } from './terminal-session.js' import { TerminalSession } from './terminal-session.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
const TABS_KEY = 'web-terminal:tabs' const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active' const ACTIVE_KEY = 'web-terminal:active'
@@ -41,6 +42,7 @@ export class TabApp {
private editingIndex = -1 private editingIndex = -1
private dragIndex = -1 private dragIndex = -1
private notifyAsked = false private notifyAsked = false
private settings: Settings = DEFAULT_SETTINGS
private readonly paneHost: HTMLElement private readonly paneHost: HTMLElement
private readonly tabBar: HTMLElement private readonly tabBar: HTMLElement
private readonly approvalBar: HTMLDivElement private readonly approvalBar: HTMLDivElement
@@ -181,6 +183,7 @@ export class TabApp {
}) })
this.paneHost.appendChild(entry.session.el) this.paneHost.appendChild(entry.session.el)
this.tabs.push(entry) this.tabs.push(entry)
entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
entry.session.connect() entry.session.connect()
return entry return entry
} }
@@ -251,6 +254,36 @@ export class TabApp {
this.tabs[this.activeIndex]?.session.send(data) this.tabs[this.activeIndex]?.session.send(data)
} }
/** Apply theme + font settings to every terminal (M3). */
applySettings(s: Settings): void {
this.settings = s
const theme = THEMES[s.theme] ?? THEMES['dark']!
for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize)
}
/** A read-only view of all tabs for the dashboard (M7). */
snapshot(): Array<{
idx: number
title: string
conn: string
claude: string
active: boolean
pending: boolean
}> {
return this.tabs.map((t, idx) => ({
idx,
title: this.displayTitle(t, idx),
conn: t.session.status,
claude: t.session.claudeStatus,
active: idx === this.activeIndex,
pending: t.session.pendingApproval,
}))
}
focusTab(idx: number): void {
this.activate(idx)
}
/** Scrollback search in the active tab (M1). */ /** Scrollback search in the active tab (M1). */
findInActive(query: string, dir: 'next' | 'prev'): void { findInActive(query: string, dir: 'next' | 'prev'): void {
const s = this.tabs[this.activeIndex]?.session const s = this.tabs[this.activeIndex]?.session

View File

@@ -9,6 +9,7 @@
*/ */
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm'
import type { ITheme } 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'
@@ -302,6 +303,16 @@ export class TerminalSession {
this.searchAddon.clearDecorations() this.searchAddon.clearDecorations()
} }
/** Apply a theme + font size (M3) and re-fit if visible. */
applyTheme(theme: ITheme, fontSize: number): void {
this.term.options.theme = theme
this.term.options.fontSize = fontSize
if (this.el.style.display !== 'none') {
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
}
}
/** Make this pane visible, fit it, and focus it. */ /** Make this pane visible, fit it, and focus it. */
show(): void { show(): void {
this.el.style.display = 'block' this.el.style.display = 'block'