From 5a58d272eaa67f47b478a588cd51db4f506dcc7d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 19:28:44 +0200 Subject: [PATCH] feat(tiles): the per-device Tiles setting, and Ctrl+Shift+G follows it showTileGridButton gets the full per-device treatment Split has: a header chip in App Settings beside Split, its load and save lines, OFF by default (and in the handheld defaults), a member of the displayKeys merge policy, stripped from the settings PUT and never declared in the .strict() SettingsUpdateSchema (sending it would 400 the whole save). The setting also gates the Ctrl+Shift+G toggle (the applied default while the owner's answer is pending; one line in tileShortcutFor to change): OFF, the chord is inert and reaches the terminal like any unbound key; ON, it opens and closes the grid where one can open. A grid that is open however it was opened (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords, the toggle that closes it included. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/index.html | 1 + src/web/public/settings-ui.js | 7 +++ src/web/public/tile-grid.js | 18 +++--- test/tile-grid-per-device-setting.test.ts | 69 +++++++++++++++++++++++ test/tile-grid-shortcuts.test.ts | 34 +++++++++-- 5 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 test/tile-grid-per-device-setting.test.ts diff --git a/src/web/public/index.html b/src/web/public/index.html index 0cbbebec..4000553c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1921,6 +1921,7 @@ + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8a5354d7..70a6ffd1 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -430,6 +430,7 @@ Object.assign(CodemanApp.prototype, { settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false; + document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings); document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; // Phone overview home screen: only meaningful under 600px, so the row is @@ -2492,6 +2493,7 @@ Object.assign(CodemanApp.prototype, { ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, showSplitButton: document.getElementById('appSettingsShowSplitButton').checked, + showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked, showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked, showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked, mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked, @@ -2730,6 +2732,9 @@ Object.assign(CodemanApp.prototype, { // SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT // (moving it into displayKeys alone is not the strip; this is). showSplitButton: _ssp, + // Same as Split: a per-device header button (and the Tiles chord), absent + // from SettingsUpdateSchema (.strict()), so sending it 400s the whole PUT. + showTileGridButton: _stg, webglRendererEnabled: _wgl, terminalWheelLocalScrollback: _twls, // Copy-on-select. Per-device (clipboard access differs by device and by @@ -3420,6 +3425,7 @@ Object.assign(CodemanApp.prototype, { ultracodeFloatingWindows: false, showMultiMonitorButton: false, showSplitButton: false, + showTileGridButton: false, // Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it // OFF so the phone header stays minimal and the mobile-header-buttons // policy guard keeps passing. @@ -4057,6 +4063,7 @@ Object.assign(CodemanApp.prototype, { 'mobileOverviewEnabled', 'sessionLineageLines', 'showSplitButton', + 'showTileGridButton', ]); // The plan-usage chip is a PER-DEVICE display setting (desktop default ON, // handheld default OFF): desktop can show it while mobile stays hidden. Drop diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index eb5a0e2a..678cf8ab 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -552,11 +552,14 @@ Object.assign(CodemanApp.prototype, { }, /** - * 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 + * The tile chord `e` asks for, if it applies right now, else null. The + * 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. (The + * applied default pending the owner's answer; one line to change.) The focus, + * zoom and remove chords apply only while the grid is open, however it was + * opened. 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 @@ -568,8 +571,9 @@ 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.needsOpen ? open : open || this.canOpenTileGrid()) return shortcut.id; - return null; + if (spec.needsOpen) return open ? shortcut.id : null; + const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; + return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; } return null; }, diff --git a/test/tile-grid-per-device-setting.test.ts b/test/tile-grid-per-device-setting.test.ts new file mode 100644 index 00000000..6f2dc12e --- /dev/null +++ b/test/tile-grid-per-device-setting.test.ts @@ -0,0 +1,69 @@ +// test/tile-grid-per-device-setting.test.ts +// Port: none (pure static analysis, runs in CI, no browser/server). +// +// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is +// a PER-DEVICE setting, default OFF, with the same three-way rule as +// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the +// history): in settings-ui.js's displayKeys merge policy, stripped out of the +// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending +// it would 400 the whole save), and never declared in the schema. Plus the +// parts that make the setting reachable: the App Settings chip, its load and +// save lines, the mobile default, and the `--hidden` marker's display:none +// rule (also covered generically by split-pane-hidden-button-css.test.ts). +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const PUBLIC = join(HERE, '../src/web/public'); +const read = (file: string) => readFileSync(join(PUBLIC, file), 'utf8'); + +describe('showTileGridButton stays per-device: display key, stripped from the PUT, absent from the schema', () => { + const settingsUi = read('settings-ui.js'); + const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8'); + + it('is in the client-side displayKeys merge policy', () => { + const start = settingsUi.indexOf('const displayKeys = new Set(['); + const displayKeys = settingsUi.slice(start, settingsUi.indexOf('])', start)); + expect(displayKeys).toContain("'showTileGridButton'"); + }); + + it('is stripped out of the object saveAppSettings() PUTs to the server', () => { + expect(settingsUi).toContain('showTileGridButton: _stg,'); + }); + + it('is never declared in the .strict() SettingsUpdateSchema', () => { + expect(schemas).not.toContain('showTileGridButton'); + }); + + it('defaults OFF on handhelds (and reads as OFF when absent everywhere)', () => { + expect(settingsUi).toMatch(/showSplitButton: false,\s*showTileGridButton: false,/); + expect(settingsUi).toContain( + "document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;" + ); + }); + + it('is saved from its App Settings chip and applied to the header', () => { + expect(settingsUi).toContain( + "showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked," + ); + expect(settingsUi).toContain('this._applyTileGridButtonVisibility?.(showTileGridButton);'); + }); +}); + +describe('markup and styles', () => { + const html = read('index.html'); + const css = read('styles.css'); + + it('has a header chip in App Settings, beside Split', () => { + expect(html).toMatch( + /data-preview-order="11\.6">[\s\S]*?Tiles<\/span>/ + ); + }); + + it('ships the header button hidden by its marker class, which has a display:none rule', () => { + expect(html).toContain('class="btn-icon-header btn-tile-grid btn-tile-grid--hidden"'); + expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/); + }); +}); diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 591b9991..e677fcef 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -46,6 +46,11 @@ const chord = (overrides: Record) => ({ ...overrides, }); const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true }; +/** The per-device Tiles setting on (it enables the Tiles button and the toggle chord). */ +const withTilesSetting = (app: GridApp) => { + app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true }); + return app; +}; const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true }; beforeEach(() => { @@ -71,15 +76,27 @@ describe('registry', () => { }); describe('when a chord applies', () => { - it('the toggle applies wherever a grid could open, and while one is open', () => { - const app = makeGridApp(IDS); + it('with the Tiles setting on, the toggle applies wherever a grid could open, and while one is open', () => { + const app = withTilesSetting(makeGridApp(IDS)); expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); app.openTileGrid(IDS); expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); }); - it('the toggle does not apply in a narrow window or a solo window', () => { + it('with the Tiles setting off (the default) the toggle is inert, like an unbound key', () => { const app = makeGridApp(IDS); + expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull(); + }); + + it('with the setting off, a grid opened another way (Ctrl+click, a drop) still has its chords, toggle included', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right'); + expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); + }); + + it('the toggle does not apply in a narrow window or a solo window', () => { + const app = withTilesSetting(makeGridApp(IDS)); gridWindow.innerWidth = 1000; expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull(); gridWindow.innerWidth = 2400; @@ -131,8 +148,17 @@ describe('the capture-phase handler', () => { return keydown[1]; } - it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => { + it('setting off: Ctrl+Shift+G is left alone (no preventDefault), the grid stays closed', () => { const app = makeGridApp(IDS); + const onKeydown = handlerFor(app); + const e = chord(TOGGLE); + onKeydown(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(app._tilesOwnTerminal()).toBe(false); + }); + + it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => { + const app = withTilesSetting(makeGridApp(IDS)); app.selectSession = vi.fn(); const onKeydown = handlerFor(app); const open = chord(TOGGLE);