feat(v0.2): multi-tab terminal + titles + Claude Code shortcut bar

Frontend feature (backend unchanged — manager was already multi-session):
- Multi-tab: each tab = independent WS + Terminal + session (terminal-session.ts,
  tabs.ts); tab bar with +, ×, middle-click close; persisted to localStorage
  (migrates v0.1 single-session key)
- Tab titles: auto from xterm onTitleChange (OSC 0/2), double-click to rename
  inline (manual wins over auto); ellipsis + tooltip
- Shortcut key bar now shown on ALL devices (clickable buttons trigger shortcuts),
  reordered for Claude Code (Esc prominent, ⇧Tab, ↑↓, ⏎, ^C, Tab, ←→, /);
  responsive sizing via @media (pointer:coarse) for touch
- Verified in browser: rename, two independent sessions, keybar on desktop +
  narrow viewport. 191 tests green; both typechecks + esbuild build pass.
This commit is contained in:
Yaojia Wang
2026-06-17 12:11:18 +02:00
parent f3b6ad5a68
commit 4f66016f02
7 changed files with 734 additions and 319 deletions

View File

@@ -1,19 +1,19 @@
/**
* public/keybar.ts — Mobile touch key bar for terminal input (T10).
* public/keybar.ts — Mobile touch key bar, tuned for Claude Code.
*
* Exports pure key-to-byte mappings and a mount function that binds DOM buttons
* to onSend callback on touchstart (bypassing xterm to avoid soft keyboard popup).
* to onSend on touchstart (bypassing xterm so the soft keyboard doesn't pop up).
*
* Key bytes (ARCHITECTURE §5):
* Esc → \x1b (0x1B)
* Shift+Tab → \x1b[Z
* ↑ → \x1b[A
* ↓ → \x1b[B
* ← → \x1b[D
* → → \x1b[C
* Enter → \r (0x0D, not \n)
* Ctrl+C → \x03 (0x03)
* Tab → \t (0x09)
* Order/selection follows Claude Code mobile habits — the keys a phone soft
* keyboard can't easily produce, most-used first (leftmost = no scroll needed):
* Esc \x1b interrupt Claude / dismiss (primary — most used)
* Shift+Tab \x1b[Z cycle plan / auto-accept mode
* ↑ ↓ \x1b[A/B select menu option / history
* Enter \r confirm
* Ctrl+C \x03 cancel / quit
* Tab \t complete / toggle
* ← → \x1b[D/C cursor
* / / slash-command launcher
*/
import type { MountKeybar } from '../src/types.js'
@@ -29,46 +29,47 @@ export const KEY_MAP = {
enter: '\r',
ctrlC: '\x03',
tab: '\t',
slash: '/',
} as const
export type KeyName = keyof typeof KEY_MAP
/** Button layout: most-used Claude Code keys first. `primary` styles a key as prominent. */
export const KEYBAR_BUTTONS: Array<{ label: string; keyName: KeyName; primary?: boolean }> = [
{ label: 'Esc', keyName: 'esc', primary: true },
{ label: '⇧Tab', keyName: 'shiftTab' },
{ label: '↑', keyName: 'arrowUp' },
{ label: '↓', keyName: 'arrowDown' },
{ label: '⏎', keyName: 'enter' },
{ label: '^C', keyName: 'ctrlC' },
{ label: 'Tab', keyName: 'tab' },
{ label: '←', keyName: 'arrowLeft' },
{ label: '→', keyName: 'arrowRight' },
{ label: '/', keyName: 'slash' },
]
/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */
export const mountKeybar: MountKeybar = (onSend) => {
const keybarEl = document.getElementById('keybar')
if (!keybarEl) return
// Button labels and their key map entries
const buttons: Array<{
label: string
keyName: KeyName
}> = [
{ label: 'Esc', keyName: 'esc' },
{ label: 'Shift+Tab', keyName: 'shiftTab' },
{ label: '↑', keyName: 'arrowUp' },
{ label: '↓', keyName: 'arrowDown' },
{ label: '←', keyName: 'arrowLeft' },
{ label: '→', keyName: 'arrowRight' },
{ label: 'Enter', keyName: 'enter' },
{ label: 'Ctrl+C', keyName: 'ctrlC' },
{ label: 'Tab', keyName: 'tab' },
]
for (const { label, keyName } of buttons) {
for (const { label, keyName, primary } of KEYBAR_BUTTONS) {
const btn = document.createElement('button')
btn.textContent = label
btn.classList.add('keybar-btn')
if (primary) btn.classList.add('keybar-btn-primary')
btn.dataset.key = keyName
btn.setAttribute('aria-label', label)
const bytes = KEY_MAP[keyName]
// touchstart: send immediately, prevent default to avoid soft keyboard
// touchstart: send immediately, prevent default so the soft keyboard stays hidden.
btn.addEventListener('touchstart', (e) => {
e.preventDefault()
onSend(bytes)
})
// For desktop testing/fallback: click also sends
// Desktop fallback: click also sends.
btn.addEventListener('click', (e) => {
e.preventDefault()
onSend(bytes)