- Segmented control: replace the two free-standing bordered boxes with a
single rounded track holding two pill buttons (iOS-style), active segment
filled Amber; centred via fit-content + margin auto. Panel top-offset 44→68px.
- Hide the bottom key-bar on the home chooser (it only targets an active
terminal): updateHomeView toggles body.home-open; CSS hides #keybar.
Frontend-only. 415 tests green; both typechecks clean; verified in-browser
(toggle pill renders, keybar hidden on home, reappears when a tab opens).
Once a project/session tab was open, the home chooser (Sessions/Projects
segmented control) was hidden and only reachable by closing every tab — no
way back to the Projects view to open another project.
Add a ⌂ Home button to the tab bar that overlays the chooser on top of the
open tabs (homeForced flag; updateHomeView shows home when no tab OR forced,
hiding the terminal panes). Clicking ⌂ again, clicking a tab (activate resets
homeForced), or opening another session/project dismisses the overlay.
Closing the last tab resets the flag. The button highlights while active.
Tests: +3 (tabs.test.ts). Full suite 415 green; both typechecks clean;
verified in-browser (open tab → ⌂ → Projects panel reachable → ⌂ → terminal).
Prior-session prep for the v0.6 Project Manager, checkpointed on the
v0.6-projects branch as a clean base for the parallel builders:
- P1 (src/types.ts): ProjectInfo + ProjectSessionRef contracts; Config
gains projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck.
- P3 (src/config.ts): parseBool + parseProjectRoots helpers; parse the
4 PROJECT_* env vars with defaults ([homeDir]/4/10000/true).
- UI: public/style.css indigo -> Amber (#e3a64a) accent theme.
- Docs: FEATURE_PROJECT_MANAGER.md (1:N session design), THEME.md,
docs/mockups/ (final-amber.png is the locked visual).
Typecheck (backend + web) green. No behavior change to runtime code yet;
P2/P4/P5/P6 implement the feature on top of this.
Opening the app no longer auto-creates a blank tab or auto-restores/discovers
sessions as tabs. Instead it lands on a launcher (session chooser): a grid of the
host's running sessions as live preview thumbnails — pick one to open (replays its
full scrollback), or '+ New session'. Closing the last tab returns to the chooser.
- public/launcher.ts: mountLauncher() renders the chooser into the terminal area
(live xterm thumbnails via /live-sessions + /preview, 5s refresh), New session +
Manage link
- tabs.ts: drop restore()/syncLiveSessions() auto-tab behavior; constructor lands
on the launcher; rebuild() toggles launcher when tab count is 0; closeTab no
longer spawns a blank tab
- style.css: .launcher chrome (reuses the .mg-* card/grid/thumbnail styles)
Verified: fresh load → 0 tabs, chooser with thumbnails; Open → tab + chooser
hides; close last tab → chooser returns. 228 tests green, tsc + build clean.
The flat session list didn't tell you what each session was doing. Rebuild
/manage.html as a full-page grid where each card renders a LIVE preview
thumbnail — a read-only xterm showing the session's actual current screen,
scaled down like a screenshot — so you can recognize each at a glance.
- RingBuffer.tail(maxBytes): newest scrollback on chunk boundaries (no ANSI/UTF-8
split); GET /live-sessions/:id/preview returns {cols,rows,data} (no attach, so
it doesn't inflate watcher counts or keep sessions alive)
- manage.ts: card grid; per card a scaled read-only Terminal renders CLEAR+tail,
auto-refresh every 4s, click thumbnail/Open to join, Kill / bulk kill
- bundles xterm into manage.js (+manage.css linked in manage.html)
- ring-buffer tail tests
Verified: 3 sessions rendered as recognizable thumbnails (top / git log / ls),
colored, with status/age; click opens. 228 tests green, tsc clean.
A shared PTY can only be one size; min-sizing clamped the shared session to the
SMALLEST viewer, so a wide desktop got letterboxed when an iPad was also attached
(iPad looked full-screen, desktop did not).
Switch to latest-writer-wins: the device that most recently fit/focused drives the
PTY size, so whichever device you're actively using is full-screen.
- session.ts: setClientDims resizes the PTY directly (drop min across clients);
attach/detach/blur never resize (the active device keeps its size)
- frontend: TerminalSession.refit() force-resends dims; window 'focus' /
visibilitychange re-assert the active tab's size, so switching devices reclaims
full-screen
- tests updated for latest-wins (incl. join/hidden/blur don't resize)
Verified (two ws clients): 200x50 → iPad 100x40 → desktop refit 200x50 → iPad blur
keeps 200x50. 225 tests green.
The mirror looked broken because a backgrounded tab on one device still pinned
the shared PTY to its (default 80x24) size — so the device actively viewing got
a cramped terminal.
Fix: only an ACTIVELY-VIEWING client votes on PTY size.
- attachWs no longer seeds a default size vote (a join may be a hidden mirror)
- new 'blur' client message + clearClientDims(): a tab going hidden withdraws its
size vote (still mirrors output); show() re-casts it
- PTY size = min cols/rows across clients that have actually reported dims
- session/protocol tests cover hidden-mirror-doesn't-clamp + blur
Session manager (separate page, for the 'too many sessions' problem):
- GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1]
- manager.killById(); LiveSessionInfo gains cols/rows
- public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached,
auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry
Verified: two concurrent clients mirror output + shared input; manage page
lists/kills (3→2→0). 225 tests green, tsc clean.
- tabs.ts: on open, syncLiveSessions() fetches /live-sessions and adds a tab for
each host session not already open → any device sees & mirrors everything
running on the host. Empty storage + no live sessions → one fresh tab.
activeSessionId() + openSession(id) helpers.
- share.ts: 🔗 toolbar button → QR + <origin>/?join=<id> for the active session.
- main.ts: ?join=<id> on load opens/focuses that shared session, then strips the
param. Mount share button (toolbar: 🔍 ⚙ ▦ 🕘 ⌨ 🔗📱).
- style.css: #sharemodal reuses the QR card/backdrop.
Verified in-browser: fresh device auto-discovered the host session and replayed
its scrollback; two concurrent clients (browser + ws) both received live output
(clientCount=2); share modal shows the join URL/QR.
Relax the one-WS-per-session invariant (#5) so multiple devices mirror the
same live terminal (everyone sees output, anyone can type — tmux-style).
- types.ts: Session.attachedWs → clients Set + clientDims Map; add cwd;
SessionManager.list() + LiveSessionInfo
- session.ts: broadcast() output/exit to all clients; attachWs JOINS (no kick,
takes dims); detachWs removes one client, stamps detachedAt only when the last
leaves; setClientDims resizes PTY to the MIN cols/rows across clients
- manager.ts: join instead of kick; broadcast status; onExit drops only when a
client was attached; list() for discovery
- server.ts: GET /live-sessions; resize → per-client setClientDims; close →
detach one client; permission gate uses clients.size
- tests: rewrote session/manager tests for multi-client (join/broadcast/min-dims/
last-detach) + list(); 222 pass, tsc clean
keybar:
- add Ctrl+R (搜历史/\x12), Ctrl+L (重绘/\x0c), Ctrl+D (退出/\x04)
- each key now shows a short function caption under the glyph (self-documenting
on touch where tooltips don't appear); 17 keys total
- test/keybar.test.ts updated to 17 keys
cheat-sheet (new public/shortcuts.ts):
- ⌨ toolbar button opens a grouped reference of common Claude Code shortcuts
(general / editing / multiline / macOS / prefixes), each labeled with what it
does; keys also on the bottom bar are tagged 键栏
- Esc / click-outside to close; styled card with sticky header + accent tags
216 tests green; tsc + build clean.
- O2: /sessions reads ~/.claude/projects JSONL; 🕘 panel resumes a past session
in a new tab (claude --resume <id>) in its project dir
- chore: *.css module decl removes the last @ts-ignore
- ui: drop in-terminal Connecting/Connected lines (tab dot shows it)
216 tests green.
- public/css.d.ts: declare module '*.css' so tsc resolves CSS side-effect
imports; removed the @ts-ignore in main.ts
- PROGRESS_LOG F1-F9 table ticked to match what's actually verified (F4/F7 still
need a physical device)
- src/http/history.ts: listSessions() reads ~/.claude/projects/*/*.jsonl
(mtime-sorted, top 50), parseSessionMeta() extracts cwd + first user prompt
(pure, 4 unit tests); GET /sessions
- terminal-session: opts.initialInput (typed ~700ms after the shell is ready)
- tabs.newTabForResume(cwd,id): new tab in the project dir running
'claude --resume <id>'
- public/history.ts: 🕘 panel listing sessions (project · time · preview) with
Resume buttons
- Verified: /sessions returns 44 real sessions; panel lists project/time/preview.
216 tests green.
The tab connection dot (green/amber/red) already shows connection state, so the
'[terminal] Connecting…/Connected' and 'Disconnected — reconnecting' lines just
cluttered the scrollback. Rely on the dot; keep only the actionable exit message.
- title-util.cwdFromOsc7: parse cwd from OSC 7 (file:// URL); 4 unit tests
- terminal-session: register OSC 7 handler → cwd getter; opts.cwd → sent in the
attach frame on a fresh session
- protocol/types: ClientMessage attach.cwd (absolute path); validated
- session/manager/server: thread cwd → createSession spawn cwd (defaults homeDir)
- tabs: '+' (newTab) opens in the active tab's reported cwd, falls back to home
- Verified: server attach.cwd spawns there; cd /private/tmp → + → new tab pwd is
/private/tmp. 212 tests green.
- 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.
- src/session/tmux.ts: sync tmux CLI wrappers (available/has/kill), best-effort
- config: useTmux from USE_TMUX env (1/0/auto→detect tmux on PATH)
- session: when useTmux, spawn 'tmux new-session -A -s web_<id> <shell>' (the
node-pty proc is a tmux CLIENT); createSession takes an optional id for
re-attach; Session.tmuxName; kill() runs tmux kill-session
- manager: handleAttach re-attaches to a surviving 'web_<id>' tmux session after
a restart (Case 3.5); shutdown kills only the client pty for tmux sessions so
the shell keeps running
- tests: USE_TMUX:0 in shared integration cfg (no tmux leakage); unit CFGs
useTmux:false; integration 'H1' (real tmux): set var → restart server →
re-attach → var survived. 208 tests green.
- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
(Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
- M5: 📱 toolbar button → modal with a client-side QR of location.origin
(shareable when opened via the LAN IP) + localhost tip
- M4: manifest.webmanifest + icon.svg + sw.js (network-first, never intercepts
/term or /hook) + registration in main.ts; apple-touch + theme-color meta
- Browser-verified: QR canvas renders, SW 'controlled', manifest linked,
no console errors. 199 tests green, build ok.
Root cause: whole tab was draggable=true, so a click with any micro-movement
became a (no-op) drag instead of a click → switch failed. Plus every event
(activity/title/status) rebuilt the entire tab bar via replaceChildren, which
could destroy the element mid-click.
Fix:
- switch on pointerdown (fires before drag arms; also works on touch)
- build tab DOM once; update classes/dot/label IN PLACE (refreshTab); full
rebuild only on structural change (add/close/reorder/rename)
- close/rename-input stopPropagation on pointerdown so they don't re-activate
Verified: single pointerdown/click switches reliably across tabs; rename + drag
still work. 198 tests green.
- title-util.ts: folderFromTitle() parses 'user@host:~/path' → last folder
segment (home stays ~); pure, dependency-free, 7 unit tests
- terminal-session.ts: onTitleChange → folderFromTitle so the tab auto-names
to the cwd folder and updates live on cd
- Browser-verified: ~ → cd project → 'web-terminal' → cd docs → 'docs' → cd ~ → '~'.
198 tests green, typecheck + build ok.
- terminal-session.ts: track SessionStatus (connecting/connected/reconnecting/
exited) + onStatus callback; FIX: wire onTitleChange (onTitle was in opts but
never bound — auto-title was dead)
- tabs.ts: per-tab status dot (color = connection state), unread flag set on
background output + cleared on activate, HTML5 drag-to-reorder (preserves
active tab, persisted)
- style.css: .tab-dot colors, .unread ring, .dragging/.drag-over feedback
- Browser-verified: green dot connected, unread ring after bg output, reorder
AAA|BBB→BBB|AAA persists across reload. 191 tests green, typechecks + build ok.