feat: streaming output for bash commands (#516)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import type { StreamingState } from "../helpers/accumulator";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { BlinkDot } from "./BlinkDot.js";
|
||||
import { CollapsedOutputDisplay } from "./CollapsedOutputDisplay";
|
||||
import { colors } from "./colors.js";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
import { StreamingOutputDisplay } from "./StreamingOutputDisplay";
|
||||
|
||||
type BashCommandLine = {
|
||||
kind: "bash_command";
|
||||
@@ -12,6 +15,7 @@ type BashCommandLine = {
|
||||
output: string;
|
||||
phase?: "running" | "finished";
|
||||
success?: boolean;
|
||||
streaming?: StreamingState;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -54,8 +58,18 @@ export const BashCommandMessage = memo(
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Command output (if present) */}
|
||||
{line.output && (
|
||||
{/* Streaming output during execution */}
|
||||
{line.phase === "running" && line.streaming && (
|
||||
<StreamingOutputDisplay streaming={line.streaming} />
|
||||
)}
|
||||
|
||||
{/* Collapsed output after completion */}
|
||||
{line.phase === "finished" && line.output && (
|
||||
<CollapsedOutputDisplay output={line.output} />
|
||||
)}
|
||||
|
||||
{/* Fallback: show output when phase is undefined (legacy bash commands before streaming) */}
|
||||
{!line.phase && line.output && (
|
||||
<Box flexDirection="row">
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text>{" ⎿ "}</Text>
|
||||
|
||||
57
src/cli/components/CollapsedOutputDisplay.tsx
Normal file
57
src/cli/components/CollapsedOutputDisplay.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
|
||||
const COLLAPSED_LINES = 3;
|
||||
|
||||
interface CollapsedOutputDisplayProps {
|
||||
output: string; // Full output from completion
|
||||
}
|
||||
|
||||
/**
|
||||
* Display component for bash output after completion.
|
||||
* Shows first 3 lines with count of hidden lines.
|
||||
* Note: expand/collapse (ctrl+o) is deferred to a future PR.
|
||||
*/
|
||||
export const CollapsedOutputDisplay = memo(
|
||||
({ output }: CollapsedOutputDisplayProps) => {
|
||||
// Keep empty lines for accurate display (don't filter them out)
|
||||
const lines = output.split("\n");
|
||||
// Remove trailing empty line from final newline
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleLines = lines.slice(0, COLLAPSED_LINES);
|
||||
const hiddenCount = Math.max(0, lines.length - COLLAPSED_LINES);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* L-bracket on first line - matches ToolCallMessageRich format " ⎿ " */}
|
||||
<Box>
|
||||
<Text>{" ⎿ "}</Text>
|
||||
<Text>{visibleLines[0]}</Text>
|
||||
</Box>
|
||||
{/* Remaining visible lines with indent (5 spaces to align with content after bracket) */}
|
||||
{visibleLines.slice(1).map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Lines are positional output, stable order within render
|
||||
<Text key={i}>
|
||||
{" "}
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
{/* Hidden count hint */}
|
||||
{hiddenCount > 0 && (
|
||||
<Text dimColor>
|
||||
{" "}… +{hiddenCount} lines
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CollapsedOutputDisplay.displayName = "CollapsedOutputDisplay";
|
||||
67
src/cli/components/StreamingOutputDisplay.tsx
Normal file
67
src/cli/components/StreamingOutputDisplay.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import type { StreamingState } from "../helpers/accumulator";
|
||||
|
||||
interface StreamingOutputDisplayProps {
|
||||
streaming: StreamingState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display component for streaming bash output during execution.
|
||||
* Shows a rolling window of the last 5 lines with elapsed time.
|
||||
*/
|
||||
export const StreamingOutputDisplay = memo(
|
||||
({ streaming }: StreamingOutputDisplayProps) => {
|
||||
// Force re-render every second for elapsed timer
|
||||
const [, forceUpdate] = useState(0);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => forceUpdate((n) => n + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const elapsed = Math.floor((Date.now() - streaming.startTime) / 1000);
|
||||
const { tailLines, totalLineCount } = streaming;
|
||||
const hiddenCount = Math.max(0, totalLineCount - tailLines.length);
|
||||
|
||||
// No output yet - don't show anything
|
||||
const firstLine = tailLines[0];
|
||||
if (!firstLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* L-bracket on first line - matches ToolCallMessageRich format " ⎿ " */}
|
||||
<Box>
|
||||
<Text dimColor>{" ⎿ "}</Text>
|
||||
<Text
|
||||
dimColor={!firstLine.isStderr}
|
||||
color={firstLine.isStderr ? "red" : undefined}
|
||||
>
|
||||
{firstLine.text}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Remaining lines with indent (5 spaces to align with content after bracket) */}
|
||||
{tailLines.slice(1).map((line, i) => (
|
||||
<Text
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Lines are positional output, stable order within render
|
||||
key={i}
|
||||
dimColor={!line.isStderr}
|
||||
color={line.isStderr ? "red" : undefined}
|
||||
>
|
||||
{" "}
|
||||
{line.text}
|
||||
</Text>
|
||||
))}
|
||||
{/* Hidden count + elapsed time */}
|
||||
{hiddenCount > 0 && (
|
||||
<Text dimColor>
|
||||
{" "}… +{hiddenCount} more lines ({elapsed}s)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
StreamingOutputDisplay.displayName = "StreamingOutputDisplay";
|
||||
@@ -29,9 +29,11 @@ function isQuestionTool(name: string): boolean {
|
||||
return name === "AskUserQuestion";
|
||||
}
|
||||
|
||||
import type { StreamingState } from "../helpers/accumulator";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||
import { BlinkDot } from "./BlinkDot.js";
|
||||
import { CollapsedOutputDisplay } from "./CollapsedOutputDisplay";
|
||||
import { colors } from "./colors.js";
|
||||
import {
|
||||
EditRenderer,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
import { MemoryDiffRenderer } from "./MemoryDiffRenderer.js";
|
||||
import { PlanRenderer } from "./PlanRenderer.js";
|
||||
import { StreamingOutputDisplay } from "./StreamingOutputDisplay";
|
||||
import { TodoRenderer } from "./TodoRenderer.js";
|
||||
|
||||
type ToolCallLine = {
|
||||
@@ -52,8 +55,25 @@ type ToolCallLine = {
|
||||
resultText?: string;
|
||||
resultOk?: boolean;
|
||||
phase: "streaming" | "ready" | "running" | "finished";
|
||||
streaming?: StreamingState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if tool is a shell/bash tool that supports streaming output
|
||||
*/
|
||||
function isShellTool(name: string): boolean {
|
||||
const shellTools = [
|
||||
"Bash",
|
||||
"Shell",
|
||||
"shell",
|
||||
"shell_command",
|
||||
"run_shell_command",
|
||||
"RunShellCommand",
|
||||
"ShellCommand",
|
||||
];
|
||||
return shellTools.includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolCallMessageRich - Rich formatting version with old layout logic
|
||||
* This preserves the exact wrapping and spacing logic from the old codebase
|
||||
@@ -680,8 +700,31 @@ export const ToolCallMessage = memo(
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tool result (if present) */}
|
||||
{getResultElement()}
|
||||
{/* Streaming output for shell tools during execution */}
|
||||
{isShellTool(rawName) && line.phase === "running" && line.streaming && (
|
||||
<StreamingOutputDisplay streaming={line.streaming} />
|
||||
)}
|
||||
|
||||
{/* Collapsed output for shell tools after completion */}
|
||||
{isShellTool(rawName) &&
|
||||
line.phase === "finished" &&
|
||||
line.resultText &&
|
||||
line.resultOk !== false && (
|
||||
<CollapsedOutputDisplay output={line.resultText} />
|
||||
)}
|
||||
|
||||
{/* Tool result for non-shell tools or shell tool errors */}
|
||||
{(() => {
|
||||
// Show default result element when:
|
||||
// - Not a shell tool (always show result)
|
||||
// - Shell tool with error (show error message)
|
||||
// - Shell tool in streaming/ready phase (show default "Running..." etc)
|
||||
const showDefaultResult =
|
||||
!isShellTool(rawName) ||
|
||||
(line.phase === "finished" && line.resultOk === false) ||
|
||||
(line.phase !== "running" && line.phase !== "finished");
|
||||
return showDefaultResult ? getResultElement() : null;
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user