diff --git a/src/cli/App.tsx b/src/cli/App.tsx index e50fea8..7cce10f 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -44,6 +44,9 @@ import { import { safeJsonParseOr } from "./helpers/safeJsonParse"; import { type ApprovalRequest, drainStream } from "./helpers/stream"; import { getRandomThinkingMessage } from "./helpers/thinkingMessages"; +import { useTerminalWidth } from "./hooks/useTerminalWidth"; + +const CLEAR_SCREEN_AND_HOME = "\u001B[2J\u001B[H"; // tiny helper for unique ids (avoid overwriting prior user lines) function uid(prefix: string) { @@ -61,7 +64,7 @@ function getPlanModeReminder(): string { return PLAN_MODE_REMINDER; } -// items that we push into that are not part of the live transcript +// Items that have finished rendering and no longer change type StaticItem = | { kind: "welcome"; @@ -134,7 +137,26 @@ export default function App({ // Guard to append welcome snapshot only once const welcomeCommittedRef = useRef(false); - // Commit immutable/finished lines into + // Track terminal shrink events to refresh static output (prevents wrapped leftovers) + const columns = useTerminalWidth(); + const prevColumnsRef = useRef(columns); + const [staticRenderEpoch, setStaticRenderEpoch] = useState(0); + useEffect(() => { + const prev = prevColumnsRef.current; + if ( + columns < prev && + typeof process !== "undefined" && + process.stdout && + "write" in process.stdout && + process.stdout.isTTY + ) { + process.stdout.write(CLEAR_SCREEN_AND_HOME); + setStaticRenderEpoch((epoch) => epoch + 1); + } + prevColumnsRef.current = columns; + }, [columns]); + + // Commit immutable/finished lines into the historical log const commitEligibleLines = useCallback((b: Buffers) => { const newlyCommitted: StaticItem[] = []; // console.log(`[COMMIT] Checking ${b.order.length} lines for commit eligibility`); @@ -254,7 +276,7 @@ export default function App({ ) { // Set flag FIRST to prevent double-execution in strict mode hasBackfilledRef.current = true; - // Append welcome snapshot FIRST so it appears above history in + // Append welcome snapshot FIRST so it appears above history if (!welcomeCommittedRef.current) { welcomeCommittedRef.current = true; setStaticItems((prev) => [ @@ -973,7 +995,11 @@ export default function App({ return ( - + {(item: StaticItem, index: number) => ( 0 ? 1 : 0}> {item.kind === "welcome" ? ( diff --git a/src/cli/components/AdvancedDiffRenderer.tsx b/src/cli/components/AdvancedDiffRenderer.tsx index 8021bb7..aada554 100644 --- a/src/cli/components/AdvancedDiffRenderer.tsx +++ b/src/cli/components/AdvancedDiffRenderer.tsx @@ -7,6 +7,7 @@ import { type AdvancedDiffSuccess, computeAdvancedDiff, } from "../helpers/diff"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { EditRenderer, MultiEditRenderer, WriteRenderer } from "./DiffRenderer"; @@ -196,6 +197,9 @@ function Line({ export function AdvancedDiffRenderer( props: Props & { precomputed?: AdvancedDiffSuccess }, ) { + // Must call hooks at top level before any early returns + const columns = useTerminalWidth(); + const result = useMemo(() => { if (props.precomputed) return props.precomputed; if (props.kind === "write") { @@ -350,12 +354,6 @@ export function AdvancedDiffRenderer( : `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 ( diff --git a/src/cli/components/AssistantMessageRich.tsx b/src/cli/components/AssistantMessageRich.tsx index c5f9c12..6d81013 100644 --- a/src/cli/components/AssistantMessageRich.tsx +++ b/src/cli/components/AssistantMessageRich.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { memo } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { MarkdownDisplay } from "./MarkdownDisplay.js"; // Helper function to normalize text - copied from old codebase @@ -28,12 +29,7 @@ type AssistantLine = { * - 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 columns = useTerminalWidth(); const contentWidth = Math.max(0, columns - 2); const normalizedText = normalize(line.text); diff --git a/src/cli/components/CommandMessage.tsx b/src/cli/components/CommandMessage.tsx index 4c197f5..cd83a03 100644 --- a/src/cli/components/CommandMessage.tsx +++ b/src/cli/components/CommandMessage.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { memo, useEffect, useState } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors.js"; import { MarkdownDisplay } from "./MarkdownDisplay.js"; @@ -34,13 +35,7 @@ const BlinkDot: React.FC<{ color?: string }> = ({ color = "yellow" }) => { * - 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 columns = useTerminalWidth(); const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols // Determine dot state based on phase and success diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 0612d29..8fd9c5f 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -5,6 +5,7 @@ import type { ComponentType } from "react"; import { useEffect, useRef, useState } from "react"; import type { PermissionMode } from "../../permissions/mode"; import { permissionMode } from "../../permissions/mode"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { CommandPreview } from "./CommandPreview"; import { colors } from "./colors"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; @@ -55,13 +56,8 @@ export function Input({ // 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; + // Terminal width (reactive to window resizing) + const columns = useTerminalWidth(); const contentWidth = Math.max(0, columns - 2); // Handle escape key for double-escape-to-clear diff --git a/src/cli/components/ReasoningMessageRich.tsx b/src/cli/components/ReasoningMessageRich.tsx index 4653706..eb5e6da 100644 --- a/src/cli/components/ReasoningMessageRich.tsx +++ b/src/cli/components/ReasoningMessageRich.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { memo } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { MarkdownDisplay } from "./MarkdownDisplay.js"; // Helper function to normalize text - copied from old codebase @@ -28,12 +29,7 @@ type ReasoningLine = { * - 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 columns = useTerminalWidth(); const contentWidth = Math.max(0, columns - 2); const normalizedText = normalize(line.text); diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index b2516f6..c332fa5 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -2,6 +2,7 @@ import { Box, Text } from "ink"; import { memo, useEffect, useState } from "react"; import { clipToolReturn } from "../../tools/manager.js"; import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors.js"; import { MarkdownDisplay } from "./MarkdownDisplay.js"; import { TodoRenderer } from "./TodoRenderer.js"; @@ -41,12 +42,7 @@ const BlinkDot: React.FC<{ color?: string }> = ({ * - 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; + const columns = useTerminalWidth(); // Parse and format the tool call const rawName = line.name ?? "?"; diff --git a/src/cli/components/UserMessageRich.tsx b/src/cli/components/UserMessageRich.tsx index bb148b4..8e80d83 100644 --- a/src/cli/components/UserMessageRich.tsx +++ b/src/cli/components/UserMessageRich.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { memo } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { MarkdownDisplay } from "./MarkdownDisplay.js"; type UserLine = { @@ -18,12 +19,7 @@ type UserLine = { * - 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 columns = useTerminalWidth(); const contentWidth = Math.max(0, columns - 2); return ( diff --git a/src/cli/hooks/useTerminalWidth.ts b/src/cli/hooks/useTerminalWidth.ts new file mode 100644 index 0000000..993bdba --- /dev/null +++ b/src/cli/hooks/useTerminalWidth.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; + +const getStdout = () => { + if (typeof process === "undefined") return undefined; + const stdout = process.stdout as NodeJS.WriteStream | undefined; + return stdout && typeof stdout.on === "function" ? stdout : undefined; +}; + +const getTerminalWidth = () => getStdout()?.columns ?? 80; + +type Listener = (columns: number) => void; + +const listeners = new Set(); +let resizeHandlerRegistered = false; +let trackedColumns = getTerminalWidth(); + +const resizeHandler = () => { + const nextColumns = getTerminalWidth(); + if (nextColumns === trackedColumns) { + return; + } + trackedColumns = nextColumns; + for (const listener of listeners) { + listener(nextColumns); + } +}; + +const ensureResizeHandler = () => { + if (resizeHandlerRegistered) return; + const stdout = getStdout(); + if (!stdout) return; + stdout.on("resize", resizeHandler); + resizeHandlerRegistered = true; +}; + +const removeResizeHandlerIfIdle = () => { + if (!resizeHandlerRegistered || listeners.size > 0) return; + const stdout = getStdout(); + if (!stdout) return; + stdout.off("resize", resizeHandler); + resizeHandlerRegistered = false; +}; + +/** + * Hook to get terminal width and reactively update on resize + * Uses a shared resize listener to avoid exceeding WriteStream listener limits. + */ +export function useTerminalWidth(): number { + const [columns, setColumns] = useState(trackedColumns); + + useEffect(() => { + ensureResizeHandler(); + const listener: Listener = (value) => { + setColumns(value); + }; + listeners.add(listener); + + return () => { + listeners.delete(listener); + removeResizeHandlerIfIdle(); + }; + }, []); + + return columns; +} diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 37a4fdc..3f01bc1 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -50,11 +50,17 @@ type ToolRegistry = Map; // Use globalThis to ensure singleton across bundle // This prevents Bun's bundler from creating duplicate instances of the registry const REGISTRY_KEY = Symbol.for("@letta/toolRegistry"); + +type GlobalWithRegistry = typeof globalThis & { + [key: symbol]: ToolRegistry; +}; + function getRegistry(): ToolRegistry { - if (!(globalThis as any)[REGISTRY_KEY]) { - (globalThis as any)[REGISTRY_KEY] = new Map(); + const global = globalThis as GlobalWithRegistry; + if (!global[REGISTRY_KEY]) { + global[REGISTRY_KEY] = new Map(); } - return (globalThis as any)[REGISTRY_KEY]; + return global[REGISTRY_KEY]; } const toolRegistry = getRegistry();