feat: letta code

This commit is contained in:
cpacker
2025-10-24 21:19:24 -07:00
commit 70ac76040d
139 changed files with 15340 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>;
});

View 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";

View 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";

View 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>
);
}

View 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>
);
}

View 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>;
});

View 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.

View 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>
);
}

View 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>
);
}

View 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>;
};

View 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>
);
}

View 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}
/>
);
}

View 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";

View 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>;
});

View 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";

View 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>;
};

View 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>
);
};

View 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>
);
});

View 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";

View 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>
);
}

View 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>;
});

View 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";

View 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>
);
}

View 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;