Files
muxplex/docs/plans/2026-03-29-settings-phase1.md
T
2026-03-29 22:12:07 -07:00

41 KiB

Settings Panel — Phase 1: Backend + Palette Removal + Settings Infrastructure

Execution: Use the subagent-driven-development workflow to implement this plan.

Phase 1 of 2 — complete this phase before starting Phase 2.

Design doc: docs/plans/2026-03-29-settings-design.md

Goal: Remove the dead command palette, add the backend settings API + new-session endpoint, and build the settings modal shell with the Display tab wired up for immediate visual feedback.

Architecture: Server-side settings live in ~/.config/muxplex/settings.json (loaded/saved via a new muxplex/settings.py module). Three new endpoints: GET/PATCH /api/settings and POST /api/sessions (create). The frontend settings modal is a <dialog> element with tabbed navigation. Display tab fields (font size, hover delay, grid columns) use localStorage and apply immediately.

Tech Stack: Python 3.11+ / FastAPI / Pydantic, vanilla JS, CSS custom properties.


Task 1: Remove command palette — HTML + CSS

Files:

  • Modify: muxplex/frontend/index.html (lines 35, 50-58)
  • Modify: muxplex/frontend/style.css (lines 356-364, 690-778)
  • Modify: muxplex/tests/test_frontend_html.py (lines 60-76, 243)
  • Modify: muxplex/tests/test_frontend_css.py (lines 69-74)

Step 1: Remove palette trigger button from expanded header

In muxplex/frontend/index.html, remove the #palette-trigger button from the expanded header (line 35):

<!-- REMOVE this line: -->
<button id="palette-trigger" class="palette-trigger" aria-label="Open command palette">&#8984;K</button>

The expanded header should now end after <span id="expanded-session-name">:

    <header class="expanded-header">
      <button id="back-btn" class="back-btn" aria-label="Back">&#8592;</button>
      <button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">&#9776;</button>
      <span id="expanded-session-name" class="expanded-session-name"></span>
    </header>

Step 2: Remove command palette HTML block

In muxplex/frontend/index.html, remove the entire command palette block (lines 50-58):

<!-- REMOVE this entire block: -->
  <!-- ── Command palette ───────────────────────────────────────── -->
  <div id="command-palette" class="command-palette hidden" role="dialog" aria-modal="true" aria-label="Switch session">
    <div class="command-palette__backdrop" id="palette-backdrop"></div>
    <div class="command-palette__dialog">
      <input id="palette-input" class="command-palette__input" type="text"
             placeholder="Jump to session…" autocomplete="off" spellcheck="false">
      <ul id="palette-list" class="command-palette__list" role="listbox" aria-label="Sessions"></ul>
    </div>
  </div>

Step 3: Remove palette CSS

In muxplex/frontend/style.css, remove the .palette-trigger block (lines 356-364):

/* REMOVE this block: */
.palette-trigger {
  background: none;
  border: 1px solid var(--border);
  border-radius: 4px;
  color: var(--text-dim);
  font-size: 12px;
  padding: 4px 10px;
  cursor: pointer;
}

Remove the entire "Command palette overlay" CSS section (lines 690-778 — from the comment block through .palette-item__time):

/* REMOVE everything from this comment through .palette-item__time { ... } */
/* ============================================================
   Command palette overlay (desktop session switching)
   ============================================================ */

.command-palette { ... }
.command-palette__backdrop { ... }
.command-palette__dialog { ... }
.command-palette__input { ... }
.command-palette__input::placeholder { ... }
.command-palette__list { ... }
.palette-item { ... }
.palette-item:hover,
.palette-item--selected { ... }
.palette-item__index { ... }
.palette-item__name { ... }
.palette-item__bell { ... }
.palette-item__time { ... }

Step 4: Update HTML tests

In muxplex/tests/test_frontend_html.py, update test_html_expanded_view_elements (line 60-69) to remove palette-trigger:

def test_html_expanded_view_elements() -> None:
    """id=back-btn, expanded-session-name, reconnect-overlay."""
    soup = _SOUP
    for id_ in (
        "back-btn",
        "expanded-session-name",
        "reconnect-overlay",
    ):
        assert soup.find(id=id_), f"Missing element with id='{id_}'"

Remove test_html_command_palette entirely (lines 72-76):

# DELETE this entire function:
def test_html_command_palette() -> None:
    """id=command-palette, palette-input, palette-list, palette-backdrop."""
    ...

In the test_html_elements_carry_their_css_classes test (around line 243), remove the palette-trigger entry:

# REMOVE this tuple from the cases list:
        ("palette-trigger", "palette-trigger", "needs border and hover styles"),

