diff --git a/README.md b/README.md
index 7d821538..6bcfbaab 100644
--- a/README.md
+++ b/README.md
@@ -483,7 +483,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|----------|--------|
| `Ctrl/Cmd+W` | Kill active session |
| `Ctrl/Cmd+Tab` | Next session |
-| `Alt+1`–`Alt+9` | Switch to tab N |
+| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
+| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
diff --git a/src/web/public/app.js b/src/web/public/app.js
index cb63d649..77db3b69 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -770,14 +770,31 @@ class CodemanApp {
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
}
- // Alt+1-9: switch to Codeman session by index
- if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
- const idx = parseInt(e.key) - 1;
- if (idx < this.sessionOrder.length) {
+ // Option/Alt session navigation uses physical key CODES, not e.key, so macOS
+ // keyboard layouts that emit special characters under Option (Option+1 -> ¡,
+ // Option+[ -> "“") still switch sessions. e.code is the physical key regardless
+ // of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
+ if (e.altKey && !e.ctrlKey && !e.shiftKey) {
+ const code = e.code || '';
+ const digitMatch = code.match(/^Digit([1-9])$/);
+ if (digitMatch) {
+ const idx = parseInt(digitMatch[1], 10) - 1;
+ if (idx < this.sessionOrder.length) {
+ e.preventDefault();
+ this.selectSession(this.sessionOrder[idx]);
+ }
+ return;
+ }
+ if (e.code === 'BracketLeft') {
e.preventDefault();
- this.selectSession(this.sessionOrder[idx]);
+ this.prevSession();
+ return;
+ }
+ if (e.code === 'BracketRight') {
+ e.preventDefault();
+ this.nextSession();
+ return;
}
- return;
}
// Match against shortcut table
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 47c61a65..90526869 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -508,7 +508,8 @@
Ctrl+W
Close Session
Ctrl+Tab
Next Session
-
Alt+1-9
Switch to Tab N
+
Alt/Option+[ / Alt/Option+]
Previous / Next Session
+
Alt/Option+1-9
Switch to Tab N
Ctrl+L
Clear Terminal
Ctrl++
Increase Font
Ctrl+-
Decrease Font
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index c6edc476..c39f91a2 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -114,8 +114,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
- // Let Alt+digit pass through to browser (tab switching)
- if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
+ // Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
+ // (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC
into
+ // the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
+ // macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
+ // don't leak an escape sequence into the focused terminal on every tab switch.
+ if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
+ return false;
+ }
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
// Route through our paste trap which handles both images and text.
diff --git a/test/keyboard-shortcuts.test.ts b/test/keyboard-shortcuts.test.ts
new file mode 100644
index 00000000..7b93e541
--- /dev/null
+++ b/test/keyboard-shortcuts.test.ts
@@ -0,0 +1,37 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+
+const appSource = readFileSync('src/web/public/app.js', 'utf8');
+const terminalUiSource = readFileSync('src/web/public/terminal-ui.js', 'utf8');
+const helpHtml = readFileSync('src/web/public/index.html', 'utf8');
+const readme = readFileSync('README.md', 'utf8');
+
+describe('keyboard shortcuts', () => {
+ it('uses physical Option+number keys so macOS special characters do not break tab switching', () => {
+ expect(appSource).toContain('e.code ||');
+ expect(appSource).toContain('Digit([1-9])');
+ expect(appSource).toContain('parseInt(digitMatch[1], 10) - 1');
+ });
+
+ it('provides Option+bracket shortcuts for previous and next session', () => {
+ expect(appSource).toContain("e.code === 'BracketLeft'");
+ expect(appSource).toContain("e.code === 'BracketRight'");
+ expect(appSource).toContain('this.prevSession()');
+ expect(appSource).toContain('this.nextSession()');
+ });
+
+ it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
+ // terminal-ui.js must gate its xterm pass-through on the SAME physical e.code set the
+ // app.js handler consumes; otherwise Alt+[ / Alt+] (and Option+digit on remapped macOS
+ // layouts) switch tabs AND inject ESC into the focused terminal. Keep in sync.
+ expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code');
+ });
+
+ it('documents the Alt/Option shortcuts in help and README', () => {
+ expect(helpHtml).toContain('Alt/Option+[');
+ expect(helpHtml).toContain('Alt/Option+]');
+ expect(helpHtml).toContain('Alt/Option+1-9');
+ expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
+ expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
+ });
+});