fix: resize input (#6)

This commit is contained in:
Charles Packer
2025-10-25 13:48:23 -07:00
committed by GitHub
parent da2c50cbeb
commit 5bb31db14f
10 changed files with 121 additions and 51 deletions

View File

@@ -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 <Static> 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 <Static>
// 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 <Static>
// 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 (
<Box flexDirection="column" gap={1}>
<Static items={staticItems} style={{ flexDirection: "column" }}>
<Static
key={staticRenderEpoch}
items={staticItems}
style={{ flexDirection: "column" }}
>
{(item: StaticItem, index: number) => (
<Box key={item.id} marginTop={index > 0 ? 1 : 0}>
{item.kind === "welcome" ? (

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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 ?? "?";

View File

@@ -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 (

View File

@@ -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<Listener>();
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;
}

View File

@@ -50,11 +50,17 @@ type ToolRegistry = Map<string, ToolDefinition>;
// 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();