refactor: remove terminal agent concept entirely

Remove the "agent can be a terminal" branching from the entire codebase.
An agent is now always a session-backed chat agent. Standalone terminal
infrastructure (terminal component, ANSI handling, terminal-stream-protocol)
is preserved.

Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails,
launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited,
supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from
all providers, terminal agent persistence/projections.

App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts,
terminal/terminalExit fields on agent state, "Terminal Agents" launcher
section, terminal-agent workspace setup flow, terminal badge in agent list.

CLI: remove terminal column from ls, terminal-agent error from send.

60 files changed, -3592 lines
This commit is contained in:
Mohamed Boudra
2026-04-02 10:55:54 +07:00
parent 59cb4d7499
commit 060f3d457c
60 changed files with 60 additions and 3592 deletions
-28
View File
@@ -26,7 +26,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { TerminalAgentPanel } from "@/panels/terminal-agent-panel";
import {
useHostRuntimeClient,
useHostRuntimeConnectionStatus,
@@ -250,11 +249,9 @@ function AgentPanelBody({
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
terminal: agent?.terminal ?? false,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
lastError: agent?.lastError ?? null,
terminalExit: agent?.terminalExit ?? null,
archivedAt: agent?.archivedAt ?? null,
};
}),
@@ -378,7 +375,6 @@ function AgentPanelBody({
status: agentState.status,
cwd: agentState.cwd,
lastError: agentState.lastError ?? null,
terminalExit: agentState.terminalExit ?? null,
projectPlacement,
}
: null;
@@ -395,27 +391,6 @@ function AgentPanelBody({
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
if (agentState.terminal) {
return (
<View style={styles.root}>
<TerminalAgentPanel
serverId={serverId}
client={client}
agent={agent}
isPaneFocused={isPaneFocused}
/>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
);
}
return (
<ChatAgentContent
serverId={serverId}
@@ -480,11 +455,9 @@ function ChatAgentContent({
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
terminal: agent?.terminal ?? false,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
lastError: agent?.lastError ?? null,
terminalExit: agent?.terminalExit ?? null,
archivedAt: agent?.archivedAt ?? null,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
@@ -716,7 +689,6 @@ function ChatAgentContent({
status: agentState.status,
cwd: agentState.cwd,
lastError: agentState.lastError ?? null,
terminalExit: agentState.terminalExit ?? null,
projectPlacement,
}
: null;
+3 -238
View File
@@ -1,30 +1,15 @@
import { useCallback, useMemo, useState, type ComponentType } from "react";
import { useCallback, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Bot, ChevronDown, Plus, SquarePen, SquareTerminal } from "lucide-react-native";
import { Plus, SquarePen, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { getProviderIcon } from "@/components/provider-icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { generateDraftId } from "@/stores/draft-keys";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { toErrorMessage } from "@/utils/error-messages";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
const MAX_VISIBLE_PROVIDER_TILES = 4;
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
function useLauncherPanelDescriptor() {
return {
@@ -45,70 +30,10 @@ function LauncherPanel() {
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const { providers, recordUsage } = useProviderRecency();
const setAgents = useSessionStore((state) => state.setAgents);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
invariant(target.kind === "launcher", "LauncherPanel requires launcher target");
const visibleProviders = useMemo(
() => providers.slice(0, MAX_VISIBLE_PROVIDER_TILES),
[providers],
);
const overflowProviders = useMemo(
() => providers.slice(MAX_VISIBLE_PROVIDER_TILES),
[providers],
);
const launchTerminalAgent = useCallback(
async (providerId: AgentProvider) => {
if (!client || !isConnected || !workspaceDirectory) {
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
return;
}
if (!workspaceAuthority.ok) {
setErrorMessage(workspaceAuthority.message);
return;
}
const persistedWorkspaceId = requireWorkspaceRecordId(workspaceAuthority.authority.workspaceId);
setPendingAction(providerId);
setErrorMessage(null);
try {
const agent = await client.createAgent({
provider: providerId,
cwd: workspaceDirectory,
workspaceId: persistedWorkspaceId,
terminal: true,
});
recordUsage(providerId);
// Retarget first so the launcher converts in place before session reconciliation
// can materialize the new agent as a separate tab.
retargetCurrentTab({ kind: "agent", agentId: agent.id });
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
} catch (error) {
setErrorMessage(toErrorMessage(error));
} finally {
setPendingAction((current) => (current === providerId ? null : current));
}
},
[
client,
isConnected,
recordUsage,
retargetCurrentTab,
serverId,
setAgents,
workspaceAuthority,
workspaceDirectory,
],
);
const openDraftTab = useCallback(() => {
setErrorMessage(null);
setPendingAction("draft");
@@ -187,34 +112,6 @@ function LauncherPanel() {
}}
/>
</View>
<Text style={styles.sectionLabel}>Terminal Agents</Text>
<View style={styles.providerGrid}>
{visibleProviders.map((provider) => (
<ProviderTile
key={provider.id}
provider={provider}
disabled={actionsDisabled}
pending={pendingAction === provider.id}
onPress={() => {
void launchTerminalAgent(provider.id);
}}
/>
))}
{overflowProviders.length > 0 ? (
<ViewAllProvidersTile
providers={overflowProviders}
disabled={actionsDisabled}
pendingProviderId={pendingAction}
onSelectProvider={(providerId) => {
void launchTerminalAgent(providerId);
}}
/>
) : null}
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</ScrollView>
@@ -272,91 +169,6 @@ function LauncherTile({
);
}
function ProviderTile({
provider,
disabled,
pending,
onPress,
}: {
provider: { id: string; label: string; description: string };
disabled: boolean;
pending: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
const Icon = getProviderIcon(provider.id);
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerTile,
(hovered || pressed) && !disabled ? styles.tileInteractive : null,
disabled ? styles.tileDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
{pending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Icon size={16} color={theme.colors.foreground} />
)}
</View>
<Text style={styles.providerLabel}>{provider.label}</Text>
</Pressable>
);
}
function ViewAllProvidersTile({
providers,
disabled,
pendingProviderId,
onSelectProvider,
}: {
providers: Array<{ id: string; label: string; description: string }>;
disabled: boolean;
pendingProviderId: string | null;
onSelectProvider: (providerId: AgentProvider) => void;
}) {
const { theme } = useUnistyles();
return (
<DropdownMenu>
<DropdownMenuTrigger disabled={disabled} style={styles.providerTile}>
{({ open }) => (
<>
<View style={styles.providerIconWrap}>
<Bot size={16} color={theme.colors.foreground} />
</View>
<Text style={styles.providerLabel}>More</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
{open ? <View style={styles.dropdownOutline} /> : null}
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260}>
{providers.map((provider) => {
const Icon = getProviderIcon(provider.id);
return (
<DropdownMenuItem
key={provider.id}
description={provider.description}
onSelect={() => onSelectProvider(provider.id as AgentProvider)}
leading={<Icon size={16} color={theme.colors.foregroundMuted} />}
status={pendingProviderId === provider.id ? "pending" : "idle"}
pendingLabel={`Launching ${provider.label}...`}
>
{provider.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const launcherPanelRegistration: PanelRegistration<"launcher"> = {
kind: "launcher",
component: LauncherPanel,
@@ -431,53 +243,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
sectionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
providerGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerTile: {
position: "relative",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
providerIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
dropdownOutline: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.accent,
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.destructive,
@@ -1,316 +0,0 @@
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@server/client/daemon-client";
import { TerminalPane } from "@/components/terminal-pane";
import { Fonts } from "@/constants/theme";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { usePaneContext } from "@/panels/pane-context";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import {
buildTerminalAgentReopenKey,
useTerminalAgentReopenStore,
} from "@/stores/terminal-agent-reopen-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
type TerminalAgentPanelProps = {
serverId: string;
client: DaemonClient;
agent: AgentScreenAgent;
isPaneFocused: boolean;
};
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function getTerminalExitTitle(agent: AgentScreenAgent): string {
const exitCode = agent.terminalExit?.exitCode;
const signal = agent.terminalExit?.signal;
if (
agent.status === "error" ||
(exitCode != null && exitCode !== 0) ||
signal != null
) {
return "Terminal session failed";
}
return "Terminal session ended";
}
function getTerminalExitMessage(agent: AgentScreenAgent): string {
const summary = agent.terminalExit?.message?.trim();
if (summary) {
return summary;
}
const lastError = agent.lastError?.trim();
if (lastError) {
return lastError;
}
return "Reopen the agent from the sessions list to start it again.";
}
function isCleanTerminalExit(agent: AgentScreenAgent): boolean {
return agent.status === "closed" && agent.terminalExit?.exitCode === 0 && agent.terminalExit.signal == null;
}
export function TerminalAgentPanel({
serverId,
client,
agent,
isPaneFocused,
}: TerminalAgentPanelProps) {
const isScreenFocused = useIsFocused();
const { theme } = useUnistyles();
const { tabId, workspaceId } = usePaneContext();
const { archiveAgent } = useArchiveAgent();
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const [terminalId, setTerminalId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [didExitInPanel, setDidExitInPanel] = useState(false);
const reopenKey = buildTerminalAgentReopenKey({ serverId, agentId: agent.id });
const reopenIntentVersion = useTerminalAgentReopenStore((state) =>
reopenKey ? (state.reopenIntentVersionByAgentKey[reopenKey] ?? 0) : 0,
);
// Refs for effect guards — these values gate whether the creation effect
// should run, but changes to them should NOT re-trigger the effect.
const isCreatingRef = useRef(false);
const didExitRef = useRef(false);
const lastHandledReopenIntentRef = useRef(reopenIntentVersion);
const isAutoClosingRef = useRef(false);
useEffect(() => {
setTerminalId(null);
setIsCreating(false);
setCreateError(null);
setDidExitInPanel(false);
isCreatingRef.current = false;
didExitRef.current = false;
}, [agent.id, serverId]);
useEffect(() => {
if (reopenIntentVersion <= lastHandledReopenIntentRef.current) {
return;
}
lastHandledReopenIntentRef.current = reopenIntentVersion;
if (!didExitRef.current && !didExitInPanel && !createError) {
return;
}
didExitRef.current = false;
setDidExitInPanel(false);
setCreateError(null);
}, [createError, didExitInPanel, reopenIntentVersion]);
useEffect(() => {
if (!terminalId) {
return;
}
return client.on("terminal_stream_exit", (message) => {
if (message.type !== "terminal_stream_exit" || message.payload.terminalId !== terminalId) {
return;
}
setTerminalId((current) => (current === message.payload.terminalId ? null : current));
setDidExitInPanel(true);
didExitRef.current = true;
});
}, [client, terminalId]);
useEffect(() => {
if (!isCleanTerminalExit(agent) || isAutoClosingRef.current) {
return;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!workspaceKey) {
return;
}
isAutoClosingRef.current = true;
void archiveAgent({ serverId, agentId: agent.id })
.then(() => {
unpinWorkspaceAgent(workspaceKey, agent.id);
closeWorkspaceTab(workspaceKey, tabId);
})
.finally(() => {
isAutoClosingRef.current = false;
});
}, [
agent,
archiveAgent,
closeWorkspaceTab,
serverId,
tabId,
unpinWorkspaceAgent,
workspaceId,
]);
// Create the terminal when the panel becomes visible and no terminal exists yet.
// Guards (isCreatingRef, didExitRef) are refs to avoid re-triggering the effect
// when their values change — we only want this to fire on genuine state transitions
// (focus change, terminal cleared, agent change).
useEffect(() => {
if (
!isScreenFocused ||
!isPaneFocused ||
terminalId ||
isCreatingRef.current ||
didExitRef.current
) {
return;
}
let cancelled = false;
isCreatingRef.current = true;
setIsCreating(true);
setCreateError(null);
void client
.createTerminal(agent.cwd, undefined, undefined, { agentId: agent.id })
.then((payload) => {
if (cancelled) {
return;
}
if (payload.error || !payload.terminal) {
setCreateError(payload.error ?? "Failed to open terminal");
return;
}
setTerminalId(payload.terminal.id);
})
.catch((error) => {
if (cancelled) {
return;
}
setCreateError(toErrorMessage(error));
})
.finally(() => {
if (!cancelled) {
isCreatingRef.current = false;
setIsCreating(false);
}
});
return () => {
cancelled = true;
};
}, [agent.cwd, agent.id, client, isPaneFocused, isScreenFocused, terminalId]);
if (!isScreenFocused) {
return <View style={styles.container} />;
}
if (terminalId) {
return (
<TerminalPane
serverId={serverId}
cwd={agent.cwd}
terminalId={terminalId}
isPaneFocused={isPaneFocused}
/>
);
}
if (isCreating) {
return (
<View style={styles.state} testID="terminal-agent-loading">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.message}>Opening terminal…</Text>
</View>
);
}
if (createError) {
return (
<View style={styles.state} testID="terminal-agent-error">
<Text style={styles.title}>Failed to open terminal</Text>
<Text style={styles.message}>{createError}</Text>
</View>
);
}
if (didExitInPanel || agent.status === "closed" || agent.status === "error") {
const terminalExit = agent.terminalExit ?? null;
const exitMeta =
terminalExit?.exitCode != null
? `Exit code ${terminalExit.exitCode}`
: terminalExit?.signal != null
? `Signal ${terminalExit.signal}`
: null;
return (
<View style={styles.state} testID="terminal-agent-closed">
<Text style={styles.title}>{getTerminalExitTitle(agent)}</Text>
<Text style={styles.message}>{getTerminalExitMessage(agent)}</Text>
{terminalExit ? (
<View style={styles.detailsCard}>
{exitMeta ? <Text style={styles.detailsLabel}>{exitMeta}</Text> : null}
{terminalExit.outputLines.length > 0 ? (
<Text style={styles.output}>{terminalExit.outputLines.join("\n")}</Text>
) : null}
</View>
) : null}
<Text style={styles.message}>Reopen the agent from the sessions list to start it again.</Text>
</View>
);
}
return (
<View style={styles.state} testID="terminal-agent-idle">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
state: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
backgroundColor: theme.colors.surface0,
},
title: {
fontSize: theme.fontSize.lg,
color: theme.colors.foreground,
textAlign: "center",
},
message: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
detailsCard: {
width: "100%",
maxWidth: 560,
padding: theme.spacing[4],
gap: theme.spacing[2],
borderRadius: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
},
detailsLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.4,
},
output: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontFamily: Fonts.mono,
lineHeight: 20,
},
}));