diff --git a/src/workflow/__tests__/selectors.test.ts b/src/workflow/__tests__/selectors.test.ts
index 72390032d..c67cc5d55 100644
--- a/src/workflow/__tests__/selectors.test.ts
+++ b/src/workflow/__tests__/selectors.test.ts
@@ -2,6 +2,8 @@ import { expect, test } from 'bun:test'
import type { AgentProgress, RunProgress } from '../progress/store.js'
import {
ALL_PHASE,
+ capTabsForDisplay,
+ filterActiveRuns,
mergePhases,
filterAgentsByPhase,
tabLabel,
@@ -80,3 +82,76 @@ test('filterAgentsByPhase: All / undefined → all; specified → only that phas
test('tabLabel: workflow name + last 4 chars short code of runId', () => {
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
})
+
+// filterActiveRuns: only running runs reach the panel's tab row. Done/killed/completed are hidden
+// so opening /workflows no longer floods the tab row with months of historical runs (caused
+// tab overflow → garbled render when total width exceeded the terminal).
+test('filterActiveRuns: only status === "running" survives; completed/failed/killed dropped', () => {
+ const r1 = run({ runId: 'r1', status: 'running' })
+ const r2 = run({ runId: 'r2', status: 'running' })
+ const r3 = run({ runId: 'r3', status: 'completed' })
+ const r4 = run({ runId: 'r4', status: 'failed' })
+ const r5 = run({ runId: 'r5', status: 'killed' })
+ expect(filterActiveRuns([r1, r2, r3, r4, r5])).toEqual([r1, r2])
+})
+
+test('filterActiveRuns: empty input -> empty output', () => {
+ expect(filterActiveRuns([])).toEqual([])
+})
+
+test('filterActiveRuns: all terminal -> empty (panel falls back to "(no active runs)")', () => {
+ expect(
+ filterActiveRuns([run({ status: 'completed' }), run({ status: 'killed' })]),
+ ).toEqual([])
+})
+
+test('filterActiveRuns: preserves input order (no re-sort)', () => {
+ const a = run({ runId: 'a', status: 'running', startedAt: 5 })
+ const b = run({ runId: 'b', status: 'running', startedAt: 1 })
+ expect(filterActiveRuns([a, b]).map(r => r.runId)).toEqual(['a', 'b'])
+})
+
+// capTabsForDisplay: even if active runs somehow accumulate (long-lived sessions, runaway launcher),
+// the tab row must never overflow the terminal — cap at maxTabs, fold the remainder into a +N marker.
+test('capTabsForDisplay: under cap -> as-is', () => {
+ const runs = [
+ run({ runId: 'r1', status: 'running' }),
+ run({ runId: 'r2', status: 'running' }),
+ ]
+ expect(capTabsForDisplay(runs, 8)).toEqual({ runs, overflow: 0 })
+})
+
+test('capTabsForDisplay: over cap -> first maxTabs runs + overflow count', () => {
+ const runs = Array.from({ length: 10 }, (_, i) =>
+ run({ runId: `r${i}`, status: 'running' }),
+ )
+ const capped = capTabsForDisplay(runs, 8)
+ expect(capped.runs).toHaveLength(8)
+ expect(capped.runs.map(r => r.runId)).toEqual([
+ 'r0',
+ 'r1',
+ 'r2',
+ 'r3',
+ 'r4',
+ 'r5',
+ 'r6',
+ 'r7',
+ ])
+ expect(capped.overflow).toBe(2)
+})
+
+test('capTabsForDisplay: exactly at cap -> no overflow', () => {
+ const runs = Array.from({ length: 8 }, (_, i) =>
+ run({ runId: `r${i}`, status: 'running' }),
+ )
+ const capped = capTabsForDisplay(runs, 8)
+ expect(capped.runs).toHaveLength(8)
+ expect(capped.overflow).toBe(0)
+})
+
+test('capTabsForDisplay: maxTabs=0 -> all folded into overflow (degenerate but defined)', () => {
+ const runs = [run({ runId: 'r1', status: 'running' })]
+ const capped = capTabsForDisplay(runs, 0)
+ expect(capped.runs).toEqual([])
+ expect(capped.overflow).toBe(1)
+})
diff --git a/src/workflow/panel/TabsBar.tsx b/src/workflow/panel/TabsBar.tsx
index 7f570b26d..af6064f81 100644
--- a/src/workflow/panel/TabsBar.tsx
+++ b/src/workflow/panel/TabsBar.tsx
@@ -3,21 +3,40 @@ import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { RunProgress } from '../progress/store.js';
import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js';
-import { tabLabel } from './selectors.js';
+import { capTabsForDisplay, tabLabel } from './selectors.js';
+import { truncateLabel } from './AgentList.js';
+
+/**
+ * Per-tab name width budget. Long workflow names truncate (keeping the `#xxxx` short-code suffix so
+ * same-name runs stay distinguishable). Sized for a ~120-col terminal: ~6 tabs fit per row.
+ */
+const TAB_LABEL_MAX = 18;
+
+/**
+ * Hard ceiling on simultaneously rendered tabs. Defensive fallback: even if active runs accumulate
+ * (long-lived session, runaway launcher), the row must never overflow the terminal width and
+ * re-introduce the garbled overlapping render seen previously. Surplus runs are folded into `+N`.
+ */
+const MAX_TABS = 6;
/**
* Top run tab row: one tab per run (status dot + name + #short code).
* The current tab is highlighted with an orange ═ underline.
+ *
+ * Defenses against overflow:
+ * - Per-tab name truncated via truncateLabel (keeps `#xxxx` suffix for disambiguation).
+ * - Row capped at MAX_TABS; remainder rendered as a `+N` marker so total width is bounded.
*/
export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunId: string | null }): React.ReactNode {
if (runs.length === 0) {
return (no runs);
}
+ const { runs: visible, overflow } = capTabsForDisplay(runs, MAX_TABS);
return (
- {runs.map(r => {
+ {visible.map(r => {
const active = r.runId === activeRunId;
- const label = tabLabel(r.workflowName, r.runId);
+ const label = truncateLabel(tabLabel(r.workflowName, r.runId), TAB_LABEL_MAX);
const underline = '═'.repeat(label.length + 2);
return (
@@ -32,6 +51,12 @@ export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunI
);
})}
+ {overflow > 0 ? (
+
+ +{overflow}
+
+
+ ) : null}
);
}
diff --git a/src/workflow/panel/WorkflowsPanel.tsx b/src/workflow/panel/WorkflowsPanel.tsx
index 87a8df03f..0e435eb84 100644
--- a/src/workflow/panel/WorkflowsPanel.tsx
+++ b/src/workflow/panel/WorkflowsPanel.tsx
@@ -9,7 +9,7 @@ import { PhaseSidebar } from './PhaseSidebar.js';
import { TabsBar } from './TabsBar.js';
import { RUN_STATUS_COLOR, RUN_STATUS_TEXT } from './status.js';
import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard } from './useWorkflowKeyboard.js';
-import { ALL_PHASE, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js';
+import { ALL_PHASE, filterActiveRuns, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js';
/**
* Clamp the selected index to a valid range (empty list -> 0; out of range -> last position; negative/NaN -> 0).
@@ -61,6 +61,10 @@ export function WorkflowsPanel({
() => svc.listRuns(),
() => [],
);
+ // Only in-flight runs reach the tab row. Terminal (completed/failed/killed) runs are hidden so opening
+ // the panel no longer floods the row with persisted history (which overflowed the terminal and rendered
+ // garbled overlapping text). They stay on disk and remain resumable via getRunAsync.
+ const activeRuns = filterActiveRuns(runs);
const [activeRunId, setActiveRunId] = useState(null);
const [focusColumn, setFocusColumn] = useState('phases');
@@ -76,18 +80,19 @@ export function WorkflowsPanel({
void svc.loadPersistedRuns();
}, [svc]);
- // On runs change: activeRunId invalidated (killed / first time) -> clamp to the first one
+ // On activeRuns change: activeRunId invalidated (killed / first time) -> clamp to the first one.
+ // Tracks activeRuns (not raw runs) so focus never lands on a hidden terminal run.
useEffect(() => {
- if (runs.length === 0) {
+ if (activeRuns.length === 0) {
if (activeRunId !== null) setActiveRunId(null);
return;
}
- if (!runs.some(r => r.runId === activeRunId)) {
- setActiveRunId(runs[0]!.runId);
+ if (!activeRuns.some(r => r.runId === activeRunId)) {
+ setActiveRunId(activeRuns[0]!.runId);
}
- }, [runs, activeRunId]);
+ }, [activeRuns, activeRunId]);
- const focused: RunProgress | undefined = runs.find(r => r.runId === activeRunId);
+ const focused: RunProgress | undefined = activeRuns.find(r => r.runId === activeRunId);
const phases = focused ? mergePhases(focused) : [];
// The sidebar includes the All row: prepend one item to the phases array -> total rows = phases.length + 1
const phaseRowCount = phases.length + 1;
@@ -122,15 +127,15 @@ export function WorkflowsPanel({
};
const nextTab = (): void => {
- if (runs.length === 0) return;
- const idx = runs.findIndex(r => r.runId === activeRunId);
- const next = runs[(idx + 1) % runs.length]!;
+ if (activeRuns.length === 0) return;
+ const idx = activeRuns.findIndex(r => r.runId === activeRunId);
+ const next = activeRuns[(idx + 1) % activeRuns.length]!;
switchTab(next.runId);
};
const prevTab = (): void => {
- if (runs.length === 0) return;
- const idx = runs.findIndex(r => r.runId === activeRunId);
- const next = runs[(idx - 1 + runs.length) % runs.length]!;
+ if (activeRuns.length === 0) return;
+ const idx = activeRuns.findIndex(r => r.runId === activeRunId);
+ const next = activeRuns[(idx - 1 + activeRuns.length) % activeRuns.length]!;
switchTab(next.runId);
};
@@ -225,9 +230,9 @@ export function WorkflowsPanel({
{focused?.description ? {focused.description} : null}
- {runs.length > 1 ? (
+ {activeRuns.length > 1 ? (
-
+
) : null}
diff --git a/src/workflow/panel/selectors.ts b/src/workflow/panel/selectors.ts
index 606dfde81..0d18eddf5 100644
--- a/src/workflow/panel/selectors.ts
+++ b/src/workflow/panel/selectors.ts
@@ -54,6 +54,37 @@ export function filterAgentsByPhase(
return agents.filter(a => a.phase === selectedPhase)
}
+/**
+ * Keep only runs still in flight. The /workflows panel defaults to this view: opening the panel
+ * no longer floods the tab row with months of persisted historical runs (which overflowed the
+ * terminal width and produced garbled overlapping text). Terminal runs (completed/failed/killed)
+ * stay on disk and remain resumable via getRunAsync; only the tab row filters them out.
+ *
+ * Pure + order-preserving: callers rely on the same relative order as the input (store.list()
+ * already returns newest-first by updatedAt).
+ */
+export function filterActiveRuns(runs: RunProgress[]): RunProgress[] {
+ return runs.filter(r => r.status === 'running')
+}
+
+/**
+ * Cap how many runs reach the tab row. Defensive fallback: even if active runs accumulate
+ * (long-lived session, runaway launcher), the row must never overflow the terminal width and
+ * re-introduce the garbled render. Anything past maxTabs is folded into an `overflow` count
+ * that the panel renders as `+N`.
+ *
+ * `runs` is sliced as-is (no re-sort); the caller is expected to have already applied
+ * filterActiveRuns and any ordering upstream.
+ */
+export function capTabsForDisplay(
+ runs: RunProgress[],
+ maxTabs: number,
+): { runs: RunProgress[]; overflow: number } {
+ const cap = Math.max(0, Math.trunc(maxTabs))
+ const visible = runs.slice(0, cap)
+ return { runs: visible, overflow: Math.max(0, runs.length - visible.length) }
+}
+
/** tab label: workflow name + `#` + last 4 chars of runId (disambiguates same-name runs). */
export function tabLabel(workflowName: string, runId: string): string {
return `${workflowName}#${runId.slice(-4)}`