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:
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
65
public/search.ts
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user