diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index fe210be..3567dce 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -140,7 +140,10 @@ const DISPLAY_DEFAULTS = {
gridColumns: 'auto',
bellSound: false,
notificationPermission: 'default',
+ viewMode: 'auto',
};
+
+var VIEW_MODES = ['auto', 'fit', 'compact'];
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
@@ -644,6 +647,17 @@ function renderGrid(sessions) {
updatePillBell();
}
+ // Reapply view mode layout after grid HTML is rebuilt
+ var currentDs = loadDisplaySettings();
+ var currentMode = currentDs.viewMode || 'auto';
+ if (currentMode === 'fit' && grid) {
+ grid.classList.add('session-grid--fit');
+ applyFitLayout(grid);
+ } else if (currentMode === 'compact' && grid) {
+ grid.classList.add('session-grid--compact');
+ grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
+ }
+
}
// ---------------------------------------------------------------------------
@@ -1071,29 +1085,121 @@ function saveDisplaySettings(settings) {
} catch (_) { /* blocked — ok */ }
}
+/**
+ * Calculate and apply grid layout to fill the viewport exactly (Fit mode).
+ * Determines optimal cols × rows based on tile count and available space.
+ * @param {Element} grid - The session grid element
+ */
+function applyFitLayout(grid) {
+ var count = grid.querySelectorAll('.session-tile').length;
+ if (count === 0) return;
+
+ // Available space — use grid's parent container
+ var parent = grid.parentElement;
+ var availH = parent ? parent.clientHeight : window.innerHeight;
+ var availW = grid.clientWidth;
+
+ // Subtract padding and gap
+ var style = getComputedStyle(grid);
+ var padT = parseFloat(style.paddingTop) || 0;
+ var padB = parseFloat(style.paddingBottom) || 0;
+ var padL = parseFloat(style.paddingLeft) || 0;
+ var padR = parseFloat(style.paddingRight) || 0;
+ var gap = parseFloat(style.gap) || 8;
+
+ var innerW = availW - padL - padR;
+ var innerH = availH - padT - padB;
+
+ // Calculate optimal cols/rows — start with square root
+ var cols = Math.ceil(Math.sqrt(count));
+ var rows = Math.ceil(count / cols);
+
+ // Prefer wider layouts (more cols, fewer rows) since tiles are landscape
+ if (rows > 1 && cols < count) {
+ var altCols = cols + 1;
+ var altRows = Math.ceil(count / altCols);
+ if (altRows < rows) {
+ cols = altCols;
+ rows = altRows;
+ }
+ }
+
+ // Tile height from available space
+ var tileH = (innerH - gap * (rows - 1)) / rows;
+
+ grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
+ grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
+
+ // Override tile height so tiles fill the grid rows
+ grid.querySelectorAll('.session-tile').forEach(function(t) {
+ t.style.height = tileH + 'px';
+ });
+}
+
+/**
+ * Cycle the dashboard view mode: auto → fit → compact → auto.
+ * Persists to localStorage and reapplies display settings.
+ */
+function cycleViewMode() {
+ var ds = loadDisplaySettings();
+ var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto');
+ ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length];
+ saveDisplaySettings(ds);
+ applyDisplaySettings(ds);
+
+ // Update button label
+ var btn = document.getElementById('view-mode-btn');
+ if (btn) btn.title = 'View: ' + ds.viewMode;
+}
+
/**
* Apply display settings to the live DOM.
* Sets --preview-font-size CSS custom property and updates #session-grid
- * grid-template-columns based on the gridColumns setting.
+ * grid-template-columns based on the gridColumns setting and viewMode.
* @param {object} ds - display settings object
*/
function applyDisplaySettings(ds) {
// Apply font size as CSS custom property (tile previews)
- document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
+ if (document.documentElement) {
+ document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
+ }
// Apply font size to the live xterm.js terminal without reconnecting
if (window._setTerminalFontSize) {
window._setTerminalFontSize(ds.fontSize);
}
- // Apply grid columns
+ // Apply view mode to grid
var grid = document.getElementById('session-grid');
- if (grid) {
- if (ds.gridColumns === 'auto') {
+ if (!grid) return;
+
+ var mode = ds.viewMode || 'auto';
+
+ // Remove all mode classes
+ grid.classList.remove('session-grid--fit', 'session-grid--compact');
+
+ // Reset any inline styles from previous fit calculation
+ grid.style.removeProperty('grid-template-rows');
+ grid.querySelectorAll('.session-tile').forEach(function(t) {
+ t.style.removeProperty('height');
+ });
+
+ if (mode === 'auto') {
+ // Restore grid columns setting
+ if (ds.gridColumns === 'auto' || !ds.gridColumns) {
grid.style.removeProperty('grid-template-columns');
} else {
grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
}
+
+ } else if (mode === 'fit') {
+ grid.classList.add('session-grid--fit');
+ applyFitLayout(grid);
+
+ } else if (mode === 'compact') {
+ grid.classList.add('session-grid--compact');
+ // Use auto-fill but with smaller minmax for higher density
+ grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
}
}
@@ -1590,6 +1696,7 @@ function bindStaticEventListeners() {
on($('sheet-backdrop'), 'click', closeBottomSheet);
// Settings dialog bindings
+ on($('view-mode-btn'), 'click', cycleViewMode);
on($('settings-btn'), 'click', openSettings);
on($('settings-btn-expanded'), 'click', openSettings);
on($('settings-close-btn'), 'click', closeSettings);
@@ -1754,9 +1861,24 @@ function _setViewMode(mode) {
_viewMode = mode;
}
+// Recalculate fit layout on window resize
+window.addEventListener('resize', function() {
+ var ds = loadDisplaySettings();
+ if ((ds.viewMode || 'auto') === 'fit') {
+ var grid = document.getElementById('session-grid');
+ if (grid) applyFitLayout(grid);
+ }
+});
+
document.addEventListener('DOMContentLoaded', () => {
initDeviceId();
- applyDisplaySettings(loadDisplaySettings());
+ var _initDs = loadDisplaySettings();
+ applyDisplaySettings(_initDs);
+
+ // Initialize view mode button title
+ var vmBtn = document.getElementById('view-mode-btn');
+ if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto');
+
document.addEventListener('keydown', trackInteraction);
document.addEventListener('click', trackInteraction);
document.addEventListener('touchstart', trackInteraction);
@@ -1822,6 +1944,8 @@ if (typeof module !== 'undefined' && module.exports) {
loadDisplaySettings,
saveDisplaySettings,
applyDisplaySettings,
+ applyFitLayout,
+ cycleViewMode,
onDisplaySettingChange,
openSettings,
closeSettings,
diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html
index e2e274a..cb375b0 100644
--- a/muxplex/frontend/index.html
+++ b/muxplex/frontend/index.html
@@ -22,6 +22,7 @@

diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css
index 69666ef..ff5b354 100644
--- a/muxplex/frontend/style.css
+++ b/muxplex/frontend/style.css
@@ -1371,6 +1371,24 @@ body {
z-index: 200;
}
+/* ============================================================
+ Dashboard view modes — Compact and Fit
+ ============================================================ */
+
+/* Compact view — small tiles, high density */
+.session-grid--compact .session-tile {
+ height: 80px;
+}
+
+.session-grid--compact .tile-body {
+ height: 40px;
+}
+
+/* Fit view — tiles fill viewport, no scroll */
+.session-grid--fit {
+ overflow: hidden;
+}
+
/* ============================================================
Responsive overlay sidebar at <960px
============================================================ */
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs
index b1b8ead..0054456 100644
--- a/muxplex/frontend/tests/test_app.mjs
+++ b/muxplex/frontend/tests/test_app.mjs
@@ -2118,3 +2118,91 @@ test('bindStaticEventListeners wires delete template reset button', () => {
'bindStaticEventListeners must wire #setting-delete-template-reset click handler'
);
});
+
+// --- View mode cycling (Auto / Fit / Compact) ---
+
+test('app.js has VIEW_MODES array with auto, fit, compact', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ assert.ok(source.includes("'auto'"), "must include 'auto' mode");
+ assert.ok(source.includes("'fit'"), "must include 'fit' mode");
+ assert.ok(source.includes("'compact'"), "must include 'compact' mode");
+ assert.ok(source.includes('VIEW_MODES'), 'must define VIEW_MODES');
+});
+
+test('app.js exports cycleViewMode function', () => {
+ assert.ok('cycleViewMode' in app, 'app.js must export cycleViewMode');
+ assert.strictEqual(typeof app.cycleViewMode, 'function', 'cycleViewMode must be a function');
+});
+
+test('app.js exports applyFitLayout function', () => {
+ assert.ok('applyFitLayout' in app, 'app.js must export applyFitLayout');
+ assert.strictEqual(typeof app.applyFitLayout, 'function', 'applyFitLayout must be a function');
+});
+
+test('DISPLAY_DEFAULTS includes viewMode: auto', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ // DISPLAY_DEFAULTS should define viewMode
+ const defaultsStart = source.indexOf('DISPLAY_DEFAULTS');
+ assert.ok(defaultsStart !== -1, 'DISPLAY_DEFAULTS must exist');
+ const defaultsEnd = source.indexOf('};', defaultsStart);
+ const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2);
+ assert.ok(defaultsBody.includes('viewMode'), 'DISPLAY_DEFAULTS must include viewMode');
+});
+
+test('applyDisplaySettings handles fit mode by adding session-grid--fit class', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ const fnStart = source.indexOf('function applyDisplaySettings(');
+ assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
+ const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
+ const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
+ assert.ok(
+ fnBody.includes('session-grid--fit'),
+ 'applyDisplaySettings must apply session-grid--fit class for fit mode'
+ );
+});
+
+test('applyDisplaySettings handles compact mode by adding session-grid--compact class', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ const fnStart = source.indexOf('function applyDisplaySettings(');
+ assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
+ const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
+ const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
+ assert.ok(
+ fnBody.includes('session-grid--compact'),
+ 'applyDisplaySettings must apply session-grid--compact class for compact mode'
+ );
+});
+
+test('cycleViewMode cycles through auto -> fit -> compact -> auto', () => {
+ // Reset display settings to auto
+ const ds = app.loadDisplaySettings();
+ ds.viewMode = 'auto';
+ app.saveDisplaySettings(ds);
+
+ // First cycle: auto -> fit
+ app.cycleViewMode();
+ const ds1 = app.loadDisplaySettings();
+ assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit');
+
+ // Second cycle: fit -> compact
+ app.cycleViewMode();
+ const ds2 = app.loadDisplaySettings();
+ assert.strictEqual(ds2.viewMode, 'compact', 'second cycle should go fit -> compact');
+
+ // Third cycle: compact -> auto
+ app.cycleViewMode();
+ const ds3 = app.loadDisplaySettings();
+ assert.strictEqual(ds3.viewMode, 'auto', 'third cycle should wrap compact -> auto');
+});
+
+test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () => {
+ const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+ const fnStart = source.indexOf('function bindStaticEventListeners(');
+ assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
+ const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
+ const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
+ assert.ok(
+ fnBody.includes('view-mode-btn'),
+ 'bindStaticEventListeners must wire #view-mode-btn click handler'
+ );
+});
diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py
index 3ff7ff8..5107a5c 100644
--- a/muxplex/tests/test_frontend_css.py
+++ b/muxplex/tests/test_frontend_css.py
@@ -1681,3 +1681,23 @@ def test_css_sidebar_footer() -> None:
css = read_css()
for cls in (".sidebar-footer", ".sidebar-new-btn"):
assert cls in css, f"Missing CSS selector '{cls}'"
+
+
+def test_css_compact_view_exists() -> None:
+ """.session-grid--compact CSS modifier must exist for compact view mode."""
+ css = read_css()
+ assert ".session-grid--compact" in css, "Missing .session-grid--compact CSS selector for compact view mode"
+
+
+def test_css_fit_view_exists() -> None:
+ """.session-grid--fit CSS modifier must exist for fit view mode."""
+ css = read_css()
+ assert ".session-grid--fit" in css, "Missing .session-grid--fit CSS selector for fit view mode"
+
+
+def test_css_compact_tile_height() -> None:
+ """.session-grid--compact .session-tile must set a compact height."""
+ css = read_css()
+ assert ".session-grid--compact .session-tile" in css, (
+ "Missing .session-grid--compact .session-tile height override"
+ )
diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py
index ef0320a..56aab2d 100644
--- a/muxplex/tests/test_frontend_html.py
+++ b/muxplex/tests/test_frontend_html.py
@@ -1153,3 +1153,16 @@ def test_html_new_session_tab_controls_with_delete() -> None:
"setting-delete-template-reset",
):
assert soup.find(id=id_), f"Missing element with id='{id_}'"
+
+
+def test_html_view_mode_button_exists() -> None:
+ """#view-mode-btn must exist in the overview header for cycling view modes."""
+ soup = _SOUP
+ btn = soup.find(id="view-mode-btn")
+ assert btn is not None, "Missing element with id='view-mode-btn' (view mode toggle button)"
+ # Must be inside the overview header area
+ overview = soup.find(id="view-overview")
+ assert overview is not None, "Missing #view-overview"
+ assert overview.find(id="view-mode-btn") is not None, (
+ "#view-mode-btn must be inside #view-overview header"
+ )