fix: subagent tree styling (#505)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-09 12:13:53 -08:00
committed by GitHub
parent 9fb1c8bb22
commit f5288d0ec1
6 changed files with 108 additions and 55 deletions

View File

@@ -1,7 +1,6 @@
// src/cli/App.tsx
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { APIError, APIUserAbortError } from "@letta-ai/letta-client/core/error";
import type {
AgentState,
@@ -32,6 +31,7 @@ import { type AgentProvenance, createAgent } from "../agent/create";
import { sendMessageStream } from "../agent/message";
import { getModelDisplayName, getModelInfo } from "../agent/model";
import { SessionStats } from "../agent/stats";
import { INTERRUPTED_BY_USER } from "../constants";
import type { ApprovalContext } from "../permissions/analyzer";
import { type PermissionMode, permissionMode } from "../permissions/mode";
import {
@@ -144,6 +144,7 @@ import {
import {
clearCompletedSubagents,
clearSubagentsByIds,
interruptActiveSubagents,
} from "./helpers/subagentState";
import { getRandomThinkingVerb } from "./helpers/thinkingMessages";
import {
@@ -2559,6 +2560,9 @@ export default function App({
(buffersRef.current.abortGeneration || 0) + 1;
const toolsCancelled = markIncompleteToolsAsCancelled(buffersRef.current);
// Mark any running subagents as interrupted
interruptActiveSubagents(INTERRUPTED_BY_USER);
// Show interrupt feedback (yellow message if no tools were cancelled)
if (!toolsCancelled) {
appendError(INTERRUPT_MESSAGE, true);
@@ -2605,6 +2609,9 @@ export default function App({
(buffersRef.current.abortGeneration || 0) + 1;
const toolsCancelled = markIncompleteToolsAsCancelled(buffersRef.current);
// Mark any running subagents as interrupted
interruptActiveSubagents(INTERRUPTED_BY_USER);
// NOW abort the stream - interrupted flag is already set
if (abortControllerRef.current) {
abortControllerRef.current.abort();

View File

@@ -7,7 +7,7 @@
*
* Features:
* - Real-time updates via useSyncExternalStore
* - Blinking dots for running agents
* - Single blinking dot in header while running
* - Expand/collapse tool calls (ctrl+o)
* - Shows "Running N subagents..." while active
*
@@ -64,24 +64,9 @@ interface AgentRowProps {
const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
const { treeChar, continueChar } = getTreeChars(isLast);
const columns = useTerminalWidth();
const gutterWidth = 7; // continueChar (3) + " ⎿ " (4)
const gutterWidth = 8; // indent (3) + continueChar (2) + status indent (3)
const contentWidth = Math.max(0, columns - gutterWidth);
const getDotElement = () => {
switch (agent.status) {
case "pending":
return <BlinkDot color={colors.subagent.running} />;
case "running":
return <BlinkDot color={colors.subagent.running} />;
case "completed":
return <Text color={colors.subagent.completed}></Text>;
case "error":
return <Text color={colors.subagent.error}></Text>;
default:
return <Text></Text>;
}
};
const isRunning = agent.status === "pending" || agent.status === "running";
const stats = formatStats(
agent.toolCalls.length,
@@ -94,19 +79,30 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
<Box flexDirection="column">
{/* Main row: tree char + description + type + model + stats */}
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{treeChar} </Text>
{getDotElement()}
<Text> {agent.description}</Text>
<Text dimColor> · {agent.type.toLowerCase()}</Text>
{agent.model && <Text dimColor> · {agent.model}</Text>}
<Text dimColor> · {stats}</Text>
<Text>
<Text color={colors.subagent.treeChar}>
{" "}
{treeChar}{" "}
</Text>
<Text bold>{agent.description}</Text>
<Text dimColor>
{" · "}
{agent.type.toLowerCase()}
{agent.model ? ` · ${agent.model}` : ""}
{" · "}
{stats}
</Text>
</Text>
</Box>
{/* Subagent URL */}
{agent.agentURL && (
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>{" Subagent: "}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar} {" "}
</Text>
<Text dimColor>{"Subagent: "}</Text>
<Text dimColor>{agent.agentURL}</Text>
</Box>
)}
@@ -117,7 +113,10 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
const formattedArgs = formatToolArgs(tc.args);
return (
<Box key={tc.id} flexDirection="row">
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar}
</Text>
<Text dimColor>
{" "}
{tc.name}({formattedArgs})
@@ -130,15 +129,21 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
<Box flexDirection="row">
{agent.status === "completed" ? (
<>
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>{" Done"}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar}
</Text>
<Text dimColor>{" Done"}</Text>
</>
) : agent.status === "error" ? (
<>
<Box width={gutterWidth} flexShrink={0}>
<Text>
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>{" "}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar}
</Text>
<Text dimColor>{" "}</Text>
</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
@@ -149,16 +154,22 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
</>
) : lastTool ? (
<>
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar}
</Text>
<Text dimColor>
{" "}
{" "}
{lastTool.name}
</Text>
</>
) : (
<>
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>{" Starting..."}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar}
</Text>
<Text dimColor>{" Starting..."}</Text>
</>
)}
</Box>

View File

@@ -51,33 +51,39 @@ interface AgentRowProps {
const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
const { treeChar, continueChar } = getTreeChars(isLast);
const columns = useTerminalWidth();
const gutterWidth = 7; // continueChar (3) + " ⎿ " (4)
const gutterWidth = 8; // indent (3) + continueChar (2) + status indent (3)
const contentWidth = Math.max(0, columns - gutterWidth);
const dotColor =
agent.status === "completed"
? colors.subagent.completed
: colors.subagent.error;
const stats = formatStats(agent.toolCount, agent.totalTokens);
return (
<Box flexDirection="column">
{/* Main row: tree char + description + type + model + stats */}
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{treeChar} </Text>
<Text color={dotColor}></Text>
<Text> {agent.description}</Text>
<Text dimColor> · {agent.type.toLowerCase()}</Text>
{agent.model && <Text dimColor> · {agent.model}</Text>}
<Text dimColor> · {stats}</Text>
<Text>
<Text color={colors.subagent.treeChar}>
{" "}
{treeChar}{" "}
</Text>
<Text bold>{agent.description}</Text>
<Text dimColor>
{" · "}
{agent.type.toLowerCase()}
{agent.model ? ` · ${agent.model}` : ""}
{" · "}
{stats}
</Text>
</Text>
</Box>
{/* Subagent URL */}
{agent.agentURL && (
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>{" Subagent: "}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar} {" "}
</Text>
<Text dimColor>{"Subagent: "}</Text>
<Text dimColor>{agent.agentURL}</Text>
</Box>
)}
@@ -86,15 +92,21 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
<Box flexDirection="row">
{agent.status === "completed" ? (
<>
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>{" Done"}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar}
</Text>
<Text dimColor>{" Done"}</Text>
</>
) : (
<>
<Box width={gutterWidth} flexShrink={0}>
<Text>
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>{" "}</Text>
<Text color={colors.subagent.treeChar}>
{" "}
{continueChar}
</Text>
<Text dimColor>{" "}</Text>
</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>

View File

@@ -127,7 +127,7 @@ export const colors = {
running: brandColors.statusWarning,
completed: brandColors.statusSuccess,
error: brandColors.statusError,
treeChar: brandColors.textDisabled,
treeChar: brandColors.textSecondary,
hint: brandColors.textDisabled,
},

View File

@@ -43,6 +43,6 @@ export function getTreeChars(isLast: boolean): {
} {
return {
treeChar: isLast ? "└─" : "├─",
continueChar: isLast ? " " : "│ ",
continueChar: isLast ? " " : "│ ",
};
}

View File

@@ -274,6 +274,29 @@ export function hasActiveSubagents(): boolean {
return false;
}
/**
* Mark all running/pending subagents as interrupted
* Called when user presses ESC to interrupt execution
*/
export function interruptActiveSubagents(errorMessage: string): void {
let anyInterrupted = false;
for (const [id, agent] of store.agents.entries()) {
if (agent.status === "pending" || agent.status === "running") {
const updatedAgent: SubagentState = {
...agent,
status: "error",
error: errorMessage,
durationMs: Date.now() - agent.startTime,
};
store.agents.set(id, updatedAgent);
anyInterrupted = true;
}
}
if (anyInterrupted) {
notifyListeners();
}
}
// ============================================================================
// React Integration (useSyncExternalStore compatible)
// ============================================================================