Skip to content

Commit cae17de

Browse files
committed
feat: prefix button sends prefix then opens combo picker
New `prefix` action type: sends the prefix byte (e.g. Ctrl-B) then immediately opens the combo picker for the follow-up key. Solves the mobile UX problem where tapping the terminal to bring up the keyboard breaks tmux prefix mode. Combo picker now accepts optional title/description so the prefix flow shows contextual help ("After prefix"). Users who want raw prefix-only behaviour can override to `{ type: 'send', data: '\x02' }` via existing config system.
1 parent 122a826 commit cae17de

10 files changed

Lines changed: 293 additions & 8 deletions

File tree

.agents/skills/remobi-setup/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,13 @@ name theme font toolbar drawer gestures mobile floatingButtons pwa reco
278278
| `type` | Required fields | Notes |
279279
|------------------|---------------------|-------|
280280
| `send` | `data: string` | Optional `keyLabel?: string` for help overlay |
281+
| `prefix` | `data: string` | Sends prefix byte then opens combo picker for follow-up key. Use `{ type: 'send', data: '\x02' }` for raw prefix-only behaviour |
281282
| `ctrl-modifier` | (none) | Opens Ctrl+key combo UI |
282283
| `paste` | (none) | Paste from clipboard |
283284
| `combo-picker` | (none) | Opens Ctrl/Alt + key modal |
284285
| `drawer-toggle` | (none) | Opens/closes command drawer |
285286

286-
Non-`send` actions must NOT have `data` or `keyLabel` — the validator rejects them.
287+
Non-`send`/`prefix` actions must NOT have `data` or `keyLabel` — the validator rejects them.
287288

288289
### ControlButton shape
289290