Step 5: Update CSS tests

In muxplex/tests/test_frontend_css.py, remove test_css_command_palette (lines 69-74):

# DELETE this entire function:
def test_css_command_palette():
    css = read_css()
    assert ".command-palette__dialog" in css
    assert ".command-palette__input" in css
    assert ".palette-item" in css
    assert ".palette-item--selected" in css

Step 6: Run tests to verify nothing breaks

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py muxplex/tests/test_frontend_css.py -v

Expected: All tests pass (palette tests removed, no other test depends on palette HTML/CSS).

Step 7: Commit

git add -A && git commit -m "refactor: remove command palette HTML and CSS"

Task 2: Remove command palette — JavaScript

Files:

  • Modify: muxplex/frontend/app.js (lines 901-1086, 1169-1170, 1219-1247, 1274-1332)

Step 1: Remove palette state variables

In muxplex/frontend/app.js, remove the command palette state block (lines 901-906):

// REMOVE this entire block:
// ─── Command palette state ────────────────────────────────────────────────
const PALETTE_MAX_ITEMS = 9;
let _paletteSelectedIndex = 0;
let _paletteFilteredSessions = [];
let _paletteOpen = false;
let _paletteInputListener = null;

Step 2: Remove palette functions

Remove the entire "Command palette functions" section (lines 908-1062):

  • renderPaletteList
  • highlightPaletteItem
  • openPalette
  • closePalette
  • onPaletteInput
  • handlePaletteKeydown

Step 3: Simplify handleGlobalKeydown

Replace the current handleGlobalKeydown function (lines 1071-1086) with a version that removes palette references. Keep only the Escape-to-close-session behavior:

/**
 * Global keydown handler.
 * In fullscreen: Escape returns to grid.
 * @param {KeyboardEvent} e
 */
function handleGlobalKeydown(e) {
  if (_viewMode === 'fullscreen') {
    if (e.key === 'Escape') {
      e.preventDefault();
      closeSession();
    }
  }
}

Step 4: Remove palette event bindings from bindStaticEventListeners

In bindStaticEventListeners (around lines 1164-1210), remove these two lines:

// REMOVE these two lines:
  on($('palette-trigger'), 'click', openPalette);
  on($('palette-backdrop'), 'click', closePalette);

Step 5: Remove palette test-only helpers

Remove these test-only helpers (lines 1219-1247):

// REMOVE all of these:
/** Test-only: set _paletteFilteredSessions directly. */
function _setPaletteFilteredSessions(sessions) { ... }
/** Test-only: get _paletteFilteredSessions. */
function _getPaletteFilteredSessions() { ... }
/** Test-only: set _paletteSelectedIndex directly. */
function _setPaletteSelectedIndex(index) { ... }
/** Test-only: get _paletteSelectedIndex. */
function _getPaletteSelectedIndex() { ... }
/** Test-only: set _paletteOpen directly. */
function _setPaletteOpen(val) { ... }
/** Test-only: get _paletteOpen. */
function _isPaletteOpen() { ... }

Step 6: Remove palette exports from module.exports

In the module.exports block (starting around line 1274), remove these entries:

// REMOVE these exports:
    // Command palette
    renderPaletteList,
    highlightPaletteItem,
    openPalette,
    closePalette,
    onPaletteInput,
    handlePaletteKeydown,
    // Test-only helpers (palette)
    _setPaletteFilteredSessions,
    _getPaletteFilteredSessions,
    _setPaletteSelectedIndex,
    _getPaletteSelectedIndex,
    _setPaletteOpen,
    _isPaletteOpen,

Keep handleGlobalKeydown and bindStaticEventListeners in exports — they still exist.

Step 7: Run full test suite

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 8: Commit

git add -A && git commit -m "refactor: remove command palette JavaScript"

Task 3: Server-side settings module

Files:

  • Create: muxplex/settings.py
  • Create: muxplex/tests/test_settings.py

Step 1: Write the failing tests

Create muxplex/tests/test_settings.py:

"""Tests for muxplex/settings.py — settings file management."""

import json

import pytest

from muxplex import settings as settings_mod


@pytest.fixture(autouse=True)
def redirect_settings_path(tmp_path, monkeypatch):
    """Redirect SETTINGS_PATH to tmp_path so tests don't touch real config."""
    tmp_settings = tmp_path / "settings.json"
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_settings)


def test_load_returns_defaults_when_no_file():
    """load_settings() returns DEFAULT_SETTINGS when file doesn't exist."""
    result = settings_mod.load_settings()
    assert result == settings_mod.DEFAULT_SETTINGS


