feat: letta code
This commit is contained in:
385
src/cli/components/AdvancedDiffRenderer.tsx
Normal file
385
src/cli/components/AdvancedDiffRenderer.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { relative } from "node:path";
|
||||
import * as Diff from "diff";
|
||||
import { Box, Text } from "ink";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ADV_DIFF_CONTEXT_LINES,
|
||||
type AdvancedDiffSuccess,
|
||||
computeAdvancedDiff,
|
||||
} from "../helpers/diff";
|
||||
import { colors } from "./colors";
|
||||
import { EditRenderer, MultiEditRenderer, WriteRenderer } from "./DiffRenderer";
|
||||
|
||||
type EditItem = {
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
};
|
||||
|
||||
type Props =
|
||||
| {
|
||||
kind: "write";
|
||||
filePath: string;
|
||||
content: string;
|
||||
showHeader?: boolean;
|
||||
oldContentOverride?: string;
|
||||
}
|
||||
| {
|
||||
kind: "edit";
|
||||
filePath: string;
|
||||
oldString: string;
|
||||
newString: string;
|
||||
replaceAll?: boolean;
|
||||
showHeader?: boolean;
|
||||
oldContentOverride?: string;
|
||||
}
|
||||
| {
|
||||
kind: "multi_edit";
|
||||
filePath: string;
|
||||
edits: EditItem[];
|
||||
showHeader?: boolean;
|
||||
oldContentOverride?: string;
|
||||
};
|
||||
|
||||
function formatRelativePath(filePath: string): string {
|
||||
const cwd = process.cwd();
|
||||
const relativePath = relative(cwd, filePath);
|
||||
return relativePath.startsWith("..") ? relativePath : `./${relativePath}`;
|
||||
}
|
||||
|
||||
function padLeft(n: number, width: number): string {
|
||||
const s = String(n);
|
||||
return s.length >= width ? s : " ".repeat(width - s.length) + s;
|
||||
}
|
||||
|
||||
// Render a single line with gutters and optional word-diff highlighting
|
||||
function Line({
|
||||
kind,
|
||||
displayNo,
|
||||
text,
|
||||
pairText,
|
||||
gutterWidth,
|
||||
contentWidth,
|
||||
enableWord,
|
||||
}: {
|
||||
kind: "context" | "remove" | "add";
|
||||
displayNo: number;
|
||||
text: string;
|
||||
pairText?: string; // when '-' followed by '+' to highlight words
|
||||
gutterWidth: number;
|
||||
contentWidth: number;
|
||||
enableWord: boolean;
|
||||
}) {
|
||||
const symbol = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
||||
const symbolColor =
|
||||
kind === "add"
|
||||
? colors.diff.symbolAdd
|
||||
: kind === "remove"
|
||||
? colors.diff.symbolRemove
|
||||
: colors.diff.symbolContext;
|
||||
const bgLine =
|
||||
kind === "add"
|
||||
? colors.diff.addedLineBg
|
||||
: kind === "remove"
|
||||
? colors.diff.removedLineBg
|
||||
: colors.diff.contextLineBg;
|
||||
const bgWord =
|
||||
kind === "add"
|
||||
? colors.diff.addedWordBg
|
||||
: kind === "remove"
|
||||
? colors.diff.removedWordBg
|
||||
: undefined;
|
||||
|
||||
// Char-level diff only for '-' or '+' when pairText is present
|
||||
const charParts: Array<{
|
||||
value: string;
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
}> | null =
|
||||
enableWord &&
|
||||
pairText &&
|
||||
(kind === "add" || kind === "remove") &&
|
||||
pairText !== text
|
||||
? kind === "add"
|
||||
? Diff.diffChars(pairText, text)
|
||||
: Diff.diffChars(text, pairText)
|
||||
: null;
|
||||
|
||||
// Compute remaining width for the text area within this row
|
||||
const textWidth = Math.max(0, contentWidth - gutterWidth - 2);
|
||||
|
||||
return (
|
||||
<Box width={contentWidth}>
|
||||
<Box width={gutterWidth}>
|
||||
<Text dimColor>{padLeft(displayNo, gutterWidth)}</Text>
|
||||
</Box>
|
||||
<Box width={2}>
|
||||
<Text color={symbolColor}>{symbol}</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box width={textWidth}>
|
||||
{charParts ? (
|
||||
<Text>
|
||||
{charParts.map((p, i) => {
|
||||
// For '-' lines: render removed + unchanged; drop added
|
||||
if (kind === "remove") {
|
||||
if (p.removed)
|
||||
return (
|
||||
<Text
|
||||
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
|
||||
backgroundColor={bgWord}
|
||||
color={colors.diff.textOnHighlight}
|
||||
>
|
||||
{p.value}
|
||||
</Text>
|
||||
);
|
||||
if (!p.added && !p.removed)
|
||||
return (
|
||||
<Text
|
||||
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
|
||||
backgroundColor={bgLine}
|
||||
color={colors.diff.textOnDark}
|
||||
>
|
||||
{p.value}
|
||||
</Text>
|
||||
);
|
||||
return null; // skip added segments on '-'
|
||||
}
|
||||
// For '+' lines: render added + unchanged; drop removed
|
||||
if (kind === "add") {
|
||||
if (p.added)
|
||||
return (
|
||||
<Text
|
||||
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
|
||||
backgroundColor={bgWord}
|
||||
color={colors.diff.textOnHighlight}
|
||||
>
|
||||
{p.value}
|
||||
</Text>
|
||||
);
|
||||
if (!p.added && !p.removed)
|
||||
return (
|
||||
<Text
|
||||
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
|
||||
backgroundColor={bgLine}
|
||||
color={colors.diff.textOnDark}
|
||||
>
|
||||
{p.value}
|
||||
</Text>
|
||||
);
|
||||
return null; // skip removed segments on '+'
|
||||
}
|
||||
// Context (should not occur with charParts), fall back to full line
|
||||
return (
|
||||
<Text
|
||||
key={`context-${i}-${p.value.substring(0, 10)}`}
|
||||
backgroundColor={bgLine}
|
||||
>
|
||||
{p.value}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={bgLine}
|
||||
color={kind === "context" ? undefined : colors.diff.textOnDark}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdvancedDiffRenderer(
|
||||
props: Props & { precomputed?: AdvancedDiffSuccess },
|
||||
) {
|
||||
const result = useMemo(() => {
|
||||
if (props.precomputed) return props.precomputed;
|
||||
if (props.kind === "write") {
|
||||
return computeAdvancedDiff(
|
||||
{ kind: "write", filePath: props.filePath, content: props.content },
|
||||
{ oldStrOverride: props.oldContentOverride },
|
||||
);
|
||||
} else if (props.kind === "edit") {
|
||||
return computeAdvancedDiff(
|
||||
{
|
||||
kind: "edit",
|
||||
filePath: props.filePath,
|
||||
oldString: props.oldString,
|
||||
newString: props.newString,
|
||||
replaceAll: props.replaceAll,
|
||||
},
|
||||
{ oldStrOverride: props.oldContentOverride },
|
||||
);
|
||||
} else {
|
||||
return computeAdvancedDiff(
|
||||
{ kind: "multi_edit", filePath: props.filePath, edits: props.edits },
|
||||
{ oldStrOverride: props.oldContentOverride },
|
||||
);
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
const showHeader = props.showHeader !== false; // default to true
|
||||
|
||||
if (result.mode === "fallback") {
|
||||
// Render simple arg-based fallback for readability
|
||||
const filePathForFallback = (props as { filePath: string }).filePath;
|
||||
if (props.kind === "write") {
|
||||
return (
|
||||
<WriteRenderer filePath={filePathForFallback} content={props.content} />
|
||||
);
|
||||
}
|
||||
if (props.kind === "edit") {
|
||||
return (
|
||||
<EditRenderer
|
||||
filePath={filePathForFallback}
|
||||
oldString={props.oldString}
|
||||
newString={props.newString}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// multi_edit fallback
|
||||
if (props.kind === "multi_edit") {
|
||||
const edits = (props.edits || []).map((e) => ({
|
||||
old_string: e.old_string,
|
||||
new_string: e.new_string,
|
||||
}));
|
||||
return <MultiEditRenderer filePath={filePathForFallback} edits={edits} />;
|
||||
}
|
||||
return <MultiEditRenderer filePath={filePathForFallback} edits={[]} />;
|
||||
}
|
||||
|
||||
if (result.mode === "unpreviewable") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor> ⎿ Cannot preview changes: {result.reason}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { hunks } = result;
|
||||
const relative = formatRelativePath((props as { filePath: string }).filePath);
|
||||
const enableWord = props.kind !== "multi_edit";
|
||||
|
||||
// Prepare display rows with shared-line-number behavior like the snippet.
|
||||
type Row = {
|
||||
kind: "context" | "remove" | "add";
|
||||
displayNo: number;
|
||||
text: string;
|
||||
pairText?: string;
|
||||
};
|
||||
const rows: Row[] = [];
|
||||
for (const h of hunks) {
|
||||
let oldNo = h.oldStart;
|
||||
let newNo = h.newStart;
|
||||
let lastRemovalNo: number | null = null;
|
||||
for (let i = 0; i < h.lines.length; i++) {
|
||||
const raw = h.lines[i].raw || "";
|
||||
const ch = raw.charAt(0);
|
||||
const body = raw.slice(1);
|
||||
// Skip meta lines (e.g., "\ No newline at end of file"): do not display, do not advance counters,
|
||||
// and do not clear pairing state.
|
||||
if (ch === "\\") continue;
|
||||
|
||||
// Helper to find next non-meta '+' index
|
||||
const findNextPlus = (start: number): string | undefined => {
|
||||
for (let j = start + 1; j < h.lines.length; j++) {
|
||||
const r = h.lines[j].raw || "";
|
||||
if (r.charAt(0) === "\\") continue; // skip meta
|
||||
if (r.startsWith("+")) return r.slice(1);
|
||||
break; // stop at first non-meta non-plus
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
// Helper to find previous non-meta '-' index
|
||||
const findPrevMinus = (start: number): string | undefined => {
|
||||
for (let k = start - 1; k >= 0; k--) {
|
||||
const r = h.lines[k].raw || "";
|
||||
if (r.charAt(0) === "\\") continue; // skip meta
|
||||
if (r.startsWith("-")) return r.slice(1);
|
||||
break; // stop at first non-meta non-minus
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
if (ch === " ") {
|
||||
rows.push({ kind: "context", displayNo: oldNo, text: body });
|
||||
oldNo++;
|
||||
newNo++;
|
||||
lastRemovalNo = null;
|
||||
} else if (ch === "-") {
|
||||
rows.push({
|
||||
kind: "remove",
|
||||
displayNo: oldNo,
|
||||
text: body,
|
||||
pairText: findNextPlus(i),
|
||||
});
|
||||
lastRemovalNo = oldNo;
|
||||
oldNo++;
|
||||
} else if (ch === "+") {
|
||||
// For insertions (no preceding '-'), use newNo for display number.
|
||||
// For single-line replacements, share the old number from the '-' line.
|
||||
const displayNo = lastRemovalNo !== null ? lastRemovalNo : newNo;
|
||||
rows.push({
|
||||
kind: "add",
|
||||
displayNo,
|
||||
text: body,
|
||||
pairText: findPrevMinus(i),
|
||||
});
|
||||
newNo++;
|
||||
lastRemovalNo = null;
|
||||
} else {
|
||||
// Unknown marker, treat as context
|
||||
rows.push({ kind: "context", displayNo: oldNo, text: raw });
|
||||
oldNo++;
|
||||
newNo++;
|
||||
lastRemovalNo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compute gutter width based on the maximum display number we will render,
|
||||
// so multi-digit line numbers (e.g., 10) never wrap.
|
||||
const maxDisplayNo = rows.reduce((m, r) => Math.max(m, r.displayNo), 1);
|
||||
const gutterWidth = String(maxDisplayNo).length;
|
||||
|
||||
const header =
|
||||
props.kind === "write"
|
||||
? `Wrote changes to ${relative}`
|
||||
: `Updated ${relative}`;
|
||||
|
||||
// Best-effort width clamp for rendering inside approval panel (border + padding + indent ~ 8 cols)
|
||||
const columns =
|
||||
typeof process !== "undefined" &&
|
||||
process.stdout &&
|
||||
"columns" in process.stdout
|
||||
? (process.stdout as NodeJS.WriteStream & { columns: number }).columns
|
||||
: 80;
|
||||
const panelInnerWidth = Math.max(20, columns - 8); // keep a reasonable minimum
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={panelInnerWidth}>
|
||||
{showHeader ? (
|
||||
<>
|
||||
<Text>{header}</Text>
|
||||
<Text
|
||||
dimColor
|
||||
>{`Showing ~${ADV_DIFF_CONTEXT_LINES} context line${ADV_DIFF_CONTEXT_LINES === 1 ? "" : "s"}`}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{rows.map((r, idx) => (
|
||||
<Line
|
||||
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
|
||||
kind={r.kind}
|
||||
displayNo={r.displayNo}
|
||||
text={r.text}
|
||||
pairText={r.pairText}
|
||||
gutterWidth={gutterWidth}
|
||||
contentWidth={panelInnerWidth}
|
||||
enableWord={enableWord}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
199
src/cli/components/ApprovalDialog.tsx
Normal file
199
src/cli/components/ApprovalDialog.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
// Import useInput from vendored Ink for bracketed paste support
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import RawTextInput from "ink-text-input";
|
||||
import { type ComponentType, useMemo, useState } from "react";
|
||||
import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff";
|
||||
import type { ApprovalRequest } from "../helpers/stream";
|
||||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||
|
||||
type Props = {
|
||||
approvalRequest: ApprovalRequest;
|
||||
onApprove: () => void;
|
||||
onApproveAlways: () => void;
|
||||
onDeny: (reason: string) => void;
|
||||
};
|
||||
|
||||
export function ApprovalDialog({
|
||||
approvalRequest,
|
||||
onApprove,
|
||||
onApproveAlways,
|
||||
onDeny,
|
||||
}: Props) {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [isEnteringReason, setIsEnteringReason] = useState(false);
|
||||
const [denyReason, setDenyReason] = useState("");
|
||||
|
||||
const options = [
|
||||
"Approve (once)",
|
||||
"Approve and don't ask again",
|
||||
"Deny and provide feedback",
|
||||
];
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (isEnteringReason) {
|
||||
// When entering reason, only handle enter/escape
|
||||
if (key.return) {
|
||||
onDeny(denyReason);
|
||||
} else if (key.escape) {
|
||||
setIsEnteringReason(false);
|
||||
setDenyReason("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate with arrow keys
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedOption((prev) => (prev < options.length - 1 ? prev + 1 : 0));
|
||||
} else if (key.return) {
|
||||
// Handle selection
|
||||
if (selectedOption === 0) {
|
||||
onApprove();
|
||||
} else if (selectedOption === 1) {
|
||||
onApproveAlways();
|
||||
} else if (selectedOption === 2) {
|
||||
setIsEnteringReason(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pretty print JSON args
|
||||
let formattedArgs = approvalRequest.toolArgs;
|
||||
let parsedArgs: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedArgs = JSON.parse(approvalRequest.toolArgs);
|
||||
formattedArgs = JSON.stringify(parsedArgs, null, 2);
|
||||
} catch {
|
||||
// Keep as-is if not valid JSON
|
||||
}
|
||||
|
||||
// Compute diff for file-editing tools
|
||||
const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => {
|
||||
if (!parsedArgs) return null;
|
||||
|
||||
const toolName = approvalRequest.toolName.toLowerCase();
|
||||
if (toolName === "write") {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "write",
|
||||
filePath: parsedArgs.file_path as string,
|
||||
content: (parsedArgs.content as string) || "",
|
||||
});
|
||||
return result.mode === "advanced" ? result : null;
|
||||
} else if (toolName === "edit") {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "edit",
|
||||
filePath: parsedArgs.file_path as string,
|
||||
oldString: (parsedArgs.old_string as string) || "",
|
||||
newString: (parsedArgs.new_string as string) || "",
|
||||
replaceAll: parsedArgs.replace_all as boolean | undefined,
|
||||
});
|
||||
return result.mode === "advanced" ? result : null;
|
||||
} else if (toolName === "multiedit") {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "multi_edit",
|
||||
filePath: parsedArgs.file_path as string,
|
||||
edits:
|
||||
(parsedArgs.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>) || [],
|
||||
});
|
||||
return result.mode === "advanced" ? result : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [approvalRequest, parsedArgs]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>Tool Approval Required</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
Tool: <Text bold>{approvalRequest.toolName}</Text>
|
||||
</Text>
|
||||
|
||||
{/* Show diff for file-editing tools */}
|
||||
{precomputedDiff && parsedArgs && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{approvalRequest.toolName.toLowerCase() === "write" ? (
|
||||
<AdvancedDiffRenderer
|
||||
precomputed={precomputedDiff}
|
||||
kind="write"
|
||||
filePath={parsedArgs.file_path as string}
|
||||
content={(parsedArgs.content as string) || ""}
|
||||
showHeader={false}
|
||||
/>
|
||||
) : approvalRequest.toolName.toLowerCase() === "edit" ? (
|
||||
<AdvancedDiffRenderer
|
||||
precomputed={precomputedDiff}
|
||||
kind="edit"
|
||||
filePath={parsedArgs.file_path as string}
|
||||
oldString={(parsedArgs.old_string as string) || ""}
|
||||
newString={(parsedArgs.new_string as string) || ""}
|
||||
replaceAll={parsedArgs.replace_all as boolean | undefined}
|
||||
showHeader={false}
|
||||
/>
|
||||
) : approvalRequest.toolName.toLowerCase() === "multiedit" ? (
|
||||
<AdvancedDiffRenderer
|
||||
precomputed={precomputedDiff}
|
||||
kind="multi_edit"
|
||||
filePath={parsedArgs.file_path as string}
|
||||
edits={
|
||||
(parsedArgs.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>) || []
|
||||
}
|
||||
showHeader={false}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Fallback: Show raw args if no diff */}
|
||||
{!precomputedDiff && (
|
||||
<>
|
||||
<Text dimColor>Arguments:</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>{formattedArgs}</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
{isEnteringReason ? (
|
||||
<Box flexDirection="column">
|
||||
<Text>Enter reason for denial (ESC to cancel):</Text>
|
||||
<Box>
|
||||
<Text dimColor>{"> "}</Text>
|
||||
{(() => {
|
||||
const TextInputAny = RawTextInput as unknown as ComponentType<{
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
}>;
|
||||
return (
|
||||
<TextInputAny value={denyReason} onChange={setDenyReason} />
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Text dimColor>Use ↑/↓ to select, Enter to confirm:</Text>
|
||||
{options.map((option) => (
|
||||
<Text key={option}>
|
||||
{selectedOption === options.indexOf(option) ? "→ " : " "}
|
||||
{option}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
419
src/cli/components/ApprovalDialogRich.tsx
Normal file
419
src/cli/components/ApprovalDialogRich.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
// Import useInput from vendored Ink for bracketed paste support
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import RawTextInput from "ink-text-input";
|
||||
import { type ComponentType, memo, useMemo, useState } from "react";
|
||||
import type { ApprovalContext } from "../../permissions/analyzer";
|
||||
import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff";
|
||||
import type { ApprovalRequest } from "../helpers/stream";
|
||||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type Props = {
|
||||
approvalRequest: ApprovalRequest;
|
||||
approvalContext: ApprovalContext | null;
|
||||
onApprove: () => void;
|
||||
onApproveAlways: (scope?: "project" | "session") => void;
|
||||
onDeny: (reason: string) => void;
|
||||
};
|
||||
|
||||
type DynamicPreviewProps = {
|
||||
toolName: string;
|
||||
toolArgs: string;
|
||||
parsedArgs: Record<string, unknown> | null;
|
||||
precomputedDiff: AdvancedDiffSuccess | null;
|
||||
};
|
||||
|
||||
// Options renderer - memoized to prevent unnecessary re-renders
|
||||
const OptionsRenderer = memo(
|
||||
({
|
||||
options,
|
||||
selectedOption,
|
||||
}: {
|
||||
options: Array<{ label: string; action: () => void }>;
|
||||
selectedOption: number;
|
||||
}) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedOption;
|
||||
const color = isSelected ? colors.approval.header : undefined;
|
||||
return (
|
||||
<Box key={option.label} flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={color}>{isSelected ? ">" : " "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color}>
|
||||
{index + 1}. {option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
OptionsRenderer.displayName = "OptionsRenderer";
|
||||
|
||||
// Dynamic preview component - defined outside to avoid recreation on every render
|
||||
const DynamicPreview: React.FC<DynamicPreviewProps> = ({
|
||||
toolName,
|
||||
toolArgs,
|
||||
parsedArgs,
|
||||
precomputedDiff,
|
||||
}) => {
|
||||
const t = toolName.toLowerCase();
|
||||
|
||||
if (t === "bash") {
|
||||
const cmdVal = parsedArgs?.command;
|
||||
const cmd =
|
||||
typeof cmdVal === "string" ? cmdVal : toolArgs || "(no arguments)";
|
||||
const descVal = parsedArgs?.description;
|
||||
const desc = typeof descVal === "string" ? descVal : "";
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text>{cmd}</Text>
|
||||
{desc ? <Text dimColor>{desc}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// File edit previews: write/edit/multi_edit
|
||||
if ((t === "write" || t === "edit" || t === "multiedit") && parsedArgs) {
|
||||
try {
|
||||
const filePath = String(parsedArgs.file_path || "");
|
||||
if (!filePath) throw new Error("no file_path");
|
||||
|
||||
if (precomputedDiff) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{t === "write" ? (
|
||||
<AdvancedDiffRenderer
|
||||
precomputed={precomputedDiff}
|
||||
kind="write"
|
||||
filePath={filePath}
|
||||
content={String(parsedArgs.content ?? "")}
|
||||
showHeader={false}
|
||||
/>
|
||||
) : t === "edit" ? (
|
||||
<AdvancedDiffRenderer
|
||||
precomputed={precomputedDiff}
|
||||
kind="edit"
|
||||
filePath={filePath}
|
||||
oldString={String(parsedArgs.old_string ?? "")}
|
||||
newString={String(parsedArgs.new_string ?? "")}
|
||||
replaceAll={Boolean(parsedArgs.replace_all)}
|
||||
showHeader={false}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedDiffRenderer
|
||||
precomputed={precomputedDiff}
|
||||
kind="multi_edit"
|
||||
filePath={filePath}
|
||||
edits={
|
||||
(parsedArgs.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>) || []
|
||||
}
|
||||
showHeader={false}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to non-precomputed rendering
|
||||
if (t === "write") {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<AdvancedDiffRenderer
|
||||
kind="write"
|
||||
filePath={filePath}
|
||||
content={String(parsedArgs.content ?? "")}
|
||||
showHeader={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (t === "edit") {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<AdvancedDiffRenderer
|
||||
kind="edit"
|
||||
filePath={filePath}
|
||||
oldString={String(parsedArgs.old_string ?? "")}
|
||||
newString={String(parsedArgs.new_string ?? "")}
|
||||
replaceAll={Boolean(parsedArgs.replace_all)}
|
||||
showHeader={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (t === "multiedit") {
|
||||
const edits =
|
||||
(parsedArgs.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>) || [];
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<AdvancedDiffRenderer
|
||||
kind="multi_edit"
|
||||
filePath={filePath}
|
||||
edits={edits}
|
||||
showHeader={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Default for file-edit tools when args not parseable yet
|
||||
if (t === "write" || t === "edit" || t === "multiedit") {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text dimColor>Preparing preview…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// For non-edit tools, pretty-print JSON if available
|
||||
let pretty: string;
|
||||
if (parsedArgs && typeof parsedArgs === "object") {
|
||||
const clone = { ...parsedArgs };
|
||||
// Remove noisy fields
|
||||
if ("request_heartbeat" in clone) delete clone.request_heartbeat;
|
||||
pretty = JSON.stringify(clone, null, 2);
|
||||
} else {
|
||||
pretty = toolArgs || "(no arguments)";
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text>{pretty}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export function ApprovalDialog({
|
||||
approvalRequest,
|
||||
approvalContext,
|
||||
onApprove,
|
||||
onApproveAlways,
|
||||
onDeny,
|
||||
}: Props) {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [isEnteringReason, setIsEnteringReason] = useState(false);
|
||||
const [denyReason, setDenyReason] = useState("");
|
||||
|
||||
// Build options based on approval context
|
||||
const options = useMemo(() => {
|
||||
const opts = [{ label: "Yes, just this once", action: onApprove }];
|
||||
|
||||
// Add context-aware approval option if available
|
||||
// Claude Code style: max 3 options total (Yes once, Yes always, No)
|
||||
// If context is missing, we just don't show "approve always" (2 options only)
|
||||
if (approvalContext?.allowPersistence) {
|
||||
opts.push({
|
||||
label: approvalContext.approveAlwaysText,
|
||||
action: () =>
|
||||
onApproveAlways(
|
||||
approvalContext.defaultScope === "user"
|
||||
? "session"
|
||||
: approvalContext.defaultScope,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add deny option
|
||||
opts.push({
|
||||
label: "No, and tell Letta what to do differently (esc)",
|
||||
action: () => {}, // Handled separately via setIsEnteringReason
|
||||
});
|
||||
|
||||
return opts;
|
||||
}, [approvalContext, onApprove, onApproveAlways]);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (isEnteringReason) {
|
||||
// When entering reason, only handle enter/escape
|
||||
if (key.return) {
|
||||
onDeny(denyReason);
|
||||
} else if (key.escape) {
|
||||
setIsEnteringReason(false);
|
||||
setDenyReason("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate with arrow keys
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedOption((prev) => (prev < options.length - 1 ? prev + 1 : 0));
|
||||
} else if (key.return) {
|
||||
// Handle selection
|
||||
const selected = options[selectedOption];
|
||||
if (selected) {
|
||||
// Check if this is the deny option (last option)
|
||||
if (selectedOption === options.length - 1) {
|
||||
setIsEnteringReason(true);
|
||||
} else {
|
||||
selected.action();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Number key shortcuts
|
||||
const num = parseInt(_input, 10);
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= options.length) {
|
||||
const selected = options[num - 1];
|
||||
if (selected) {
|
||||
// Check if this is the deny option (last option)
|
||||
if (num === options.length) {
|
||||
setIsEnteringReason(true);
|
||||
} else {
|
||||
selected.action();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Parse JSON args
|
||||
let parsedArgs: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedArgs = JSON.parse(approvalRequest.toolArgs);
|
||||
} catch {
|
||||
// Keep as-is if not valid JSON
|
||||
}
|
||||
|
||||
// Compute diff for file-editing tools
|
||||
const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => {
|
||||
if (!parsedArgs) return null;
|
||||
|
||||
const toolName = approvalRequest.toolName.toLowerCase();
|
||||
if (toolName === "write") {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "write",
|
||||
filePath: parsedArgs.file_path as string,
|
||||
content: (parsedArgs.content as string) || "",
|
||||
});
|
||||
return result.mode === "advanced" ? result : null;
|
||||
} else if (toolName === "edit") {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "edit",
|
||||
filePath: parsedArgs.file_path as string,
|
||||
oldString: (parsedArgs.old_string as string) || "",
|
||||
newString: (parsedArgs.new_string as string) || "",
|
||||
replaceAll: parsedArgs.replace_all as boolean | undefined,
|
||||
});
|
||||
return result.mode === "advanced" ? result : null;
|
||||
} else if (toolName === "multiedit") {
|
||||
const result = computeAdvancedDiff({
|
||||
kind: "multi_edit",
|
||||
filePath: parsedArgs.file_path as string,
|
||||
edits:
|
||||
(parsedArgs.edits as Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
}>) || [],
|
||||
});
|
||||
return result.mode === "advanced" ? result : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [approvalRequest, parsedArgs]);
|
||||
|
||||
// Get the human-readable header label
|
||||
const headerLabel = getHeaderLabel(approvalRequest.toolName);
|
||||
|
||||
if (isEnteringReason) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={colors.approval.border}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold>Enter reason for denial (ESC to cancel):</Text>
|
||||
<Box height={1} />
|
||||
<Box>
|
||||
<Text dimColor>{"> "}</Text>
|
||||
{(() => {
|
||||
const TextInputAny = RawTextInput as unknown as ComponentType<{
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
}>;
|
||||
return (
|
||||
<TextInputAny value={denyReason} onChange={setDenyReason} />
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={colors.approval.border}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
{/* Human-readable header (same color as border) */}
|
||||
<Text bold color={colors.approval.header}>
|
||||
{headerLabel}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
|
||||
{/* Dynamic per-tool renderer (indented) */}
|
||||
<DynamicPreview
|
||||
toolName={approvalRequest.toolName}
|
||||
toolArgs={approvalRequest.toolArgs}
|
||||
parsedArgs={parsedArgs}
|
||||
precomputedDiff={precomputedDiff}
|
||||
/>
|
||||
<Box height={1} />
|
||||
|
||||
{/* Prompt */}
|
||||
<Text bold>Do you want to proceed?</Text>
|
||||
<Box height={1} />
|
||||
|
||||
{/* Options selector (single line per option) */}
|
||||
<OptionsRenderer options={options} selectedOption={selectedOption} />
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions for tool name mapping
|
||||
function getHeaderLabel(toolName: string): string {
|
||||
const t = toolName.toLowerCase();
|
||||
if (t === "bash") return "Bash command";
|
||||
if (t === "ls") return "List Files";
|
||||
if (t === "read") return "Read File";
|
||||
if (t === "write") return "Write File";
|
||||
if (t === "edit") return "Edit File";
|
||||
if (t === "multi_edit" || t === "multiedit") return "Edit Files";
|
||||
if (t === "grep") return "Search in Files";
|
||||
if (t === "glob") return "Find Files";
|
||||
if (t === "todo_write" || t === "todowrite") return "Update Todos";
|
||||
return toolName;
|
||||
}
|
||||
13
src/cli/components/AssistantMessage.tsx
Normal file
13
src/cli/components/AssistantMessage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Text } from "ink";
|
||||
import { memo } from "react";
|
||||
|
||||
type AssistantLine = {
|
||||
kind: "assistant";
|
||||
id: string;
|
||||
text: string;
|
||||
phase: "streaming" | "finished";
|
||||
};
|
||||
|
||||
export const AssistantMessage = memo(({ line }: { line: AssistantLine }) => {
|
||||
return <Text>{line.text}</Text>;
|
||||
});
|
||||
53
src/cli/components/AssistantMessageRich.tsx
Normal file
53
src/cli/components/AssistantMessageRich.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
|
||||
// Helper function to normalize text - copied from old codebase
|
||||
const normalize = (s: string) =>
|
||||
s
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/^\n+|\n+$/g, "");
|
||||
|
||||
type AssistantLine = {
|
||||
kind: "assistant";
|
||||
id: string;
|
||||
text: string;
|
||||
phase: "streaming" | "finished";
|
||||
};
|
||||
|
||||
/**
|
||||
* AssistantMessageRich - Rich formatting version with two-column layout
|
||||
* This is a direct port from the old letta-code codebase to preserve the exact styling
|
||||
*
|
||||
* Features:
|
||||
* - Left column (2 chars wide) with bullet point marker
|
||||
* - Right column with wrapped text content
|
||||
* - Proper text normalization
|
||||
* - Support for markdown rendering (when MarkdownDisplay is available)
|
||||
*/
|
||||
export const AssistantMessage = memo(({ line }: { line: AssistantLine }) => {
|
||||
const columns =
|
||||
typeof process !== "undefined" &&
|
||||
process.stdout &&
|
||||
"columns" in process.stdout
|
||||
? ((process.stdout as { columns?: number }).columns ?? 80)
|
||||
: 80;
|
||||
const contentWidth = Math.max(0, columns - 2);
|
||||
|
||||
const normalizedText = normalize(line.text);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text>●</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<MarkdownDisplay text={normalizedText} hangingIndent={0} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
AssistantMessage.displayName = "AssistantMessage";
|
||||
89
src/cli/components/CommandMessage.tsx
Normal file
89
src/cli/components/CommandMessage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { colors } from "./colors.js";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
|
||||
type CommandLine = {
|
||||
kind: "command";
|
||||
id: string;
|
||||
input: string;
|
||||
output: string;
|
||||
phase?: "running" | "finished";
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
// BlinkDot component for running commands
|
||||
const BlinkDot: React.FC<{ color?: string }> = ({ color = "yellow" }) => {
|
||||
const [on, setOn] = useState(true);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setOn((v) => !v), 400);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
// Visible = colored dot; Off = space (keeps width/alignment)
|
||||
return <Text color={color}>{on ? "●" : " "}</Text>;
|
||||
};
|
||||
|
||||
/**
|
||||
* CommandMessage - Rich formatting version with two-column layout
|
||||
* Matches the formatting pattern used by other message types
|
||||
*
|
||||
* Features:
|
||||
* - Two-column layout with left gutter (2 chars) and right content area
|
||||
* - Proper terminal width calculation and wrapping
|
||||
* - Markdown rendering for output
|
||||
* - Consistent symbols (● for command, ⎿ for result)
|
||||
*/
|
||||
export const CommandMessage = memo(({ line }: { line: CommandLine }) => {
|
||||
const columns =
|
||||
typeof process !== "undefined" &&
|
||||
process.stdout &&
|
||||
"columns" in process.stdout
|
||||
? ((process.stdout as { columns?: number }).columns ?? 80)
|
||||
: 80;
|
||||
|
||||
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
|
||||
|
||||
// Determine dot state based on phase and success
|
||||
const getDotElement = () => {
|
||||
if (!line.phase || line.phase === "finished") {
|
||||
// Show red dot for failed commands, green for successful
|
||||
if (line.success === false) {
|
||||
return <Text color={colors.command.error}>●</Text>;
|
||||
}
|
||||
return <Text color={colors.tool.completed}>●</Text>;
|
||||
}
|
||||
if (line.phase === "running") {
|
||||
return <BlinkDot color={colors.command.running} />;
|
||||
}
|
||||
return <Text>●</Text>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Command input */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
{getDotElement()}
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={rightWidth}>
|
||||
<Text>{line.input}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Command output (if present) */}
|
||||
{line.output && (
|
||||
<Box flexDirection="row">
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text>{" ⎿ "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
<MarkdownDisplay text={line.output} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
CommandMessage.displayName = "CommandMessage";
|
||||
31
src/cli/components/CommandPreview.tsx
Normal file
31
src/cli/components/CommandPreview.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { commands } from "../commands/registry";
|
||||
import { colors } from "./colors";
|
||||
|
||||
// Compute command list once at module level since it never changes
|
||||
const commandList = Object.entries(commands).map(([cmd, { desc }]) => ({
|
||||
cmd,
|
||||
desc,
|
||||
}));
|
||||
|
||||
export function CommandPreview({ currentInput }: { currentInput: string }) {
|
||||
if (!currentInput.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={colors.command.border}
|
||||
paddingX={1}
|
||||
>
|
||||
{commandList.map((item) => (
|
||||
<Box key={item.cmd} justifyContent="space-between" width={40}>
|
||||
<Text>{item.cmd}</Text>
|
||||
<Text dimColor>{item.desc}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
259
src/cli/components/DiffRenderer.tsx
Normal file
259
src/cli/components/DiffRenderer.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { relative } from "node:path";
|
||||
import * as Diff from "diff";
|
||||
import { Box, Text } from "ink";
|
||||
import { colors } from "./colors";
|
||||
|
||||
// Helper to format path as relative with ../
|
||||
function formatRelativePath(filePath: string): string {
|
||||
const cwd = process.cwd();
|
||||
const relativePath = relative(cwd, filePath);
|
||||
|
||||
// If file is outside cwd, it will start with ..
|
||||
// If file is in cwd, add ./ prefix
|
||||
if (!relativePath.startsWith("..")) {
|
||||
return `./${relativePath}`;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// Helper to count lines in a string
|
||||
function countLines(str: string): number {
|
||||
if (!str) return 0;
|
||||
return str.split("\n").length;
|
||||
}
|
||||
|
||||
// Helper to render a diff line with word-level highlighting
|
||||
interface DiffLineProps {
|
||||
lineNumber: number;
|
||||
type: "add" | "remove";
|
||||
content: string;
|
||||
compareContent?: string; // The other version to compare against for word diff
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
lineNumber,
|
||||
type,
|
||||
content,
|
||||
compareContent,
|
||||
}: DiffLineProps) {
|
||||
const prefix = type === "add" ? "+" : "-";
|
||||
const lineBg =
|
||||
type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg;
|
||||
const wordBg =
|
||||
type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg;
|
||||
|
||||
// If we have something to compare against, do word-level diff
|
||||
if (compareContent !== undefined && content.trim() && compareContent.trim()) {
|
||||
const wordDiffs =
|
||||
type === "add"
|
||||
? Diff.diffWords(compareContent, content)
|
||||
: Diff.diffWords(content, compareContent);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text> </Text>
|
||||
<Text backgroundColor={lineBg} color={colors.diff.textOnDark}>
|
||||
{`${lineNumber} ${prefix} `}
|
||||
</Text>
|
||||
{wordDiffs.map((part, i) => {
|
||||
if (part.added && type === "add") {
|
||||
// This part was added (show with brighter background, black text)
|
||||
return (
|
||||
<Text
|
||||
key={`word-${i}-${part.value.substring(0, 10)}`}
|
||||
backgroundColor={wordBg}
|
||||
color={colors.diff.textOnHighlight}
|
||||
>
|
||||
{part.value}
|
||||
</Text>
|
||||
);
|
||||
} else if (part.removed && type === "remove") {
|
||||
// This part was removed (show with brighter background, black text)
|
||||
return (
|
||||
<Text
|
||||
key={`word-${i}-${part.value.substring(0, 10)}`}
|
||||
backgroundColor={wordBg}
|
||||
color={colors.diff.textOnHighlight}
|
||||
>
|
||||
{part.value}
|
||||
</Text>
|
||||
);
|
||||
} else if (!part.added && !part.removed) {
|
||||
// Unchanged part (show with line background, white text)
|
||||
return (
|
||||
<Text
|
||||
key={`word-${i}-${part.value.substring(0, 10)}`}
|
||||
backgroundColor={lineBg}
|
||||
color={colors.diff.textOnDark}
|
||||
>
|
||||
{part.value}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// Skip parts that don't belong in this line
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// No comparison, just show the whole line with one background
|
||||
return (
|
||||
<Box>
|
||||
<Text> </Text>
|
||||
<Text backgroundColor={lineBg} color={colors.diff.textOnDark}>
|
||||
{`${lineNumber} ${prefix} ${content}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface WriteRendererProps {
|
||||
filePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function WriteRenderer({ filePath, content }: WriteRendererProps) {
|
||||
const relativePath = formatRelativePath(filePath);
|
||||
const lines = content.split("\n");
|
||||
const lineCount = lines.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
⎿ Wrote {lineCount} line{lineCount !== 1 ? "s" : ""} to {relativePath}
|
||||
</Text>
|
||||
{lines.map((line, i) => (
|
||||
<Text key={`line-${i}-${line.substring(0, 20)}`}> {line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditRendererProps {
|
||||
filePath: string;
|
||||
oldString: string;
|
||||
newString: string;
|
||||
}
|
||||
|
||||
export function EditRenderer({
|
||||
filePath,
|
||||
oldString,
|
||||
newString,
|
||||
}: EditRendererProps) {
|
||||
const relativePath = formatRelativePath(filePath);
|
||||
const oldLines = oldString.split("\n");
|
||||
const newLines = newString.split("\n");
|
||||
|
||||
// For the summary
|
||||
const additions = newLines.length;
|
||||
const removals = oldLines.length;
|
||||
|
||||
// Try to match up lines for word-level diff
|
||||
// This is a simple approach - for single-line changes, compare directly
|
||||
// For multi-line, we could do more sophisticated matching
|
||||
const singleLineEdit = oldLines.length === 1 && newLines.length === 1;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
⎿ Updated {relativePath} with {additions} addition
|
||||
{additions !== 1 ? "s" : ""} and {removals} removal
|
||||
{removals !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
{/* Show removals */}
|
||||
{oldLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`old-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1}
|
||||
type="remove"
|
||||
content={line}
|
||||
compareContent={singleLineEdit ? newLines[0] : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show additions */}
|
||||
{newLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`new-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1}
|
||||
type="add"
|
||||
content={line}
|
||||
compareContent={singleLineEdit ? oldLines[0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface MultiEditRendererProps {
|
||||
filePath: string;
|
||||
edits: Array<{
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) {
|
||||
const relativePath = formatRelativePath(filePath);
|
||||
|
||||
// Count total additions and removals
|
||||
let totalAdditions = 0;
|
||||
let totalRemovals = 0;
|
||||
|
||||
edits.forEach((edit) => {
|
||||
totalAdditions += countLines(edit.new_string);
|
||||
totalRemovals += countLines(edit.old_string);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
⎿ Updated {relativePath} with {totalAdditions} addition
|
||||
{totalAdditions !== 1 ? "s" : ""} and {totalRemovals} removal
|
||||
{totalRemovals !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
{/* For multi-edit, show each edit sequentially */}
|
||||
{edits.map((edit, index) => {
|
||||
const oldLines = edit.old_string.split("\n");
|
||||
const newLines = edit.new_string.split("\n");
|
||||
const singleLineEdit = oldLines.length === 1 && newLines.length === 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`edit-${index}-${edit.old_string.substring(0, 20)}-${edit.new_string.substring(0, 20)}`}
|
||||
flexDirection="column"
|
||||
>
|
||||
{oldLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`old-${index}-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1} // TODO: This should be actual file line numbers
|
||||
type="remove"
|
||||
content={line}
|
||||
compareContent={
|
||||
singleLineEdit && i === 0 ? newLines[0] : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{newLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`new-${index}-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1} // TODO: This should be actual file line numbers
|
||||
type="add"
|
||||
content={line}
|
||||
compareContent={
|
||||
singleLineEdit && i === 0 ? oldLines[0] : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
12
src/cli/components/ErrorMessage.tsx
Normal file
12
src/cli/components/ErrorMessage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Text } from "ink";
|
||||
import { memo } from "react";
|
||||
|
||||
type ErrorLine = {
|
||||
kind: "error";
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const ErrorMessage = memo(({ line }: { line: ErrorLine }) => {
|
||||
return <Text>{line.text}</Text>;
|
||||
});
|
||||
117
src/cli/components/InlineMarkdownRenderer.tsx
Normal file
117
src/cli/components/InlineMarkdownRenderer.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Text } from "ink";
|
||||
import type React from "react";
|
||||
import { colors } from "./colors.js";
|
||||
|
||||
interface InlineMarkdownProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders inline markdown (bold, italic, code, etc.) using pure Ink components.
|
||||
* Based on Gemini CLI's approach - NO ANSI codes!
|
||||
* Note: dimColor should be handled by parent Text component for proper wrapping
|
||||
*/
|
||||
export const InlineMarkdown: React.FC<InlineMarkdownProps> = ({ text }) => {
|
||||
// Early return for plain text without markdown (treat underscores as plain text)
|
||||
if (!/[*~`[]/.test(text)) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
// Regex to match inline markdown patterns (underscore italics disabled)
|
||||
// Matches: **bold**, *italic*, ~~strikethrough~~, `code`, [link](url)
|
||||
const inlineRegex =
|
||||
/(\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
|
||||
let match: RegExpExecArray | null = inlineRegex.exec(text);
|
||||
|
||||
while (match !== null) {
|
||||
// Add text before the match
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const fullMatch = match[0];
|
||||
const key = `m-${match.index}`;
|
||||
|
||||
// Handle different markdown patterns
|
||||
if (
|
||||
fullMatch.startsWith("**") &&
|
||||
fullMatch.endsWith("**") &&
|
||||
fullMatch.length > 4
|
||||
) {
|
||||
// Bold
|
||||
nodes.push(
|
||||
<Text key={key} bold>
|
||||
{fullMatch.slice(2, -2)}
|
||||
</Text>,
|
||||
);
|
||||
} else if (
|
||||
fullMatch.length > 2 &&
|
||||
fullMatch.startsWith("*") &&
|
||||
fullMatch.endsWith("*")
|
||||
) {
|
||||
// Italic
|
||||
nodes.push(
|
||||
<Text key={key} italic>
|
||||
{fullMatch.slice(1, -1)}
|
||||
</Text>,
|
||||
);
|
||||
} else if (
|
||||
fullMatch.startsWith("~~") &&
|
||||
fullMatch.endsWith("~~") &&
|
||||
fullMatch.length > 4
|
||||
) {
|
||||
// Strikethrough
|
||||
nodes.push(
|
||||
<Text key={key} strikethrough>
|
||||
{fullMatch.slice(2, -2)}
|
||||
</Text>,
|
||||
);
|
||||
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
|
||||
// Inline code
|
||||
nodes.push(
|
||||
<Text key={key} color={colors.link.text}>
|
||||
{fullMatch.slice(1, -1)}
|
||||
</Text>,
|
||||
);
|
||||
} else if (
|
||||
fullMatch.startsWith("[") &&
|
||||
fullMatch.includes("](") &&
|
||||
fullMatch.endsWith(")")
|
||||
) {
|
||||
// Link [text](url)
|
||||
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
const linkText = linkMatch[1];
|
||||
const url = linkMatch[2];
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
{linkText}
|
||||
<Text color={colors.link.url}> ({url})</Text>
|
||||
</Text>,
|
||||
);
|
||||
} else {
|
||||
// Fallback if link parsing fails
|
||||
nodes.push(fullMatch);
|
||||
}
|
||||
} else {
|
||||
// Unknown pattern, render as-is
|
||||
nodes.push(fullMatch);
|
||||
}
|
||||
|
||||
lastIndex = inlineRegex.lastIndex;
|
||||
match = inlineRegex.exec(text);
|
||||
}
|
||||
|
||||
// Add remaining text after last match
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return <>{nodes}</>;
|
||||
};
|
||||
|
||||
// Test helper: expose the tokenizer logic for simple unit validation without rendering.
|
||||
// This mirrors the logic above; keep it in sync with InlineMarkdown for tests.
|
||||
129
src/cli/components/Input.tsx
Normal file
129
src/cli/components/Input.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// Import useInput from vendored Ink for bracketed paste support
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CommandPreview } from "./CommandPreview";
|
||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||
|
||||
// Only show token count when it exceeds this threshold
|
||||
const COUNTER_VISIBLE_THRESHOLD = 1000;
|
||||
|
||||
// Stable reference to prevent re-renders during typing
|
||||
const EMPTY_STATUS = " ";
|
||||
|
||||
export function Input({
|
||||
streaming,
|
||||
tokenCount,
|
||||
thinkingMessage,
|
||||
onSubmit,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
tokenCount: number;
|
||||
thinkingMessage: string;
|
||||
onSubmit: (message?: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [escapePressed, setEscapePressed] = useState(false);
|
||||
const escapeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [ctrlCPressed, setCtrlCPressed] = useState(false);
|
||||
const ctrlCTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previousValueRef = useRef(value);
|
||||
|
||||
// Handle escape key for double-escape-to-clear
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && value) {
|
||||
// Only work when input is non-empty
|
||||
if (escapePressed) {
|
||||
// Second escape - clear input
|
||||
setValue("");
|
||||
setEscapePressed(false);
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
} else {
|
||||
// First escape - start 1-second timer
|
||||
setEscapePressed(true);
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
setEscapePressed(false);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle CTRL-C for double-ctrl-c-to-exit
|
||||
useInput((input, key) => {
|
||||
if (input === "c" && key.ctrl) {
|
||||
if (ctrlCPressed) {
|
||||
// Second CTRL-C - exit application
|
||||
process.exit(0);
|
||||
} else {
|
||||
// First CTRL-C - start 1-second timer
|
||||
setCtrlCPressed(true);
|
||||
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressed(false);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset escape and ctrl-c state when user types (value changes)
|
||||
useEffect(() => {
|
||||
if (value !== previousValueRef.current && value !== "") {
|
||||
setEscapePressed(false);
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
setCtrlCPressed(false);
|
||||
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
||||
}
|
||||
previousValueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
// Clean up timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (streaming) {
|
||||
return;
|
||||
}
|
||||
onSubmit(value);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const footerText = ctrlCPressed
|
||||
? "Press CTRL-C again to exit"
|
||||
: escapePressed
|
||||
? "Press Esc again to clear"
|
||||
: "Press / for commands";
|
||||
|
||||
const thinkingText = streaming
|
||||
? tokenCount > COUNTER_VISIBLE_THRESHOLD
|
||||
? `${thinkingMessage}… (${tokenCount}↑)`
|
||||
: `${thinkingMessage}…`
|
||||
: EMPTY_STATUS;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* Live status / token counter (per-turn) - always takes up space to prevent layout shift */}
|
||||
<Text dimColor>{thinkingText}</Text>
|
||||
<Box>
|
||||
<Text dimColor>{"> "}</Text>
|
||||
<PasteAwareTextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
{value.startsWith("/") ? (
|
||||
<CommandPreview currentInput={value} />
|
||||
) : (
|
||||
<Box justifyContent="space-between">
|
||||
<Text dimColor>{footerText}</Text>
|
||||
<Text dimColor>Letta Code v0.1</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
268
src/cli/components/InputRich.tsx
Normal file
268
src/cli/components/InputRich.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
// Import useInput from vendored Ink for bracketed paste support
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import SpinnerLib from "ink-spinner";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PermissionMode } from "../../permissions/mode";
|
||||
import { permissionMode } from "../../permissions/mode";
|
||||
import { CommandPreview } from "./CommandPreview";
|
||||
import { colors } from "./colors";
|
||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||
import { ShimmerText } from "./ShimmerText";
|
||||
|
||||
// Type assertion for ink-spinner compatibility
|
||||
const Spinner = SpinnerLib as ComponentType;
|
||||
|
||||
// Only show token count when it exceeds this threshold
|
||||
const COUNTER_VISIBLE_THRESHOLD = 1000;
|
||||
|
||||
export function Input({
|
||||
visible = true,
|
||||
streaming,
|
||||
commandRunning = false,
|
||||
tokenCount,
|
||||
thinkingMessage,
|
||||
onSubmit,
|
||||
permissionMode: externalMode,
|
||||
onPermissionModeChange,
|
||||
}: {
|
||||
visible?: boolean;
|
||||
streaming: boolean;
|
||||
commandRunning?: boolean;
|
||||
tokenCount: number;
|
||||
thinkingMessage: string;
|
||||
onSubmit: (message?: string) => void;
|
||||
permissionMode?: PermissionMode;
|
||||
onPermissionModeChange?: (mode: PermissionMode) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [escapePressed, setEscapePressed] = useState(false);
|
||||
const escapeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [ctrlCPressed, setCtrlCPressed] = useState(false);
|
||||
const ctrlCTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previousValueRef = useRef(value);
|
||||
const [currentMode, setCurrentMode] = useState<PermissionMode>(
|
||||
externalMode || permissionMode.getMode(),
|
||||
);
|
||||
|
||||
// Sync with external mode changes (from plan approval dialog)
|
||||
useEffect(() => {
|
||||
if (externalMode !== undefined) {
|
||||
setCurrentMode(externalMode);
|
||||
}
|
||||
}, [externalMode]);
|
||||
|
||||
// Shimmer animation state
|
||||
const [shimmerOffset, setShimmerOffset] = useState(-3);
|
||||
|
||||
// Get terminal width for proper column sizing
|
||||
const columns =
|
||||
typeof process !== "undefined" &&
|
||||
process.stdout &&
|
||||
"columns" in process.stdout
|
||||
? ((process.stdout as { columns?: number }).columns ?? 80)
|
||||
: 80;
|
||||
const contentWidth = Math.max(0, columns - 2);
|
||||
|
||||
// Handle escape key for double-escape-to-clear
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && value) {
|
||||
// Only work when input is non-empty
|
||||
if (escapePressed) {
|
||||
// Second escape - clear input
|
||||
setValue("");
|
||||
setEscapePressed(false);
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
} else {
|
||||
// First escape - start 1-second timer
|
||||
setEscapePressed(true);
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
setEscapePressed(false);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle CTRL-C for double-ctrl-c-to-exit
|
||||
useInput((input, key) => {
|
||||
if (input === "c" && key.ctrl) {
|
||||
if (ctrlCPressed) {
|
||||
// Second CTRL-C - exit application
|
||||
process.exit(0);
|
||||
} else {
|
||||
// First CTRL-C - start 1-second timer
|
||||
setCtrlCPressed(true);
|
||||
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressed(false);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Shift+Tab for permission mode cycling
|
||||
useInput((_input, key) => {
|
||||
if (key.shift && key.tab) {
|
||||
// Cycle through permission modes
|
||||
const modes: PermissionMode[] = [
|
||||
"default",
|
||||
"acceptEdits",
|
||||
"plan",
|
||||
"bypassPermissions",
|
||||
];
|
||||
const currentIndex = modes.indexOf(currentMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
const nextMode = modes[nextIndex] ?? "default";
|
||||
|
||||
// Update both singleton and local state
|
||||
permissionMode.setMode(nextMode);
|
||||
setCurrentMode(nextMode);
|
||||
|
||||
// Notify parent of mode change
|
||||
if (onPermissionModeChange) {
|
||||
onPermissionModeChange(nextMode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset escape and ctrl-c state when user types (value changes)
|
||||
useEffect(() => {
|
||||
if (value !== previousValueRef.current && value !== "") {
|
||||
setEscapePressed(false);
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
setCtrlCPressed(false);
|
||||
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
||||
}
|
||||
previousValueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
// Clean up timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
||||
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Shimmer animation effect
|
||||
useEffect(() => {
|
||||
if (!streaming) return;
|
||||
|
||||
const id = setInterval(() => {
|
||||
setShimmerOffset((prev) => {
|
||||
const len = thinkingMessage.length;
|
||||
const next = prev + 1;
|
||||
return next > len + 3 ? -3 : next;
|
||||
});
|
||||
}, 120); // Speed of shimmer animation
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [streaming, thinkingMessage]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (streaming || commandRunning) {
|
||||
return;
|
||||
}
|
||||
onSubmit(value);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
// Get display name and color for permission mode
|
||||
const getModeInfo = () => {
|
||||
switch (currentMode) {
|
||||
case "acceptEdits":
|
||||
return { name: "accept edits", color: colors.status.processing };
|
||||
case "plan":
|
||||
return { name: "plan (read-only) mode", color: colors.status.success };
|
||||
case "bypassPermissions":
|
||||
return {
|
||||
name: "yolo (allow all) mode",
|
||||
color: colors.status.error,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const modeInfo = getModeInfo();
|
||||
|
||||
const shouldShowTokenCount =
|
||||
streaming && tokenCount > COUNTER_VISIBLE_THRESHOLD;
|
||||
|
||||
// Create a horizontal line using box-drawing characters
|
||||
const horizontalLine = "─".repeat(columns);
|
||||
|
||||
// If not visible, render nothing but keep component mounted to preserve state
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Live status / token counter - only show when streaming */}
|
||||
{streaming && (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={colors.status.processing}>
|
||||
<Spinner type="layer" />
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<ShimmerText
|
||||
message={thinkingMessage}
|
||||
shimmerOffset={shimmerOffset}
|
||||
/>
|
||||
{shouldShowTokenCount && <Text dimColor> ({tokenCount}↑)</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column">
|
||||
{/* Top horizontal divider */}
|
||||
<Text dimColor>{horizontalLine}</Text>
|
||||
|
||||
{/* Two-column layout for input, matching message components */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={colors.input.prompt}>{">"}</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<PasteAwareTextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom horizontal divider */}
|
||||
<Text dimColor>{horizontalLine}</Text>
|
||||
|
||||
{value.startsWith("/") ? (
|
||||
<CommandPreview currentInput={value} />
|
||||
) : (
|
||||
<Box justifyContent="space-between" marginBottom={1}>
|
||||
{ctrlCPressed ? (
|
||||
<Text dimColor>Press CTRL-C again to exit</Text>
|
||||
) : escapePressed ? (
|
||||
<Text dimColor>Press Esc again to clear</Text>
|
||||
) : modeInfo ? (
|
||||
<Text>
|
||||
<Text color={modeInfo.color}>⏵⏵ {modeInfo.name}</Text>
|
||||
<Text color={modeInfo.color} dimColor>
|
||||
{" "}
|
||||
(shift+tab to cycle)
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>Press / for commands</Text>
|
||||
)}
|
||||
<Text dimColor>https://discord.gg/letta</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
210
src/cli/components/MarkdownDisplay.tsx
Normal file
210
src/cli/components/MarkdownDisplay.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Box, Text } from "ink";
|
||||
import type React from "react";
|
||||
import { colors } from "./colors.js";
|
||||
import { InlineMarkdown } from "./InlineMarkdownRenderer.js";
|
||||
|
||||
interface MarkdownDisplayProps {
|
||||
text: string;
|
||||
dimColor?: boolean;
|
||||
hangingIndent?: number; // indent for wrapped lines within a paragraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders full markdown content using pure Ink components.
|
||||
* Based on Gemini CLI's approach - NO ANSI codes, NO marked-terminal!
|
||||
*/
|
||||
import { Transform } from "ink";
|
||||
|
||||
export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
|
||||
text,
|
||||
dimColor,
|
||||
hangingIndent = 0,
|
||||
}) => {
|
||||
if (!text) return null;
|
||||
|
||||
const lines = text.split("\n");
|
||||
const contentBlocks: React.ReactNode[] = [];
|
||||
|
||||
// Regex patterns for markdown elements
|
||||
const headerRegex = /^(#{1,6})\s+(.*)$/;
|
||||
const codeBlockRegex = /^```(\w*)?$/;
|
||||
const listItemRegex = /^(\s*)([*\-+]|\d+\.)\s+(.*)$/;
|
||||
const blockquoteRegex = /^>\s*(.*)$/;
|
||||
const hrRegex = /^[-*_]{3,}$/;
|
||||
|
||||
let inCodeBlock = false;
|
||||
let codeBlockContent: string[] = [];
|
||||
let _codeBlockLang = "";
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const key = `line-${index}`;
|
||||
|
||||
// Handle code blocks
|
||||
if (line.match(codeBlockRegex)) {
|
||||
if (!inCodeBlock) {
|
||||
// Start of code block
|
||||
const match = line.match(codeBlockRegex);
|
||||
_codeBlockLang = match?.[1] || "";
|
||||
inCodeBlock = true;
|
||||
codeBlockContent = [];
|
||||
} else {
|
||||
// End of code block
|
||||
inCodeBlock = false;
|
||||
|
||||
// Render the code block
|
||||
const code = codeBlockContent.join("\n");
|
||||
|
||||
// For now, use simple colored text for code blocks
|
||||
// TODO: Could parse cli-highlight output and convert ANSI to Ink components
|
||||
// but for MVP, just use a nice color like Gemini does
|
||||
contentBlocks.push(
|
||||
<Box key={key} paddingLeft={2} marginY={1}>
|
||||
<Text color={colors.code.inline}>{code}</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
codeBlockContent = [];
|
||||
_codeBlockLang = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're inside a code block, collect the content
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for headers
|
||||
const headerMatch = line.match(headerRegex);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const content = headerMatch[2];
|
||||
|
||||
// Different styling for different header levels
|
||||
let headerElement: React.ReactNode;
|
||||
if (level === 1) {
|
||||
headerElement = (
|
||||
<Text bold color={colors.heading.primary}>
|
||||
<InlineMarkdown text={content} />
|
||||
</Text>
|
||||
);
|
||||
} else if (level === 2) {
|
||||
headerElement = (
|
||||
<Text bold color={colors.heading.secondary}>
|
||||
<InlineMarkdown text={content} />
|
||||
</Text>
|
||||
);
|
||||
} else if (level === 3) {
|
||||
headerElement = (
|
||||
<Text bold>
|
||||
<InlineMarkdown text={content} />
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
headerElement = (
|
||||
<Text italic>
|
||||
<InlineMarkdown text={content} />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
contentBlocks.push(
|
||||
<Box key={key} marginY={1}>
|
||||
{headerElement}
|
||||
</Box>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for list items
|
||||
const listMatch = line.match(listItemRegex);
|
||||
if (listMatch) {
|
||||
const indent = listMatch[1].length;
|
||||
const marker = listMatch[2];
|
||||
const content = listMatch[3];
|
||||
|
||||
// Determine if it's ordered or unordered list
|
||||
const isOrdered = /^\d+\./.test(marker);
|
||||
const bullet = isOrdered ? `${marker} ` : "• ";
|
||||
const bulletWidth = bullet.length;
|
||||
|
||||
contentBlocks.push(
|
||||
<Box key={key} paddingLeft={indent} flexDirection="row">
|
||||
<Box width={bulletWidth} flexShrink={0}>
|
||||
<Text dimColor={dimColor}>{bullet}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" dimColor={dimColor}>
|
||||
<InlineMarkdown text={content} />
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for blockquotes
|
||||
const blockquoteMatch = line.match(blockquoteRegex);
|
||||
if (blockquoteMatch) {
|
||||
contentBlocks.push(
|
||||
<Box key={key} paddingLeft={2}>
|
||||
<Text dimColor>│ </Text>
|
||||
<Text wrap="wrap" dimColor={dimColor}>
|
||||
<InlineMarkdown text={blockquoteMatch[1]} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for horizontal rules
|
||||
if (line.match(hrRegex)) {
|
||||
contentBlocks.push(
|
||||
<Box key={key} marginY={1}>
|
||||
<Text dimColor>───────────────────────────────</Text>
|
||||
</Box>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty lines
|
||||
if (line.trim() === "") {
|
||||
contentBlocks.push(<Box key={key} height={1} />);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular paragraph text with optional hanging indent for wrapped lines
|
||||
contentBlocks.push(
|
||||
<Box key={key}>
|
||||
{hangingIndent > 0 ? (
|
||||
<Transform
|
||||
transform={(ln, i) =>
|
||||
i === 0 ? ln : " ".repeat(hangingIndent) + ln
|
||||
}
|
||||
>
|
||||
<Text wrap="wrap" dimColor={dimColor}>
|
||||
<InlineMarkdown text={line} />
|
||||
</Text>
|
||||
</Transform>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor={dimColor}>
|
||||
<InlineMarkdown text={line} />
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
|
||||
// Handle unclosed code block at end of input
|
||||
if (inCodeBlock && codeBlockContent.length > 0) {
|
||||
const code = codeBlockContent.join("\n");
|
||||
contentBlocks.push(
|
||||
<Box key="unclosed-code" paddingLeft={2} marginY={1}>
|
||||
<Text color={colors.code.inline}>{code}</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
return <Box flexDirection="column">{contentBlocks}</Box>;
|
||||
};
|
||||
75
src/cli/components/ModelSelector.tsx
Normal file
75
src/cli/components/ModelSelector.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// Import useInput from vendored Ink for bracketed paste support
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useState } from "react";
|
||||
import models from "../../models.json";
|
||||
import { colors } from "./colors";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
currentModel?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
currentModel,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: ModelSelectorProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => Math.min(models.length - 1, prev + 1));
|
||||
} else if (key.return) {
|
||||
const selectedModel = models[selectedIndex];
|
||||
if (selectedModel) {
|
||||
onSelect(selectedModel.id);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Select Model (↑↓ to navigate, Enter to select, ESC to cancel)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
{models.map((model, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isCurrent = model.handle === currentModel;
|
||||
|
||||
return (
|
||||
<Box key={model.id} flexDirection="row" gap={1}>
|
||||
<Text
|
||||
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
||||
>
|
||||
{isSelected ? "›" : " "}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{model.label}
|
||||
{isCurrent && (
|
||||
<Text color={colors.selector.itemCurrent}> (current)</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text dimColor> {model.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
254
src/cli/components/PasteAwareTextInput.tsx
Normal file
254
src/cli/components/PasteAwareTextInput.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
// Paste-aware text input wrapper that:
|
||||
// 1. Detects large pastes (>5 lines or >500 chars) and replaces with placeholders
|
||||
// 2. Supports image pasting (iTerm2 inline, data URLs, file paths, macOS clipboard)
|
||||
// 3. Maintains separate display value (with placeholders) vs actual value (full content)
|
||||
// 4. Resolves placeholders on submit
|
||||
|
||||
// Import useInput from vendored Ink for bracketed paste support
|
||||
import { useInput } from "ink";
|
||||
import RawTextInput from "ink-text-input";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
translatePasteForImages,
|
||||
tryImportClipboardImageMac,
|
||||
} from "../helpers/clipboard";
|
||||
import { allocatePaste, resolvePlaceholders } from "../helpers/pasteRegistry";
|
||||
|
||||
interface PasteAwareTextInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
}
|
||||
|
||||
function countLines(text: string): number {
|
||||
return (text.match(/\r\n|\r|\n/g) || []).length + 1;
|
||||
}
|
||||
|
||||
export function PasteAwareTextInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
focus = true,
|
||||
}: PasteAwareTextInputProps) {
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const [actualValue, setActualValue] = useState(value);
|
||||
const lastPasteDetectedAtRef = useRef<number>(0);
|
||||
const suppressNextChangeRef = useRef<boolean>(false);
|
||||
const caretOffsetRef = useRef<number>((value || "").length);
|
||||
const [nudgeCursorOffset, setNudgeCursorOffset] = useState<
|
||||
number | undefined
|
||||
>(undefined);
|
||||
const TextInputAny = RawTextInput as unknown as React.ComponentType<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
}>;
|
||||
|
||||
// Sync external value changes (treat incoming value as DISPLAY value)
|
||||
useEffect(() => {
|
||||
setDisplayValue(value);
|
||||
// Recompute ACTUAL by substituting placeholders via shared registry
|
||||
const resolved = resolvePlaceholders(value);
|
||||
setActualValue(resolved);
|
||||
}, [value]);
|
||||
|
||||
// Intercept paste events and macOS fallback for image clipboard imports
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Handle bracketed paste events emitted by vendored Ink
|
||||
const isPasted = (key as unknown as { isPasted?: boolean })?.isPasted;
|
||||
if (isPasted) {
|
||||
lastPasteDetectedAtRef.current = Date.now();
|
||||
|
||||
const payload = typeof input === "string" ? input : "";
|
||||
// Translate any image payloads in the paste (OSC 1337, data URLs, file paths)
|
||||
let translated = translatePasteForImages(payload);
|
||||
// If paste event carried no text (common for image-only clipboard), try macOS import
|
||||
if ((!translated || translated.length === 0) && payload.length === 0) {
|
||||
const clip = tryImportClipboardImageMac();
|
||||
if (clip) translated = clip;
|
||||
}
|
||||
|
||||
if (translated && translated.length > 0) {
|
||||
// Insert at current caret position
|
||||
const at = Math.max(
|
||||
0,
|
||||
Math.min(caretOffsetRef.current, displayValue.length),
|
||||
);
|
||||
const isLarge = countLines(translated) > 5 || translated.length > 500;
|
||||
if (isLarge) {
|
||||
const pasteId = allocatePaste(translated);
|
||||
const placeholder = `[Pasted text #${pasteId} +${countLines(translated)} lines]`;
|
||||
const newDisplay =
|
||||
displayValue.slice(0, at) + placeholder + displayValue.slice(at);
|
||||
const newActual =
|
||||
actualValue.slice(0, at) + translated + actualValue.slice(at);
|
||||
setDisplayValue(newDisplay);
|
||||
setActualValue(newActual);
|
||||
onChange(newDisplay);
|
||||
const nextCaret = at + placeholder.length;
|
||||
setNudgeCursorOffset(nextCaret);
|
||||
caretOffsetRef.current = nextCaret;
|
||||
} else {
|
||||
const newDisplay =
|
||||
displayValue.slice(0, at) + translated + displayValue.slice(at);
|
||||
const newActual =
|
||||
actualValue.slice(0, at) + translated + actualValue.slice(at);
|
||||
setDisplayValue(newDisplay);
|
||||
setActualValue(newActual);
|
||||
onChange(newDisplay);
|
||||
const nextCaret = at + translated.length;
|
||||
setNudgeCursorOffset(nextCaret);
|
||||
caretOffsetRef.current = nextCaret;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If nothing to insert, fall through
|
||||
}
|
||||
|
||||
if (
|
||||
(key.meta && (input === "v" || input === "V")) ||
|
||||
(key.ctrl && key.shift && (input === "v" || input === "V"))
|
||||
) {
|
||||
const placeholder = tryImportClipboardImageMac();
|
||||
if (placeholder) {
|
||||
const at = Math.max(
|
||||
0,
|
||||
Math.min(caretOffsetRef.current, displayValue.length),
|
||||
);
|
||||
const newDisplay =
|
||||
displayValue.slice(0, at) + placeholder + displayValue.slice(at);
|
||||
const newActual =
|
||||
actualValue.slice(0, at) + placeholder + actualValue.slice(at);
|
||||
setDisplayValue(newDisplay);
|
||||
setActualValue(newActual);
|
||||
onChange(newDisplay);
|
||||
const nextCaret = at + placeholder.length;
|
||||
setNudgeCursorOffset(nextCaret);
|
||||
caretOffsetRef.current = nextCaret;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
// If we just handled a paste via useInput, ignore this immediate change
|
||||
if (suppressNextChangeRef.current) {
|
||||
suppressNextChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Heuristic: detect large additions that look like pastes
|
||||
const addedLen = newValue.length - displayValue.length;
|
||||
const lineDelta = countLines(newValue) - countLines(displayValue);
|
||||
const sincePasteMs = Date.now() - lastPasteDetectedAtRef.current;
|
||||
|
||||
// If we see a large addition (and it's not too soon after the last paste), treat it as a paste
|
||||
if (
|
||||
sincePasteMs > 1000 &&
|
||||
addedLen > 0 &&
|
||||
(addedLen > 500 || lineDelta > 5)
|
||||
) {
|
||||
lastPasteDetectedAtRef.current = Date.now();
|
||||
|
||||
// Compute inserted segment via longest common prefix/suffix
|
||||
const a = displayValue;
|
||||
const b = newValue;
|
||||
let lcp = 0;
|
||||
while (lcp < a.length && lcp < b.length && a[lcp] === b[lcp]) lcp++;
|
||||
let lcs = 0;
|
||||
while (
|
||||
lcs < a.length - lcp &&
|
||||
lcs < b.length - lcp &&
|
||||
a[a.length - 1 - lcs] === b[b.length - 1 - lcs]
|
||||
)
|
||||
lcs++;
|
||||
const inserted = b.slice(lcp, b.length - lcs);
|
||||
|
||||
// Translate any image payloads in the inserted text (run always for reliability)
|
||||
const translated = translatePasteForImages(inserted);
|
||||
const translatedLines = countLines(translated);
|
||||
const translatedChars = translated.length;
|
||||
|
||||
// If translated text is still large, create a placeholder
|
||||
if (translatedLines > 5 || translatedChars > 500) {
|
||||
const pasteId = allocatePaste(translated);
|
||||
const placeholder = `[Pasted text #${pasteId} +${translatedLines} lines]`;
|
||||
|
||||
const newDisplayValue =
|
||||
a.slice(0, lcp) + placeholder + a.slice(a.length - lcs);
|
||||
const newActualValue =
|
||||
actualValue.slice(0, lcp) +
|
||||
translated +
|
||||
actualValue.slice(actualValue.length - lcs);
|
||||
|
||||
setDisplayValue(newDisplayValue);
|
||||
setActualValue(newActualValue);
|
||||
onChange(newDisplayValue);
|
||||
const nextCaret = lcp + placeholder.length;
|
||||
setNudgeCursorOffset(nextCaret);
|
||||
caretOffsetRef.current = nextCaret;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, insert the translated text inline
|
||||
const newDisplayValue =
|
||||
a.slice(0, lcp) + translated + a.slice(a.length - lcs);
|
||||
const newActualValue =
|
||||
actualValue.slice(0, lcp) +
|
||||
translated +
|
||||
actualValue.slice(actualValue.length - lcs);
|
||||
|
||||
setDisplayValue(newDisplayValue);
|
||||
setActualValue(newActualValue);
|
||||
onChange(newDisplayValue);
|
||||
const nextCaret = lcp + translated.length;
|
||||
setNudgeCursorOffset(nextCaret);
|
||||
caretOffsetRef.current = nextCaret;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal typing/edits - update display and compute actual by substituting placeholders
|
||||
setDisplayValue(newValue);
|
||||
const resolved = resolvePlaceholders(newValue);
|
||||
setActualValue(resolved);
|
||||
onChange(newValue);
|
||||
// Default caret behavior on typing/appends: move to end
|
||||
caretOffsetRef.current = newValue.length;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (onSubmit) {
|
||||
// Pass the display value (with placeholders) to onSubmit
|
||||
// The parent will handle conversion to content parts and cleanup
|
||||
onSubmit(displayValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear one-shot cursor nudge after it applies
|
||||
useEffect(() => {
|
||||
if (typeof nudgeCursorOffset === "number") {
|
||||
const t = setTimeout(() => setNudgeCursorOffset(undefined), 0);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [nudgeCursorOffset]);
|
||||
|
||||
return (
|
||||
<TextInputAny
|
||||
value={displayValue}
|
||||
externalCursorOffset={nudgeCursorOffset}
|
||||
onCursorOffsetChange={(n: number) => {
|
||||
caretOffsetRef.current = n;
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
focus={focus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
146
src/cli/components/PlanModeDialog.tsx
Normal file
146
src/cli/components/PlanModeDialog.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import RawTextInput from "ink-text-input";
|
||||
import { type ComponentType, memo, useState } from "react";
|
||||
import { colors } from "./colors";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay";
|
||||
|
||||
type Props = {
|
||||
plan: string;
|
||||
onApprove: () => void;
|
||||
onApproveAndAcceptEdits: () => void;
|
||||
onKeepPlanning: (reason: string) => void;
|
||||
};
|
||||
|
||||
const OptionsRenderer = memo(
|
||||
({
|
||||
options,
|
||||
selectedOption,
|
||||
}: {
|
||||
options: Array<{ label: string }>;
|
||||
selectedOption: number;
|
||||
}) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedOption;
|
||||
const color = isSelected ? colors.approval.header : undefined;
|
||||
return (
|
||||
<Box key={option.label} flexDirection="row">
|
||||
<Text color={color}>
|
||||
{isSelected ? "❯" : " "} {index + 1}. {option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
OptionsRenderer.displayName = "OptionsRenderer";
|
||||
|
||||
export const PlanModeDialog = memo(
|
||||
({ plan, onApprove, onApproveAndAcceptEdits, onKeepPlanning }: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [isEnteringReason, setIsEnteringReason] = useState(false);
|
||||
const [denyReason, setDenyReason] = useState("");
|
||||
|
||||
const options = [
|
||||
{ label: "Yes, and auto-accept edits", action: onApproveAndAcceptEdits },
|
||||
{ label: "Yes, and manually approve edits", action: onApprove },
|
||||
{ label: "No, keep planning", action: () => {} }, // Handled via setIsEnteringReason
|
||||
];
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (isEnteringReason) {
|
||||
// When entering reason, only handle enter/escape
|
||||
if (key.return) {
|
||||
onKeepPlanning(denyReason);
|
||||
setIsEnteringReason(false);
|
||||
setDenyReason("");
|
||||
} else if (key.escape) {
|
||||
setIsEnteringReason(false);
|
||||
setDenyReason("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedOption((prev) => Math.min(options.length - 1, prev + 1));
|
||||
} else if (key.return) {
|
||||
// Check if this is the "keep planning" option (last option)
|
||||
if (selectedOption === options.length - 1) {
|
||||
setIsEnteringReason(true);
|
||||
} else {
|
||||
options[selectedOption]?.action();
|
||||
}
|
||||
} else if (key.escape) {
|
||||
setIsEnteringReason(true); // ESC also goes to denial input
|
||||
}
|
||||
});
|
||||
|
||||
// Show denial input screen if entering reason
|
||||
if (isEnteringReason) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={colors.approval.border}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold>
|
||||
Enter feedback to continue planning (ESC to cancel):
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box>
|
||||
<Text dimColor>{"> "}</Text>
|
||||
{(() => {
|
||||
const TextInputAny = RawTextInput as unknown as ComponentType<{
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
}>;
|
||||
return (
|
||||
<TextInputAny value={denyReason} onChange={setDenyReason} />
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={colors.approval.border}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color={colors.approval.header}>
|
||||
Ready to code?
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text>Here's the proposed plan:</Text>
|
||||
<Box height={1} />
|
||||
|
||||
{/* Nested box for plan content */}
|
||||
<Box borderStyle="round" paddingX={1}>
|
||||
<MarkdownDisplay text={plan} />
|
||||
</Box>
|
||||
|
||||
<Box height={1} />
|
||||
<Text>Would you like to proceed?</Text>
|
||||
<Box height={1} />
|
||||
|
||||
<OptionsRenderer options={options} selectedOption={selectedOption} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PlanModeDialog.displayName = "PlanModeDialog";
|
||||
13
src/cli/components/ReasoningMessage.tsx
Normal file
13
src/cli/components/ReasoningMessage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Text } from "ink";
|
||||
import { memo } from "react";
|
||||
|
||||
type ReasoningLine = {
|
||||
kind: "reasoning";
|
||||
id: string;
|
||||
text: string;
|
||||
phase: "streaming" | "finished";
|
||||
};
|
||||
|
||||
export const ReasoningMessage = memo(({ line }: { line: ReasoningLine }) => {
|
||||
return <Text dimColor>{line.text}</Text>;
|
||||
});
|
||||
64
src/cli/components/ReasoningMessageRich.tsx
Normal file
64
src/cli/components/ReasoningMessageRich.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
|
||||
// Helper function to normalize text - copied from old codebase
|
||||
const normalize = (s: string) =>
|
||||
s
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/^\n+|\n+$/g, "");
|
||||
|
||||
type ReasoningLine = {
|
||||
kind: "reasoning";
|
||||
id: string;
|
||||
text: string;
|
||||
phase: "streaming" | "finished";
|
||||
};
|
||||
|
||||
/**
|
||||
* ReasoningMessageRich - Rich formatting version with special reasoning layout
|
||||
* This is a direct port from the old letta-code codebase to preserve the exact styling
|
||||
*
|
||||
* Features:
|
||||
* - Header row with "✻" symbol and "Thinking…" text
|
||||
* - Reasoning content indented with 2 spaces
|
||||
* - Full markdown rendering with dimmed colors
|
||||
* - Proper text normalization
|
||||
*/
|
||||
export const ReasoningMessage = memo(({ line }: { line: ReasoningLine }) => {
|
||||
const columns =
|
||||
typeof process !== "undefined" &&
|
||||
process.stdout &&
|
||||
"columns" in process.stdout
|
||||
? ((process.stdout as { columns?: number }).columns ?? 80)
|
||||
: 80;
|
||||
const contentWidth = Math.max(0, columns - 2);
|
||||
|
||||
const normalizedText = normalize(line.text);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text dimColor>✻</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text dimColor>Thinking…</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<MarkdownDisplay text={normalizedText} dimColor={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
ReasoningMessage.displayName = "ReasoningMessage";
|
||||
34
src/cli/components/ShimmerText.tsx
Normal file
34
src/cli/components/ShimmerText.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import chalk from "chalk";
|
||||
import { Text } from "ink";
|
||||
import type React from "react";
|
||||
import { colors } from "./colors.js";
|
||||
|
||||
interface ShimmerTextProps {
|
||||
color?: string;
|
||||
message: string;
|
||||
shimmerOffset: number;
|
||||
}
|
||||
|
||||
export const ShimmerText: React.FC<ShimmerTextProps> = ({
|
||||
color = colors.status.processing,
|
||||
message,
|
||||
shimmerOffset,
|
||||
}) => {
|
||||
const fullText = `${message}…`;
|
||||
|
||||
// Create the shimmer effect - simple 3-char highlight
|
||||
const shimmerText = fullText
|
||||
.split("")
|
||||
.map((char, i) => {
|
||||
// Check if this character is within the 3-char shimmer window
|
||||
const isInShimmer = i >= shimmerOffset && i < shimmerOffset + 3;
|
||||
|
||||
if (isInShimmer) {
|
||||
return chalk.hex(colors.status.processingShimmer)(char);
|
||||
}
|
||||
return chalk.hex(color)(char);
|
||||
})
|
||||
.join("");
|
||||
|
||||
return <Text>{shimmerText}</Text>;
|
||||
};
|
||||
59
src/cli/components/TodoRenderer.tsx
Normal file
59
src/cli/components/TodoRenderer.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Box, Text } from "ink";
|
||||
import type React from "react";
|
||||
import { colors } from "./colors.js";
|
||||
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
id: string;
|
||||
priority?: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
interface TodoRendererProps {
|
||||
todos: TodoItem[];
|
||||
}
|
||||
|
||||
export const TodoRenderer: React.FC<TodoRendererProps> = ({ todos }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{todos.map((todo, index) => {
|
||||
const checkbox = todo.status === "completed" ? "☒" : "☐";
|
||||
|
||||
// Format based on status
|
||||
let textElement: React.ReactNode;
|
||||
if (todo.status === "completed") {
|
||||
// Green with strikethrough
|
||||
textElement = (
|
||||
<Text color={colors.todo.completed} strikethrough>
|
||||
{checkbox} {todo.content}
|
||||
</Text>
|
||||
);
|
||||
} else if (todo.status === "in_progress") {
|
||||
// Blue bold (like code formatting)
|
||||
textElement = (
|
||||
<Text color={colors.todo.inProgress} bold>
|
||||
{checkbox} {todo.content}
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
// Plain text for pending
|
||||
textElement = (
|
||||
<Text>
|
||||
{checkbox} {todo.content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// First item gets the prefix, others get indentation
|
||||
const prefix = index === 0 ? " ⎿ " : " ";
|
||||
|
||||
return (
|
||||
<Box key={todo.id || index}>
|
||||
<Text>{prefix}</Text>
|
||||
{textElement}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
60
src/cli/components/ToolCallMessage.tsx
Normal file
60
src/cli/components/ToolCallMessage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
|
||||
type ToolCallLine = {
|
||||
kind: "tool_call";
|
||||
id: string;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
argsText?: string;
|
||||
resultText?: string;
|
||||
resultOk?: boolean;
|
||||
phase: "streaming" | "ready" | "running" | "finished";
|
||||
};
|
||||
|
||||
export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
const name = line.name ?? "?";
|
||||
const args = line.argsText ?? "...";
|
||||
|
||||
let dotColor: string | undefined;
|
||||
if (line.phase === "streaming") {
|
||||
dotColor = "gray";
|
||||
} else if (line.phase === "running") {
|
||||
dotColor = "yellow";
|
||||
} else if (line.phase === "finished") {
|
||||
dotColor = line.resultOk === false ? "red" : "green";
|
||||
}
|
||||
|
||||
// Parse and clean up result text for display
|
||||
const displayText = (() => {
|
||||
if (!line.resultText) return undefined;
|
||||
|
||||
// Try to parse JSON and extract error message for cleaner display
|
||||
try {
|
||||
const parsed = JSON.parse(line.resultText);
|
||||
if (parsed.error && typeof parsed.error === "string") {
|
||||
return parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON or parse failed, use raw text
|
||||
}
|
||||
|
||||
// Truncate long results
|
||||
return line.resultText.length > 80
|
||||
? `${line.resultText.slice(0, 80)}...`
|
||||
: line.resultText;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text color={dotColor}>•</Text> {name}({args})
|
||||
</Text>
|
||||
{displayText && (
|
||||
<Text>
|
||||
└ {line.resultOk === false ? "Error" : "Success"}: {displayText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
242
src/cli/components/ToolCallMessageRich.tsx
Normal file
242
src/cli/components/ToolCallMessageRich.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { clipToolReturn } from "../../tools/manager.js";
|
||||
import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js";
|
||||
import { colors } from "./colors.js";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
import { TodoRenderer } from "./TodoRenderer.js";
|
||||
|
||||
type ToolCallLine = {
|
||||
kind: "tool_call";
|
||||
id: string;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
argsText?: string;
|
||||
resultText?: string;
|
||||
resultOk?: boolean;
|
||||
phase: "streaming" | "ready" | "running" | "finished";
|
||||
};
|
||||
|
||||
// BlinkDot component copied verbatim from old codebase
|
||||
const BlinkDot: React.FC<{ color?: string }> = ({
|
||||
color = colors.tool.pending,
|
||||
}) => {
|
||||
const [on, setOn] = useState(true);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setOn((v) => !v), 400);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
// Visible = colored dot; Off = space (keeps width/alignment)
|
||||
return <Text color={color}>{on ? "●" : " "}</Text>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ToolCallMessageRich - Rich formatting version with old layout logic
|
||||
* This preserves the exact wrapping and spacing logic from the old codebase
|
||||
*
|
||||
* Features:
|
||||
* - Two-column layout for tool calls (2 chars for dot)
|
||||
* - Smart wrapping that keeps function name and args together when possible
|
||||
* - Blinking dots for pending/running states
|
||||
* - Result shown with ⎿ prefix underneath
|
||||
*/
|
||||
export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
const columns =
|
||||
typeof process !== "undefined" &&
|
||||
process.stdout &&
|
||||
"columns" in process.stdout
|
||||
? ((process.stdout as { columns?: number }).columns ?? 80)
|
||||
: 80;
|
||||
|
||||
// Parse and format the tool call
|
||||
const rawName = line.name ?? "?";
|
||||
const argsText = line.argsText ?? "...";
|
||||
|
||||
// Apply tool name remapping from old codebase
|
||||
let displayName = rawName;
|
||||
if (displayName === "write") displayName = "Write";
|
||||
else if (displayName === "edit" || displayName === "multi_edit")
|
||||
displayName = "Edit";
|
||||
else if (displayName === "read") displayName = "Read";
|
||||
else if (displayName === "bash") displayName = "Bash";
|
||||
else if (displayName === "grep") displayName = "Grep";
|
||||
else if (displayName === "glob") displayName = "Glob";
|
||||
else if (displayName === "ls") displayName = "LS";
|
||||
else if (displayName === "todo_write") displayName = "Update Todos";
|
||||
else if (displayName === "ExitPlanMode") displayName = "Planning";
|
||||
|
||||
// Format arguments for display using the old formatting logic
|
||||
const formatted = formatArgsDisplay(argsText);
|
||||
const args = `(${formatted.display})`;
|
||||
|
||||
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
|
||||
|
||||
// If name exceeds available width, fall back to simple wrapped rendering
|
||||
const fallback = displayName.length >= rightWidth;
|
||||
|
||||
// Determine dot state based on phase
|
||||
const getDotElement = () => {
|
||||
switch (line.phase) {
|
||||
case "streaming":
|
||||
return <Text color={colors.tool.streaming}>●</Text>;
|
||||
case "ready":
|
||||
return <BlinkDot color={colors.tool.pending} />;
|
||||
case "running":
|
||||
return <BlinkDot color={colors.tool.running} />;
|
||||
case "finished":
|
||||
if (line.resultOk === false) {
|
||||
return <Text color={colors.tool.error}>●</Text>;
|
||||
}
|
||||
return <Text color={colors.tool.completed}>●</Text>;
|
||||
default:
|
||||
return <Text>●</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
// Format result for display
|
||||
const getResultElement = () => {
|
||||
if (!line.resultText) return null;
|
||||
|
||||
const prefix = ` ⎿ `; // Match old format: 2 spaces, glyph, 2 spaces
|
||||
const prefixWidth = 5; // Total width of prefix
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
|
||||
// Special cases from old ToolReturnBlock (check before truncation)
|
||||
if (line.resultText === "Running...") {
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text dimColor>Running...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (line.resultText === "Interrupted by user") {
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text color={colors.status.interrupt}>Interrupted by user</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Truncate the result text for display (UI only, API gets full response)
|
||||
const displayResultText = clipToolReturn(line.resultText);
|
||||
|
||||
// Check if this is a todo_write tool with successful result
|
||||
// Check both the raw name and the display name since it might be "TodoWrite"
|
||||
const isTodoTool =
|
||||
rawName === "todo_write" ||
|
||||
rawName === "TodoWrite" ||
|
||||
displayName === "Update Todos";
|
||||
if (isTodoTool && line.resultOk !== false && line.argsText) {
|
||||
try {
|
||||
const parsedArgs = JSON.parse(line.argsText);
|
||||
if (parsedArgs.todos && Array.isArray(parsedArgs.todos)) {
|
||||
// Helper to check if a value is a record
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null;
|
||||
|
||||
// Convert todos to safe format for TodoRenderer
|
||||
const safeTodos = parsedArgs.todos.map((t: unknown, i: number) => {
|
||||
const rec = isRecord(t) ? t : {};
|
||||
const status: "pending" | "in_progress" | "completed" =
|
||||
rec.status === "completed"
|
||||
? "completed"
|
||||
: rec.status === "in_progress"
|
||||
? "in_progress"
|
||||
: "pending";
|
||||
const id = typeof rec.id === "string" ? rec.id : String(i);
|
||||
const content =
|
||||
typeof rec.content === "string" ? rec.content : JSON.stringify(t);
|
||||
const priority: "high" | "medium" | "low" | undefined =
|
||||
rec.priority === "high"
|
||||
? "high"
|
||||
: rec.priority === "medium"
|
||||
? "medium"
|
||||
: rec.priority === "low"
|
||||
? "low"
|
||||
: undefined;
|
||||
return { content, status, id, priority };
|
||||
});
|
||||
|
||||
// Return TodoRenderer directly - it has its own prefix
|
||||
return <TodoRenderer todos={safeTodos} />;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fall through to regular handling
|
||||
}
|
||||
}
|
||||
|
||||
// Regular result handling
|
||||
const isError = line.resultOk === false;
|
||||
|
||||
// Try to parse JSON for cleaner error display
|
||||
let displayText = displayResultText;
|
||||
try {
|
||||
const parsed = JSON.parse(displayResultText);
|
||||
if (parsed.error && typeof parsed.error === "string") {
|
||||
displayText = parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, use raw text
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
{isError ? (
|
||||
<Text color={colors.status.error}>{displayText}</Text>
|
||||
) : (
|
||||
<MarkdownDisplay text={displayText} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Tool call with exact wrapping logic from old codebase */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
{getDotElement()}
|
||||
<Text></Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={rightWidth}>
|
||||
{fallback ? (
|
||||
<Text wrap="wrap">{`${displayName}${args}`}</Text>
|
||||
) : (
|
||||
<Box flexDirection="row">
|
||||
<Text>{displayName}</Text>
|
||||
{args ? (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
width={Math.max(0, rightWidth - displayName.length)}
|
||||
>
|
||||
<Text wrap="wrap">{args}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tool result (if present) */}
|
||||
{getResultElement()}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
ToolCallMessage.displayName = "ToolCallMessage";
|
||||
24
src/cli/components/Transcript.tsx
Normal file
24
src/cli/components/Transcript.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Box, Text } from "ink";
|
||||
|
||||
export type Row =
|
||||
| { kind: "user"; text: string; id?: string }
|
||||
| { kind: "assistant"; text: string; id?: string }
|
||||
| { kind: "reasoning"; text: string; id?: string };
|
||||
|
||||
export function Transcript({ rows }: { rows: Row[] }) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rows.map((r, i) => {
|
||||
if (r.kind === "user")
|
||||
return <Text key={r.id ?? i}>{`> ${r.text}`}</Text>;
|
||||
if (r.kind === "assistant")
|
||||
return <Text key={r.id ?? i}>{r.text}</Text>;
|
||||
return (
|
||||
<Text key={r.id ?? i} dimColor>
|
||||
{r.text}
|
||||
</Text>
|
||||
); // reasoning
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
12
src/cli/components/UserMessage.tsx
Normal file
12
src/cli/components/UserMessage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Text } from "ink";
|
||||
import { memo } from "react";
|
||||
|
||||
type UserLine = {
|
||||
kind: "user";
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const UserMessage = memo(({ line }: { line: UserLine }) => {
|
||||
return <Text>{`> ${line.text}`}</Text>;
|
||||
});
|
||||
41
src/cli/components/UserMessageRich.tsx
Normal file
41
src/cli/components/UserMessageRich.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
|
||||
type UserLine = {
|
||||
kind: "user";
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* UserMessageRich - Rich formatting version with two-column layout
|
||||
* This is a direct port from the old letta-code codebase to preserve the exact styling
|
||||
*
|
||||
* Features:
|
||||
* - Left column (2 chars wide) with "> " prompt indicator
|
||||
* - Right column with wrapped text content
|
||||
* - Full markdown rendering support
|
||||
*/
|
||||
export const UserMessage = memo(({ line }: { line: UserLine }) => {
|
||||
const columns =
|
||||
typeof process !== "undefined" &&
|
||||
process.stdout &&
|
||||
"columns" in process.stdout
|
||||
? ((process.stdout as { columns?: number }).columns ?? 80)
|
||||
: 80;
|
||||
const contentWidth = Math.max(0, columns - 2);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text>{">"} </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<MarkdownDisplay text={line.text} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
UserMessage.displayName = "UserMessage";
|
||||
53
src/cli/components/WelcomeScreen.tsx
Normal file
53
src/cli/components/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type LoadingState =
|
||||
| "assembling"
|
||||
| "upserting"
|
||||
| "initializing"
|
||||
| "checking"
|
||||
| "ready";
|
||||
|
||||
export function WelcomeScreen({
|
||||
loadingState,
|
||||
continueSession,
|
||||
agentId,
|
||||
}: {
|
||||
loadingState: LoadingState;
|
||||
continueSession?: boolean;
|
||||
agentId?: string;
|
||||
}) {
|
||||
const getInitializingMessage = () => {
|
||||
if (continueSession && agentId) {
|
||||
return `Resuming agent ${agentId}...`;
|
||||
}
|
||||
return "Creating agent...";
|
||||
};
|
||||
|
||||
const getReadyMessage = () => {
|
||||
if (continueSession && agentId) {
|
||||
return `Resumed agent (${agentId}). Ready to go!`;
|
||||
}
|
||||
if (agentId) {
|
||||
return `Created a new agent (${agentId}). Ready to go!`;
|
||||
}
|
||||
return "Ready to go!";
|
||||
};
|
||||
|
||||
const stateMessages: Record<LoadingState, string> = {
|
||||
assembling: "Assembling tools...",
|
||||
upserting: "Upserting tools...",
|
||||
initializing: getInitializingMessage(),
|
||||
checking: "Checking for pending approvals...",
|
||||
ready: getReadyMessage(),
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={colors.welcome.accent}>
|
||||
Letta Code
|
||||
</Text>
|
||||
<Text dimColor>{stateMessages[loadingState]}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
148
src/cli/components/colors.ts
Normal file
148
src/cli/components/colors.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Letta Code Color System
|
||||
*
|
||||
* This file defines all colors used in the application.
|
||||
* No colors should be hardcoded in components - all should reference this file.
|
||||
*/
|
||||
|
||||
// Brand colors (dark mode)
|
||||
export const brandColors = {
|
||||
orange: "#FF5533", // dark orange
|
||||
blue: "#0707AC", // dark blue
|
||||
// text colors
|
||||
primaryAccent: "#8C8CF9", // lighter blue
|
||||
primaryAccentLight: "#BEBEEE", // even lighter blue
|
||||
textMain: "#DEE1E4", // white
|
||||
textSecondary: "#A5A8AB", // light grey
|
||||
textDisabled: "#46484A", // dark grey
|
||||
// status colors
|
||||
statusSuccess: "#64CF64", // green
|
||||
statusWarning: "FEE19C", // yellow
|
||||
statusError: "#F1689F", // red
|
||||
} as const;
|
||||
|
||||
// Brand colors (light mode)
|
||||
export const brandColorsLight = {
|
||||
orange: "#FF5533", // dark orange
|
||||
blue: "#0707AC", // dark blue
|
||||
// text colors
|
||||
primaryAccent: "#3939BD", // lighter blue
|
||||
primaryAccentLight: "#A9A9DE", // even lighter blue
|
||||
textMain: "#202020", // white
|
||||
textSecondary: "#797B7D", // light grey
|
||||
textDisabled: "#A5A8AB", // dark grey
|
||||
// status colors
|
||||
statusSuccess: "#28A428", // green
|
||||
statusWarning: "#B98813", // yellow
|
||||
statusError: "#BA024C", // red
|
||||
} as const;
|
||||
|
||||
// Semantic color system
|
||||
export const colors = {
|
||||
// Welcome screen
|
||||
welcome: {
|
||||
border: brandColors.primaryAccent,
|
||||
accent: brandColors.primaryAccent,
|
||||
},
|
||||
|
||||
// Selector boxes (model, agent, generic select)
|
||||
selector: {
|
||||
border: brandColors.primaryAccentLight,
|
||||
title: brandColors.primaryAccentLight,
|
||||
itemHighlighted: brandColors.primaryAccent,
|
||||
itemCurrent: brandColors.statusSuccess, // for "(current)" label
|
||||
},
|
||||
|
||||
// Command autocomplete and command messages
|
||||
command: {
|
||||
selected: brandColors.primaryAccent,
|
||||
inactive: brandColors.textDisabled, // uses dimColor prop
|
||||
border: brandColors.textDisabled,
|
||||
running: brandColors.statusWarning,
|
||||
error: brandColors.statusError,
|
||||
},
|
||||
|
||||
// Approval/HITL screens
|
||||
approval: {
|
||||
border: brandColors.primaryAccentLight,
|
||||
header: brandColors.primaryAccent,
|
||||
},
|
||||
|
||||
// Code and markdown elements
|
||||
code: {
|
||||
inline: brandColors.statusSuccess,
|
||||
},
|
||||
|
||||
link: {
|
||||
text: brandColors.primaryAccent,
|
||||
url: brandColors.primaryAccent,
|
||||
},
|
||||
|
||||
heading: {
|
||||
primary: brandColors.primaryAccent,
|
||||
secondary: brandColors.blue,
|
||||
},
|
||||
|
||||
// Status indicators
|
||||
status: {
|
||||
error: brandColors.statusError,
|
||||
success: brandColors.statusSuccess,
|
||||
interrupt: brandColors.statusError,
|
||||
processing: brandColors.primaryAccent, // base text color
|
||||
processingShimmer: brandColors.primaryAccentLight, // shimmer highlight
|
||||
},
|
||||
|
||||
// Tool calls
|
||||
tool: {
|
||||
pending: brandColors.textSecondary, // blinking dot (ready/waiting for approval)
|
||||
completed: brandColors.statusSuccess, // solid green dot (finished successfully)
|
||||
streaming: brandColors.textDisabled, // solid gray dot (streaming/in progress)
|
||||
running: brandColors.statusWarning, // blinking yellow dot (executing)
|
||||
error: brandColors.statusError, // solid red dot (failed)
|
||||
},
|
||||
|
||||
// Input box
|
||||
input: {
|
||||
border: brandColors.textDisabled,
|
||||
prompt: brandColors.textMain,
|
||||
},
|
||||
|
||||
// Todo list
|
||||
todo: {
|
||||
completed: brandColors.blue,
|
||||
inProgress: brandColors.primaryAccent,
|
||||
},
|
||||
|
||||
// Info/modal views
|
||||
info: {
|
||||
border: brandColors.primaryAccent,
|
||||
prompt: "blue",
|
||||
},
|
||||
|
||||
// Diff rendering
|
||||
diff: {
|
||||
addedLineBg: "#1a4d1a",
|
||||
addedWordBg: "#2d7a2d",
|
||||
removedLineBg: "#4d1a1a",
|
||||
removedWordBg: "#7a2d2d",
|
||||
contextLineBg: undefined,
|
||||
textOnDark: "white",
|
||||
textOnHighlight: "black",
|
||||
symbolAdd: "green",
|
||||
symbolRemove: "red",
|
||||
symbolContext: undefined,
|
||||
},
|
||||
|
||||
// Error display
|
||||
error: {
|
||||
border: "red",
|
||||
text: "red",
|
||||
},
|
||||
|
||||
// Generic text colors (used with dimColor prop or general text)
|
||||
text: {
|
||||
normal: "white",
|
||||
dim: "gray",
|
||||
bold: "white",
|
||||
},
|
||||
} as const;
|
||||
Reference in New Issue
Block a user