Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
157 changes: 156 additions & 1 deletion src/commands/run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
rotateServerToken,
DEFAULT_LISTEN_OPTIONS,
InstanceManager,
listRemotes,
type RemoteServer,
type InstanceTab,
} from '../remote/index.js';
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1060,6 +1067,9 @@ Options:
--listen Enable remote listener (implies --headless)
--listen-port <n> 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
Expand All @@ -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
`);
}

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -1649,6 +1662,7 @@ function RunAppWrapper({
onConflictSkip,
parallelRefreshedTasks,
onRefreshTasks,
remoteOnly = false,
}: RunAppWrapperProps) {
const [showInterruptDialog, setShowInterruptDialog] = useState(false);
const [storedConfig, setStoredConfig] = useState<StoredConfig | undefined>(initialStoredConfig);
Expand All @@ -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<InstanceTab[]>([]);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [connectionToast, setConnectionToast] = useState<ConnectionToastMessage | null>(null);
Expand Down Expand Up @@ -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<void> {
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<void>((resolve) => {
resolveQuitPromise = resolve;
});

const renderer = await createCliRenderer({
exitOnCtrlC: false,
});

const root = createRoot(renderer);

const cleanup = async (): Promise<void> => {
interruptHandler.dispose();
renderer.destroy();
};

const gracefulShutdown = async (): Promise<void> => {
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(
<RunAppWrapper
interruptHandler={interruptHandler}
onQuit={gracefulShutdown}
onInterruptConfirmed={gracefulShutdown}
initialTasks={[]}
storedConfig={args.storedConfig}
cwd={args.cwd}
remoteOnly={true}
/>
);

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.
*
Expand Down Expand Up @@ -3141,6 +3251,51 @@ export async function executeRunCommand(args: string[]): Promise<void> {
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 <alias> <host>:<port> --token <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('');
Expand Down
43 changes: 38 additions & 5 deletions src/remote/instance-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -55,14 +63,27 @@ export class InstanceManager {
private remoteConfigs: Map<string, RemoteServerConfig> = new Map();
private toastHandler: ToastHandler | null = null;
private engineEventHandlers: Set<EngineEventHandler> = 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;
}
Comment thread
subsy marked this conversation as resolved.

/**
* 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<void> {
// Always start with the local tab
this.tabs = [createLocalTab()];
// Start with the local tab unless in remote-only mode
this.tabs = this.remoteOnly ? [] : [createLocalTab()];

Comment thread
subsy marked this conversation as resolved.
// Load remote configurations
const remotes = await listRemotes();
Expand Down Expand Up @@ -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<void> {
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') {
Expand All @@ -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<void> {
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<void> {
if (this.tabs.length === 0) return;
const prevIndex = (this.selectedIndex - 1 + this.tabs.length) % this.tabs.length;
await this.selectTab(prevIndex);
}
Expand Down
Loading
Loading