@@ -340,7 +341,7 @@ Valid positions: `top-left | top-right | top-centre | bottom-left | bottom-right
340341
| `id` | `label` | `action` |
341342
|------|---------|----------|
342343
| `esc` | Esc | `send` `\x1b` |
343-
| `tmux-prefix` | Prefix | `send` `\x02` |
344+
| `tmux-prefix` | Prefix | `prefix` `\x02` (sends prefix then opens combo picker for follow-up key) |
344345
| `tab` | Tab | `send` `\t` |
345346
| `shift-tab` | S-Tab | `send` `\x1b[Z` |
346347
| `left` | <- | `send` `\x1b[D` |
@@ -479,7 +480,7 @@ export default {
479480
toolbar: {
480481
row1: (defaults) => defaults.map(b =>
481482
b.id === 'tmux-prefix'
482-
? { ...b, description: 'Send tmux prefix key (Ctrl-A)', action: { type: 'send', data: '\x01' } }
483+
? { ...b, description: 'Send tmux prefix key (Ctrl-A)', action: { type: 'prefix', data: '\x01' } }
483484
: b
484485
),
485486
},

src/actions/registry.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface ActionExecutionContext {
1010
readonly openComboPicker?: (options: {
1111
readonly sendText: (data: string) => Promise<void>
1212
readonly focusIfNeeded: () => void
13+
readonly title?: string
14+
readonly description?: string
1315
}) => void
1416
readonly toggleCtrlModifier?: () => void
1517
}
@@ -33,7 +35,7 @@ export function createActionRegistry(): ActionRegistry {
3335
const handler = handlers.get(action.type)
3436
if (!handler) return false
3537

36-
if (action.type === 'send') {
38+
if (action.type === 'send' || action.type === 'prefix') {
3739
const current = sendQueue.then(async () => {
3840
await handler(action, context)
3941
})
@@ -55,6 +57,17 @@ export function createActionRegistry(): ActionRegistry {
5557
return { register, execute }
5658
}
5759

60+
/** Map a prefix byte to a human-readable label (e.g. '\x02' → 'Ctrl-B') */
61+
function describePrefixByte(data: string): string | null {
62+
if (data.length !== 1) return null
63+
const code = data.charCodeAt(0)
64+
// Ctrl-A through Ctrl-Z → 0x01–0x1A
65+
if (code >= 1 && code <= 26) {
66+
return `Ctrl-${String.fromCharCode(code + 64)}`
67+
}
68+
return null
69+
}
70+
5871
export function createDefaultActionRegistry(): ActionRegistry {
5972
const registry = createActionRegistry()
6073
let pasteQueue: Promise<void> = Promise.resolve()
@@ -108,6 +121,28 @@ export function createDefaultActionRegistry(): ActionRegistry {
108121
}
109122
})
110123

124+
registry.register('prefix', async (action, context) => {
125+
if (action.type !== 'prefix') return
126+
await context.sendText(action.data)
127+
if (context.openComboPicker) {
128+
const prefixLabel = describePrefixByte(action.data)
129+
context.openComboPicker({
130+
title: `Prefix sent${prefixLabel ? ` (${prefixLabel})` : ''} — type follow-up`,
131+
description:
132+
'A letter like r (reload config) or c (new window). ' + 'C-x = Ctrl+x, M-x = Alt+x',
133+
sendText: async (data: string) => {
134+
await registry.execute(
135+
{ type: 'send', data },
136+
{ ...context, sendText: context.sendRawText ?? context.sendText },
137+
)
138+
},
139+
focusIfNeeded: context.focusIfNeeded,
140+
})
141+
} else {
142+
context.focusIfNeeded()
143+
}
144+
})
145+
111146
registry.register('combo-picker', (_action, context) => {
112147
if (context.openComboPicker) {
113148
context.openComboPicker({

src/config-schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@ const sendActionSchema = v.strictObject({
1616
keyLabel: v.optional(v.string()),
1717
})
1818

19+
const prefixActionSchema = v.strictObject({
20+
type: v.literal('prefix'),
21+
data: v.string(),
22+
})
1923
const ctrlModifierActionSchema = v.strictObject({ type: v.literal('ctrl-modifier') })
2024
const pasteActionSchema = v.strictObject({ type: v.literal('paste') })
2125
const comboPickerActionSchema = v.strictObject({ type: v.literal('combo-picker') })
2226
const drawerToggleActionSchema = v.strictObject({ type: v.literal('drawer-toggle') })
2327

2428
const buttonActionSchema = v.variant('type', [
2529
sendActionSchema,
30+
prefixActionSchema,
2631
ctrlModifierActionSchema,
2732
pasteActionSchema,
2833
comboPickerActionSchema,

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const defaultRow1: RemobiConfig['toolbar']['row1'] = [
4545
id: 'tmux-prefix',
4646
label: 'Prefix',
4747
description: 'Send tmux prefix key (Ctrl-B)',
48-
action: { type: 'send', data: '\x02' },
48+
action: { type: 'prefix', data: '\x02' },
4949
},
5050
{ id: 'tab', label: 'Tab', description: 'Send Tab key', action: { type: 'send', data: '\t' } },
5151
{

src/controls/combo-picker.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { onTap } from '../util/tap'
55
interface ComboDispatch {
66
readonly sendText: (data: string) => Promise<void>
77
readonly focusIfNeeded: () => void
8+
readonly title?: string
9+
readonly description?: string
810
}
911

1012
type ComboParseResult =
@@ -263,15 +265,22 @@ export function createComboPicker(): ComboPickerResult {
263265
}
264266
}
265267

268+
const defaultTitle = 'Send combo'
269+
const defaultDescription = 'Examples: C-s, C-[, M-Enter, Alt-x'
270+
266271
function open(dispatch: ComboDispatch): void {
267272
currentDispatch = dispatch
268273
clearError()
269274
input.value = ''
275+
title.textContent = dispatch.title ?? defaultTitle
276+
description.textContent = dispatch.description ?? defaultDescription
270277
backdrop.style.display = 'flex'
271278
setTimeout(() => input.focus(), 0)
272279
}
273280

274281
function close(): void {
282+
title.textContent = defaultTitle
283+
description.textContent = defaultDescription
275284
closeAndFocus()
276285
}
277286

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export type ButtonAction =
33
| { readonly type: 'send'; readonly data: string; readonly keyLabel?: string }
44
| { readonly type: 'ctrl-modifier' }
55
| { readonly type: 'paste' }
6+
| { readonly type: 'prefix'; readonly data: string }
67
| { readonly type: 'combo-picker' }
78
| { readonly type: 'drawer-toggle' }
89

tests/action-registry.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,107 @@ describe('createDefaultActionRegistry', () => {
349349
expect(sent).toBe('raw:abc')
350350
})
351351

352+
test('prefix action sends data then opens combo picker', async () => {
353+
const registry = createDefaultActionRegistry()
354+
const sent: string[] = []
355+
let pickerOptions: { title?: string; description?: string } | null = null
356+
357+
await registry.execute(
358+
{ type: 'prefix', data: '\x02' },
359+
{
360+
term: mockTerminal(),
361+
kbWasOpen: false,
362+
focusIfNeeded() {},
363+
async sendText(data: string) {
364+
sent.push(data)
365+
},
366+
openComboPicker(options) {
367+
pickerOptions = options
368+
},
369+
},
370+
)
371+
372+
expect(sent).toEqual(['\x02'])
373+
expect(pickerOptions).toMatchObject({
374+
title: expect.stringContaining('Ctrl-B'),
375+
description: expect.stringContaining('C-x = Ctrl+x'),
376+
})
377+
})
378+
379+
test('prefix action falls back to focus when picker unavailable', async () => {
380+
const registry = createDefaultActionRegistry()
381+
const sent: string[] = []
382+
let focused = false
383+
384+
await registry.execute(
385+
{ type: 'prefix', data: '\x02' },
386+
{
387+
term: mockTerminal(),
388+
kbWasOpen: false,
389+
focusIfNeeded() {
390+
focused = true
391+
},
392+
async sendText(data: string) {
393+
sent.push(data)
394+
},
395+
},
396+
)
397+
398+
expect(sent).toEqual(['\x02'])
399+
expect(focused).toBe(true)
400+
})
401+
402+
test('prefix action serialises with send queue', async () => {
403+
const registry = createDefaultActionRegistry()
404+
const sent: string[] = []
405+
406+
const context = {
407+
term: mockTerminal(),
408+
kbWasOpen: false,
409+
focusIfNeeded() {},
410+
async sendText(data: string) {
411+
if (data === '\x02') {
412+
await new Promise((resolve) => setTimeout(resolve, 10))
413+
}
414+
sent.push(data)
415+
},
416+
openComboPicker() {},
417+
}
418+
419+
const first = registry.execute({ type: 'prefix', data: '\x02' }, context)
420+
const second = registry.execute({ type: 'send', data: 'q' }, context)
421+
await Promise.all([first, second])
422+
423+
expect(sent).toEqual(['\x02', 'q'])
424+
})
425+
426+
test('prefix combo follow-up uses raw sender', async () => {
427+
const registry = createDefaultActionRegistry()
428+
let sent = ''
429+
let comboPromise: Promise<void> | null = null
430+
431+
await registry.execute(
432+
{ type: 'prefix', data: '\x02' },
433+
{
434+
term: mockTerminal(),
435+
kbWasOpen: false,
436+
focusIfNeeded() {},
437+
async sendText(_data: string) {},
438+
async sendRawText(data: string) {
439+
sent = `raw:${data}`
440+
},
441+
openComboPicker(options) {
442+
comboPromise = options.sendText('r')
443+
},
444+
},
445+
)
446+
447+
if (comboPromise) {
448+
await comboPromise
449+
}
450+
expect(sent).toBe('raw:r')
451+
})
452+
352453
test('combo-picker falls back to focus when picker unavailable', async () => {
353454
const registry = createDefaultActionRegistry()
354455
let focused = false

tests/buttons.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('defaultRow1', () => {
1010
test('has tmux Prefix button', () => {
1111
const prefix = defaultRow1.find((b) => b.id === 'tmux-prefix')
1212
expect(prefix).toBeDefined()
13-
expect(prefix?.action).toEqual({ type: 'send', data: '\x02' })
13+
expect(prefix?.action).toEqual({ type: 'prefix', data: '\x02' })
1414
})
1515

1616
test('has S-Tab after Tab', () => {

tests/combo-picker.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,66 @@
1-
import { describe, expect, test } from 'vitest'
2-
import { parseComboInput } from '../src/controls/combo-picker'
1+
import { GlobalRegistrator } from '@happy-dom/global-registrator'
2+
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
3+
import { createComboPicker, parseComboInput } from '../src/controls/combo-picker'
4+
5+
beforeEach(() => {
6+
GlobalRegistrator.register()
7+
})
8+
9+
afterEach(() => {
10+
GlobalRegistrator.unregister()
11+
})
12+
13+
describe('createComboPicker', () => {
14+
test('open with custom title and description sets DOM text', () => {
15+
const picker = createComboPicker()
16+
document.body.appendChild(picker.element)
17+
18+
picker.open({
19+
async sendText() {},
20+
focusIfNeeded() {},
21+
title: 'After prefix',
22+
description: 'Examples: r (reload), c (new window)',
23+
})
24+
25+
const title = picker.element.querySelector('h3')
26+
const desc = picker.element.querySelector('p')
27+
expect(title?.textContent).toBe('After prefix')
28+
expect(desc?.textContent).toBe('Examples: r (reload), c (new window)')
29+
})
30+
31+
test('open without custom title uses defaults', () => {
32+
const picker = createComboPicker()
33+
document.body.appendChild(picker.element)
34+
35+
picker.open({
36+
async sendText() {},
37+
focusIfNeeded() {},
38+
})
39+
40+
const title = picker.element.querySelector('h3')
41+
const desc = picker.element.querySelector('p')
42+
expect(title?.textContent).toBe('Send combo')
43+
expect(desc?.textContent).toBe('Examples: C-s, C-[, M-Enter, Alt-x')
44+
})
45+
46+
test('close resets title and description to defaults', () => {
47+
const picker = createComboPicker()
48+
document.body.appendChild(picker.element)
49+
50+
picker.open({
51+
async sendText() {},
52+
focusIfNeeded() {},
53+
title: 'Custom',
54+
description: 'Custom desc',
55+
})
56+
picker.close()
57+
58+
const title = picker.element.querySelector('h3')
59+
const desc = picker.element.querySelector('p')
60+
expect(title?.textContent).toBe('Send combo')
61+
expect(desc?.textContent).toBe('Examples: C-s, C-[, M-Enter, Alt-x')
62+
})
63+
})
364

465
describe('parseComboInput', () => {
566
test('parses Ctrl letter combos', () => {

0 commit comments

Comments
 (0)