tmux that actually persists, and a running-shells panel
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 <name>`, 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) <noreply@anthropic.com>
This commit is contained in:
@@ -133,7 +133,10 @@ async function handleCommand(msg) {
|
|||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
cwd,
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
@@ -18,9 +18,6 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR
|
|||||||
const { dashboardId, cwd } = useWorkspace();
|
const { dashboardId, cwd } = useWorkspace();
|
||||||
const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`;
|
const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`;
|
||||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||||
const setTerminalsRef = useRef(setTerminals);
|
|
||||||
setTerminalsRef.current = setTerminals;
|
|
||||||
|
|
||||||
const sessionId = terminals[panelId];
|
const sessionId = terminals[panelId];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,14 +26,8 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR
|
|||||||
}
|
}
|
||||||
}, [panelId, sessionId, setTerminals]);
|
}, [panelId, sessionId, setTerminals]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Kept across unmounts, same as TerminalWrapper: deleting it here meant every layout or route change
|
||||||
return () => {
|
// minted a new uuid, abandoned the running shell, and re-ran the command from scratch.
|
||||||
setTerminalsRef.current((prev) => {
|
|
||||||
const { [panelId]: _, ...rest } = prev;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [panelId]);
|
|
||||||
|
|
||||||
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||||
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden text-sm">
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2">
|
||||||
|
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-xs font-medium">Running shells</span>
|
||||||
|
<span className="text-[10px] opacity-60">{sessions.length}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
title="Refresh"
|
||||||
|
className="ml-auto cursor-pointer opacity-60 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading && <p className="px-3 py-3 text-xs opacity-60">Loading…</p>}
|
||||||
|
{!isLoading && sessions.length === 0 && (
|
||||||
|
<p className="px-3 py-3 text-xs opacity-60">No shells running.</p>
|
||||||
|
)}
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div key={s.sessionId} className="flex items-center gap-2 border-b border-white/5 px-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* 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. */}
|
||||||
|
<p className="truncate text-xs font-medium">{s.title || 'shell'}</p>
|
||||||
|
<p className="truncate font-mono text-[10px] opacity-50">
|
||||||
|
{s.sessionId.slice(0, 8)} · {s.cols}×{s.rows}
|
||||||
|
{s.pid ? ` · pid ${s.pid}` : ''} · idle {since(s.lastActivityAt)} · up {since(s.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => kill(s.sessionId)}
|
||||||
|
title="Kill this shell"
|
||||||
|
className="shrink-0 cursor-pointer p-1 opacity-60 hover:text-red-400 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -145,6 +145,12 @@ export const TerminalView = ({
|
|||||||
fontFamily,
|
fontFamily,
|
||||||
scrollback: SCROLLBACK_LINES,
|
scrollback: SCROLLBACK_LINES,
|
||||||
allowProposedApi: true, // required by the unicode11 addon
|
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: {
|
theme: {
|
||||||
background,
|
background,
|
||||||
foreground,
|
foreground,
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
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 { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { TerminalWrapper } from './TerminalWrapper';
|
import { TerminalWrapper } from './TerminalWrapper';
|
||||||
import { HostTerminalWrapper } from './HostTerminalWrapper';
|
import { HostTerminalWrapper } from './HostTerminalWrapper';
|
||||||
import { CommandTerminalWrapper } from './CommandTerminalWrapper';
|
import { CommandTerminalWrapper } from './CommandTerminalWrapper';
|
||||||
import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader, ClaudeCodeHeader } from './Headers';
|
import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader, ClaudeCodeHeader } from './Headers';
|
||||||
|
import { RunningShells } from './RunningShells';
|
||||||
|
|
||||||
export { TerminalView, type TerminalViewProps, type TerminalConnectionState } from './Terminal';
|
export { TerminalView, type TerminalViewProps, type TerminalConnectionState } from './Terminal';
|
||||||
|
|
||||||
|
// `new-session -A -s <name>` 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 TmuxWrapper = ({ panelId }: { panelId: string }) => (
|
||||||
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
|
<CommandTerminalWrapper
|
||||||
|
panelId={panelId}
|
||||||
|
command={`tmux new-session -A -s ${tmuxSessionName(panelId)}`}
|
||||||
|
statePrefix="tmux"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const NvimWrapper = ({ panelId }: { panelId: string }) => (
|
const NvimWrapper = ({ panelId }: { panelId: string }) => (
|
||||||
@@ -66,6 +78,12 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
|||||||
component: NvimWrapper,
|
component: NvimWrapper,
|
||||||
header: NvimHeader,
|
header: NvimHeader,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'officerdev/running-shells',
|
||||||
|
name: 'Running Shells',
|
||||||
|
icon: ListTree,
|
||||||
|
component: RunningShells,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'officerdev/claude-code',
|
key: 'officerdev/claude-code',
|
||||||
name: 'Claude Code',
|
name: 'Claude Code',
|
||||||
|
|||||||
Reference in New Issue
Block a user