feat(tiles): Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)

Four rebindable registry chords in the Tiles group move the focused
tile: it trades places with the neighbour the Alt+Shift+Arrow focus
chords pick (tileInDirection), through the same _reorderTiles path as
the header drag, and keeps the focus. Nothing at an edge.

They go through tileShortcutFor()/runTileShortcut() like the other
tile chords, so every xterm key handler swallows them while they
apply: only while the grid is open, a zoomed grid included (a no-op
there, so the keys never reach the CLI), and never in a text field
other than xterm's own textarea, where Ctrl+Shift+Arrows select by
word.

Ctrl+Shift+Arrows because every other two-modifier arrow chord is
taken: Ctrl+Alt switches workspaces (GNOME, Xfce, some Windows
graphics drivers), Ctrl+Alt+Shift moves a window to another workspace
(GNOME, Cinnamon, Xfce), Super belongs to the desktop, Alt is the
browser's back and forward, Alt+Shift focuses tiles. No browser,
GNOME, KDE, macOS or Claude Code default uses Ctrl+Shift+Arrows; it
costs a terminal editor's word selection inside a tile while the grid
is open.

The Help modal lists the chords and the header drag; the shortcut
overlay lists the registry. zh-CN entries for every new string.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 23:49:11 +02:00
parent 2b20288ca0
commit 20c2561a45
7 changed files with 328 additions and 6 deletions
+39
View File
@@ -579,6 +579,45 @@ const DEFAULT_SHORTCUTS = [
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
action: 'focusTileDown',
},
// Move the focused tile: it trades places with the neighbour the focus
// chords would pick. Ctrl+Shift+Arrows because every other two-modifier
// arrow chord is taken: Ctrl+Alt+Arrows switch workspaces (GNOME, Xfce, and
// rotate the screen on some Windows graphics drivers), Ctrl+Alt+Shift+Arrows
// move a window to another workspace (GNOME, Cinnamon, Xfce), Super chords
// belong to the desktop, Alt+Arrows are the browser's back and forward, and
// Alt+Shift+Arrows focus tiles. No browser, GNOME, KDE or macOS default and
// no Claude Code default uses Ctrl+Shift+Arrows (it parallels Ctrl+Shift+{ }
// for moving tabs); what it costs is word selection, so the chords skip a
// text field (tileShortcutFor) and give up only a terminal editor's
// word selection (nano, micro, emacs) inside a tile while the grid is open.
{
id: 'move-tile-left',
group: 'Tiles',
label: 'Move Tile Left',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowLeft' }],
action: 'moveTileLeft',
},
{
id: 'move-tile-right',
group: 'Tiles',
label: 'Move Tile Right',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowRight' }],
action: 'moveTileRight',
},
{
id: 'move-tile-up',
group: 'Tiles',
label: 'Move Tile Up',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowUp' }],
action: 'moveTileUp',
},
{
id: 'move-tile-down',
group: 'Tiles',
label: 'Move Tile Down',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowDown' }],
action: 'moveTileDown',
},
{
id: 'zoom-tile',
group: 'Tiles',
+8
View File
@@ -114,6 +114,14 @@
'Focus Tile Up': '聚焦上方窗格',
'Focus Tile Down': '聚焦下方窗格',
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
'Move Tile Left': '向左移动窗格',
'Move Tile Right': '向右移动窗格',
'Move Tile Up': '向上移动窗格',
'Move Tile Down': '向下移动窗格',
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
Drag: '拖动',
"a tile's header": '窗格的标题栏',
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
+2
View File
@@ -838,6 +838,8 @@
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></div><div>Toggle Tile Grid</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose Which Sessions to Tile</div>
+36 -3
View File
@@ -40,17 +40,33 @@ function tileGridTracks(fr) {
return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `);
}
// 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.
// needs the grid open. The toggle applies wherever a grid could open. The move
// chords leave a text field its keys (`move`: see tileShortcutFor).
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' },
'move-tile-left': { needsOpen: true, move: 'left' },
'move-tile-right': { needsOpen: true, move: 'right' },
'move-tile-up': { needsOpen: true, move: 'up' },
'move-tile-down': { needsOpen: true, move: 'down' },
'remove-tile': { needsOpen: true },
'zoom-tile': { needsOpen: true },
};
/**
* A text field other than a terminal's own input (xterm's helper textarea):
* the rename input, the file editor, a settings field. Ctrl+Shift+Arrows
* selects by word there, so the move chords leave it alone.
*/
function isTextFieldTarget(target) {
if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false;
const tag = String(target.tagName || '').toUpperCase();
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
}
/** The grid's state. `has(id)` answers only while it is open. */
class TileGridModel {
constructor() {
@@ -528,9 +544,11 @@ Object.assign(CodemanApp.prototype, {
* toggle applies while the grid is open, or where one could open AND the
* per-device `showTileGridButton` setting is on: with it off (the default)
* the chord is inert and reaches the terminal like any unbound key (owner
* decision 6 in docs/tile-grid-plan.md). The focus,
* decision 6 in docs/tile-grid-plan.md). The focus, move,
* zoom and remove chords apply only while the grid is open, however it was
* opened. Registry-aware (rebinds and disables in App Settings, Shortcuts).
* opened (a move chord also while a tile is zoomed, as a no-op, so its keys
* never reach the CLI; and never in a text field, whose keys they are).
* 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.
*
@@ -543,6 +561,7 @@ Object.assign(CodemanApp.prototype, {
for (const shortcut of this.getShortcutRegistry()) {
const spec = TILE_SHORTCUTS[shortcut.id];
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
if (spec.move && isTextFieldTarget(e.target)) continue;
if (spec.needsOpen) return open ? shortcut.id : null;
const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true;
return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null;
@@ -558,6 +577,7 @@ Object.assign(CodemanApp.prototype, {
else if (id === 'remove-tile') this.removeFocusedTile();
else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId);
else if (spec.direction) this.focusTileInDirection(spec.direction);
else if (spec.move) this.moveTileInDirection(spec.move);
},
/**
@@ -838,6 +858,19 @@ Object.assign(CodemanApp.prototype, {
return this._reorderTiles(ids);
},
/**
* Move Tile Left/Right/Up/Down: the focused tile trades places with its
* neighbour in that direction, the neighbour the Alt+Shift+Arrow focus
* chords pick (tileInDirection). Focus stays on the moved tile. Nothing while
* a tile is zoomed, or at an edge.
*/
moveTileInDirection(direction) {
const grid = this._tileGrid;
if (!grid?.open || grid.zoomedId || !grid.focusedId) return;
const neighbor = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols);
if (neighbor) this._swapTiles(grid.focusedId, neighbor);
},
/**
* A tab dropped on a tile, or a tile dragged there by its header: a session
* not yet tiled REPLACES that tile (same place; the replaced session keeps