def test_load_returns_saved_values(tmp_path, monkeypatch):
    """load_settings() reads values from existing settings.json."""
    path = settings_mod.SETTINGS_PATH
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps({"sort_order": "alphabetical"}))
    result = settings_mod.load_settings()
    assert result["sort_order"] == "alphabetical"
    # Missing keys filled with defaults
    assert result["default_session"] is None


def test_save_creates_file_and_dirs():
    """save_settings() creates parent dirs and writes JSON."""
    settings_mod.save_settings({"sort_order": "recent"})
    assert settings_mod.SETTINGS_PATH.exists()
    data = json.loads(settings_mod.SETTINGS_PATH.read_text())
    assert data["sort_order"] == "recent"


def test_save_merges_with_defaults():
    """save_settings() merges partial data with defaults."""
    settings_mod.save_settings({"sort_order": "alphabetical"})
    data = json.loads(settings_mod.SETTINGS_PATH.read_text())
    assert data["sort_order"] == "alphabetical"
    assert data["new_session_template"] == "tmux new-session -d -s {name}"


def test_load_handles_corrupt_json(tmp_path):
    """load_settings() returns defaults if JSON is corrupt."""
    path = settings_mod.SETTINGS_PATH
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("not json {{{")
    result = settings_mod.load_settings()
    assert result == settings_mod.DEFAULT_SETTINGS


def test_patch_settings_merges_single_field():
    """patch_settings() merges a single field into existing settings."""
    settings_mod.save_settings(settings_mod.DEFAULT_SETTINGS.copy())
    result = settings_mod.patch_settings({"sort_order": "alphabetical"})
    assert result["sort_order"] == "alphabetical"
    assert result["default_session"] is None  # unchanged


def test_patch_settings_ignores_unknown_keys():
    """patch_settings() ignores keys not in DEFAULT_SETTINGS."""
    settings_mod.save_settings(settings_mod.DEFAULT_SETTINGS.copy())
    result = settings_mod.patch_settings({"bogus_key": "ignored"})
    assert "bogus_key" not in result

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_settings.py -v

Expected: FAIL — ModuleNotFoundError: No module named 'muxplex.settings'

Step 3: Write the implementation

Create muxplex/settings.py:

"""
muxplex settings — server-side configuration file management.

Settings are stored in ~/.config/muxplex/settings.json.
"""

import json
import logging
from pathlib import Path

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Path
# ---------------------------------------------------------------------------

SETTINGS_PATH: Path = Path.home() / ".config" / "muxplex" / "settings.json"

# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------

DEFAULT_SETTINGS: dict = {
    "default_session": None,
    "sort_order": "manual",
    "hidden_sessions": [],
    "window_size_largest": False,
    "auto_open_created": True,
    "new_session_template": "tmux new-session -d -s {name}",
}

# ---------------------------------------------------------------------------
# Load / save
# ---------------------------------------------------------------------------


def load_settings() -> dict:
    """Load settings from disk, returning defaults for missing keys or corrupt files."""
    defaults = DEFAULT_SETTINGS.copy()
    if not SETTINGS_PATH.exists():
        return defaults
    try:
        data = json.loads(SETTINGS_PATH.read_text())
        if not isinstance(data, dict):
            return defaults
    except (json.JSONDecodeError, OSError):
        _log.warning("Corrupt settings file at %s, using defaults", SETTINGS_PATH)
        return defaults
    # Merge: file values override defaults, unknown keys ignored
    for key in defaults:
        if key in data:
            defaults[key] = data[key]
    return defaults


def save_settings(data: dict) -> None:
    """Write settings to disk, merging with defaults for any missing keys."""
    merged = DEFAULT_SETTINGS.copy()
    for key in merged:
        if key in data:
            merged[key] = data[key]
    SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
    SETTINGS_PATH.write_text(json.dumps(merged, indent=2) + "\n")


def patch_settings(patch: dict) -> dict:
    """Load current settings, merge patch (known keys only), save, and return result."""
    current = load_settings()
    for key in DEFAULT_SETTINGS:
        if key in patch:
            current[key] = patch[key]
    save_settings(current)
    return current

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_settings.py -v

Expected: All 8 tests pass.

Step 5: Commit

git add -A && git commit -m "feat: add server-side settings module"

Task 4: Settings API endpoints

Files:

  • Modify: muxplex/main.py
  • Modify: muxplex/tests/test_api.py

Step 1: Write the failing tests

Add to the bottom of muxplex/tests/test_api.py:

# ---------------------------------------------------------------------------
# GET /api/settings
# ---------------------------------------------------------------------------


