feat(v0.3): keybar Claude shortcuts + scrollback search + clickable links

- keybar: add Esc·Esc / ^O / ^T / ^B + per-button tooltips (KEY_MAP 14 keys)
- M1 search: @xterm/addon-search per terminal; 🔍 toolbar button + search box
  (Enter=next, Shift+Enter=prev, Esc=close)
- M2 links: @xterm/addon-web-links per terminal (tap URLs Claude prints)
- tab bar split into #tabs (scrollable) + #toolbar (utility cluster) for future
  utilities (QR/settings/dashboard)
- Browser-verified: 14 keybar buttons, search highlights matches, no console errors.
  199 tests green, typechecks + build ok.
This commit is contained in:
Yaojia Wang
2026-06-17 18:28:40 +02:00
parent 110c1752d4
commit eaeacf3a78
10 changed files with 576 additions and 42 deletions

View File

@@ -8,7 +8,10 @@
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="tabbar"></div>
<div id="tabbar">
<div id="tabs"></div>
<div id="toolbar"></div>
</div>
<div id="term"></div>
<div id="keybar"></div>
<script type="module" src="./build/main.js"></script>

View File

@@ -1,19 +1,22 @@
/**
* public/keybar.ts — Mobile touch key bar, tuned for Claude Code.
* public/keybar.ts — Mobile/desktop touch key bar, tuned for Claude Code.
*
* Exports pure key-to-byte mappings and a mount function that binds DOM buttons
* to onSend on touchstart (bypassing xterm so the soft keyboard doesn't pop up).
* Buttons send terminal byte sequences a phone soft keyboard can't easily
* produce, most-used first. Order/selection follows the official Claude Code
* keyboard-shortcut docs (interactive-mode / keybindings).
*
* 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
* Esc \x1b interrupt Claude / dismiss (primary)
* Esc·Esc \x1b\x1b rewind / clear draft
* Shift+Tab \x1b[Z cycle plan / auto-accept mode
* ↑ ↓ \x1b[A/B select menu option / history
* Enter \r confirm
* Ctrl+C \x03 cancel / quit
* Ctrl+O \x0f expand transcript / tool detail
* Ctrl+T \x14 toggle task list
* Ctrl+B \x02 background running task
* Tab \t complete / toggle
* ← → \x1b[D/C cursor
* / / slash-command launcher
*/
import type { MountKeybar } from '../src/types.js'
@@ -21,6 +24,7 @@ import type { MountKeybar } from '../src/types.js'
/** Key name → byte string mapping (pure data, for testing). */
export const KEY_MAP = {
esc: '\x1b',
escEsc: '\x1b\x1b',
shiftTab: '\x1b[Z',
arrowUp: '\x1b[A',
arrowDown: '\x1b[B',
@@ -28,24 +32,38 @@ export const KEY_MAP = {
arrowRight: '\x1b[C',
enter: '\r',
ctrlC: '\x03',
ctrlO: '\x0f',
ctrlT: '\x14',
ctrlB: '\x02',
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' },
interface KeybarButton {
label: string
keyName: KeyName
title: string // tooltip / aria-label
primary?: boolean // styled as prominent
}
/** Button layout: most-used Claude Code keys first. */
export const KEYBAR_BUTTONS: KeybarButton[] = [
{ label: 'Esc', keyName: 'esc', title: 'Esc — interrupt Claude / dismiss', primary: true },
{ label: 'Esc²', keyName: 'escEsc', title: 'Esc Esc — rewind / clear draft' },
{ label: '⇧Tab', keyName: 'shiftTab', title: 'Shift+Tab — cycle plan / auto-accept mode' },
{ label: '↑', keyName: 'arrowUp', title: 'Up — previous option / history' },
{ label: '↓', keyName: 'arrowDown', title: 'Down — next option / history' },
{ label: '⏎', keyName: 'enter', title: 'Enter — confirm' },
{ label: '^C', keyName: 'ctrlC', title: 'Ctrl+C — cancel / quit' },
{ label: '^O', keyName: 'ctrlO', title: 'Ctrl+O — expand transcript / tool detail' },
{ label: '^T', keyName: 'ctrlT', title: 'Ctrl+T — toggle task list' },
{ label: '^B', keyName: 'ctrlB', title: 'Ctrl+B — background running task' },
{ label: 'Tab', keyName: 'tab', title: 'Tab — complete / toggle' },
{ label: '←', keyName: 'arrowLeft', title: 'Left' },
{ label: '→', keyName: 'arrowRight', title: 'Right' },
{ label: '/', keyName: 'slash', title: 'Slash — command launcher' },
]
/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */
@@ -53,13 +71,14 @@ export const mountKeybar: MountKeybar = (onSend) => {
const keybarEl = document.getElementById('keybar')
if (!keybarEl) return
for (const { label, keyName, primary } of KEYBAR_BUTTONS) {
for (const { label, keyName, title, 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)
btn.title = title
btn.setAttribute('aria-label', title)
const bytes = KEY_MAP[keyName]

View File

@@ -1,27 +1,34 @@
/**
* public/main.ts — esbuild entry point for the browser frontend.
*
* Bootstraps the multi-tab app: a tab bar (#tabbar) over a stack of terminal
* panes (#term), each tab an independent TerminalSession (own WS + server
* session). The mobile key bar routes input to the active tab.
* Bootstraps the multi-tab app: a tab bar (#tabs) + a utility toolbar (#toolbar)
* over a stack of terminal panes (#term), each tab an independent TerminalSession.
* The mobile key bar routes input to the active tab.
*
* Per-session terminal/WS/reconnect logic lives in terminal-session.ts;
* tab management + persistence in tabs.ts.
*/
// CSS side-effect import processed by esbuild (→ public/build/main.css).
// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic.
// @ts-ignore
// @ts-ignore CSS side-effect import processed by esbuild (→ public/build/main.css).
import '@xterm/xterm/css/xterm.css'
import { mountKeybar } from './keybar.js'
import { TabApp } from './tabs.js'
import { mountSearch } from './search.js'
const paneHost = document.getElementById('term')
const tabBar = document.getElementById('tabbar')
const tabs = document.getElementById('tabs')
const toolbar = document.getElementById('toolbar')
if (!paneHost) throw new Error('#term element not found in DOM')
if (!tabBar) throw new Error('#tabbar element not found in DOM')
if (!tabs) throw new Error('#tabs element not found in DOM')
if (!toolbar) throw new Error('#toolbar element not found in DOM')
const app = new TabApp(paneHost, tabBar)
const app = new TabApp(paneHost, tabs)
// Key bar (mobile) sends to whichever tab is active.
// Key bar (mobile + desktop) sends to whichever tab is active.
mountKeybar((data) => app.sendToActive(data))
// Toolbar utilities.
mountSearch(toolbar, {
find: (query, dir) => app.findInActive(query, dir),
clear: () => app.clearActiveSearch(),
})

65
public/search.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* public/search.ts — scrollback search box for the active terminal (M1).
*
* Adds a 🔍 button to the toolbar that toggles a small search overlay.
* Enter = next, Shift+Enter = previous, Esc = close.
*/
export interface SearchHooks {
find: (query: string, dir: 'next' | 'prev') => void
clear: () => void
}
function makeBtn(label: string, title: string): HTMLButtonElement {
const b = document.createElement('button')
b.textContent = label
b.title = title
b.setAttribute('aria-label', title)
return b
}
export function mountSearch(toolbar: HTMLElement, hooks: SearchHooks): void {
const box = document.createElement('div')
box.id = 'searchbox'
box.style.display = 'none'
const input = document.createElement('input')
input.type = 'text'
input.className = 'search-input'
input.placeholder = 'Search scrollback…'
const prev = makeBtn('↑', 'Previous match (Shift+Enter)')
const next = makeBtn('↓', 'Next match (Enter)')
const close = makeBtn('×', 'Close (Esc)')
box.append(input, prev, next, close)
document.body.appendChild(box)
const open = (): void => {
box.style.display = 'flex'
input.focus()
input.select()
}
const hide = (): void => {
box.style.display = 'none'
hooks.clear()
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
hooks.find(input.value, e.shiftKey ? 'prev' : 'next')
} else if (e.key === 'Escape') {
e.preventDefault()
hide()
}
e.stopPropagation()
})
prev.addEventListener('click', () => hooks.find(input.value, 'prev'))
next.addEventListener('click', () => hooks.find(input.value, 'next'))
close.addEventListener('click', hide)
const toggle = makeBtn('🔍', 'Search scrollback')
toggle.className = 'toolbtn'
toggle.addEventListener('click', () => (box.style.display === 'none' ? open() : hide()))
toolbar.appendChild(toggle)
}

View File

@@ -36,12 +36,77 @@ html, body {
border-bottom: 1px solid #3a3a3a;
display: flex;
align-items: stretch;
z-index: 1001;
}
/* tabs: scrollable; toolbar: fixed cluster on the right */
#tabs {
flex: 1 1 auto;
display: flex;
align-items: stretch;
overflow-x: auto;
overflow-y: hidden;
z-index: 1001;
scrollbar-width: thin;
}
#toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
border-left: 1px solid #3a3a3a;
}
.toolbtn {
background: none;
border: none;
color: #aaa;
cursor: pointer;
font-size: 15px;
padding: 0 12px;
height: 100%;
}
.toolbtn:hover {
color: #fff;
background-color: #2a2a2a;
}
/* Search box overlay (M1) */
#searchbox {
position: fixed;
top: calc(var(--tabbar-h) + 6px);
right: 8px;
z-index: 1100;
display: flex;
gap: 4px;
padding: 6px;
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.search-input {
width: 180px;
padding: 4px 6px;
font: inherit;
font-size: 13px;
color: #fff;
background: #111;
border: 1px solid #444;
border-radius: 3px;
outline: none;
}
#searchbox button {
background: #333;
border: 1px solid #444;
color: #ddd;
cursor: pointer;
border-radius: 3px;
padding: 0 8px;
}
#searchbox button:hover {
background: #555;
}
.tab {
display: flex;
align-items: center;

View File

@@ -206,6 +206,18 @@ export class TabApp {
this.tabs[this.activeIndex]?.session.send(data)
}
/** Scrollback search in the active tab (M1). */
findInActive(query: string, dir: 'next' | 'prev'): void {
const s = this.tabs[this.activeIndex]?.session
if (!s) return
if (dir === 'next') s.findNext(query)
else s.findPrevious(query)
}
clearActiveSearch(): void {
this.tabs[this.activeIndex]?.session.clearSearch()
}
/* ── rendering ───────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */

View File

@@ -10,6 +10,8 @@
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 { folderFromTitle } from './title-util.js'
@@ -56,6 +58,7 @@ export class TerminalSession {
private readonly term: Terminal
private readonly fitAddon: FitAddon
private readonly searchAddon: SearchAddon
private readonly resizeObserver: ResizeObserver
private readonly onSessionId: (id: string) => void
private readonly onActivity: (() => void) | undefined
@@ -92,6 +95,9 @@ export class TerminalSession {
})
this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon)
this.searchAddon = new SearchAddon()
this.term.loadAddon(this.searchAddon)
this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it
this.term.open(this.el)
this.term.onData((data) => this.send(data))
@@ -244,6 +250,17 @@ export class TerminalSession {
this.ws.send(buildMessage({ type: 'input', data }))
}
/** Scrollback search (M1). */
findNext(query: string): void {
this.searchAddon.findNext(query)
}
findPrevious(query: string): void {
this.searchAddon.findPrevious(query)
}
clearSearch(): void {
this.searchAddon.clearDecorations()
}
/** Make this pane visible, fit it, and focus it. */
show(): void {
this.el.style.display = 'block'