fix(tui): prevent reflow glitches (footer + resize) (#1098)
This commit is contained in:
138
src/cli/App.tsx
138
src/cli/App.tsx
@@ -1733,16 +1733,124 @@ export default function App({
|
||||
null,
|
||||
);
|
||||
const prevColumnsRef = useRef(rawColumns);
|
||||
const lastResizeColumnsRef = useRef(rawColumns);
|
||||
const lastResizeRowsRef = useRef(terminalRows);
|
||||
const lastClearedColumnsRef = useRef(rawColumns);
|
||||
const pendingResizeRef = useRef(false);
|
||||
const pendingResizeColumnsRef = useRef<number | null>(null);
|
||||
const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
|
||||
const resizeClearTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastClearAtRef = useRef(0);
|
||||
const resizeGestureTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const didImmediateShrinkClearRef = useRef(false);
|
||||
const isInitialResizeRef = useRef(true);
|
||||
const columns = stableColumns;
|
||||
// Keep bottom chrome from ever exceeding the *actual* terminal width.
|
||||
// When widening, we prefer the old behavior (wait until settle), so we use
|
||||
// stableColumns. When shrinking, we must clamp to rawColumns to avoid Ink
|
||||
// wrapping the footer/input chrome and "printing" divider rows into the
|
||||
// transcript while dragging.
|
||||
const chromeColumns = Math.min(rawColumns, stableColumns);
|
||||
const debugFlicker = process.env.LETTA_DEBUG_FLICKER === "1";
|
||||
|
||||
// Terminal resize + Ink:
|
||||
// When the terminal shrinks, the *previous* frame reflows (wraps to more
|
||||
// lines) instantly at the emulator level. Ink's incremental redraw then tries
|
||||
// to clear based on the old line count and can leave stale rows behind.
|
||||
//
|
||||
// Fix: on shrink events, clear the screen *synchronously* in the resize event
|
||||
// handler (before React/Ink flushes the next frame) and remount Static output.
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof process === "undefined" ||
|
||||
!process.stdout ||
|
||||
!("on" in process.stdout) ||
|
||||
!process.stdout.isTTY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stdout = process.stdout;
|
||||
const onResize = () => {
|
||||
const nextColumns = stdout.columns ?? lastResizeColumnsRef.current;
|
||||
const nextRows = stdout.rows ?? lastResizeRowsRef.current;
|
||||
|
||||
const prevColumns = lastResizeColumnsRef.current;
|
||||
const prevRows = lastResizeRowsRef.current;
|
||||
|
||||
lastResizeColumnsRef.current = nextColumns;
|
||||
lastResizeRowsRef.current = nextRows;
|
||||
|
||||
// Skip initial mount.
|
||||
if (isInitialResizeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shrunk = nextColumns < prevColumns || nextRows < prevRows;
|
||||
if (!shrunk) {
|
||||
// Reset shrink-clear guard once the gesture ends.
|
||||
if (resizeGestureTimeoutRef.current) {
|
||||
clearTimeout(resizeGestureTimeoutRef.current);
|
||||
}
|
||||
resizeGestureTimeoutRef.current = setTimeout(() => {
|
||||
resizeGestureTimeoutRef.current = null;
|
||||
didImmediateShrinkClearRef.current = false;
|
||||
}, RESIZE_SETTLE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// During a shrink gesture, do an immediate clear only once.
|
||||
// Clearing on every resize event causes extreme flicker.
|
||||
if (didImmediateShrinkClearRef.current) {
|
||||
if (resizeGestureTimeoutRef.current) {
|
||||
clearTimeout(resizeGestureTimeoutRef.current);
|
||||
}
|
||||
resizeGestureTimeoutRef.current = setTimeout(() => {
|
||||
resizeGestureTimeoutRef.current = null;
|
||||
didImmediateShrinkClearRef.current = false;
|
||||
}, RESIZE_SETTLE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debugFlicker) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[debug:flicker:resize-immediate-clear] next=${nextColumns}x${nextRows} prev=${prevColumns}x${prevRows} streaming=${streamingRef.current}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel any debounced clear; we're taking the immediate-clear path.
|
||||
if (resizeClearTimeout.current) {
|
||||
clearTimeout(resizeClearTimeout.current);
|
||||
resizeClearTimeout.current = null;
|
||||
}
|
||||
|
||||
stdout.write(CLEAR_SCREEN_AND_HOME);
|
||||
setStaticRenderEpoch((epoch) => epoch + 1);
|
||||
lastClearedColumnsRef.current = nextColumns;
|
||||
lastClearAtRef.current = Date.now();
|
||||
didImmediateShrinkClearRef.current = true;
|
||||
if (resizeGestureTimeoutRef.current) {
|
||||
clearTimeout(resizeGestureTimeoutRef.current);
|
||||
}
|
||||
resizeGestureTimeoutRef.current = setTimeout(() => {
|
||||
resizeGestureTimeoutRef.current = null;
|
||||
didImmediateShrinkClearRef.current = false;
|
||||
}, RESIZE_SETTLE_MS);
|
||||
};
|
||||
|
||||
stdout.on("resize", onResize);
|
||||
return () => {
|
||||
stdout.off("resize", onResize);
|
||||
if (resizeGestureTimeoutRef.current) {
|
||||
clearTimeout(resizeGestureTimeoutRef.current);
|
||||
resizeGestureTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [debugFlicker, streamingRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawColumns === stableColumns) {
|
||||
if (stableColumnsTimeoutRef.current) {
|
||||
@@ -1902,6 +2010,20 @@ export default function App({
|
||||
prevColumnsRef.current = rawColumns;
|
||||
}, [rawColumns, streaming, scheduleResizeClear]);
|
||||
|
||||
// Reflow Static output for 1-col width changes too.
|
||||
// rawColumns resize handling intentionally ignores 1-col "jitter" to reduce
|
||||
// flicker, but that also means widening by small increments won't remount
|
||||
// Static and existing output won't reflow.
|
||||
//
|
||||
// stableColumns only advances once the width has settled, so it's safe to use
|
||||
// for a low-frequency remount trigger.
|
||||
useEffect(() => {
|
||||
if (isInitialResizeRef.current) return;
|
||||
if (streaming) return;
|
||||
if (stableColumns === lastClearedColumnsRef.current) return;
|
||||
scheduleResizeClear(stableColumns);
|
||||
}, [stableColumns, streaming, scheduleResizeClear]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) {
|
||||
if (resizeClearTimeout.current) {
|
||||
@@ -2135,6 +2257,9 @@ export default function App({
|
||||
const statusLine = useConfigurableStatusLine({
|
||||
modelId: llmConfigRef.current?.model ?? null,
|
||||
modelDisplayName: currentModelDisplay,
|
||||
reasoningEffort: currentReasoningEffort,
|
||||
systemPromptId: currentSystemPromptId,
|
||||
toolset: currentToolset,
|
||||
currentDirectory: process.cwd(),
|
||||
projectDirectory,
|
||||
sessionId: conversationId,
|
||||
@@ -2147,7 +2272,7 @@ export default function App({
|
||||
usedContextTokens: contextTrackerRef.current.lastContextTokens,
|
||||
permissionMode: uiPermissionMode,
|
||||
networkPhase,
|
||||
terminalWidth: columns,
|
||||
terminalWidth: chromeColumns,
|
||||
triggerVersion: statusLineTriggerVersion,
|
||||
});
|
||||
|
||||
@@ -2160,7 +2285,7 @@ export default function App({
|
||||
previousStreamingForStatusLineRef.current = streaming;
|
||||
}, [streaming, triggerStatusLineRefresh]);
|
||||
|
||||
const statusLineRefreshIdentity = `${conversationId}|${currentModelDisplay ?? ""}|${currentModelProvider ?? ""}|${agentName ?? ""}|${columns}|${contextWindowSize ?? ""}`;
|
||||
const statusLineRefreshIdentity = `${conversationId}|${currentModelDisplay ?? ""}|${currentModelProvider ?? ""}|${agentName ?? ""}|${columns}|${contextWindowSize ?? ""}|${currentReasoningEffort ?? ""}|${currentSystemPromptId ?? ""}|${currentToolset ?? ""}`;
|
||||
|
||||
// Trigger status line when key session identity/display state changes.
|
||||
useEffect(() => {
|
||||
@@ -6650,6 +6775,9 @@ export default function App({
|
||||
buildStatusLinePayload({
|
||||
modelId: llmConfigRef.current?.model ?? null,
|
||||
modelDisplayName: currentModelDisplay,
|
||||
reasoningEffort: currentReasoningEffort,
|
||||
systemPromptId: currentSystemPromptId,
|
||||
toolset: currentToolset,
|
||||
currentDirectory: wd,
|
||||
projectDirectory,
|
||||
sessionId: conversationIdRef.current,
|
||||
@@ -6663,7 +6791,7 @@ export default function App({
|
||||
contextTrackerRef.current.lastContextTokens,
|
||||
permissionMode: uiPermissionMode,
|
||||
networkPhase,
|
||||
terminalWidth: columns,
|
||||
terminalWidth: chromeColumns,
|
||||
}),
|
||||
{ timeout: config.timeout, workingDirectory: wd },
|
||||
);
|
||||
@@ -12030,6 +12158,8 @@ Plan file path: ${planFilePath}`;
|
||||
currentModel={currentModelDisplay}
|
||||
currentModelProvider={currentModelProvider}
|
||||
currentReasoningEffort={currentReasoningEffort}
|
||||
currentSystemPromptId={currentSystemPromptId}
|
||||
currentToolset={currentToolset}
|
||||
messageQueue={messageQueue}
|
||||
onEnterQueueEditMode={handleEnterQueueEditMode}
|
||||
onEscapeCancel={
|
||||
@@ -12044,7 +12174,7 @@ Plan file path: ${planFilePath}`;
|
||||
restoredInput={restoredInput}
|
||||
onRestoredInputConsumed={() => setRestoredInput(null)}
|
||||
networkPhase={networkPhase}
|
||||
terminalWidth={columns}
|
||||
terminalWidth={chromeColumns}
|
||||
shouldAnimate={shouldAnimate}
|
||||
statusLineText={statusLine.text || undefined}
|
||||
statusLineRight={statusLine.rightText || undefined}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { Box } from "ink";
|
||||
import Link from "ink-link";
|
||||
import { memo, useMemo } from "react";
|
||||
import stringWidth from "string-width";
|
||||
import type { ModelReasoningEffort } from "../../agent/model";
|
||||
import { DEFAULT_AGENT_NAME } from "../../constants";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
import { getVersion } from "../../version";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
import { Text } from "./Text";
|
||||
|
||||
function truncateText(text: string, maxWidth: number): string {
|
||||
if (maxWidth <= 0) return "";
|
||||
if (stringWidth(text) <= maxWidth) return text;
|
||||
if (maxWidth <= 3) return ".".repeat(maxWidth);
|
||||
|
||||
const suffix = "...";
|
||||
const budget = Math.max(0, maxWidth - stringWidth(suffix));
|
||||
let out = "";
|
||||
for (const ch of text) {
|
||||
const next = out + ch;
|
||||
if (stringWidth(next) > budget) break;
|
||||
out = next;
|
||||
}
|
||||
return out + suffix;
|
||||
}
|
||||
|
||||
interface AgentInfoBarProps {
|
||||
agentId?: string;
|
||||
agentName?: string | null;
|
||||
@@ -40,7 +57,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({
|
||||
serverUrl,
|
||||
conversationId,
|
||||
}: AgentInfoBarProps) {
|
||||
const isTmux = Boolean(process.env.TMUX);
|
||||
const columns = useTerminalWidth();
|
||||
// Check if current agent is pinned
|
||||
const isPinned = useMemo(() => {
|
||||
if (!agentId) return false;
|
||||
@@ -50,9 +67,12 @@ export const AgentInfoBar = memo(function AgentInfoBar({
|
||||
}, [agentId]);
|
||||
|
||||
const isCloudUser = serverUrl?.includes("api.letta.com");
|
||||
const adeUrl =
|
||||
agentId && agentId !== "loading"
|
||||
? `https://app.letta.com/agents/${agentId}${conversationId && conversationId !== "default" ? `?conversation=${conversationId}` : ""}`
|
||||
const adeConversationUrl =
|
||||
agentId &&
|
||||
agentId !== "loading" &&
|
||||
conversationId &&
|
||||
conversationId !== "default"
|
||||
? `https://app.letta.com/agents/${agentId}?conversation=${conversationId}`
|
||||
: "";
|
||||
const showBottomBar = agentId && agentId !== "loading";
|
||||
const reasoningLabel = formatReasoningLabel(currentReasoningEffort);
|
||||
@@ -66,6 +86,16 @@ export const AgentInfoBar = memo(function AgentInfoBar({
|
||||
|
||||
// Alien ASCII art lines (4 lines tall, with 2-char indent + extra space before text)
|
||||
const alienLines = [" ▗▖▗▖ ", " ▙█▜▛█▟ ", " ▝▜▛▜▛▘ ", " "];
|
||||
const leftWidth = Math.max(...alienLines.map((l) => stringWidth(l)));
|
||||
const rightWidth = Math.max(0, columns - leftWidth);
|
||||
|
||||
const agentNameLabel = agentName || "Unnamed";
|
||||
const agentHint = isPinned
|
||||
? " (pinned)"
|
||||
: agentName === DEFAULT_AGENT_NAME || !agentName
|
||||
? " (type /pin to give your agent a real name!)"
|
||||
: " (type /pin to pin agent)";
|
||||
const agentNameLine = `${agentNameLabel}${agentHint}`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -74,11 +104,8 @@ export const AgentInfoBar = memo(function AgentInfoBar({
|
||||
|
||||
{/* Version and Discord/feedback info */}
|
||||
<Box>
|
||||
<Text>
|
||||
{" "}Letta Code v{getVersion()} · Report bugs with /feedback or{" "}
|
||||
<Link url="https://discord.gg/letta">
|
||||
<Text>on Discord ↗</Text>
|
||||
</Link>
|
||||
<Text wrap="truncate-end">
|
||||
{" "}Letta Code v{getVersion()} · /feedback · discord.gg/letta
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -88,61 +115,83 @@ export const AgentInfoBar = memo(function AgentInfoBar({
|
||||
{/* Alien + Agent name */}
|
||||
<Box>
|
||||
<Text color={colors.footer.agentName}>{alienLines[0]}</Text>
|
||||
<Text bold color={colors.footer.agentName}>
|
||||
{agentName || "Unnamed"}
|
||||
</Text>
|
||||
{isPinned ? (
|
||||
<Text color="green"> (pinned ✓)</Text>
|
||||
) : agentName === DEFAULT_AGENT_NAME || !agentName ? (
|
||||
<Text color="gray"> (type /pin to give your agent a real name!)</Text>
|
||||
) : (
|
||||
<Text color="gray"> (type /pin to pin agent)</Text>
|
||||
)}
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text bold color={colors.footer.agentName} wrap="truncate-end">
|
||||
{truncateText(agentNameLine, rightWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Alien + Links */}
|
||||
<Box>
|
||||
<Text color={colors.footer.agentName}>{alienLines[1]}</Text>
|
||||
{isCloudUser && adeUrl && !isTmux && (
|
||||
<>
|
||||
<Link url={adeUrl}>
|
||||
<Text>Open in ADE ↗</Text>
|
||||
</Link>
|
||||
<Text dimColor>· </Text>
|
||||
</>
|
||||
{!isCloudUser && (
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{truncateText(serverUrl ?? "", rightWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isCloudUser && adeUrl && isTmux && (
|
||||
<Text dimColor>Open in ADE: {adeUrl} · </Text>
|
||||
)}
|
||||
{isCloudUser && (
|
||||
<Link url="https://app.letta.com/settings/organization/usage">
|
||||
<Text>View usage ↗</Text>
|
||||
</Link>
|
||||
)}
|
||||
{!isCloudUser && <Text dimColor>{serverUrl}</Text>}
|
||||
</Box>
|
||||
|
||||
{/* Keep usage on its own line to avoid breaking the alien art rows. */}
|
||||
{isCloudUser && (
|
||||
<Box>
|
||||
<Text color={colors.footer.agentName}>{alienLines[3]}</Text>
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{truncateText(
|
||||
"Usage: https://app.letta.com/settings/organization/usage",
|
||||
rightWidth,
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Model summary */}
|
||||
<Box>
|
||||
<Text color={colors.footer.agentName}>{alienLines[2]}</Text>
|
||||
<Text dimColor>{modelLine ?? "model unknown"}</Text>
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{truncateText(modelLine ?? "model unknown", rightWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Agent ID */}
|
||||
<Box>
|
||||
<Text>{alienLines[3]}</Text>
|
||||
<Text dimColor>{agentId}</Text>
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{truncateText(agentId, rightWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Phantom alien row + Conversation ID */}
|
||||
<Box>
|
||||
<Text>{alienLines[3]}</Text>
|
||||
{conversationId && conversationId !== "default" ? (
|
||||
<Text dimColor>{conversationId}</Text>
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{truncateText(conversationId, rightWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text dimColor>default conversation</Text>
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text dimColor>default conversation</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Full ADE conversation URL (may wrap; kept last so it can't break the art rows) */}
|
||||
{isCloudUser && adeConversationUrl && (
|
||||
<Box>
|
||||
<Text>{alienLines[3]}</Text>
|
||||
<Text dimColor>{`ADE: ${adeConversationUrl}`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,8 @@ export function AutocompleteItem({
|
||||
<Text
|
||||
color={selected ? colors.command.selected : undefined}
|
||||
bold={selected}
|
||||
// Keep each item one visual line tall so navigating doesn't reflow the footer.
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{" "}
|
||||
{children}
|
||||
|
||||
@@ -217,6 +217,8 @@ const InputFooter = memo(function InputFooter({
|
||||
agentName,
|
||||
currentModel,
|
||||
currentReasoningEffort,
|
||||
currentSystemPromptId,
|
||||
currentToolset,
|
||||
isOpenAICodexProvider,
|
||||
isByokProvider,
|
||||
hideFooter,
|
||||
@@ -234,6 +236,8 @@ const InputFooter = memo(function InputFooter({
|
||||
agentName: string | null | undefined;
|
||||
currentModel: string | null | undefined;
|
||||
currentReasoningEffort?: ModelReasoningEffort | null;
|
||||
currentSystemPromptId?: string | null;
|
||||
currentToolset?: string | null;
|
||||
isOpenAICodexProvider: boolean;
|
||||
isByokProvider: boolean;
|
||||
hideFooter: boolean;
|
||||
@@ -247,13 +251,50 @@ const InputFooter = memo(function InputFooter({
|
||||
const displayAgentName = truncateEnd(agentName || "Unnamed", maxAgentChars);
|
||||
const reasoningTag = getReasoningEffortTag(currentReasoningEffort);
|
||||
const byokExtraChars = isByokProvider ? 2 : 0; // " ▲"
|
||||
const reservedChars = displayAgentName.length + byokExtraChars + 4;
|
||||
const maxModelChars = Math.max(8, rightColumnWidth - reservedChars);
|
||||
|
||||
const baseReservedChars = displayAgentName.length + byokExtraChars + 4;
|
||||
const modelWithReasoning =
|
||||
(currentModel ?? "unknown") + (reasoningTag ? ` (${reasoningTag})` : "");
|
||||
|
||||
// Optional suffixes: system prompt id + toolset.
|
||||
const suffixParts: string[] = [];
|
||||
if (currentSystemPromptId) {
|
||||
suffixParts.push(`s:${currentSystemPromptId}`);
|
||||
}
|
||||
if (currentToolset) {
|
||||
suffixParts.push(`t:${currentToolset}`);
|
||||
}
|
||||
|
||||
// Reserve 4 chars per suffix part so the label is visible even on narrow terminals.
|
||||
const minSuffixBudget = suffixParts.length * 4;
|
||||
const maxModelChars = Math.max(
|
||||
8,
|
||||
rightColumnWidth - baseReservedChars - minSuffixBudget,
|
||||
);
|
||||
const displayModel = truncateEnd(modelWithReasoning, maxModelChars);
|
||||
const rightTextLength =
|
||||
|
||||
const baseTextLength =
|
||||
displayAgentName.length + displayModel.length + byokExtraChars + 3;
|
||||
const maxSuffixChars = Math.max(0, rightColumnWidth - baseTextLength);
|
||||
|
||||
const displaySuffix = (() => {
|
||||
if (suffixParts.length === 0 || maxSuffixChars <= 0) return "";
|
||||
|
||||
let remaining = maxSuffixChars;
|
||||
const out: string[] = [];
|
||||
for (const part of suffixParts) {
|
||||
// Leading space before each part.
|
||||
if (remaining <= 1) break;
|
||||
const budget = remaining - 1;
|
||||
const clipped = truncateEnd(part, budget);
|
||||
if (!clipped) break;
|
||||
out.push(` ${clipped}`);
|
||||
remaining -= 1 + clipped.length;
|
||||
}
|
||||
return out.join("");
|
||||
})();
|
||||
|
||||
const rightTextLength = baseTextLength + displaySuffix.length;
|
||||
const rightPrefixSpaces = Math.max(0, rightColumnWidth - rightTextLength);
|
||||
const rightLabel = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
@@ -268,11 +309,17 @@ const InputFooter = memo(function InputFooter({
|
||||
);
|
||||
}
|
||||
parts.push(chalk.dim("]"));
|
||||
|
||||
if (displaySuffix) {
|
||||
parts.push(chalk.dim(displaySuffix));
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}, [
|
||||
rightPrefixSpaces,
|
||||
displayAgentName,
|
||||
displayModel,
|
||||
displaySuffix,
|
||||
isByokProvider,
|
||||
isOpenAICodexProvider,
|
||||
]);
|
||||
@@ -365,12 +412,44 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
terminalWidth: number;
|
||||
shouldAnimate: boolean;
|
||||
}) {
|
||||
// While the user is actively resizing the terminal, Ink can struggle to
|
||||
// clear/redraw rapidly-changing animated output (spinner/shimmer).
|
||||
// Freeze animations briefly during resize to keep output stable.
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastWidthRef = useRef<number>(terminalWidth);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalWidth === lastWidthRef.current) return;
|
||||
lastWidthRef.current = terminalWidth;
|
||||
|
||||
setIsResizing(true);
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
}
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
resizeTimerRef.current = null;
|
||||
setIsResizing(false);
|
||||
}, 750);
|
||||
}, [terminalWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
resizeTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const animate = shouldAnimate && !isResizing;
|
||||
|
||||
const [shimmerOffset, setShimmerOffset] = useState(-3);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const streamStartRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streaming || !visible || !shouldAnimate) return;
|
||||
if (!streaming || !visible || !animate) return;
|
||||
|
||||
const id = setInterval(() => {
|
||||
setShimmerOffset((prev) => {
|
||||
@@ -383,17 +462,17 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
}, 120); // Speed of shimmer animation
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [streaming, thinkingMessage, visible, agentName, shouldAnimate]);
|
||||
}, [streaming, thinkingMessage, visible, agentName, animate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
if (!animate) {
|
||||
setShimmerOffset(-3);
|
||||
}
|
||||
}, [shouldAnimate]);
|
||||
}, [animate]);
|
||||
|
||||
// Elapsed time tracking
|
||||
useEffect(() => {
|
||||
if (streaming && visible) {
|
||||
if (streaming && visible && !isResizing) {
|
||||
// Start tracking when streaming begins
|
||||
if (streamStartRef.current === null) {
|
||||
streamStartRef.current = performance.now();
|
||||
@@ -408,7 +487,7 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
// Reset when streaming stops
|
||||
streamStartRef.current = null;
|
||||
setElapsedMs(0);
|
||||
}, [streaming, visible]);
|
||||
}, [streaming, visible, isResizing]);
|
||||
|
||||
const estimatedTokens = charsToTokens(tokenCount);
|
||||
const totalElapsedMs = elapsedBaseMs + elapsedMs;
|
||||
@@ -425,7 +504,10 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
return "↑\u0338";
|
||||
}, [networkPhase]);
|
||||
const showErrorArrow = networkArrow === "↑\u0338";
|
||||
const statusContentWidth = Math.max(0, terminalWidth - 2);
|
||||
// Avoid painting into the terminal's last column; some terminals will soft-wrap
|
||||
// padded Ink rows at the edge which breaks Ink's line-clearing accounting and
|
||||
// leaves duplicate status rows behind during streaming/resizes.
|
||||
const statusContentWidth = Math.max(0, terminalWidth - 3);
|
||||
const minMessageWidth = 12;
|
||||
const statusHintParts = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
@@ -469,14 +551,16 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
// Uses chalk.dim to match reasoning text styling
|
||||
// Memoized to prevent unnecessary re-renders during shimmer updates
|
||||
const statusHintText = useMemo(() => {
|
||||
const hintColor = chalk.hex(colors.subagent.hint);
|
||||
const hintBold = hintColor.bold;
|
||||
const suffix = `${statusHintSuffix})`;
|
||||
if (interruptRequested) {
|
||||
return hintColor(` (interrupting${suffix}`);
|
||||
return <Text dimColor>{` (interrupting${suffix}`}</Text>;
|
||||
}
|
||||
return (
|
||||
hintColor(" (") + hintBold("esc") + hintColor(` to interrupt${suffix}`)
|
||||
<Text dimColor>
|
||||
{" ("}
|
||||
<Text bold>esc</Text>
|
||||
{` to interrupt${suffix}`}
|
||||
</Text>
|
||||
);
|
||||
}, [interruptRequested, statusHintSuffix]);
|
||||
|
||||
@@ -488,7 +572,7 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={colors.status.processing}>
|
||||
{shouldAnimate ? <Spinner type="layer" /> : "●"}
|
||||
{animate ? <Spinner type="layer" /> : "●"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={statusContentWidth} flexShrink={0} flexDirection="row">
|
||||
@@ -496,7 +580,7 @@ const StreamingStatus = memo(function StreamingStatus({
|
||||
<ShimmerText
|
||||
boldPrefix={agentName || undefined}
|
||||
message={thinkingMessage}
|
||||
shimmerOffset={shouldAnimate ? shimmerOffset : -3}
|
||||
shimmerOffset={animate ? shimmerOffset : -3}
|
||||
wrap="truncate-end"
|
||||
/>
|
||||
</Box>
|
||||
@@ -541,6 +625,8 @@ export function Input({
|
||||
currentModel,
|
||||
currentModelProvider,
|
||||
currentReasoningEffort,
|
||||
currentSystemPromptId,
|
||||
currentToolset,
|
||||
messageQueue,
|
||||
onEnterQueueEditMode,
|
||||
onEscapeCancel,
|
||||
@@ -582,6 +668,8 @@ export function Input({
|
||||
currentModel?: string | null;
|
||||
currentModelProvider?: string | null;
|
||||
currentReasoningEffort?: ModelReasoningEffort | null;
|
||||
currentSystemPromptId?: string | null;
|
||||
currentToolset?: string | null;
|
||||
messageQueue?: QueuedMessage[];
|
||||
onEnterQueueEditMode?: () => void;
|
||||
onEscapeCancel?: () => void;
|
||||
@@ -618,9 +706,49 @@ export function Input({
|
||||
// Terminal width is sourced from App.tsx to avoid duplicate resize subscriptions.
|
||||
const columns = terminalWidth;
|
||||
|
||||
// During shrink drags, Ink's incremental clear can leave stale rows behind.
|
||||
// The worst offender is the full-width divider line, which wraps as the
|
||||
// terminal shrinks and appears to "spam" into the transcript.
|
||||
// Hide dividers during shrink gestures; restore after the width settles.
|
||||
const [suppressDividers, setSuppressDividers] = useState(false);
|
||||
const resizeDividersTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const lastColumnsRef = useRef(columns);
|
||||
|
||||
// Bash mode state (declared early so prompt width can feed into contentWidth)
|
||||
const [isBashMode, setIsBashMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = lastColumnsRef.current;
|
||||
if (columns === prev) return;
|
||||
lastColumnsRef.current = columns;
|
||||
|
||||
const isShrinking = columns < prev;
|
||||
if (isShrinking) {
|
||||
setSuppressDividers(true);
|
||||
}
|
||||
|
||||
if (resizeDividersTimerRef.current) {
|
||||
clearTimeout(resizeDividersTimerRef.current);
|
||||
}
|
||||
resizeDividersTimerRef.current = setTimeout(() => {
|
||||
resizeDividersTimerRef.current = null;
|
||||
setSuppressDividers(false);
|
||||
}, 250);
|
||||
|
||||
return;
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resizeDividersTimerRef.current) {
|
||||
clearTimeout(resizeDividersTimerRef.current);
|
||||
resizeDividersTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const promptChar = isBashMode ? "!" : statusLinePrompt || ">";
|
||||
const promptVisualWidth = stringWidth(promptChar) + 1; // +1 for trailing space
|
||||
const contentWidth = Math.max(0, columns - promptVisualWidth);
|
||||
@@ -1266,9 +1394,14 @@ export function Input({
|
||||
}
|
||||
}, [ralphPending, ralphPendingYolo, ralphActive, currentMode]);
|
||||
|
||||
// Create a horizontal line using box-drawing characters
|
||||
// Memoized since it only changes when terminal width changes
|
||||
const horizontalLine = useMemo(() => "─".repeat(columns), [columns]);
|
||||
// Create a horizontal line using box-drawing characters.
|
||||
// IMPORTANT: never draw into the terminal's last column; some terminals will
|
||||
// soft-wrap at the edge which breaks Ink's clear/redraw accounting during
|
||||
// resize and can leave stacks of stale divider rows behind.
|
||||
const horizontalLine = useMemo(
|
||||
() => "─".repeat(Math.max(0, columns - 1)),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const lowerPane = useMemo(() => {
|
||||
return (
|
||||
@@ -1281,12 +1414,14 @@ export function Input({
|
||||
{interactionEnabled ? (
|
||||
<Box flexDirection="column">
|
||||
{/* Top horizontal divider */}
|
||||
<Text
|
||||
dimColor={!isBashMode}
|
||||
color={isBashMode ? colors.bash.border : undefined}
|
||||
>
|
||||
{horizontalLine}
|
||||
</Text>
|
||||
{!suppressDividers && (
|
||||
<Text
|
||||
dimColor={!isBashMode}
|
||||
color={isBashMode ? colors.bash.border : undefined}
|
||||
>
|
||||
{horizontalLine}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Two-column layout for input, matching message components */}
|
||||
<Box flexDirection="row">
|
||||
@@ -1314,52 +1449,65 @@ export function Input({
|
||||
</Box>
|
||||
|
||||
{/* Bottom horizontal divider */}
|
||||
<Text
|
||||
dimColor={!isBashMode}
|
||||
color={isBashMode ? colors.bash.border : undefined}
|
||||
>
|
||||
{horizontalLine}
|
||||
</Text>
|
||||
{!suppressDividers && (
|
||||
<Text
|
||||
dimColor={!isBashMode}
|
||||
color={isBashMode ? colors.bash.border : undefined}
|
||||
>
|
||||
{horizontalLine}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<InputAssist
|
||||
currentInput={value}
|
||||
cursorPosition={currentCursorPosition}
|
||||
onFileSelect={handleFileSelect}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
onCommandAutocomplete={handleCommandAutocomplete}
|
||||
onAutocompleteActiveChange={setIsAutocompleteActive}
|
||||
agentId={agentId}
|
||||
agentName={agentName}
|
||||
currentModel={currentModel}
|
||||
currentReasoningEffort={currentReasoningEffort}
|
||||
serverUrl={serverUrl}
|
||||
workingDirectory={process.cwd()}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
{/*
|
||||
During shrink drags Ink's incremental clear is most fragile.
|
||||
Hide the entire footer chrome (assist + footer) until the width
|
||||
settles to avoid "printing" wrapped rows into the transcript.
|
||||
*/}
|
||||
{!suppressDividers && (
|
||||
<InputAssist
|
||||
currentInput={value}
|
||||
cursorPosition={currentCursorPosition}
|
||||
onFileSelect={handleFileSelect}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
onCommandAutocomplete={handleCommandAutocomplete}
|
||||
onAutocompleteActiveChange={setIsAutocompleteActive}
|
||||
agentId={agentId}
|
||||
agentName={agentName}
|
||||
currentModel={currentModel}
|
||||
currentReasoningEffort={currentReasoningEffort}
|
||||
serverUrl={serverUrl}
|
||||
workingDirectory={process.cwd()}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputFooter
|
||||
ctrlCPressed={ctrlCPressed}
|
||||
escapePressed={escapePressed}
|
||||
isBashMode={isBashMode}
|
||||
modeName={modeInfo?.name ?? null}
|
||||
modeColor={modeInfo?.color ?? null}
|
||||
showExitHint={ralphActive || ralphPending}
|
||||
agentName={agentName}
|
||||
currentModel={currentModel}
|
||||
currentReasoningEffort={currentReasoningEffort}
|
||||
isOpenAICodexProvider={
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
isByokProvider={
|
||||
currentModelProvider?.startsWith("lc-") ||
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
hideFooter={hideFooter}
|
||||
rightColumnWidth={footerRightColumnWidth}
|
||||
statusLineText={statusLineText}
|
||||
statusLineRight={statusLineRight}
|
||||
statusLinePadding={statusLinePadding}
|
||||
/>
|
||||
{!suppressDividers && (
|
||||
<InputFooter
|
||||
ctrlCPressed={ctrlCPressed}
|
||||
escapePressed={escapePressed}
|
||||
isBashMode={isBashMode}
|
||||
modeName={modeInfo?.name ?? null}
|
||||
modeColor={modeInfo?.color ?? null}
|
||||
showExitHint={ralphActive || ralphPending}
|
||||
agentName={agentName}
|
||||
currentModel={currentModel}
|
||||
currentReasoningEffort={currentReasoningEffort}
|
||||
currentSystemPromptId={currentSystemPromptId}
|
||||
currentToolset={currentToolset}
|
||||
isOpenAICodexProvider={
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
isByokProvider={
|
||||
currentModelProvider?.startsWith("lc-") ||
|
||||
currentModelProvider === OPENAI_CODEX_PROVIDER_NAME
|
||||
}
|
||||
hideFooter={hideFooter}
|
||||
rightColumnWidth={footerRightColumnWidth}
|
||||
statusLineText={statusLineText}
|
||||
statusLineRight={statusLineRight}
|
||||
statusLinePadding={statusLinePadding}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : reserveInputSpace ? (
|
||||
<Box height={inputChromeHeight} />
|
||||
@@ -1403,8 +1551,11 @@ export function Input({
|
||||
statusLineText,
|
||||
statusLineRight,
|
||||
statusLinePadding,
|
||||
currentSystemPromptId,
|
||||
currentToolset,
|
||||
promptChar,
|
||||
promptVisualWidth,
|
||||
suppressDividers,
|
||||
]);
|
||||
|
||||
// If not visible, render nothing but keep component mounted to preserve state
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import chalk from "chalk";
|
||||
import { memo } from "react";
|
||||
import { colors } from "./colors.js";
|
||||
import { Text } from "./Text";
|
||||
@@ -23,25 +22,74 @@ export const ShimmerText = memo(function ShimmerText({
|
||||
shimmerOffset,
|
||||
wrap,
|
||||
}: ShimmerTextProps) {
|
||||
const fullText = `${boldPrefix ? `${boldPrefix} ` : ""}${message}…`;
|
||||
const prefixLength = boldPrefix ? boldPrefix.length + 1 : 0; // +1 for space
|
||||
const prefix = boldPrefix ? `${boldPrefix} ` : "";
|
||||
const prefixLen = prefix.length;
|
||||
const fullText = `${prefix}${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;
|
||||
const isInPrefix = i < prefixLength;
|
||||
// Avoid per-character ANSI styling. Rendering shimmer with a small number of
|
||||
// <Text> spans keeps Ink's wrapping/truncation behavior stable during resize.
|
||||
const start = Math.max(0, shimmerOffset);
|
||||
const end = Math.max(start, shimmerOffset + 3);
|
||||
|
||||
if (isInShimmer) {
|
||||
const styledChar = chalk.hex(colors.status.processingShimmer)(char);
|
||||
return isInPrefix ? chalk.bold(styledChar) : styledChar;
|
||||
}
|
||||
const styledChar = chalk.hex(color)(char);
|
||||
return isInPrefix ? chalk.bold(styledChar) : styledChar;
|
||||
})
|
||||
.join("");
|
||||
type Segment = { key: string; text: string; color?: string; bold?: boolean };
|
||||
const segments: Segment[] = [];
|
||||
|
||||
return <Text wrap={wrap}>{shimmerText}</Text>;
|
||||
const pushRegion = (
|
||||
text: string,
|
||||
regionStart: number,
|
||||
regionColor?: string,
|
||||
) => {
|
||||
if (!text) return;
|
||||
|
||||
const regionEnd = regionStart + text.length;
|
||||
const crossesPrefix = regionStart < prefixLen && regionEnd > prefixLen;
|
||||
|
||||
if (!crossesPrefix) {
|
||||
const bold = regionStart < prefixLen;
|
||||
segments.push({
|
||||
key: `${regionStart}:${regionColor ?? ""}:${bold ? "b" : "n"}`,
|
||||
text,
|
||||
color: regionColor,
|
||||
bold,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cut = Math.max(0, prefixLen - regionStart);
|
||||
const left = text.slice(0, cut);
|
||||
const right = text.slice(cut);
|
||||
|
||||
if (left)
|
||||
segments.push({
|
||||
key: `${regionStart}:${regionColor ?? ""}:b`,
|
||||
text: left,
|
||||
color: regionColor,
|
||||
bold: true,
|
||||
});
|
||||
if (right)
|
||||
segments.push({
|
||||
key: `${prefixLen}:${regionColor ?? ""}:n`,
|
||||
text: right,
|
||||
color: regionColor,
|
||||
bold: false,
|
||||
});
|
||||
};
|
||||
|
||||
const before = fullText.slice(0, start);
|
||||
const shimmer = fullText.slice(start, end);
|
||||
const after = fullText.slice(end);
|
||||
|
||||
pushRegion(before, 0, color);
|
||||
pushRegion(shimmer, start, colors.status.processingShimmer);
|
||||
pushRegion(after, end, color);
|
||||
|
||||
return (
|
||||
<Text wrap={wrap}>
|
||||
{segments.map((seg) => (
|
||||
<Text key={seg.key} color={seg.color} bold={seg.bold}>
|
||||
{seg.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,20 @@ import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
import { commands } from "../commands/registry";
|
||||
import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { AutocompleteBox, AutocompleteItem } from "./Autocomplete";
|
||||
import { Text } from "./Text";
|
||||
import type { AutocompleteProps, CommandMatch } from "./types/autocomplete";
|
||||
|
||||
const VISIBLE_COMMANDS = 7; // Number of commands visible at once
|
||||
const CMD_COL_WIDTH = 14;
|
||||
|
||||
function truncateText(text: string, maxWidth: number): string {
|
||||
if (maxWidth <= 0) return "";
|
||||
if (text.length <= maxWidth) return text;
|
||||
if (maxWidth <= 3) return text.slice(0, maxWidth);
|
||||
return `${text.slice(0, maxWidth - 3)}...`;
|
||||
}
|
||||
|
||||
// Compute filtered command list (excluding hidden commands), sorted by order
|
||||
const _allCommands: CommandMatch[] = Object.entries(commands)
|
||||
@@ -50,6 +59,7 @@ export function SlashCommandAutocomplete({
|
||||
agentId,
|
||||
workingDirectory = process.cwd(),
|
||||
}: AutocompleteProps) {
|
||||
const columns = useTerminalWidth();
|
||||
const [customCommands, setCustomCommands] = useState<CommandMatch[]>([]);
|
||||
|
||||
// Load custom commands once on mount
|
||||
@@ -213,13 +223,23 @@ export function SlashCommandAutocomplete({
|
||||
<AutocompleteBox>
|
||||
{visibleMatches.map((item, idx) => {
|
||||
const actualIndex = startIndex + idx;
|
||||
|
||||
// Keep the footer height stable while navigating by forcing a single-line
|
||||
// representation for each row.
|
||||
const displayCmd = truncateText(item.cmd, CMD_COL_WIDTH).padEnd(
|
||||
CMD_COL_WIDTH,
|
||||
);
|
||||
// 2-char gutter comes from <AutocompleteItem />.
|
||||
const maxDescWidth = Math.max(0, columns - 2 - CMD_COL_WIDTH - 1);
|
||||
const displayDesc = truncateText(item.desc, maxDescWidth);
|
||||
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={item.cmd}
|
||||
selected={actualIndex === selectedIndex}
|
||||
>
|
||||
{item.cmd.padEnd(14)}{" "}
|
||||
<Text dimColor={actualIndex !== selectedIndex}>{item.desc}</Text>
|
||||
{displayCmd}{" "}
|
||||
<Text dimColor={actualIndex !== selectedIndex}>{displayDesc}</Text>
|
||||
</AutocompleteItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { getVersion } from "../../version";
|
||||
export interface StatusLinePayloadBuildInput {
|
||||
modelId?: string | null;
|
||||
modelDisplayName?: string | null;
|
||||
reasoningEffort?: string | null;
|
||||
systemPromptId?: string | null;
|
||||
toolset?: string | null;
|
||||
currentDirectory: string;
|
||||
projectDirectory: string;
|
||||
sessionId?: string;
|
||||
@@ -32,6 +35,10 @@ export interface StatusLinePayload {
|
||||
session_id?: string;
|
||||
transcript_path: string | null;
|
||||
version: string;
|
||||
// Back-compat fields used by custom statusline scripts.
|
||||
reasoning_effort: string | null;
|
||||
system_prompt_id: string | null;
|
||||
toolset: string | null;
|
||||
model: {
|
||||
id: string | null;
|
||||
display_name: string | null;
|
||||
@@ -122,6 +129,9 @@ export function buildStatusLinePayload(
|
||||
...(input.sessionId ? { session_id: input.sessionId } : {}),
|
||||
transcript_path: null,
|
||||
version: getVersion(),
|
||||
reasoning_effort: input.reasoningEffort ?? null,
|
||||
system_prompt_id: input.systemPromptId ?? null,
|
||||
toolset: input.toolset ?? null,
|
||||
model: {
|
||||
id: input.modelId ?? null,
|
||||
display_name: input.modelDisplayName ?? null,
|
||||
|
||||
@@ -21,6 +21,9 @@ import { executeStatusLineCommand } from "../helpers/statusLineRuntime";
|
||||
export interface StatusLineInputs {
|
||||
modelId?: string | null;
|
||||
modelDisplayName?: string | null;
|
||||
reasoningEffort?: string | null;
|
||||
systemPromptId?: string | null;
|
||||
toolset?: string | null;
|
||||
currentDirectory: string;
|
||||
projectDirectory: string;
|
||||
sessionId?: string;
|
||||
@@ -54,6 +57,9 @@ function toPayloadInput(inputs: StatusLineInputs): StatusLinePayloadBuildInput {
|
||||
return {
|
||||
modelId: inputs.modelId,
|
||||
modelDisplayName: inputs.modelDisplayName,
|
||||
reasoningEffort: inputs.reasoningEffort,
|
||||
systemPromptId: inputs.systemPromptId,
|
||||
toolset: inputs.toolset,
|
||||
currentDirectory: inputs.currentDirectory,
|
||||
projectDirectory: inputs.projectDirectory,
|
||||
sessionId: inputs.sessionId,
|
||||
|
||||
Reference in New Issue
Block a user