def test_get_settings_returns_defaults(client, monkeypatch):
    """GET /api/settings returns default settings when no file exists."""
    import muxplex.settings as settings_mod

    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", client.app.state._tmp_path / "settings.json")
    response = client.get("/api/settings")
    assert response.status_code == 200
    data = response.json()
    assert data["sort_order"] == "manual"
    assert data["new_session_template"] == "tmux new-session -d -s {name}"


def test_get_settings_returns_saved_values(client, tmp_path, monkeypatch):
    """GET /api/settings returns previously saved settings."""
    import json
    import muxplex.settings as settings_mod

    settings_path = tmp_path / "settings.json"
    settings_path.write_text(json.dumps({"sort_order": "alphabetical"}))
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)

    response = client.get("/api/settings")
    assert response.status_code == 200
    assert response.json()["sort_order"] == "alphabetical"


# ---------------------------------------------------------------------------
# PATCH /api/settings
# ---------------------------------------------------------------------------


def test_patch_settings_updates_field(client, tmp_path, monkeypatch):
    """PATCH /api/settings merges a single field and returns updated settings."""
    import muxplex.settings as settings_mod

    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")

    response = client.patch("/api/settings", json={"sort_order": "alphabetical"})
    assert response.status_code == 200
    data = response.json()
    assert data["sort_order"] == "alphabetical"
    assert data["default_session"] is None  # unchanged default


def test_patch_settings_ignores_unknown_keys(client, tmp_path, monkeypatch):
    """PATCH /api/settings ignores keys not in the schema."""
    import muxplex.settings as settings_mod

    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")

    response = client.patch("/api/settings", json={"unknown_key": "value"})
    assert response.status_code == 200
    assert "unknown_key" not in response.json()

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_api.py::test_get_settings_returns_defaults -v

Expected: FAIL — 404 (route doesn't exist yet).

Step 3: Add the endpoints to main.py

In muxplex/main.py, add the import at the top (after existing imports):

from muxplex.settings import load_settings, patch_settings

Add the route handlers after the setup_hooks endpoint and before the WebSocket proxy section:

# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------


@app.get("/api/settings")
async def get_settings() -> dict:
    """Return server-side settings."""
    return load_settings()


@app.patch("/api/settings")
async def update_settings(request: Request) -> dict:
    """Partial update of server-side settings. Merges known keys only."""
    body = await request.json()
    return patch_settings(body)

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_api.py -k "settings" -v

Expected: All 4 settings tests pass.

Step 5: Commit

git add -A && git commit -m "feat: add GET/PATCH /api/settings endpoints"

Task 5: POST /api/sessions (create new session)

Files:

  • Modify: muxplex/main.py
  • Modify: muxplex/tests/test_api.py

Step 1: Write the failing tests

Add to the bottom of muxplex/tests/test_api.py:

# ---------------------------------------------------------------------------
# POST /api/sessions (create new session)
# ---------------------------------------------------------------------------


def test_create_session_returns_200_with_name(client, tmp_path, monkeypatch):
    """POST /api/sessions returns 200 with the session name."""
    import muxplex.settings as settings_mod

    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")

    # Mock subprocess so nothing actually runs
    import subprocess
    monkeypatch.setattr(subprocess, "Popen", lambda *a, **kw: None)

    response = client.post("/api/sessions", json={"name": "my-project"})
    assert response.status_code == 200
    assert response.json()["name"] == "my-project"


def test_create_session_substitutes_name_in_template(client, tmp_path, monkeypatch):
    """POST /api/sessions substitutes {name} in the template command."""
    import json
    import subprocess
    import muxplex.settings as settings_mod

    settings_path = tmp_path / "settings.json"
    settings_path.write_text(json.dumps({
        "new_session_template": "echo {name}"
    }))
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)

    captured_cmd = []
    original_popen = subprocess.Popen

    def mock_popen(*args, **kwargs):
        captured_cmd.append(args[0] if args else kwargs.get("args"))
        return None

    monkeypatch.setattr(subprocess, "Popen", mock_popen)

    client.post("/api/sessions", json={"name": "test-proj"})
    assert len(captured_cmd) == 1
    assert "test-proj" in captured_cmd[0]


def test_create_session_rejects_empty_name(client):
    """POST /api/sessions with empty name returns 422."""
    response = client.post("/api/sessions", json={"name": ""})
    assert response.status_code == 422


def test_create_session_rejects_missing_name(client):
    """POST /api/sessions without name field returns 422."""
    response = client.post("/api/sessions", json={})
    assert response.status_code == 422

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_api.py::test_create_session_returns_200_with_name -v

