From f79f14b89d0c395d477ab8b8a3caa97ecdb06716 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 05:40:35 +0200 Subject: [PATCH] feat(v0.3): M3 themes/font settings + M7 session dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- public/dashboard.ts | 94 +++++++++++++++++++++++++++ public/main.ts | 18 ++++++ public/settings.ts | 128 +++++++++++++++++++++++++++++++++++++ public/style.css | 100 +++++++++++++++++++++++++++++ public/tabs.ts | 33 ++++++++++ public/terminal-session.ts | 11 ++++ 6 files changed, 384 insertions(+) create mode 100644 public/dashboard.ts create mode 100644 public/settings.ts diff --git a/public/dashboard.ts b/public/dashboard.ts new file mode 100644 index 0000000..65af614 --- /dev/null +++ b/public/dashboard.ts @@ -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 | 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) +} diff --git a/public/main.ts b/public/main.ts index 88a0348..0361269 100644 --- a/public/main.ts +++ b/public/main.ts @@ -15,6 +15,8 @@ import { mountKeybar } from './keybar.js' import { TabApp } from './tabs.js' import { mountSearch } from './search.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 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) +// 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. mountKeybar((data) => app.sendToActive(data)) @@ -33,6 +39,18 @@ mountSearch(toolbar, { find: (query, dir) => app.findInActive(query, dir), clear: () => app.clearActiveSearch(), }) +mountSettings( + toolbar, + () => settings, + (s) => { + settings = s + app.applySettings(s) + }, +) +mountDashboard(toolbar, { + snapshot: () => app.snapshot(), + focus: (idx) => app.focusTab(idx), +}) mountQrConnect(toolbar) // PWA: register the service worker (installable + offline shell, M4). diff --git a/public/settings.ts b/public/settings.ts new file mode 100644 index 0000000..7337b17 --- /dev/null +++ b/public/settings.ts @@ -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 = { + 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 + 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) +} diff --git a/public/style.css b/public/style.css index e3a16c7..46b45ff 100644 --- a/public/style.css +++ b/public/style.css @@ -153,6 +153,106 @@ html, body { 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 { display: flex; align-items: center; diff --git a/public/tabs.ts b/public/tabs.ts index ca44dcd..883f783 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -17,6 +17,7 @@ */ import { TerminalSession } from './terminal-session.js' +import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' @@ -41,6 +42,7 @@ export class TabApp { private editingIndex = -1 private dragIndex = -1 private notifyAsked = false + private settings: Settings = DEFAULT_SETTINGS private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement private readonly approvalBar: HTMLDivElement @@ -181,6 +183,7 @@ export class TabApp { }) this.paneHost.appendChild(entry.session.el) this.tabs.push(entry) + entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) entry.session.connect() return entry } @@ -251,6 +254,36 @@ export class TabApp { 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). */ findInActive(query: string, dir: 'next' | 'prev'): void { const s = this.tabs[this.activeIndex]?.session diff --git a/public/terminal-session.ts b/public/terminal-session.ts index ecdbeb1..8caf957 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -9,6 +9,7 @@ */ import { Terminal } from '@xterm/xterm' +import type { ITheme } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' @@ -302,6 +303,16 @@ export class TerminalSession { 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. */ show(): void { this.el.style.display = 'block'