diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx
index 81795d16d..65ff51050 100644
--- a/src/cli/tui/App.tsx
+++ b/src/cli/tui/App.tsx
@@ -10,6 +10,7 @@ import { ConfigBundleFlow } from './screens/config-bundle-hub';
import { CreateScreen } from './screens/create';
import { DatasetFlow } from './screens/dataset-hub';
import { DeployScreen } from './screens/deploy/DeployScreen';
+import { DevScreen } from './screens/dev/DevScreen';
import { EvalHubScreen, EvalScreen } from './screens/eval';
import { ExportHarnessFlow } from './screens/export';
import { FetchAccessScreen } from './screens/fetch-access';
@@ -52,6 +53,7 @@ type Route =
autoSession?: boolean;
}
| { name: 'logs' }
+ | { name: 'dev' }
| { name: 'create' }
| { name: 'add' }
| { name: 'status' }
@@ -142,9 +144,7 @@ function AppContent({
}
if (id === 'dev') {
- setExitAction({ type: 'dev' });
- exit();
- return;
+ setRoute({ name: 'dev' });
} else if (id === 'exec') {
setExitAction({ type: 'exec' });
exit();
@@ -271,6 +271,21 @@ function AppContent({
return ;
}
+ if (route.name === 'dev') {
+ // Render the dev picker in-TUI so agent-less projects get a guided error
+ // screen (Esc to go back) instead of crashing out of the TUI. When agents
+ // exist, the picker hands off to browser dev mode via the exit action.
+ return (
+ {
+ setExitAction({ type: 'dev' });
+ exit();
+ }}
+ />
+ );
+ }
+
if (route.name === 'status') {
return ;
}
diff --git a/src/cli/tui/screens/dev/__tests__/DevScreen.test.tsx b/src/cli/tui/screens/dev/__tests__/DevScreen.test.tsx
new file mode 100644
index 000000000..6e3adf790
--- /dev/null
+++ b/src/cli/tui/screens/dev/__tests__/DevScreen.test.tsx
@@ -0,0 +1,70 @@
+import { DevScreen } from '../DevScreen.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const { mockLoadProjectConfig, mockGetDevSupportedAgents } = vi.hoisted(() => ({
+ mockLoadProjectConfig: vi.fn(),
+ mockGetDevSupportedAgents: vi.fn(),
+}));
+
+vi.mock('../../../../operations/dev', () => ({
+ loadProjectConfig: mockLoadProjectConfig,
+ getDevSupportedAgents: mockGetDevSupportedAgents,
+ getEndpointUrl: vi.fn(),
+}));
+
+// The dev hooks would otherwise try to spin up a real dev server / deploy.
+vi.mock('../../../hooks/useDevServer', () => ({
+ useDevServer: () => ({
+ logs: [],
+ status: 'idle',
+ isStreaming: false,
+ conversation: [],
+ streamingResponse: null,
+ config: null,
+ configLoaded: true,
+ actualPort: 8080,
+ invoke: vi.fn(),
+ execCommand: vi.fn(),
+ execInContainer: vi.fn(),
+ isContainer: false,
+ clearConversation: vi.fn(),
+ restart: vi.fn(),
+ stop: vi.fn(),
+ logFilePath: undefined,
+ hasUndeployedMemory: false,
+ hasVpc: false,
+ protocol: undefined,
+ mcpTools: [],
+ fetchMcpTools: vi.fn(),
+ showMcpHint: false,
+ a2aAgentCard: undefined,
+ a2aStatus: undefined,
+ fetchAgentCard: vi.fn(),
+ }),
+}));
+
+vi.mock('../../../hooks/useDevDeploy', () => ({
+ useDevDeploy: () => ({ steps: [], deployMessages: [], isComplete: false, error: undefined }),
+}));
+
+const noop = () => undefined;
+
+describe('DevScreen', () => {
+ afterEach(() => vi.clearAllMocks());
+
+ // Regression: selecting "dev" in a project with no agents/harnesses must render
+ // the in-TUI error screen (not crash the TUI out to the shell). See issue #1588.
+ it('renders the in-TUI error when no agents or harnesses are defined', async () => {
+ mockLoadProjectConfig.mockResolvedValue({ runtimes: [], harnesses: [] });
+ mockGetDevSupportedAgents.mockReturnValue([]);
+
+ const onLaunchBrowser = vi.fn();
+ const { lastFrame } = render();
+ await new Promise(r => setTimeout(r, 50));
+
+ expect(lastFrame()).toContain('No agents or harnesses defined in project.');
+ expect(onLaunchBrowser).not.toHaveBeenCalled();
+ });
+});