Expected: FAIL — 405 or 404 (route doesn't exist yet).

Step 3: Add the endpoint to main.py

Add to the imports at the top of muxplex/main.py:

import subprocess

Add a new Pydantic model near the existing models:

class CreateSessionPayload(BaseModel):
    name: str

    @property
    def validated_name(self) -> str:
        if not self.name or not self.name.strip():
            raise ValueError("name must not be empty")
        return self.name.strip()

Add the route handler after the settings endpoints:

# ---------------------------------------------------------------------------
# New session creation
# ---------------------------------------------------------------------------


@app.post("/api/sessions")
async def create_session(payload: CreateSessionPayload) -> dict:
    """Create a new session by executing the configured template command.

    Substitutes {name} in the template, runs it as a fire-and-forget subprocess.
    No existence check — handles create-or-reattach patterns.
    Returns 200 with {"name": "..."} regardless of outcome.
    """
    name = payload.validated_name
    template = load_settings().get(
        "new_session_template", "tmux new-session -d -s {name}"
    )
    command = template.replace("{name}", name)
    try:
        subprocess.Popen(command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except Exception:
        _log.warning("Failed to execute new session command: %s", command)
    return {"name": name}

Note: The Pydantic validator on name gives us automatic 422 for empty/missing name. We need to add a custom validator. Actually, Pydantic v2 with str type accepts empty strings, so use a field_validator:

from pydantic import BaseModel, field_validator

class CreateSessionPayload(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def name_must_not_be_empty(cls, v: str) -> str:
        if not v or not v.strip():
            raise ValueError("name must not be empty")
        return v.strip()

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_api.py -k "create_session" -v

Expected: All 4 create_session tests pass.

Step 5: Commit

git add -A && git commit -m "feat: add POST /api/sessions endpoint for new session creation"

Task 6: Settings modal HTML + CSS skeleton

Files:

  • Modify: muxplex/frontend/index.html
  • Modify: muxplex/frontend/style.css

Step 1: Add gear icon and + button to the overview header

In muxplex/frontend/index.html, replace the overview header (lines 21-24):

    <header class="app-header">
      <h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
      <div class="header-actions">
        <button id="new-session-btn" class="header-btn" aria-label="New session" title="New session">+</button>
        <button id="settings-btn" class="header-btn" aria-label="Settings" title="Settings">&#9881;</button>
        <span id="connection-status"></span>
      </div>
    </header>

Step 2: Add the same buttons to the expanded header

Update the expanded header to include the gear icon (after expanded-session-name):

    <header class="expanded-header">
      <button id="back-btn" class="back-btn" aria-label="Back">&#8592;</button>
      <button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">&#9776;</button>
      <span id="expanded-session-name" class="expanded-session-name"></span>
      <button id="settings-btn-expanded" class="header-btn" aria-label="Settings" title="Settings">&#9881;</button>
    </header>

Step 3: Add the settings dialog HTML

Add after the toast element (before the <!-- ── Scripts --> comment):

  <!-- ── Settings dialog ──────────────────────────────────────── -->
  <div id="settings-backdrop" class="settings-backdrop hidden"></div>
  <dialog id="settings-dialog" class="settings-dialog">
    <div class="settings-layout">
      <nav class="settings-tabs" role="tablist">
        <button class="settings-tab settings-tab--active" data-tab="display" role="tab" aria-selected="true">Display</button>
        <button class="settings-tab" data-tab="sessions" role="tab" aria-selected="false">Sessions</button>
        <button class="settings-tab" data-tab="notifications" role="tab" aria-selected="false">Notifications</button>
        <button class="settings-tab" data-tab="new-session" role="tab" aria-selected="false">New Session</button>
      </nav>
      <div class="settings-content">
        <!-- Display tab -->
        <div id="settings-panel-display" class="settings-panel" role="tabpanel" data-tab="display">
          <h3 class="settings-panel-title">Display</h3>
          <label class="settings-field">
            <span class="settings-label">Font size</span>
            <select id="setting-font-size" class="settings-select">
              <option value="11">11px</option>
              <option value="12">12px</option>
              <option value="13">13px</option>
              <option value="14" selected>14px</option>
              <option value="16">16px</option>
            </select>
          </label>
          <label class="settings-field">
            <span class="settings-label">Hover preview delay</span>
            <select id="setting-hover-delay" class="settings-select">
              <option value="0">Off</option>
              <option value="1000">1s</option>
              <option value="1500" selected>1.5s</option>
              <option value="2000">2s</option>
              <option value="3000">3s</option>
            </select>
          </label>
          <label class="settings-field">
            <span class="settings-label">Grid columns</span>
            <select id="setting-grid-columns" class="settings-select">
              <option value="auto" selected>Auto</option>
              <option value="2">2</option>
              <option value="3">3</option>
              <option value="4">4</option>
            </select>
          </label>
        </div>
        <!-- Sessions tab (Phase 2) -->
        <div id="settings-panel-sessions" class="settings-panel hidden" role="tabpanel" data-tab="sessions"></div>
        <!-- Notifications tab (Phase 2) -->
        <div id="settings-panel-notifications" class="settings-panel hidden" role="tabpanel" data-tab="notifications"></div>
        <!-- New Session tab (Phase 2) -->
        <div id="settings-panel-new-session" class="settings-panel hidden" role="tabpanel" data-tab="new-session"></div>
      </div>
    </div>
  </dialog>

Step 4: Add settings CSS

Append to muxplex/frontend/style.css (at the bottom, before any final closing comments):

/* ============================================================
   Header action buttons (+ and gear)
   ============================================================ */

.header-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.header-btn {
  background: none;
  border: 1px solid var(--border);
  border-radius: 4px;
  color: var(--text-dim);
  font-size: 16px;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: color var(--t-fast), border-color var(--t-fast);
}

.header-btn:hover {
  color: var(--text);
  border-color: var(--text-muted);
}

/* ============================================================
   Settings dialog
   ============================================================ */

.settings-backdrop {
  position: fixed;
  inset: 0;
  background: var(--bg-overlay);
  backdrop-filter: blur(2px);
  z-index: 299;
}

.settings-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(600px, 90vw);
  height: min(480px, 80vh);
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  padding: 0;
  overflow: hidden;
  z-index: 300;
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
}

.settings-dialog::backdrop {
  background: transparent;  /* we use our own backdrop for blur */
}

.settings-layout {
  display: flex;
  height: 100%;
}

.settings-tabs {
  width: 140px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 16px 0;
  border-right: 1px solid var(--border);
  background: var(--bg);
}

.settings-tab {
  background: none;
  border: none;
  border-left: 2px solid transparent;
  color: var(--text-muted);
  font-size: 13px;
  font-family: var(--font-ui);
  padding: 8px 16px;
  text-align: left;
  cursor: pointer;
  transition: color var(--t-fast);
}

.settings-tab:hover {
  color: var(--text);
}

.settings-tab--active {
  color: var(--accent);
  border-left-color: var(--accent);
}

.settings-content {
  flex: 1;
  overflow-y: auto;
  padding: 24px;
}

.settings-panel-title {
  font-size: 16px;
  font-weight: 600;
  margin: 0 0 20px;
  color: var(--text);
}

.settings-field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 0;
  border-bottom: 1px solid var(--border-subtle);
}

.settings-label {
  font-size: 13px;
  color: var(--text);
}

.settings-select {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  color: var(--text);
  font-size: 13px;
  font-family: var(--font-ui);
  padding: 6px 10px;
  cursor: pointer;
}

.settings-select:focus {
  outline: 1px solid var(--accent);
  border-color: var(--accent);
}

/* Mobile: bottom half-sheet */
@media (max-width: 599px) {
  .settings-dialog {
    top: auto;
    bottom: 0;
    left: 0;
    right: 0;
    transform: none;
    width: 100%;
    height: 85vh;
    border-radius: 12px 12px 0 0;
  }

  .settings-layout {
    flex-direction: column;
  }

  .settings-tabs {
    width: 100%;
    flex-direction: row;
    border-right: none;
    border-bottom: 1px solid var(--border);
    padding: 0;
    overflow-x: auto;
  }

  .settings-tab {
    border-left: none;
    border-bottom: 2px solid transparent;
    padding: 12px 16px;
    white-space: nowrap;
    min-height: 48px;
  }

  .settings-tab--active {
    border-bottom-color: var(--accent);
    border-left-color: transparent;
  }
}

Step 5: Run tests

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py muxplex/tests/test_frontend_css.py -v

Expected: All tests pass (new elements don't conflict with existing tests).

Step 6: Commit

git add -A && git commit -m "feat: add settings dialog HTML structure and CSS"

Task 7: Settings JS infrastructure — open/close, tabs, localStorage

Files:

  • Modify: muxplex/frontend/app.js

Step 1: Add settings state variables

In muxplex/frontend/app.js, add after the existing app state block (after let _previewSessionName = null;):

// ─── Settings state ──────────────────────────────────────────────────────────
let _settingsOpen = false;
const DISPLAY_SETTINGS_KEY = 'muxplex.display';
const DISPLAY_DEFAULTS = {
  fontSize: 14,
  hoverPreviewDelay: 1500,
  gridColumns: 'auto',
  bellSound: false,
  notificationPermission: 'default',
};

Step 2: Add settings open/close functions

Add after the existing closeSession function:

// ─── Settings dialog ─────────────────────────────────────────────────────────

/**
 * Load display settings from localStorage, merged with defaults.
 * @returns {object}
 */
function loadDisplaySettings() {
  try {
    const raw = localStorage.getItem(DISPLAY_SETTINGS_KEY);
    if (raw) {
      const parsed = JSON.parse(raw);
      return Object.assign({}, DISPLAY_DEFAULTS, parsed);
    }
  } catch (_) { /* blocked or corrupt — use defaults */ }
  return Object.assign({}, DISPLAY_DEFAULTS);
}

/**
 * Save display settings to localStorage.
 * @param {object} settings
 */
function saveDisplaySettings(settings) {
  try {
    localStorage.setItem(DISPLAY_SETTINGS_KEY, JSON.stringify(settings));
  } catch (_) { /* blocked — ok */ }
}

/**
 * Open the settings dialog. Loads current values into form controls.
 */
function openSettings() {
  _settingsOpen = true;
  const dialog = $('settings-dialog');
  const backdrop = $('settings-backdrop');
  if (dialog && typeof dialog.showModal === 'function') {
    dialog.showModal();
  }
  if (backdrop) backdrop.classList.remove('hidden');

  // Load current display settings into form controls
  const ds = loadDisplaySettings();
  var fontSel = $('setting-font-size');
  var delaySel = $('setting-hover-delay');
  var colsSel = $('setting-grid-columns');
  if (fontSel) fontSel.value = String(ds.fontSize);
  if (delaySel) delaySel.value = String(ds.hoverPreviewDelay);
  if (colsSel) colsSel.value = String(ds.gridColumns);
}

/**
 * Close the settings dialog.
 */
function closeSettings() {
  _settingsOpen = false;
  const dialog = $('settings-dialog');
  const backdrop = $('settings-backdrop');
  if (dialog && typeof dialog.close === 'function') {
    try { dialog.close(); } catch (_) {}
  }
  if (backdrop) backdrop.classList.add('hidden');
}

/**
 * Switch to a settings tab by name.
 * @param {string} tabName - one of 'display', 'sessions', 'notifications', 'new-session'
 */
function switchSettingsTab(tabName) {
  // Update tab buttons
  document.querySelectorAll('.settings-tab').forEach(function(btn) {
    if (btn.dataset.tab === tabName) {
      btn.classList.add('settings-tab--active');
      btn.setAttribute('aria-selected', 'true');
    } else {
      btn.classList.remove('settings-tab--active');
      btn.setAttribute('aria-selected', 'false');
    }
  });
  // Show/hide panels
  document.querySelectorAll('.settings-panel').forEach(function(panel) {
    if (panel.dataset.tab === tabName) {
      panel.classList.remove('hidden');
    } else {
      panel.classList.add('hidden');
    }
  });
}

Step 3: Update handleGlobalKeydown for settings

Replace the handleGlobalKeydown function to add , shortcut and Escape for settings:

/**
 * Global keydown handler.
 * Comma opens settings. Escape closes settings or returns to grid.
 * @param {KeyboardEvent} e
 */
function handleGlobalKeydown(e) {
  // Settings dialog
  if (_settingsOpen) {
    if (e.key === 'Escape') {
      e.preventDefault();
      closeSettings();
    }
    return;  // don't process other shortcuts while settings is open
  }

  // Comma opens settings (unless typing in an input)
  if (e.key === ',' && !e.ctrlKey && !e.metaKey) {
    var tag = e.target && e.target.tagName;
    if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') {
      e.preventDefault();
      openSettings();
      return;
    }
  }

  if (_viewMode === 'fullscreen') {
    if (e.key === 'Escape') {
      e.preventDefault();
      closeSession();
    }
  }
}

Step 4: Wire up event listeners in bindStaticEventListeners

Add to bindStaticEventListeners (after the existing bindings, before the hover preview section):

  // Settings
  on($('settings-btn'), 'click', openSettings);
  on($('settings-btn-expanded'), 'click', openSettings);
  on($('settings-backdrop'), 'click', closeSettings);

  // Settings dialog: close on Escape via dialog's built-in cancel event
  var settingsDialog = $('settings-dialog');
  if (settingsDialog) {
    settingsDialog.addEventListener('cancel', function(e) {
      e.preventDefault();
      closeSettings();
    });
  }

  // Settings tab switching
  document.querySelectorAll('.settings-tab').forEach(function(tab) {
    on(tab, 'click', function() {
      switchSettingsTab(tab.dataset.tab);
    });
  });

Step 5: Add settings to module.exports

In the module.exports block, add:

    // Settings
    loadDisplaySettings,
    saveDisplaySettings,
    openSettings,
    closeSettings,
    switchSettingsTab,

Step 6: Run tests

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 7: Commit

git add -A && git commit -m "feat: add settings dialog open/close, tab switching, localStorage management"

Task 8: Wire Display tab — font size, hover delay, grid columns

Files:

  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/style.css

Step 1: Add display settings change handlers

In muxplex/frontend/app.js, add after the switchSettingsTab function:

/**
 * Apply display settings to the live UI.
 * Called on page load and whenever a display setting changes.
 * @param {object} ds - display settings object
 */
function applyDisplaySettings(ds) {
  // Font size: update CSS custom property (grid previews use it)
  document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');

  // Grid columns: set CSS custom property on session-grid
  var grid = $('session-grid');
  if (grid) {
    if (ds.gridColumns === 'auto') {
      grid.style.removeProperty('grid-template-columns');
    } else {
      grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
    }
  }
}

/**
 * Handle changes to display setting <select> elements.
 * Reads the value, updates localStorage, and applies the change immediately.
 */
function onDisplaySettingChange() {
  var ds = loadDisplaySettings();

  var fontSel = $('setting-font-size');
  var delaySel = $('setting-hover-delay');
  var colsSel = $('setting-grid-columns');

  if (fontSel) ds.fontSize = parseInt(fontSel.value, 10);
  if (delaySel) ds.hoverPreviewDelay = parseInt(delaySel.value, 10);
  if (colsSel) ds.gridColumns = colsSel.value === 'auto' ? 'auto' : parseInt(colsSel.value, 10);

  saveDisplaySettings(ds);
  applyDisplaySettings(ds);
}

Step 2: Update the hover preview timer to use settings

In bindStaticEventListeners, update the hover preview mouseenter handlers. Replace the hardcoded 1500 in both the grid and sidebar hover handlers with a dynamic lookup:

Find these two occurrences in bindStaticEventListeners:

      _previewTimer = setTimeout(function () { showPreview(name); }, 1500);

Replace each with:

      var delay = loadDisplaySettings().hoverPreviewDelay;
      if (delay > 0) {
        _previewTimer = setTimeout(function () { showPreview(name); }, delay);
      }

This makes "Off" (value 0) disable preview entirely, and other values use the configured delay.

Step 3: Bind change listeners for Display tab controls in bindStaticEventListeners

Add to bindStaticEventListeners (after the settings tab switching code):

  // Display settings change handlers
  on($('setting-font-size'), 'change', onDisplaySettingChange);
  on($('setting-hover-delay'), 'change', onDisplaySettingChange);
  on($('setting-grid-columns'), 'change', onDisplaySettingChange);

Step 4: Apply display settings on page load

In the DOMContentLoaded handler, add applyDisplaySettings(loadDisplaySettings()) right after initDeviceId():

document.addEventListener('DOMContentLoaded', () => {
  initDeviceId();
  applyDisplaySettings(loadDisplaySettings());  // <-- add this line
  document.addEventListener('keydown', trackInteraction);
  // ... rest unchanged
});

Step 5: Add grid column override CSS support

In muxplex/frontend/style.css, find the .session-grid rule (it should contain display: grid and grid-template-columns). Verify it uses auto-fill or auto-fit — the inline style.gridTemplateColumns from JS will override it when set. No CSS change needed if the existing grid uses the standard grid-template-columns: repeat(auto-fill, minmax(...)) pattern.

Step 6: Add exports

Add to module.exports:

    applyDisplaySettings,
    onDisplaySettingChange,

Step 7: Run full test suite

cd muxplex && python -m pytest muxplex/tests/ -v

Expected: All tests pass.

Step 8: Commit

git add -A && git commit -m "feat: wire Display tab — font size, hover delay, grid columns with immediate apply"

Phase 1 Complete

After completing all 8 tasks, Phase 1 delivers:

  1. Command palette fully removed — no dead code remaining
  2. Server-side settings modulesettings.py with load/save/patch
  3. Three new API endpointsGET/PATCH /api/settings, POST /api/sessions
  4. Settings modal — centered dialog (desktop) / bottom sheet (mobile) with tab navigation
  5. Display tab functional — font size, hover delay, grid columns all apply immediately via localStorage
  6. Gear icon and + button in both headers (ready for Phase 2 wiring)

Proceed to Phase 2 for the remaining tabs and new session UI flow.