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);