mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(split): terminal shortcuts, voice and paste follow the focused pane
With the split open, every app-level terminal action resolved against Pane A: Ctrl+L typed into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's PTY, and Ctrl+Shift+R restored Pane A's size. _focusedPane() now answers with the terminal focused LAST (a mic or header click moves DOM focus but not the user's pane): Pane B claims it from its own textarea's focus, the primary terminal's focus gives it back, and a destroyed pane never holds it. Ctrl+L, Ctrl+Shift+R, voice dictation and image paste act on the focused pane. Ctrl+W deliberately still closes the active session: it kills with no confirm, so moving it is an owner decision (docs/tile-grid-plan.md, decision 5). Also destroys every tile after each terminal-tile-input test: a real reconnect timer from one test opened a socket in a later one and flaked under full-suite load. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,7 @@
|
|||||||
// Whether the pointer is over a link in THIS pane (the primary pane's own
|
// Whether the pointer is over a link in THIS pane (the primary pane's own
|
||||||
// flag, app._linkHovered, belongs to its terminal alone).
|
// flag, app._linkHovered, belongs to its terminal alone).
|
||||||
this._linkHovered = false;
|
this._linkHovered = false;
|
||||||
|
this._onFocusIn = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
@@ -151,6 +152,11 @@
|
|||||||
|
|
||||||
this._installWheelListener();
|
this._installWheelListener();
|
||||||
|
|
||||||
|
// Focusing this terminal makes it the pane the keyboard is in, so the
|
||||||
|
// app-level shortcuts, voice and paste act on it (app._focusedPane).
|
||||||
|
this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
|
||||||
|
this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
|
||||||
|
|
||||||
this.terminal.onData((data) => this._onTerminalData(data));
|
this.terminal.onData((data) => this._onTerminalData(data));
|
||||||
|
|
||||||
// Pane B has no gates of its own by default, so every app-level chord
|
// Pane B has no gates of its own by default, so every app-level chord
|
||||||
@@ -850,6 +856,13 @@
|
|||||||
this._onWheel = null;
|
this._onWheel = null;
|
||||||
}
|
}
|
||||||
this._detachSocket();
|
this._detachSocket();
|
||||||
|
if (this._onFocusIn) {
|
||||||
|
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
|
||||||
|
this._onFocusIn = null;
|
||||||
|
}
|
||||||
|
// A destroyed pane cannot hold the keyboard: shortcuts fall back to the
|
||||||
|
// primary terminal (_focusedPane also skips a destroyed tile on its own).
|
||||||
|
if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
|
||||||
if (this.terminal) {
|
if (this.terminal) {
|
||||||
this.terminal.dispose();
|
this.terminal.dispose();
|
||||||
this.terminal = null;
|
this.terminal = null;
|
||||||
|
|||||||
@@ -544,6 +544,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._installMobileTapMouseGuard();
|
this._installMobileTapMouseGuard();
|
||||||
this._installShiftDragSelection();
|
this._installShiftDragSelection();
|
||||||
this._installTouchSelectionFocusGuard();
|
this._installTouchSelectionFocusGuard();
|
||||||
|
// Focus coming back to the primary terminal ends a second pane's claim on
|
||||||
|
// the keyboard (see _focusedPane).
|
||||||
|
this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null));
|
||||||
|
|
||||||
// Let xterm's CompositionHelper own IME key events. In particular, a
|
// Let xterm's CompositionHelper own IME key events. In particular, a
|
||||||
// non-composing keyCode 229 is how an active IME commits numbers and
|
// non-composing keyCode 229 is how an active IME commits numbers and
|
||||||
@@ -4498,15 +4501,27 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary }`.
|
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`.
|
||||||
*
|
*
|
||||||
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
|
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
|
||||||
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
|
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
|
||||||
* primary pane. Today it always answers with the primary pane; the split
|
* primary pane. It answers with the pane whose terminal was focused LAST, not
|
||||||
* pane's second terminal plugs in here once it tracks its own focus.
|
* with `document.activeElement`: clicking the mic or a header button moves
|
||||||
|
* DOM focus to that button, and the dictation it starts still belongs to the
|
||||||
|
* pane the user was typing in. A second pane (the split pane's Pane B) claims
|
||||||
|
* it from its own terminal's focus; the primary terminal's focus gives it back.
|
||||||
*/
|
*/
|
||||||
_focusedPane() {
|
_focusedPane() {
|
||||||
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true };
|
const tile = this._focusedTile;
|
||||||
|
if (tile && !tile._destroyed && tile.terminal) {
|
||||||
|
return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile };
|
||||||
|
}
|
||||||
|
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Record which second pane holds the keyboard (null: the primary terminal). */
|
||||||
|
_noteFocusedTile(tile) {
|
||||||
|
this._focusedTile = tile || null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4519,8 +4534,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._splitPane?.terminal) fn(this._splitPane);
|
if (this._splitPane?.terminal) fn(this._splitPane);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Clears the pane the keyboard is in. The chord itself also reaches that
|
||||||
|
// pane's xterm (the capture handler only preventDefault()s), so the ^L lands
|
||||||
|
// in the same pane whose display is cleared, never a different one.
|
||||||
clearTerminal() {
|
clearTerminal() {
|
||||||
this.terminal.clear();
|
this._focusedPane().terminal?.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Insert editable text at the active prompt without pressing Enter. */
|
/** Insert editable text at the active prompt without pressing Enter. */
|
||||||
@@ -4584,6 +4602,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
||||||
*/
|
*/
|
||||||
async restoreTerminalSize() {
|
async restoreTerminalSize() {
|
||||||
|
// A second pane owns its own geometry: refit it and force its PTY to the
|
||||||
|
// size it renders at (TerminalTile.fit), whatever another device set.
|
||||||
|
const pane = this._focusedPane();
|
||||||
|
if (!pane.isPrimary) {
|
||||||
|
pane.tile.fit({ force: true });
|
||||||
|
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.activeSessionId) {
|
if (!this.activeSessionId) {
|
||||||
this.showToast('No active session', 'warning');
|
this.showToast('No active session', 'warning');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview App-level terminal shortcuts act on the pane the keyboard is in.
|
||||||
|
*
|
||||||
|
* With the split pane open, the global shortcut handler (app.js, capture phase)
|
||||||
|
* used to resolve every terminal action against the PRIMARY pane: Ctrl+L typed
|
||||||
|
* into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's
|
||||||
|
* PTY, and Ctrl+Shift+R restored Pane A's size. `_focusedPane()` (terminal-ui.js)
|
||||||
|
* now answers with the pane whose terminal was focused last, and the actions
|
||||||
|
* that are about a TERMINAL (clear, restore size) go through it.
|
||||||
|
*
|
||||||
|
* Ctrl+W deliberately does NOT follow focus yet: it kills a session outright,
|
||||||
|
* with no confirm, so moving it changes which agent a muscle-memory press kills
|
||||||
|
* (docs/tile-grid-plan.md, decision 5). It stays on the active session.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|
||||||
|
function loadApp() {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: {},
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
setTimeout: () => 1,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
app.activeSessionId = 'session-a';
|
||||||
|
app.terminal = { clear: vi.fn(), cols: 120, rows: 40 };
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
app.sendResize = vi.fn(async () => true);
|
||||||
|
app.getTerminalDimensions = () => ({ cols: 120, rows: 40 });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneB(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
terminal: { clear: vi.fn(), cols: 60, rows: 30 },
|
||||||
|
fit: vi.fn(),
|
||||||
|
_destroyed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('_focusedPane()', () => {
|
||||||
|
it('is the primary pane until another pane claims the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
const pane = app._focusedPane();
|
||||||
|
|
||||||
|
expect(pane.isPrimary).toBe(true);
|
||||||
|
expect(pane.terminal).toBe(app.terminal);
|
||||||
|
expect(pane.sessionId).toBe('session-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the second pane after its terminal was focused, and the primary again after the primary was', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
expect(app._focusedPane()).toMatchObject({ isPrimary: false, sessionId: 'session-b', terminal: tile.terminal });
|
||||||
|
|
||||||
|
app._noteFocusedTile(null);
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never answers with a destroyed pane', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
tile._destroyed = true;
|
||||||
|
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal shortcuts follow the focused pane', () => {
|
||||||
|
it('Ctrl+L clears the focused second pane, not the primary', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(tile.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.terminal.clear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+L still clears the primary when it holds the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ctrl+Shift+R forces the focused second pane's size onto its own PTY", async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(tile.fit).toHaveBeenCalledWith({ force: true });
|
||||||
|
expect(app.sendResize).not.toHaveBeenCalled();
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ctrl+W stays on the active session (decision 5)', () => {
|
||||||
|
it('killActiveSession closes activeSessionId and never consults the focused pane', () => {
|
||||||
|
const appSource = read('app.js');
|
||||||
|
const body = appSource.slice(
|
||||||
|
appSource.indexOf('async killActiveSession() {'),
|
||||||
|
appSource.indexOf('async killAllSessions() {')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('await this.closeSession(this.activeSessionId);');
|
||||||
|
expect(body).not.toContain('_focusedPane');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,18 @@ class FakeTerminal {
|
|||||||
this.dataCb = cb;
|
this.dataCb = cb;
|
||||||
}
|
}
|
||||||
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
||||||
|
focusListeners: Array<() => void> = [];
|
||||||
|
textarea = {
|
||||||
|
addEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners.push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
focusTextarea() {
|
||||||
|
for (const fn of this.focusListeners) fn();
|
||||||
|
}
|
||||||
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
||||||
this.keyHandler = fn;
|
this.keyHandler = fn;
|
||||||
}
|
}
|
||||||
@@ -199,6 +211,13 @@ type Tile = {
|
|||||||
};
|
};
|
||||||
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tile a test creates, destroyed after it. A tile closed with real timers
|
||||||
|
* schedules a real reconnect, and one firing during a LATER test opens a socket
|
||||||
|
* there (FakeSocket.instances is shared), which flaked under full-suite load.
|
||||||
|
*/
|
||||||
|
const liveTiles: Tile[] = [];
|
||||||
|
|
||||||
async function connectTile(app: App, opts: Record<string, unknown> = {}) {
|
async function connectTile(app: App, opts: Record<string, unknown> = {}) {
|
||||||
windowStub.app = app;
|
windowStub.app = app;
|
||||||
const tile = new TerminalTile(
|
const tile = new TerminalTile(
|
||||||
@@ -206,12 +225,14 @@ async function connectTile(app: App, opts: Record<string, unknown> = {}) {
|
|||||||
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
{ mode: 'claude', ...opts }
|
{ mode: 'claude', ...opts }
|
||||||
);
|
);
|
||||||
|
liveTiles.push(tile);
|
||||||
await tile.connect();
|
await tile.connect();
|
||||||
const ws = FakeSocket.instances.at(-1)!;
|
const ws = FakeSocket.instances.at(-1)!;
|
||||||
return { tile, ws, term: FakeTerminal.last! };
|
return { tile, ws, term: FakeTerminal.last! };
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
for (const tile of liveTiles.splice(0)) tile.destroy();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -640,3 +661,18 @@ describe('TerminalTile links and paste follow THIS pane', () => {
|
|||||||
expect(paste).not.toHaveBeenCalled();
|
expect(paste).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
|
||||||
|
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, term } = await connectTile(app);
|
||||||
|
expect(app._focusedTile ?? null).toBeNull();
|
||||||
|
|
||||||
|
term.focusTextarea();
|
||||||
|
expect(app._focusedTile).toBe(tile);
|
||||||
|
|
||||||
|
tile.destroy();
|
||||||
|
expect(app._focusedTile).toBeNull();
|
||||||
|
expect(term.focusListeners).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user