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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user