diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af18466e..8f1014f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,8 +85,8 @@ jobs: # Run tests in batches to avoid mock.module() conflicts between test files # Each batch outputs its own lcov file for later merging - echo "=== Running tests/ (excluding commands/info.test.ts and beads-rust-bv-tracker.test.ts) ===" | tee -a coverage-output.txt - mapfile -t TEST_FILES < <(find tests -type f -name '*.test.ts' ! -path 'tests/commands/info.test.ts' ! -path 'tests/plugins/beads-rust-bv-tracker.test.ts' | sort) + echo "=== Running tests/ (excluding commands/info.test.ts, commands/run-remote-only.test.ts and beads-rust-bv-tracker.test.ts) ===" | tee -a coverage-output.txt + mapfile -t TEST_FILES < <(find tests -type f -name '*.test.ts' ! -path 'tests/commands/info.test.ts' ! -path 'tests/commands/run-remote-only.test.ts' ! -path 'tests/plugins/beads-rust-bv-tracker.test.ts' | sort) bun test "${TEST_FILES[@]}" --coverage --coverage-reporter=text --coverage-reporter=lcov 2>&1 | tee -a coverage-output.txt cp coverage/lcov.info coverage-parts/tests.lcov @@ -102,6 +102,15 @@ jobs: bun test tests/commands/info.test.ts --coverage --coverage-reporter=text --coverage-reporter=lcov 2>&1 | tee -a coverage-output.txt cp coverage/lcov.info coverage-parts/tests-info.lcov + # Run tests/commands/run-remote-only.test.ts in isolation: it mocks + # @opentui/core, @opentui/react, ../../src/remote/index.js, and the + # interruption module. Other tests (notably tests/engine/execution-engine.test.ts) + # also mock.module() the agent/tracker registry, which pollutes our success-path + # tests by replacing getAgentRegistry with a stub missing registerBuiltin. + echo "=== Running tests/commands/run-remote-only.test.ts (isolated) ===" | tee -a coverage-output.txt + bun test tests/commands/run-remote-only.test.ts --coverage --coverage-reporter=text --coverage-reporter=lcov 2>&1 | tee -a coverage-output.txt + cp coverage/lcov.info coverage-parts/tests-run-remote-only.lcov + echo "=== Running doctor.test.ts ===" | tee -a coverage-output.txt bun test src/commands/doctor.test.ts --coverage --coverage-reporter=text --coverage-reporter=lcov 2>&1 | tee -a coverage-output.txt cp coverage/lcov.info coverage-parts/doctor.lcov @@ -232,7 +241,7 @@ jobs: uses: codecov/codecov-action@v4 with: # Upload all batch coverage files - Codecov will merge them correctly - files: ./coverage-parts/tests.lcov,./coverage-parts/tests-beads-rust-bv.lcov,./coverage-parts/tests-info.lcov,./coverage-parts/doctor.lcov,./coverage-parts/info.lcov,./coverage-parts/skills.lcov,./coverage-parts/run.lcov,./coverage-parts/config.lcov,./coverage-parts/engine.lcov,./coverage-parts/beads-bv.lcov,./coverage-parts/beads-rust.lcov,./coverage-parts/beads.lcov,./coverage-parts/plugins.lcov,./coverage-parts/jira.lcov,./coverage-parts/linear-body.lcov,./coverage-parts/linear-client.lcov,./coverage-parts/linear-index.lcov,./coverage-parts/session.lcov,./coverage-parts/sandbox.lcov,./coverage-parts/wizard.lcov,./coverage-parts/setup.lcov,./coverage-parts/skill-installer-spawn.lcov,./coverage-parts/migration-install.lcov,./coverage-parts/templates.lcov,./coverage-parts/tui.lcov,./coverage-parts/prd.lcov,./coverage-parts/chat.lcov,./coverage-parts/parallel.lcov + files: ./coverage-parts/tests.lcov,./coverage-parts/tests-beads-rust-bv.lcov,./coverage-parts/tests-info.lcov,./coverage-parts/tests-run-remote-only.lcov,./coverage-parts/doctor.lcov,./coverage-parts/info.lcov,./coverage-parts/skills.lcov,./coverage-parts/run.lcov,./coverage-parts/config.lcov,./coverage-parts/engine.lcov,./coverage-parts/beads-bv.lcov,./coverage-parts/beads-rust.lcov,./coverage-parts/beads.lcov,./coverage-parts/plugins.lcov,./coverage-parts/jira.lcov,./coverage-parts/linear-body.lcov,./coverage-parts/linear-client.lcov,./coverage-parts/linear-index.lcov,./coverage-parts/session.lcov,./coverage-parts/sandbox.lcov,./coverage-parts/wizard.lcov,./coverage-parts/setup.lcov,./coverage-parts/skill-installer-spawn.lcov,./coverage-parts/migration-install.lcov,./coverage-parts/templates.lcov,./coverage-parts/tui.lcov,./coverage-parts/prd.lcov,./coverage-parts/chat.lcov,./coverage-parts/parallel.lcov fail_ci_if_error: false verbose: true env: diff --git a/src/commands/run.tsx b/src/commands/run.tsx index ed92a1e7..a654b30d 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -77,6 +77,7 @@ import { rotateServerToken, DEFAULT_LISTEN_OPTIONS, InstanceManager, + listRemotes, type RemoteServer, type InstanceTab, } from '../remote/index.js'; @@ -747,6 +748,8 @@ interface ExtendedRuntimeOptions extends RuntimeOptions { targetBranch?: string; /** Filter tasks by index range (e.g., 1-5, 3-, -10) */ taskRange?: TaskRangeFilter; + /** Skip local engine; TUI acts as pure client to configured remotes */ + remoteOnly?: boolean; } /** @@ -935,6 +938,10 @@ export function parseRunArgs(args: string[]): ExtendedRuntimeOptions { options.rotateToken = true; break; + case '--remote-only': + options.remoteOnly = true; + break; + case '--theme': if (nextArg && !nextArg.startsWith('-')) { options.themePath = nextArg; @@ -1060,6 +1067,9 @@ Options: --listen Enable remote listener (implies --headless) --listen-port Port for remote listener (default: 7890) --rotate-token Rotate server token before starting listener + --remote-only Skip local engine — TUI acts as pure remote client. + Requires at least one remote configured in + ~/.config/ralph-tui/remotes.toml. Log Output Format (--no-tui mode): [timestamp] [level] [component] message @@ -1084,6 +1094,7 @@ Examples: ralph-tui run --resume # Resume previous session ralph-tui run --no-tui # Run headless for CI/scripts ralph-tui run --listen --prd ./prd.json # Run with remote listener enabled + ralph-tui run --remote-only # TUI-only client for configured remotes `); } @@ -1592,6 +1603,8 @@ interface RunAppWrapperProps { parallelRefreshedTasks?: TrackerTask[]; /** Callback to manually refresh tasks in parallel mode (for 'r' key) */ onRefreshTasks?: () => void; + /** When true, the InstanceManager skips the local tab (remote-only mode). */ + remoteOnly?: boolean; } /** @@ -1649,6 +1662,7 @@ function RunAppWrapper({ onConflictSkip, parallelRefreshedTasks, onRefreshTasks, + remoteOnly = false, }: RunAppWrapperProps) { const [showInterruptDialog, setShowInterruptDialog] = useState(false); const [storedConfig, setStoredConfig] = useState(initialStoredConfig); @@ -1659,7 +1673,7 @@ function RunAppWrapper({ const localGitInfo = useMemo(() => getGitInfo(cwd), [cwd]); // Remote instance management - const [instanceManager] = useState(() => new InstanceManager()); + const [instanceManager] = useState(() => new InstanceManager({ remoteOnly })); const [instanceTabs, setInstanceTabs] = useState([]); const [selectedTabIndex, setSelectedTabIndex] = useState(0); const [connectionToast, setConnectionToast] = useState(null); @@ -2199,6 +2213,102 @@ async function runWithTui( return currentState; } +/** + * Run the TUI as a pure remote client. + * + * Used in --remote-only mode. No local ExecutionEngine, no session persistence, + * no lock acquisition. The InstanceManager (constructed inside RunAppWrapper with + * remoteOnly: true) skips the local tab so the TUI only shows configured remotes. + * + * Keeps the same interrupt-handler / graceful-shutdown plumbing as runWithTui so + * Ctrl+C / q behave consistently across modes. + */ +async function runRemoteOnlyTui(args: { + cwd: string; + storedConfig?: StoredConfig; +}): Promise { + let showDialogCallback: (() => void) | null = null; + let hideDialogCallback: (() => void) | null = null; + let cancelledCallback: (() => void) | null = null; + + // Create the quit Promise up front and capture its resolver before installing + // any listeners. This avoids a race where SIGTERM (or any other shutdown path) + // fires before `await new Promise(...)` runs and the resolver is still unset. + let resolveQuitPromise: () => void = () => {}; + const quitPromise = new Promise((resolve) => { + resolveQuitPromise = resolve; + }); + + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + }); + + const root = createRoot(renderer); + + const cleanup = async (): Promise => { + interruptHandler.dispose(); + renderer.destroy(); + }; + + const gracefulShutdown = async (): Promise => { + try { + await cleanup(); + } catch { + // Ensure quit promise still resolves when cleanup fails. + } + resolveQuitPromise(); + }; + + const forceQuit = (): void => { + process.exit(1); + }; + + const interruptHandler = createInterruptHandler({ + doublePressWindowMs: 1000, + onConfirmed: gracefulShutdown, + onCancelled: () => { + cancelledCallback?.(); + }, + onShowDialog: () => { + showDialogCallback?.(); + }, + onHideDialog: () => { + hideDialogCallback?.(); + }, + onForceQuit: forceQuit, + }); + + process.on('SIGTERM', gracefulShutdown); + + root.render( + + ); + + const checkCallbacks = setInterval(() => { + const handler = interruptHandler as { + _showDialog?: () => void; + _hideDialog?: () => void; + _cancelled?: () => void; + }; + if (handler._showDialog) showDialogCallback = handler._showDialog; + if (handler._hideDialog) hideDialogCallback = handler._hideDialog; + if (handler._cancelled) cancelledCallback = handler._cancelled; + }, 10); + + await quitPromise; + + clearInterval(checkCallbacks); + process.removeListener('SIGTERM', gracefulShutdown); +} + /** * Run the parallel executor with TUI visualization. * @@ -3141,6 +3251,51 @@ export async function executeRunCommand(args: string[]): Promise { const options = parseRunArgs(args); const cwd = options.cwd ?? process.cwd(); + // --remote-only conflict guards: these combinations have no coherent meaning. + // Check --listen first because it implies --headless. + if (options.remoteOnly && options.listen) { + console.error('Error: --remote-only cannot be combined with --listen (listen mode requires a local engine to expose).'); + process.exit(1); + } + if (options.remoteOnly && options.headless) { + console.error('Error: --remote-only requires the TUI; cannot be combined with --headless / --no-tui.'); + process.exit(1); + } + + // --remote-only: skip all local engine setup and run the TUI as a pure client. + if (options.remoteOnly) { + const remotes = await listRemotes(); + if (remotes.length === 0) { + console.error(''); + console.error('Error: --remote-only requires at least one configured remote.'); + console.error(''); + console.error('No remotes found in ~/.config/ralph-tui/remotes.toml.'); + console.error(''); + console.error('Add a remote first:'); + console.error(' ralph-tui remote add : --token '); + console.error(''); + process.exit(1); + } + + if (options.themePath) { + try { + await initializeTheme(options.themePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nTheme loading failed: ${message}`); + process.exit(1); + } + } + + await initializePlugins(); + const storedConfig = await loadStoredConfig(cwd); + + console.log(`Initializing remote-only TUI with ${remotes.length} remote(s)...`); + + await runRemoteOnlyTui({ cwd, storedConfig }); + return; + } + // Detect markdown PRD files and show helpful guidance if (options.prdPath && /\.(?:md|markdown)$/i.test(options.prdPath)) { console.error(''); diff --git a/src/remote/instance-manager.ts b/src/remote/instance-manager.ts index c98df93d..3de904e2 100644 --- a/src/remote/instance-manager.ts +++ b/src/remote/instance-manager.ts @@ -42,6 +42,14 @@ export type InstanceStateChangeHandler = (tabs: InstanceTab[], selectedIndex: nu */ export type EngineEventHandler = (event: import('../engine/types.js').EngineEvent) => void; +/** + * Options for InstanceManager construction. + */ +export interface InstanceManagerOptions { + /** When true, skip the local tab and only show remote tabs */ + remoteOnly?: boolean; +} + /** * Manages local and remote ralph-tui instances. * Handles tab state, connection management, and instance selection. @@ -55,14 +63,27 @@ export class InstanceManager { private remoteConfigs: Map = new Map(); private toastHandler: ToastHandler | null = null; private engineEventHandlers: Set = new Set(); + private readonly remoteOnly: boolean; + + constructor(options: InstanceManagerOptions = {}) { + this.remoteOnly = options.remoteOnly ?? false; + } + + /** + * Returns true when the manager was constructed in remote-only mode + * (no local tab will be present). + */ + isRemoteOnly(): boolean { + return this.remoteOnly; + } /** * Initialize the instance manager. - * Loads remote configurations and sets up the local tab. + * Loads remote configurations and sets up the local tab (unless in remote-only mode). */ async initialize(): Promise { - // Always start with the local tab - this.tabs = [createLocalTab()]; + // Start with the local tab unless in remote-only mode + this.tabs = this.remoteOnly ? [] : [createLocalTab()]; // Load remote configurations const remotes = await listRemotes(); @@ -123,14 +144,22 @@ export class InstanceManager { /** * Select a tab by index. * If the tab is disconnected, initiates a reconnection. + * Defensively rejects non-integer indices (e.g., NaN from `% 0`) + * and out-of-range values, returning without side effects. */ async selectTab(index: number): Promise { + if (!Number.isInteger(index)) { + return; + } if (index < 0 || index >= this.tabs.length) { return; } this.selectedIndex = index; const tab = this.tabs[index]; + if (!tab) { + return; + } // Reconnect if disconnected (per acceptance criteria: no auto-reconnect, only on selection) if (!tab.isLocal && tab.status === 'disconnected') { @@ -141,17 +170,21 @@ export class InstanceManager { } /** - * Select the next tab (wraps around) + * Select the next tab (wraps around). + * No-op when there are no tabs (avoids `% 0 === NaN` reaching selectTab). */ async selectNextTab(): Promise { + if (this.tabs.length === 0) return; const nextIndex = (this.selectedIndex + 1) % this.tabs.length; await this.selectTab(nextIndex); } /** - * Select the previous tab (wraps around) + * Select the previous tab (wraps around). + * No-op when there are no tabs. */ async selectPreviousTab(): Promise { + if (this.tabs.length === 0) return; const prevIndex = (this.selectedIndex - 1 + this.tabs.length) % this.tabs.length; await this.selectTab(prevIndex); } diff --git a/src/tui/components/RunApp.tsx b/src/tui/components/RunApp.tsx index 46051253..5ce6d637 100644 --- a/src/tui/components/RunApp.tsx +++ b/src/tui/components/RunApp.tsx @@ -734,7 +734,8 @@ export function RunApp({ const [detectedModel, setDetectedModel] = useState(currentModel); // Remote viewing state - const isViewingRemote = selectedTabIndex > 0; + // Use tab.isLocal so remote-only mode (where index 0 is already a remote tab) works correctly. + const isViewingRemote = instanceTabs?.[selectedTabIndex]?.isLocal === false; const [remoteTasks, setRemoteTasks] = useState([]); const [remoteStatus, setRemoteStatus] = useState('ready'); const [remoteOutput, setRemoteOutput] = useState(''); @@ -2629,7 +2630,7 @@ export function RunApp({ // Remote management: 'e' to edit current remote (only when viewing a remote tab) case 'e': - if (isViewingRemote && instanceTabs && selectedTabIndex > 0) { + if (isViewingRemote && instanceTabs) { const tab = instanceTabs[selectedTabIndex]; if (tab?.alias) { // Load remote data for editing @@ -2662,7 +2663,7 @@ export function RunApp({ break; } // Remote management: delete current remote (only when viewing a remote tab) - if (isViewingRemote && instanceTabs && selectedTabIndex > 0) { + if (isViewingRemote && instanceTabs) { const tab = instanceTabs[selectedTabIndex]; if (tab?.alias) { // Load remote data for delete confirmation @@ -2692,9 +2693,13 @@ export function RunApp({ useKeyboard(handleKeyboard); - // Calculate layout - account for dashboard and tab bar height when visible + // Calculate layout - account for dashboard and tab bar height when visible. + // Show the TabBar whenever there's something useful to display: any remote tab, + // multiple tabs, or zero tabs in remote-only mode (so the empty-state hint shows). + // Hide it only when the single tab is the local tab (original behavior). + const shouldShowTabBar = !!instanceTabs && !(instanceTabs.length === 1 && instanceTabs[0]?.isLocal); const dashboardHeight = showDashboard ? layout.progressDashboard.height : 0; - const tabBarHeight = instanceTabs && instanceTabs.length > 1 ? layout.tabBar.height : 0; + const tabBarHeight = shouldShowTabBar ? layout.tabBar.height : 0; const contentHeight = Math.max( 1, height - layout.header.height - layout.footer.height - dashboardHeight - tabBarHeight @@ -3401,10 +3406,12 @@ export function RunApp({ backgroundColor: colors.bg.primary, }} > - {/* Tab Bar - instance navigation (local + remotes) */} - {instanceTabs && instanceTabs.length > 1 && ( + {/* Tab Bar - instance navigation (local + remotes). + Visible whenever there are remotes (or zero tabs in remote-only mode); + hidden only for the default "just the local tab" case. */} + {shouldShowTabBar && ( )} diff --git a/src/tui/components/TabBar.tsx b/src/tui/components/TabBar.tsx index 33b9aa9b..155cbcfa 100644 --- a/src/tui/components/TabBar.tsx +++ b/src/tui/components/TabBar.tsx @@ -191,14 +191,22 @@ export function TabBar({ flexGrow: 1, }} > - {tabs.map((tab, index) => ( - - ))} + {tabs.length === 0 ? ( + + + No remotes configured. Press A to add a remote. + + + ) : ( + tabs.map((tab, index) => ( + + )) + )} {/* Add remote button */} diff --git a/tests/commands/run-remote-only.test.ts b/tests/commands/run-remote-only.test.ts new file mode 100644 index 00000000..602c05b9 --- /dev/null +++ b/tests/commands/run-remote-only.test.ts @@ -0,0 +1,220 @@ +/** + * ABOUTME: Integration tests for `ralph-tui run --remote-only` paths that + * require mocking the remotes config (listRemotes) and the TUI renderer. + * Run in isolation in CI because Bun's mock.module() is process-wide and + * pollutes other tests (and is polluted by other tests that mock the agent + * registry — see tests/engine/execution-engine.test.ts). + */ + +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; + +import * as realRemoteIndex from '../../src/remote/index.js'; +import * as realOpentuiCore from '@opentui/core'; +import * as realOpentuiReact from '@opentui/react'; +import * as realInterruption from '../../src/interruption/index.js'; +import type { RemoteServerConfig } from '../../src/remote/index.js'; + +let mockedRemotes: Array<[string, RemoteServerConfig]> = []; +let mockedRendererBehavior: 'throw' | 'normal' = 'normal'; +let createCliRendererCallCount = 0; + +mock.module('../../src/remote/index.js', () => ({ + ...realRemoteIndex, + listRemotes: () => Promise.resolve(mockedRemotes), +})); + +mock.module('@opentui/core', () => ({ + ...realOpentuiCore, + createCliRenderer: () => { + createCliRendererCallCount++; + if (mockedRendererBehavior === 'throw') { + throw new Error('test-mock: createCliRenderer disabled'); + } + return { destroy: () => {} }; + }, +})); + +mock.module('@opentui/react', () => ({ + ...realOpentuiReact, + createRoot: () => ({ render: () => {} }), +})); + +mock.module('../../src/interruption/index.js', () => ({ + ...realInterruption, + createInterruptHandler: () => ({ + handleSigint: () => {}, + handleResponse: async () => {}, + getState: () => 'idle' as const, + reset: () => {}, + dispose: () => {}, + }), +})); + +describe('executeRunCommand --remote-only with no remotes', () => { + let consoleErrorOutput: string[]; + let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + mockedRemotes = []; + mockedRendererBehavior = 'normal'; + createCliRendererCallCount = 0; + consoleErrorOutput = []; + consoleErrorSpy = spyOn(console, 'error').mockImplementation((...args) => { + consoleErrorOutput.push(args.join(' ')); + }); + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + processExitSpy = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + test('exits with a clear error when no remotes configured', async () => { + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only']) + ); + } catch { + // Expected: process.exit throws + } + + const output = consoleErrorOutput.join('\n'); + expect(output).toContain('--remote-only requires at least one configured remote'); + expect(output).toContain('remotes.toml'); + expect(output).toContain('ralph-tui remote add'); + expect(processExitSpy).toHaveBeenCalledWith(1); + // Never reached the renderer because we exited at the empty-remotes check. + expect(createCliRendererCallCount).toBe(0); + }); + + test('error guidance points at the correct config path', async () => { + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only']) + ); + } catch { + // Expected: process.exit throws + } + + const output = consoleErrorOutput.join('\n'); + expect(output).toContain('~/.config/ralph-tui/remotes.toml'); + }); +}); + +describe('executeRunCommand --remote-only with configured remotes', () => { + let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + mockedRemotes = [ + ['testrem', { host: 'localhost', port: 7890, token: 'tk', addedAt: new Date().toISOString() }], + ]; + mockedRendererBehavior = 'throw'; + createCliRendererCallCount = 0; + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + processExitSpy = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + test('proceeds past empty-remotes check and reaches runRemoteOnlyTui', async () => { + // With a remote configured and the renderer mocked to throw, executeRunCommand + // should run through the remote-only setup and reject inside runRemoteOnlyTui + // when createCliRenderer throws. + let caught: Error | null = null; + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only']) + ); + } catch (err) { + caught = err as Error; + } + + // Assert via the renderer-mock counter + the propagated error rather than + // console output (brittle under shared-process test runs). + expect(createCliRendererCallCount).toBe(1); + expect(caught?.message ?? '').toContain('test-mock: createCliRenderer disabled'); + // The empty-remotes guard exits with 1; reaching this path means no exit fired. + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + test('reaches the renderer regardless of remote count', async () => { + mockedRemotes = [ + ['rem1', { host: 'h1', port: 7890, token: 'tk1', addedAt: 'x' }], + ['rem2', { host: 'h2', port: 7891, token: 'tk2', addedAt: 'x' }], + ['rem3', { host: 'h3', port: 7892, token: 'tk3', addedAt: 'x' }], + ]; + + let caught: Error | null = null; + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only']) + ); + } catch (err) { + caught = err as Error; + } + + expect(mockedRemotes.length).toBe(3); + expect(createCliRendererCallCount).toBe(1); + expect(caught?.message ?? '').toContain('test-mock: createCliRenderer disabled'); + }); +}); + +describe('runRemoteOnlyTui end-to-end with mocked renderer', () => { + let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + mockedRemotes = [ + ['testrem', { host: 'localhost', port: 7890, token: 'tk', addedAt: new Date().toISOString() }], + ]; + mockedRendererBehavior = 'normal'; + createCliRendererCallCount = 0; + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + processExitSpy = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + test('runs through runRemoteOnlyTui and resolves on SIGTERM', async () => { + const runPromise = import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only']) + ); + + // Give the TUI a tick to install the SIGTERM handler. + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Trigger graceful shutdown by emitting SIGTERM. + process.emit('SIGTERM', 'SIGTERM'); + + // The shutdown handler calls renderer.destroy() and resolves the quit promise. + await runPromise; + + // The renderer was constructed exactly once; clean shutdown means no exit fired. + expect(createCliRendererCallCount).toBe(1); + expect(processExitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/run.test.ts b/tests/commands/run.test.ts index 2add0ba5..bdb86df9 100644 --- a/tests/commands/run.test.ts +++ b/tests/commands/run.test.ts @@ -301,6 +301,18 @@ describe('run command', () => { }); }); + describe('remote-only flag', () => { + test('parses --remote-only flag', () => { + const result = parseRunArgs(['--remote-only']); + expect(result.remoteOnly).toBe(true); + }); + + test('remoteOnly is undefined when not specified', () => { + const result = parseRunArgs([]); + expect(result.remoteOnly).toBeUndefined(); + }); + }); + describe('combined options', () => { test('parses multiple options', () => { const result = parseRunArgs([ @@ -380,6 +392,7 @@ describe('run command', () => { expect(output).toContain('--headless'); expect(output).toContain('--no-tui'); expect(output).toContain('--no-setup'); + expect(output).toContain('--remote-only'); }); test('includes examples', () => { @@ -477,6 +490,86 @@ describe('run command', () => { }); }); + describe('--remote-only flag conflicts', () => { + let consoleErrorOutput: string[]; + let consoleErrorSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + consoleErrorOutput = []; + consoleErrorSpy = spyOn(console, 'error').mockImplementation((...args) => { + consoleErrorOutput.push(args.join(' ')); + }); + processExitSpy = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + test('rejects --remote-only combined with --listen', async () => { + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only', '--listen']) + ); + } catch { + // Expected: process.exit throws + } + + const output = consoleErrorOutput.join('\n'); + expect(output).toContain('--remote-only cannot be combined with --listen'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + test('rejects --remote-only combined with --headless', async () => { + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only', '--headless']) + ); + } catch { + // Expected: process.exit throws + } + + const output = consoleErrorOutput.join('\n'); + expect(output).toContain('--remote-only requires the TUI'); + expect(output).toContain('--headless'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + test('rejects --remote-only combined with --no-tui (headless alias)', async () => { + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only', '--no-tui']) + ); + } catch { + // Expected: process.exit throws + } + + const output = consoleErrorOutput.join('\n'); + expect(output).toContain('--remote-only requires the TUI'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + test('listen check fires before headless check when both implied', async () => { + // --listen sets options.headless = true internally. The listen-specific + // error message is more useful, so it must be checked first. + try { + await import('../../src/commands/run.jsx').then((m) => + m.executeRunCommand(['--remote-only', '--listen', '--headless']) + ); + } catch { + // Expected: process.exit throws + } + + const output = consoleErrorOutput.join('\n'); + expect(output).toContain('--listen'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + describe('shouldMarkCompletedLocally', () => { test('returns true when task completed with at least one commit', () => { expect(shouldMarkCompletedLocally(true, 1)).toBe(true); diff --git a/tests/remote/remote.test.ts b/tests/remote/remote.test.ts index a4943226..4f423f6e 100644 --- a/tests/remote/remote.test.ts +++ b/tests/remote/remote.test.ts @@ -646,6 +646,51 @@ describe('InstanceManager', () => { }); }); + describe('Remote-only mode', () => { + test('default constructor leaves remoteOnly false and adds local tab on init', async () => { + const { InstanceManager } = await import('../../src/remote/instance-manager.js'); + const manager = new InstanceManager(); + expect(manager.isRemoteOnly()).toBe(false); + + await manager.initialize(); + const tabs = manager.getTabs(); + expect(tabs.some((t) => t.isLocal)).toBe(true); + expect(tabs[0]?.isLocal).toBe(true); + }); + + test('remoteOnly: true skips the local tab', async () => { + const { InstanceManager } = await import('../../src/remote/instance-manager.js'); + const manager = new InstanceManager({ remoteOnly: true }); + expect(manager.isRemoteOnly()).toBe(true); + + await manager.initialize(); + const tabs = manager.getTabs(); + expect(tabs.some((t) => t.isLocal)).toBe(false); + }); + + test('navigation helpers are safe with zero tabs', async () => { + const { InstanceManager } = await import('../../src/remote/instance-manager.js'); + // Skip initialize() so we don't pull in real remotes from the dev/CI + // environment — the construction-time tab list is empty, which is what + // we want to validate (matches the remote-only + zero-remotes runtime case). + const manager = new InstanceManager({ remoteOnly: true }); + + expect(manager.getTabs()).toHaveLength(0); + const initialIndex = manager.getSelectedIndex(); + + // None of these should throw or move the selected index off the rails. + await expect(manager.selectNextTab()).resolves.toBeUndefined(); + await expect(manager.selectPreviousTab()).resolves.toBeUndefined(); + await expect(manager.selectTab(Number.NaN)).resolves.toBeUndefined(); + await expect(manager.selectTab(-1)).resolves.toBeUndefined(); + await expect(manager.selectTab(10)).resolves.toBeUndefined(); + await expect(manager.selectTab(1.5)).resolves.toBeUndefined(); + + expect(manager.getSelectedIndex()).toBe(initialIndex); + expect(manager.getSelectedTab()).toBeUndefined(); + }); + }); + describe('Remote Management Methods', () => { test('getTabIndexByAlias returns -1 for non-existent alias', async () => { const { InstanceManager } = await import('../../src/remote/instance-manager.js'); diff --git a/website/content/docs/cli/overview.mdx b/website/content/docs/cli/overview.mdx index 5db5a7b3..59d0706a 100644 --- a/website/content/docs/cli/overview.mdx +++ b/website/content/docs/cli/overview.mdx @@ -91,7 +91,8 @@ The commands are typically used in this order: ### Remote Control -- **[run --listen](/docs/cli/run#remote-listener)** - Enable WebSocket server for remote control +- **[run --listen](/docs/cli/run#remote-listener)** - Enable WebSocket server for remote control (server side) +- **[run --remote-only](/docs/cli/run#remote-only-mode)** - Launch the TUI as a pure remote client — no local engine, no local tab - **[remote](/docs/cli/remote)** - Manage connections to remote ralph-tui instances (add, remove, list, test, push-config) @@ -135,6 +136,9 @@ ralph-tui remote test prod # Launch TUI to see tabs for local + remote instances ralph-tui + +# Or launch the TUI as a pure client (no local engine, no local tab) +ralph-tui run --remote-only ``` ### Key Features diff --git a/website/content/docs/cli/remote.mdx b/website/content/docs/cli/remote.mdx index 584a0f79..737d873d 100644 --- a/website/content/docs/cli/remote.mdx +++ b/website/content/docs/cli/remote.mdx @@ -317,9 +317,16 @@ Tokens are stored in plain text in `remotes.toml`. Ensure appropriate file permi - Full tokens are only shown when generated on the server - All remote actions are logged to `~/.config/ralph-tui/audit.log` +## Pure-Client Mode + +To launch the TUI without spinning up a local engine — i.e., as a pure client over your configured remotes — use `ralph-tui run --remote-only`. The TUI will skip the local tab and connect only to the remotes listed in `remotes.toml`. This is the natural counterpart to the server-side `--listen` flag and is the recommended way to drive multiple remote instances from one workstation. + +See [`run --remote-only`](/docs/cli/run#remote-only-mode) for full details. + ## Related Commands -- [`run --listen`](/docs/cli/run) - Run with remote listener enabled +- [`run --listen`](/docs/cli/run#remote-listener) - Run with remote listener enabled (server side) +- [`run --remote-only`](/docs/cli/run#remote-only-mode) - Run the TUI as a pure remote client (no local engine) ## Troubleshooting diff --git a/website/content/docs/cli/run.mdx b/website/content/docs/cli/run.mdx index e28681bd..82d53598 100644 --- a/website/content/docs/cli/run.mdx +++ b/website/content/docs/cli/run.mdx @@ -56,6 +56,7 @@ Running `ralph-tui` without any command also starts the TUI interface, allowing |--------|-------------| | `--headless` | Run without TUI (alias: `--no-tui`) | | `--no-setup` | Skip interactive setup even if no config exists | +| `--remote-only` | Start the TUI as a pure remote client — no local engine, no local tab. See [Remote-Only Mode](#remote-only-mode). | ## Model Options @@ -260,6 +261,62 @@ All remote actions are logged to `~/.config/ralph-tui/audit.log`: {"timestamp":"2026-01-19T15:30:05.000Z","clientId":"abc12345@192.168.1.100","action":"pause","success":true} ``` +## Remote-Only Mode + +Pass `--remote-only` to start the TUI as a **pure remote client**: no local execution engine, no local session/lock, no local tab. The TUI loads your configured remotes and lets you switch between them. Useful when you want to drive multiple remote machines (e.g., several GPU boxes) from one TUI on a workstation that isn't itself running a Ralph engine. + +### Requirements + +- At least one remote configured in `~/.config/ralph-tui/remotes.toml` (add one with [`ralph-tui remote add`](/docs/cli/remote#add)). +- The TUI must be visible — `--remote-only` cannot be combined with `--headless` / `--no-tui`. + +### Behavior + +| Aspect | `--remote-only` | +|--------|------------------| +| Local engine | **Not started.** No local agent process, no session file, no lock acquired. | +| Local tab | **Hidden.** Only remote tabs appear in the TabBar. | +| Tab navigation | Number keys (1–9) and `[` / `]` switch between remote tabs as usual. | +| Empty state | At startup, `--remote-only` with zero configured remotes **fails fast** with a non-interactive error (exit 1) — the TUI never opens. See [Fail-fast](#fail-fast-when-no-remotes-are-configured) below. The in-TUI hint `No remotes configured. Press A to add a remote.` only appears if you remove the last remaining remote during an interactive `--remote-only` session; pressing `A` opens the add-remote overlay. | +| `--epic`, `--prd`, `--agent`, `--model`, `--tracker`, etc. | Silently ignored — each remote uses its own configuration. | + +### Examples + +```bash +# Connect to all configured remotes as a pure client +ralph-tui run --remote-only + +# Pair with a theme override (display options still apply) +ralph-tui run --remote-only --theme dracula +``` + +### Flag conflicts + +These combinations exit with a clear error: + +```bash +ralph-tui run --remote-only --headless # cannot run without TUI +ralph-tui run --remote-only --no-tui # same as --headless +ralph-tui run --remote-only --listen # --listen requires a local engine to expose +``` + +### Fail-fast when no remotes are configured + +If `~/.config/ralph-tui/remotes.toml` is empty (or missing), `--remote-only` exits with exit code 1 and prints: + +``` +Error: --remote-only requires at least one configured remote. + +No remotes found in ~/.config/ralph-tui/remotes.toml. + +Add a remote first: + ralph-tui remote add : --token +``` + + + Remote-only mode pairs naturally with `--listen` on the other side. Run `ralph-tui run --prd ./prd.json --listen` on each machine you want to control, then use `ralph-tui run --remote-only` on your workstation to drive them all from one TUI. + + ## Parallel Execution By default, ralph-tui runs sequentially. Parallel execution is opt-in via `--parallel` or configuration (`parallel.mode = "auto"` or `parallel.mode = "always"`). Each parallel worker runs in its own git worktree for full isolation.