mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(tiles): tile chords in the shortcut registry, and the grid never beside a split
Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable): - Toggle Tile Grid, Ctrl+Shift+G: opens the grid this tab last left (one step back after a selection outside it), else an open split as two tiles, else the active session as one tile; pressed again, back to the single view of the focused session. xterm emits nothing for a shifted Ctrl letter; the browser's find-previous is overridden only where the grid can open. - Focus Tile Left/Right/Up/Down, Alt+Shift+Arrows: a human selection of the tile in that direction. - Remove Focused Tile, unbound: the session keeps running. tileShortcutFor() decides whether a chord applies (the toggle wherever a grid could open, the rest only while one is open, so outside the grid Alt+Shift+Arrows reach the terminal untouched) and is registry-aware. The capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and before the Shift+Enter gate, so a chord that applies never reaches a PTY. Coexistence with the split pane: opening the grid over an open split closes it (no wasted resize for the pane about to park) and seeds the grid with both of its sessions, Pane A focused. While the grid is open openSplitPicker and openSplitPane refuse and the Split button reads as unavailable (aria-disabled); closing the grid never reopens a split. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -525,6 +525,56 @@ const DEFAULT_SHORTCUTS = [
|
||||
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
||||
action: 'toggleSessionSidebar',
|
||||
},
|
||||
// Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut()
|
||||
// and deliberately absent from SHORTCUT_ACTIONS: each applies only in some
|
||||
// states (the focus chords only while the grid is open), and outside them the
|
||||
// chord must reach the terminal untouched. Every xterm key handler swallows a
|
||||
// chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes
|
||||
// xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's
|
||||
// find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs.
|
||||
{
|
||||
id: 'toggle-tile-grid',
|
||||
group: 'Tiles',
|
||||
label: 'Toggle Tile Grid',
|
||||
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }],
|
||||
action: 'toggleTileGrid',
|
||||
},
|
||||
{
|
||||
id: 'focus-tile-left',
|
||||
group: 'Tiles',
|
||||
label: 'Focus Tile Left',
|
||||
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowLeft' }],
|
||||
action: 'focusTileLeft',
|
||||
},
|
||||
{
|
||||
id: 'focus-tile-right',
|
||||
group: 'Tiles',
|
||||
label: 'Focus Tile Right',
|
||||
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowRight' }],
|
||||
action: 'focusTileRight',
|
||||
},
|
||||
{
|
||||
id: 'focus-tile-up',
|
||||
group: 'Tiles',
|
||||
label: 'Focus Tile Up',
|
||||
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowUp' }],
|
||||
action: 'focusTileUp',
|
||||
},
|
||||
{
|
||||
id: 'focus-tile-down',
|
||||
group: 'Tiles',
|
||||
label: 'Focus Tile Down',
|
||||
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
|
||||
action: 'focusTileDown',
|
||||
},
|
||||
{
|
||||
id: 'remove-tile',
|
||||
group: 'Tiles',
|
||||
label: 'Remove Focused Tile',
|
||||
// Unbound by default; the session keeps running either way.
|
||||
bindings: [],
|
||||
action: 'removeTile',
|
||||
},
|
||||
{
|
||||
id: 'previous-next-session',
|
||||
group: 'Session',
|
||||
@@ -1362,6 +1412,15 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
|
||||
// so outside the grid Alt+Shift+Arrows still reach the terminal.
|
||||
const tileShortcut = this.tileShortcutFor?.(e);
|
||||
if (tileShortcut) {
|
||||
e.preventDefault();
|
||||
this.runTileShortcut(tileShortcut);
|
||||
return;
|
||||
}
|
||||
|
||||
// Match against the shortcut registry so user rebinds and per-shortcut
|
||||
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
||||
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
|
||||
|
||||
@@ -2510,6 +2510,12 @@ html[data-tab-orientation='vertical'].tab-rail-compact
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The tile grid is open (tile-grid.js): the split cannot open beside it. */
|
||||
.btn-split.btn-split--blocked {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* While a split is open, the button's own click closes it instead of opening
|
||||
the picker — the accent fill is what tells the user which of its two
|
||||
behaviours the next click will get (aria-pressed carries the same state
|
||||
|
||||
@@ -46,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the old exact-node check below) bubbled straight through to
|
||||
// `document` and self-closed the menu it just opened.
|
||||
event?.stopPropagation();
|
||||
// The tile grid and the split are never open together (tile-grid.js); the
|
||||
// Split button shows as unavailable meanwhile.
|
||||
if (this._tilesOwnTerminal?.()) return;
|
||||
if (this._splitPane) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
@@ -139,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||
// Never beside the tile grid: the main terminal is parked while it is open.
|
||||
if (this._tilesOwnTerminal?.()) return;
|
||||
// No active session means there is no `.terminal-wrap` to split against
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
|
||||
@@ -208,6 +208,10 @@
|
||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
// Tile grid chords (focus, toggle): acted on by the capture handler, so
|
||||
// they must never reach this tile's PTY. Every event type, and before
|
||||
// the Shift+Enter branch below.
|
||||
if (global.app?.tileShortcutFor?.(ev)) return false;
|
||||
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
|
||||
// a pasted image uploads to this pane's session and its path is typed
|
||||
// here, and pasted text goes into this xterm with its bracketed-paste
|
||||
|
||||
@@ -590,6 +590,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tile grid chords (Ctrl+Shift+G, Alt+Shift+Arrows): the capture handler
|
||||
// has already acted on one that applies, and its preventDefault() does not
|
||||
// stop xterm. Every event type, and BEFORE the Shift+Enter branch below.
|
||||
if (this.tileShortcutFor?.(ev)) return false;
|
||||
|
||||
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
|
||||
// ^C. With NO selection the branch must fall through (return true, and no
|
||||
// preventDefault) or the interrupt key is lost, which is the whole reason
|
||||
|
||||
+108
-1
@@ -26,6 +26,16 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
|
||||
// Trailing debounce for refitting tiles after the grid area changes size, so a
|
||||
// window drag sends each tile's PTY one resize, not one per frame.
|
||||
const TILE_GRID_REFIT_MS = 150;
|
||||
// Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each
|
||||
// needs the grid open. The toggle applies wherever a grid could open.
|
||||
const TILE_SHORTCUTS = {
|
||||
'toggle-tile-grid': { needsOpen: false },
|
||||
'focus-tile-left': { needsOpen: true, direction: 'left' },
|
||||
'focus-tile-right': { needsOpen: true, direction: 'right' },
|
||||
'focus-tile-up': { needsOpen: true, direction: 'up' },
|
||||
'focus-tile-down': { needsOpen: true, direction: 'down' },
|
||||
'remove-tile': { needsOpen: true },
|
||||
};
|
||||
|
||||
/** The grid's state. `has(id)` answers only while it is open. */
|
||||
class TileGridModel {
|
||||
@@ -137,8 +147,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.canOpenTileGrid()) return false;
|
||||
const grid = (this._tileGrid ||= new TileGridModel());
|
||||
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
||||
// The grid and the split are never open together. An open split becomes the
|
||||
// grid's first two tiles (Pane A focused, Pane B beside it), so "split, then
|
||||
// want more" is one step. No closing resize for Pane A: it is about to park.
|
||||
let requested = ids || [];
|
||||
if (this._splitPane) {
|
||||
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
|
||||
this.closeSplitPane({ skipPrimaryResize: true });
|
||||
requested = [...seed, ...requested];
|
||||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||
}
|
||||
const wanted = [];
|
||||
for (const id of ids || []) {
|
||||
for (const id of requested) {
|
||||
if (typeof id !== 'string' || wanted.includes(id)) continue;
|
||||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue;
|
||||
wanted.push(id);
|
||||
@@ -175,6 +195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._installTileGridWidthGate();
|
||||
this._selectTiledSession(focus, { auto });
|
||||
this._updateConnectionIndicator?.();
|
||||
this._updateSplitButtonForTiles();
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -230,9 +251,95 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminalBufferCache?.delete(id);
|
||||
}
|
||||
this._updateConnectionIndicator?.();
|
||||
this._updateSplitButtonForTiles();
|
||||
if (reselect) this._selectAfterTileGrid(focusedId);
|
||||
},
|
||||
|
||||
// The Split button cannot act while the grid is open (openSplitPicker and
|
||||
// openSplitPane refuse), so it says so: aria-disabled plus a title, the same
|
||||
// refusal the split already gives for web tabs and the welcome screen.
|
||||
_updateSplitButtonForTiles() {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
const blocked = this._tilesOwnTerminal();
|
||||
btn.classList.toggle('btn-split--blocked', blocked);
|
||||
btn.setAttribute('aria-disabled', blocked ? 'true' : 'false');
|
||||
if (blocked) {
|
||||
btn.title = 'Split: unavailable while tiles are open';
|
||||
btn.setAttribute('aria-label', btn.title);
|
||||
} else {
|
||||
this._updateSplitButtonState?.(!!this._splitPane);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The tile chord `e` asks for, if it applies right now, else null: the toggle
|
||||
* wherever a grid could open (or is open), the focus and remove chords only
|
||||
* while it is open, so outside the grid they reach the terminal untouched.
|
||||
* Registry-aware (rebinds and disables in App Settings, Shortcuts). The
|
||||
* capture handler (app.js) dispatches it; every xterm key handler returns
|
||||
* false for it, so a chord that applies never reaches a PTY.
|
||||
*
|
||||
* @returns {string|null} the registry id
|
||||
*/
|
||||
tileShortcutFor(e) {
|
||||
if (!e || (!e.ctrlKey && !e.metaKey && !e.altKey)) return null;
|
||||
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return null;
|
||||
const open = this._tilesOwnTerminal();
|
||||
for (const shortcut of this.getShortcutRegistry()) {
|
||||
const spec = TILE_SHORTCUTS[shortcut.id];
|
||||
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
|
||||
if (spec.needsOpen ? open : open || this.canOpenTileGrid()) return shortcut.id;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Runs a chord tileShortcutFor() matched. */
|
||||
runTileShortcut(id) {
|
||||
const spec = TILE_SHORTCUTS[id];
|
||||
if (!spec) return;
|
||||
if (id === 'toggle-tile-grid') this.toggleTileGrid();
|
||||
else if (id === 'remove-tile') this.removeFocusedTile();
|
||||
else if (spec.direction) this.focusTileInDirection(spec.direction);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the grid, or closes it to the single view of the focused session.
|
||||
* Opening brings back the grid this tab last left (decision 1: one step back
|
||||
* after a selection outside it), else an open split as two tiles, else the
|
||||
* active session as one tile.
|
||||
*/
|
||||
toggleTileGrid() {
|
||||
if (this._tilesOwnTerminal()) {
|
||||
this.closeTileGrid({ keepStored: true, reselect: true });
|
||||
return;
|
||||
}
|
||||
if (!this.canOpenTileGrid()) return;
|
||||
const remembered = this._tileGridRemembered;
|
||||
const ids = (remembered?.ids || []).filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id));
|
||||
if (ids.length > 0) {
|
||||
this.openTileGrid(ids, { focusedId: remembered.focusedId, auto: true });
|
||||
return;
|
||||
}
|
||||
if (this.activeSessionId) this.openTileGrid([this.activeSessionId], { focusedId: this.activeSessionId });
|
||||
},
|
||||
|
||||
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
||||
focusTileInDirection(direction) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) return;
|
||||
const id = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols);
|
||||
if (id) this.selectSession(id);
|
||||
},
|
||||
|
||||
/** Removes the focused tile (the session keeps running); a neighbour takes focus. */
|
||||
removeFocusedTile() {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || !grid.focusedId) return;
|
||||
this.removeTile(grid.focusedId, { refocus: true, auto: true });
|
||||
},
|
||||
|
||||
// The single view after the grid closes: the session the grid was focused on,
|
||||
// replayed fresh (forceReload drops the stale snapshot and nulls
|
||||
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
||||
|
||||
Reference in New Issue
Block a user