feat: syntax-highlighted bash commands + fix backfill missing tool calls (#1347)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -430,9 +430,7 @@ export async function getResumeData(
|
||||
return {
|
||||
pendingApproval,
|
||||
pendingApprovals,
|
||||
messageHistory: prepareMessageHistory(messages, {
|
||||
primaryOnly: true,
|
||||
}),
|
||||
messageHistory: prepareMessageHistory(messages),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
@@ -445,7 +443,7 @@ export async function getResumeData(
|
||||
return {
|
||||
pendingApproval: null,
|
||||
pendingApprovals: [],
|
||||
messageHistory: prepareMessageHistory(messages, { primaryOnly: true }),
|
||||
messageHistory: prepareMessageHistory(messages),
|
||||
};
|
||||
} else {
|
||||
// Use agent messages API for "default" conversation or when no conversation ID
|
||||
@@ -520,9 +518,7 @@ export async function getResumeData(
|
||||
return {
|
||||
pendingApproval,
|
||||
pendingApprovals,
|
||||
messageHistory: prepareMessageHistory(messages, {
|
||||
primaryOnly: true,
|
||||
}),
|
||||
messageHistory: prepareMessageHistory(messages),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
@@ -535,7 +531,7 @@ export async function getResumeData(
|
||||
return {
|
||||
pendingApproval: null,
|
||||
pendingApprovals: [],
|
||||
messageHistory: prepareMessageHistory(messages, { primaryOnly: true }),
|
||||
messageHistory: prepareMessageHistory(messages),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
114
src/cli/App.tsx
114
src/cli/App.tsx
@@ -12990,56 +12990,70 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa
|
||||
style={{ flexDirection: "column" }}
|
||||
>
|
||||
{(item: StaticItem, index: number) => {
|
||||
return (
|
||||
<Box key={item.id} marginTop={index > 0 ? 1 : 0}>
|
||||
{item.kind === "welcome" ? (
|
||||
<WelcomeScreen loadingState="ready" {...item.snapshot} />
|
||||
) : item.kind === "user" ? (
|
||||
<UserMessage line={item} prompt={statusLine.prompt} />
|
||||
) : item.kind === "reasoning" ? (
|
||||
<ReasoningMessage line={item} />
|
||||
) : item.kind === "assistant" ? (
|
||||
<AssistantMessage line={item} />
|
||||
) : item.kind === "tool_call" ? (
|
||||
<ToolCallMessage
|
||||
line={item}
|
||||
precomputedDiffs={precomputedDiffsRef.current}
|
||||
lastPlanFilePath={lastPlanFilePathRef.current}
|
||||
/>
|
||||
) : item.kind === "subagent_group" ? (
|
||||
<SubagentGroupStatic agents={item.agents} />
|
||||
) : item.kind === "error" ? (
|
||||
<ErrorMessage line={item} />
|
||||
) : item.kind === "status" ? (
|
||||
<StatusMessage line={item} />
|
||||
) : item.kind === "event" ? (
|
||||
!showCompactionsEnabled &&
|
||||
item.eventType === "compaction" ? null : (
|
||||
<EventMessage line={item} />
|
||||
)
|
||||
) : item.kind === "separator" ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{"─".repeat(columns)}</Text>
|
||||
</Box>
|
||||
) : item.kind === "command" ? (
|
||||
<CommandMessage line={item} />
|
||||
) : item.kind === "bash_command" ? (
|
||||
<BashCommandMessage line={item} />
|
||||
) : item.kind === "trajectory_summary" ? (
|
||||
<TrajectorySummary line={item} />
|
||||
) : item.kind === "approval_preview" ? (
|
||||
<ApprovalPreview
|
||||
toolName={item.toolName}
|
||||
toolArgs={item.toolArgs}
|
||||
precomputedDiff={item.precomputedDiff}
|
||||
allDiffs={precomputedDiffsRef.current}
|
||||
planContent={item.planContent}
|
||||
planFilePath={item.planFilePath}
|
||||
toolCallId={item.toolCallId}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
try {
|
||||
return (
|
||||
<Box key={item.id} marginTop={index > 0 ? 1 : 0}>
|
||||
{item.kind === "welcome" ? (
|
||||
<WelcomeScreen loadingState="ready" {...item.snapshot} />
|
||||
) : item.kind === "user" ? (
|
||||
<UserMessage line={item} prompt={statusLine.prompt} />
|
||||
) : item.kind === "reasoning" ? (
|
||||
<ReasoningMessage line={item} />
|
||||
) : item.kind === "assistant" ? (
|
||||
<AssistantMessage line={item} />
|
||||
) : item.kind === "tool_call" ? (
|
||||
<ToolCallMessage
|
||||
line={item}
|
||||
precomputedDiffs={precomputedDiffsRef.current}
|
||||
lastPlanFilePath={lastPlanFilePathRef.current}
|
||||
/>
|
||||
) : item.kind === "subagent_group" ? (
|
||||
<SubagentGroupStatic agents={item.agents} />
|
||||
) : item.kind === "error" ? (
|
||||
<ErrorMessage line={item} />
|
||||
) : item.kind === "status" ? (
|
||||
<StatusMessage line={item} />
|
||||
) : item.kind === "event" ? (
|
||||
!showCompactionsEnabled &&
|
||||
item.eventType === "compaction" ? null : (
|
||||
<EventMessage line={item} />
|
||||
)
|
||||
) : item.kind === "separator" ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{"─".repeat(columns)}</Text>
|
||||
</Box>
|
||||
) : item.kind === "command" ? (
|
||||
<CommandMessage line={item} />
|
||||
) : item.kind === "bash_command" ? (
|
||||
<BashCommandMessage line={item} />
|
||||
) : item.kind === "trajectory_summary" ? (
|
||||
<TrajectorySummary line={item} />
|
||||
) : item.kind === "approval_preview" ? (
|
||||
<ApprovalPreview
|
||||
toolName={item.toolName}
|
||||
toolArgs={item.toolArgs}
|
||||
precomputedDiff={item.precomputedDiff}
|
||||
allDiffs={precomputedDiffsRef.current}
|
||||
planContent={item.planContent}
|
||||
planFilePath={item.planFilePath}
|
||||
toolCallId={item.toolCallId}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[Static render error] kind=${item.kind} id=${item.id}`,
|
||||
err,
|
||||
);
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
<Text color="red">
|
||||
⚠ render error: {item.kind} ({String(err)})
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</Static>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useProgressIndicator } from "../hooks/useProgressIndicator";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { useTextInputCursor } from "../hooks/useTextInputCursor";
|
||||
import { colors } from "./colors";
|
||||
import { SyntaxHighlightedCommand } from "./SyntaxHighlightedCommand";
|
||||
import { Text } from "./Text";
|
||||
|
||||
type BashInfo = {
|
||||
@@ -151,9 +152,11 @@ export const InlineBashApproval = memo(
|
||||
|
||||
{/* Command preview */}
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text>{bashInfo.command}</Text>
|
||||
<SyntaxHighlightedCommand command={bashInfo.command} />
|
||||
{bashInfo.description && (
|
||||
<Text dimColor>{bashInfo.description}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{bashInfo.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
|
||||
172
src/cli/components/SyntaxHighlightedCommand.tsx
Normal file
172
src/cli/components/SyntaxHighlightedCommand.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { ElementContent, RootContent } from "hast";
|
||||
import { Box } from "ink";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import { memo } from "react";
|
||||
import { colors } from "./colors";
|
||||
import { Text } from "./Text";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
const BASH_LANGUAGE = "bash";
|
||||
const FIRST_LINE_PREFIX = "$ ";
|
||||
|
||||
type Props = {
|
||||
command: string;
|
||||
showPrompt?: boolean;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
type ShellSyntaxPalette = typeof colors.shellSyntax;
|
||||
|
||||
/** Styled text span with a resolved color. */
|
||||
type StyledSpan = { text: string; color: string };
|
||||
|
||||
function colorForClassName(
|
||||
className: string,
|
||||
palette: ShellSyntaxPalette,
|
||||
): string {
|
||||
if (className === "hljs-comment") return palette.comment;
|
||||
if (className === "hljs-keyword") return palette.keyword;
|
||||
if (className === "hljs-string" || className === "hljs-regexp") {
|
||||
return palette.string;
|
||||
}
|
||||
if (className === "hljs-number") return palette.number;
|
||||
if (className === "hljs-literal") return palette.literal;
|
||||
if (
|
||||
className === "hljs-built_in" ||
|
||||
className === "hljs-builtin-name" ||
|
||||
className === "hljs-type"
|
||||
) {
|
||||
return palette.builtIn;
|
||||
}
|
||||
if (
|
||||
className === "hljs-variable" ||
|
||||
className === "hljs-template-variable" ||
|
||||
className === "hljs-params"
|
||||
) {
|
||||
return palette.variable;
|
||||
}
|
||||
if (className === "hljs-title" || className === "hljs-function") {
|
||||
return palette.title;
|
||||
}
|
||||
if (className === "hljs-attr" || className === "hljs-attribute") {
|
||||
return palette.attr;
|
||||
}
|
||||
if (className === "hljs-meta") return palette.meta;
|
||||
if (
|
||||
className === "hljs-operator" ||
|
||||
className === "hljs-punctuation" ||
|
||||
className === "hljs-symbol"
|
||||
) {
|
||||
return palette.operator;
|
||||
}
|
||||
if (className === "hljs-subst") return palette.substitution;
|
||||
return palette.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the HAST tree depth-first, collecting flat StyledSpan entries.
|
||||
* Newlines within text nodes are preserved so callers can split into lines.
|
||||
*/
|
||||
function collectSpans(
|
||||
node: RootContent | ElementContent,
|
||||
palette: ShellSyntaxPalette,
|
||||
spans: StyledSpan[],
|
||||
inheritedColor?: string,
|
||||
): void {
|
||||
if (node.type === "text") {
|
||||
spans.push({ text: node.value, color: inheritedColor ?? palette.text });
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "element") {
|
||||
const nodeClasses =
|
||||
(node.properties?.className as string[] | undefined) ?? [];
|
||||
const highlightClass = [...nodeClasses]
|
||||
.reverse()
|
||||
.find((name) => name.startsWith("hljs-"));
|
||||
const nodeColor = highlightClass
|
||||
? colorForClassName(highlightClass, palette)
|
||||
: inheritedColor;
|
||||
|
||||
for (const child of node.children) {
|
||||
collectSpans(child, palette, spans, nodeColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the full command at once (preserves heredoc/multi-line parser
|
||||
* state), then split the flat span list at newline boundaries into per-line
|
||||
* arrays.
|
||||
*/
|
||||
function highlightCommand(
|
||||
command: string,
|
||||
palette: ShellSyntaxPalette,
|
||||
): StyledSpan[][] {
|
||||
let spans: StyledSpan[];
|
||||
try {
|
||||
const root = lowlight.highlight(BASH_LANGUAGE, command);
|
||||
spans = [];
|
||||
for (const child of root.children) {
|
||||
collectSpans(child, palette, spans);
|
||||
}
|
||||
} catch {
|
||||
// Fallback: plain text, split by newlines.
|
||||
return command
|
||||
.split("\n")
|
||||
.map((line) => [{ text: line, color: palette.text }]);
|
||||
}
|
||||
|
||||
// Split spans at newline characters into separate lines.
|
||||
const lines: StyledSpan[][] = [[]];
|
||||
for (const span of spans) {
|
||||
const parts = span.text.split("\n");
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i > 0) {
|
||||
lines.push([]);
|
||||
}
|
||||
const part = parts[i];
|
||||
if (part && part.length > 0) {
|
||||
const currentLine = lines[lines.length - 1];
|
||||
currentLine?.push({ text: part, color: span.color });
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export const SyntaxHighlightedCommand = memo(
|
||||
({ command, showPrompt = true, prefix, suffix }: Props) => {
|
||||
const palette = colors.shellSyntax;
|
||||
const lines = highlightCommand(command, palette);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((spans, lineIdx) => {
|
||||
const lineKey = spans.map((s) => s.text).join("");
|
||||
return (
|
||||
<Box key={`${lineIdx}:${lineKey}`}>
|
||||
{showPrompt ? (
|
||||
<Text color={palette.prompt}>
|
||||
{lineIdx === 0 ? FIRST_LINE_PREFIX : " "}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text color={palette.text}>
|
||||
{lineIdx === 0 && prefix ? prefix : null}
|
||||
{spans.map((span) => (
|
||||
<Text key={`${span.color}:${span.text}`} color={span.color}>
|
||||
{span.text}
|
||||
</Text>
|
||||
))}
|
||||
{lineIdx === lines.length - 1 && suffix ? suffix : null}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SyntaxHighlightedCommand.displayName = "SyntaxHighlightedCommand";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,42 @@ const _colors = {
|
||||
dot: brandColors.statusError, // Red dot in output
|
||||
},
|
||||
|
||||
// Shell command syntax highlighting
|
||||
shellSyntaxDark: {
|
||||
prompt: "#cba6f7",
|
||||
text: "#cdd6f4",
|
||||
comment: "#6c7086",
|
||||
keyword: "#89b4fa",
|
||||
string: "#a6e3a1",
|
||||
number: "#fab387",
|
||||
literal: "#fab387",
|
||||
builtIn: "#f9e2af",
|
||||
variable: "#f5c2e7",
|
||||
title: "#94e2d5",
|
||||
attr: "#74c7ec",
|
||||
operator: "#bac2de",
|
||||
punctuation: "#bac2de",
|
||||
meta: "#f38ba8",
|
||||
substitution: "#f5c2e7",
|
||||
},
|
||||
shellSyntaxLight: {
|
||||
prompt: "#8839ef",
|
||||
text: "#4c4f69",
|
||||
comment: "#9ca0b0",
|
||||
keyword: "#1e66f5",
|
||||
string: "#40a02b",
|
||||
number: "#fe640b",
|
||||
literal: "#fe640b",
|
||||
builtIn: "#df8e1d",
|
||||
variable: "#ea76cb",
|
||||
title: "#179299",
|
||||
attr: "#209fb5",
|
||||
operator: "#5c5f77",
|
||||
punctuation: "#5c5f77",
|
||||
meta: "#d20f39",
|
||||
substitution: "#e64553",
|
||||
},
|
||||
|
||||
// Todo list
|
||||
todo: {
|
||||
completed: brandColors.primaryAccent, // Same blue as in-progress, with strikethrough
|
||||
@@ -220,6 +256,12 @@ const _colors = {
|
||||
export const colors = {
|
||||
..._colors,
|
||||
|
||||
get shellSyntax() {
|
||||
return getTerminalTheme() === "light"
|
||||
? _colors.shellSyntaxLight
|
||||
: _colors.shellSyntaxDark;
|
||||
},
|
||||
|
||||
// User messages (past prompts) - theme-aware background
|
||||
// Uses getter to read theme at render time (after async init)
|
||||
get userMessage() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Box } from "ink";
|
||||
import { memo } from "react";
|
||||
import { useTerminalWidth } from "../../hooks/useTerminalWidth";
|
||||
import { colors } from "../colors";
|
||||
import { SyntaxHighlightedCommand } from "../SyntaxHighlightedCommand";
|
||||
import { Text } from "../Text";
|
||||
|
||||
const SOLID_LINE = "─";
|
||||
@@ -36,7 +37,7 @@ export const BashPreview = memo(({ command, description }: Props) => {
|
||||
|
||||
{/* Command preview */}
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text>{command}</Text>
|
||||
<SyntaxHighlightedCommand command={command} />
|
||||
{description && <Text dimColor>{description}</Text>}
|
||||
</Box>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user