feat: syntax-highlighted bash commands + fix backfill missing tool calls (#1347)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-03-10 21:29:54 -07:00
committed by GitHub
parent bfc9d4b25a
commit bf53c6e364
10 changed files with 1126 additions and 778 deletions

View File

@@ -6,7 +6,9 @@
"dependencies": { "dependencies": {
"@letta-ai/letta-client": "^1.7.12", "@letta-ai/letta-client": "^1.7.12",
"glob": "^13.0.0", "glob": "^13.0.0",
"highlight.js": "^11.11.1",
"ink-link": "^5.0.0", "ink-link": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.2.0", "open": "^10.2.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"ws": "^8.19.0", "ws": "^8.19.0",
@@ -98,12 +100,16 @@
"@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
"@types/picomatch": ["@types/picomatch@4.0.2", "", {}, "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA=="], "@types/picomatch": ["@types/picomatch@4.0.2", "", {}, "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vscode/ripgrep": ["@vscode/ripgrep@1.17.0", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g=="], "@vscode/ripgrep": ["@vscode/ripgrep@1.17.0", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g=="],
@@ -154,8 +160,12 @@
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -178,6 +188,8 @@
"has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="], "has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -214,6 +226,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],

View File

@@ -35,7 +35,9 @@
"dependencies": { "dependencies": {
"@letta-ai/letta-client": "^1.7.12", "@letta-ai/letta-client": "^1.7.12",
"glob": "^13.0.0", "glob": "^13.0.0",
"highlight.js": "^11.11.1",
"ink-link": "^5.0.0", "ink-link": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.2.0", "open": "^10.2.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"ws": "^8.19.0" "ws": "^8.19.0"

View File

@@ -430,9 +430,7 @@ export async function getResumeData(
return { return {
pendingApproval, pendingApproval,
pendingApprovals, pendingApprovals,
messageHistory: prepareMessageHistory(messages, { messageHistory: prepareMessageHistory(messages),
primaryOnly: true,
}),
}; };
} }
} else { } else {
@@ -445,7 +443,7 @@ export async function getResumeData(
return { return {
pendingApproval: null, pendingApproval: null,
pendingApprovals: [], pendingApprovals: [],
messageHistory: prepareMessageHistory(messages, { primaryOnly: true }), messageHistory: prepareMessageHistory(messages),
}; };
} else { } else {
// Use agent messages API for "default" conversation or when no conversation ID // Use agent messages API for "default" conversation or when no conversation ID
@@ -520,9 +518,7 @@ export async function getResumeData(
return { return {
pendingApproval, pendingApproval,
pendingApprovals, pendingApprovals,
messageHistory: prepareMessageHistory(messages, { messageHistory: prepareMessageHistory(messages),
primaryOnly: true,
}),
}; };
} }
} else { } else {
@@ -535,7 +531,7 @@ export async function getResumeData(
return { return {
pendingApproval: null, pendingApproval: null,
pendingApprovals: [], pendingApprovals: [],
messageHistory: prepareMessageHistory(messages, { primaryOnly: true }), messageHistory: prepareMessageHistory(messages),
}; };
} }
} catch (error) { } catch (error) {

View File

@@ -12990,56 +12990,70 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa
style={{ flexDirection: "column" }} style={{ flexDirection: "column" }}
> >
{(item: StaticItem, index: number) => { {(item: StaticItem, index: number) => {
return ( try {
<Box key={item.id} marginTop={index > 0 ? 1 : 0}> return (
{item.kind === "welcome" ? ( <Box key={item.id} marginTop={index > 0 ? 1 : 0}>
<WelcomeScreen loadingState="ready" {...item.snapshot} /> {item.kind === "welcome" ? (
) : item.kind === "user" ? ( <WelcomeScreen loadingState="ready" {...item.snapshot} />
<UserMessage line={item} prompt={statusLine.prompt} /> ) : item.kind === "user" ? (
) : item.kind === "reasoning" ? ( <UserMessage line={item} prompt={statusLine.prompt} />
<ReasoningMessage line={item} /> ) : item.kind === "reasoning" ? (
) : item.kind === "assistant" ? ( <ReasoningMessage line={item} />
<AssistantMessage line={item} /> ) : item.kind === "assistant" ? (
) : item.kind === "tool_call" ? ( <AssistantMessage line={item} />
<ToolCallMessage ) : item.kind === "tool_call" ? (
line={item} <ToolCallMessage
precomputedDiffs={precomputedDiffsRef.current} line={item}
lastPlanFilePath={lastPlanFilePathRef.current} precomputedDiffs={precomputedDiffsRef.current}
/> lastPlanFilePath={lastPlanFilePathRef.current}
) : item.kind === "subagent_group" ? ( />
<SubagentGroupStatic agents={item.agents} /> ) : item.kind === "subagent_group" ? (
) : item.kind === "error" ? ( <SubagentGroupStatic agents={item.agents} />
<ErrorMessage line={item} /> ) : item.kind === "error" ? (
) : item.kind === "status" ? ( <ErrorMessage line={item} />
<StatusMessage line={item} /> ) : item.kind === "status" ? (
) : item.kind === "event" ? ( <StatusMessage line={item} />
!showCompactionsEnabled && ) : item.kind === "event" ? (
item.eventType === "compaction" ? null : ( !showCompactionsEnabled &&
<EventMessage line={item} /> item.eventType === "compaction" ? null : (
) <EventMessage line={item} />
) : item.kind === "separator" ? ( )
<Box marginTop={1}> ) : item.kind === "separator" ? (
<Text dimColor>{"─".repeat(columns)}</Text> <Box marginTop={1}>
</Box> <Text dimColor>{"─".repeat(columns)}</Text>
) : item.kind === "command" ? ( </Box>
<CommandMessage line={item} /> ) : item.kind === "command" ? (
) : item.kind === "bash_command" ? ( <CommandMessage line={item} />
<BashCommandMessage line={item} /> ) : item.kind === "bash_command" ? (
) : item.kind === "trajectory_summary" ? ( <BashCommandMessage line={item} />
<TrajectorySummary line={item} /> ) : item.kind === "trajectory_summary" ? (
) : item.kind === "approval_preview" ? ( <TrajectorySummary line={item} />
<ApprovalPreview ) : item.kind === "approval_preview" ? (
toolName={item.toolName} <ApprovalPreview
toolArgs={item.toolArgs} toolName={item.toolName}
precomputedDiff={item.precomputedDiff} toolArgs={item.toolArgs}
allDiffs={precomputedDiffsRef.current} precomputedDiff={item.precomputedDiff}
planContent={item.planContent} allDiffs={precomputedDiffsRef.current}
planFilePath={item.planFilePath} planContent={item.planContent}
toolCallId={item.toolCallId} planFilePath={item.planFilePath}
/> toolCallId={item.toolCallId}
) : null} />
</Box> ) : 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> </Static>

View File

@@ -4,6 +4,7 @@ import { useProgressIndicator } from "../hooks/useProgressIndicator";
import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { useTextInputCursor } from "../hooks/useTextInputCursor";
import { colors } from "./colors"; import { colors } from "./colors";
import { SyntaxHighlightedCommand } from "./SyntaxHighlightedCommand";
import { Text } from "./Text"; import { Text } from "./Text";
type BashInfo = { type BashInfo = {
@@ -151,9 +152,11 @@ export const InlineBashApproval = memo(
{/* Command preview */} {/* Command preview */}
<Box paddingLeft={2} flexDirection="column"> <Box paddingLeft={2} flexDirection="column">
<Text>{bashInfo.command}</Text> <SyntaxHighlightedCommand command={bashInfo.command} />
{bashInfo.description && ( {bashInfo.description && (
<Text dimColor>{bashInfo.description}</Text> <Box marginTop={1}>
<Text dimColor>{bashInfo.description}</Text>
</Box>
)} )}
</Box> </Box>
</> </>

View 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

View File

@@ -145,6 +145,42 @@ const _colors = {
dot: brandColors.statusError, // Red dot in output 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 list
todo: { todo: {
completed: brandColors.primaryAccent, // Same blue as in-progress, with strikethrough completed: brandColors.primaryAccent, // Same blue as in-progress, with strikethrough
@@ -220,6 +256,12 @@ const _colors = {
export const colors = { export const colors = {
..._colors, ..._colors,
get shellSyntax() {
return getTerminalTheme() === "light"
? _colors.shellSyntaxLight
: _colors.shellSyntaxDark;
},
// User messages (past prompts) - theme-aware background // User messages (past prompts) - theme-aware background
// Uses getter to read theme at render time (after async init) // Uses getter to read theme at render time (after async init)
get userMessage() { get userMessage() {

View File

@@ -2,6 +2,7 @@ import { Box } from "ink";
import { memo } from "react"; import { memo } from "react";
import { useTerminalWidth } from "../../hooks/useTerminalWidth"; import { useTerminalWidth } from "../../hooks/useTerminalWidth";
import { colors } from "../colors"; import { colors } from "../colors";
import { SyntaxHighlightedCommand } from "../SyntaxHighlightedCommand";
import { Text } from "../Text"; import { Text } from "../Text";
const SOLID_LINE = "─"; const SOLID_LINE = "─";
@@ -36,7 +37,7 @@ export const BashPreview = memo(({ command, description }: Props) => {
{/* Command preview */} {/* Command preview */}
<Box paddingLeft={2} flexDirection="column"> <Box paddingLeft={2} flexDirection="column">
<Text>{command}</Text> <SyntaxHighlightedCommand command={command} />
{description && <Text dimColor>{description}</Text>} {description && <Text dimColor>{description}</Text>}
</Box> </Box>
</> </>

View File

@@ -70,21 +70,21 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
let renderedValue = value; let renderedValue = value;
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined; let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
if (showCursor && focus) { if (showCursor && focus) {
renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.inverse(' '); renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.inverse('\u00A0');
renderedValue = value.length > 0 ? '' : chalk.inverse(' '); renderedValue = value.length > 0 ? '' : chalk.inverse('\u00A0');
let i = 0; let i = 0;
for (const char of value) { for (const char of value) {
const isCursorPosition = i >= cursorOffset - cursorActualWidth && i <= cursorOffset; const isCursorPosition = i >= cursorOffset - cursorActualWidth && i <= cursorOffset;
if (isCursorPosition && char === '\n') { if (isCursorPosition && char === '\n') {
// Newline at cursor: show inverted space (visible cursor) then the newline // Newline at cursor: show inverted space (visible cursor) then the newline
renderedValue += chalk.inverse(' ') + char; renderedValue += chalk.inverse('\u00A0') + char;
} else { } else {
renderedValue += isCursorPosition ? chalk.inverse(char) : char; renderedValue += isCursorPosition ? chalk.inverse(char) : char;
} }
i++; i++;
} }
if (value.length > 0 && cursorOffset === value.length) { if (value.length > 0 && cursorOffset === value.length) {
renderedValue += chalk.inverse(' '); renderedValue += chalk.inverse('\u00A0');
} }
} }
useInput((input, key) => { useInput((input, key) => {