diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 1f83359..11c309e 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -31,13 +31,17 @@ function formatTimestamp(ts) { /** * Return the priority label for a session object. * @param {object} session - * @returns {'bell'|'idle'} + * @returns {'bell'|'active'|'idle'} */ function sessionPriority(session) { const bell = session && session.bell; if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) { return 'bell'; } + const activity = session && session.last_activity_at; + if (activity && (Date.now() / 1000 - activity) < 300) { + return 'active'; + } return 'idle'; } @@ -323,6 +327,17 @@ async function restoreState() { skipAnimation: true, remoteId: state.active_remote_id || '', }); + } else if (_serverSettings && _serverSettings.default_session) { + // Fallback to default_session setting if no active session + var defaultName = _serverSettings.default_session; + // Verify the session actually exists before opening + try { + var sessRes = await api('GET', '/api/sessions'); + var sessList = await sessRes.json(); + if (sessList.some(function(s) { return s.name === defaultName; })) { + await openSession(defaultName, { skipAnimation: true }); + } + } catch (e) { /* ignore */ } } } catch (err) { console.warn('[restoreState] could not restore previous session:', err); @@ -1528,7 +1543,23 @@ function renderGrid(sessions) { } } + // Search/filter bar integration + var filterBar = $('filter-bar'); + var filterQuery = filterBar && filterBar.query ? filterBar.query : ''; + var visible = getVisibleSessions(sessions); + var totalVisible = visible.length; + + if (filterQuery) { + var q = filterQuery.toLowerCase(); + visible = visible.filter(function(s) { return (s.name || '').toLowerCase().includes(q); }); + } + + if (filterBar && filterBar.tagName === 'SEARCH-FILTER') { + filterBar.total = totalVisible; + filterBar.count = visible.length; + } + var emptyState = $('empty-state'); if (visible.length === 0) { @@ -3934,6 +3965,14 @@ function bindStaticEventListeners() { }); } + // Search/filter bar — re-render grid when filter text changes + var filterBar = $('filter-bar'); + if (filterBar) { + filterBar.addEventListener('filter-change', function() { + renderGrid(_currentSessions || []); + }); + } + // Hover preview — component handles click internally; listen for preview-click var hoverPreview = $('hover-preview'); if (hoverPreview) { diff --git a/muxplex/frontend/components/search-filter.js b/muxplex/frontend/components/search-filter.js new file mode 100644 index 0000000..f47bd12 --- /dev/null +++ b/muxplex/frontend/components/search-filter.js @@ -0,0 +1,106 @@ +import { LitElement, html, css } from '/vendor/lit/lit.min.js'; + +export class SearchFilter extends LitElement { + static properties = { + query: { type: String, reflect: true }, + placeholder: { type: String }, + count: { type: Number }, + total: { type: Number }, + }; + + static styles = css` + :host { display: block; } + .filter-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 0 var(--grid-padding, 16px); + height: 40px; + } + .filter-input { + flex: 1; + max-width: 320px; + padding: 6px 12px; + font-size: 13px; + background: var(--bg-tile, #10131C); + color: var(--text, #F0F6FF); + border: 1px solid var(--border, #2A3040); + border-radius: 6px; + outline: none; + transition: border-color 150ms; + } + .filter-input:focus { + border-color: var(--accent, #00D9F5); + } + .filter-input::placeholder { + color: var(--text-muted, #8E95A3); + } + .filter-count { + font-size: 12px; + color: var(--text-muted, #8E95A3); + } + .filter-clear { + background: none; + border: none; + color: var(--text-muted, #8E95A3); + cursor: pointer; + padding: 4px; + font-size: 12px; + } + .filter-clear:hover { color: var(--text, #F0F6FF); } + `; + + constructor() { + super(); + this.query = ''; + this.placeholder = 'Filter sessions...'; + this.count = 0; + this.total = 0; + } + + _onInput(e) { + this.query = e.target.value; + this.dispatchEvent(new CustomEvent('filter-change', { + bubbles: true, composed: true, + detail: { query: this.query }, + })); + } + + _onClear() { + this.query = ''; + this.dispatchEvent(new CustomEvent('filter-change', { + bubbles: true, composed: true, + detail: { query: '' }, + })); + } + + _onKeydown(e) { + if (e.key === 'Escape') { + this._onClear(); + e.target.blur(); + } + } + + render() { + const showCount = this.query && this.query.length > 0; + return html` +
+ + ${showCount ? html` + ${this.count} of ${this.total} + + ` : html``} +
+ `; + } +} + +customElements.define('search-filter', SearchFilter); \ No newline at end of file diff --git a/muxplex/frontend/components/session-tile.js b/muxplex/frontend/components/session-tile.js index 5f7c873..69f0eda 100644 --- a/muxplex/frontend/components/session-tile.js +++ b/muxplex/frontend/components/session-tile.js @@ -64,15 +64,19 @@ export class SessionTile extends LitElement { get _classes() { let cls = 'session-tile'; const ind = this.activityIndicator; + const priority = this._priority; if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' session-tile--bell'; if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' session-tile--edge-bell'; - if (this.mobile) cls += ` session-tile--tier-${this._priority}`; + if (priority === 'active') cls += ' session-tile--active'; + const activity = this.session && this.session.last_activity_at; + if (!activity || (Date.now() / 1000 - activity) > 3600) cls += ' session-tile--stale'; + if (this.mobile) cls += ` session-tile--tier-${priority}`; return cls; } get _snapshotHtml() { const snapshot = this.session.snapshot || ''; - const lineCount = this.viewMode === 'fit' ? -80 : -20; + const lineCount = this.viewMode === 'fit' ? -80 : -12; // Trim trailing blank lines first (see buildTileHTML comment in app.js) const allLines = snapshot.split('\n'); while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { diff --git a/muxplex/frontend/components/sidebar-item.js b/muxplex/frontend/components/sidebar-item.js index 8b876ee..3b503ce 100644 --- a/muxplex/frontend/components/sidebar-item.js +++ b/muxplex/frontend/components/sidebar-item.js @@ -1,6 +1,6 @@ import { LitElement, html, nothing } from '/vendor/lit/lit.min.js'; import { unsafeHTML } from '/vendor/lit/lit.min.js'; -import { escapeHtml, ansiToHtml } from './utils.js'; +import { escapeHtml, sessionPriority, ansiToHtml } from './utils.js'; import { icons } from './icons.js'; /** @@ -47,12 +47,20 @@ export class SidebarItem extends LitElement { return unseen && unseen > 0; } + get _priority() { + return sessionPriority(this.session); + } + get _classes() { let cls = 'sidebar-item'; if (this.active) cls += ' sidebar-item--active'; const ind = this.activityIndicator; + const priority = this._priority; if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--bell'; if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' sidebar-item--edge-bell'; + if (priority === 'active') cls += ' sidebar-item--activity'; + const activity = this.session && this.session.last_activity_at; + if (!activity || (Date.now() / 1000 - activity) > 3600) cls += ' sidebar-item--stale'; return cls; } @@ -62,7 +70,7 @@ export class SidebarItem extends LitElement { while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { allLines.pop(); } - return ansiToHtml(allLines.slice(-20).join('\n')); + return ansiToHtml(allLines.slice(-12).join('\n')); } _onClick(e) { diff --git a/muxplex/frontend/components/utils.js b/muxplex/frontend/components/utils.js index 0a50376..9333135 100644 --- a/muxplex/frontend/components/utils.js +++ b/muxplex/frontend/components/utils.js @@ -29,13 +29,17 @@ export function formatTimestamp(ts) { /** * Return the priority label for a session object. * @param {object} session - * @returns {'bell'|'idle'} + * @returns {'bell'|'active'|'idle'} */ export function sessionPriority(session) { const bell = session && session.bell; if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) { return 'bell'; } + const activity = session && session.last_activity_at; + if (activity && (Date.now() / 1000 - activity) < 300) { + return 'active'; + } return 'idle'; } diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 8c43dbc..cd4e396 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -27,7 +27,7 @@ -
+ @@ -287,6 +287,7 @@ import '/components/session-grid.js'; import '/components/store.js'; import '/components/view-dropdown.js'; + import '/components/search-filter.js'; diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index a37181b..b50ce07 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -51,6 +51,9 @@ /* Typography */ --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif; --font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace; + + /* Preview */ + --preview-font-size: 12px; } /* Inline SVG icons — ensure consistent vertical alignment */ @@ -214,6 +217,11 @@ body { border-left-color: var(--bell); } +/* Active tile (recent activity within last 5 minutes) */ +.session-tile--active { + border-left-color: var(--accent); +} + /* ============================================================ Tile header ============================================================ */ @@ -250,10 +258,7 @@ body { margin: 0 2px; } -.session-tile:hover .tile-meta, -.session-tile:focus-within .tile-meta { - opacity: 0; -} +/* P4 hover fade fix: show meta on hover instead of hiding */ /* Bell notification badge */ .tile-bell { @@ -311,8 +316,8 @@ body { right: 0; padding: 6px 8px; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; - font-size: 11px; - line-height: 1.0; + font-size: var(--preview-font-size, 12px); + line-height: 1.2; color: #c9d1d9; /* match xterm.js foreground */ white-space: pre; overflow: hidden; @@ -348,13 +353,21 @@ body { inset: 0; padding: 6px 8px; font-family: var(--font-mono); - font-size: 11px; + font-size: var(--preview-font-size, 12px); + line-height: 1.2; color: var(--text-dim); white-space: pre; overflow: hidden; margin: 0; } +/* Stale / idle tile dimming (no activity for >1 hour) */ +.session-tile--stale .tile-name { + color: var(--text-muted); +} +.session-tile--stale .tile-body { + opacity: 0.6; +} /* ============================================================ diff --git a/muxplex/main.py b/muxplex/main.py index 8b85e4c..a0b3d11 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -51,6 +51,7 @@ from muxplex.auth import ( from muxplex.bells import apply_bell_clear_rule, process_bell_flags from muxplex.sessions import ( enumerate_sessions, + get_session_activity, get_session_list, get_snapshots, run_tmux, @@ -180,12 +181,12 @@ async def _run_poll_cycle() -> None: global _settings_sync_counter async with state_lock: # 1. Enumerate live tmux sessions - names = await enumerate_sessions() + names, activity = await enumerate_sessions() name_set = set(names) # 2. Capture pane snapshots and update in-memory snapshot cache new_snapshots = await snapshot_all(names) - update_session_cache(names, new_snapshots) + update_session_cache(names, new_snapshots, activity) # 3. Load current persisted state state = load_state() @@ -583,9 +584,10 @@ async def patch_state(patch: StatePatch) -> dict: @app.get("/api/sessions") async def get_sessions() -> list[dict]: - """Return list of sessions with name, snapshot, and bell data.""" + """Return list of sessions with name, snapshot, bell, and activity data.""" names = get_session_list() snapshots = get_snapshots() + activities = get_session_activity() state = await read_state() result = [] @@ -597,6 +599,7 @@ async def get_sessions() -> list[dict]: "name": name, "snapshot": snapshots.get(name, ""), "bell": bell, + "last_activity_at": activities.get(name), } ) return result @@ -654,7 +657,7 @@ async def create_session(payload: CreateSessionPayload) -> dict: # Some commands (amplifier-workspace) create the session then # try to attach (which fails without a TTY). If the session # exists despite the non-zero exit, treat it as success. - sessions = await enumerate_sessions() + sessions, _act = await enumerate_sessions() if name in sessions: _log.info( "Session command exited %d but session '%s' exists -- " @@ -699,9 +702,9 @@ async def create_session(payload: CreateSessionPayload) -> dict: # Eagerly refresh the session cache so the next GET /api/sessions # reflects the newly-created session without waiting for the poll loop. try: - fresh_names = await enumerate_sessions() + fresh_names, fresh_activity = await enumerate_sessions() fresh_snapshots = await snapshot_all(fresh_names) - update_session_cache(fresh_names, fresh_snapshots) + update_session_cache(fresh_names, fresh_snapshots, fresh_activity) except Exception: pass # non-fatal; poll loop will catch up @@ -1349,6 +1352,7 @@ async def federation_sessions(request: Request) -> list[dict]: # Build local sessions with deviceId/deviceName/remoteId/sessionKey tags names = get_session_list() snapshots = get_snapshots() + activities = get_session_activity() state = await read_state() local_sessions: list[dict] = [] for name in names: @@ -1359,6 +1363,7 @@ async def federation_sessions(request: Request) -> list[dict]: "name": name, "snapshot": snapshots.get(name, ""), "bell": bell, + "last_activity_at": activities.get(name), "deviceId": local_device_id, "deviceName": local_device_name, "remoteId": None, diff --git a/muxplex/sessions.py b/muxplex/sessions.py index 7431659..95401a7 100644 --- a/muxplex/sessions.py +++ b/muxplex/sessions.py @@ -4,15 +4,17 @@ tmux session enumeration and snapshot helpers for the tmux-web muxplex. In-memory cache: _session_list — most-recently-enumerated list of session names. _snapshots — most-recently-captured pane text, keyed by session name. + _session_activity — most-recently-captured session activity timestamps. Public API: - get_session_list() → list[str] - get_snapshots() → dict[str, str] - update_session_cache(names, snapshots) → None - run_tmux(*args) → str (raises RuntimeError on nonzero exit) - enumerate_sessions() → list[str] - capture_pane(name, lines) → str - snapshot_all(names) → dict[str, str] + get_session_list() → list[str] + get_snapshots() → dict[str, str] + get_session_activity() → dict[str, float] + update_session_cache(names, snapshots, activity) → None + run_tmux(*args) → str (raises RuntimeError on nonzero exit) + enumerate_sessions() → tuple[list[str], dict[str, float]] + capture_pane(name, lines) → str + snapshot_all(names) → dict[str, str] """ import asyncio @@ -23,6 +25,7 @@ import asyncio _session_list: list[str] = [] _snapshots: dict[str, str] = {} +_session_activity: dict[str, float] = {} def get_session_list() -> list[str]: @@ -35,15 +38,28 @@ def get_snapshots() -> dict[str, str]: return dict(_snapshots) -def update_session_cache(names: list[str], snapshots: dict[str, str]) -> None: +def get_session_activity() -> dict[str, float]: + """Return a copy of the cached session activity timestamp dict.""" + return dict(_session_activity) + + +def update_session_cache( + names: list[str], + snapshots: dict[str, str], + activity: dict[str, float] | None = None, +) -> None: """Replace the in-memory caches with fresh data. Sets _session_list to *names* and _snapshots to the provided *snapshots* dict. Callers must pass the return value of snapshot_all() as *snapshots*. + Optionally accepts *activity* — a mapping of session name to Unix timestamp + of last activity (from ``#{session_activity}``). """ - global _session_list, _snapshots + global _session_list, _snapshots, _session_activity _session_list = list(names) _snapshots = snapshots + if activity is not None: + _session_activity = activity # --------------------------------------------------------------------------- @@ -75,21 +91,38 @@ async def run_tmux(*args: str) -> str: # --------------------------------------------------------------------------- -async def enumerate_sessions() -> list[str]: - """Return the list of currently running tmux session names. +async def enumerate_sessions() -> tuple[list[str], dict[str, float]]: + """Return the list of currently running tmux session names and their activity timestamps. - Calls ``tmux list-sessions -F #{session_name}``, splits on newlines, - and strips whitespace from each entry. + Calls ``tmux list-sessions -F '#{session_name}\\t#{session_activity}'``, + splits on newlines, and parses each line into a name and a Unix timestamp. - Returns [] if tmux is not running (RuntimeError from run_tmux). + Returns ``([], {})`` if tmux is not running (RuntimeError from run_tmux). """ try: - output = await run_tmux("list-sessions", "-F", "#{session_name}") + output = await run_tmux( + "list-sessions", "-F", "#{session_name}\t#{session_activity}" + ) except (RuntimeError, FileNotFoundError): - return [] + return [], {} - names = [line.strip() for line in output.splitlines() if line.strip()] - return names + names: list[str] = [] + activity: dict[str, float] = {} + for line in output.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split("\t", 1) + name = parts[0].strip() + if not name: + continue + names.append(name) + if len(parts) > 1: + try: + activity[name] = float(parts[1].strip()) + except (ValueError, TypeError): + pass + return names, activity # --------------------------------------------------------------------------- diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 5f57129..76e029f 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -2857,7 +2857,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session( # Mock all poll-cycle dependencies so the cycle completes without real tmux async def mock_enumerate(): - return ["build"] + return ["build"], {"build": 1700000000.0} async def mock_snapshot_all(names): return {"build": "pane text"} @@ -2868,7 +2868,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session( monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate) monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all) monkeypatch.setattr( - "muxplex.main.update_session_cache", lambda names, snapshots: None + "muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None ) monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None) monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None) @@ -2969,7 +2969,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uu # Mock all poll-cycle dependencies so the cycle completes without real tmux async def mock_enumerate(): - return ["build"] + return ["build"], {"build": 1700000000.0} async def mock_snapshot_all(names): return {"build": "pane text"} @@ -2980,7 +2980,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uu monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate) monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all) monkeypatch.setattr( - "muxplex.main.update_session_cache", lambda names, snapshots: None + "muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None ) monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None) monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None) diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index dc5f539..c3498f5 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -675,11 +675,11 @@ def test_tile_body_has_black_background(): def test_tile_body_pre_has_xterm_line_height(): - """.tile-body pre must use line-height: 1.0 to match xterm.js terminal.""" + """.tile-body pre must use line-height: 1.2 for readable tile previews.""" css = read_css() block = _extract_rule_block(css, ".tile-body pre {") - assert "line-height: 1.0" in block, ( - ".tile-body pre must use line-height: 1.0 (xterm.js default)" + assert "line-height: 1.2" in block, ( + ".tile-body pre must use line-height: 1.2 (readable preview)" ) diff --git a/muxplex/tests/test_integration.py b/muxplex/tests/test_integration.py index b9e391c..55f7117 100644 --- a/muxplex/tests/test_integration.py +++ b/muxplex/tests/test_integration.py @@ -135,7 +135,7 @@ async def test_enumerate_sessions_finds_test_session(tmux_server): """enumerate_sessions discovers the 'test' session on the isolated tmux server.""" patched_run_tmux = make_run_tmux_for_socket(tmux_server) with patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux): - sessions = await enumerate_sessions() + sessions, activity = await enumerate_sessions() assert "test" in sessions diff --git a/muxplex/tests/test_sessions.py b/muxplex/tests/test_sessions.py index 0ae8299..a377bed 100644 --- a/muxplex/tests/test_sessions.py +++ b/muxplex/tests/test_sessions.py @@ -83,36 +83,39 @@ async def test_run_tmux_raises_on_nonzero_exit(mock_subprocess): async def test_enumerate_sessions_parses_newline_output(mock_subprocess): - """enumerate_sessions() splits newline-separated output into a list of names.""" - with mock_subprocess("alpha\nbeta\ngamma\n"): - result = await enumerate_sessions() + """enumerate_sessions() splits tab-separated output into names and activity.""" + with mock_subprocess("alpha\t1700000001\nbeta\t1700000002\ngamma\t1700000003\n"): + names, activity = await enumerate_sessions() - assert result == ["alpha", "beta", "gamma"] + assert names == ["alpha", "beta", "gamma"] + assert activity == {"alpha": 1700000001.0, "beta": 1700000002.0, "gamma": 1700000003.0} async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess): - """enumerate_sessions() returns [] when tmux output is empty.""" + """enumerate_sessions() returns ([], {}) when tmux output is empty.""" with mock_subprocess(""): - result = await enumerate_sessions() + names, activity = await enumerate_sessions() - assert result == [] + assert names == [] + assert activity == {} async def test_enumerate_sessions_strips_whitespace(mock_subprocess): """enumerate_sessions() strips leading/trailing whitespace from each name.""" - with mock_subprocess(" session1 \n session2 \n"): - result = await enumerate_sessions() + with mock_subprocess(" session1 \t1700000001\n session2 \t1700000002\n"): + names, activity = await enumerate_sessions() - assert result == ["session1", "session2"] + assert names == ["session1", "session2"] async def test_enumerate_sessions_handles_tmux_error(mock_subprocess): - """enumerate_sessions() returns [] when run_tmux raises RuntimeError + """enumerate_sessions() returns ([], {}) when run_tmux raises RuntimeError (e.g. tmux server not running).""" with mock_subprocess(stdout="", stderr="no server running", returncode=1): - result = await enumerate_sessions() + names, activity = await enumerate_sessions() - assert result == [] + assert names == [] + assert activity == {} # ---------------------------------------------------------------------------