diff --git a/public/keybar.ts b/public/keybar.ts index 747b579..c5c7d66 100644 --- a/public/keybar.ts +++ b/public/keybar.ts @@ -2,8 +2,10 @@ * public/keybar.ts — Mobile/desktop touch key bar, tuned for Claude Code. * * 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). + * produce, most-used first. Each button shows a short function caption under + * the glyph so it's self-documenting on touch (where tooltips don't appear). + * Order/selection follows the official Claude Code keyboard-shortcut docs + * (interactive-mode / keybindings). * * Esc \x1b interrupt Claude / dismiss (primary) * Esc·Esc \x1b\x1b rewind / clear draft @@ -11,9 +13,12 @@ * ↑ ↓ \x1b[A/B select menu option / history * Enter \r confirm * Ctrl+C \x03 cancel / quit + * Ctrl+R \x12 reverse-search command history * Ctrl+O \x0f expand transcript / tool detail + * Ctrl+L \x0c redraw screen * Ctrl+T \x14 toggle task list * Ctrl+B \x02 background running task + * Ctrl+D \x04 exit session (EOF) * Tab \t complete / toggle * ← → \x1b[D/C cursor * / / slash-command launcher @@ -32,9 +37,12 @@ export const KEY_MAP = { arrowRight: '\x1b[C', enter: '\r', ctrlC: '\x03', + ctrlR: '\x12', ctrlO: '\x0f', + ctrlL: '\x0c', ctrlT: '\x14', ctrlB: '\x02', + ctrlD: '\x04', tab: '\t', slash: '/', } as const @@ -43,6 +51,7 @@ export type KeyName = keyof typeof KEY_MAP interface KeybarButton { label: string + caption: string // short function label shown under the glyph keyName: KeyName title: string // tooltip / aria-label primary?: boolean // styled as prominent @@ -50,20 +59,23 @@ interface KeybarButton { /** 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' }, + { label: 'Esc', caption: '中断', keyName: 'esc', title: 'Esc — interrupt Claude / dismiss', primary: true }, + { label: 'Esc²', caption: '回溯', keyName: 'escEsc', title: 'Esc Esc — rewind / clear draft' }, + { label: '⇧Tab', caption: '模式', keyName: 'shiftTab', title: 'Shift+Tab — cycle plan / auto-accept mode' }, + { label: '↑', caption: '上一个', keyName: 'arrowUp', title: 'Up — previous option / history' }, + { label: '↓', caption: '下一个', keyName: 'arrowDown', title: 'Down — next option / history' }, + { label: '⏎', caption: '确认', keyName: 'enter', title: 'Enter — confirm' }, + { label: '^C', caption: '取消', keyName: 'ctrlC', title: 'Ctrl+C — cancel / quit' }, + { label: '^R', caption: '搜历史', keyName: 'ctrlR', title: 'Ctrl+R — reverse-search command history' }, + { label: '^O', caption: '详情', keyName: 'ctrlO', title: 'Ctrl+O — expand transcript / tool detail' }, + { label: '^L', caption: '重绘', keyName: 'ctrlL', title: 'Ctrl+L — redraw screen' }, + { label: '^T', caption: '任务', keyName: 'ctrlT', title: 'Ctrl+T — toggle task list' }, + { label: '^B', caption: '后台', keyName: 'ctrlB', title: 'Ctrl+B — background running task' }, + { label: '^D', caption: '退出', keyName: 'ctrlD', title: 'Ctrl+D — exit session (EOF)' }, + { label: 'Tab', caption: '补全', keyName: 'tab', title: 'Tab — complete / toggle' }, + { label: '←', caption: '左移', keyName: 'arrowLeft', title: 'Left — move cursor left' }, + { label: '→', caption: '右移', keyName: 'arrowRight', title: 'Right — move cursor right' }, + { label: '/', caption: '命令', keyName: 'slash', title: 'Slash — command launcher' }, ] /** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */ @@ -71,15 +83,22 @@ export const mountKeybar: MountKeybar = (onSend) => { const keybarEl = document.getElementById('keybar') if (!keybarEl) return - for (const { label, keyName, title, primary } of KEYBAR_BUTTONS) { + for (const { label, caption, 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.title = title btn.setAttribute('aria-label', title) + const keyEl = document.createElement('span') + keyEl.className = 'kb-key' + keyEl.textContent = label + const capEl = document.createElement('span') + capEl.className = 'kb-cap' + capEl.textContent = caption + btn.append(keyEl, capEl) + const bytes = KEY_MAP[keyName] // touchstart: send immediately, prevent default so the soft keyboard stays hidden. diff --git a/public/main.ts b/public/main.ts index 3cc004d..fd1858d 100644 --- a/public/main.ts +++ b/public/main.ts @@ -18,6 +18,7 @@ import { mountQrConnect } from './qr.js' import { mountSettings, loadSettings, type Settings } from './settings.js' import { mountDashboard } from './dashboard.js' import { mountHistory } from './history.js' +import { mountShortcuts } from './shortcuts.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -55,6 +56,7 @@ mountDashboard(toolbar, { mountHistory(toolbar, { resume: (cwd, id) => app.newTabForResume(cwd, id), }) +mountShortcuts(toolbar) mountQrConnect(toolbar) // PWA: register the service worker (installable + offline shell, M4). diff --git a/public/shortcuts.ts b/public/shortcuts.ts new file mode 100644 index 0000000..f10e0eb --- /dev/null +++ b/public/shortcuts.ts @@ -0,0 +1,142 @@ +/** + * public/shortcuts.ts — Claude Code keyboard cheat-sheet (⌨ toolbar button). + * + * Opens an overlay listing the common Claude Code shortcuts grouped by purpose, + * each labeled with what it does. Keys also present on the bottom touch key bar + * are tagged "键栏". Source: official Claude Code interactive-mode docs. + */ + +interface Shortcut { + keys: string + desc: string + onBar?: boolean // also available on the bottom key bar +} + +interface ShortcutGroup { + title: string + items: Shortcut[] +} + +const GROUPS: ShortcutGroup[] = [ + { + title: '常用 General', + items: [ + { keys: 'Esc', desc: '打断 Claude / 关闭弹窗', onBar: true }, + { keys: 'Esc Esc', desc: '有输入→清空存草稿;空→打开回溯菜单', onBar: true }, + { keys: 'Ctrl+C', desc: '中断运行;无运行时清空,再按退出', onBar: true }, + { keys: 'Ctrl+D', desc: '退出会话 (EOF)', onBar: true }, + { keys: 'Shift+Tab', desc: '循环权限模式 (default/acceptEdits/plan…)', onBar: true }, + { keys: 'Ctrl+R', desc: '反向搜索命令历史', onBar: true }, + { keys: 'Ctrl+O', desc: '切换 transcript(详细工具调用)视图', onBar: true }, + { keys: 'Ctrl+L', desc: '重绘屏幕(显示错乱时救场)', onBar: true }, + { keys: 'Ctrl+T', desc: '切换任务列表', onBar: true }, + { keys: 'Ctrl+B', desc: '把命令/agent 转到后台(tmux 按两次)', onBar: true }, + { keys: '↑ / ↓', desc: '移动光标 / 翻命令历史', onBar: true }, + { keys: 'Tab', desc: '补全建议 / 接受提示', onBar: true }, + { keys: '/', desc: '命令与 skill 启动器', onBar: true }, + ], + }, + { + title: '行内编辑 Editing', + items: [ + { keys: 'Ctrl+A', desc: '光标到行首' }, + { keys: 'Ctrl+E', desc: '光标到行尾' }, + { keys: 'Ctrl+K', desc: '删除到行尾' }, + { keys: 'Ctrl+U', desc: '删除到行首' }, + { keys: 'Ctrl+W', desc: '删除前一个词' }, + { keys: 'Ctrl+Y', desc: '粘回被删除的内容' }, + { keys: 'Alt+B / Alt+F', desc: '按词左移 / 右移' }, + ], + }, + { + title: '多行输入 Multiline', + items: [ + { keys: '\\ + Enter', desc: '换行(任何终端都行)' }, + { keys: 'Shift+Enter', desc: '换行(iTerm2/WezTerm/Kitty… 原生)' }, + { keys: 'Ctrl+J', desc: '换行(无需配置,任何终端)' }, + ], + }, + { + title: 'macOS(需 Option 当 Meta)', + items: [ + { keys: 'Option+P', desc: '切换模型' }, + { keys: 'Option+T', desc: '开关 extended thinking' }, + { keys: 'Option+O', desc: '开关 fast mode' }, + ], + }, + { + title: '前缀 Prefixes', + items: [ + { keys: '! 开头', desc: 'shell 模式,直接跑命令并入上下文' }, + { keys: '@', desc: '文件路径补全' }, + ], + }, +] + +export function mountShortcuts(toolbar: HTMLElement): void { + const overlay = document.createElement('div') + overlay.id = 'shortcutsmodal' + overlay.style.display = 'none' + const card = document.createElement('div') + card.className = 'sc-card' + overlay.appendChild(card) + document.body.appendChild(overlay) + + const hide = (): void => { + overlay.style.display = 'none' + } + overlay.addEventListener('click', (e) => { + if (e.target === overlay) hide() + }) + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && overlay.style.display !== 'none') hide() + }) + + const build = (): void => { + card.replaceChildren() + const title = document.createElement('div') + title.className = 'sc-title' + title.textContent = 'Claude Code 快捷键' + card.appendChild(title) + + for (const group of GROUPS) { + const gh = document.createElement('div') + gh.className = 'sc-group' + gh.textContent = group.title + card.appendChild(gh) + + for (const sc of group.items) { + const row = document.createElement('div') + row.className = 'sc-row' + + const key = document.createElement('span') + key.className = 'sc-key' + key.textContent = sc.keys + + const desc = document.createElement('span') + desc.className = 'sc-desc' + desc.textContent = sc.desc + + row.append(key, desc) + if (sc.onBar) { + const tag = document.createElement('span') + tag.className = 'sc-tag' + tag.textContent = '键栏' + row.appendChild(tag) + } + card.appendChild(row) + } + } + } + build() + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = '⌨' + toggle.title = 'Claude Code 快捷键速查' + toggle.setAttribute('aria-label', 'Keyboard shortcuts') + toggle.addEventListener('click', () => { + overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none' + }) + toolbar.appendChild(toggle) +} diff --git a/public/style.css b/public/style.css index 7237cc4..eb0c881 100644 --- a/public/style.css +++ b/public/style.css @@ -266,14 +266,18 @@ body { } #keybar button { flex: 1 0 auto; - min-width: 46px; - height: calc(var(--keybar-h) - 14px); - padding: 0 12px; + min-width: 50px; + height: calc(var(--keybar-h) - 12px); + padding: 0 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; border: 1px solid var(--border); background: var(--surface-2); color: var(--text); font-family: var(--ui-font); - font-size: 13px; cursor: pointer; border-radius: 8px; transition: @@ -283,6 +287,16 @@ body { -webkit-user-select: none; -webkit-touch-callout: none; } +#keybar .kb-key { + font-size: 13px; + line-height: 1.1; +} +#keybar .kb-cap { + font-size: 9px; + line-height: 1; + color: var(--text-faint); + letter-spacing: 0.02em; +} #keybar button:hover { background: var(--surface-3); } @@ -300,6 +314,9 @@ body { #keybar button.keybar-btn-primary:hover { background: rgba(124, 140, 255, 0.26); } +#keybar button.keybar-btn-primary .kb-cap { + color: rgba(205, 212, 255, 0.7); +} /* ── Shared: overlays, cards, inputs, buttons ────────────────────── */ .overlay { @@ -457,9 +474,10 @@ body { text-align: center; } -/* Dashboard + history share the centered-card look */ +/* Dashboard + history + shortcuts share the centered-card look */ #dashboard, -#historymodal { +#historymodal, +#shortcutsmodal { position: fixed; inset: 0; z-index: 1200; @@ -472,7 +490,8 @@ body { backdrop-filter: blur(6px); } .dash-card, -.hist-card { +.hist-card, +.sc-card { background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); @@ -482,12 +501,60 @@ body { overflow-y: auto; box-shadow: var(--shadow); } +.sc-card { + width: 540px; +} .dash-title, -.hist-title { +.hist-title, +.sc-title { padding: 14px 18px; font-weight: 600; color: #fff; border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--surface-2); +} + +/* Shortcut cheat-sheet */ +.sc-group { + padding: 12px 18px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); +} +.sc-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 18px; +} +.sc-key { + flex: none; + min-width: 110px; + font-family: Menlo, Consolas, monospace; + font-size: 12px; + color: #fff; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 3px 8px; + text-align: center; +} +.sc-desc { + flex: 1 1 auto; + color: var(--text-dim); + font-size: 13px; +} +.sc-tag { + flex: none; + font-size: 10px; + color: var(--accent); + background: var(--accent-soft); + border-radius: 5px; + padding: 2px 6px; } .dash-row, .hist-row { diff --git a/test/keybar.test.ts b/test/keybar.test.ts index 4869c8f..b2c3b02 100644 --- a/test/keybar.test.ts +++ b/test/keybar.test.ts @@ -80,12 +80,15 @@ describe('keybar', () => { it('Claude Code control keys map to the right bytes', () => { expect(KEY_MAP.escEsc).toBe('\x1b\x1b') // rewind / clear draft + expect(KEY_MAP.ctrlR).toBe('\x12') // reverse history search expect(KEY_MAP.ctrlO).toBe('\x0f') // expand transcript + expect(KEY_MAP.ctrlL).toBe('\x0c') // redraw screen expect(KEY_MAP.ctrlT).toBe('\x14') // task list expect(KEY_MAP.ctrlB).toBe('\x02') // background + expect(KEY_MAP.ctrlD).toBe('\x04') // exit (EOF) }) - it('all 14 keys are defined', () => { + it('all 17 keys are defined', () => { const keys = Object.keys(KEY_MAP) expect(keys).toEqual([ 'esc', @@ -97,9 +100,12 @@ describe('keybar', () => { 'arrowRight', 'enter', 'ctrlC', + 'ctrlR', 'ctrlO', + 'ctrlL', 'ctrlT', 'ctrlB', + 'ctrlD', 'tab', 'slash', ]) @@ -107,8 +113,8 @@ describe('keybar', () => { it('all mapped bytes are unique strings', () => { const bytes = Object.values(KEY_MAP) - expect(bytes).toHaveLength(14) - expect(new Set(bytes).size).toBe(14) // all unique + expect(bytes).toHaveLength(17) + expect(new Set(bytes).size).toBe(17) // all unique }) it('ANSI arrow sequences use correct format', () => {