From fccf212fe5fd260ebd33867e97b424f5550cf882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 10:40:11 +0000 Subject: [PATCH] tmux that actually persists, and a running-shells panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tmux panel typed bare `tmux`, which starts a NEW session every time — so the panel forgot your windows whenever the shell behind it went away, including on a `pm2 restart officer-pty`. It now runs `new-session -A -s `, which attaches if the session exists and creates it otherwise, named per panel from panelId (stable in the saved layout). The tmux server outlives the pty, so this survives what a pty session cannot. CommandTerminalWrapper had the same unmount bug TerminalWrapper did — it deleted the panel -> session mapping on unmount, so every layout change abandoned the shell AND re-ran the command from scratch. Kept across unmounts now, same as the plain terminal. Two things the seeded .tmux.conf needs that were missing: - COLORTERM=truecolor in the pty env. TERM only advertises 256 colours, and COLORTERM is what programs check before emitting 24-bit — so we were throwing away colour depth for tmux, neovim, bat, delta and any modern TUI. xterm.js renders it fine. - macOptionIsMeta. On macOS Option is a compose key, so Alt bindings never reach the shell — silently breaking the M-arrow and M-hjkl pane switching the config leans on. No effect on other platforms. Also rightClickSelectsWord, so right-click stops opening the browser menu over the terminal. Adds a Running Shells panel: every shell the sidecar holds, with the title it set for itself (usually the running command), pid, size, idle and uptime, and a kill button. That is the other half of keeping session mappings across unmounts — the leak stops being invisible, and stops needing curl to find. Co-Authored-By: Claude Opus 5 (1M context) --- src/servers/sidecar/pty/index.mjs | 5 +- .../apps/Terminal/CommandTerminalWrapper.tsx | 15 +-- .../src/apps/Terminal/RunningShells.tsx | 104 ++++++++++++++++++ .../officerdev/src/apps/Terminal/Terminal.tsx | 6 + .../officerdev/src/apps/Terminal/index.tsx | 22 +++- 5 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Terminal/RunningShells.tsx diff --git a/src/servers/sidecar/pty/index.mjs b/src/servers/sidecar/pty/index.mjs index 05d2cb37..78980134 100644 --- a/src/servers/sidecar/pty/index.mjs +++ b/src/servers/sidecar/pty/index.mjs @@ -133,7 +133,10 @@ async function handleCommand(msg) { cols, rows, cwd, - env: { ...process.env, TERM: 'xterm-256color' }, + // COLORTERM is how programs decide they may emit 24-bit colour — TERM only advertises 256. + // xterm.js renders truecolor fine, so without this we were throwing away colour depth for + // anything that checks (tmux with `*:RGB`, neovim, bat, delta, modern TUIs). + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to start terminal'; diff --git a/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx b/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx index cbc528c9..68228d0f 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/CommandTerminalWrapper.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { useWorkspace } from '../../components/Workspace'; import { useDashboardState } from 'state/useDashboardState'; import { useGlobal } from 'hooks/useGlobal'; @@ -18,9 +18,6 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR const { dashboardId, cwd } = useWorkspace(); const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`; const { value: terminals, setValue: setTerminals } = useDashboardState>(stateKey, EMPTY_TERMINALS); - const setTerminalsRef = useRef(setTerminals); - setTerminalsRef.current = setTerminals; - const sessionId = terminals[panelId]; useEffect(() => { @@ -29,14 +26,8 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR } }, [panelId, sessionId, setTerminals]); - useEffect(() => { - return () => { - setTerminalsRef.current((prev) => { - const { [panelId]: _, ...rest } = prev; - return rest; - }); - }; - }, [panelId]); + // Kept across unmounts, same as TerminalWrapper: deleting it here meant every layout or route change + // minted a new uuid, abandoned the running shell, and re-ran the command from scratch. const [, setConnState] = useGlobal(`terminal-conn-${panelId}`, 'disconnected'); const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]); diff --git a/src/workspaces/officerdev/src/apps/Terminal/RunningShells.tsx b/src/workspaces/officerdev/src/apps/Terminal/RunningShells.tsx new file mode 100644 index 00000000..1574b499 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Terminal/RunningShells.tsx @@ -0,0 +1,104 @@ +import { useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { TerminalSquare, Trash2, RefreshCw } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; + +// Every shell the pty sidecar is holding, whether or not a panel is pointing at it. +// +// A terminal panel keeps its session id across unmounts so reopening re-attaches — which means a panel +// deleted for good leaves its shell running with nothing pointing at it. This is where those become +// visible and killable. Without it the only way to find one was curl. + +type PtySession = { + sessionId: string; + cols: number; + rows: number; + createdAt: number; + lastActivityAt: number; + title?: string; + pid?: number; +}; + +const RELATIVE_UNITS: [limit: number, div: number, unit: string][] = [ + [60_000, 1000, 's'], + [3_600_000, 60_000, 'm'], + [86_400_000, 3_600_000, 'h'], +]; + +const since = (ts: number): string => { + if (!ts) return '—'; + const delta = Date.now() - ts; + for (const [limit, div, unit] of RELATIVE_UNITS) { + if (delta < limit) return `${Math.max(0, Math.floor(delta / div))}${unit}`; + } + return `${Math.floor(delta / 86_400_000)}d`; +}; + +export const RunningShells = () => { + const client = useClient(); + const queryClient = useQueryClient(); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['terminal-sessions'], + queryFn: () => client.get<{ sessions: PtySession[] }>('/terminal/sessions'), + refetchInterval: 5000, + }); + + const kill = useCallback( + async (sessionId: string) => { + await client.delete(`/terminal/sessions/${sessionId}`); + // The shell dies asynchronously (the sidecar answers on its pty:exit broadcast, not to us), so give + // it a beat before asking again rather than showing a row that is already gone. + setTimeout(() => queryClient.invalidateQueries({ queryKey: ['terminal-sessions'] }), 300); + }, + [client, queryClient], + ); + + const sessions = data?.sessions ?? []; + + return ( +
+
+ + Running shells + {sessions.length} + +
+ +
+ {isLoading &&

Loading…

} + {!isLoading && sessions.length === 0 && ( +

No shells running.

+ )} + {sessions.map((s) => ( +
+
+ {/* The title the shell set for itself (OSC 0/2) — usually the running command. It is what + makes an abandoned shell identifiable instead of a bare uuid. */} +

{s.title || 'shell'}

+

+ {s.sessionId.slice(0, 8)} · {s.cols}×{s.rows} + {s.pid ? ` · pid ${s.pid}` : ''} · idle {since(s.lastActivityAt)} · up {since(s.createdAt)} +

+
+ +
+ ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx index 71824c41..7c0e0716 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx @@ -145,6 +145,12 @@ export const TerminalView = ({ fontFamily, scrollback: SCROLLBACK_LINES, allowProposedApi: true, // required by the unicode11 addon + // On macOS, Option is a compose key by default, so Alt-based bindings never reach the shell — which + // silently breaks the M-arrow / M-hjkl pane switching in the seeded .tmux.conf. Treating it as Meta + // sends the ESC-prefixed sequence tmux expects. No effect on other platforms. + macOptionIsMeta: true, + // Right-click selects a word rather than opening the browser menu over the terminal. + rightClickSelectsWord: true, theme: { background, foreground, diff --git a/src/workspaces/officerdev/src/apps/Terminal/index.tsx b/src/workspaces/officerdev/src/apps/Terminal/index.tsx index bc2be2e0..81dbf3f6 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/index.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/index.tsx @@ -1,16 +1,28 @@ import { useCallback } from 'react'; import type { AppRegistryMeta } from '../../AppRegistry'; -import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles } from 'lucide-react'; +import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, ListTree } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { TerminalWrapper } from './TerminalWrapper'; import { HostTerminalWrapper } from './HostTerminalWrapper'; import { CommandTerminalWrapper } from './CommandTerminalWrapper'; import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader, ClaudeCodeHeader } from './Headers'; +import { RunningShells } from './RunningShells'; export { TerminalView, type TerminalViewProps, type TerminalConnectionState } from './Terminal'; +// `new-session -A -s ` attaches to that session if it exists and creates it otherwise. Bare `tmux` +// started a brand new session every time, so the panel forgot your windows whenever the shell behind it +// went away — including on a `pm2 restart officer-pty`. Named per panel and derived from panelId, which is +// stable in the saved layout, so this panel always returns to its own session. The tmux server outlives +// the pty, which is what makes this survive things the pty session cannot. +const tmuxSessionName = (panelId: string) => `off-${panelId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24)}`; + const TmuxWrapper = ({ panelId }: { panelId: string }) => ( - + ); const NvimWrapper = ({ panelId }: { panelId: string }) => ( @@ -66,6 +78,12 @@ export const appRegistryMetas: AppRegistryMeta[] = [ component: NvimWrapper, header: NvimHeader, }, + { + key: 'officerdev/running-shells', + name: 'Running Shells', + icon: ListTree, + component: RunningShells, + }, { key: 'officerdev/claude-code', name: 'Claude Code',