` in the browser.
*/
-function Box(t0) {
- const $ = _c(42);
- let autoFocus;
- let children;
- let flexDirection;
- let flexGrow;
- let flexShrink;
- let flexWrap;
- let onBlur;
- let onBlurCapture;
- let onClick;
- let onFocus;
- let onFocusCapture;
- let onKeyDown;
- let onKeyDownCapture;
- let onMouseEnter;
- let onMouseLeave;
- let ref;
- let style;
- let tabIndex;
- if ($[0] !== t0) {
- const {
- children: t1,
- flexWrap: t2,
- flexDirection: t3,
- flexGrow: t4,
- flexShrink: t5,
- ref: t6,
- tabIndex: t7,
- autoFocus: t8,
- onClick: t9,
- onFocus: t10,
- onFocusCapture: t11,
- onBlur: t12,
- onBlurCapture: t13,
- onMouseEnter: t14,
- onMouseLeave: t15,
- onKeyDown: t16,
- onKeyDownCapture: t17,
- ...t18
- } = t0;
- children = t1;
- ref = t6;
- tabIndex = t7;
- autoFocus = t8;
- onClick = t9;
- onFocus = t10;
- onFocusCapture = t11;
- onBlur = t12;
- onBlurCapture = t13;
- onMouseEnter = t14;
- onMouseLeave = t15;
- onKeyDown = t16;
- onKeyDownCapture = t17;
- style = t18;
- flexWrap = t2 === undefined ? "nowrap" : t2;
- flexDirection = t3 === undefined ? "row" : t3;
- flexGrow = t4 === undefined ? 0 : t4;
- flexShrink = t5 === undefined ? 1 : t5;
- warn.ifNotInteger(style.margin, "margin");
- warn.ifNotInteger(style.marginX, "marginX");
- warn.ifNotInteger(style.marginY, "marginY");
- warn.ifNotInteger(style.marginTop, "marginTop");
- warn.ifNotInteger(style.marginBottom, "marginBottom");
- warn.ifNotInteger(style.marginLeft, "marginLeft");
- warn.ifNotInteger(style.marginRight, "marginRight");
- warn.ifNotInteger(style.padding, "padding");
- warn.ifNotInteger(style.paddingX, "paddingX");
- warn.ifNotInteger(style.paddingY, "paddingY");
- warn.ifNotInteger(style.paddingTop, "paddingTop");
- warn.ifNotInteger(style.paddingBottom, "paddingBottom");
- warn.ifNotInteger(style.paddingLeft, "paddingLeft");
- warn.ifNotInteger(style.paddingRight, "paddingRight");
- warn.ifNotInteger(style.gap, "gap");
- warn.ifNotInteger(style.columnGap, "columnGap");
- warn.ifNotInteger(style.rowGap, "rowGap");
- $[0] = t0;
- $[1] = autoFocus;
- $[2] = children;
- $[3] = flexDirection;
- $[4] = flexGrow;
- $[5] = flexShrink;
- $[6] = flexWrap;
- $[7] = onBlur;
- $[8] = onBlurCapture;
- $[9] = onClick;
- $[10] = onFocus;
- $[11] = onFocusCapture;
- $[12] = onKeyDown;
- $[13] = onKeyDownCapture;
- $[14] = onMouseEnter;
- $[15] = onMouseLeave;
- $[16] = ref;
- $[17] = style;
- $[18] = tabIndex;
- } else {
- autoFocus = $[1];
- children = $[2];
- flexDirection = $[3];
- flexGrow = $[4];
- flexShrink = $[5];
- flexWrap = $[6];
- onBlur = $[7];
- onBlurCapture = $[8];
- onClick = $[9];
- onFocus = $[10];
- onFocusCapture = $[11];
- onKeyDown = $[12];
- onKeyDownCapture = $[13];
- onMouseEnter = $[14];
- onMouseLeave = $[15];
- ref = $[16];
- style = $[17];
- tabIndex = $[18];
- }
- const t1 = style.overflowX ?? style.overflow ?? "visible";
- const t2 = style.overflowY ?? style.overflow ?? "visible";
- let t3;
- if ($[19] !== flexDirection || $[20] !== flexGrow || $[21] !== flexShrink || $[22] !== flexWrap || $[23] !== style || $[24] !== t1 || $[25] !== t2) {
- t3 = {
- flexWrap,
- flexDirection,
- flexGrow,
- flexShrink,
- ...style,
- overflowX: t1,
- overflowY: t2
- };
- $[19] = flexDirection;
- $[20] = flexGrow;
- $[21] = flexShrink;
- $[22] = flexWrap;
- $[23] = style;
- $[24] = t1;
- $[25] = t2;
- $[26] = t3;
- } else {
- t3 = $[26];
- }
- let t4;
- if ($[27] !== autoFocus || $[28] !== children || $[29] !== onBlur || $[30] !== onBlurCapture || $[31] !== onClick || $[32] !== onFocus || $[33] !== onFocusCapture || $[34] !== onKeyDown || $[35] !== onKeyDownCapture || $[36] !== onMouseEnter || $[37] !== onMouseLeave || $[38] !== ref || $[39] !== t3 || $[40] !== tabIndex) {
- t4 =
{children};
- $[27] = autoFocus;
- $[28] = children;
- $[29] = onBlur;
- $[30] = onBlurCapture;
- $[31] = onClick;
- $[32] = onFocus;
- $[33] = onFocusCapture;
- $[34] = onKeyDown;
- $[35] = onKeyDownCapture;
- $[36] = onMouseEnter;
- $[37] = onMouseLeave;
- $[38] = ref;
- $[39] = t3;
- $[40] = tabIndex;
- $[41] = t4;
- } else {
- t4 = $[41];
- }
- return t4;
+function Box({
+ children,
+ flexWrap = 'nowrap',
+ flexDirection = 'row',
+ flexGrow = 0,
+ flexShrink = 1,
+ ref,
+ tabIndex,
+ autoFocus,
+ onClick,
+ onFocus,
+ onFocusCapture,
+ onBlur,
+ onBlurCapture,
+ onMouseEnter,
+ onMouseLeave,
+ onKeyDown,
+ onKeyDownCapture,
+ ...style
+}: PropsWithChildren
): React.ReactNode {
+ // Warn if spacing values are not integers to prevent fractional layout dimensions
+ warn.ifNotInteger(style.margin, 'margin')
+ warn.ifNotInteger(style.marginX, 'marginX')
+ warn.ifNotInteger(style.marginY, 'marginY')
+ warn.ifNotInteger(style.marginTop, 'marginTop')
+ warn.ifNotInteger(style.marginBottom, 'marginBottom')
+ warn.ifNotInteger(style.marginLeft, 'marginLeft')
+ warn.ifNotInteger(style.marginRight, 'marginRight')
+ warn.ifNotInteger(style.padding, 'padding')
+ warn.ifNotInteger(style.paddingX, 'paddingX')
+ warn.ifNotInteger(style.paddingY, 'paddingY')
+ warn.ifNotInteger(style.paddingTop, 'paddingTop')
+ warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
+ warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
+ warn.ifNotInteger(style.paddingRight, 'paddingRight')
+ warn.ifNotInteger(style.gap, 'gap')
+ warn.ifNotInteger(style.columnGap, 'columnGap')
+ warn.ifNotInteger(style.rowGap, 'rowGap')
+
+ return (
+
+ {children}
+
+ )
}
-export default Box;
+
+export default Box
diff --git a/src/ink/components/Button.tsx b/src/ink/components/Button.tsx
index 95b3ae711..0095d9c59 100644
--- a/src/ink/components/Button.tsx
+++ b/src/ink/components/Button.tsx
@@ -1,32 +1,39 @@
-import { c as _c } from "react/compiler-runtime";
-import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
-import type { Except } from 'type-fest';
-import type { DOMElement } from '../dom.js';
-import type { ClickEvent } from '../events/click-event.js';
-import type { FocusEvent } from '../events/focus-event.js';
-import type { KeyboardEvent } from '../events/keyboard-event.js';
-import type { Styles } from '../styles.js';
-import Box from './Box.js';
+import React, {
+ type Ref,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+import type { Except } from 'type-fest'
+import type { DOMElement } from '../dom.js'
+import type { ClickEvent } from '../events/click-event.js'
+import type { FocusEvent } from '../events/focus-event.js'
+import type { KeyboardEvent } from '../events/keyboard-event.js'
+import type { Styles } from '../styles.js'
+import Box from './Box.js'
+
type ButtonState = {
- focused: boolean;
- hovered: boolean;
- active: boolean;
-};
+ focused: boolean
+ hovered: boolean
+ active: boolean
+}
+
export type Props = Except & {
- ref?: Ref;
+ ref?: Ref
/**
* Called when the button is activated via Enter, Space, or click.
*/
- onAction: () => void;
+ onAction: () => void
/**
* Tab order index. Defaults to 0 (in tab order).
* Set to -1 for programmatically focusable only.
*/
- tabIndex?: number;
+ tabIndex?: number
/**
* Focus this button when it mounts.
*/
- autoFocus?: boolean;
+ autoFocus?: boolean
/**
* Render prop receiving the interactive state. Use this to
* style children based on focus/hover/active — Button itself
@@ -34,158 +41,82 @@ export type Props = Except & {
*
* If not provided, children render as-is (no state-dependent styling).
*/
- children: ((state: ButtonState) => React.ReactNode) | React.ReactNode;
-};
-function Button(t0) {
- const $ = _c(30);
- let autoFocus;
- let children;
- let onAction;
- let ref;
- let style;
- let t1;
- if ($[0] !== t0) {
- ({
- onAction,
- tabIndex: t1,
- autoFocus,
- children,
- ref,
- ...style
- } = t0);
- $[0] = t0;
- $[1] = autoFocus;
- $[2] = children;
- $[3] = onAction;
- $[4] = ref;
- $[5] = style;
- $[6] = t1;
- } else {
- autoFocus = $[1];
- children = $[2];
- onAction = $[3];
- ref = $[4];
- style = $[5];
- t1 = $[6];
- }
- const tabIndex = t1 === undefined ? 0 : t1;
- const [isFocused, setIsFocused] = useState(false);
- const [isHovered, setIsHovered] = useState(false);
- const [isActive, setIsActive] = useState(false);
- const activeTimer = useRef(null);
- let t2;
- let t3;
- if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
- t2 = () => () => {
- if (activeTimer.current) {
- clearTimeout(activeTimer.current);
- }
- };
- t3 = [];
- $[7] = t2;
- $[8] = t3;
- } else {
- t2 = $[7];
- t3 = $[8];
- }
- useEffect(t2, t3);
- let t4;
- if ($[9] !== onAction) {
- t4 = e => {
- if (e.key === "return" || e.key === " ") {
- e.preventDefault();
- setIsActive(true);
- onAction();
- if (activeTimer.current) {
- clearTimeout(activeTimer.current);
- }
- activeTimer.current = setTimeout(_temp, 100, setIsActive);
- }
- };
- $[9] = onAction;
- $[10] = t4;
- } else {
- t4 = $[10];
- }
- const handleKeyDown = t4;
- let t5;
- if ($[11] !== onAction) {
- t5 = _e => {
- onAction();
- };
- $[11] = onAction;
- $[12] = t5;
- } else {
- t5 = $[12];
- }
- const handleClick = t5;
- let t6;
- if ($[13] === Symbol.for("react.memo_cache_sentinel")) {
- t6 = _e_0 => setIsFocused(true);
- $[13] = t6;
- } else {
- t6 = $[13];
- }
- const handleFocus = t6;
- let t7;
- if ($[14] === Symbol.for("react.memo_cache_sentinel")) {
- t7 = _e_1 => setIsFocused(false);
- $[14] = t7;
- } else {
- t7 = $[14];
- }
- const handleBlur = t7;
- let t8;
- if ($[15] === Symbol.for("react.memo_cache_sentinel")) {
- t8 = () => setIsHovered(true);
- $[15] = t8;
- } else {
- t8 = $[15];
- }
- const handleMouseEnter = t8;
- let t9;
- if ($[16] === Symbol.for("react.memo_cache_sentinel")) {
- t9 = () => setIsHovered(false);
- $[16] = t9;
- } else {
- t9 = $[16];
- }
- const handleMouseLeave = t9;
- let t10;
- if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) {
- const state = {
- focused: isFocused,
- hovered: isHovered,
- active: isActive
- };
- t10 = typeof children === "function" ? children(state) : children;
- $[17] = children;
- $[18] = isActive;
- $[19] = isFocused;
- $[20] = isHovered;
- $[21] = t10;
- } else {
- t10 = $[21];
- }
- const content = t10;
- let t11;
- if ($[22] !== autoFocus || $[23] !== content || $[24] !== handleClick || $[25] !== handleKeyDown || $[26] !== ref || $[27] !== style || $[28] !== tabIndex) {
- t11 = {content};
- $[22] = autoFocus;
- $[23] = content;
- $[24] = handleClick;
- $[25] = handleKeyDown;
- $[26] = ref;
- $[27] = style;
- $[28] = tabIndex;
- $[29] = t11;
- } else {
- t11 = $[29];
- }
- return t11;
+ children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
}
-function _temp(setter) {
- return setter(false);
+
+function Button({
+ onAction,
+ tabIndex = 0,
+ autoFocus,
+ children,
+ ref,
+ ...style
+}: Props): React.ReactNode {
+ const [isFocused, setIsFocused] = useState(false)
+ const [isHovered, setIsHovered] = useState(false)
+ const [isActive, setIsActive] = useState(false)
+
+ const activeTimer = useRef | null>(null)
+
+ useEffect(() => {
+ return () => {
+ if (activeTimer.current) clearTimeout(activeTimer.current)
+ }
+ }, [])
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'return' || e.key === ' ') {
+ e.preventDefault()
+ setIsActive(true)
+ onAction()
+ if (activeTimer.current) clearTimeout(activeTimer.current)
+ activeTimer.current = setTimeout(
+ setter => setter(false),
+ 100,
+ setIsActive,
+ )
+ }
+ },
+ [onAction],
+ )
+
+ const handleClick = useCallback(
+ (_e: ClickEvent) => {
+ onAction()
+ },
+ [onAction],
+ )
+
+ const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
+ const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
+ const handleMouseEnter = useCallback(() => setIsHovered(true), [])
+ const handleMouseLeave = useCallback(() => setIsHovered(false), [])
+
+ const state: ButtonState = {
+ focused: isFocused,
+ hovered: isHovered,
+ active: isActive,
+ }
+ const content = typeof children === 'function' ? children(state) : children
+
+ return (
+
+ {content}
+
+ )
}
-export default Button;
-export type { ButtonState };
+
+export default Button
+export type { ButtonState }
diff --git a/src/ink/components/ClockContext.tsx b/src/ink/components/ClockContext.tsx
index 62b5bf0a5..32a8b9a28 100644
--- a/src/ink/components/ClockContext.tsx
+++ b/src/ink/components/ClockContext.tsx
@@ -1,111 +1,99 @@
-import { c as _c } from "react/compiler-runtime";
-import React, { createContext, useEffect, useState } from 'react';
-import { FRAME_INTERVAL_MS } from '../constants.js';
-import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
+import React, { createContext, useEffect, useState } from 'react'
+import { FRAME_INTERVAL_MS } from '../constants.js'
+import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
+
export type Clock = {
- subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
- now: () => number;
- setTickInterval: (ms: number) => void;
-};
+ subscribe: (onChange: () => void, keepAlive: boolean) => () => void
+ now: () => number
+ setTickInterval: (ms: number) => void
+}
+
export function createClock(tickIntervalMs: number): Clock {
- const subscribers = new Map<() => void, boolean>();
- let interval: ReturnType | null = null;
- let currentTickIntervalMs = tickIntervalMs;
- let startTime = 0;
+ const subscribers = new Map<() => void, boolean>()
+ let interval: ReturnType | null = null
+ let currentTickIntervalMs = tickIntervalMs
+ let startTime = 0
// Snapshot of the current tick's time, ensuring all subscribers in the same
// tick see the same value (keeps animations synchronized)
- let tickTime = 0;
+ let tickTime = 0
+
function tick(): void {
- tickTime = Date.now() - startTime;
+ tickTime = Date.now() - startTime
for (const onChange of subscribers.keys()) {
- onChange();
+ onChange()
}
}
+
function updateInterval(): void {
- const anyKeepAlive = [...subscribers.values()].some(Boolean);
+ const anyKeepAlive = [...subscribers.values()].some(Boolean)
+
if (anyKeepAlive) {
if (interval) {
- clearInterval(interval);
- interval = null;
+ clearInterval(interval)
+ interval = null
}
if (startTime === 0) {
- startTime = Date.now();
+ startTime = Date.now()
}
- interval = setInterval(tick, currentTickIntervalMs);
+ interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) {
- clearInterval(interval);
- interval = null;
+ clearInterval(interval)
+ interval = null
}
}
+
return {
subscribe(onChange, keepAlive) {
- subscribers.set(onChange, keepAlive);
- updateInterval();
+ subscribers.set(onChange, keepAlive)
+ updateInterval()
return () => {
- subscribers.delete(onChange);
- updateInterval();
- };
+ subscribers.delete(onChange)
+ updateInterval()
+ }
},
+
now() {
if (startTime === 0) {
- startTime = Date.now();
+ startTime = Date.now()
}
// When the clock interval is running, return the synchronized tickTime
// so all subscribers in the same tick see the same value.
// When paused (no keepAlive subscribers), return real-time to avoid
// returning a stale tickTime from the last tick before the pause.
if (interval && tickTime) {
- return tickTime;
+ return tickTime
}
- return Date.now() - startTime;
+ return Date.now() - startTime
},
+
setTickInterval(ms) {
- if (ms === currentTickIntervalMs) return;
- currentTickIntervalMs = ms;
- updateInterval();
- }
- };
+ if (ms === currentTickIntervalMs) return
+ currentTickIntervalMs = ms
+ updateInterval()
+ },
+ }
}
-export const ClockContext = createContext(null);
-const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2;
+
+export const ClockContext = createContext(null)
+
+const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
// Own component so App.tsx doesn't re-render when the clock is created.
// The clock value is stable (created once via useState), so the provider
// never causes consumer re-renders on its own.
-export function ClockProvider(t0) {
- const $ = _c(7);
- const {
- children
- } = t0;
- const [clock] = useState(_temp);
- const focused = useTerminalFocus();
- let t1;
- let t2;
- if ($[0] !== clock || $[1] !== focused) {
- t1 = () => {
- clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
- };
- t2 = [clock, focused];
- $[0] = clock;
- $[1] = focused;
- $[2] = t1;
- $[3] = t2;
- } else {
- t1 = $[2];
- t2 = $[3];
- }
- useEffect(t1, t2);
- let t3;
- if ($[4] !== children || $[5] !== clock) {
- t3 = {children};
- $[4] = children;
- $[5] = clock;
- $[6] = t3;
- } else {
- t3 = $[6];
- }
- return t3;
-}
-function _temp() {
- return createClock(FRAME_INTERVAL_MS);
+export function ClockProvider({
+ children,
+}: {
+ children: React.ReactNode
+}): React.ReactNode {
+ const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
+ const focused = useTerminalFocus()
+
+ useEffect(() => {
+ clock.setTickInterval(
+ focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
+ )
+ }, [clock, focused])
+
+ return {children}
}
diff --git a/src/ink/components/ErrorOverview.tsx b/src/ink/components/ErrorOverview.tsx
index da8ce9367..3effc4217 100644
--- a/src/ink/components/ErrorOverview.tsx
+++ b/src/ink/components/ErrorOverview.tsx
@@ -1,55 +1,57 @@
-import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
-import { readFileSync } from 'fs';
-import React from 'react';
-import StackUtils from 'stack-utils';
-import Box from './Box.js';
-import Text from './Text.js';
+import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
+import { readFileSync } from 'fs'
+import React from 'react'
+import StackUtils from 'stack-utils'
+import Box from './Box.js'
+import Text from './Text.js'
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
// Error's source file is reported as file:///home/user/file.js
// This function removes the file://[cwd] part
const cleanupPath = (path: string | undefined): string | undefined => {
- return path?.replace(`file://${process.cwd()}/`, '');
-};
-let stackUtils: StackUtils | undefined;
+ return path?.replace(`file://${process.cwd()}/`, '')
+}
+
+let stackUtils: StackUtils | undefined
function getStackUtils(): StackUtils {
- return stackUtils ??= new StackUtils({
+ return (stackUtils ??= new StackUtils({
cwd: process.cwd(),
- internals: StackUtils.nodeInternals()
- });
+ internals: StackUtils.nodeInternals(),
+ }))
}
/* eslint-enable custom-rules/no-process-cwd */
type Props = {
- readonly error: Error;
-};
-export default function ErrorOverview({
- error
-}: Props) {
- const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
- const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined;
- const filePath = cleanupPath(origin?.file);
- let excerpt: CodeExcerpt[] | undefined;
- let lineWidth = 0;
+ readonly error: Error
+}
+
+export default function ErrorOverview({ error }: Props) {
+ const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
+ const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
+ const filePath = cleanupPath(origin?.file)
+ let excerpt: CodeExcerpt[] | undefined
+ let lineWidth = 0
+
if (filePath && origin?.line) {
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
- const sourceCode = readFileSync(filePath, 'utf8');
- excerpt = codeExcerpt(sourceCode, origin.line);
+ const sourceCode = readFileSync(filePath, 'utf8')
+ excerpt = codeExcerpt(sourceCode, origin.line)
+
if (excerpt) {
- for (const {
- line
- } of excerpt) {
- lineWidth = Math.max(lineWidth, String(line).length);
+ for (const { line } of excerpt) {
+ lineWidth = Math.max(lineWidth, String(line).length)
}
}
} catch {
// file not readable — skip source context
}
}
- return
+
+ return (
+
{' '}
@@ -59,41 +61,62 @@ export default function ErrorOverview({
{error.message}
- {origin && filePath &&
+ {origin && filePath && (
+
{filePath}:{origin.line}:{origin.column}
- }
+
+ )}
- {origin && excerpt &&
- {excerpt.map(({
- line: line_0,
- value
- }) =>
+ {origin && excerpt && (
+
+ {excerpt.map(({ line, value }) => (
+
-
- {String(line_0).padStart(lineWidth, ' ')}:
+
+ {String(line).padStart(lineWidth, ' ')}:
-
+
{' ' + value}
- )}
- }
+
+ ))}
+
+ )}
- {error.stack &&
- {error.stack.split('\n').slice(1).map(line_1 => {
- const parsedLine = getStackUtils().parseLine(line_1);
+ {error.stack && (
+
+ {error.stack
+ .split('\n')
+ .slice(1)
+ .map(line => {
+ const parsedLine = getStackUtils().parseLine(line)
- // If the line from the stack cannot be parsed, we print out the unparsed line.
- if (!parsedLine) {
- return
+ // If the line from the stack cannot be parsed, we print out the unparsed line.
+ if (!parsedLine) {
+ return (
+
-
- {line_1}
- ;
- }
- return
+ {line}
+
+ )
+ }
+
+ return (
+
-
{parsedLine.function}
@@ -101,8 +124,11 @@ export default function ErrorOverview({
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
- ;
- })}
- }
- ;
+
+ )
+ })}
+
+ )}
+
+ )
}
diff --git a/src/ink/components/Link.tsx b/src/ink/components/Link.tsx
index 772f344d0..ee7f04d14 100644
--- a/src/ink/components/Link.tsx
+++ b/src/ink/components/Link.tsx
@@ -1,41 +1,31 @@
-import { c as _c } from "react/compiler-runtime";
-import type { ReactNode } from 'react';
-import React from 'react';
-import { supportsHyperlinks } from '../supports-hyperlinks.js';
-import Text from './Text.js';
+import type { ReactNode } from 'react'
+import React from 'react'
+import { supportsHyperlinks } from '../supports-hyperlinks.js'
+import Text from './Text.js'
+
export type Props = {
- readonly children?: ReactNode;
- readonly url: string;
- readonly fallback?: ReactNode;
-};
-export default function Link(t0) {
- const $ = _c(5);
- const {
- children,
- url,
- fallback
- } = t0;
- const content = children ?? url;
- if (supportsHyperlinks()) {
- let t1;
- if ($[0] !== content || $[1] !== url) {
- t1 = {content};
- $[0] = content;
- $[1] = url;
- $[2] = t1;
- } else {
- t1 = $[2];
- }
- return t1;
- }
- const t1 = fallback ?? content;
- let t2;
- if ($[3] !== t1) {
- t2 = {t1};
- $[3] = t1;
- $[4] = t2;
- } else {
- t2 = $[4];
- }
- return t2;
+ readonly children?: ReactNode
+ readonly url: string
+ readonly fallback?: ReactNode
+}
+
+export default function Link({
+ children,
+ url,
+ fallback,
+}: Props): React.ReactNode {
+ // Use children if provided, otherwise display the URL
+ const content = children ?? url
+
+ if (supportsHyperlinks()) {
+ // Wrap in Text to ensure we're in a text context
+ // (ink-link is a text element like ink-text)
+ return (
+
+ {content}
+
+ )
+ }
+
+ return {fallback ?? content}
}
diff --git a/src/ink/components/Newline.tsx b/src/ink/components/Newline.tsx
index c5e6b2b76..b8d6a88a2 100644
--- a/src/ink/components/Newline.tsx
+++ b/src/ink/components/Newline.tsx
@@ -1,38 +1,17 @@
-import { c as _c } from "react/compiler-runtime";
-import React from 'react';
+import React from 'react'
+
export type Props = {
/**
* Number of newlines to insert.
*
* @default 1
*/
- readonly count?: number;
-};
+ readonly count?: number
+}
/**
* Adds one or more newline (\n) characters. Must be used within components.
*/
-export default function Newline(t0) {
- const $ = _c(4);
- const {
- count: t1
- } = t0;
- const count = t1 === undefined ? 1 : t1;
- let t2;
- if ($[0] !== count) {
- t2 = "\n".repeat(count);
- $[0] = count;
- $[1] = t2;
- } else {
- t2 = $[1];
- }
- let t3;
- if ($[2] !== t2) {
- t3 = {t2};
- $[2] = t2;
- $[3] = t3;
- } else {
- t3 = $[3];
- }
- return t3;
+export default function Newline({ count = 1 }: Props) {
+ return {'\n'.repeat(count)}
}
diff --git a/src/ink/components/NoSelect.tsx b/src/ink/components/NoSelect.tsx
index ab0876919..882097608 100644
--- a/src/ink/components/NoSelect.tsx
+++ b/src/ink/components/NoSelect.tsx
@@ -1,6 +1,6 @@
-import { c as _c } from "react/compiler-runtime";
-import React, { type PropsWithChildren } from 'react';
-import Box, { type Props as BoxProps } from './Box.js';
+import React, { type PropsWithChildren } from 'react'
+import Box, { type Props as BoxProps } from './Box.js'
+
type Props = Omit & {
/**
* Extend the exclusion zone from column 0 to this box's right edge,
@@ -11,8 +11,8 @@ type Props = Omit & {
*
* @default false
*/
- fromLeftEdge?: boolean;
-};
+ fromLeftEdge?: boolean
+}
/**
* Marks its contents as non-selectable in fullscreen text selection.
@@ -32,36 +32,14 @@ type Props = Omit & {
* tracking). No-op in the main-screen scrollback render where the
* terminal's native selection is used instead.
*/
-export function NoSelect(t0) {
- const $ = _c(8);
- let boxProps;
- let children;
- let fromLeftEdge;
- if ($[0] !== t0) {
- ({
- children,
- fromLeftEdge,
- ...boxProps
- } = t0);
- $[0] = t0;
- $[1] = boxProps;
- $[2] = children;
- $[3] = fromLeftEdge;
- } else {
- boxProps = $[1];
- children = $[2];
- fromLeftEdge = $[3];
- }
- const t1 = fromLeftEdge ? "from-left-edge" : true;
- let t2;
- if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) {
- t2 = {children};
- $[4] = boxProps;
- $[5] = children;
- $[6] = t1;
- $[7] = t2;
- } else {
- t2 = $[7];
- }
- return t2;
+export function NoSelect({
+ children,
+ fromLeftEdge,
+ ...boxProps
+}: PropsWithChildren): React.ReactNode {
+ return (
+
+ {children}
+
+ )
}
diff --git a/src/ink/components/RawAnsi.tsx b/src/ink/components/RawAnsi.tsx
index 732005164..a1a23ab4b 100644
--- a/src/ink/components/RawAnsi.tsx
+++ b/src/ink/components/RawAnsi.tsx
@@ -1,14 +1,14 @@
-import { c as _c } from "react/compiler-runtime";
-import React from 'react';
+import React from 'react'
+
type Props = {
/**
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
*/
- lines: string[];
+ lines: string[]
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
- width: number;
-};
+ width: number
+}
/**
* Bypass the → React tree → Yoga → squash → re-serialize roundtrip for
@@ -25,32 +25,15 @@ type Props = {
* (width × lines.length) and hands the joined string straight to output.write(),
* which already splits on '\n' and parses ANSI into the screen buffer.
*/
-export function RawAnsi(t0) {
- const $ = _c(6);
- const {
- lines,
- width
- } = t0;
+export function RawAnsi({ lines, width }: Props): React.ReactNode {
if (lines.length === 0) {
- return null;
+ return null
}
- let t1;
- if ($[0] !== lines) {
- t1 = lines.join("\n");
- $[0] = lines;
- $[1] = t1;
- } else {
- t1 = $[1];
- }
- let t2;
- if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
- t2 = ;
- $[2] = lines.length;
- $[3] = t1;
- $[4] = width;
- $[5] = t2;
- } else {
- t2 = $[5];
- }
- return t2;
+ return (
+
+ )
}
diff --git a/src/ink/components/ScrollBox.tsx b/src/ink/components/ScrollBox.tsx
index 7174deede..c2d432be2 100644
--- a/src/ink/components/ScrollBox.tsx
+++ b/src/ink/components/ScrollBox.tsx
@@ -1,14 +1,21 @@
-import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
-import type { Except } from 'type-fest';
-import { markScrollActivity } from '../../bootstrap/state.js';
-import type { DOMElement } from '../dom.js';
-import { markDirty, scheduleRenderFrom } from '../dom.js';
-import { markCommitStart } from '../reconciler.js';
-import type { Styles } from '../styles.js';
-import Box from './Box.js';
+import React, {
+ type PropsWithChildren,
+ type Ref,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react'
+import type { Except } from 'type-fest'
+import { markScrollActivity } from '../../bootstrap/state.js'
+import type { DOMElement } from '../dom.js'
+import { markDirty, scheduleRenderFrom } from '../dom.js'
+import { markCommitStart } from '../reconciler.js'
+import type { Styles } from '../styles.js'
+import Box from './Box.js'
+
export type ScrollBoxHandle = {
- scrollTo: (y: number) => void;
- scrollBy: (dy: number) => void;
+ scrollTo: (y: number) => void
+ scrollBy: (dy: number) => void
/**
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
* scrollTo which bakes a number that's stale by the time the throttled
@@ -16,24 +23,24 @@ export type ScrollBoxHandle = {
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
*/
- scrollToElement: (el: DOMElement, offset?: number) => void;
- scrollToBottom: () => void;
- getScrollTop: () => number;
- getPendingDelta: () => number;
- getScrollHeight: () => number;
+ scrollToElement: (el: DOMElement, offset?: number) => void
+ scrollToBottom: () => void
+ getScrollTop: () => number
+ getPendingDelta: () => number
+ getScrollHeight: () => number
/**
* Like getScrollHeight, but reads Yoga directly instead of the cached
* value written by render-node-to-output (throttled, up to 16ms stale).
* Use when you need a fresh value in useLayoutEffect after a React commit
* that grew content. Slightly more expensive (native Yoga call).
*/
- getFreshScrollHeight: () => number;
- getViewportHeight: () => number;
+ getFreshScrollHeight: () => number
+ getViewportHeight: () => number
/**
* Absolute screen-buffer row of the first visible content line (inside
* padding). Used for drag-to-scroll edge detection.
*/
- getViewportTop: () => number;
+ getViewportTop: () => number
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
@@ -41,14 +48,14 @@ export type ScrollBoxHandle = {
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
* layout values (unlike scrollTop+viewportH >= scrollHeight).
*/
- isSticky: () => boolean;
+ isSticky: () => boolean
/**
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
* happen during Ink's render phase after React has committed. Callers that
* care about the sticky case should treat "at bottom" as a fallback.
*/
- subscribe: (listener: () => void) => () => void;
+ subscribe: (listener: () => void) => () => void
/**
* Set the render-time scrollTop clamp to the currently-mounted children's
* coverage span. Called by useVirtualScroll after computing its range;
@@ -57,16 +64,20 @@ export type ScrollBoxHandle = {
* content instead of blank spacer. Pass undefined to disable (sticky,
* cold start).
*/
- setClampBounds: (min: number | undefined, max: number | undefined) => void;
-};
-export type ScrollBoxProps = Except & {
- ref?: Ref;
+ setClampBounds: (min: number | undefined, max: number | undefined) => void
+}
+
+export type ScrollBoxProps = Except<
+ Styles,
+ 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
+> & {
+ ref?: Ref
/**
* When true, automatically pins scroll position to the bottom when content
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
*/
- stickyScroll?: boolean;
-};
+ stickyScroll?: boolean
+}
/**
* A Box with `overflow: scroll` and an imperative scroll API.
@@ -84,7 +95,7 @@ function ScrollBox({
stickyScroll,
...style
}: PropsWithChildren): React.ReactNode {
- const domRef = useRef(null);
+ const domRef = useRef(null)
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
// mark it dirty, and call the root's throttled scheduleRender directly.
// The Ink renderer reads scrollTop from the node — no React state needed,
@@ -93,114 +104,121 @@ function ScrollBox({
// render — otherwise scheduleRender's leading edge fires on the FIRST
// event before subsequent events mutate scrollTop. scrollToBottom still
// forces a React render: sticky is attribute-observed, no DOM-only path.
- const [, forceRender] = useState(0);
- const listenersRef = useRef(new Set<() => void>());
- const renderQueuedRef = useRef(false);
+ const [, forceRender] = useState(0)
+ const listenersRef = useRef(new Set<() => void>())
+ const renderQueuedRef = useRef(false)
+
const notify = () => {
- for (const l of listenersRef.current) l();
- };
+ for (const l of listenersRef.current) l()
+ }
+
function scrollMutated(el: DOMElement): void {
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
// check) to skip their next tick — they compete for the event loop and
// contributed to 1402ms max frame gaps during scroll drain.
- markScrollActivity();
- markDirty(el);
- markCommitStart();
- notify();
- if (renderQueuedRef.current) return;
- renderQueuedRef.current = true;
+ markScrollActivity()
+ markDirty(el)
+ markCommitStart()
+ notify()
+ if (renderQueuedRef.current) return
+ renderQueuedRef.current = true
queueMicrotask(() => {
- renderQueuedRef.current = false;
- scheduleRenderFrom(el);
- });
+ renderQueuedRef.current = false
+ scheduleRenderFrom(el)
+ })
}
- useImperativeHandle(ref, (): ScrollBoxHandle => ({
- scrollTo(y: number) {
- const el = domRef.current;
- if (!el) return;
- // Explicit false overrides the DOM attribute so manual scroll
- // breaks stickiness. Render code checks ?? precedence.
- el.stickyScroll = false;
- el.pendingScrollDelta = undefined;
- el.scrollAnchor = undefined;
- el.scrollTop = Math.max(0, Math.floor(y));
- scrollMutated(el);
- },
- scrollToElement(el: DOMElement, offset = 0) {
- const box = domRef.current;
- if (!box) return;
- box.stickyScroll = false;
- box.pendingScrollDelta = undefined;
- box.scrollAnchor = {
- el,
- offset
- };
- scrollMutated(box);
- },
- scrollBy(dy: number) {
- const el = domRef.current;
- if (!el) return;
- el.stickyScroll = false;
- // Wheel input cancels any in-flight anchor seek — user override.
- el.scrollAnchor = undefined;
- // Accumulate in pendingScrollDelta; renderer drains it at a capped
- // rate so fast flicks show intermediate frames. Pure accumulator:
- // scroll-up followed by scroll-down naturally cancels.
- el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
- scrollMutated(el);
- },
- scrollToBottom() {
- const el = domRef.current;
- if (!el) return;
- el.pendingScrollDelta = undefined;
- el.stickyScroll = true;
- markDirty(el);
- notify();
- forceRender(n => n + 1);
- },
- getScrollTop() {
- return domRef.current?.scrollTop ?? 0;
- },
- getPendingDelta() {
- // Accumulated-but-not-yet-drained delta. useVirtualScroll needs
- // this to mount the union [committed, committed+pending] range —
- // otherwise intermediate drain frames find no children (blank).
- return domRef.current?.pendingScrollDelta ?? 0;
- },
- getScrollHeight() {
- return domRef.current?.scrollHeight ?? 0;
- },
- getFreshScrollHeight() {
- const content = domRef.current?.childNodes[0] as DOMElement | undefined;
- return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
- },
- getViewportHeight() {
- return domRef.current?.scrollViewportHeight ?? 0;
- },
- getViewportTop() {
- return domRef.current?.scrollViewportTop ?? 0;
- },
- isSticky() {
- const el = domRef.current;
- if (!el) return false;
- return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']);
- },
- subscribe(listener: () => void) {
- listenersRef.current.add(listener);
- return () => listenersRef.current.delete(listener);
- },
- setClampBounds(min, max) {
- const el = domRef.current;
- if (!el) return;
- el.scrollClampMin = min;
- el.scrollClampMax = max;
- }
- }),
- // notify/scrollMutated are inline (no useCallback) but only close over
- // refs + imports — stable. Empty deps avoids rebuilding the handle on
- // every render (which re-registers the ref = churn).
- // eslint-disable-next-line react-hooks/exhaustive-deps
- []);
+
+ useImperativeHandle(
+ ref,
+ (): ScrollBoxHandle => ({
+ scrollTo(y: number) {
+ const el = domRef.current
+ if (!el) return
+ // Explicit false overrides the DOM attribute so manual scroll
+ // breaks stickiness. Render code checks ?? precedence.
+ el.stickyScroll = false
+ el.pendingScrollDelta = undefined
+ el.scrollAnchor = undefined
+ el.scrollTop = Math.max(0, Math.floor(y))
+ scrollMutated(el)
+ },
+ scrollToElement(el: DOMElement, offset = 0) {
+ const box = domRef.current
+ if (!box) return
+ box.stickyScroll = false
+ box.pendingScrollDelta = undefined
+ box.scrollAnchor = { el, offset }
+ scrollMutated(box)
+ },
+ scrollBy(dy: number) {
+ const el = domRef.current
+ if (!el) return
+ el.stickyScroll = false
+ // Wheel input cancels any in-flight anchor seek — user override.
+ el.scrollAnchor = undefined
+ // Accumulate in pendingScrollDelta; renderer drains it at a capped
+ // rate so fast flicks show intermediate frames. Pure accumulator:
+ // scroll-up followed by scroll-down naturally cancels.
+ el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
+ scrollMutated(el)
+ },
+ scrollToBottom() {
+ const el = domRef.current
+ if (!el) return
+ el.pendingScrollDelta = undefined
+ el.stickyScroll = true
+ markDirty(el)
+ notify()
+ forceRender(n => n + 1)
+ },
+ getScrollTop() {
+ return domRef.current?.scrollTop ?? 0
+ },
+ getPendingDelta() {
+ // Accumulated-but-not-yet-drained delta. useVirtualScroll needs
+ // this to mount the union [committed, committed+pending] range —
+ // otherwise intermediate drain frames find no children (blank).
+ return domRef.current?.pendingScrollDelta ?? 0
+ },
+ getScrollHeight() {
+ return domRef.current?.scrollHeight ?? 0
+ },
+ getFreshScrollHeight() {
+ const content = domRef.current?.childNodes[0] as DOMElement | undefined
+ return (
+ content?.yogaNode?.getComputedHeight() ??
+ domRef.current?.scrollHeight ??
+ 0
+ )
+ },
+ getViewportHeight() {
+ return domRef.current?.scrollViewportHeight ?? 0
+ },
+ getViewportTop() {
+ return domRef.current?.scrollViewportTop ?? 0
+ },
+ isSticky() {
+ const el = domRef.current
+ if (!el) return false
+ return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
+ },
+ subscribe(listener: () => void) {
+ listenersRef.current.add(listener)
+ return () => listenersRef.current.delete(listener)
+ },
+ setClampBounds(min, max) {
+ const el = domRef.current
+ if (!el) return
+ el.scrollClampMin = min
+ el.scrollClampMax = max
+ },
+ }),
+ // notify/scrollMutated are inline (no useCallback) but only close over
+ // refs + imports — stable. Empty deps avoids rebuilding the handle on
+ // every render (which re-registers the ref = churn).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ )
// Structure: outer viewport (overflow:scroll, constrained height) >
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
@@ -213,23 +231,28 @@ function ScrollBox({
// stickyScroll is passed as a DOM attribute (via ink-box directly) so it's
// available on the first render — ref callbacks fire after the initial
// commit, which is too late for the first frame.
- return {
- domRef.current = el;
- if (el) el.scrollTop ??= 0;
- }} style={{
- flexWrap: 'nowrap',
- flexDirection: style.flexDirection ?? 'row',
- flexGrow: style.flexGrow ?? 0,
- flexShrink: style.flexShrink ?? 1,
- ...style,
- overflowX: 'scroll',
- overflowY: 'scroll'
- }} {...stickyScroll ? {
- stickyScroll: true
- } : {}}>
+ return (
+ {
+ domRef.current = el
+ if (el) el.scrollTop ??= 0
+ }}
+ style={{
+ flexWrap: 'nowrap',
+ flexDirection: style.flexDirection ?? 'row',
+ flexGrow: style.flexGrow ?? 0,
+ flexShrink: style.flexShrink ?? 1,
+ ...style,
+ overflowX: 'scroll',
+ overflowY: 'scroll',
+ }}
+ {...(stickyScroll ? { stickyScroll: true } : {})}
+ >
{children}
- ;
+
+ )
}
-export default ScrollBox;
+
+export default ScrollBox
diff --git a/src/ink/components/Spacer.tsx b/src/ink/components/Spacer.tsx
index f005e0230..eb55fa9e4 100644
--- a/src/ink/components/Spacer.tsx
+++ b/src/ink/components/Spacer.tsx
@@ -1,19 +1,10 @@
-import { c as _c } from "react/compiler-runtime";
-import React from 'react';
-import Box from './Box.js';
+import React from 'react'
+import Box from './Box.js'
/**
* A flexible space that expands along the major axis of its containing layout.
* It's useful as a shortcut for filling all the available spaces between elements.
*/
export default function Spacer() {
- const $ = _c(1);
- let t0;
- if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
- t0 = ;
- $[0] = t0;
- } else {
- t0 = $[0];
- }
- return t0;
+ return
}
diff --git a/src/ink/components/TerminalFocusContext.tsx b/src/ink/components/TerminalFocusContext.tsx
index 376e118a2..81dbaf60b 100644
--- a/src/ink/components/TerminalFocusContext.tsx
+++ b/src/ink/components/TerminalFocusContext.tsx
@@ -1,51 +1,53 @@
-import { c as _c } from "react/compiler-runtime";
-import React, { createContext, useMemo, useSyncExternalStore } from 'react';
-import { getTerminalFocused, getTerminalFocusState, subscribeTerminalFocus, type TerminalFocusState } from '../terminal-focus-state.js';
-export type { TerminalFocusState };
+import React, { createContext, useMemo, useSyncExternalStore } from 'react'
+import {
+ getTerminalFocused,
+ getTerminalFocusState,
+ subscribeTerminalFocus,
+ type TerminalFocusState,
+} from '../terminal-focus-state.js'
+
+export type { TerminalFocusState }
+
export type TerminalFocusContextProps = {
- readonly isTerminalFocused: boolean;
- readonly terminalFocusState: TerminalFocusState;
-};
+ readonly isTerminalFocused: boolean
+ readonly terminalFocusState: TerminalFocusState
+}
+
const TerminalFocusContext = createContext({
isTerminalFocused: true,
- terminalFocusState: 'unknown'
-});
+ terminalFocusState: 'unknown',
+})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
-TerminalFocusContext.displayName = 'TerminalFocusContext';
+TerminalFocusContext.displayName = 'TerminalFocusContext'
// Separate component so App.tsx doesn't re-render on focus changes.
// Children are a stable prop reference, so they don't re-render either —
// only components that consume the context will re-render.
-export function TerminalFocusProvider(t0) {
- const $ = _c(6);
- const {
- children
- } = t0;
- const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused);
- const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState);
- let t1;
- if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) {
- t1 = {
- isTerminalFocused,
- terminalFocusState
- };
- $[0] = isTerminalFocused;
- $[1] = terminalFocusState;
- $[2] = t1;
- } else {
- t1 = $[2];
- }
- const value = t1;
- let t2;
- if ($[3] !== children || $[4] !== value) {
- t2 = {children};
- $[3] = children;
- $[4] = value;
- $[5] = t2;
- } else {
- t2 = $[5];
- }
- return t2;
+export function TerminalFocusProvider({
+ children,
+}: {
+ children: React.ReactNode
+}): React.ReactNode {
+ const isTerminalFocused = useSyncExternalStore(
+ subscribeTerminalFocus,
+ getTerminalFocused,
+ )
+ const terminalFocusState = useSyncExternalStore(
+ subscribeTerminalFocus,
+ getTerminalFocusState,
+ )
+
+ const value = useMemo(
+ () => ({ isTerminalFocused, terminalFocusState }),
+ [isTerminalFocused, terminalFocusState],
+ )
+
+ return (
+
+ {children}
+
+ )
}
-export default TerminalFocusContext;
+
+export default TerminalFocusContext
diff --git a/src/ink/components/TerminalSizeContext.tsx b/src/ink/components/TerminalSizeContext.tsx
index 45cbf3ee8..cdf139c57 100644
--- a/src/ink/components/TerminalSizeContext.tsx
+++ b/src/ink/components/TerminalSizeContext.tsx
@@ -1,6 +1,8 @@
-import { createContext } from 'react';
+import { createContext } from 'react'
+
export type TerminalSize = {
- columns: number;
- rows: number;
-};
-export const TerminalSizeContext = createContext(null);
+ columns: number
+ rows: number
+}
+
+export const TerminalSizeContext = createContext(null)
diff --git a/src/ink/components/Text.tsx b/src/ink/components/Text.tsx
index bfec5b083..f2e2bdb77 100644
--- a/src/ink/components/Text.tsx
+++ b/src/ink/components/Text.tsx
@@ -1,253 +1,144 @@
-import { c as _c } from "react/compiler-runtime";
-import type { ReactNode } from 'react';
-import React from 'react';
-import type { Color, Styles, TextStyles } from '../styles.js';
+import type { ReactNode } from 'react'
+import React from 'react'
+import type { Color, Styles, TextStyles } from '../styles.js'
+
type BaseProps = {
/**
* Change text color. Accepts a raw color value (rgb, hex, ansi).
*/
- readonly color?: Color;
+ readonly color?: Color
/**
* Same as `color`, but for background.
*/
- readonly backgroundColor?: Color;
+ readonly backgroundColor?: Color
/**
* Make the text italic.
*/
- readonly italic?: boolean;
+ readonly italic?: boolean
/**
* Make the text underlined.
*/
- readonly underline?: boolean;
+ readonly underline?: boolean
/**
* Make the text crossed with a line.
*/
- readonly strikethrough?: boolean;
+ readonly strikethrough?: boolean
/**
* Inverse background and foreground colors.
*/
- readonly inverse?: boolean;
+ readonly inverse?: boolean
/**
* This property tells Ink to wrap or truncate text if its width is larger than container.
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
*/
- readonly wrap?: Styles['textWrap'];
- readonly children?: ReactNode;
-};
+ readonly wrap?: Styles['textWrap']
+
+ readonly children?: ReactNode
+}
/**
* Bold and dim are mutually exclusive in terminals.
* This type ensures you can use one or the other, but not both.
*/
-type WeightProps = {
- bold?: never;
- dim?: never;
-} | {
- bold: boolean;
- dim?: never;
-} | {
- dim: boolean;
- bold?: never;
-};
-export type Props = BaseProps & WeightProps;
+type WeightProps =
+ | { bold?: never; dim?: never }
+ | { bold: boolean; dim?: never }
+ | { dim: boolean; bold?: never }
+
+export type Props = BaseProps & WeightProps
+
const memoizedStylesForWrap: Record, Styles> = {
wrap: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'wrap'
+ textWrap: 'wrap',
},
'wrap-trim': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'wrap-trim'
+ textWrap: 'wrap-trim',
},
end: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'end'
+ textWrap: 'end',
},
middle: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'middle'
+ textWrap: 'middle',
},
'truncate-end': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'truncate-end'
+ textWrap: 'truncate-end',
},
truncate: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'truncate'
+ textWrap: 'truncate',
},
'truncate-middle': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'truncate-middle'
+ textWrap: 'truncate-middle',
},
'truncate-start': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
- textWrap: 'truncate-start'
- }
-} as const;
+ textWrap: 'truncate-start',
+ },
+} as const
/**
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
*/
-export default function Text(t0) {
- const $ = _c(29);
- const {
- color,
- backgroundColor,
- bold,
- dim,
- italic: t1,
- underline: t2,
- strikethrough: t3,
- inverse: t4,
- wrap: t5,
- children
- } = t0;
- const italic = t1 === undefined ? false : t1;
- const underline = t2 === undefined ? false : t2;
- const strikethrough = t3 === undefined ? false : t3;
- const inverse = t4 === undefined ? false : t4;
- const wrap = t5 === undefined ? "wrap" : t5;
+export default function Text({
+ color,
+ backgroundColor,
+ bold,
+ dim,
+ italic = false,
+ underline = false,
+ strikethrough = false,
+ inverse = false,
+ wrap = 'wrap',
+ children,
+}: Props): React.ReactNode {
if (children === undefined || children === null) {
- return null;
+ return null
}
- let t6;
- if ($[0] !== color) {
- t6 = color && {
- color
- };
- $[0] = color;
- $[1] = t6;
- } else {
- t6 = $[1];
+
+ // Build textStyles object with only the properties that are set
+ const textStyles: TextStyles = {
+ ...(color && { color }),
+ ...(backgroundColor && { backgroundColor }),
+ ...(dim && { dim }),
+ ...(bold && { bold }),
+ ...(italic && { italic }),
+ ...(underline && { underline }),
+ ...(strikethrough && { strikethrough }),
+ ...(inverse && { inverse }),
}
- let t7;
- if ($[2] !== backgroundColor) {
- t7 = backgroundColor && {
- backgroundColor
- };
- $[2] = backgroundColor;
- $[3] = t7;
- } else {
- t7 = $[3];
- }
- let t8;
- if ($[4] !== dim) {
- t8 = dim && {
- dim
- };
- $[4] = dim;
- $[5] = t8;
- } else {
- t8 = $[5];
- }
- let t9;
- if ($[6] !== bold) {
- t9 = bold && {
- bold
- };
- $[6] = bold;
- $[7] = t9;
- } else {
- t9 = $[7];
- }
- let t10;
- if ($[8] !== italic) {
- t10 = italic && {
- italic
- };
- $[8] = italic;
- $[9] = t10;
- } else {
- t10 = $[9];
- }
- let t11;
- if ($[10] !== underline) {
- t11 = underline && {
- underline
- };
- $[10] = underline;
- $[11] = t11;
- } else {
- t11 = $[11];
- }
- let t12;
- if ($[12] !== strikethrough) {
- t12 = strikethrough && {
- strikethrough
- };
- $[12] = strikethrough;
- $[13] = t12;
- } else {
- t12 = $[13];
- }
- let t13;
- if ($[14] !== inverse) {
- t13 = inverse && {
- inverse
- };
- $[14] = inverse;
- $[15] = t13;
- } else {
- t13 = $[15];
- }
- let t14;
- if ($[16] !== t10 || $[17] !== t11 || $[18] !== t12 || $[19] !== t13 || $[20] !== t6 || $[21] !== t7 || $[22] !== t8 || $[23] !== t9) {
- t14 = {
- ...t6,
- ...t7,
- ...t8,
- ...t9,
- ...t10,
- ...t11,
- ...t12,
- ...t13
- };
- $[16] = t10;
- $[17] = t11;
- $[18] = t12;
- $[19] = t13;
- $[20] = t6;
- $[21] = t7;
- $[22] = t8;
- $[23] = t9;
- $[24] = t14;
- } else {
- t14 = $[24];
- }
- const textStyles = t14;
- const t15 = memoizedStylesForWrap[wrap];
- let t16;
- if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) {
- t16 = {children};
- $[25] = children;
- $[26] = t15;
- $[27] = textStyles;
- $[28] = t16;
- } else {
- t16 = $[28];
- }
- return t16;
+
+ return (
+
+ {children}
+
+ )
}
diff --git a/src/ink/ink.tsx b/src/ink/ink.tsx
index 4aa1abe1b..65bf32bd3 100644
--- a/src/ink/ink.tsx
+++ b/src/ink/ink.tsx
@@ -1,129 +1,195 @@
-import autoBind from 'auto-bind';
-import { closeSync, constants as fsConstants, openSync, readSync, writeSync } from 'fs';
-import noop from 'lodash-es/noop.js';
-import throttle from 'lodash-es/throttle.js';
-import React, { type ReactNode } from 'react';
-import type { FiberRoot } from 'react-reconciler';
-import { ConcurrentRoot } from 'react-reconciler/constants.js';
-import { onExit } from 'signal-exit';
-import { flushInteractionTime } from 'src/bootstrap/state.js';
-import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js';
-import { logForDebugging } from 'src/utils/debug.js';
-import { logError } from 'src/utils/log.js';
-import { format } from 'util';
-import { colorize } from './colorize.js';
-import App from './components/App.js';
-import type { CursorDeclaration, CursorDeclarationSetter } from './components/CursorDeclarationContext.js';
-import { FRAME_INTERVAL_MS } from './constants.js';
-import * as dom from './dom.js';
-import { KeyboardEvent } from './events/keyboard-event.js';
-import { FocusManager } from './focus.js';
-import { emptyFrame, type Frame, type FrameEvent } from './frame.js';
-import { dispatchClick, dispatchHover } from './hit-test.js';
-import instances from './instances.js';
-import { LogUpdate } from './log-update.js';
-import { nodeCache } from './node-cache.js';
-import { optimize } from './optimizer.js';
-import Output from './output.js';
-import type { ParsedKey } from './parse-keypress.js';
-import reconciler, { dispatcher, getLastCommitMs, getLastYogaMs, isDebugRepaintsEnabled, recordYogaMs, resetProfileCounters } from './reconciler.js';
-import renderNodeToOutput, { consumeFollowScroll, didLayoutShift } from './render-node-to-output.js';
-import { applyPositionedHighlight, type MatchPosition, scanPositions } from './render-to-screen.js';
-import createRenderer, { type Renderer } from './renderer.js';
-import { CellWidth, CharPool, cellAt, createScreen, HyperlinkPool, isEmptyCellAt, migrateScreenPools, StylePool } from './screen.js';
-import { applySearchHighlight } from './searchHighlight.js';
-import { applySelectionOverlay, captureScrolledRows, clearSelection, createSelectionState, extendSelection, type FocusMove, findPlainTextUrlAt, getSelectedText, hasSelection, moveFocus, type SelectionState, selectLineAt, selectWordAt, shiftAnchor, shiftSelection, shiftSelectionForFollow, startSelection, updateSelection } from './selection.js';
-import { SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js';
-import { CURSOR_HOME, cursorMove, cursorPosition, DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, ERASE_SCREEN } from './termio/csi.js';
-import { DBP, DFE, DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN, SHOW_CURSOR } from './termio/dec.js';
-import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, setClipboard, supportsTabStatus, wrapForMultiplexer } from './termio/osc.js';
-import { TerminalWriteProvider } from './useTerminalNotification.js';
+import autoBind from 'auto-bind'
+import {
+ closeSync,
+ constants as fsConstants,
+ openSync,
+ readSync,
+ writeSync,
+} from 'fs'
+import noop from 'lodash-es/noop.js'
+import throttle from 'lodash-es/throttle.js'
+import React, { type ReactNode } from 'react'
+import type { FiberRoot } from 'react-reconciler'
+import { ConcurrentRoot } from 'react-reconciler/constants.js'
+import { onExit } from 'signal-exit'
+import { flushInteractionTime } from 'src/bootstrap/state.js'
+import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'
+import { logForDebugging } from 'src/utils/debug.js'
+import { logError } from 'src/utils/log.js'
+import { format } from 'util'
+import { colorize } from './colorize.js'
+import App from './components/App.js'
+import type {
+ CursorDeclaration,
+ CursorDeclarationSetter,
+} from './components/CursorDeclarationContext.js'
+import { FRAME_INTERVAL_MS } from './constants.js'
+import * as dom from './dom.js'
+import { KeyboardEvent } from './events/keyboard-event.js'
+import { FocusManager } from './focus.js'
+import { emptyFrame, type Frame, type FrameEvent } from './frame.js'
+import { dispatchClick, dispatchHover } from './hit-test.js'
+import instances from './instances.js'
+import { LogUpdate } from './log-update.js'
+import { nodeCache } from './node-cache.js'
+import { optimize } from './optimizer.js'
+import Output from './output.js'
+import type { ParsedKey } from './parse-keypress.js'
+import reconciler, {
+ dispatcher,
+ getLastCommitMs,
+ getLastYogaMs,
+ isDebugRepaintsEnabled,
+ recordYogaMs,
+ resetProfileCounters,
+} from './reconciler.js'
+import renderNodeToOutput, {
+ consumeFollowScroll,
+ didLayoutShift,
+} from './render-node-to-output.js'
+import {
+ applyPositionedHighlight,
+ type MatchPosition,
+ scanPositions,
+} from './render-to-screen.js'
+import createRenderer, { type Renderer } from './renderer.js'
+import {
+ CellWidth,
+ CharPool,
+ cellAt,
+ createScreen,
+ HyperlinkPool,
+ isEmptyCellAt,
+ migrateScreenPools,
+ StylePool,
+} from './screen.js'
+import { applySearchHighlight } from './searchHighlight.js'
+import {
+ applySelectionOverlay,
+ captureScrolledRows,
+ clearSelection,
+ createSelectionState,
+ extendSelection,
+ type FocusMove,
+ findPlainTextUrlAt,
+ getSelectedText,
+ hasSelection,
+ moveFocus,
+ type SelectionState,
+ selectLineAt,
+ selectWordAt,
+ shiftAnchor,
+ shiftSelection,
+ shiftSelectionForFollow,
+ startSelection,
+ updateSelection,
+} from './selection.js'
+import {
+ SYNC_OUTPUT_SUPPORTED,
+ supportsExtendedKeys,
+ type Terminal,
+ writeDiffToTerminal,
+} from './terminal.js'
+import {
+ CURSOR_HOME,
+ cursorMove,
+ cursorPosition,
+ DISABLE_KITTY_KEYBOARD,
+ DISABLE_MODIFY_OTHER_KEYS,
+ ENABLE_KITTY_KEYBOARD,
+ ENABLE_MODIFY_OTHER_KEYS,
+ ERASE_SCREEN,
+} from './termio/csi.js'
+import {
+ DBP,
+ DFE,
+ DISABLE_MOUSE_TRACKING,
+ ENABLE_MOUSE_TRACKING,
+ ENTER_ALT_SCREEN,
+ EXIT_ALT_SCREEN,
+ SHOW_CURSOR,
+} from './termio/dec.js'
+import {
+ CLEAR_ITERM2_PROGRESS,
+ CLEAR_TAB_STATUS,
+ setClipboard,
+ supportsTabStatus,
+ wrapForMultiplexer,
+} from './termio/osc.js'
+import { TerminalWriteProvider } from './useTerminalNotification.js'
// Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0,
// which is always false in alt-screen (TTY + content fills screen).
// Reusing a frozen object saves 1 allocation per frame.
-const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({
- x: 0,
- y: 0,
- visible: false
-});
+const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ x: 0, y: 0, visible: false })
const CURSOR_HOME_PATCH = Object.freeze({
type: 'stdout' as const,
- content: CURSOR_HOME
-});
+ content: CURSOR_HOME,
+})
const ERASE_THEN_HOME_PATCH = Object.freeze({
type: 'stdout' as const,
- content: ERASE_SCREEN + CURSOR_HOME
-});
+ content: ERASE_SCREEN + CURSOR_HOME,
+})
// Cached per-Ink-instance, invalidated on resize. frame.cursor.y for
// alt-screen is always terminalRows - 1 (renderer.ts).
function makeAltScreenParkPatch(terminalRows: number) {
return Object.freeze({
type: 'stdout' as const,
- content: cursorPosition(terminalRows, 1)
- });
+ content: cursorPosition(terminalRows, 1),
+ })
}
+
export type Options = {
- stdout: NodeJS.WriteStream;
- stdin: NodeJS.ReadStream;
- stderr: NodeJS.WriteStream;
- exitOnCtrlC: boolean;
- patchConsole: boolean;
- waitUntilExit?: () => Promise;
- onFrame?: (event: FrameEvent) => void;
-};
+ stdout: NodeJS.WriteStream
+ stdin: NodeJS.ReadStream
+ stderr: NodeJS.WriteStream
+ exitOnCtrlC: boolean
+ patchConsole: boolean
+ waitUntilExit?: () => Promise
+ onFrame?: (event: FrameEvent) => void
+}
+
export default class Ink {
- private readonly log: LogUpdate;
- private readonly terminal: Terminal;
- private scheduleRender: (() => void) & {
- cancel?: () => void;
- };
+ private readonly log: LogUpdate
+ private readonly terminal: Terminal
+ private scheduleRender: (() => void) & { cancel?: () => void }
// Ignore last render after unmounting a tree to prevent empty output before exit
- private isUnmounted = false;
- private isPaused = false;
- private readonly container: FiberRoot;
- private rootNode: dom.DOMElement;
- readonly focusManager: FocusManager;
- private renderer: Renderer;
- private readonly stylePool: StylePool;
- private charPool: CharPool;
- private hyperlinkPool: HyperlinkPool;
- private exitPromise?: Promise;
- private restoreConsole?: () => void;
- private restoreStderr?: () => void;
- private readonly unsubscribeTTYHandlers?: () => void;
- private terminalColumns: number;
- private terminalRows: number;
- private currentNode: ReactNode = null;
- private frontFrame: Frame;
- private backFrame: Frame;
- private lastPoolResetTime = performance.now();
- private drainTimer: ReturnType | null = null;
+ private isUnmounted = false
+ private isPaused = false
+ private readonly container: FiberRoot
+ private rootNode: dom.DOMElement
+ readonly focusManager: FocusManager
+ private renderer: Renderer
+ private readonly stylePool: StylePool
+ private charPool: CharPool
+ private hyperlinkPool: HyperlinkPool
+ private exitPromise?: Promise
+ private restoreConsole?: () => void
+ private restoreStderr?: () => void
+ private readonly unsubscribeTTYHandlers?: () => void
+ private terminalColumns: number
+ private terminalRows: number
+ private currentNode: ReactNode = null
+ private frontFrame: Frame
+ private backFrame: Frame
+ private lastPoolResetTime = performance.now()
+ private drainTimer: ReturnType | null = null
private lastYogaCounters: {
- ms: number;
- visited: number;
- measured: number;
- cacheHits: number;
- live: number;
- } = {
- ms: 0,
- visited: 0,
- measured: 0,
- cacheHits: 0,
- live: 0
- };
- private altScreenParkPatch: Readonly<{
- type: 'stdout';
- content: string;
- }>;
+ ms: number
+ visited: number
+ measured: number
+ cacheHits: number
+ live: number
+ } = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0 }
+ private altScreenParkPatch: Readonly<{ type: 'stdout'; content: string }>
// Text selection state (alt-screen only). Owned here so the overlay
// pass in onRender can read it and App.tsx can update it from mouse
// events. Public so instances.get() callers can access.
- readonly selection: SelectionState = createSelectionState();
+ readonly selection: SelectionState = createSelectionState()
// Search highlight query (alt-screen only). Setter below triggers
// scheduleRender; applySearchHighlight in onRender inverts matching cells.
- private searchHighlightQuery = '';
+ private searchHighlightQuery = ''
// Position-based highlight. VML scans positions ONCE (via
// scanElementSubtree, when the target message is mounted), stores them
// message-relative, sets this for every-frame apply. rowOffset =
@@ -131,74 +197,88 @@ export default class Ink {
// "current" (yellow). null clears. Positions are known upfront —
// navigation is index arithmetic, no scan-feedback loop.
private searchPositions: {
- positions: MatchPosition[];
- rowOffset: number;
- currentIdx: number;
- } | null = null;
+ positions: MatchPosition[]
+ rowOffset: number
+ currentIdx: number
+ } | null = null
// React-land subscribers for selection state changes (useHasSelection).
// Fired alongside the terminal repaint whenever the selection mutates
// so UI (e.g. footer hints) can react to selection appearing/clearing.
- private readonly selectionListeners = new Set<() => void>();
+ private readonly selectionListeners = new Set<() => void>()
// DOM nodes currently under the pointer (mode-1003 motion). Held here
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
// against this set and mutates it in place.
- private readonly hoveredNodes = new Set();
+ private readonly hoveredNodes = new Set()
// Set by via setAltScreenActive(). Controls the
// renderer's cursor.y clamping (keeps cursor in-viewport to avoid
// LF-induced scroll when screen.height === terminalRows) and gates
// alt-screen-aware SIGCONT/resize/unmount handling.
- private altScreenActive = false;
+ private altScreenActive = false
// Set alongside altScreenActive so SIGCONT resume knows whether to
// re-enable mouse tracking (not all uses want it).
- private altScreenMouseTracking = false;
+ private altScreenMouseTracking = false
// True when the previous frame's screen buffer cannot be trusted for
// blit — selection overlay mutated it, resetFramesForAltScreen()
// replaced it with blanks, or forceRedraw() reset it to 0×0. Forces
// one full-render frame; steady-state frames after clear it and regain
// the blit + narrow-damage fast path.
- private prevFrameContaminated = false;
+ private prevFrameContaminated = false
// Set by handleResize: prepend ERASE_SCREEN to the next onRender's patches
// INSIDE the BSU/ESU block so clear+paint is atomic. Writing ERASE_SCREEN
// synchronously in handleResize would leave the screen blank for the ~80ms
// render() takes; deferring into the atomic block means old content stays
// visible until the new frame is fully ready.
- private needsEraseBeforePaint = false;
+ private needsEraseBeforePaint = false
// Native cursor positioning: a component (via useDeclaredCursor) declares
// where the terminal cursor should be parked after each frame. Terminal
// emulators render IME preedit text at the physical cursor position, and
// screen readers / screen magnifiers track it — so parking at the text
// input's caret makes CJK input appear inline and lets a11y tools follow.
- private cursorDeclaration: CursorDeclaration | null = null;
+ private cursorDeclaration: CursorDeclaration | null = null
// Main-screen: physical cursor position after the declared-cursor move,
// tracked separately from frame.cursor (which must stay at content-bottom
// for log-update's relative-move invariants). Alt-screen doesn't need
// this — every frame begins with CSI H. null = no move emitted last frame.
- private displayCursor: {
- x: number;
- y: number;
- } | null = null;
+ private displayCursor: { x: number; y: number } | null = null
+
constructor(private readonly options: Options) {
- autoBind(this);
+ autoBind(this)
+
if (this.options.patchConsole) {
- this.restoreConsole = this.patchConsole();
- this.restoreStderr = this.patchStderr();
+ this.restoreConsole = this.patchConsole()
+ this.restoreStderr = this.patchStderr()
}
+
this.terminal = {
stdout: options.stdout,
- stderr: options.stderr
- };
- this.terminalColumns = options.stdout.columns || 80;
- this.terminalRows = options.stdout.rows || 24;
- this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows);
- this.stylePool = new StylePool();
- this.charPool = new CharPool();
- this.hyperlinkPool = new HyperlinkPool();
- this.frontFrame = emptyFrame(this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool);
- this.backFrame = emptyFrame(this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool);
+ stderr: options.stderr,
+ }
+
+ this.terminalColumns = options.stdout.columns || 80
+ this.terminalRows = options.stdout.rows || 24
+ this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
+ this.stylePool = new StylePool()
+ this.charPool = new CharPool()
+ this.hyperlinkPool = new HyperlinkPool()
+ this.frontFrame = emptyFrame(
+ this.terminalRows,
+ this.terminalColumns,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
+ this.backFrame = emptyFrame(
+ this.terminalRows,
+ this.terminalColumns,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
+
this.log = new LogUpdate({
- isTTY: options.stdout.isTTY as boolean | undefined || false,
- stylePool: this.stylePool
- });
+ isTTY: (options.stdout.isTTY as boolean | undefined) || false,
+ stylePool: this.stylePool,
+ })
// scheduleRender is called from the reconciler's resetAfterCommit, which
// runs BEFORE React's layout phase (ref attach + useLayoutEffect). Any
@@ -209,94 +289,115 @@ export default class Ink {
// a one-keystroke lag. Same event-loop tick, so throughput is unchanged.
// Test env uses onImmediateRender (direct onRender, no throttle) so
// existing synchronous lastFrame() tests are unaffected.
- const deferredRender = (): void => queueMicrotask(this.onRender);
+ const deferredRender = (): void => queueMicrotask(this.onRender)
this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, {
leading: true,
- trailing: true
- });
+ trailing: true,
+ })
// Ignore last render after unmounting a tree to prevent empty output before exit
- this.isUnmounted = false;
+ this.isUnmounted = false
// Unmount when process exits
- this.unsubscribeExit = onExit(this.unmount, {
- alwaysLast: false
- });
+ this.unsubscribeExit = onExit(this.unmount, { alwaysLast: false })
+
if (options.stdout.isTTY) {
- options.stdout.on('resize', this.handleResize);
- process.on('SIGCONT', this.handleResume);
+ options.stdout.on('resize', this.handleResize)
+ process.on('SIGCONT', this.handleResume)
+
this.unsubscribeTTYHandlers = () => {
- options.stdout.off('resize', this.handleResize);
- process.off('SIGCONT', this.handleResume);
- };
+ options.stdout.off('resize', this.handleResize)
+ process.off('SIGCONT', this.handleResume)
+ }
}
- this.rootNode = dom.createNode('ink-root');
- this.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event));
- this.rootNode.focusManager = this.focusManager;
- this.renderer = createRenderer(this.rootNode, this.stylePool);
- this.rootNode.onRender = this.scheduleRender;
- this.rootNode.onImmediateRender = this.onRender;
+
+ this.rootNode = dom.createNode('ink-root')
+ this.focusManager = new FocusManager((target, event) =>
+ dispatcher.dispatchDiscrete(target, event),
+ )
+ this.rootNode.focusManager = this.focusManager
+ this.renderer = createRenderer(this.rootNode, this.stylePool)
+ this.rootNode.onRender = this.scheduleRender
+ this.rootNode.onImmediateRender = this.onRender
this.rootNode.onComputeLayout = () => {
// Calculate layout during React's commit phase so useLayoutEffect hooks
// have access to fresh layout data
// Guard against accessing freed Yoga nodes after unmount
if (this.isUnmounted) {
- return;
+ return
}
- if (this.rootNode.yogaNode) {
- const t0 = performance.now();
- this.rootNode.yogaNode.setWidth(this.terminalColumns);
- this.rootNode.yogaNode.calculateLayout(this.terminalColumns);
- const ms = performance.now() - t0;
- recordYogaMs(ms);
- const c = getYogaCounters();
- this.lastYogaCounters = {
- ms,
- ...c
- };
- }
- };
- this.container = reconciler.createContainer(this.rootNode, ConcurrentRoot, null, false, null, 'id', noop,
- // onUncaughtError
- noop,
- // onCaughtError
- noop,
- // onRecoverableError
- noop // onDefaultTransitionIndicator
- );
- if (("production" as string) === 'development') {
+ if (this.rootNode.yogaNode) {
+ const t0 = performance.now()
+ this.rootNode.yogaNode.setWidth(this.terminalColumns)
+ this.rootNode.yogaNode.calculateLayout(this.terminalColumns)
+ const ms = performance.now() - t0
+ recordYogaMs(ms)
+ const c = getYogaCounters()
+ this.lastYogaCounters = { ms, ...c }
+ }
+ }
+
+ // @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks,
+ // but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks)
+ this.container = reconciler.createContainer(
+ this.rootNode,
+ ConcurrentRoot,
+ null,
+ false,
+ null,
+ 'id',
+ noop, // onUncaughtError
+ noop, // onCaughtError
+ noop, // onRecoverableError
+ noop, // onDefaultTransitionIndicator
+ )
+
+ if ("production" === 'development') {
reconciler.injectIntoDevTools({
bundleType: 0,
// Reporting React DOM's version, not Ink's
// See https://github.com/facebook/react/issues/16666#issuecomment-532639905
version: '16.13.1',
- rendererPackageName: 'ink'
- });
+ rendererPackageName: 'ink',
+ })
}
}
+
private handleResume = () => {
if (!this.options.stdout.isTTY) {
- return;
+ return
}
// Alt screen: after SIGCONT, content is stale (shell may have written
// to main screen, switching focus away) and mouse tracking was
// disabled by handleSuspend.
if (this.altScreenActive) {
- this.reenterAltScreen();
- return;
+ this.reenterAltScreen()
+ return
}
// Main screen: start fresh to prevent clobbering terminal content
- this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
- this.backFrame = emptyFrame(this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
- this.log.reset();
+ this.frontFrame = emptyFrame(
+ this.frontFrame.viewport.height,
+ this.frontFrame.viewport.width,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
+ this.backFrame = emptyFrame(
+ this.backFrame.viewport.height,
+ this.backFrame.viewport.width,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
+ this.log.reset()
// Physical cursor position is unknown after the shell took over during
// suspend. Clear displayCursor so the next frame's cursor preamble
// doesn't emit a relative move from a stale park position.
- this.displayCursor = null;
- };
+ this.displayCursor = null
+ }
// NOT debounced. A debounce opens a window where stdout.columns is NEW
// but this.terminalColumns/Yoga are OLD — any scheduleRender during that
@@ -305,15 +406,15 @@ export default class Ink {
// blank→paint flicker). useVirtualScroll's height scaling already bounds
// the per-resize cost; synchronous handling keeps dimensions consistent.
private handleResize = () => {
- const cols = this.options.stdout.columns || 80;
- const rows = this.options.stdout.rows || 24;
+ const cols = this.options.stdout.columns || 80
+ const rows = this.options.stdout.rows || 24
// Terminals often emit 2+ resize events for one user action (window
// settling). Same-dimension events are no-ops; skip to avoid redundant
// frame resets and renders.
- if (cols === this.terminalColumns && rows === this.terminalRows) return;
- this.terminalColumns = cols;
- this.terminalRows = rows;
- this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows);
+ if (cols === this.terminalColumns && rows === this.terminalRows) return
+ this.terminalColumns = cols
+ this.terminalRows = rows
+ this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
// Alt screen: reset frame buffers so the next render repaints from
// scratch (prevFrameContaminated → every cell written, wrapped in
@@ -327,10 +428,10 @@ export default class Ink {
// can take ~80ms; erasing first leaves the screen blank that whole time.
if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) {
if (this.altScreenMouseTracking) {
- this.options.stdout.write(ENABLE_MOUSE_TRACKING);
+ this.options.stdout.write(ENABLE_MOUSE_TRACKING)
}
- this.resetFramesForAltScreen();
- this.needsEraseBeforePaint = true;
+ this.resetFramesForAltScreen()
+ this.needsEraseBeforePaint = true
}
// Re-render the React tree with updated props so the context value changes.
@@ -339,12 +440,13 @@ export default class Ink {
// We don't call scheduleRender() here because that would render before the
// layout is updated, causing a mismatch between viewport and content dimensions.
if (this.currentNode !== null) {
- this.render(this.currentNode);
+ this.render(this.currentNode)
}
- };
- resolveExitPromise: () => void = () => {};
- rejectExitPromise: (reason?: Error) => void = () => {};
- unsubscribeExit: () => void = () => {};
+ }
+
+ resolveExitPromise: () => void = () => {}
+ rejectExitPromise: (reason?: Error) => void = () => {}
+ unsubscribeExit: () => void = () => {}
/**
* Pause Ink and hand the terminal over to an external TUI (e.g. git
@@ -353,26 +455,22 @@ export default class Ink {
* Call `exitAlternateScreen()` when done to restore Ink.
*/
enterAlternateScreen(): void {
- this.pause();
- this.suspendStdin();
+ this.pause()
+ this.suspendStdin()
this.options.stdout.write(
- // Disable extended key reporting first — editors that don't speak
- // CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl- if
- // kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables.
- DISABLE_KITTY_KEYBOARD + DISABLE_MODIFY_OTHER_KEYS + (this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') + (
- // disable mouse (no-op if off)
- this.altScreenActive ? '' : '\x1b[?1049h') +
- // enter alt (already in alt if fullscreen)
- '\x1b[?1004l' +
- // disable focus reporting
- '\x1b[0m' +
- // reset attributes
- '\x1b[?25h' +
- // show cursor
- '\x1b[2J' +
- // clear screen
- '\x1b[H' // cursor home
- );
+ // Disable extended key reporting first — editors that don't speak
+ // CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl- if
+ // kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables.
+ DISABLE_KITTY_KEYBOARD +
+ DISABLE_MODIFY_OTHER_KEYS +
+ (this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') + // disable mouse (no-op if off)
+ (this.altScreenActive ? '' : '\x1b[?1049h') + // enter alt (already in alt if fullscreen)
+ '\x1b[?1004l' + // disable focus reporting
+ '\x1b[0m' + // reset attributes
+ '\x1b[?25h' + // show cursor
+ '\x1b[2J' + // clear screen
+ '\x1b[H', // cursor home
+ )
}
/**
@@ -388,53 +486,59 @@ export default class Ink {
* returns, fullscreen scroll is dead.
*/
exitAlternateScreen(): void {
- this.options.stdout.write((this.altScreenActive ? ENTER_ALT_SCREEN : '') +
- // re-enter alt — vim's rmcup dropped us to main
- '\x1b[2J' +
- // clear screen (now alt if fullscreen)
- '\x1b[H' + (
- // cursor home
- this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + (
- // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE)
- this.altScreenActive ? '' : '\x1b[?1049l') +
- // exit alt (non-fullscreen only)
- '\x1b[?25l' // hide cursor (Ink manages)
- );
- this.resumeStdin();
+ this.options.stdout.write(
+ (this.altScreenActive ? ENTER_ALT_SCREEN : '') + // re-enter alt — vim's rmcup dropped us to main
+ '\x1b[2J' + // clear screen (now alt if fullscreen)
+ '\x1b[H' + // cursor home
+ (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE)
+ (this.altScreenActive ? '' : '\x1b[?1049l') + // exit alt (non-fullscreen only)
+ '\x1b[?25l', // hide cursor (Ink manages)
+ )
+ this.resumeStdin()
if (this.altScreenActive) {
- this.resetFramesForAltScreen();
+ this.resetFramesForAltScreen()
} else {
- this.repaint();
+ this.repaint()
}
- this.resume();
+ this.resume()
// Re-enable focus reporting and extended key reporting — terminal
// editors (vim, nano, etc.) write their own modifyOtherKeys level on
// entry and reset it on exit, leaving us unable to distinguish
// ctrl+shift+ from ctrl+. Pop-before-push keeps the
// Kitty stack balanced (a well-behaved editor restores our entry, so
// without the pop we'd accumulate depth on each editor round-trip).
- this.options.stdout.write('\x1b[?1004h' + (supportsExtendedKeys() ? DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS : ''));
+ this.options.stdout.write(
+ '\x1b[?1004h' +
+ (supportsExtendedKeys()
+ ? DISABLE_KITTY_KEYBOARD +
+ ENABLE_KITTY_KEYBOARD +
+ ENABLE_MODIFY_OTHER_KEYS
+ : ''),
+ )
}
+
onRender() {
if (this.isUnmounted || this.isPaused) {
- return;
+ return
}
// Entering a render cancels any pending drain tick — this render will
// handle the drain (and re-schedule below if needed). Prevents a
// wheel-event-triggered render AND a drain-timer render both firing.
if (this.drainTimer !== null) {
- clearTimeout(this.drainTimer);
- this.drainTimer = null;
+ clearTimeout(this.drainTimer)
+ this.drainTimer = null
}
// Flush deferred interaction-time update before rendering so we call
// Date.now() at most once per frame instead of once per keypress.
// Done before the render to avoid dirtying state that would trigger
// an extra React re-render cycle.
- flushInteractionTime();
- const renderStart = performance.now();
- const terminalWidth = this.options.stdout.columns || 80;
- const terminalRows = this.options.stdout.rows || 24;
+ flushInteractionTime()
+
+ const renderStart = performance.now()
+ const terminalWidth = this.options.stdout.columns || 80
+ const terminalRows = this.options.stdout.rows || 24
+
const frame = this.renderer({
frontFrame: this.frontFrame,
backFrame: this.backFrame,
@@ -442,9 +546,9 @@ export default class Ink {
terminalWidth,
terminalRows,
altScreen: this.altScreenActive,
- prevFrameContaminated: this.prevFrameContaminated
- });
- const rendererMs = performance.now() - renderStart;
+ prevFrameContaminated: this.prevFrameContaminated,
+ })
+ const rendererMs = performance.now() - renderStart
// Sticky/auto-follow scrolled the ScrollBox this frame. Translate the
// selection by the same delta so the highlight stays anchored to the
@@ -457,20 +561,20 @@ export default class Ink {
// (screen-local) so only anchor shifts — selection grows toward the
// mouse as the anchor walks up. After release, both ends are text-
// anchored and move as a block.
- const follow = consumeFollowScroll();
- if (follow && this.selection.anchor &&
- // Only translate if the selection is ON scrollbox content. Selections
- // in the footer/prompt/StickyPromptHeader are on static text — the
- // scroll doesn't move what's under them. Without this guard, a
- // footer selection would be shifted by -delta then clamped to
- // viewportBottom, teleporting it into the scrollbox. Mirror the
- // bounds check the deleted check() in ScrollKeybindingHandler had.
- this.selection.anchor.row >= follow.viewportTop && this.selection.anchor.row <= follow.viewportBottom) {
- const {
- delta,
- viewportTop,
- viewportBottom
- } = follow;
+ const follow = consumeFollowScroll()
+ if (
+ follow &&
+ this.selection.anchor &&
+ // Only translate if the selection is ON scrollbox content. Selections
+ // in the footer/prompt/StickyPromptHeader are on static text — the
+ // scroll doesn't move what's under them. Without this guard, a
+ // footer selection would be shifted by -delta then clamped to
+ // viewportBottom, teleporting it into the scrollbox. Mirror the
+ // bounds check the deleted check() in ScrollKeybindingHandler had.
+ this.selection.anchor.row >= follow.viewportTop &&
+ this.selection.anchor.row <= follow.viewportBottom
+ ) {
+ const { delta, viewportTop, viewportBottom } = follow
// captureScrolledRows and shift* are a pair: capture grabs rows about
// to scroll off, shift moves the selection endpoint so the same rows
// won't intersect again next frame. Capturing without shifting leaves
@@ -480,33 +584,53 @@ export default class Ink {
// each shift branch so the pairing can't be broken by a new guard.
if (this.selection.isDragging) {
if (hasSelection(this.selection)) {
- captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above');
+ captureScrolledRows(
+ this.selection,
+ this.frontFrame.screen,
+ viewportTop,
+ viewportTop + delta - 1,
+ 'above',
+ )
}
- shiftAnchor(this.selection, -delta, viewportTop, viewportBottom);
+ shiftAnchor(this.selection, -delta, viewportTop, viewportBottom)
} else if (
- // Flag-3 guard: the anchor check above only proves ONE endpoint is
- // on scrollbox content. A drag from row 3 (scrollbox) into the
- // footer at row 6, then release, leaves focus outside the viewport
- // — shiftSelectionForFollow would clamp it to viewportBottom,
- // teleporting the highlight from static footer into the scrollbox.
- // Symmetric check: require BOTH ends inside to translate. A
- // straddling selection falls through to NEITHER shift NOR capture:
- // the footer endpoint pins the selection, text scrolls away under
- // the highlight, and getSelectedText reads the CURRENT screen
- // contents — no accumulation. Dragging branch doesn't need this:
- // shiftAnchor ignores focus, and the anchor DOES shift (so capture
- // is correct there even when focus is in the footer).
- !this.selection.focus || this.selection.focus.row >= viewportTop && this.selection.focus.row <= viewportBottom) {
+ // Flag-3 guard: the anchor check above only proves ONE endpoint is
+ // on scrollbox content. A drag from row 3 (scrollbox) into the
+ // footer at row 6, then release, leaves focus outside the viewport
+ // — shiftSelectionForFollow would clamp it to viewportBottom,
+ // teleporting the highlight from static footer into the scrollbox.
+ // Symmetric check: require BOTH ends inside to translate. A
+ // straddling selection falls through to NEITHER shift NOR capture:
+ // the footer endpoint pins the selection, text scrolls away under
+ // the highlight, and getSelectedText reads the CURRENT screen
+ // contents — no accumulation. Dragging branch doesn't need this:
+ // shiftAnchor ignores focus, and the anchor DOES shift (so capture
+ // is correct there even when focus is in the footer).
+ !this.selection.focus ||
+ (this.selection.focus.row >= viewportTop &&
+ this.selection.focus.row <= viewportBottom)
+ ) {
if (hasSelection(this.selection)) {
- captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above');
+ captureScrolledRows(
+ this.selection,
+ this.frontFrame.screen,
+ viewportTop,
+ viewportTop + delta - 1,
+ 'above',
+ )
}
- const cleared = shiftSelectionForFollow(this.selection, -delta, viewportTop, viewportBottom);
+ const cleared = shiftSelectionForFollow(
+ this.selection,
+ -delta,
+ viewportTop,
+ viewportBottom,
+ )
// Auto-clear (both ends overshot minRow) must notify React-land
// so useHasSelection re-renders and the footer copy/escape hint
// disappears. notifySelectionChange() would recurse into onRender;
// fire the listeners directly — they schedule a React update for
// LATER, they don't re-enter this frame.
- if (cleared) for (const cb of this.selectionListeners) cb();
+ if (cleared) for (const cb of this.selectionListeners) cb()
}
}
@@ -529,23 +653,33 @@ export default class Ink {
// which doesn't track damage, and prev-frame overlay cells need to be
// compared when selection moves/clears. prevFrameContaminated covers
// the frame-after-selection-clears case.
- let selActive = false;
- let hlActive = false;
+ let selActive = false
+ let hlActive = false
if (this.altScreenActive) {
- selActive = hasSelection(this.selection);
+ selActive = hasSelection(this.selection)
if (selActive) {
- applySelectionOverlay(frame.screen, this.selection, this.stylePool);
+ applySelectionOverlay(frame.screen, this.selection, this.stylePool)
}
// Scan-highlight: inverse on ALL visible matches (less/vim style).
// Position-highlight (below) overlays CURRENT (yellow) on top.
- hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool);
+ hlActive = applySearchHighlight(
+ frame.screen,
+ this.searchHighlightQuery,
+ this.stylePool,
+ )
// Position-based CURRENT: write yellow at positions[currentIdx] +
// rowOffset. No scanning — positions came from a prior scan when
// the message first mounted. Message-relative + rowOffset = screen.
if (this.searchPositions) {
- const sp = this.searchPositions;
- const posApplied = applyPositionedHighlight(frame.screen, this.stylePool, sp.positions, sp.rowOffset, sp.currentIdx);
- hlActive = hlActive || posApplied;
+ const sp = this.searchPositions
+ const posApplied = applyPositionedHighlight(
+ frame.screen,
+ this.stylePool,
+ sp.positions,
+ sp.rowOffset,
+ sp.currentIdx,
+ )
+ hlActive = hlActive || posApplied
}
}
@@ -554,13 +688,18 @@ export default class Ink {
// cells at sibling boundaries that per-node damage tracking misses.
// Selection/highlight overlays write via setCellStyleId which doesn't
// track damage. prevFrameContaminated covers the cleanup frame.
- if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) {
+ if (
+ didLayoutShift() ||
+ selActive ||
+ hlActive ||
+ this.prevFrameContaminated
+ ) {
frame.screen.damage = {
x: 0,
y: 0,
width: frame.screen.width,
- height: frame.screen.height
- };
+ height: frame.screen.height,
+ }
}
// Alt-screen: anchor the physical cursor to (0,0) before every diff.
@@ -573,52 +712,63 @@ export default class Ink {
// can't do this — cursor.y tracks scrollback rows CSI H can't reach.
// The CSI H write is deferred until after the diff is computed so we
// can skip it for empty diffs (no writes → physical cursor unused).
- let prevFrame = this.frontFrame;
+ let prevFrame = this.frontFrame
if (this.altScreenActive) {
- prevFrame = {
- ...this.frontFrame,
- cursor: ALT_SCREEN_ANCHOR_CURSOR
- };
+ prevFrame = { ...this.frontFrame, cursor: ALT_SCREEN_ANCHOR_CURSOR }
}
- const tDiff = performance.now();
- const diff = this.log.render(prevFrame, frame, this.altScreenActive,
- // DECSTBM needs BSU/ESU atomicity — without it the outer terminal
- // renders the scrolled-but-not-yet-repainted intermediate state.
- // tmux is the main case (re-emits DECSTBM with its own timing and
- // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false).
- SYNC_OUTPUT_SUPPORTED);
- const diffMs = performance.now() - tDiff;
+
+ const tDiff = performance.now()
+ const diff = this.log.render(
+ prevFrame,
+ frame,
+ this.altScreenActive,
+ // DECSTBM needs BSU/ESU atomicity — without it the outer terminal
+ // renders the scrolled-but-not-yet-repainted intermediate state.
+ // tmux is the main case (re-emits DECSTBM with its own timing and
+ // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false).
+ SYNC_OUTPUT_SUPPORTED,
+ )
+ const diffMs = performance.now() - tDiff
// Swap buffers
- this.backFrame = this.frontFrame;
- this.frontFrame = frame;
+ this.backFrame = this.frontFrame
+ this.frontFrame = frame
// Periodically reset char/hyperlink pools to prevent unbounded growth
// during long sessions. 5 minutes is infrequent enough that the O(cells)
// migration cost is negligible. Reuses renderStart to avoid extra clock call.
if (renderStart - this.lastPoolResetTime > 5 * 60 * 1000) {
- this.resetPools();
- this.lastPoolResetTime = renderStart;
+ this.resetPools()
+ this.lastPoolResetTime = renderStart
}
- const flickers: FrameEvent['flickers'] = [];
+
+ const flickers: FrameEvent['flickers'] = []
for (const patch of diff) {
if (patch.type === 'clearTerminal') {
flickers.push({
desiredHeight: frame.screen.height,
availableHeight: frame.viewport.height,
- reason: patch.reason
- });
+ reason: patch.reason,
+ })
if (isDebugRepaintsEnabled() && patch.debug) {
- const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY);
- logForDebugging(`[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + ` prev: "${patch.debug.prevLine}"\n` + ` next: "${patch.debug.nextLine}"\n` + ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, {
- level: 'warn'
- });
+ const chain = dom.findOwnerChainAtRow(
+ this.rootNode,
+ patch.debug.triggerY,
+ )
+ logForDebugging(
+ `[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` +
+ ` prev: "${patch.debug.prevLine}"\n` +
+ ` next: "${patch.debug.nextLine}"\n` +
+ ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`,
+ { level: 'warn' },
+ )
}
}
}
- const tOptimize = performance.now();
- const optimized = optimize(diff);
- const optimizeMs = performance.now() - tOptimize;
- const hasDiff = optimized.length > 0;
+
+ const tOptimize = performance.now()
+ const optimized = optimize(diff)
+ const optimizeMs = performance.now() - tOptimize
+ const hasDiff = optimized.length > 0
if (this.altScreenActive && hasDiff) {
// Prepend CSI H to anchor the physical cursor to (0,0) so
// log-update's relative moves compute from a known spot (self-healing
@@ -640,12 +790,12 @@ export default class Ink {
// synchronously in handleResize would blank the screen for the ~80ms
// render() takes.
if (this.needsEraseBeforePaint) {
- this.needsEraseBeforePaint = false;
- optimized.unshift(ERASE_THEN_HOME_PATCH);
+ this.needsEraseBeforePaint = false
+ optimized.unshift(ERASE_THEN_HOME_PATCH)
} else {
- optimized.unshift(CURSOR_HOME_PATCH);
+ optimized.unshift(CURSOR_HOME_PATCH)
}
- optimized.push(this.altScreenParkPatch);
+ optimized.push(this.altScreenParkPatch)
}
// Native cursor positioning: park the terminal cursor at the declared
@@ -655,60 +805,54 @@ export default class Ink {
// translation) — if the declared node didn't render (stale declaration
// after remount, or scrolled out of view), it won't be in the cache
// and no move is emitted.
- const decl = this.cursorDeclaration;
- const rect = decl !== null ? nodeCache.get(decl.node) : undefined;
- const target = decl !== null && rect !== undefined ? {
- x: rect.x + decl.relativeX,
- y: rect.y + decl.relativeY
- } : null;
- const parked = this.displayCursor;
+ const decl = this.cursorDeclaration
+ const rect = decl !== null ? nodeCache.get(decl.node) : undefined
+ const target =
+ decl !== null && rect !== undefined
+ ? { x: rect.x + decl.relativeX, y: rect.y + decl.relativeY }
+ : null
+ const parked = this.displayCursor
// Preserve the empty-diff zero-write fast path: skip all cursor writes
// when nothing rendered AND the park target is unchanged.
- const targetMoved = target !== null && (parked === null || parked.x !== target.x || parked.y !== target.y);
- if (hasDiff || targetMoved || target === null && parked !== null) {
+ const targetMoved =
+ target !== null &&
+ (parked === null || parked.x !== target.x || parked.y !== target.y)
+ if (hasDiff || targetMoved || (target === null && parked !== null)) {
// Main-screen preamble: log-update's relative moves assume the
// physical cursor is at prevFrame.cursor. If last frame parked it
// elsewhere, move back before the diff runs. Alt-screen's CSI H
// already resets to (0,0) so no preamble needed.
if (parked !== null && !this.altScreenActive && hasDiff) {
- const pdx = prevFrame.cursor.x - parked.x;
- const pdy = prevFrame.cursor.y - parked.y;
+ const pdx = prevFrame.cursor.x - parked.x
+ const pdy = prevFrame.cursor.y - parked.y
if (pdx !== 0 || pdy !== 0) {
- optimized.unshift({
- type: 'stdout',
- content: cursorMove(pdx, pdy)
- });
+ optimized.unshift({ type: 'stdout', content: cursorMove(pdx, pdy) })
}
}
+
if (target !== null) {
if (this.altScreenActive) {
// Absolute CUP (1-indexed); next frame's CSI H resets regardless.
// Emitted after altScreenParkPatch so the declared position wins.
- const row = Math.min(Math.max(target.y + 1, 1), terminalRows);
- const col = Math.min(Math.max(target.x + 1, 1), terminalWidth);
- optimized.push({
- type: 'stdout',
- content: cursorPosition(row, col)
- });
+ const row = Math.min(Math.max(target.y + 1, 1), terminalRows)
+ const col = Math.min(Math.max(target.x + 1, 1), terminalWidth)
+ optimized.push({ type: 'stdout', content: cursorPosition(row, col) })
} else {
// After the diff (or preamble), cursor is at frame.cursor. If no
// diff AND previously parked, it's still at the old park position
// (log-update wrote nothing). Otherwise it's at frame.cursor.
- const from = !hasDiff && parked !== null ? parked : {
- x: frame.cursor.x,
- y: frame.cursor.y
- };
- const dx = target.x - from.x;
- const dy = target.y - from.y;
+ const from =
+ !hasDiff && parked !== null
+ ? parked
+ : { x: frame.cursor.x, y: frame.cursor.y }
+ const dx = target.x - from.x
+ const dy = target.y - from.y
if (dx !== 0 || dy !== 0) {
- optimized.push({
- type: 'stdout',
- content: cursorMove(dx, dy)
- });
+ optimized.push({ type: 'stdout', content: cursorMove(dx, dy) })
}
}
- this.displayCursor = target;
+ this.displayCursor = target
} else {
// Declaration cleared (input blur, unmount). Restore physical cursor
// to frame.cursor before forgetting the park position — otherwise
@@ -718,27 +862,29 @@ export default class Ink {
// !hasDiff (e.g. accessibility mode where blur doesn't change
// renderedValue since invert is identity).
if (parked !== null && !this.altScreenActive && !hasDiff) {
- const rdx = frame.cursor.x - parked.x;
- const rdy = frame.cursor.y - parked.y;
+ const rdx = frame.cursor.x - parked.x
+ const rdy = frame.cursor.y - parked.y
if (rdx !== 0 || rdy !== 0) {
- optimized.push({
- type: 'stdout',
- content: cursorMove(rdx, rdy)
- });
+ optimized.push({ type: 'stdout', content: cursorMove(rdx, rdy) })
}
}
- this.displayCursor = null;
+ this.displayCursor = null
}
}
- const tWrite = performance.now();
- writeDiffToTerminal(this.terminal, optimized, this.altScreenActive && !SYNC_OUTPUT_SUPPORTED);
- const writeMs = performance.now() - tWrite;
+
+ const tWrite = performance.now()
+ writeDiffToTerminal(
+ this.terminal,
+ optimized,
+ this.altScreenActive && !SYNC_OUTPUT_SUPPORTED,
+ )
+ const writeMs = performance.now() - tWrite
// Update blit safety for the NEXT frame. The frame just rendered
// becomes frontFrame (= next frame's prevScreen). If we applied the
// selection overlay, that buffer has inverted cells. selActive/hlActive
// are only ever true in alt-screen; in main-screen this is false→false.
- this.prevFrameContaminated = selActive || hlActive;
+ this.prevFrameContaminated = selActive || hlActive
// A ScrollBox has pendingScrollDelta left to drain — schedule the next
// frame. MUST NOT call this.scheduleRender() here: we're inside a
@@ -753,20 +899,24 @@ export default class Ink {
// quarter interval (~250fps, setTimeout practical floor) for max scroll
// speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle.
if (frame.scrollDrainPending) {
- this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2);
+ this.drainTimer = setTimeout(
+ () => this.onRender(),
+ FRAME_INTERVAL_MS >> 2,
+ )
}
- const yogaMs = getLastYogaMs();
- const commitMs = getLastCommitMs();
- const yc = this.lastYogaCounters;
+
+ const yogaMs = getLastYogaMs()
+ const commitMs = getLastCommitMs()
+ const yc = this.lastYogaCounters
// Reset so drain-only frames (no React commit) don't repeat stale values.
- resetProfileCounters();
+ resetProfileCounters()
this.lastYogaCounters = {
ms: 0,
visited: 0,
measured: 0,
cacheHits: 0,
- live: 0
- };
+ live: 0,
+ }
this.options.onFrame?.({
durationMs: performance.now() - renderStart,
phases: {
@@ -780,20 +930,24 @@ export default class Ink {
yogaVisited: yc.visited,
yogaMeasured: yc.measured,
yogaCacheHits: yc.cacheHits,
- yogaLive: yc.live
+ yogaLive: yc.live,
},
- flickers
- });
+ flickers,
+ })
}
+
pause(): void {
// Flush pending React updates and render before pausing.
- reconciler.flushSyncFromReconciler();
- this.onRender();
- this.isPaused = true;
+ // @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler
+ reconciler.flushSyncFromReconciler()
+ this.onRender()
+
+ this.isPaused = true
}
+
resume(): void {
- this.isPaused = false;
- this.onRender();
+ this.isPaused = false
+ this.onRender()
}
/**
@@ -802,13 +956,25 @@ export default class Ink {
* an external process (e.g. tmux, shell, full-screen TUI).
*/
repaint(): void {
- this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
- this.backFrame = emptyFrame(this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
- this.log.reset();
+ this.frontFrame = emptyFrame(
+ this.frontFrame.viewport.height,
+ this.frontFrame.viewport.width,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
+ this.backFrame = emptyFrame(
+ this.backFrame.viewport.height,
+ this.backFrame.viewport.width,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
+ this.log.reset()
// Physical cursor position is unknown after external terminal corruption.
// Clear displayCursor so the cursor preamble doesn't emit a stale
// relative move from where we last parked it.
- this.displayCursor = null;
+ this.displayCursor = null
}
/**
@@ -820,18 +986,18 @@ export default class Ink {
* unchanged cells don't need repainting. Scrollback is preserved.
*/
forceRedraw(): void {
- if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return;
- this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME);
+ if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return
+ this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME)
if (this.altScreenActive) {
- this.resetFramesForAltScreen();
+ this.resetFramesForAltScreen()
} else {
- this.repaint();
+ this.repaint()
// repaint() resets frontFrame to 0×0. Without this flag the next
// frame's blit optimization copies from that empty screen and the
// diff sees no content. onRender resets the flag at frame end.
- this.prevFrameContaminated = true;
+ this.prevFrameContaminated = true
}
- this.onRender();
+ this.onRender()
}
/**
@@ -845,7 +1011,7 @@ export default class Ink {
* onRender resets the flag at frame end so it's one-shot.
*/
invalidatePrevFrame(): void {
- this.prevFrameContaminated = true;
+ this.prevFrameContaminated = true
}
/**
@@ -856,17 +1022,18 @@ export default class Ink {
* a full redraw with no stale diff state.
*/
setAltScreenActive(active: boolean, mouseTracking = false): void {
- if (this.altScreenActive === active) return;
- this.altScreenActive = active;
- this.altScreenMouseTracking = active && mouseTracking;
+ if (this.altScreenActive === active) return
+ this.altScreenActive = active
+ this.altScreenMouseTracking = active && mouseTracking
if (active) {
- this.resetFramesForAltScreen();
+ this.resetFramesForAltScreen()
} else {
- this.repaint();
+ this.repaint()
}
}
+
get isAltScreenActive(): boolean {
- return this.altScreenActive;
+ return this.altScreenActive
}
/**
@@ -891,29 +1058,33 @@ export default class Ink {
* handleResize.
*/
reassertTerminalModes = (includeAltScreen = false): void => {
- if (!this.options.stdout.isTTY) return;
+ if (!this.options.stdout.isTTY) return
// Don't touch the terminal during an editor handoff — re-enabling kitty
// keyboard here would undo enterAlternateScreen's disable and nano would
// start seeing CSI-u sequences again.
- if (this.isPaused) return;
+ if (this.isPaused) return
// Extended keys — re-assert if enabled (App.tsx enables these on
// allowlisted terminals at raw-mode entry; a terminal reset clears them).
// Pop-before-push keeps Kitty stack depth at 1 instead of accumulating
// on each call.
if (supportsExtendedKeys()) {
- this.options.stdout.write(DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS);
+ this.options.stdout.write(
+ DISABLE_KITTY_KEYBOARD +
+ ENABLE_KITTY_KEYBOARD +
+ ENABLE_MODIFY_OTHER_KEYS,
+ )
}
- if (!this.altScreenActive) return;
+ if (!this.altScreenActive) return
// Mouse tracking — idempotent, safe to re-assert on every stdin gap.
if (this.altScreenMouseTracking) {
- this.options.stdout.write(ENABLE_MOUSE_TRACKING);
+ this.options.stdout.write(ENABLE_MOUSE_TRACKING)
}
// Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that
// have a strong signal the terminal actually dropped mode 1049.
if (includeAltScreen) {
- this.reenterAltScreen();
+ this.reenterAltScreen()
}
- };
+ }
/**
* Mark this instance as unmounted so future unmount() calls early-return.
@@ -927,28 +1098,28 @@ export default class Ink {
* as restoring the saved cursor position — clobbering the resume hint.
*/
detachForShutdown(): void {
- this.isUnmounted = true;
+ this.isUnmounted = true
// Cancel any pending throttled render so it doesn't fire between
// cleanupTerminalModes() and process.exit() and write to main screen.
- this.scheduleRender.cancel?.();
+ this.scheduleRender.cancel?.()
// Restore stdin from raw mode. unmount() used to do this via React
// unmount (App.componentWillUnmount → handleSetRawMode(false)) but we're
// short-circuiting that path. Must use this.options.stdin — NOT
// process.stdin — because getStdinOverride() may have opened /dev/tty
// when stdin is piped.
const stdin = this.options.stdin as NodeJS.ReadStream & {
- isRaw?: boolean;
- setRawMode?: (m: boolean) => void;
- };
- this.drainStdin();
+ isRaw?: boolean
+ setRawMode?: (m: boolean) => void
+ }
+ this.drainStdin()
if (stdin.isTTY && stdin.isRaw && stdin.setRawMode) {
- stdin.setRawMode(false);
+ stdin.setRawMode(false)
}
}
/** @see drainStdin */
drainStdin(): void {
- drainStdin(this.options.stdin);
+ drainStdin(this.options.stdin)
}
/**
@@ -959,8 +1130,13 @@ export default class Ink {
* stays true. ENTER_ALT_SCREEN is a terminal-side no-op if already in alt.
*/
private reenterAltScreen(): void {
- this.options.stdout.write(ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''));
- this.resetFramesForAltScreen();
+ this.options.stdout.write(
+ ENTER_ALT_SCREEN +
+ ERASE_SCREEN +
+ CURSOR_HOME +
+ (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''),
+ )
+ this.resetFramesForAltScreen()
}
/**
@@ -979,30 +1155,29 @@ export default class Ink {
* matches the physical cursor after ENTER_ALT_SCREEN + CSI H (home).
*/
private resetFramesForAltScreen(): void {
- const rows = this.terminalRows;
- const cols = this.terminalColumns;
+ const rows = this.terminalRows
+ const cols = this.terminalColumns
const blank = (): Frame => ({
- screen: createScreen(cols, rows, this.stylePool, this.charPool, this.hyperlinkPool),
- viewport: {
- width: cols,
- height: rows + 1
- },
- cursor: {
- x: 0,
- y: 0,
- visible: true
- }
- });
- this.frontFrame = blank();
- this.backFrame = blank();
- this.log.reset();
+ screen: createScreen(
+ cols,
+ rows,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ ),
+ viewport: { width: cols, height: rows + 1 },
+ cursor: { x: 0, y: 0, visible: true },
+ })
+ this.frontFrame = blank()
+ this.backFrame = blank()
+ this.log.reset()
// Defense-in-depth: alt-screen skips the cursor preamble anyway (CSI H
// resets), but a stale displayCursor would be misleading if we later
// exit to main-screen without an intervening render.
- this.displayCursor = null;
+ this.displayCursor = null
// Fresh frontFrame is blank rows×cols — blitting from it would copy
// blanks over content. Next alt-screen frame must full-render.
- this.prevFrameContaminated = true;
+ this.prevFrameContaminated = true
}
/**
@@ -1011,16 +1186,16 @@ export default class Ink {
* region stays visible after the automatic copy.
*/
copySelectionNoClear(): string {
- if (!hasSelection(this.selection)) return '';
- const text = getSelectedText(this.selection, this.frontFrame.screen);
+ if (!hasSelection(this.selection)) return ''
+ const text = getSelectedText(this.selection, this.frontFrame.screen)
if (text) {
// Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux
// drops it silently unless allow-passthrough is on — no regression).
void setClipboard(text).then(raw => {
- if (raw) this.options.stdout.write(raw);
- });
+ if (raw) this.options.stdout.write(raw)
+ })
}
- return text;
+ return text
}
/**
@@ -1028,18 +1203,18 @@ export default class Ink {
* and clear the selection. Returns the copied text (empty if no selection).
*/
copySelection(): string {
- if (!hasSelection(this.selection)) return '';
- const text = this.copySelectionNoClear();
- clearSelection(this.selection);
- this.notifySelectionChange();
- return text;
+ if (!hasSelection(this.selection)) return ''
+ const text = this.copySelectionNoClear()
+ clearSelection(this.selection)
+ this.notifySelectionChange()
+ return text
}
/** Clear the current text selection without copying. */
clearTextSelection(): void {
- if (!hasSelection(this.selection)) return;
- clearSelection(this.selection);
- this.notifySelectionChange();
+ if (!hasSelection(this.selection)) return
+ clearSelection(this.selection)
+ this.notifySelectionChange()
}
/**
@@ -1050,9 +1225,9 @@ export default class Ink {
* damage, so the overlay forces full-frame damage while active.
*/
setSearchHighlight(query: string): void {
- if (this.searchHighlightQuery === query) return;
- this.searchHighlightQuery = query;
- this.scheduleRender();
+ if (this.searchHighlightQuery === query) return
+ this.searchHighlightQuery = query
+ this.scheduleRender()
}
/** Paint an EXISTING DOM subtree to a fresh Screen at its natural
@@ -1066,35 +1241,49 @@ export default class Ink {
*
* ~1-2ms (paint only, no reconcile — the DOM is already built). */
scanElementSubtree(el: dom.DOMElement): MatchPosition[] {
- if (!this.searchHighlightQuery || !el.yogaNode) return [];
- const width = Math.ceil(el.yogaNode.getComputedWidth());
- const height = Math.ceil(el.yogaNode.getComputedHeight());
- if (width <= 0 || height <= 0) return [];
+ if (!this.searchHighlightQuery || !el.yogaNode) return []
+ const width = Math.ceil(el.yogaNode.getComputedWidth())
+ const height = Math.ceil(el.yogaNode.getComputedHeight())
+ if (width <= 0 || height <= 0) return []
// renderNodeToOutput adds el's OWN computedLeft/Top to offsetX/Y.
// Passing -elLeft/-elTop nets to 0 → paints at (0,0) in our buffer.
- const elLeft = el.yogaNode.getComputedLeft();
- const elTop = el.yogaNode.getComputedTop();
- const screen = createScreen(width, height, this.stylePool, this.charPool, this.hyperlinkPool);
+ const elLeft = el.yogaNode.getComputedLeft()
+ const elTop = el.yogaNode.getComputedTop()
+ const screen = createScreen(
+ width,
+ height,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
const output = new Output({
width,
height,
stylePool: this.stylePool,
- screen
- });
+ screen,
+ })
renderNodeToOutput(el, output, {
offsetX: -elLeft,
offsetY: -elTop,
- prevScreen: undefined
- });
- const rendered = output.get();
+ prevScreen: undefined,
+ })
+ const rendered = output.get()
// renderNodeToOutput wrote our offset positions to nodeCache —
// corrupts the main render (it'd blit from wrong coords). Mark the
// subtree dirty so the next main render repaints + re-caches
// correctly. One extra paint of this message, but correct > fast.
- dom.markDirty(el);
- const positions = scanPositions(rendered, this.searchHighlightQuery);
- logForDebugging(`scanElementSubtree: q='${this.searchHighlightQuery}' ` + `el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` + `[${positions.slice(0, 10).map(p => `${p.row}:${p.col}`).join(',')}` + `${positions.length > 10 ? ',…' : ''}]`);
- return positions;
+ dom.markDirty(el)
+ const positions = scanPositions(rendered, this.searchHighlightQuery)
+ logForDebugging(
+ `scanElementSubtree: q='${this.searchHighlightQuery}' ` +
+ `el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` +
+ `[${positions
+ .slice(0, 10)
+ .map(p => `${p.row}:${p.col}`)
+ .join(',')}` +
+ `${positions.length > 10 ? ',…' : ''}]`,
+ )
+ return positions
}
/** Set the position-based highlight state. Every frame, writes CURRENT
@@ -1102,13 +1291,15 @@ export default class Ink {
* highlight (inverse on all matches) still runs — this overlays yellow
* on top. rowOffset changes as the user scrolls (= message's current
* screen-top); positions stay stable (message-relative). */
- setSearchPositions(state: {
- positions: MatchPosition[];
- rowOffset: number;
- currentIdx: number;
- } | null): void {
- this.searchPositions = state;
- this.scheduleRender();
+ setSearchPositions(
+ state: {
+ positions: MatchPosition[]
+ rowOffset: number
+ currentIdx: number
+ } | null,
+ ): void {
+ this.searchPositions = state
+ this.scheduleRender()
}
/**
@@ -1129,17 +1320,17 @@ export default class Ink {
// Wrap a NUL marker, then split on it to extract the open/close SGR.
// colorize returns the input unchanged if the color string is bad —
// no NUL-split then, so fall through to null (inverse fallback).
- const wrapped = colorize('\0', color, 'background');
- const nul = wrapped.indexOf('\0');
+ const wrapped = colorize('\0', color, 'background')
+ const nul = wrapped.indexOf('\0')
if (nul <= 0 || nul === wrapped.length - 1) {
- this.stylePool.setSelectionBg(null);
- return;
+ this.stylePool.setSelectionBg(null)
+ return
}
this.stylePool.setSelectionBg({
type: 'ansi',
code: wrapped.slice(0, nul),
- endCode: wrapped.slice(nul + 1) // always \x1b[49m for bg
- });
+ endCode: wrapped.slice(nul + 1), // always \x1b[49m for bg
+ })
// No scheduleRender: this is called from a React effect that already
// runs inside the render cycle, and the bg only matters once a
// selection exists (which itself triggers a full-damage frame).
@@ -1151,8 +1342,18 @@ export default class Ink {
* screen buffer still holds the outgoing content. Accumulated into
* the selection state and joined back in by getSelectedText.
*/
- captureScrolledRows(firstRow: number, lastRow: number, side: 'above' | 'below'): void {
- captureScrolledRows(this.selection, this.frontFrame.screen, firstRow, lastRow, side);
+ captureScrolledRows(
+ firstRow: number,
+ lastRow: number,
+ side: 'above' | 'below',
+ ): void {
+ captureScrolledRows(
+ this.selection,
+ this.frontFrame.screen,
+ firstRow,
+ lastRow,
+ side,
+ )
}
/**
@@ -1163,14 +1364,20 @@ export default class Ink {
* edge. Supplies screen.width for the col-reset-on-clamp boundary.
*/
shiftSelectionForScroll(dRow: number, minRow: number, maxRow: number): void {
- const hadSel = hasSelection(this.selection);
- shiftSelection(this.selection, dRow, minRow, maxRow, this.frontFrame.screen.width);
+ const hadSel = hasSelection(this.selection)
+ shiftSelection(
+ this.selection,
+ dRow,
+ minRow,
+ maxRow,
+ this.frontFrame.screen.width,
+ )
// shiftSelection clears when both endpoints overshoot the same edge
// (Home/g/End/G page-jump past the selection). Notify subscribers so
// useHasSelection updates. Safe to call notifySelectionChange here —
// this runs from keyboard handlers, not inside onRender().
if (hadSel && !hasSelection(this.selection)) {
- this.notifySelectionChange();
+ this.notifySelectionChange()
}
}
@@ -1183,55 +1390,49 @@ export default class Ink {
* char mode. No-op outside alt-screen or without an active selection.
*/
moveSelectionFocus(move: FocusMove): void {
- if (!this.altScreenActive) return;
- const {
- focus
- } = this.selection;
- if (!focus) return;
- const {
- width,
- height
- } = this.frontFrame.screen;
- const maxCol = width - 1;
- const maxRow = height - 1;
- let {
- col,
- row
- } = focus;
+ if (!this.altScreenActive) return
+ const { focus } = this.selection
+ if (!focus) return
+ const { width, height } = this.frontFrame.screen
+ const maxCol = width - 1
+ const maxRow = height - 1
+ let { col, row } = focus
switch (move) {
case 'left':
- if (col > 0) col--;else if (row > 0) {
- col = maxCol;
- row--;
+ if (col > 0) col--
+ else if (row > 0) {
+ col = maxCol
+ row--
}
- break;
+ break
case 'right':
- if (col < maxCol) col++;else if (row < maxRow) {
- col = 0;
- row++;
+ if (col < maxCol) col++
+ else if (row < maxRow) {
+ col = 0
+ row++
}
- break;
+ break
case 'up':
- if (row > 0) row--;
- break;
+ if (row > 0) row--
+ break
case 'down':
- if (row < maxRow) row++;
- break;
+ if (row < maxRow) row++
+ break
case 'lineStart':
- col = 0;
- break;
+ col = 0
+ break
case 'lineEnd':
- col = maxCol;
- break;
+ col = maxCol
+ break
}
- if (col === focus.col && row === focus.row) return;
- moveFocus(this.selection, col, row);
- this.notifySelectionChange();
+ if (col === focus.col && row === focus.row) return
+ moveFocus(this.selection, col, row)
+ this.notifySelectionChange()
}
/** Whether there is an active text selection. */
hasTextSelection(): boolean {
- return hasSelection(this.selection);
+ return hasSelection(this.selection)
}
/**
@@ -1239,12 +1440,13 @@ export default class Ink {
* is started, updated, cleared, or copied. Returns an unsubscribe fn.
*/
subscribeToSelectionChange(cb: () => void): () => void {
- this.selectionListeners.add(cb);
- return () => this.selectionListeners.delete(cb);
+ this.selectionListeners.add(cb)
+ return () => this.selectionListeners.delete(cb)
}
+
private notifySelectionChange(): void {
- this.onRender();
- for (const cb of this.selectionListeners) cb();
+ this.onRender()
+ for (const cb of this.selectionListeners) cb()
}
/**
@@ -1255,26 +1457,33 @@ export default class Ink {
* nodeCache rects map 1:1 to terminal cells (no scrollback offset).
*/
dispatchClick(col: number, row: number): boolean {
- if (!this.altScreenActive) return false;
- const blank = isEmptyCellAt(this.frontFrame.screen, col, row);
- return dispatchClick(this.rootNode, col, row, blank);
+ if (!this.altScreenActive) return false
+ const blank = isEmptyCellAt(this.frontFrame.screen, col, row)
+ return dispatchClick(this.rootNode, col, row, blank)
}
+
dispatchHover(col: number, row: number): void {
- if (!this.altScreenActive) return;
- dispatchHover(this.rootNode, col, row, this.hoveredNodes);
+ if (!this.altScreenActive) return
+ dispatchHover(this.rootNode, col, row, this.hoveredNodes)
}
+
dispatchKeyboardEvent(parsedKey: ParsedKey): void {
- const target = this.focusManager.activeElement ?? this.rootNode;
- const event = new KeyboardEvent(parsedKey);
- dispatcher.dispatchDiscrete(target, event);
+ const target = this.focusManager.activeElement ?? this.rootNode
+ const event = new KeyboardEvent(parsedKey)
+ dispatcher.dispatchDiscrete(target, event)
// Tab cycling is the default action — only fires if no handler
// called preventDefault(). Mirrors browser behavior.
- if (!event.defaultPrevented && parsedKey.name === 'tab' && !parsedKey.ctrl && !parsedKey.meta) {
+ if (
+ !event.defaultPrevented &&
+ parsedKey.name === 'tab' &&
+ !parsedKey.ctrl &&
+ !parsedKey.meta
+ ) {
if (parsedKey.shift) {
- this.focusManager.focusPrevious(this.rootNode);
+ this.focusManager.focusPrevious(this.rootNode)
} else {
- this.focusManager.focusNext(this.rootNode);
+ this.focusManager.focusNext(this.rootNode)
}
}
}
@@ -1288,23 +1497,23 @@ export default class Ink {
* the browser-open action via a timer.
*/
getHyperlinkAt(col: number, row: number): string | undefined {
- if (!this.altScreenActive) return undefined;
- const screen = this.frontFrame.screen;
- const cell = cellAt(screen, col, row);
- let url = cell?.hyperlink;
+ if (!this.altScreenActive) return undefined
+ const screen = this.frontFrame.screen
+ const cell = cellAt(screen, col, row)
+ let url = cell?.hyperlink
// SpacerTail cells (right half of wide/CJK/emoji chars) store the
// hyperlink on the head cell at col-1.
if (!url && cell?.width === CellWidth.SpacerTail && col > 0) {
- url = cellAt(screen, col - 1, row)?.hyperlink;
+ url = cellAt(screen, col - 1, row)?.hyperlink
}
- return url ?? findPlainTextUrlAt(screen, col, row);
+ return url ?? findPlainTextUrlAt(screen, col, row)
}
/**
* Optional callback fired when clicking an OSC 8 hyperlink in fullscreen
* mode. Set by FullscreenLayout via useLayoutEffect.
*/
- onHyperlinkClick: ((url: string) => void) | undefined;
+ onHyperlinkClick: ((url: string) => void) | undefined
/**
* Stable prototype wrapper for onHyperlinkClick. Passed to as
@@ -1312,7 +1521,7 @@ export default class Ink {
* the mutable field at call time — not the undefined-at-render value.
*/
openHyperlink(url: string): void {
- this.onHyperlinkClick?.(url);
+ this.onHyperlinkClick?.(url)
}
/**
@@ -1323,17 +1532,18 @@ export default class Ink {
* char-mode startSelection if the click lands on a noSelect cell.
*/
handleMultiClick(col: number, row: number, count: 2 | 3): void {
- if (!this.altScreenActive) return;
- const screen = this.frontFrame.screen;
+ if (!this.altScreenActive) return
+ const screen = this.frontFrame.screen
// selectWordAt/selectLineAt no-op on noSelect/out-of-bounds. Seed with
// a char-mode selection so the press still starts a drag even if the
// word/line scan finds nothing selectable.
- startSelection(this.selection, col, row);
- if (count === 2) selectWordAt(this.selection, screen, col, row);else selectLineAt(this.selection, screen, row);
+ startSelection(this.selection, col, row)
+ if (count === 2) selectWordAt(this.selection, screen, col, row)
+ else selectLineAt(this.selection, screen, row)
// Ensure hasSelection is true so release doesn't re-dispatch onClickAt.
// selectWordAt no-ops on noSelect; selectLineAt no-ops out-of-bounds.
- if (!this.selection.focus) this.selection.focus = this.selection.anchor;
- this.notifySelectionChange();
+ if (!this.selection.focus) this.selection.focus = this.selection.anchor
+ this.notifySelectionChange()
}
/**
@@ -1343,83 +1553,85 @@ export default class Ink {
* altScreenActive for the same reason as dispatchClick.
*/
handleSelectionDrag(col: number, row: number): void {
- if (!this.altScreenActive) return;
- const sel = this.selection;
+ if (!this.altScreenActive) return
+ const sel = this.selection
if (sel.anchorSpan) {
- extendSelection(sel, this.frontFrame.screen, col, row);
+ extendSelection(sel, this.frontFrame.screen, col, row)
} else {
- updateSelection(sel, col, row);
+ updateSelection(sel, col, row)
}
- this.notifySelectionChange();
+ this.notifySelectionChange()
}
// Methods to properly suspend stdin for external editor usage
// This is needed to prevent Ink from swallowing keystrokes when an external editor is active
private stdinListeners: Array<{
- event: string;
- listener: (...args: unknown[]) => void;
- }> = [];
- private wasRawMode = false;
+ event: string
+ listener: (...args: unknown[]) => void
+ }> = []
+ private wasRawMode = false
+
suspendStdin(): void {
- const stdin = this.options.stdin;
+ const stdin = this.options.stdin
if (!stdin.isTTY) {
- return;
+ return
}
// Store and remove all 'readable' event listeners temporarily
// This prevents Ink from consuming stdin while the editor is active
- const readableListeners = stdin.listeners('readable');
- logForDebugging(`[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${(stdin as NodeJS.ReadStream & {
- isRaw?: boolean;
- }).isRaw ?? false}`);
+ const readableListeners = stdin.listeners('readable')
+ logForDebugging(
+ `[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${(stdin as NodeJS.ReadStream & { isRaw?: boolean }).isRaw ?? false}`,
+ )
readableListeners.forEach(listener => {
this.stdinListeners.push({
event: 'readable',
- listener: listener as (...args: unknown[]) => void
- });
- stdin.removeListener('readable', listener as (...args: unknown[]) => void);
- });
+ listener: listener as (...args: unknown[]) => void,
+ })
+ stdin.removeListener('readable', listener as (...args: unknown[]) => void)
+ })
// If raw mode is enabled, disable it temporarily
const stdinWithRaw = stdin as NodeJS.ReadStream & {
- isRaw?: boolean;
- setRawMode?: (mode: boolean) => void;
- };
+ isRaw?: boolean
+ setRawMode?: (mode: boolean) => void
+ }
if (stdinWithRaw.isRaw && stdinWithRaw.setRawMode) {
- stdinWithRaw.setRawMode(false);
- this.wasRawMode = true;
+ stdinWithRaw.setRawMode(false)
+ this.wasRawMode = true
}
}
+
resumeStdin(): void {
- const stdin = this.options.stdin;
+ const stdin = this.options.stdin
if (!stdin.isTTY) {
- return;
+ return
}
// Re-attach all the stored listeners
if (this.stdinListeners.length === 0 && !this.wasRawMode) {
- logForDebugging('[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', {
- level: 'warn'
- });
+ logForDebugging(
+ '[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)',
+ { level: 'warn' },
+ )
}
- logForDebugging(`[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}`);
- this.stdinListeners.forEach(({
- event,
- listener
- }) => {
- stdin.addListener(event, listener);
- });
- this.stdinListeners = [];
+ logForDebugging(
+ `[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}`,
+ )
+ this.stdinListeners.forEach(({ event, listener }) => {
+ stdin.addListener(event, listener)
+ })
+ this.stdinListeners = []
// Re-enable raw mode if it was enabled before
if (this.wasRawMode) {
const stdinWithRaw = stdin as NodeJS.ReadStream & {
- setRawMode?: (mode: boolean) => void;
- };
- if (stdinWithRaw.setRawMode) {
- stdinWithRaw.setRawMode(true);
+ setRawMode?: (mode: boolean) => void
}
- this.wasRawMode = false;
+ if (stdinWithRaw.setRawMode) {
+ stdinWithRaw.setRawMode(true)
+ }
+ this.wasRawMode = false
}
}
@@ -1428,41 +1640,78 @@ export default class Ink {
// cascades through useContext → 's useLayoutEffect dep
// array → spurious exit+re-enter of the alt screen on every SIGWINCH.
private writeRaw(data: string): void {
- this.options.stdout.write(data);
+ this.options.stdout.write(data)
}
- private setCursorDeclaration: CursorDeclarationSetter = (decl, clearIfNode) => {
- if (decl === null && clearIfNode !== undefined && this.cursorDeclaration?.node !== clearIfNode) {
- return;
+
+ private setCursorDeclaration: CursorDeclarationSetter = (
+ decl,
+ clearIfNode,
+ ) => {
+ if (
+ decl === null &&
+ clearIfNode !== undefined &&
+ this.cursorDeclaration?.node !== clearIfNode
+ ) {
+ return
}
- this.cursorDeclaration = decl;
- };
+ this.cursorDeclaration = decl
+ }
+
render(node: ReactNode): void {
- this.currentNode = node;
- const tree =
+ this.currentNode = node
+
+ const tree = (
+
{node}
- ;
+
+ )
- reconciler.updateContainerSync(tree, this.container, null, noop);
- reconciler.flushSyncWork();
+ // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
+ reconciler.updateContainerSync(tree, this.container, null, noop)
+ // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
+ reconciler.flushSyncWork()
}
+
unmount(error?: Error | number | null): void {
if (this.isUnmounted) {
- return;
+ return
}
- this.onRender();
- this.unsubscribeExit();
+
+ this.onRender()
+ this.unsubscribeExit()
+
if (typeof this.restoreConsole === 'function') {
- this.restoreConsole();
+ this.restoreConsole()
}
- this.restoreStderr?.();
- this.unsubscribeTTYHandlers?.();
+ this.restoreStderr?.()
+
+ this.unsubscribeTTYHandlers?.()
// Non-TTY environments don't handle erasing ansi escapes well, so it's better to
// only render last frame of non-static output
- const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame);
- writeDiffToTerminal(this.terminal, optimize(diff));
+ const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame)
+ writeDiffToTerminal(this.terminal, optimize(diff))
// Clean up terminal modes synchronously before process exit.
// React's componentWillUnmount won't run in time when process.exit() is called,
@@ -1476,70 +1725,83 @@ export default class Ink {
if (this.altScreenActive) {
// 's unmount effect won't run during signal-exit.
// Exit alt screen FIRST so other cleanup sequences go to the main screen.
- writeSync(1, EXIT_ALT_SCREEN);
+ writeSync(1, EXIT_ALT_SCREEN)
}
// Disable mouse tracking — unconditional because altScreenActive can be
// stale if AlternateScreen's unmount (which flips the flag) raced a
// blocked event loop + SIGINT. No-op if tracking was never enabled.
- writeSync(1, DISABLE_MOUSE_TRACKING);
+ writeSync(1, DISABLE_MOUSE_TRACKING)
// Drain stdin so in-flight mouse events don't leak to the shell
- this.drainStdin();
+ this.drainStdin()
// Disable extended key reporting (both kitty and modifyOtherKeys)
- writeSync(1, DISABLE_MODIFY_OTHER_KEYS);
- writeSync(1, DISABLE_KITTY_KEYBOARD);
+ writeSync(1, DISABLE_MODIFY_OTHER_KEYS)
+ writeSync(1, DISABLE_KITTY_KEYBOARD)
// Disable focus events (DECSET 1004)
- writeSync(1, DFE);
+ writeSync(1, DFE)
// Disable bracketed paste mode
- writeSync(1, DBP);
+ writeSync(1, DBP)
// Show cursor
- writeSync(1, SHOW_CURSOR);
+ writeSync(1, SHOW_CURSOR)
// Clear iTerm2 progress bar
- writeSync(1, CLEAR_ITERM2_PROGRESS);
+ writeSync(1, CLEAR_ITERM2_PROGRESS)
// Clear tab status (OSC 21337) so a stale dot doesn't linger
- if (supportsTabStatus()) writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS));
+ if (supportsTabStatus())
+ writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS))
}
/* eslint-enable custom-rules/no-sync-fs */
- this.isUnmounted = true;
+ this.isUnmounted = true
// Cancel any pending throttled renders to prevent accessing freed Yoga nodes
- this.scheduleRender.cancel?.();
+ this.scheduleRender.cancel?.()
if (this.drainTimer !== null) {
- clearTimeout(this.drainTimer);
- this.drainTimer = null;
+ clearTimeout(this.drainTimer)
+ this.drainTimer = null
}
- reconciler.updateContainerSync(null, this.container, null, noop);
- reconciler.flushSyncWork();
- instances.delete(this.options.stdout);
+ // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
+ reconciler.updateContainerSync(null, this.container, null, noop)
+ // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
+ reconciler.flushSyncWork()
+ instances.delete(this.options.stdout)
// Free the root yoga node, then clear its reference. Children are already
// freed by the reconciler's removeChildFromContainer; using .free() (not
// .freeRecursive()) avoids double-freeing them.
- this.rootNode.yogaNode?.free();
- this.rootNode.yogaNode = undefined;
+ this.rootNode.yogaNode?.free()
+ this.rootNode.yogaNode = undefined
+
if (error instanceof Error) {
- this.rejectExitPromise(error);
+ this.rejectExitPromise(error)
} else {
- this.resolveExitPromise();
+ this.resolveExitPromise()
}
}
+
async waitUntilExit(): Promise {
this.exitPromise ||= new Promise((resolve, reject) => {
- this.resolveExitPromise = resolve;
- this.rejectExitPromise = reject;
- });
- return this.exitPromise;
+ this.resolveExitPromise = resolve
+ this.rejectExitPromise = reject
+ })
+
+ return this.exitPromise
}
+
resetLineCount(): void {
if (this.options.stdout.isTTY) {
// Swap so old front becomes back (for screen reuse), then reset front
- this.backFrame = this.frontFrame;
- this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
- this.log.reset();
+ this.backFrame = this.frontFrame
+ this.frontFrame = emptyFrame(
+ this.frontFrame.viewport.height,
+ this.frontFrame.viewport.width,
+ this.stylePool,
+ this.charPool,
+ this.hyperlinkPool,
+ )
+ this.log.reset()
// frontFrame is reset, so frame.cursor on the next render is (0,0).
// Clear displayCursor so the preamble doesn't compute a stale delta.
- this.displayCursor = null;
+ this.displayCursor = null
}
}
@@ -1552,34 +1814,41 @@ export default class Ink {
* Call between conversation turns or periodically.
*/
resetPools(): void {
- this.charPool = new CharPool();
- this.hyperlinkPool = new HyperlinkPool();
- migrateScreenPools(this.frontFrame.screen, this.charPool, this.hyperlinkPool);
+ this.charPool = new CharPool()
+ this.hyperlinkPool = new HyperlinkPool()
+ migrateScreenPools(
+ this.frontFrame.screen,
+ this.charPool,
+ this.hyperlinkPool,
+ )
// Back frame's data is zeroed by resetScreen before reads, but its pool
// references are used by the renderer to intern new characters. Point
// them at the new pools so the next frame's IDs are comparable.
- this.backFrame.screen.charPool = this.charPool;
- this.backFrame.screen.hyperlinkPool = this.hyperlinkPool;
+ this.backFrame.screen.charPool = this.charPool
+ this.backFrame.screen.hyperlinkPool = this.hyperlinkPool
}
+
patchConsole(): () => void {
// biome-ignore lint/suspicious/noConsole: intentionally patching global console
- const con = console;
- const originals: Partial> = {};
- const toDebug = (...args: unknown[]) => logForDebugging(`console.log: ${format(...args)}`);
- const toError = (...args: unknown[]) => logError(new Error(`console.error: ${format(...args)}`));
+ const con = console
+ const originals: Partial> = {}
+ const toDebug = (...args: unknown[]) =>
+ logForDebugging(`console.log: ${format(...args)}`)
+ const toError = (...args: unknown[]) =>
+ logError(new Error(`console.error: ${format(...args)}`))
for (const m of CONSOLE_STDOUT_METHODS) {
- originals[m] = con[m];
- con[m] = toDebug;
+ originals[m] = con[m]
+ con[m] = toDebug
}
for (const m of CONSOLE_STDERR_METHODS) {
- originals[m] = con[m];
- con[m] = toError;
+ originals[m] = con[m]
+ con[m] = toError
}
- originals.assert = con.assert;
+ originals.assert = con.assert
con.assert = (condition: unknown, ...args: unknown[]) => {
- if (!condition) toError(...args);
- };
- return () => Object.assign(con, originals);
+ if (!condition) toError(...args)
+ }
+ return () => Object.assign(con, originals)
}
/**
@@ -1595,40 +1864,46 @@ export default class Ink {
* process.stdout — Ink itself writes there.
*/
private patchStderr(): () => void {
- const stderr = process.stderr;
- const originalWrite = stderr.write;
- let reentered = false;
- const intercept = (chunk: Uint8Array | string, encodingOrCb?: BufferEncoding | ((err?: Error) => void), cb?: (err?: Error) => void): boolean => {
- const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb;
+ const stderr = process.stderr
+ const originalWrite = stderr.write
+ let reentered = false
+ const intercept = (
+ chunk: Uint8Array | string,
+ encodingOrCb?: BufferEncoding | ((err?: Error) => void),
+ cb?: (err?: Error) => void,
+ ): boolean => {
+ const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb
// Reentrancy guard: logForDebugging → writeToStderr → here. Pass
// through to the original so --debug-to-stderr still works and we
// don't stack-overflow.
if (reentered) {
- const encoding = typeof encodingOrCb === 'string' ? encodingOrCb : undefined;
- return originalWrite.call(stderr, chunk, encoding, callback);
+ const encoding =
+ typeof encodingOrCb === 'string' ? encodingOrCb : undefined
+ return originalWrite.call(stderr, chunk, encoding, callback)
}
- reentered = true;
+ reentered = true
try {
- const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
- logForDebugging(`[stderr] ${text}`, {
- level: 'warn'
- });
+ const text =
+ typeof chunk === 'string'
+ ? chunk
+ : Buffer.from(chunk).toString('utf8')
+ logForDebugging(`[stderr] ${text}`, { level: 'warn' })
if (this.altScreenActive && !this.isUnmounted && !this.isPaused) {
- this.prevFrameContaminated = true;
- this.scheduleRender();
+ this.prevFrameContaminated = true
+ this.scheduleRender()
}
} finally {
- reentered = false;
- callback?.();
+ reentered = false
+ callback?.()
}
- return true;
- };
- stderr.write = intercept;
+ return true
+ }
+ stderr.write = intercept
return () => {
if (stderr.write === intercept) {
- stderr.write = originalWrite;
+ stderr.write = originalWrite
}
- };
+ }
}
}
@@ -1655,7 +1930,7 @@ export default class Ink {
*/
/* eslint-disable custom-rules/no-sync-fs -- must be sync; called from signal handler / unmount */
export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void {
- if (!stdin.isTTY) return;
+ if (!stdin.isTTY) return
// Drain Node's stream buffer (bytes libuv already pulled in). read()
// returns null when empty — never blocks.
try {
@@ -1667,27 +1942,27 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void {
}
// No /dev/tty on Windows; CONIN$ doesn't support O_NONBLOCK semantics.
// Windows Terminal also doesn't buffer mouse reports the same way.
- if (process.platform === 'win32') return;
+ if (process.platform === 'win32') return
// termios is per-device: flip stdin to raw so canonical-mode line
// buffering doesn't hide partial input from the non-blocking read.
// Restored in the finally block.
const tty = stdin as NodeJS.ReadStream & {
- isRaw?: boolean;
- setRawMode?: (raw: boolean) => void;
- };
- const wasRaw = tty.isRaw === true;
+ isRaw?: boolean
+ setRawMode?: (raw: boolean) => void
+ }
+ const wasRaw = tty.isRaw === true
// Drain the kernel TTY buffer via a fresh O_NONBLOCK fd. Bounded at 64
// reads (64KB) — a real mouse burst is a few hundred bytes; the cap
// guards against a terminal that ignores O_NONBLOCK.
- let fd = -1;
+ let fd = -1
try {
// setRawMode inside try: on revoked TTY (SIGHUP/SSH disconnect) the
// ioctl throws EBADF — same recovery path as openSync/readSync below.
- if (!wasRaw) tty.setRawMode?.(true);
- fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
- const buf = Buffer.alloc(1024);
+ if (!wasRaw) tty.setRawMode?.(true)
+ fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK)
+ const buf = Buffer.alloc(1024)
for (let i = 0; i < 64; i++) {
- if (readSync(fd, buf, 0, buf.length, null) <= 0) break;
+ if (readSync(fd, buf, 0, buf.length, null) <= 0) break
}
} catch {
// EAGAIN (buffer empty — expected), ENXIO/ENOENT (no controlling tty),
@@ -1695,14 +1970,14 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void {
} finally {
if (fd >= 0) {
try {
- closeSync(fd);
+ closeSync(fd)
} catch {
/* ignore */
}
}
if (!wasRaw) {
try {
- tty.setRawMode?.(false);
+ tty.setRawMode?.(false)
} catch {
/* TTY may be gone */
}
@@ -1711,5 +1986,20 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void {
}
/* eslint-enable custom-rules/no-sync-fs */
-const CONSOLE_STDOUT_METHODS = ['log', 'info', 'debug', 'dir', 'dirxml', 'count', 'countReset', 'group', 'groupCollapsed', 'groupEnd', 'table', 'time', 'timeEnd', 'timeLog'] as const;
-const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const;
+const CONSOLE_STDOUT_METHODS = [
+ 'log',
+ 'info',
+ 'debug',
+ 'dir',
+ 'dirxml',
+ 'count',
+ 'countReset',
+ 'group',
+ 'groupCollapsed',
+ 'groupEnd',
+ 'table',
+ 'time',
+ 'timeEnd',
+ 'timeLog',
+] as const
+const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const
diff --git a/src/keybindings/KeybindingContext.tsx b/src/keybindings/KeybindingContext.tsx
index ef61bcf68..bc85e81c0 100644
--- a/src/keybindings/KeybindingContext.tsx
+++ b/src/keybindings/KeybindingContext.tsx
@@ -1,149 +1,152 @@
-import { c as _c } from "react/compiler-runtime";
-import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react';
-import type { Key } from '../ink.js';
-import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js';
-import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js';
+import React, {
+ createContext,
+ type RefObject,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+} from 'react'
+import type { Key } from '../ink.js'
+import {
+ type ChordResolveResult,
+ getBindingDisplayText,
+ resolveKeyWithChordState,
+} from './resolver.js'
+import type {
+ KeybindingContextName,
+ ParsedBinding,
+ ParsedKeystroke,
+} from './types.js'
/** Handler registration for action callbacks */
type HandlerRegistration = {
- action: string;
- context: KeybindingContextName;
- handler: () => void;
-};
+ action: string
+ context: KeybindingContextName
+ handler: () => void
+}
+
type KeybindingContextValue = {
/** Resolve a key input to an action name (with chord support) */
- resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult;
+ resolve: (
+ input: string,
+ key: Key,
+ activeContexts: KeybindingContextName[],
+ ) => ChordResolveResult
/** Update the pending chord state */
- setPendingChord: (pending: ParsedKeystroke[] | null) => void;
+ setPendingChord: (pending: ParsedKeystroke[] | null) => void
/** Get display text for an action (e.g., "ctrl+t") */
- getDisplayText: (action: string, context: KeybindingContextName) => string | undefined;
+ getDisplayText: (
+ action: string,
+ context: KeybindingContextName,
+ ) => string | undefined
/** All parsed bindings (for help display) */
- bindings: ParsedBinding[];
+ bindings: ParsedBinding[]
/** Current pending chord keystrokes (null if not in a chord) */
- pendingChord: ParsedKeystroke[] | null;
+ pendingChord: ParsedKeystroke[] | null
/** Currently active keybinding contexts (for priority resolution) */
- activeContexts: Set;
+ activeContexts: Set
/** Register a context as active (call on mount) */
- registerActiveContext: (context: KeybindingContextName) => void;
+ registerActiveContext: (context: KeybindingContextName) => void
/** Unregister a context (call on unmount) */
- unregisterActiveContext: (context: KeybindingContextName) => void;
+ unregisterActiveContext: (context: KeybindingContextName) => void
/** Register a handler for an action (used by useKeybinding) */
- registerHandler: (registration: HandlerRegistration) => () => void;
+ registerHandler: (registration: HandlerRegistration) => () => void
/** Invoke all handlers for an action (used by ChordInterceptor) */
- invokeAction: (action: string) => boolean;
-};
-const KeybindingContext = createContext(null);
+ invokeAction: (action: string) => boolean
+}
+
+const KeybindingContext = createContext(null)
+
type ProviderProps = {
- bindings: ParsedBinding[];
+ bindings: ParsedBinding[]
/** Ref for immediate access to pending chord (avoids React state delay) */
- pendingChordRef: RefObject;
+ pendingChordRef: RefObject
/** State value for re-renders (UI updates) */
- pendingChord: ParsedKeystroke[] | null;
- setPendingChord: (pending: ParsedKeystroke[] | null) => void;
- activeContexts: Set;
- registerActiveContext: (context: KeybindingContextName) => void;
- unregisterActiveContext: (context: KeybindingContextName) => void;
+ pendingChord: ParsedKeystroke[] | null
+ setPendingChord: (pending: ParsedKeystroke[] | null) => void
+ activeContexts: Set
+ registerActiveContext: (context: KeybindingContextName) => void
+ unregisterActiveContext: (context: KeybindingContextName) => void
/** Ref to handler registry (used by ChordInterceptor) */
- handlerRegistryRef: RefObject
+ ) : null}
+
+ )
}
-const TITLE_ANIMATION_FRAMES = ['⠂', '⠐'];
-const TITLE_STATIC_PREFIX = '✳';
-const TITLE_ANIMATION_INTERVAL_MS = 960;
+
+const TITLE_ANIMATION_FRAMES = ['⠂', '⠐']
+const TITLE_STATIC_PREFIX = '✳'
+const TITLE_ANIMATION_INTERVAL_MS = 960
/**
* Sets the terminal tab title, with an animated prefix glyph while a query
@@ -483,94 +825,86 @@ const TITLE_ANIMATION_INTERVAL_MS = 960;
* entire REPL tree. Before extraction, the tick was ~1 REPL render/sec for
* the duration of every turn, dragging PromptInput and friends along.
*/
-function AnimatedTerminalTitle(t0) {
- const $ = _c(6);
- const {
- isAnimating,
- title,
- disabled,
- noPrefix
- } = t0;
- const terminalFocused = useTerminalFocus();
- const [frame, setFrame] = useState(0);
- let t1;
- let t2;
- if ($[0] !== disabled || $[1] !== isAnimating || $[2] !== noPrefix || $[3] !== terminalFocused) {
- t1 = () => {
- if (disabled || noPrefix || !isAnimating || !terminalFocused) {
- return;
- }
- const interval = setInterval(_temp2, TITLE_ANIMATION_INTERVAL_MS, setFrame);
- return () => clearInterval(interval);
- };
- t2 = [disabled, noPrefix, isAnimating, terminalFocused];
- $[0] = disabled;
- $[1] = isAnimating;
- $[2] = noPrefix;
- $[3] = terminalFocused;
- $[4] = t1;
- $[5] = t2;
- } else {
- t1 = $[4];
- t2 = $[5];
- }
- useEffect(t1, t2);
- const prefix = isAnimating ? TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX : TITLE_STATIC_PREFIX;
- useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`);
- return null;
-}
-function _temp2(setFrame_0) {
- return setFrame_0(_temp);
-}
-function _temp(f) {
- return (f + 1) % TITLE_ANIMATION_FRAMES.length;
+function AnimatedTerminalTitle({
+ isAnimating,
+ title,
+ disabled,
+ noPrefix,
+}: {
+ isAnimating: boolean
+ title: string
+ disabled: boolean
+ noPrefix: boolean
+}): null {
+ const terminalFocused = useTerminalFocus()
+ const [frame, setFrame] = useState(0)
+ useEffect(() => {
+ if (disabled || noPrefix || !isAnimating || !terminalFocused) return
+ const interval = setInterval(
+ setFrame => setFrame(f => (f + 1) % TITLE_ANIMATION_FRAMES.length),
+ TITLE_ANIMATION_INTERVAL_MS,
+ setFrame,
+ )
+ return () => clearInterval(interval)
+ }, [disabled, noPrefix, isAnimating, terminalFocused])
+ const prefix = isAnimating
+ ? (TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX)
+ : TITLE_STATIC_PREFIX
+ useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`)
+ return null
}
+
export type Props = {
- commands: Command[];
- debug: boolean;
- initialTools: Tool[];
+ commands: Command[]
+ debug: boolean
+ initialTools: Tool[]
// Initial messages to populate the REPL with
- initialMessages?: MessageType[];
+ initialMessages?: MessageType[]
// Deferred hook messages promise — REPL renders immediately and injects
// hook messages when they resolve. Awaited before the first API call.
- pendingHookMessages?: Promise;
- initialFileHistorySnapshots?: FileHistorySnapshot[];
+ pendingHookMessages?: Promise
+ initialFileHistorySnapshots?: FileHistorySnapshot[]
// Content-replacement records from a resumed session's transcript — used to
// reconstruct contentReplacementState so the same results are re-replaced
- initialContentReplacements?: ContentReplacementRecord[];
+ initialContentReplacements?: ContentReplacementRecord[]
// Initial agent context for session resume (name/color set via /rename or /color)
- initialAgentName?: string;
- initialAgentColor?: AgentColorName;
- mcpClients?: MCPServerConnection[];
- dynamicMcpConfig?: Record;
- autoConnectIdeFlag?: boolean;
- strictMcpConfig?: boolean;
- systemPrompt?: string;
- appendSystemPrompt?: string;
+ initialAgentName?: string
+ initialAgentColor?: AgentColorName
+ mcpClients?: MCPServerConnection[]
+ dynamicMcpConfig?: Record
+ autoConnectIdeFlag?: boolean
+ strictMcpConfig?: boolean
+ systemPrompt?: string
+ appendSystemPrompt?: string
// Optional callback invoked before query execution
// Called after user message is added to conversation but before API call
// Return false to prevent query execution
- onBeforeQuery?: (input: string, newMessages: MessageType[]) => Promise;
+ onBeforeQuery?: (
+ input: string,
+ newMessages: MessageType[],
+ ) => Promise
// Optional callback when a turn completes (model finishes responding)
- onTurnComplete?: (messages: MessageType[]) => void | Promise;
+ onTurnComplete?: (messages: MessageType[]) => void | Promise
// When true, disables REPL input (hides prompt and prevents message selector)
- disabled?: boolean;
+ disabled?: boolean
// Optional agent definition to use for the main thread
- mainThreadAgentDefinition?: AgentDefinition;
+ mainThreadAgentDefinition?: AgentDefinition
// When true, disables all slash commands
- disableSlashCommands?: boolean;
+ disableSlashCommands?: boolean
// Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks.
- taskListId?: string;
+ taskListId?: string
// Remote session config for --remote mode (uses CCR as execution engine)
- remoteSessionConfig?: RemoteSessionConfig;
+ remoteSessionConfig?: RemoteSessionConfig
// Direct connect config for `claude connect` mode (connects to a claude server)
- directConnectConfig?: DirectConnectConfig;
+ directConnectConfig?: DirectConnectConfig
// SSH session for `claude ssh` mode (local REPL, remote tools over ssh)
- sshSession?: SSHSession;
+ sshSession?: SSHSession
// Thinking configuration to use when thinking is enabled
- thinkingConfig: ThinkingConfig;
-};
-export type Screen = 'prompt' | 'transcript';
+ thinkingConfig: ThinkingConfig
+}
+
+export type Screen = 'prompt' | 'transcript'
+
export function REPL({
commands: initialCommands,
debug,
@@ -596,67 +930,92 @@ export function REPL({
remoteSessionConfig,
directConnectConfig,
sshSession,
- thinkingConfig
+ thinkingConfig,
}: Props): React.ReactNode {
- const isRemoteSession = !!remoteSessionConfig;
+ const isRemoteSession = !!remoteSessionConfig
// Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+
// includes, and these were on the render path (hot during PageUp spam).
- const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []);
- const moreRightEnabled = useMemo(() => (process.env.USER_TYPE) === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []);
- const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
- const disableMessageActions = feature('MESSAGE_ACTIONS') ?
- // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
- useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []) : false;
+ const titleDisabled = useMemo(
+ () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE),
+ [],
+ )
+ const moreRightEnabled = useMemo(
+ () =>
+ process.env.USER_TYPE === 'ant' &&
+ isEnvTruthy(process.env.CLAUDE_MORERIGHT),
+ [],
+ )
+ const disableVirtualScroll = useMemo(
+ () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL),
+ [],
+ )
+ const disableMessageActions = feature('MESSAGE_ACTIONS')
+ ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
+ useMemo(
+ () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS),
+ [],
+ )
+ : false
// Log REPL mount/unmount lifecycle
useEffect(() => {
- logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`);
- return () => logForDebugging(`[REPL:unmount] REPL unmounting`);
- }, [disabled]);
+ logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`)
+ return () => logForDebugging(`[REPL:unmount] REPL unmounting`)
+ }, [disabled])
// Agent definition is state so /resume can update it mid-session
- const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState(initialMainThreadAgentDefinition);
- const toolPermissionContext = useAppState(s => s.toolPermissionContext);
- const verbose = useAppState(s => s.verbose);
- const mcp = useAppState(s => s.mcp);
- const plugins = useAppState(s => s.plugins);
- const agentDefinitions = useAppState(s => s.agentDefinitions);
- const fileHistory = useAppState(s => s.fileHistory);
- const initialMessage = useAppState(s => s.initialMessage);
- const queuedCommands = useCommandQueue();
+ const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState(
+ initialMainThreadAgentDefinition,
+ )
+
+ const toolPermissionContext = useAppState(s => s.toolPermissionContext)
+ const verbose = useAppState(s => s.verbose)
+ const mcp = useAppState(s => s.mcp)
+ const plugins = useAppState(s => s.plugins)
+ const agentDefinitions = useAppState(s => s.agentDefinitions)
+ const fileHistory = useAppState(s => s.fileHistory)
+ const initialMessage = useAppState(s => s.initialMessage)
+ const queuedCommands = useCommandQueue()
// feature() is a build-time constant — dead code elimination removes the hook
// call entirely in external builds, so this is safe despite looking conditional.
// These fields contain excluded strings that must not appear in external builds.
- const spinnerTip = useAppState(s => s.spinnerTip);
- const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks';
- const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest);
- const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest);
- const teamContext = useAppState(s => s.teamContext);
- const tasks = useAppState(s => s.tasks);
- const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions);
- const elicitation = useAppState(s => s.elicitation);
- const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice);
- const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending);
- const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
- const setAppState = useSetAppState();
+ const spinnerTip = useAppState(s => s.spinnerTip)
+ const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks'
+ const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest)
+ const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest)
+ const teamContext = useAppState(s => s.teamContext)
+ const tasks = useAppState(s => s.tasks)
+ const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions)
+ const elicitation = useAppState(s => s.elicitation)
+ const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice)
+ const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending)
+ const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
+ const setAppState = useSetAppState()
// Bootstrap: retained local_agent that hasn't loaded disk yet → read
// sidechain JSONL and UUID-merge with whatever stream has appended so far.
// Stream appends immediately on retain (no defer); bootstrap fills the
// prefix. Disk-write-before-yield means live is always a suffix of disk.
- const viewedLocalAgent = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
- const needsBootstrap = isLocalAgentTask(viewedLocalAgent) && viewedLocalAgent.retain && !viewedLocalAgent.diskLoaded;
+ const viewedLocalAgent = viewingAgentTaskId
+ ? tasks[viewingAgentTaskId]
+ : undefined
+ const needsBootstrap =
+ isLocalAgentTask(viewedLocalAgent) &&
+ viewedLocalAgent.retain &&
+ !viewedLocalAgent.diskLoaded
useEffect(() => {
- if (!viewingAgentTaskId || !needsBootstrap) return;
- const taskId = viewingAgentTaskId;
+ if (!viewingAgentTaskId || !needsBootstrap) return
+ const taskId = viewingAgentTaskId
void getAgentTranscript(asAgentId(taskId)).then(result => {
setAppState(prev => {
- const t = prev.tasks[taskId];
- if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev;
- const live = t.messages ?? [];
- const liveUuids = new Set(live.map(m => m.uuid));
- const diskOnly = result ? result.messages.filter(m => !liveUuids.has(m.uuid)) : [];
+ const t = prev.tasks[taskId]
+ if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev
+ const live = t.messages ?? []
+ const liveUuids = new Set(live.map(m => m.uuid))
+ const diskOnly = result
+ ? result.messages.filter(m => !liveUuids.has(m.uuid))
+ : []
return {
...prev,
tasks: {
@@ -664,29 +1023,36 @@ export function REPL({
[taskId]: {
...t,
messages: [...diskOnly, ...live],
- diskLoaded: true
- }
- }
- };
- });
- });
- }, [viewingAgentTaskId, needsBootstrap, setAppState]);
- const store = useAppStateStore();
- const terminal = useTerminalNotification();
- const mainLoopModel = useMainLoopModel();
+ diskLoaded: true,
+ },
+ },
+ }
+ })
+ })
+ }, [viewingAgentTaskId, needsBootstrap, setAppState])
+
+ const store = useAppStateStore()
+ const terminal = useTerminalNotification()
+ const mainLoopModel = useMainLoopModel()
// Note: standaloneAgentContext is initialized in main.tsx (via initialState) or
// ResumeConversation.tsx (via setAppState before rendering REPL) to avoid
// useEffect-based state initialization on mount (per CLAUDE.md guidelines)
// Local state for commands (hot-reloadable when skill files change)
- const [localCommands, setLocalCommands] = useState(initialCommands);
+ const [localCommands, setLocalCommands] = useState(initialCommands)
// Watch for skill file changes and reload all commands
- useSkillsChange(isRemoteSession ? undefined : getProjectRoot(), setLocalCommands);
+ useSkillsChange(
+ isRemoteSession ? undefined : getProjectRoot(),
+ setLocalCommands,
+ )
// Track proactive mode for tools dependency - SleepTool filters by proactive state
- const proactiveActive = React.useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE);
+ const proactiveActive = React.useSyncExternalStore(
+ proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE,
+ proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE,
+ )
// BriefTool.isEnabled() reads getUserMsgOptIn() from bootstrap state, which
// /brief flips mid-session alongside isBriefOnly. The memo below needs a
@@ -694,99 +1060,113 @@ export function REPL({
// the AppState mirror that triggers the re-render. Without this, toggling
// /brief mid-session leaves the stale tool list (no SendUserMessage) and
// the model emits plain text the brief filter hides.
- const isBriefOnly = useAppState(s => s.isBriefOnly);
- const localTools = useMemo(() => getTools(toolPermissionContext), [toolPermissionContext, proactiveActive, isBriefOnly]);
- useKickOffCheckAndDisableBypassPermissionsIfNeeded();
- useKickOffCheckAndDisableAutoModeIfNeeded();
- const [dynamicMcpConfig, setDynamicMcpConfig] = useState | undefined>(initialDynamicMcpConfig);
- const onChangeDynamicMcpConfig = useCallback((config: Record) => {
- setDynamicMcpConfig(config);
- }, [setDynamicMcpConfig]);
- const [screen, setScreen] = useState('prompt');
- const [showAllInTranscript, setShowAllInTranscript] = useState(false);
+ const isBriefOnly = useAppState(s => s.isBriefOnly)
+
+ const localTools = useMemo(
+ () => getTools(toolPermissionContext),
+ [toolPermissionContext, proactiveActive, isBriefOnly],
+ )
+
+ useKickOffCheckAndDisableBypassPermissionsIfNeeded()
+ useKickOffCheckAndDisableAutoModeIfNeeded()
+
+ const [dynamicMcpConfig, setDynamicMcpConfig] = useState<
+ Record | undefined
+ >(initialDynamicMcpConfig)
+
+ const onChangeDynamicMcpConfig = useCallback(
+ (config: Record) => {
+ setDynamicMcpConfig(config)
+ },
+ [setDynamicMcpConfig],
+ )
+
+ const [screen, setScreen] = useState('prompt')
+ const [showAllInTranscript, setShowAllInTranscript] = useState(false)
// [ forces the dump-to-scrollback path inside transcript mode. Separate
// from CLAUDE_CODE_NO_FLICKER=0 (which is process-lifetime) — this is
// ephemeral, reset on transcript exit. Diagnostic escape hatch so
// terminal/tmux native cmd-F can search the full flat render.
- const [dumpMode, setDumpMode] = useState(false);
+ const [dumpMode, setDumpMode] = useState(false)
// v-for-editor render progress. Inline in the footer — notifications
// render inside PromptInput which isn't mounted in transcript.
- const [editorStatus, setEditorStatus] = useState('');
+ const [editorStatus, setEditorStatus] = useState('')
// Incremented on transcript exit. Async v-render captures this at start;
// each status write no-ops if stale (user left transcript mid-render —
// the stable setState would otherwise stamp a ghost toast into the next
// session). Also clears any pending 4s auto-clear.
- const editorGenRef = useRef(0);
- const editorTimerRef = useRef | undefined>(undefined);
- const editorRenderingRef = useRef(false);
- const {
- addNotification,
- removeNotification
- } = useNotifications();
+ const editorGenRef = useRef(0)
+ const editorTimerRef = useRef | undefined>(
+ undefined,
+ )
+ const editorRenderingRef = useRef(false)
+ const { addNotification, removeNotification } = useNotifications()
// eslint-disable-next-line prefer-const
- let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP;
- const mcpClients = useMergedClients(initialMcpClients, mcp.clients);
+ let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP
+
+ const mcpClients = useMergedClients(initialMcpClients, mcp.clients)
// IDE integration
- const [ideSelection, setIDESelection] = useState(undefined);
- const [ideToInstallExtension, setIDEToInstallExtension] = useState(null);
- const [ideInstallationStatus, setIDEInstallationStatus] = useState(null);
- const [showIdeOnboarding, setShowIdeOnboarding] = useState(false);
+ const [ideSelection, setIDESelection] = useState(
+ undefined,
+ )
+ const [ideToInstallExtension, setIDEToInstallExtension] =
+ useState(null)
+ const [ideInstallationStatus, setIDEInstallationStatus] =
+ useState(null)
+ const [showIdeOnboarding, setShowIdeOnboarding] = useState(false)
// Dead code elimination: model switch callout state (ant-only)
const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
- if ((process.env.USER_TYPE) === 'ant') {
- return shouldShowAntModelSwitch();
+ if (process.env.USER_TYPE === 'ant') {
+ return shouldShowAntModelSwitch()
}
- return false;
- });
- const [showEffortCallout, setShowEffortCallout] = useState(() => shouldShowEffortCallout(mainLoopModel));
- const showRemoteCallout = useAppState(s => s.showRemoteCallout);
- const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => shouldShowDesktopUpsellStartup());
+ return false
+ })
+ const [showEffortCallout, setShowEffortCallout] = useState(() =>
+ shouldShowEffortCallout(mainLoopModel),
+ )
+ const showRemoteCallout = useAppState(s => s.showRemoteCallout)
+ const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() =>
+ shouldShowDesktopUpsellStartup(),
+ )
// notifications
- useModelMigrationNotifications();
- useCanSwitchToExistingSubscription();
- useIDEStatusIndicator({
- ideSelection,
- mcpClients,
- ideInstallationStatus
- });
- useMcpConnectivityStatus({
- mcpClients
- });
- useAutoModeUnavailableNotification();
- usePluginInstallationStatus();
- usePluginAutoupdateNotification();
- useSettingsErrors();
- useRateLimitWarningNotification(mainLoopModel);
- useFastModeNotification();
- useDeprecationWarningNotification(mainLoopModel);
- useNpmDeprecationNotification();
- useAntOrgWarningNotification();
- useInstallMessages();
- useChromeExtensionNotification();
- useOfficialMarketplaceNotification();
- useLspInitializationNotification();
- useTeammateLifecycleNotification();
+ useModelMigrationNotifications()
+ useCanSwitchToExistingSubscription()
+ useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus })
+ useMcpConnectivityStatus({ mcpClients })
+ useAutoModeUnavailableNotification()
+ usePluginInstallationStatus()
+ usePluginAutoupdateNotification()
+ useSettingsErrors()
+ useRateLimitWarningNotification(mainLoopModel)
+ useFastModeNotification()
+ useDeprecationWarningNotification(mainLoopModel)
+ useNpmDeprecationNotification()
+ useAntOrgWarningNotification()
+ useInstallMessages()
+ useChromeExtensionNotification()
+ useOfficialMarketplaceNotification()
+ useLspInitializationNotification()
+ useTeammateLifecycleNotification()
const {
recommendation: lspRecommendation,
- handleResponse: handleLspResponse
- } = useLspPluginRecommendation();
+ handleResponse: handleLspResponse,
+ } = useLspPluginRecommendation()
const {
recommendation: hintRecommendation,
- handleResponse: handleHintResponse
- } = useClaudeCodeHintRecommendation();
+ handleResponse: handleHintResponse,
+ } = useClaudeCodeHintRecommendation()
// Memoize the combined initial tools array to prevent reference changes
const combinedInitialTools = useMemo(() => {
- return [...localTools, ...initialTools];
- }, [localTools, initialTools]);
+ return [...localTools, ...initialTools]
+ }, [localTools, initialTools])
// Initialize plugin management
- useManagePlugins({
- enabled: !isRemoteSession
- });
- const tasksV2 = useTasksV2WithCollapseEffect();
+ useManagePlugins({ enabled: !isRemoteSession })
+
+ const tasksV2 = useTasksV2WithCollapseEffect()
// Start background plugin installations
@@ -797,47 +1177,71 @@ export function REPL({
// This ensures that plugin installations from repository and user settings only
// happen after explicit user consent to trust the current working directory.
useEffect(() => {
- if (isRemoteSession) return;
- void performStartupChecks(setAppState);
- }, [setAppState, isRemoteSession]);
+ if (isRemoteSession) return
+ void performStartupChecks(setAppState)
+ }, [setAppState, isRemoteSession])
// Allow Claude in Chrome MCP to send prompts through MCP notifications
// and sync permission mode changes to the Chrome extension
- usePromptsFromClaudeInChrome(isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, toolPermissionContext.mode);
+ usePromptsFromClaudeInChrome(
+ isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients,
+ toolPermissionContext.mode,
+ )
// Initialize swarm features: teammate hooks and context
// Handles both fresh spawns and resumed teammate sessions
useSwarmInitialization(setAppState, initialMessages, {
- enabled: !isRemoteSession
- });
- const mergedTools = useMergedTools(combinedInitialTools, mcp.tools, toolPermissionContext);
+ enabled: !isRemoteSession,
+ })
+
+ const mergedTools = useMergedTools(
+ combinedInitialTools,
+ mcp.tools,
+ toolPermissionContext,
+ )
// Apply agent tool restrictions if mainThreadAgentDefinition is set
- const {
- tools,
- allowedAgentTypes
- } = useMemo(() => {
+ const { tools, allowedAgentTypes } = useMemo(() => {
if (!mainThreadAgentDefinition) {
return {
tools: mergedTools,
- allowedAgentTypes: undefined as string[] | undefined
- };
+ allowedAgentTypes: undefined as string[] | undefined,
+ }
}
- const resolved = resolveAgentTools(mainThreadAgentDefinition, mergedTools, false, true);
+ const resolved = resolveAgentTools(
+ mainThreadAgentDefinition,
+ mergedTools,
+ false,
+ true,
+ )
return {
tools: resolved.resolvedTools,
- allowedAgentTypes: resolved.allowedAgentTypes
- };
- }, [mainThreadAgentDefinition, mergedTools]);
+ allowedAgentTypes: resolved.allowedAgentTypes,
+ }
+ }, [mainThreadAgentDefinition, mergedTools])
// Merge commands from local state, plugins, and MCP
- const commandsWithPlugins = useMergedCommands(localCommands, plugins.commands as Command[]);
- const mergedCommands = useMergedCommands(commandsWithPlugins, mcp.commands as Command[]);
+ const commandsWithPlugins = useMergedCommands(
+ localCommands,
+ plugins.commands as Command[],
+ )
+ const mergedCommands = useMergedCommands(
+ commandsWithPlugins,
+ mcp.commands as Command[],
+ )
// Filter out all commands if disableSlashCommands is true
- const commands = useMemo(() => disableSlashCommands ? [] : mergedCommands, [disableSlashCommands, mergedCommands]);
- useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients);
- useIdeSelection(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection);
- const [streamMode, setStreamMode] = useState('responding');
+ const commands = useMemo(
+ () => (disableSlashCommands ? [] : mergedCommands),
+ [disableSlashCommands, mergedCommands],
+ )
+
+ useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients)
+ useIdeSelection(
+ isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients,
+ setIDESelection,
+ )
+
+ const [streamMode, setStreamMode] = useState('responding')
// Ref mirror so onSubmit can read the latest value without adding
// streamMode to its deps. streamMode flips between
// requesting/responding/tool-use ~10x per turn during streaming; having it
@@ -846,99 +1250,115 @@ export function REPL({
// invalidation. The only consumers inside callbacks are debug logging and
// telemetry (handlePromptSubmit.ts), so a stale-by-one-render value is
// harmless — but ref mirrors sync on every render anyway so it's fresh.
- const streamModeRef = useRef(streamMode);
- streamModeRef.current = streamMode;
- const [streamingToolUses, setStreamingToolUses] = useState([]);
- const [streamingThinking, setStreamingThinking] = useState(null);
+ const streamModeRef = useRef(streamMode)
+ streamModeRef.current = streamMode
+ const [streamingToolUses, setStreamingToolUses] = useState<
+ StreamingToolUse[]
+ >([])
+ const [streamingThinking, setStreamingThinking] =
+ useState(null)
// Auto-hide streaming thinking after 30 seconds of being completed
useEffect(() => {
- if (streamingThinking && !streamingThinking.isStreaming && streamingThinking.streamingEndedAt) {
- const elapsed = Date.now() - streamingThinking.streamingEndedAt;
- const remaining = 30000 - elapsed;
+ if (
+ streamingThinking &&
+ !streamingThinking.isStreaming &&
+ streamingThinking.streamingEndedAt
+ ) {
+ const elapsed = Date.now() - streamingThinking.streamingEndedAt
+ const remaining = 30000 - elapsed
if (remaining > 0) {
- const timer = setTimeout(setStreamingThinking, remaining, null);
- return () => clearTimeout(timer);
+ const timer = setTimeout(setStreamingThinking, remaining, null)
+ return () => clearTimeout(timer)
} else {
- setStreamingThinking(null);
+ setStreamingThinking(null)
}
}
- }, [streamingThinking]);
- const [abortController, setAbortController] = useState(null);
+ }, [streamingThinking])
+
+ const [abortController, setAbortController] =
+ useState(null)
// Ref that always points to the current abort controller, used by the
// REPL bridge to abort the active query when a remote interrupt arrives.
- const abortControllerRef = useRef(null);
- abortControllerRef.current = abortController;
+ const abortControllerRef = useRef(null)
+ abortControllerRef.current = abortController
// Ref for the bridge result callback — set after useReplBridge initializes,
// read in the onQuery finally block to notify mobile clients that a turn ended.
- const sendBridgeResultRef = useRef<() => void>(() => {});
+ const sendBridgeResultRef = useRef<() => void>(() => {})
// Ref for the synchronous restore callback — set after restoreMessageSync is
// defined, read in the onQuery finally block for auto-restore on interrupt.
- const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {});
+ const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {})
// Ref to the fullscreen layout's scroll box for keyboard scrolling.
// Null when fullscreen mode is disabled (ref never attached).
- const scrollRef = useRef(null);
+ const scrollRef = useRef(null)
// Separate ref for the modal slot's inner ScrollBox — passed through
// FullscreenLayout → ModalContext so Tabs can attach it to its own
// ScrollBox for tall content (e.g. /status's MCP-server list). NOT
// keyboard-driven — ScrollKeybindingHandler stays on the outer ref so
// PgUp/PgDn/wheel always scroll the transcript behind the modal.
// Plumbing kept for future modal-scroll wiring.
- const modalScrollRef = useRef(null);
+ const modalScrollRef = useRef(null)
// Timestamp of the last user-initiated scroll (wheel, PgUp/PgDn, ctrl+u,
// End/Home, G, drag-to-scroll). Stamped in composedOnScroll — the single
// chokepoint ScrollKeybindingHandler calls for every user scroll action.
// Programmatic scrolls (repinScroll's scrollToBottom, sticky auto-follow)
// do NOT go through composedOnScroll, so they don't stamp this. Ref not
// state: no re-render on every wheel tick.
- const lastUserScrollTsRef = useRef(0);
+ const lastUserScrollTsRef = useRef(0)
// Synchronous state machine for the query lifecycle. Replaces the
// error-prone dual-state pattern where isLoading (React state, async
// batched) and isQueryRunning (ref, sync) could desync. See QueryGuard.ts.
- const queryGuard = React.useRef(new QueryGuard()).current;
+ const queryGuard = React.useRef(new QueryGuard()).current
// Subscribe to the guard — true during dispatching or running.
// This is the single source of truth for "is a local query in flight".
- const isQueryActive = React.useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot);
+ const isQueryActive = React.useSyncExternalStore(
+ queryGuard.subscribe,
+ queryGuard.getSnapshot,
+ )
// Separate loading flag for operations outside the local query guard:
// remote sessions (useRemoteSession / useDirectConnect) and foregrounded
// background tasks (useSessionBackgrounding). These don't route through
// onQuery / queryGuard, so they need their own spinner-visibility state.
// Initialize true if remote mode with initial prompt (CCR processing it).
- const [isExternalLoading, setIsExternalLoadingRaw] = React.useState(remoteSessionConfig?.hasInitialPrompt ?? false);
+ const [isExternalLoading, setIsExternalLoadingRaw] = React.useState(
+ remoteSessionConfig?.hasInitialPrompt ?? false,
+ )
// Derived: any loading source active. Read-only — no setter. Local query
// loading is driven by queryGuard (reserve/tryStart/end/cancelReservation),
// external loading by setIsExternalLoading.
- const isLoading = isQueryActive || isExternalLoading;
+ const isLoading = isQueryActive || isExternalLoading
// Elapsed time is computed by SpinnerWithVerb from these refs on each
// animation frame, avoiding a useInterval that re-renders the entire REPL.
- const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState(undefined);
+ const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState<
+ string | undefined
+ >(undefined)
// messagesRef.current.length at the moment userInputOnProcessing was set.
// The placeholder hides once displayedMessages grows past this — i.e. the
// real user message has landed in the visible transcript.
- const userInputBaselineRef = React.useRef(0);
+ const userInputBaselineRef = React.useRef(0)
// True while the submitted prompt is being processed but its user message
// hasn't reached setMessages yet. setMessages uses this to keep the
// baseline in sync when unrelated async messages (bridge status, hook
// results, scheduled tasks) land during that window.
- const userMessagePendingRef = React.useRef(false);
+ const userMessagePendingRef = React.useRef(false)
// Wall-clock time tracking refs for accurate elapsed time calculation
- const loadingStartTimeRef = React.useRef(0);
- const totalPausedMsRef = React.useRef(0);
- const pauseStartTimeRef = React.useRef(null);
+ const loadingStartTimeRef = React.useRef(0)
+ const totalPausedMsRef = React.useRef(0)
+ const pauseStartTimeRef = React.useRef(null)
const resetTimingRefs = React.useCallback(() => {
- loadingStartTimeRef.current = Date.now();
- totalPausedMsRef.current = 0;
- pauseStartTimeRef.current = null;
- }, []);
+ loadingStartTimeRef.current = Date.now()
+ totalPausedMsRef.current = 0
+ pauseStartTimeRef.current = null
+ }, [])
// Reset timing refs inline when isQueryActive transitions false→true.
// queryGuard.reserve() (in executeUserInput) fires BEFORE processUserInput's
@@ -948,52 +1368,57 @@ export function REPL({
// first render where isQueryActive is observed true — the same render that
// first shows the spinner — so the ref is correct by the time the spinner
// reads it. See INC-4549.
- const wasQueryActiveRef = React.useRef(false);
+ const wasQueryActiveRef = React.useRef(false)
if (isQueryActive && !wasQueryActiveRef.current) {
- resetTimingRefs();
+ resetTimingRefs()
}
- wasQueryActiveRef.current = isQueryActive;
+ wasQueryActiveRef.current = isQueryActive
// Wrapper for setIsExternalLoading that resets timing refs on transition
// to true — SpinnerWithVerb reads these for elapsed time, so they must be
// reset for remote sessions / foregrounded tasks too (not just local
// queries, which reset them in onQuery). Without this, a remote-only
// session would show ~56 years elapsed (Date.now() - 0).
- const setIsExternalLoading = React.useCallback((value: boolean) => {
- setIsExternalLoadingRaw(value);
- if (value) resetTimingRefs();
- }, [resetTimingRefs]);
+ const setIsExternalLoading = React.useCallback(
+ (value: boolean) => {
+ setIsExternalLoadingRaw(value)
+ if (value) resetTimingRefs()
+ },
+ [resetTimingRefs],
+ )
// Start time of the first turn that had swarm teammates running
// Used to compute total elapsed time (including teammate execution) for the deferred message
- const swarmStartTimeRef = React.useRef(null);
- const swarmBudgetInfoRef = React.useRef<{
- tokens: number;
- limit: number;
- nudges: number;
- } | undefined>(undefined);
+ const swarmStartTimeRef = React.useRef(null)
+ const swarmBudgetInfoRef = React.useRef<
+ { tokens: number; limit: number; nudges: number } | undefined
+ >(undefined)
// Ref to track current focusedInputDialog for use in callbacks
// This avoids stale closures when checking dialog state in timer callbacks
- const focusedInputDialogRef = React.useRef>(undefined);
+ const focusedInputDialogRef =
+ React.useRef>(undefined)
// How long after the last keystroke before deferred dialogs are shown
- const PROMPT_SUPPRESSION_MS = 1500;
+ const PROMPT_SUPPRESSION_MS = 1500
// True when user is actively typing — defers interrupt dialogs so keystrokes
// don't accidentally dismiss or answer a permission prompt the user hasn't read yet.
- const [isPromptInputActive, setIsPromptInputActive] = React.useState(false);
- const [autoUpdaterResult, setAutoUpdaterResult] = useState(null);
+ const [isPromptInputActive, setIsPromptInputActive] = React.useState(false)
+
+ const [autoUpdaterResult, setAutoUpdaterResult] =
+ useState(null)
+
useEffect(() => {
if (autoUpdaterResult?.notifications) {
autoUpdaterResult.notifications.forEach(notification => {
addNotification({
key: 'auto-updater-notification',
text: notification,
- priority: 'low'
- });
- });
+ priority: 'low',
+ })
+ })
}
- }, [autoUpdaterResult, addNotification]);
+ }, [autoUpdaterResult, addNotification])
// tmux + fullscreen + `mouse off`: one-time hint that wheel won't scroll.
// We no longer mutate tmux's session-scoped mouse option (it poisoned
@@ -1005,50 +1430,52 @@ export function REPL({
addNotification({
key: 'tmux-mouse-hint',
text: hint,
- priority: 'low'
- });
+ priority: 'low',
+ })
}
- });
+ })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
- const [showUndercoverCallout, setShowUndercoverCallout] = useState(false);
+ }, [])
+
+ const [showUndercoverCallout, setShowUndercoverCallout] = useState(false)
useEffect(() => {
- if ((process.env.USER_TYPE) === 'ant') {
+ if (process.env.USER_TYPE === 'ant') {
void (async () => {
// Wait for repo classification to settle (memoized, no-op if primed).
- const {
- isInternalModelRepo
- } = await import('../utils/commitAttribution.js');
- await isInternalModelRepo();
- const {
- shouldShowUndercoverAutoNotice
- } = await import('../utils/undercover.js');
+ const { isInternalModelRepo } = await import(
+ '../utils/commitAttribution.js'
+ )
+ await isInternalModelRepo()
+ const { shouldShowUndercoverAutoNotice } = await import(
+ '../utils/undercover.js'
+ )
if (shouldShowUndercoverAutoNotice()) {
- setShowUndercoverCallout(true);
+ setShowUndercoverCallout(true)
}
- })();
+ })()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [])
+
const [toolJSX, setToolJSXInternal] = useState<{
- jsx: React.ReactNode | null;
- shouldHidePromptInput: boolean;
- shouldContinueAnimation?: true;
- showSpinner?: boolean;
- isLocalJSXCommand?: boolean;
- isImmediate?: boolean;
- } | null>(null);
+ jsx: React.ReactNode | null
+ shouldHidePromptInput: boolean
+ shouldContinueAnimation?: true
+ showSpinner?: boolean
+ isLocalJSXCommand?: boolean
+ isImmediate?: boolean
+ } | null>(null)
// Track local JSX commands separately so tools can't overwrite them.
// This enables "immediate" commands (like /btw) to persist while Claude is processing.
const localJSXCommandRef = useRef<{
- jsx: React.ReactNode | null;
- shouldHidePromptInput: boolean;
- shouldContinueAnimation?: true;
- showSpinner?: boolean;
- isLocalJSXCommand: true;
- } | null>(null);
+ jsx: React.ReactNode | null
+ shouldHidePromptInput: boolean
+ shouldContinueAnimation?: true
+ showSpinner?: boolean
+ isLocalJSXCommand: true
+ } | null>(null)
// Wrapper for setToolJSX that preserves local JSX commands (like /btw).
// When a local JSX command is active, we ignore updates from tools
@@ -1059,89 +1486,108 @@ export function REPL({
// 2. Set `isLocalJSXCommand: true` when calling setToolJSX in the command's JSX
// 3. In the onDone callback, use `setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true })`
// to explicitly clear the overlay when the user dismisses it
- const setToolJSX = useCallback((args: {
- jsx: React.ReactNode | null;
- shouldHidePromptInput: boolean;
- shouldContinueAnimation?: true;
- showSpinner?: boolean;
- isLocalJSXCommand?: boolean;
- clearLocalJSX?: boolean;
- } | null) => {
- // If setting a local JSX command, store it in the ref
- if (args?.isLocalJSXCommand) {
- const {
- clearLocalJSX: _,
- ...rest
- } = args;
- localJSXCommandRef.current = {
- ...rest,
- isLocalJSXCommand: true
- };
- setToolJSXInternal(rest);
- return;
- }
-
- // If there's an active local JSX command in the ref
- if (localJSXCommandRef.current) {
- // Allow clearing only if explicitly requested (from onDone callbacks)
- if (args?.clearLocalJSX) {
- localJSXCommandRef.current = null;
- setToolJSXInternal(null);
- return;
+ const setToolJSX = useCallback(
+ (
+ args: {
+ jsx: React.ReactNode | null
+ shouldHidePromptInput: boolean
+ shouldContinueAnimation?: true
+ showSpinner?: boolean
+ isLocalJSXCommand?: boolean
+ clearLocalJSX?: boolean
+ } | null,
+ ) => {
+ // If setting a local JSX command, store it in the ref
+ if (args?.isLocalJSXCommand) {
+ const { clearLocalJSX: _, ...rest } = args
+ localJSXCommandRef.current = { ...rest, isLocalJSXCommand: true }
+ setToolJSXInternal(rest)
+ return
}
- // Otherwise, keep the local JSX command visible - ignore tool updates
- return;
- }
- // No active local JSX command, allow any update
- if (args?.clearLocalJSX) {
- setToolJSXInternal(null);
- return;
- }
- setToolJSXInternal(args);
- }, []);
- const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState([]);
+ // If there's an active local JSX command in the ref
+ if (localJSXCommandRef.current) {
+ // Allow clearing only if explicitly requested (from onDone callbacks)
+ if (args?.clearLocalJSX) {
+ localJSXCommandRef.current = null
+ setToolJSXInternal(null)
+ return
+ }
+ // Otherwise, keep the local JSX command visible - ignore tool updates
+ return
+ }
+
+ // No active local JSX command, allow any update
+ if (args?.clearLocalJSX) {
+ setToolJSXInternal(null)
+ return
+ }
+ setToolJSXInternal(args)
+ },
+ [],
+ )
+ const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState<
+ ToolUseConfirm[]
+ >([])
// Sticky footer JSX registered by permission request components (currently
// only ExitPlanModePermissionRequest). Renders in FullscreenLayout's `bottom`
// slot so response options stay visible while the user scrolls a long plan.
- const [permissionStickyFooter, setPermissionStickyFooter] = useState(null);
- const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] = useState void;
- }>>([]);
- const [promptQueue, setPromptQueue] = useState void;
- reject: (error: Error) => void;
- }>>([]);
+ const [permissionStickyFooter, setPermissionStickyFooter] =
+ useState(null)
+ const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] =
+ useState<
+ Array<{
+ hostPattern: NetworkHostPattern
+ resolvePromise: (allowConnection: boolean) => void
+ }>
+ >([])
+ const [promptQueue, setPromptQueue] = useState<
+ Array<{
+ request: PromptRequest
+ title: string
+ toolInputSummary?: string | null
+ resolve: (response: PromptResponse) => void
+ reject: (error: Error) => void
+ }>
+ >([])
// Track bridge cleanup functions for sandbox permission requests so the
// local dialog handler can cancel the remote prompt when the local user
// responds first. Keyed by host to support concurrent same-host requests.
- const sandboxBridgeCleanupRef = useRef void>>>(new Map());
+ const sandboxBridgeCleanupRef = useRef void>>>(
+ new Map(),
+ )
// -- Terminal title management
// Session title (set via /rename or restored on resume) wins over
// the agent name, which wins over the Haiku-extracted topic;
// all fall back to the product name.
- const terminalTitleFromRename = useAppState(s => s.settings.terminalTitleFromRename) !== false;
- const sessionTitle = terminalTitleFromRename ? getCurrentSessionTitle(getSessionId()) : undefined;
- const [haikuTitle, setHaikuTitle] = useState();
+ const terminalTitleFromRename =
+ useAppState(s => s.settings.terminalTitleFromRename) !== false
+ const sessionTitle = terminalTitleFromRename
+ ? getCurrentSessionTitle(getSessionId())
+ : undefined
+ const [haikuTitle, setHaikuTitle] = useState()
// Gates the one-shot Haiku call that generates the tab title. Seeded true
// on resume (initialMessages present) so we don't re-title a resumed
// session from mid-conversation context.
- const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0);
- const agentTitle = mainThreadAgentDefinition?.agentType;
- const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code';
- const isWaitingForApproval = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || pendingWorkerRequest || pendingSandboxRequest;
+ const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0)
+ const agentTitle = mainThreadAgentDefinition?.agentType
+ const terminalTitle =
+ sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code'
+ const isWaitingForApproval =
+ toolUseConfirmQueue.length > 0 ||
+ promptQueue.length > 0 ||
+ pendingWorkerRequest ||
+ pendingSandboxRequest
// Local-jsx commands (like /plugin, /config) show user-facing dialogs that
// wait for input. Require jsx != null — if the flag is stuck true but jsx
// is null, treat as not-showing so TextInput focus and queue processor
// aren't deadlocked by a phantom overlay.
- const isShowingLocalJSXCommand = toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null;
- const titleIsAnimating = isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand;
+ const isShowingLocalJSXCommand =
+ toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null
+ const titleIsAnimating =
+ isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand
// Title animation state lives in so the 960ms tick
// doesn't re-render REPL. titleDisabled/terminalTitle are still computed
// here because onQueryImpl reads them (background session description,
@@ -1150,44 +1596,66 @@ export function REPL({
// Prevent macOS from sleeping while Claude is working
useEffect(() => {
if (isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand) {
- startPreventSleep();
- return () => stopPreventSleep();
+ startPreventSleep()
+ return () => stopPreventSleep()
}
- }, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand]);
- const sessionStatus: TabStatusKind = isWaitingForApproval || isShowingLocalJSXCommand ? 'waiting' : isLoading ? 'busy' : 'idle';
- const waitingFor = sessionStatus !== 'waiting' ? undefined : toolUseConfirmQueue.length > 0 ? `approve ${toolUseConfirmQueue[0]!.tool.name}` : pendingWorkerRequest ? 'worker request' : pendingSandboxRequest ? 'sandbox request' : isShowingLocalJSXCommand ? 'dialog open' : 'input needed';
+ }, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand])
+
+ const sessionStatus: TabStatusKind =
+ isWaitingForApproval || isShowingLocalJSXCommand
+ ? 'waiting'
+ : isLoading
+ ? 'busy'
+ : 'idle'
+
+ const waitingFor =
+ sessionStatus !== 'waiting'
+ ? undefined
+ : toolUseConfirmQueue.length > 0
+ ? `approve ${toolUseConfirmQueue[0]!.tool.name}`
+ : pendingWorkerRequest
+ ? 'worker request'
+ : pendingSandboxRequest
+ ? 'sandbox request'
+ : isShowingLocalJSXCommand
+ ? 'dialog open'
+ : 'input needed'
// Push status to the PID file for `claude ps`. Fire-and-forget; ps falls
// back to transcript-tail derivation when this is missing/stale.
useEffect(() => {
if (feature('BG_SESSIONS')) {
- void updateSessionActivity({
- status: sessionStatus,
- waitingFor
- });
+ void updateSessionActivity({ status: sessionStatus, waitingFor })
}
- }, [sessionStatus, waitingFor]);
+ }, [sessionStatus, waitingFor])
// 3P default: off — OSC 21337 is ant-only while the spec stabilizes.
// Gated so we can roll back if the sidebar indicator conflicts with
// the title spinner in terminals that render both. When the flag is
// on, the user-facing config setting controls whether it's active.
- const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false);
- const showStatusInTerminalTab = tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false);
- useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus);
+ const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
+ 'tengu_terminal_sidebar',
+ false,
+ )
+ const showStatusInTerminalTab =
+ tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false)
+ useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus)
// Register the leader's setToolUseConfirmQueue for in-process teammates
useEffect(() => {
- registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue);
- return () => unregisterLeaderToolUseConfirmQueue();
- }, [setToolUseConfirmQueue]);
- const [messages, rawSetMessages] = useState(initialMessages ?? []);
- const messagesRef = useRef(messages);
+ registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue)
+ return () => unregisterLeaderToolUseConfirmQueue()
+ }, [setToolUseConfirmQueue])
+
+ const [messages, rawSetMessages] = useState(
+ initialMessages ?? [],
+ )
+ const messagesRef = useRef(messages)
// Stores the willowMode variant that was shown (or false if no hint shown).
// Captured at hint_shown time so hint_converted telemetry reports the same
// variant — the GrowthBook value shouldn't change mid-session, but reading
// it once guarantees consistency between the paired events.
- const idleHintShownRef = useRef(false);
+ const idleHintShownRef = useRef(false)
// Wrap setMessages so messagesRef is always current the instant the
// call returns — not when React later processes the batch. Apply the
// updater eagerly against the ref, then hand React the computed value
@@ -1197,42 +1665,49 @@ export function REPL({
// truth, React state is the render projection. Without this, paths
// that queue functional updaters then synchronously read the ref
// (e.g. handleSpeculationAccept → onQuery) see stale data.
- const setMessages = useCallback((action: React.SetStateAction) => {
- const prev = messagesRef.current;
- const next = typeof action === 'function' ? action(messagesRef.current) : action;
- messagesRef.current = next;
- if (next.length < userInputBaselineRef.current) {
- // Shrank (compact/rewind/clear) — clamp so placeholderText's length
- // check can't go stale.
- userInputBaselineRef.current = 0;
- } else if (next.length > prev.length && userMessagePendingRef.current) {
- // Grew while the submitted user message hasn't landed yet. If the
- // added messages don't include it (bridge status, hook results,
- // scheduled tasks landing async during processUserInputBase), bump
- // baseline so the placeholder stays visible. Once the user message
- // lands, stop tracking — later additions (assistant stream) should
- // not re-show the placeholder.
- const delta = next.length - prev.length;
- const added = prev.length === 0 || next[0] === prev[0] ? next.slice(-delta) : next.slice(0, delta);
- if (added.some(isHumanTurn)) {
- userMessagePendingRef.current = false;
- } else {
- userInputBaselineRef.current = next.length;
+ const setMessages = useCallback(
+ (action: React.SetStateAction) => {
+ const prev = messagesRef.current
+ const next =
+ typeof action === 'function' ? action(messagesRef.current) : action
+ messagesRef.current = next
+ if (next.length < userInputBaselineRef.current) {
+ // Shrank (compact/rewind/clear) — clamp so placeholderText's length
+ // check can't go stale.
+ userInputBaselineRef.current = 0
+ } else if (next.length > prev.length && userMessagePendingRef.current) {
+ // Grew while the submitted user message hasn't landed yet. If the
+ // added messages don't include it (bridge status, hook results,
+ // scheduled tasks landing async during processUserInputBase), bump
+ // baseline so the placeholder stays visible. Once the user message
+ // lands, stop tracking — later additions (assistant stream) should
+ // not re-show the placeholder.
+ const delta = next.length - prev.length
+ const added =
+ prev.length === 0 || next[0] === prev[0]
+ ? next.slice(-delta)
+ : next.slice(0, delta)
+ if (added.some(isHumanTurn)) {
+ userMessagePendingRef.current = false
+ } else {
+ userInputBaselineRef.current = next.length
+ }
}
- }
- rawSetMessages(next);
- }, []);
+ rawSetMessages(next)
+ },
+ [],
+ )
// Capture the baseline message count alongside the placeholder text so
// the render can hide it once displayedMessages grows past the baseline.
const setUserInputOnProcessing = useCallback((input: string | undefined) => {
if (input !== undefined) {
- userInputBaselineRef.current = messagesRef.current.length;
- userMessagePendingRef.current = true;
+ userInputBaselineRef.current = messagesRef.current.length
+ userMessagePendingRef.current = true
} else {
- userMessagePendingRef.current = false;
+ userMessagePendingRef.current = false
}
- setUserInputOnProcessingRaw(input);
- }, []);
+ setUserInputOnProcessingRaw(input)
+ }, [])
// Fullscreen: track the unseen-divider position. dividerIndex changes
// only ~twice/scroll-session (first scroll-away + repin). pillVisible
// and stickyPrompt now live in FullscreenLayout — they subscribe to
@@ -1243,149 +1718,186 @@ export function REPL({
onScrollAway,
onRepin,
jumpToNew,
- shiftDivider
- } = useUnseenDivider(messages.length);
+ shiftDivider,
+ } = useUnseenDivider(messages.length)
if (feature('AWAY_SUMMARY')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
- useAwaySummary(messages, setMessages, isLoading);
+ useAwaySummary(messages, setMessages, isLoading)
}
- const [cursor, setCursor] = useState(null);
- const cursorNavRef = useRef(null);
+ const [cursor, setCursor] = useState(null)
+ const cursorNavRef = useRef(null)
// Memoized so Messages' React.memo holds.
- const unseenDivider = useMemo(() => computeUnseenDivider(messages, dividerIndex),
- // eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind
- [dividerIndex, messages.length]);
+ const unseenDivider = useMemo(
+ () => computeUnseenDivider(messages, dividerIndex),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind
+ [dividerIndex, messages.length],
+ )
// Re-pin scroll to bottom and clear the unseen-messages baseline. Called
// on any user-driven return-to-live action (submit, type-into-empty,
// overlay appear/dismiss).
const repinScroll = useCallback(() => {
- scrollRef.current?.scrollToBottom();
- onRepin();
- setCursor(null);
- }, [onRepin, setCursor]);
+ scrollRef.current?.scrollToBottom()
+ onRepin()
+ setCursor(null)
+ }, [onRepin, setCursor])
// Backstop for the submit-handler repin at onSubmit. If a buffered stdin
// event (wheel/drag) races between handler-fire and state-commit, the
// handler's scrollToBottom can be undone. This effect fires on the render
// where the user's message actually lands — tied to React's commit cycle,
// so it can't race with stdin. Keyed on lastMsg identity (not messages.length)
// so useAssistantHistory's prepends don't spuriously repin.
- const lastMsg = messages.at(-1);
- const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg);
+ const lastMsg = messages.at(-1)
+ const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg)
useEffect(() => {
if (lastMsgIsHuman) {
- repinScroll();
+ repinScroll()
}
- }, [lastMsgIsHuman, lastMsg, repinScroll]);
+ }, [lastMsgIsHuman, lastMsg, repinScroll])
// Assistant-chat: lazy-load remote history on scroll-up. No-op unless
// KAIROS build + config.viewerOnly. feature() is build-time constant so
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
// as useUnseenDivider above).
- const {
- maybeLoadOlder
- } = feature('KAIROS') ?
- // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
- useAssistantHistory({
- config: remoteSessionConfig,
- setMessages,
- scrollRef,
- onPrepend: shiftDivider
- }) : HISTORY_STUB;
+ const { maybeLoadOlder } = feature('KAIROS')
+ ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
+ useAssistantHistory({
+ config: remoteSessionConfig,
+ setMessages,
+ scrollRef,
+ onPrepend: shiftDivider,
+ })
+ : HISTORY_STUB
// Compose useUnseenDivider's callbacks with the lazy-load trigger.
- const composedOnScroll = useCallback((sticky: boolean, handle: ScrollBoxHandle) => {
- lastUserScrollTsRef.current = Date.now();
- if (sticky) {
- onRepin();
- } else {
- onScrollAway(handle);
- if (feature('KAIROS')) maybeLoadOlder(handle);
- // Dismiss the companion bubble on scroll — it's absolute-positioned
- // at bottom-right and covers transcript content. Scrolling = user is
- // trying to read something under it.
- if (feature('BUDDY')) {
- setAppState(prev => prev.companionReaction === undefined ? prev : {
- ...prev,
- companionReaction: undefined
- });
+ const composedOnScroll = useCallback(
+ (sticky: boolean, handle: ScrollBoxHandle) => {
+ lastUserScrollTsRef.current = Date.now()
+ if (sticky) {
+ onRepin()
+ } else {
+ onScrollAway(handle)
+ if (feature('KAIROS')) maybeLoadOlder(handle)
+ // Dismiss the companion bubble on scroll — it's absolute-positioned
+ // at bottom-right and covers transcript content. Scrolling = user is
+ // trying to read something under it.
+ if (feature('BUDDY')) {
+ setAppState(prev =>
+ prev.companionReaction === undefined
+ ? prev
+ : { ...prev, companionReaction: undefined },
+ )
+ }
}
- }
- }, [onRepin, onScrollAway, maybeLoadOlder, setAppState]);
+ },
+ [onRepin, onScrollAway, maybeLoadOlder, setAppState],
+ )
// Deferred SessionStart hook messages — REPL renders immediately and
// hook messages are injected when they resolve. awaitPendingHooks()
// must be called before the first API call so the model sees hook context.
- const awaitPendingHooks = useDeferredHookMessages(pendingHookMessages, setMessages);
+ const awaitPendingHooks = useDeferredHookMessages(
+ pendingHookMessages,
+ setMessages,
+ )
// Deferred messages for the Messages component — renders at transition
// priority so the reconciler yields every 5ms, keeping input responsive
// while the expensive message processing pipeline runs.
- const deferredMessages = useDeferredValue(messages);
- const deferredBehind = messages.length - deferredMessages.length;
+ const deferredMessages = useDeferredValue(messages)
+ const deferredBehind = messages.length - deferredMessages.length
if (deferredBehind > 0) {
- logForDebugging(`[useDeferredValue] Messages deferred by ${deferredBehind} (${deferredMessages.length}→${messages.length})`);
+ logForDebugging(
+ `[useDeferredValue] Messages deferred by ${deferredBehind} (${deferredMessages.length}→${messages.length})`,
+ )
}
// Frozen state for transcript mode - stores lengths instead of cloning arrays for memory efficiency
const [frozenTranscriptState, setFrozenTranscriptState] = useState<{
- messagesLength: number;
- streamingToolUsesLength: number;
- } | null>(null);
+ messagesLength: number
+ streamingToolUsesLength: number
+ } | null>(null)
// Initialize input with any early input that was captured before REPL was ready.
// Using lazy initialization ensures cursor offset is set correctly in PromptInput.
- const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput());
- const inputValueRef = useRef(inputValue);
- inputValueRef.current = inputValue;
+ const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput())
+ const inputValueRef = useRef(inputValue)
+ inputValueRef.current = inputValue
const insertTextRef = useRef<{
- insert: (text: string) => void;
- setInputWithCursor: (value: string, cursor: number) => void;
- cursorOffset: number;
- } | null>(null);
+ insert: (text: string) => void
+ setInputWithCursor: (value: string, cursor: number) => void
+ cursorOffset: number
+ } | null>(null)
// Wrap setInputValue to co-locate suppression state updates.
// Both setState calls happen in the same synchronous context so React
// batches them into a single render, eliminating the extra render that
// the previous useEffect → setState pattern caused.
- const setInputValue = useCallback((value: string) => {
- if (trySuggestBgPRIntercept(inputValueRef.current, value)) return;
- // In fullscreen mode, typing into an empty prompt re-pins scroll to
- // bottom. Only fires on empty→non-empty so scrolling up to reference
- // something while composing a message doesn't yank the view back on
- // every keystroke. Restores the pre-fullscreen muscle memory of
- // typing to snap back to the end of the conversation.
- // Skipped if the user scrolled within the last 3s — they're actively
- // reading, not lost. lastUserScrollTsRef starts at 0 so the first-
- // ever keypress (no scroll yet) always repins.
- if (inputValueRef.current === '' && value !== '' && Date.now() - lastUserScrollTsRef.current >= RECENT_SCROLL_REPIN_WINDOW_MS) {
- repinScroll();
- }
- // Sync ref immediately (like setMessages) so callers that read
- // inputValueRef before React commits — e.g. the auto-restore finally
- // block's `=== ''` guard — see the fresh value, not the stale render.
- inputValueRef.current = value;
- setInputValueRaw(value);
- setIsPromptInputActive(value.trim().length > 0);
- }, [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept]);
+ const setInputValue = useCallback(
+ (value: string) => {
+ if (trySuggestBgPRIntercept(inputValueRef.current, value)) return
+ // In fullscreen mode, typing into an empty prompt re-pins scroll to
+ // bottom. Only fires on empty→non-empty so scrolling up to reference
+ // something while composing a message doesn't yank the view back on
+ // every keystroke. Restores the pre-fullscreen muscle memory of
+ // typing to snap back to the end of the conversation.
+ // Skipped if the user scrolled within the last 3s — they're actively
+ // reading, not lost. lastUserScrollTsRef starts at 0 so the first-
+ // ever keypress (no scroll yet) always repins.
+ if (
+ inputValueRef.current === '' &&
+ value !== '' &&
+ Date.now() - lastUserScrollTsRef.current >=
+ RECENT_SCROLL_REPIN_WINDOW_MS
+ ) {
+ repinScroll()
+ }
+ // Sync ref immediately (like setMessages) so callers that read
+ // inputValueRef before React commits — e.g. the auto-restore finally
+ // block's `=== ''` guard — see the fresh value, not the stale render.
+ inputValueRef.current = value
+ setInputValueRaw(value)
+ setIsPromptInputActive(value.trim().length > 0)
+ },
+ [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept],
+ )
// Schedule a timeout to stop suppressing dialogs after the user stops typing.
// Only manages the timeout — the immediate activation is handled by setInputValue above.
useEffect(() => {
- if (inputValue.trim().length === 0) return;
- const timer = setTimeout(setIsPromptInputActive, PROMPT_SUPPRESSION_MS, false);
- return () => clearTimeout(timer);
- }, [inputValue]);
- const [inputMode, setInputMode] = useState('prompt');
- const [stashedPrompt, setStashedPrompt] = useState<{
- text: string;
- cursorOffset: number;
- pastedContents: Record;
- } | undefined>();
+ if (inputValue.trim().length === 0) return
+ const timer = setTimeout(
+ setIsPromptInputActive,
+ PROMPT_SUPPRESSION_MS,
+ false,
+ )
+ return () => clearTimeout(timer)
+ }, [inputValue])
+
+ const [inputMode, setInputMode] = useState('prompt')
+ const [stashedPrompt, setStashedPrompt] = useState<
+ | {
+ text: string
+ cursorOffset: number
+ pastedContents: Record
+ }
+ | undefined
+ >()
// Callback to filter commands based on CCR's available slash commands
- const handleRemoteInit = useCallback((remoteSlashCommands: string[]) => {
- const remoteCommandSet = new Set(remoteSlashCommands);
- // Keep commands that CCR lists OR that are in the local-safe set
- setLocalCommands(prev => prev.filter(cmd => remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd)));
- }, [setLocalCommands]);
- const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState>(new Set());
- const hasInterruptibleToolInProgressRef = useRef(false);
+ const handleRemoteInit = useCallback(
+ (remoteSlashCommands: string[]) => {
+ const remoteCommandSet = new Set(remoteSlashCommands)
+ // Keep commands that CCR lists OR that are in the local-safe set
+ setLocalCommands(prev =>
+ prev.filter(
+ cmd =>
+ remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd),
+ ),
+ )
+ },
+ [setLocalCommands],
+ )
+
+ const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState>(
+ new Set(),
+ )
+ const hasInterruptibleToolInProgressRef = useRef(false)
// Remote session hook - manages WebSocket connection and message handling for --remote mode
const remoteSession = useRemoteSession({
@@ -1397,8 +1909,8 @@ export function REPL({
tools: combinedInitialTools,
setStreamingToolUses,
setStreamMode,
- setInProgressToolUseIDs
- });
+ setInProgressToolUseIDs,
+ })
// Direct connect hook - manages WebSocket to a claude server for `claude connect` mode
const directConnect = useDirectConnect({
@@ -1406,8 +1918,8 @@ export function REPL({
setMessages,
setIsLoading: setIsExternalLoading,
setToolUseConfirmQueue,
- tools: combinedInitialTools
- });
+ tools: combinedInitialTools,
+ })
// SSH session hook - manages ssh child process for `claude ssh` mode.
// Same callback shape as useDirectConnect; only the transport under the
@@ -1417,79 +1929,101 @@ export function REPL({
setMessages,
setIsLoading: setIsExternalLoading,
setToolUseConfirmQueue,
- tools: combinedInitialTools
- });
+ tools: combinedInitialTools,
+ })
// Use whichever remote mode is active
- const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession;
- const [pastedContents, setPastedContents] = useState>({});
- const [submitCount, setSubmitCount] = useState(0);
+ const activeRemote = sshRemote.isRemoteMode
+ ? sshRemote
+ : directConnect.isRemoteMode
+ ? directConnect
+ : remoteSession
+
+ const [pastedContents, setPastedContents] = useState<
+ Record
+ >({})
+ const [submitCount, setSubmitCount] = useState(0)
// Ref instead of state to avoid triggering React re-renders on every
// streaming text_delta. The spinner reads this via its animation timer.
- const responseLengthRef = useRef(0);
+ const responseLengthRef = useRef(0)
// API performance metrics ref for ant-only spinner display (TTFT/OTPS).
// Accumulates metrics from all API requests in a turn for P50 aggregation.
- const apiMetricsRef = useRef>([]);
+ const apiMetricsRef = useRef<
+ Array<{
+ ttftMs: number
+ firstTokenTime: number
+ lastTokenTime: number
+ responseLengthBaseline: number
+ // Tracks responseLengthRef at the time of the last content addition.
+ // Updated by both streaming deltas and subagent message content.
+ // lastTokenTime is also updated at the same time, so the OTPS
+ // denominator correctly includes subagent processing time.
+ endResponseLength: number
+ }>
+ >([])
const setResponseLength = useCallback((f: (prev: number) => number) => {
- const prev = responseLengthRef.current;
- responseLengthRef.current = f(prev);
+ const prev = responseLengthRef.current
+ responseLengthRef.current = f(prev)
// When content is added (not a compaction reset), update the latest
// metrics entry so OTPS reflects all content generation activity.
// Updating lastTokenTime here ensures the denominator includes both
// streaming time AND subagent execution time, preventing inflation.
if (responseLengthRef.current > prev) {
- const entries = apiMetricsRef.current;
+ const entries = apiMetricsRef.current
if (entries.length > 0) {
- const lastEntry = entries.at(-1)!;
- lastEntry.lastTokenTime = Date.now();
- lastEntry.endResponseLength = responseLengthRef.current;
+ const lastEntry = entries.at(-1)!
+ lastEntry.lastTokenTime = Date.now()
+ lastEntry.endResponseLength = responseLengthRef.current
}
}
- }, []);
+ }, [])
// Streaming text display: set state directly per delta (Ink's 16ms render
// throttle batches rapid updates). Cleared on message arrival (messages.ts)
// so displayedMessages switches from deferredMessages to messages atomically.
- const [streamingText, setStreamingText] = useState(null);
- const reducedMotion = useAppState(s => s.settings.prefersReducedMotion) ?? false;
- const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug();
- const onStreamingText = useCallback((f: (current: string | null) => string | null) => {
- if (!showStreamingText) return;
- setStreamingText(f);
- }, [showStreamingText]);
+ const [streamingText, setStreamingText] = useState(null)
+ const reducedMotion =
+ useAppState(s => s.settings.prefersReducedMotion) ?? false
+ const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug()
+ const onStreamingText = useCallback(
+ (f: (current: string | null) => string | null) => {
+ if (!showStreamingText) return
+ setStreamingText(f)
+ },
+ [showStreamingText],
+ )
// Hide the in-progress source line so text streams line-by-line, not
// char-by-char. lastIndexOf returns -1 when no newline, giving '' → null.
// Guard on showStreamingText so toggling reducedMotion mid-stream
// immediately hides the streaming preview.
- const visibleStreamingText = streamingText && showStreamingText ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null : null;
- const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0);
- const [spinnerMessage, setSpinnerMessage] = useState(null);
- const [spinnerColor, setSpinnerColor] = useState(null);
- const [spinnerShimmerColor, setSpinnerShimmerColor] = useState(null);
- const [isMessageSelectorVisible, setIsMessageSelectorVisible] = useState(false);
- const [messageSelectorPreselect, setMessageSelectorPreselect] = useState(undefined);
- const [showCostDialog, setShowCostDialog] = useState(false);
- const [conversationId, setConversationId] = useState(randomUUID());
+ const visibleStreamingText =
+ streamingText && showStreamingText
+ ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null
+ : null
+
+ const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0)
+ const [spinnerMessage, setSpinnerMessage] = useState(null)
+ const [spinnerColor, setSpinnerColor] = useState(null)
+ const [spinnerShimmerColor, setSpinnerShimmerColor] = useState<
+ keyof Theme | null
+ >(null)
+ const [isMessageSelectorVisible, setIsMessageSelectorVisible] =
+ useState(false)
+ const [messageSelectorPreselect, setMessageSelectorPreselect] = useState<
+ UserMessage | undefined
+ >(undefined)
+ const [showCostDialog, setShowCostDialog] = useState(false)
+ const [conversationId, setConversationId] = useState(randomUUID())
// Idle-return dialog: shown when user submits after a long idle gap
const [idleReturnPending, setIdleReturnPending] = useState<{
- input: string;
- idleMinutes: number;
- } | null>(null);
- const skipIdleCheckRef = useRef(false);
- const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime);
- lastQueryCompletionTimeRef.current = lastQueryCompletionTime;
+ input: string
+ idleMinutes: number
+ } | null>(null)
+ const skipIdleCheckRef = useRef(false)
+ const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime)
+ lastQueryCompletionTimeRef.current = lastQueryCompletionTime
// Aggregate tool result budget: per-conversation decision tracking.
// When the GrowthBook flag is on, query.ts enforces the budget; when
@@ -1503,13 +2037,21 @@ export function REPL({
// For large resumed sessions, reconstruction does O(messages × blocks)
// work; we only want that once.
const [contentReplacementStateRef] = useState(() => ({
- current: provisionContentReplacementState(initialMessages, initialContentReplacements)
- }));
- const [haveShownCostDialog, setHaveShownCostDialog] = useState(getGlobalConfig().hasAcknowledgedCostThreshold);
- const [vimMode, setVimMode] = useState('INSERT');
- const [showBashesDialog, setShowBashesDialog] = useState(false);
- const [isSearchingHistory, setIsSearchingHistory] = useState(false);
- const [isHelpOpen, setIsHelpOpen] = useState(false);
+ current: provisionContentReplacementState(
+ initialMessages,
+ initialContentReplacements,
+ ),
+ }))
+
+ const [haveShownCostDialog, setHaveShownCostDialog] = useState(
+ getGlobalConfig().hasAcknowledgedCostThreshold,
+ )
+ const [vimMode, setVimMode] = useState('INSERT')
+ const [showBashesDialog, setShowBashesDialog] = useState(
+ false,
+ )
+ const [isSearchingHistory, setIsSearchingHistory] = useState(false)
+ const [isHelpOpen, setIsHelpOpen] = useState(false)
// showBashesDialog is REPL-level so it survives PromptInput unmounting.
// When ultraplan approval fires while the pill dialog is open, PromptInput
@@ -1518,51 +2060,48 @@ export function REPL({
// (the completed ultraplan task has been filtered out). Close it here.
useEffect(() => {
if (ultraplanPendingChoice && showBashesDialog) {
- setShowBashesDialog(false);
+ setShowBashesDialog(false)
}
- }, [ultraplanPendingChoice, showBashesDialog]);
- const isTerminalFocused = useTerminalFocus();
- const terminalFocusRef = useRef(isTerminalFocused);
- terminalFocusRef.current = isTerminalFocused;
- const [theme] = useTheme();
+ }, [ultraplanPendingChoice, showBashesDialog])
+
+ const isTerminalFocused = useTerminalFocus()
+ const terminalFocusRef = useRef(isTerminalFocused)
+ terminalFocusRef.current = isTerminalFocused
+
+ const [theme] = useTheme()
// resetLoadingState runs twice per turn (onQueryImpl tail + onQuery finally).
// Without this guard, both calls pick a tip → two recordShownTip → two
// saveGlobalConfig writes back-to-back. Reset at submit in onSubmit.
- const tipPickedThisTurnRef = React.useRef(false);
+ const tipPickedThisTurnRef = React.useRef(false)
const pickNewSpinnerTip = useCallback(() => {
- if (tipPickedThisTurnRef.current) return;
- tipPickedThisTurnRef.current = true;
- const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current);
+ if (tipPickedThisTurnRef.current) return
+ tipPickedThisTurnRef.current = true
+ const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current)
for (const tool of extractBashToolsFromMessages(newMessages)) {
- bashTools.current.add(tool);
+ bashTools.current.add(tool)
}
- bashToolsProcessedIdx.current = messagesRef.current.length;
+ bashToolsProcessedIdx.current = messagesRef.current.length
void getTipToShowOnSpinner({
theme,
readFileState: readFileState.current,
- bashTools: bashTools.current
+ bashTools: bashTools.current,
}).then(async tip => {
if (tip) {
- const content = await tip.content({
- theme
- });
+ const content = await tip.content({ theme })
setAppState(prev => ({
...prev,
- spinnerTip: content
- }));
- recordShownTip(tip);
+ spinnerTip: content,
+ }))
+ recordShownTip(tip)
} else {
setAppState(prev => {
- if (prev.spinnerTip === undefined) return prev;
- return {
- ...prev,
- spinnerTip: undefined
- };
- });
+ if (prev.spinnerTip === undefined) return prev
+ return { ...prev, spinnerTip: undefined }
+ })
}
- });
- }, [setAppState, theme]);
+ })
+ }, [setAppState, theme])
// Resets UI loading state. Does NOT call onTurnComplete - that should be
// called explicitly only when a query turn actually completes.
@@ -1571,159 +2110,226 @@ export function REPL({
// queryGuard.end() (onQuery finally) or cancelReservation() (executeUserInput
// finally) have already transitioned the guard to idle by the time this runs.
// External loading (remote/backgrounding) is reset separately by those hooks.
- setIsExternalLoading(false);
- setUserInputOnProcessing(undefined);
- responseLengthRef.current = 0;
- apiMetricsRef.current = [];
- setStreamingText(null);
- setStreamingToolUses([]);
- setSpinnerMessage(null);
- setSpinnerColor(null);
- setSpinnerShimmerColor(null);
- pickNewSpinnerTip();
- endInteractionSpan();
+ setIsExternalLoading(false)
+ setUserInputOnProcessing(undefined)
+ responseLengthRef.current = 0
+ apiMetricsRef.current = []
+ setStreamingText(null)
+ setStreamingToolUses([])
+ setSpinnerMessage(null)
+ setSpinnerColor(null)
+ setSpinnerShimmerColor(null)
+ pickNewSpinnerTip()
+ endInteractionSpan()
// Speculative bash classifier checks are only valid for the current
// turn's commands — clear after each turn to avoid accumulating
// Promise chains for unconsumed checks (denied/aborted paths).
- clearSpeculativeChecks();
- }, [pickNewSpinnerTip]);
+ clearSpeculativeChecks()
+ }, [pickNewSpinnerTip])
// Session backgrounding — hook is below, after getToolUseContext
- const hasRunningTeammates = useMemo(() => getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'), [tasks]);
+ const hasRunningTeammates = useMemo(
+ () => getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'),
+ [tasks],
+ )
// Show deferred turn duration message once all swarm teammates finish
useEffect(() => {
if (!hasRunningTeammates && swarmStartTimeRef.current !== null) {
- const totalMs = Date.now() - swarmStartTimeRef.current;
- const deferredBudget = swarmBudgetInfoRef.current;
- swarmStartTimeRef.current = null;
- swarmBudgetInfoRef.current = undefined;
- setMessages(prev => [...prev, createTurnDurationMessage(totalMs, deferredBudget,
- // Count only what recordTranscript will persist — ephemeral
- // progress ticks and non-ant attachments are filtered by
- // isLoggableMessage and never reach disk. Using raw prev.length
- // would make checkResumeConsistency report false delta<0 for
- // every turn that ran a progress-emitting tool.
- count(prev, isLoggableMessage))]);
+ const totalMs = Date.now() - swarmStartTimeRef.current
+ const deferredBudget = swarmBudgetInfoRef.current
+ swarmStartTimeRef.current = null
+ swarmBudgetInfoRef.current = undefined
+ setMessages(prev => [
+ ...prev,
+ createTurnDurationMessage(
+ totalMs,
+ deferredBudget,
+ // Count only what recordTranscript will persist — ephemeral
+ // progress ticks and non-ant attachments are filtered by
+ // isLoggableMessage and never reach disk. Using raw prev.length
+ // would make checkResumeConsistency report false delta<0 for
+ // every turn that ran a progress-emitting tool.
+ count(prev, isLoggableMessage),
+ ),
+ ])
}
- }, [hasRunningTeammates, setMessages]);
+ }, [hasRunningTeammates, setMessages])
// Show auto permissions warning when entering auto mode
// (either via Shift+Tab toggle or on startup). Debounced to avoid
// flashing when the user is cycling through modes quickly.
// Only shown 3 times total across sessions.
- const safeYoloMessageShownRef = useRef(false);
+ const safeYoloMessageShownRef = useRef(false)
useEffect(() => {
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (toolPermissionContext.mode !== 'auto') {
- safeYoloMessageShownRef.current = false;
- return;
+ safeYoloMessageShownRef.current = false
+ return
}
- if (safeYoloMessageShownRef.current) return;
- const config = getGlobalConfig();
- const count = config.autoPermissionsNotificationCount ?? 0;
- if (count >= 3) return;
- const timer = setTimeout((ref, setMessages) => {
- ref.current = true;
- saveGlobalConfig(prev => {
- const prevCount = prev.autoPermissionsNotificationCount ?? 0;
- if (prevCount >= 3) return prev;
- return {
+ if (safeYoloMessageShownRef.current) return
+ const config = getGlobalConfig()
+ const count = config.autoPermissionsNotificationCount ?? 0
+ if (count >= 3) return
+ const timer = setTimeout(
+ (ref, setMessages) => {
+ ref.current = true
+ saveGlobalConfig(prev => {
+ const prevCount = prev.autoPermissionsNotificationCount ?? 0
+ if (prevCount >= 3) return prev
+ return {
+ ...prev,
+ autoPermissionsNotificationCount: prevCount + 1,
+ }
+ })
+ setMessages(prev => [
...prev,
- autoPermissionsNotificationCount: prevCount + 1
- };
- });
- setMessages(prev => [...prev, createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning')]);
- }, 800, safeYoloMessageShownRef, setMessages);
- return () => clearTimeout(timer);
+ createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning'),
+ ])
+ },
+ 800,
+ safeYoloMessageShownRef,
+ setMessages,
+ )
+ return () => clearTimeout(timer)
}
- }, [toolPermissionContext.mode, setMessages]);
+ }, [toolPermissionContext.mode, setMessages])
// If worktree creation was slow and sparse-checkout isn't configured,
// nudge the user toward settings.worktree.sparsePaths.
- const worktreeTipShownRef = useRef(false);
+ const worktreeTipShownRef = useRef(false)
useEffect(() => {
- if (worktreeTipShownRef.current) return;
- const wt = getCurrentWorktreeSession();
- if (!wt?.creationDurationMs || wt.usedSparsePaths) return;
- if (wt.creationDurationMs < 15_000) return;
- worktreeTipShownRef.current = true;
- const secs = Math.round(wt.creationDurationMs / 1000);
- setMessages(prev => [...prev, createSystemMessage(`Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, 'info')]);
- }, [setMessages]);
+ if (worktreeTipShownRef.current) return
+ const wt = getCurrentWorktreeSession()
+ if (!wt?.creationDurationMs || wt.usedSparsePaths) return
+ if (wt.creationDurationMs < 15_000) return
+ worktreeTipShownRef.current = true
+ const secs = Math.round(wt.creationDurationMs / 1000)
+ setMessages(prev => [
+ ...prev,
+ createSystemMessage(
+ `Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`,
+ 'info',
+ ),
+ ])
+ }, [setMessages])
// Hide spinner when the only in-progress tool is Sleep
const onlySleepToolActive = useMemo(() => {
- const lastAssistant = messages.findLast(m => m.type === 'assistant');
- if (lastAssistant?.type !== 'assistant') return false;
- const content = lastAssistant.message.content;
- if (typeof content === 'string') return false;
- const contentArr = content as unknown as Array<{ type: string; id?: string; name?: string; [key: string]: unknown }>;
- const inProgressToolUses = contentArr.filter(b => b.type === 'tool_use' && b.id && inProgressToolUseIDs.has(b.id));
- return inProgressToolUses.length > 0 && inProgressToolUses.every(b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME);
- }, [messages, inProgressToolUseIDs]);
+ const lastAssistant = messages.findLast(m => m.type === 'assistant')
+ if (lastAssistant?.type !== 'assistant') return false
+ const inProgressToolUses = lastAssistant.message.content.filter(
+ b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id),
+ )
+ return (
+ inProgressToolUses.length > 0 &&
+ inProgressToolUses.every(
+ b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME,
+ )
+ )
+ }, [messages, inProgressToolUseIDs])
+
const {
onBeforeQuery: mrOnBeforeQuery,
onTurnComplete: mrOnTurnComplete,
- render: mrRender
+ render: mrRender,
} = useMoreRight({
enabled: moreRightEnabled,
setMessages,
inputValue,
setInputValue,
- setToolJSX
- });
- const showSpinner = (!toolJSX || toolJSX.showSpinner === true) && toolUseConfirmQueue.length === 0 && promptQueue.length === 0 && (
- // Show spinner during input processing, API call, while teammates are running,
- // or while pending task notifications are queued (prevents spinner bounce between consecutive notifications)
- isLoading || userInputOnProcessing || hasRunningTeammates ||
- // Keep spinner visible while task notifications are queued for processing.
- // Without this, the spinner briefly disappears between consecutive notifications
- // (e.g., multiple background agents completing in rapid succession) because
- // isLoading goes false momentarily between processing each one.
- getCommandQueueLength() > 0) &&
- // Hide spinner when waiting for leader to approve permission request
- !pendingWorkerRequest && !onlySleepToolActive && (
- // Hide spinner when streaming text is visible (the text IS the feedback),
- // but keep it when isBriefOnly suppresses the streaming text display
- !visibleStreamingText || isBriefOnly);
+ setToolJSX,
+ })
+
+ const showSpinner =
+ (!toolJSX || toolJSX.showSpinner === true) &&
+ toolUseConfirmQueue.length === 0 &&
+ promptQueue.length === 0 &&
+ // Show spinner during input processing, API call, while teammates are running,
+ // or while pending task notifications are queued (prevents spinner bounce between consecutive notifications)
+ (isLoading ||
+ userInputOnProcessing ||
+ hasRunningTeammates ||
+ // Keep spinner visible while task notifications are queued for processing.
+ // Without this, the spinner briefly disappears between consecutive notifications
+ // (e.g., multiple background agents completing in rapid succession) because
+ // isLoading goes false momentarily between processing each one.
+ getCommandQueueLength() > 0) &&
+ // Hide spinner when waiting for leader to approve permission request
+ !pendingWorkerRequest &&
+ !onlySleepToolActive &&
+ // Hide spinner when streaming text is visible (the text IS the feedback),
+ // but keep it when isBriefOnly suppresses the streaming text display
+ (!visibleStreamingText || isBriefOnly)
// Check if any permission or ask question prompt is currently visible
// This is used to prevent the survey from opening while prompts are active
- const hasActivePrompt = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || sandboxPermissionRequestQueue.length > 0 || elicitation.queue.length > 0 || workerSandboxPermissions.queue.length > 0;
- const feedbackSurveyOriginal = useFeedbackSurvey(messages, isLoading, submitCount, 'session', hasActivePrompt);
- const skillImprovementSurvey = useSkillImprovementSurvey(setMessages);
- const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount);
+ const hasActivePrompt =
+ toolUseConfirmQueue.length > 0 ||
+ promptQueue.length > 0 ||
+ sandboxPermissionRequestQueue.length > 0 ||
+ elicitation.queue.length > 0 ||
+ workerSandboxPermissions.queue.length > 0
+
+ const feedbackSurveyOriginal = useFeedbackSurvey(
+ messages,
+ isLoading,
+ submitCount,
+ 'session',
+ hasActivePrompt,
+ )
+
+ const skillImprovementSurvey = useSkillImprovementSurvey(setMessages)
+
+ const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount)
// Wrap feedback survey handler to trigger auto-run /issue
- const feedbackSurvey = useMemo(() => ({
- ...feedbackSurveyOriginal,
- handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => {
- // Reset the ref when a new survey response comes in
- didAutoRunIssueRef.current = false;
- const showedTranscriptPrompt = feedbackSurveyOriginal.handleSelect(selected);
- // Auto-run /issue for "bad" if transcript prompt wasn't shown
- if (selected === 'bad' && !showedTranscriptPrompt && shouldAutoRunIssue('feedback_survey_bad')) {
- setAutoRunIssueReason('feedback_survey_bad');
- didAutoRunIssueRef.current = true;
- }
- }
- }), [feedbackSurveyOriginal]);
+ const feedbackSurvey = useMemo(
+ () => ({
+ ...feedbackSurveyOriginal,
+ handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => {
+ // Reset the ref when a new survey response comes in
+ didAutoRunIssueRef.current = false
+ const showedTranscriptPrompt =
+ feedbackSurveyOriginal.handleSelect(selected)
+ // Auto-run /issue for "bad" if transcript prompt wasn't shown
+ if (
+ selected === 'bad' &&
+ !showedTranscriptPrompt &&
+ shouldAutoRunIssue('feedback_survey_bad')
+ ) {
+ setAutoRunIssueReason('feedback_survey_bad')
+ didAutoRunIssueRef.current = true
+ }
+ },
+ }),
+ [feedbackSurveyOriginal],
+ )
// Post-compact survey: shown after compaction if feature gate is enabled
- const postCompactSurvey = usePostCompactSurvey(messages, isLoading, hasActivePrompt, {
- enabled: !isRemoteSession
- });
+ const postCompactSurvey = usePostCompactSurvey(
+ messages,
+ isLoading,
+ hasActivePrompt,
+ { enabled: !isRemoteSession },
+ )
// Memory survey: shown when the assistant mentions memory and a memory file
// was read this conversation
const memorySurvey = useMemorySurvey(messages, isLoading, hasActivePrompt, {
- enabled: !isRemoteSession
- });
+ enabled: !isRemoteSession,
+ })
// Frustration detection: show transcript sharing prompt after detecting frustrated messages
- const frustrationDetection = useFrustrationDetection(messages, isLoading, hasActivePrompt, feedbackSurvey.state !== 'closed' || postCompactSurvey.state !== 'closed' || memorySurvey.state !== 'closed');
+ const frustrationDetection = useFrustrationDetection(
+ messages,
+ isLoading,
+ hasActivePrompt,
+ feedbackSurvey.state !== 'closed' ||
+ postCompactSurvey.state !== 'closed' ||
+ memorySurvey.state !== 'closed',
+ )
// Initialize IDE integration
useIDEIntegration({
@@ -1731,366 +2337,464 @@ export function REPL({
ideToInstallExtension,
setDynamicMcpConfig,
setShowIdeOnboarding,
- setIDEInstallationState: setIDEInstallationStatus
- });
- useFileHistorySnapshotInit(initialFileHistorySnapshots, fileHistory, fileHistoryState => setAppState(prev => ({
- ...prev,
- fileHistory: fileHistoryState
- })));
- const resume = useCallback(async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => {
- const resumeStart = performance.now();
- try {
- // Deserialize messages to properly clean up the conversation
- // This filters unresolved tool uses and adds a synthetic assistant message if needed
- const messages = deserializeMessages(log.messages);
+ setIDEInstallationState: setIDEInstallationStatus,
+ })
- // Match coordinator/normal mode to the resumed session
- if (feature('COORDINATOR_MODE')) {
- /* eslint-disable @typescript-eslint/no-require-imports */
- const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js');
- /* eslint-enable @typescript-eslint/no-require-imports */
- const warning = coordinatorModule.matchSessionMode(log.mode);
- if (warning) {
- // Re-derive agent definitions after mode switch so built-in agents
- // reflect the new coordinator/normal mode
+ useFileHistorySnapshotInit(
+ initialFileHistorySnapshots,
+ fileHistory,
+ fileHistoryState =>
+ setAppState(prev => ({
+ ...prev,
+ fileHistory: fileHistoryState,
+ })),
+ )
+
+ const resume = useCallback(
+ async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => {
+ const resumeStart = performance.now()
+ try {
+ // Deserialize messages to properly clean up the conversation
+ // This filters unresolved tool uses and adds a synthetic assistant message if needed
+ const messages = deserializeMessages(log.messages)
+
+ // Match coordinator/normal mode to the resumed session
+ if (feature('COORDINATOR_MODE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
- const {
- getAgentDefinitionsWithOverrides,
- getActiveAgentsFromList
- } = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js');
+ const coordinatorModule =
+ require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
/* eslint-enable @typescript-eslint/no-require-imports */
- getAgentDefinitionsWithOverrides.cache.clear?.();
- const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd());
- setAppState(prev => ({
- ...prev,
- agentDefinitions: {
- ...freshAgentDefs,
- allAgents: freshAgentDefs.allAgents,
- activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents)
- }
- }));
- messages.push(createSystemMessage(warning, 'warning'));
+ const warning = coordinatorModule.matchSessionMode(log.mode)
+ if (warning) {
+ // Re-derive agent definitions after mode switch so built-in agents
+ // reflect the new coordinator/normal mode
+ /* eslint-disable @typescript-eslint/no-require-imports */
+ const {
+ getAgentDefinitionsWithOverrides,
+ getActiveAgentsFromList,
+ } =
+ require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js')
+ /* eslint-enable @typescript-eslint/no-require-imports */
+ getAgentDefinitionsWithOverrides.cache.clear?.()
+ const freshAgentDefs = await getAgentDefinitionsWithOverrides(
+ getOriginalCwd(),
+ )
+
+ setAppState(prev => ({
+ ...prev,
+ agentDefinitions: {
+ ...freshAgentDefs,
+ allAgents: freshAgentDefs.allAgents,
+ activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents),
+ },
+ }))
+ messages.push(createSystemMessage(warning, 'warning'))
+ }
}
- }
- // Fire SessionEnd hooks for the current session before starting the
- // resumed one, mirroring the /clear flow in conversation.ts.
- const sessionEndTimeoutMs = getSessionEndHookTimeoutMs();
- await executeSessionEndHooks('resume', {
- getAppState: () => store.getState(),
- setAppState,
- signal: AbortSignal.timeout(sessionEndTimeoutMs),
- timeoutMs: sessionEndTimeoutMs
- });
-
- // Process session start hooks for resume
- const hookMessages = await processSessionStartHooks('resume', {
- sessionId,
- agentType: mainThreadAgentDefinition?.agentType,
- model: mainLoopModel
- });
-
- // Append hook messages to the conversation
- messages.push(...hookMessages);
- // For forks, generate a new plan slug and copy the plan content so the
- // original and forked sessions don't clobber each other's plan files.
- // For regular resumes, reuse the original session's plan slug.
- if (entrypoint === 'fork') {
- void copyPlanForFork(log, asSessionId(sessionId));
- } else {
- void copyPlanForResume(log, asSessionId(sessionId));
- }
-
- // Restore file history and attribution state from the resumed conversation
- restoreSessionStateFromLog(log, setAppState);
- if (log.fileHistorySnapshots) {
- void copyFileHistoryForResume(log);
- }
-
- // Restore agent setting from the resumed conversation
- // Always reset to the new session's values (or clear if none),
- // matching the standaloneAgentContext pattern below
- const {
- agentDefinition: restoredAgent
- } = restoreAgentFromSession(log.agentSetting, initialMainThreadAgentDefinition, agentDefinitions);
- setMainThreadAgentDefinition(restoredAgent);
- setAppState(prev => ({
- ...prev,
- agent: restoredAgent?.agentType
- }));
-
- // Restore standalone agent context from the resumed conversation
- // Always reset to the new session's values (or clear if none)
- setAppState(prev => ({
- ...prev,
- standaloneAgentContext: computeStandaloneAgentContext(log.agentName, log.agentColor)
- }));
- void updateSessionName(log.agentName);
-
- // Restore read file state from the message history
- restoreReadFileState(messages, log.projectPath ?? getOriginalCwd());
-
- // Clear any active loading state (no queryId since we're not in a query)
- resetLoadingState();
- setAbortController(null);
- setConversationId(sessionId);
-
- // Get target session's costs BEFORE saving current session
- // (saveCurrentSessionCosts overwrites the config, so we need to read first)
- const targetSessionCosts = getStoredSessionCosts(sessionId);
-
- // Save current session's costs before switching to avoid losing accumulated costs
- saveCurrentSessionCosts();
-
- // Reset cost state for clean slate before restoring target session
- resetCostState();
-
- // Switch session (id + project dir atomically). fullPath may point to
- // a different project (cross-worktree, /branch); null derives from
- // current originalCwd.
- switchSession(asSessionId(sessionId), log.fullPath ? dirname(log.fullPath) : null);
- // Rename asciicast recording to match the resumed session ID
- const {
- renameRecordingForSession
- } = await import('../utils/asciicast.js');
- await renameRecordingForSession();
- await resetSessionFilePointer();
-
- // Clear then restore session metadata so it's re-appended on exit via
- // reAppendSessionMetadata. clearSessionMetadata must be called first:
- // restoreSessionMetadata only sets-if-truthy, so without the clear,
- // a session without an agent name would inherit the previous session's
- // cached name and write it to the wrong transcript on first message.
- clearSessionMetadata();
- restoreSessionMetadata(log);
- // Resumed sessions shouldn't re-title from mid-conversation context
- // (same reasoning as the useRef seed), and the previous session's
- // Haiku title shouldn't carry over.
- haikuTitleAttemptedRef.current = true;
- setHaikuTitle(undefined);
-
- // Exit any worktree a prior /resume entered, then cd into the one
- // this session was in. Without the exit, resuming from worktree B
- // to non-worktree C leaves cwd/currentWorktreeSession stale;
- // resuming B→C where C is also a worktree fails entirely
- // (getCurrentWorktreeSession guard blocks the switch).
- //
- // Skipped for /branch: forkLog doesn't carry worktreeSession, so
- // this would kick the user out of a worktree they're still working
- // in. Same fork skip as processResumedConversation for the adopt —
- // fork materializes its own file via recordTranscript on REPL mount.
- if (entrypoint !== 'fork') {
- exitRestoredWorktree();
- restoreWorktreeForResume(log.worktreeSession);
- adoptResumedSessionFile();
- void restoreRemoteAgentTasks({
- abortController: new AbortController(),
+ // Fire SessionEnd hooks for the current session before starting the
+ // resumed one, mirroring the /clear flow in conversation.ts.
+ const sessionEndTimeoutMs = getSessionEndHookTimeoutMs()
+ await executeSessionEndHooks('resume', {
getAppState: () => store.getState(),
- setAppState
- });
- } else {
- // Fork: same re-persist as /clear (conversation.ts). The clear
- // above wiped currentSessionWorktree, forkLog doesn't carry it,
- // and the process is still in the same worktree.
- const ws = getCurrentWorktreeSession();
- if (ws) saveWorktreeState(ws);
+ setAppState,
+ signal: AbortSignal.timeout(sessionEndTimeoutMs),
+ timeoutMs: sessionEndTimeoutMs,
+ })
+
+ // Process session start hooks for resume
+ const hookMessages = await processSessionStartHooks('resume', {
+ sessionId,
+ agentType: mainThreadAgentDefinition?.agentType,
+ model: mainLoopModel,
+ })
+
+ // Append hook messages to the conversation
+ messages.push(...hookMessages)
+ // For forks, generate a new plan slug and copy the plan content so the
+ // original and forked sessions don't clobber each other's plan files.
+ // For regular resumes, reuse the original session's plan slug.
+ if (entrypoint === 'fork') {
+ void copyPlanForFork(log, asSessionId(sessionId))
+ } else {
+ void copyPlanForResume(log, asSessionId(sessionId))
+ }
+
+ // Restore file history and attribution state from the resumed conversation
+ restoreSessionStateFromLog(log, setAppState)
+ if (log.fileHistorySnapshots) {
+ void copyFileHistoryForResume(log)
+ }
+
+ // Restore agent setting from the resumed conversation
+ // Always reset to the new session's values (or clear if none),
+ // matching the standaloneAgentContext pattern below
+ const { agentDefinition: restoredAgent } = restoreAgentFromSession(
+ log.agentSetting,
+ initialMainThreadAgentDefinition,
+ agentDefinitions,
+ )
+ setMainThreadAgentDefinition(restoredAgent)
+ setAppState(prev => ({ ...prev, agent: restoredAgent?.agentType }))
+
+ // Restore standalone agent context from the resumed conversation
+ // Always reset to the new session's values (or clear if none)
+ setAppState(prev => ({
+ ...prev,
+ standaloneAgentContext: computeStandaloneAgentContext(
+ log.agentName,
+ log.agentColor,
+ ),
+ }))
+ void updateSessionName(log.agentName)
+
+ // Restore read file state from the message history
+ restoreReadFileState(messages, log.projectPath ?? getOriginalCwd())
+
+ // Clear any active loading state (no queryId since we're not in a query)
+ resetLoadingState()
+ setAbortController(null)
+
+ setConversationId(sessionId)
+
+ // Get target session's costs BEFORE saving current session
+ // (saveCurrentSessionCosts overwrites the config, so we need to read first)
+ const targetSessionCosts = getStoredSessionCosts(sessionId)
+
+ // Save current session's costs before switching to avoid losing accumulated costs
+ saveCurrentSessionCosts()
+
+ // Reset cost state for clean slate before restoring target session
+ resetCostState()
+
+ // Switch session (id + project dir atomically). fullPath may point to
+ // a different project (cross-worktree, /branch); null derives from
+ // current originalCwd.
+ switchSession(
+ asSessionId(sessionId),
+ log.fullPath ? dirname(log.fullPath) : null,
+ )
+ // Rename asciicast recording to match the resumed session ID
+ const { renameRecordingForSession } = await import(
+ '../utils/asciicast.js'
+ )
+ await renameRecordingForSession()
+ await resetSessionFilePointer()
+
+ // Clear then restore session metadata so it's re-appended on exit via
+ // reAppendSessionMetadata. clearSessionMetadata must be called first:
+ // restoreSessionMetadata only sets-if-truthy, so without the clear,
+ // a session without an agent name would inherit the previous session's
+ // cached name and write it to the wrong transcript on first message.
+ clearSessionMetadata()
+ restoreSessionMetadata(log)
+ // Resumed sessions shouldn't re-title from mid-conversation context
+ // (same reasoning as the useRef seed), and the previous session's
+ // Haiku title shouldn't carry over.
+ haikuTitleAttemptedRef.current = true
+ setHaikuTitle(undefined)
+
+ // Exit any worktree a prior /resume entered, then cd into the one
+ // this session was in. Without the exit, resuming from worktree B
+ // to non-worktree C leaves cwd/currentWorktreeSession stale;
+ // resuming B→C where C is also a worktree fails entirely
+ // (getCurrentWorktreeSession guard blocks the switch).
+ //
+ // Skipped for /branch: forkLog doesn't carry worktreeSession, so
+ // this would kick the user out of a worktree they're still working
+ // in. Same fork skip as processResumedConversation for the adopt —
+ // fork materializes its own file via recordTranscript on REPL mount.
+ if (entrypoint !== 'fork') {
+ exitRestoredWorktree()
+ restoreWorktreeForResume(log.worktreeSession)
+ adoptResumedSessionFile()
+ void restoreRemoteAgentTasks({
+ abortController: new AbortController(),
+ getAppState: () => store.getState(),
+ setAppState,
+ })
+ } else {
+ // Fork: same re-persist as /clear (conversation.ts). The clear
+ // above wiped currentSessionWorktree, forkLog doesn't carry it,
+ // and the process is still in the same worktree.
+ const ws = getCurrentWorktreeSession()
+ if (ws) saveWorktreeState(ws)
+ }
+
+ // Persist the current mode so future resumes know what mode this session was in
+ if (feature('COORDINATOR_MODE')) {
+ /* eslint-disable @typescript-eslint/no-require-imports */
+ const { saveMode } = require('../utils/sessionStorage.js')
+ const { isCoordinatorMode } =
+ require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
+ /* eslint-enable @typescript-eslint/no-require-imports */
+ saveMode(isCoordinatorMode() ? 'coordinator' : 'normal')
+ }
+
+ // Restore target session's costs from the data we read earlier
+ if (targetSessionCosts) {
+ setCostStateForRestore(targetSessionCosts)
+ }
+
+ // Reconstruct replacement state for the resumed session. Runs after
+ // setSessionId so any NEW replacements post-resume write to the
+ // resumed session's tool-results dir. Gated on ref.current: the
+ // initial mount already read the feature flag, so we don't re-read
+ // it here (mid-session flag flips stay unobservable in both
+ // directions).
+ //
+ // Skipped for in-session /branch: the existing ref is already correct
+ // (branch preserves tool_use_ids), so there's no need to reconstruct.
+ // createFork() does write content-replacement entries to the forked
+ // JSONL with the fork's sessionId, so `claude -r {forkId}` also works.
+ if (contentReplacementStateRef.current && entrypoint !== 'fork') {
+ contentReplacementStateRef.current =
+ reconstructContentReplacementState(
+ messages,
+ log.contentReplacements ?? [],
+ )
+ }
+
+ // Reset messages to the provided initial messages
+ // Use a callback to ensure we're not dependent on stale state
+ setMessages(() => messages)
+
+ // Clear any active tool JSX
+ setToolJSX(null)
+
+ // Clear input to ensure no residual state
+ setInputValue('')
+
+ logEvent('tengu_session_resumed', {
+ entrypoint:
+ entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ success: true,
+ resume_duration_ms: Math.round(performance.now() - resumeStart),
+ })
+ } catch (error) {
+ logEvent('tengu_session_resumed', {
+ entrypoint:
+ entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ success: false,
+ })
+ throw error
}
-
- // Persist the current mode so future resumes know what mode this session was in
- if (feature('COORDINATOR_MODE')) {
- /* eslint-disable @typescript-eslint/no-require-imports */
- const {
- saveMode
- } = require('../utils/sessionStorage.js');
- const {
- isCoordinatorMode
- } = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js');
- /* eslint-enable @typescript-eslint/no-require-imports */
- saveMode(isCoordinatorMode() ? 'coordinator' : 'normal');
- }
-
- // Restore target session's costs from the data we read earlier
- if (targetSessionCosts) {
- setCostStateForRestore(targetSessionCosts);
- }
-
- // Reconstruct replacement state for the resumed session. Runs after
- // setSessionId so any NEW replacements post-resume write to the
- // resumed session's tool-results dir. Gated on ref.current: the
- // initial mount already read the feature flag, so we don't re-read
- // it here (mid-session flag flips stay unobservable in both
- // directions).
- //
- // Skipped for in-session /branch: the existing ref is already correct
- // (branch preserves tool_use_ids), so there's no need to reconstruct.
- // createFork() does write content-replacement entries to the forked
- // JSONL with the fork's sessionId, so `claude -r {forkId}` also works.
- if (contentReplacementStateRef.current && entrypoint !== 'fork') {
- contentReplacementStateRef.current = reconstructContentReplacementState(messages, log.contentReplacements ?? []);
- }
-
- // Reset messages to the provided initial messages
- // Use a callback to ensure we're not dependent on stale state
- setMessages(() => messages);
-
- // Clear any active tool JSX
- setToolJSX(null);
-
- // Clear input to ensure no residual state
- setInputValue('');
- logEvent('tengu_session_resumed', {
- entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- success: true,
- resume_duration_ms: Math.round(performance.now() - resumeStart)
- });
- } catch (error) {
- logEvent('tengu_session_resumed', {
- entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- success: false
- });
- throw error;
- }
- }, [resetLoadingState, setAppState]);
+ },
+ [resetLoadingState, setAppState],
+ )
// Lazy init: useRef(createX()) would call createX on every render and
// discard the result. LRUCache construction inside FileStateCache is
// expensive (~170ms), so we use useState's lazy initializer to create
// it exactly once, then feed that stable reference into useRef.
- const [initialReadFileState] = useState(() => createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE));
- const readFileState = useRef(initialReadFileState);
- const bashTools = useRef(new Set());
- const bashToolsProcessedIdx = useRef(0);
+ const [initialReadFileState] = useState(() =>
+ createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE),
+ )
+ const readFileState = useRef(initialReadFileState)
+ const bashTools = useRef(new Set())
+ const bashToolsProcessedIdx = useRef(0)
// Session-scoped skill discovery tracking (feeds was_discovered on
// tengu_skill_tool_invocation). Must persist across getToolUseContext
// rebuilds within a session: turn-0 discovery writes via processUserInput
// before onQuery builds its own context, and discovery on turn N must
// still attribute a SkillTool call on turn N+k. Cleared in clearConversation.
- const discoveredSkillNamesRef = useRef(new Set());
+ const discoveredSkillNamesRef = useRef(new Set())
// Session-level dedup for nested_memory CLAUDE.md attachments.
// readFileState is a 100-entry LRU; once it evicts a CLAUDE.md path,
// the next discovery cycle re-injects it. Cleared in clearConversation.
- const loadedNestedMemoryPathsRef = useRef(new Set());
+ const loadedNestedMemoryPathsRef = useRef(new Set())
// Helper to restore read file state from messages (used for resume flows)
// This allows Claude to edit files that were read in previous sessions
- const restoreReadFileState = useCallback((messages: MessageType[], cwd: string) => {
- const extracted = extractReadFilesFromMessages(messages, cwd, READ_FILE_STATE_CACHE_SIZE);
- readFileState.current = mergeFileStateCaches(readFileState.current, extracted);
- for (const tool of extractBashToolsFromMessages(messages)) {
- bashTools.current.add(tool);
- }
- }, []);
+ const restoreReadFileState = useCallback(
+ (messages: MessageType[], cwd: string) => {
+ const extracted = extractReadFilesFromMessages(
+ messages,
+ cwd,
+ READ_FILE_STATE_CACHE_SIZE,
+ )
+ readFileState.current = mergeFileStateCaches(
+ readFileState.current,
+ extracted,
+ )
+ for (const tool of extractBashToolsFromMessages(messages)) {
+ bashTools.current.add(tool)
+ }
+ },
+ [],
+ )
// Extract read file state from initialMessages on mount
// This handles CLI flag resume (--resume-session) and ResumeConversation screen
// where messages are passed as props rather than through the resume callback
useEffect(() => {
if (initialMessages && initialMessages.length > 0) {
- restoreReadFileState(initialMessages, getOriginalCwd());
+ restoreReadFileState(initialMessages, getOriginalCwd())
void restoreRemoteAgentTasks({
abortController: new AbortController(),
getAppState: () => store.getState(),
- setAppState
- });
+ setAppState,
+ })
}
// Only run on mount - initialMessages shouldn't change during component lifetime
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
- const {
- status: apiKeyStatus,
- reverify
- } = useApiKeyVerification();
+ }, [])
+
+ const { status: apiKeyStatus, reverify } = useApiKeyVerification()
// Auto-run /issue state
- const [autoRunIssueReason, setAutoRunIssueReason] = useState(null);
+ const [autoRunIssueReason, setAutoRunIssueReason] =
+ useState(null)
// Ref to track if autoRunIssue was triggered this survey cycle,
// so we can suppress the [1] follow-up prompt even after
// autoRunIssueReason is cleared.
- const didAutoRunIssueRef = useRef(false);
+ const didAutoRunIssueRef = useRef(false)
// State for exit feedback flow
- const [exitFlow, setExitFlow] = useState(null);
- const [isExiting, setIsExiting] = useState(false);
+ const [exitFlow, setExitFlow] = useState(null)
+ const [isExiting, setIsExiting] = useState(false)
// Calculate if cost dialog should be shown
- const showingCostDialog = !isLoading && showCostDialog;
+ const showingCostDialog = !isLoading && showCostDialog
// Determine which dialog should have focus (if any)
// Permission and interactive dialogs can show even when toolJSX is set,
// as long as shouldContinueAnimation is true. This prevents deadlocks when
// agents set background hints while waiting for user interaction.
- function getFocusedInputDialog(): 'message-selector' | 'sandbox-permission' | 'tool-permission' | 'prompt' | 'worker-sandbox-permission' | 'elicitation' | 'cost' | 'idle-return' | 'init-onboarding' | 'ide-onboarding' | 'model-switch' | 'undercover-callout' | 'effort-callout' | 'remote-callout' | 'lsp-recommendation' | 'plugin-hint' | 'desktop-upsell' | 'ultraplan-choice' | 'ultraplan-launch' | undefined {
+ function getFocusedInputDialog():
+ | 'message-selector'
+ | 'sandbox-permission'
+ | 'tool-permission'
+ | 'prompt'
+ | 'worker-sandbox-permission'
+ | 'elicitation'
+ | 'cost'
+ | 'idle-return'
+ | 'init-onboarding'
+ | 'ide-onboarding'
+ | 'model-switch'
+ | 'undercover-callout'
+ | 'effort-callout'
+ | 'remote-callout'
+ | 'lsp-recommendation'
+ | 'plugin-hint'
+ | 'desktop-upsell'
+ | 'ultraplan-choice'
+ | 'ultraplan-launch'
+ | undefined {
// Exit states always take precedence
- if (isExiting || exitFlow) return undefined;
+ if (isExiting || exitFlow) return undefined
// High priority dialogs (always show regardless of typing)
- if (isMessageSelectorVisible) return 'message-selector';
+ if (isMessageSelectorVisible) return 'message-selector'
// Suppress interrupt dialogs while user is actively typing
- if (isPromptInputActive) return undefined;
- if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission';
+ if (isPromptInputActive) return undefined
+
+ if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission'
// Permission/interactive dialogs (show unless blocked by toolJSX)
- const allowDialogsWithAnimation = !toolJSX || toolJSX.shouldContinueAnimation;
- if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) return 'tool-permission';
- if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt';
+ const allowDialogsWithAnimation =
+ !toolJSX || toolJSX.shouldContinueAnimation
+
+ if (allowDialogsWithAnimation && toolUseConfirmQueue[0])
+ return 'tool-permission'
+ if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt'
// Worker sandbox permission prompts (network access) from swarm workers
- if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) return 'worker-sandbox-permission';
- if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation';
- if (allowDialogsWithAnimation && showingCostDialog) return 'cost';
- if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return';
- if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanPendingChoice) return 'ultraplan-choice';
- if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanLaunchPending) return 'ultraplan-launch';
+ if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0])
+ return 'worker-sandbox-permission'
+ if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation'
+ if (allowDialogsWithAnimation && showingCostDialog) return 'cost'
+ if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return'
+
+ if (
+ feature('ULTRAPLAN') &&
+ allowDialogsWithAnimation &&
+ !isLoading &&
+ ultraplanPendingChoice
+ )
+ return 'ultraplan-choice'
+
+ if (
+ feature('ULTRAPLAN') &&
+ allowDialogsWithAnimation &&
+ !isLoading &&
+ ultraplanLaunchPending
+ )
+ return 'ultraplan-launch'
// Onboarding dialogs (special conditions)
- if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding';
+ if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding'
// Model switch callout (ant-only, eliminated from external builds)
- if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
+ if (
+ process.env.USER_TYPE === 'ant' &&
+ allowDialogsWithAnimation &&
+ showModelSwitchCallout
+ )
+ return 'model-switch'
// Undercover auto-enable explainer (ant-only, eliminated from external builds)
- if ((process.env.USER_TYPE) === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
+ if (
+ process.env.USER_TYPE === 'ant' &&
+ allowDialogsWithAnimation &&
+ showUndercoverCallout
+ )
+ return 'undercover-callout'
// Effort callout (shown once for Opus 4.6 users when effort is enabled)
- if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout';
+ if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout'
// Remote callout (shown once before first bridge enable)
- if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout';
+ if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout'
// LSP plugin recommendation (lowest priority - non-blocking suggestion)
- if (allowDialogsWithAnimation && lspRecommendation) return 'lsp-recommendation';
+ if (allowDialogsWithAnimation && lspRecommendation)
+ return 'lsp-recommendation'
// Plugin hint from CLI/SDK stderr (same priority band as LSP rec)
- if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint';
+ if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint'
// Desktop app upsell (max 3 launches, lowest priority)
- if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell';
- return undefined;
+ if (allowDialogsWithAnimation && showDesktopUpsellStartup)
+ return 'desktop-upsell'
+
+ return undefined
}
- const focusedInputDialog = getFocusedInputDialog();
+
+ const focusedInputDialog = getFocusedInputDialog()
// True when permission prompts exist but are hidden because the user is typing
- const hasSuppressedDialogs = isPromptInputActive && (sandboxPermissionRequestQueue[0] || toolUseConfirmQueue[0] || promptQueue[0] || workerSandboxPermissions.queue[0] || elicitation.queue[0] || showingCostDialog);
+ const hasSuppressedDialogs =
+ isPromptInputActive &&
+ (sandboxPermissionRequestQueue[0] ||
+ toolUseConfirmQueue[0] ||
+ promptQueue[0] ||
+ workerSandboxPermissions.queue[0] ||
+ elicitation.queue[0] ||
+ showingCostDialog)
// Keep ref in sync so timer callbacks can read the current value
- focusedInputDialogRef.current = focusedInputDialog;
+ focusedInputDialogRef.current = focusedInputDialog
// Immediately capture pause/resume when focusedInputDialog changes
// This ensures accurate timing even under high system load, rather than
// relying on the 100ms polling interval to detect state changes
useEffect(() => {
- if (!isLoading) return;
- const isPaused = focusedInputDialog === 'tool-permission';
- const now = Date.now();
+ if (!isLoading) return
+
+ const isPaused = focusedInputDialog === 'tool-permission'
+ const now = Date.now()
+
if (isPaused && pauseStartTimeRef.current === null) {
// Just entered pause state - record the exact moment
- pauseStartTimeRef.current = now;
+ pauseStartTimeRef.current = now
} else if (!isPaused && pauseStartTimeRef.current !== null) {
// Just exited pause state - accumulate paused time immediately
- totalPausedMsRef.current += now - pauseStartTimeRef.current;
- pauseStartTimeRef.current = null;
+ totalPausedMsRef.current += now - pauseStartTimeRef.current
+ pauseStartTimeRef.current = null
}
- }, [focusedInputDialog, isLoading]);
+ }, [focusedInputDialog, isLoading])
// Re-pin scroll to bottom whenever the permission overlay appears or
// dismisses. Overlay now renders below messages inside the same
@@ -2101,98 +2805,105 @@ export function REPL({
// overlay, and onScroll was suppressed so the pill state is stale
// useLayoutEffect so the re-pin commits before the Ink frame renders —
// no 1-frame flash of the wrong scroll position.
- const prevDialogRef = useRef(focusedInputDialog);
+ const prevDialogRef = useRef(focusedInputDialog)
useLayoutEffect(() => {
- const was = prevDialogRef.current === 'tool-permission';
- const now = focusedInputDialog === 'tool-permission';
- if (was !== now) repinScroll();
- prevDialogRef.current = focusedInputDialog;
- }, [focusedInputDialog, repinScroll]);
+ const was = prevDialogRef.current === 'tool-permission'
+ const now = focusedInputDialog === 'tool-permission'
+ if (was !== now) repinScroll()
+ prevDialogRef.current = focusedInputDialog
+ }, [focusedInputDialog, repinScroll])
+
function onCancel() {
if (focusedInputDialog === 'elicitation') {
// Elicitation dialog handles its own Escape, and closing it shouldn't affect any loading state.
- return;
+ return
}
- logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`);
+
+ logForDebugging(
+ `[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`,
+ )
// Pause proactive mode so the user gets control back.
// It will resume when they submit their next input (see onSubmit).
if (feature('PROACTIVE') || feature('KAIROS')) {
- proactiveModule?.pauseProactive();
+ proactiveModule?.pauseProactive()
}
- queryGuard.forceEnd();
- skipIdleCheckRef.current = false;
+
+ queryGuard.forceEnd()
+ skipIdleCheckRef.current = false
// Preserve partially-streamed text so the user can read what was
// generated before pressing Esc. Pushed before resetLoadingState clears
// streamingText, and before query.ts yields the async interrupt marker,
// giving final order [user, partial-assistant, [Request interrupted by user]].
if (streamingText?.trim()) {
- setMessages(prev => [...prev, createAssistantMessage({
- content: streamingText
- })]);
+ setMessages(prev => [
+ ...prev,
+ createAssistantMessage({ content: streamingText }),
+ ])
}
- resetLoadingState();
+
+ resetLoadingState()
// Clear any active token budget so the backstop doesn't fire on
// a stale budget if the query generator hasn't exited yet.
if (feature('TOKEN_BUDGET')) {
- snapshotOutputTokensForTurn(null);
+ snapshotOutputTokensForTurn(null)
}
+
if (focusedInputDialog === 'tool-permission') {
// Tool use confirm handles the abort signal itself
- toolUseConfirmQueue[0]?.onAbort();
- setToolUseConfirmQueue([]);
+ toolUseConfirmQueue[0]?.onAbort()
+ setToolUseConfirmQueue([])
} else if (focusedInputDialog === 'prompt') {
// Reject all pending prompts and clear the queue
for (const item of promptQueue) {
- item.reject(new Error('Prompt cancelled by user'));
+ item.reject(new Error('Prompt cancelled by user'))
}
- setPromptQueue([]);
- abortController?.abort('user-cancel');
+ setPromptQueue([])
+ abortController?.abort('user-cancel')
} else if (activeRemote.isRemoteMode) {
// Remote mode: send interrupt signal to CCR
- activeRemote.cancelRequest();
+ activeRemote.cancelRequest()
} else {
- abortController?.abort('user-cancel');
+ abortController?.abort('user-cancel')
}
// Clear the controller so subsequent Escape presses don't see a stale
// aborted signal. Without this, canCancelRunningTask is false (signal
// defined but .aborted === true), so isActive becomes false if no other
// activating conditions hold — leaving the Escape keybinding inactive.
- setAbortController(null);
+ setAbortController(null)
// forceEnd() skips the finally path — fire directly (aborted=true).
- void mrOnTurnComplete(messagesRef.current, true);
+ void mrOnTurnComplete(messagesRef.current, true)
}
// Function to handle queued command when canceling a permission request
const handleQueuedCommandOnCancel = useCallback(() => {
- const result = popAllEditable(inputValue, 0);
- if (!result) return;
- setInputValue(result.text);
- setInputMode('prompt');
+ const result = popAllEditable(inputValue, 0)
+ if (!result) return
+ setInputValue(result.text)
+ setInputMode('prompt')
// Restore images from queued commands to pastedContents
if (result.images.length > 0) {
setPastedContents(prev => {
- const newContents = {
- ...prev
- };
+ const newContents = { ...prev }
for (const image of result.images) {
- newContents[image.id] = image;
+ newContents[image.id] = image
}
- return newContents;
- });
+ return newContents
+ })
}
- }, [setInputValue, setInputMode, inputValue, setPastedContents]);
+ }, [setInputValue, setInputMode, inputValue, setPastedContents])
// CancelRequestHandler props - rendered inside KeybindingSetup
const cancelRequestProps = {
setToolUseConfirmQueue,
onCancel,
- onAgentsKilled: () => setMessages(prev => [...prev, createAgentsKilledMessage()]),
+ onAgentsKilled: () =>
+ setMessages(prev => [...prev, createAgentsKilledMessage()]),
isMessageSelectorVisible: isMessageSelectorVisible || !!showBashesDialog,
screen,
abortSignal: abortController?.signal,
@@ -2203,116 +2914,146 @@ export function REPL({
isHelpOpen,
inputMode,
inputValue,
- streamMode
- };
+ streamMode,
+ }
+
useEffect(() => {
- const totalCost = getTotalCost();
+ const totalCost = getTotalCost()
if (totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog) {
- logEvent('tengu_cost_threshold_reached', {});
+ logEvent('tengu_cost_threshold_reached', {})
// Mark as shown even if the dialog won't render (no console billing
// access). Otherwise this effect re-fires on every message change for
// the rest of the session — 200k+ spurious events observed.
- setHaveShownCostDialog(true);
+ setHaveShownCostDialog(true)
if (hasConsoleBillingAccess()) {
- setShowCostDialog(true);
+ setShowCostDialog(true)
}
}
- }, [messages, showCostDialog, haveShownCostDialog]);
- const sandboxAskCallback: SandboxAskCallback = useCallback(async (hostPattern: NetworkHostPattern) => {
- // If running as a swarm worker, forward the request to the leader via mailbox
- if (isAgentSwarmsEnabled() && isSwarmWorker()) {
- const requestId = generateSandboxRequestId();
+ }, [messages, showCostDialog, haveShownCostDialog])
- // Send the request to the leader via mailbox
- const sent = await sendSandboxPermissionRequestViaMailbox(hostPattern.host, requestId);
- return new Promise(resolveShouldAllowHost => {
- if (!sent) {
- // If we couldn't send via mailbox, fall back to local handling
- setSandboxPermissionRequestQueue(prev => [...prev, {
- hostPattern,
- resolvePromise: resolveShouldAllowHost
- }]);
- return;
- }
+ const sandboxAskCallback: SandboxAskCallback = useCallback(
+ async (hostPattern: NetworkHostPattern) => {
+ // If running as a swarm worker, forward the request to the leader via mailbox
+ if (isAgentSwarmsEnabled() && isSwarmWorker()) {
+ const requestId = generateSandboxRequestId()
- // Register the callback for when the leader responds
- registerSandboxPermissionCallback({
+ // Send the request to the leader via mailbox
+ const sent = await sendSandboxPermissionRequestViaMailbox(
+ hostPattern.host,
requestId,
- host: hostPattern.host,
- resolve: resolveShouldAllowHost
- });
+ )
- // Update AppState to show pending indicator
- setAppState(prev => ({
- ...prev,
- pendingSandboxRequest: {
- requestId,
- host: hostPattern.host
+ return new Promise(resolveShouldAllowHost => {
+ if (!sent) {
+ // If we couldn't send via mailbox, fall back to local handling
+ setSandboxPermissionRequestQueue(prev => [
+ ...prev,
+ {
+ hostPattern,
+ resolvePromise: resolveShouldAllowHost,
+ },
+ ])
+ return
}
- }));
- });
- }
- // Normal flow for non-workers: show local UI and optionally race
- // against the REPL bridge (Remote Control) if connected.
- return new Promise(resolveShouldAllowHost => {
- let resolved = false;
- function resolveOnce(allow: boolean): void {
- if (resolved) return;
- resolved = true;
- resolveShouldAllowHost(allow);
+ // Register the callback for when the leader responds
+ registerSandboxPermissionCallback({
+ requestId,
+ host: hostPattern.host,
+ resolve: resolveShouldAllowHost,
+ })
+
+ // Update AppState to show pending indicator
+ setAppState(prev => ({
+ ...prev,
+ pendingSandboxRequest: {
+ requestId,
+ host: hostPattern.host,
+ },
+ }))
+ })
}
- // Queue the local sandbox permission dialog
- setSandboxPermissionRequestQueue(prev => [...prev, {
- hostPattern,
- resolvePromise: resolveOnce
- }]);
-
- // When the REPL bridge is connected, also forward the sandbox
- // permission request as a can_use_tool control_request so the
- // remote user (e.g. on claude.ai) can approve it too.
- if (feature('BRIDGE_MODE')) {
- const bridgeCallbacks = store.getState().replBridgePermissionCallbacks;
- if (bridgeCallbacks) {
- const bridgeRequestId = randomUUID();
- bridgeCallbacks.sendRequest(bridgeRequestId, SANDBOX_NETWORK_ACCESS_TOOL_NAME, {
- host: hostPattern.host
- }, randomUUID(), `Allow network connection to ${hostPattern.host}?`);
- const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => {
- unsubscribe();
- const allow = response.behavior === 'allow';
- // Resolve ALL pending requests for the same host, not just
- // this one — mirrors the local dialog handler pattern.
- setSandboxPermissionRequestQueue(queue => {
- queue.filter(item => item.hostPattern.host === hostPattern.host).forEach(item => item.resolvePromise(allow));
- return queue.filter(item => item.hostPattern.host !== hostPattern.host);
- });
- // Clean up all sibling bridge subscriptions for this host
- // (other concurrent same-host requests) before deleting.
- const siblingCleanups = sandboxBridgeCleanupRef.current.get(hostPattern.host);
- if (siblingCleanups) {
- for (const fn of siblingCleanups) {
- fn();
- }
- sandboxBridgeCleanupRef.current.delete(hostPattern.host);
- }
- });
-
- // Register cleanup so the local dialog handler can cancel
- // the remote prompt and unsubscribe when the local user
- // responds first.
- const cleanup = () => {
- unsubscribe();
- bridgeCallbacks.cancelRequest(bridgeRequestId);
- };
- const existing = sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? [];
- existing.push(cleanup);
- sandboxBridgeCleanupRef.current.set(hostPattern.host, existing);
+ // Normal flow for non-workers: show local UI and optionally race
+ // against the REPL bridge (Remote Control) if connected.
+ return new Promise(resolveShouldAllowHost => {
+ let resolved = false
+ function resolveOnce(allow: boolean): void {
+ if (resolved) return
+ resolved = true
+ resolveShouldAllowHost(allow)
}
- }
- });
- }, [setAppState, store]);
+
+ // Queue the local sandbox permission dialog
+ setSandboxPermissionRequestQueue(prev => [
+ ...prev,
+ {
+ hostPattern,
+ resolvePromise: resolveOnce,
+ },
+ ])
+
+ // When the REPL bridge is connected, also forward the sandbox
+ // permission request as a can_use_tool control_request so the
+ // remote user (e.g. on claude.ai) can approve it too.
+ if (feature('BRIDGE_MODE')) {
+ const bridgeCallbacks = store.getState().replBridgePermissionCallbacks
+ if (bridgeCallbacks) {
+ const bridgeRequestId = randomUUID()
+ bridgeCallbacks.sendRequest(
+ bridgeRequestId,
+ SANDBOX_NETWORK_ACCESS_TOOL_NAME,
+ { host: hostPattern.host },
+ randomUUID(),
+ `Allow network connection to ${hostPattern.host}?`,
+ )
+
+ const unsubscribe = bridgeCallbacks.onResponse(
+ bridgeRequestId,
+ response => {
+ unsubscribe()
+ const allow = response.behavior === 'allow'
+ // Resolve ALL pending requests for the same host, not just
+ // this one — mirrors the local dialog handler pattern.
+ setSandboxPermissionRequestQueue(queue => {
+ queue
+ .filter(item => item.hostPattern.host === hostPattern.host)
+ .forEach(item => item.resolvePromise(allow))
+ return queue.filter(
+ item => item.hostPattern.host !== hostPattern.host,
+ )
+ })
+ // Clean up all sibling bridge subscriptions for this host
+ // (other concurrent same-host requests) before deleting.
+ const siblingCleanups = sandboxBridgeCleanupRef.current.get(
+ hostPattern.host,
+ )
+ if (siblingCleanups) {
+ for (const fn of siblingCleanups) {
+ fn()
+ }
+ sandboxBridgeCleanupRef.current.delete(hostPattern.host)
+ }
+ },
+ )
+
+ // Register cleanup so the local dialog handler can cancel
+ // the remote prompt and unsubscribe when the local user
+ // responds first.
+ const cleanup = () => {
+ unsubscribe()
+ bridgeCallbacks.cancelRequest(bridgeRequestId)
+ }
+ const existing =
+ sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? []
+ existing.push(cleanup)
+ sandboxBridgeCleanupRef.current.set(hostPattern.host, existing)
+ }
+ }
+ })
+ },
+ [setAppState, store],
+ )
// #34044: if user explicitly set sandbox.enabled=true but deps are missing,
// isSandboxingEnabled() returns false silently. Surface the reason once at
@@ -2320,247 +3061,345 @@ export function REPL({
// reason goes to debug log; notification points to /sandbox for details.
// addNotification is stable (useCallback) so the effect fires once.
useEffect(() => {
- const reason = SandboxManager.getSandboxUnavailableReason();
- if (!reason) return;
+ const reason = SandboxManager.getSandboxUnavailableReason()
+ if (!reason) return
if (SandboxManager.isSandboxRequired()) {
- process.stderr.write(`\nError: sandbox required but unavailable: ${reason}\n` + ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`);
- gracefulShutdownSync(1, 'other');
- return;
+ process.stderr.write(
+ `\nError: sandbox required but unavailable: ${reason}\n` +
+ ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`,
+ )
+ gracefulShutdownSync(1, 'other')
+ return
}
- logForDebugging(`sandbox disabled: ${reason}`, {
- level: 'warn'
- });
+ logForDebugging(`sandbox disabled: ${reason}`, { level: 'warn' })
addNotification({
key: 'sandbox-unavailable',
- jsx: <>
+ jsx: (
+ <>
sandbox disabled
· /sandbox
- >,
- priority: 'medium'
- });
- }, [addNotification]);
+ >
+ ),
+ priority: 'medium',
+ })
+ }, [addNotification])
+
if (SandboxManager.isSandboxingEnabled()) {
// If sandboxing is enabled (setting.sandbox is defined, initialise the manager)
SandboxManager.initialize(sandboxAskCallback).catch(err => {
// Initialization/validation failed - display error and exit
- process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`);
- gracefulShutdownSync(1, 'other');
- });
+ process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`)
+ gracefulShutdownSync(1, 'other')
+ })
}
- const setToolPermissionContext = useCallback((context: ToolPermissionContext, options?: {
- preserveMode?: boolean;
- }) => {
- setAppState(prev => ({
- ...prev,
- toolPermissionContext: {
- ...context,
- // Preserve the coordinator's mode only when explicitly requested.
- // Workers' getAppState() returns a transformed context with mode
- // 'acceptEdits' that must not leak into the coordinator's actual
- // state via permission-rule updates — those call sites pass
- // { preserveMode: true }. User-initiated mode changes (e.g.,
- // selecting "allow all edits") must NOT be overridden.
- mode: options?.preserveMode ? prev.toolPermissionContext.mode : context.mode
- }
- }));
- // When permission context changes, recheck all queued items
- // This handles the case where approving item1 with "don't ask again"
- // should auto-approve other queued items that now match the updated rules
- setImmediate(setToolUseConfirmQueue => {
- // Use setToolUseConfirmQueue callback to get current queue state
- // instead of capturing it in the closure, to avoid stale closure issues
- setToolUseConfirmQueue(currentQueue => {
- currentQueue.forEach(item => {
- void item.recheckPermission();
- });
- return currentQueue;
- });
- }, setToolUseConfirmQueue);
- }, [setAppState, setToolUseConfirmQueue]);
+ const setToolPermissionContext = useCallback(
+ (context: ToolPermissionContext, options?: { preserveMode?: boolean }) => {
+ setAppState(prev => ({
+ ...prev,
+ toolPermissionContext: {
+ ...context,
+ // Preserve the coordinator's mode only when explicitly requested.
+ // Workers' getAppState() returns a transformed context with mode
+ // 'acceptEdits' that must not leak into the coordinator's actual
+ // state via permission-rule updates — those call sites pass
+ // { preserveMode: true }. User-initiated mode changes (e.g.,
+ // selecting "allow all edits") must NOT be overridden.
+ mode: options?.preserveMode
+ ? prev.toolPermissionContext.mode
+ : context.mode,
+ },
+ }))
+
+ // When permission context changes, recheck all queued items
+ // This handles the case where approving item1 with "don't ask again"
+ // should auto-approve other queued items that now match the updated rules
+ setImmediate(setToolUseConfirmQueue => {
+ // Use setToolUseConfirmQueue callback to get current queue state
+ // instead of capturing it in the closure, to avoid stale closure issues
+ setToolUseConfirmQueue(currentQueue => {
+ currentQueue.forEach(item => {
+ void item.recheckPermission()
+ })
+ return currentQueue
+ })
+ }, setToolUseConfirmQueue)
+ },
+ [setAppState, setToolUseConfirmQueue],
+ )
// Register the leader's setToolPermissionContext for in-process teammates
useEffect(() => {
- registerLeaderSetToolPermissionContext(setToolPermissionContext);
- return () => unregisterLeaderSetToolPermissionContext();
- }, [setToolPermissionContext]);
- const canUseTool = useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext);
- const requestPrompt = useCallback((title: string, toolInputSummary?: string | null) => (request: PromptRequest): Promise => new Promise((resolve, reject) => {
- setPromptQueue(prev => [...prev, {
- request,
- title,
- toolInputSummary,
- resolve,
- reject
- }]);
- }), []);
- const getToolUseContext = useCallback((messages: MessageType[], newMessages: MessageType[], abortController: AbortController, mainLoopModel: string): ProcessUserInputContext => {
- // Read mutable values fresh from the store rather than closure-capturing
- // useAppState() snapshots. Same values today (closure is refreshed by the
- // render between turns); decouples freshness from React's render cycle for
- // a future headless conversation loop. Same pattern refreshTools() uses.
- const s = store.getState();
+ registerLeaderSetToolPermissionContext(setToolPermissionContext)
+ return () => unregisterLeaderSetToolPermissionContext()
+ }, [setToolPermissionContext])
- // Compute tools fresh from store.getState() rather than the closure-
- // captured `tools`. useManageMCPConnections populates appState.mcp
- // async as servers connect — the store may have newer MCP state than
- // the closure captured at render time. Also doubles as refreshTools()
- // for mid-query tool list updates.
- const computeTools = () => {
- const state = store.getState();
- const assembled = assembleToolPool(state.toolPermissionContext, state.mcp.tools);
- const merged = mergeAndFilterTools(combinedInitialTools, assembled, state.toolPermissionContext.mode);
- if (!mainThreadAgentDefinition) return merged;
- return resolveAgentTools(mainThreadAgentDefinition, merged, false, true).resolvedTools;
- };
- return {
- abortController,
- options: {
- commands,
- tools: computeTools(),
- debug,
- verbose: s.verbose,
- mainLoopModel,
- thinkingConfig: s.thinkingEnabled !== false ? thinkingConfig : {
- type: 'disabled'
+ const canUseTool = useCanUseTool(
+ setToolUseConfirmQueue,
+ setToolPermissionContext,
+ )
+
+ const requestPrompt = useCallback(
+ (title: string, toolInputSummary?: string | null) =>
+ (request: PromptRequest): Promise =>
+ new Promise((resolve, reject) => {
+ setPromptQueue(prev => [
+ ...prev,
+ { request, title, toolInputSummary, resolve, reject },
+ ])
+ }),
+ [],
+ )
+
+ const getToolUseContext = useCallback(
+ (
+ messages: MessageType[],
+ newMessages: MessageType[],
+ abortController: AbortController,
+ mainLoopModel: string,
+ ): ProcessUserInputContext => {
+ // Read mutable values fresh from the store rather than closure-capturing
+ // useAppState() snapshots. Same values today (closure is refreshed by the
+ // render between turns); decouples freshness from React's render cycle for
+ // a future headless conversation loop. Same pattern refreshTools() uses.
+ const s = store.getState()
+
+ // Compute tools fresh from store.getState() rather than the closure-
+ // captured `tools`. useManageMCPConnections populates appState.mcp
+ // async as servers connect — the store may have newer MCP state than
+ // the closure captured at render time. Also doubles as refreshTools()
+ // for mid-query tool list updates.
+ const computeTools = () => {
+ const state = store.getState()
+ const assembled = assembleToolPool(
+ state.toolPermissionContext,
+ state.mcp.tools,
+ )
+ const merged = mergeAndFilterTools(
+ combinedInitialTools,
+ assembled,
+ state.toolPermissionContext.mode,
+ )
+ if (!mainThreadAgentDefinition) return merged
+ return resolveAgentTools(mainThreadAgentDefinition, merged, false, true)
+ .resolvedTools
+ }
+
+ return {
+ abortController,
+ options: {
+ commands,
+ tools: computeTools(),
+ debug,
+ verbose: s.verbose,
+ mainLoopModel,
+ thinkingConfig:
+ s.thinkingEnabled !== false ? thinkingConfig : { type: 'disabled' },
+ // Merge fresh from store rather than closing over useMergedClients'
+ // memoized output. initialMcpClients is a prop (session-constant).
+ mcpClients: mergeClients(initialMcpClients, s.mcp.clients),
+ mcpResources: s.mcp.resources,
+ ideInstallationStatus: ideInstallationStatus,
+ isNonInteractiveSession: false,
+ dynamicMcpConfig,
+ theme,
+ agentDefinitions: allowedAgentTypes
+ ? { ...s.agentDefinitions, allowedAgentTypes }
+ : s.agentDefinitions,
+ customSystemPrompt,
+ appendSystemPrompt,
+ refreshTools: computeTools,
},
- // Merge fresh from store rather than closing over useMergedClients'
- // memoized output. initialMcpClients is a prop (session-constant).
- mcpClients: mergeClients(initialMcpClients, s.mcp.clients),
- mcpResources: s.mcp.resources,
- ideInstallationStatus: ideInstallationStatus,
- isNonInteractiveSession: false,
- dynamicMcpConfig,
- theme,
- agentDefinitions: allowedAgentTypes ? {
- ...s.agentDefinitions,
- allowedAgentTypes
- } : s.agentDefinitions,
- customSystemPrompt,
- appendSystemPrompt,
- refreshTools: computeTools
- },
- getAppState: () => store.getState(),
+ getAppState: () => store.getState(),
+ setAppState,
+ messages,
+ setMessages,
+ updateFileHistoryState(
+ updater: (prev: FileHistoryState) => FileHistoryState,
+ ) {
+ // Perf: skip the setState when the updater returns the same reference
+ // (e.g. fileHistoryTrackEdit returns `state` when the file is already
+ // tracked). Otherwise every no-op call would notify all store listeners.
+ setAppState(prev => {
+ const updated = updater(prev.fileHistory)
+ if (updated === prev.fileHistory) return prev
+ return { ...prev, fileHistory: updated }
+ })
+ },
+ updateAttributionState(
+ updater: (prev: AttributionState) => AttributionState,
+ ) {
+ setAppState(prev => {
+ const updated = updater(prev.attribution)
+ if (updated === prev.attribution) return prev
+ return { ...prev, attribution: updated }
+ })
+ },
+ openMessageSelector: () => {
+ if (!disabled) {
+ setIsMessageSelectorVisible(true)
+ }
+ },
+ onChangeAPIKey: reverify,
+ readFileState: readFileState.current,
+ setToolJSX,
+ addNotification,
+ appendSystemMessage: msg => setMessages(prev => [...prev, msg]),
+ sendOSNotification: opts => {
+ void sendNotification(opts, terminal)
+ },
+ onChangeDynamicMcpConfig,
+ onInstallIDEExtension: setIDEToInstallExtension,
+ nestedMemoryAttachmentTriggers: new Set(),
+ loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current,
+ dynamicSkillDirTriggers: new Set(),
+ discoveredSkillNames: discoveredSkillNamesRef.current,
+ setResponseLength,
+ pushApiMetricsEntry:
+ process.env.USER_TYPE === 'ant'
+ ? (ttftMs: number) => {
+ const now = Date.now()
+ const baseline = responseLengthRef.current
+ apiMetricsRef.current.push({
+ ttftMs,
+ firstTokenTime: now,
+ lastTokenTime: now,
+ responseLengthBaseline: baseline,
+ endResponseLength: baseline,
+ })
+ }
+ : undefined,
+ setStreamMode,
+ onCompactProgress: event => {
+ switch (event.type) {
+ case 'hooks_start':
+ setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER')
+ setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER')
+ setSpinnerMessage(
+ event.hookType === 'pre_compact'
+ ? 'Running PreCompact hooks\u2026'
+ : event.hookType === 'post_compact'
+ ? 'Running PostCompact hooks\u2026'
+ : 'Running SessionStart hooks\u2026',
+ )
+ break
+ case 'compact_start':
+ setSpinnerMessage('Compacting conversation')
+ break
+ case 'compact_end':
+ setSpinnerMessage(null)
+ setSpinnerColor(null)
+ setSpinnerShimmerColor(null)
+ break
+ }
+ },
+ setInProgressToolUseIDs,
+ setHasInterruptibleToolInProgress: (v: boolean) => {
+ hasInterruptibleToolInProgressRef.current = v
+ },
+ resume,
+ setConversationId,
+ requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined,
+ contentReplacementState: contentReplacementStateRef.current,
+ }
+ },
+ [
+ commands,
+ combinedInitialTools,
+ mainThreadAgentDefinition,
+ debug,
+ initialMcpClients,
+ ideInstallationStatus,
+ dynamicMcpConfig,
+ theme,
+ allowedAgentTypes,
+ store,
setAppState,
- messages,
- setMessages,
- updateFileHistoryState(updater: (prev: FileHistoryState) => FileHistoryState) {
- // Perf: skip the setState when the updater returns the same reference
- // (e.g. fileHistoryTrackEdit returns `state` when the file is already
- // tracked). Otherwise every no-op call would notify all store listeners.
- setAppState(prev => {
- const updated = updater(prev.fileHistory);
- if (updated === prev.fileHistory) return prev;
- return {
- ...prev,
- fileHistory: updated
- };
- });
- },
- updateAttributionState(updater: (prev: AttributionState) => AttributionState) {
- setAppState(prev => {
- const updated = updater(prev.attribution);
- if (updated === prev.attribution) return prev;
- return {
- ...prev,
- attribution: updated
- };
- });
- },
- openMessageSelector: () => {
- if (!disabled) {
- setIsMessageSelectorVisible(true);
- }
- },
- onChangeAPIKey: reverify,
- readFileState: readFileState.current,
- setToolJSX,
+ reverify,
addNotification,
- appendSystemMessage: msg => setMessages(prev => [...prev, msg]),
- sendOSNotification: opts => {
- void sendNotification(opts, terminal);
- },
+ setMessages,
onChangeDynamicMcpConfig,
- onInstallIDEExtension: setIDEToInstallExtension,
- nestedMemoryAttachmentTriggers: new Set(),
- loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current,
- dynamicSkillDirTriggers: new Set(),
- discoveredSkillNames: discoveredSkillNamesRef.current,
- setResponseLength,
- pushApiMetricsEntry: (process.env.USER_TYPE) === 'ant' ? (ttftMs: number) => {
- const now = Date.now();
- const baseline = responseLengthRef.current;
- apiMetricsRef.current.push({
- ttftMs,
- firstTokenTime: now,
- lastTokenTime: now,
- responseLengthBaseline: baseline,
- endResponseLength: baseline
- });
- } : undefined,
- setStreamMode,
- onCompactProgress: event => {
- switch (event.type) {
- case 'hooks_start':
- setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER');
- setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER');
- setSpinnerMessage(event.hookType === 'pre_compact' ? 'Running PreCompact hooks\u2026' : event.hookType === 'post_compact' ? 'Running PostCompact hooks\u2026' : 'Running SessionStart hooks\u2026');
- break;
- case 'compact_start':
- setSpinnerMessage('Compacting conversation');
- break;
- case 'compact_end':
- setSpinnerMessage(null);
- setSpinnerColor(null);
- setSpinnerShimmerColor(null);
- break;
- }
- },
- setInProgressToolUseIDs,
- setHasInterruptibleToolInProgress: (v: boolean) => {
- hasInterruptibleToolInProgressRef.current = v;
- },
resume,
+ requestPrompt,
+ disabled,
+ customSystemPrompt,
+ appendSystemPrompt,
setConversationId,
- requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined,
- contentReplacementState: contentReplacementStateRef.current
- };
- }, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId]);
+ ],
+ )
// Session backgrounding (Ctrl+B to background/foreground)
const handleBackgroundQuery = useCallback(() => {
// Stop the foreground query so the background one takes over
- abortController?.abort('background');
+ abortController?.abort('background')
// Aborting subagents may produce task-completed notifications.
// Clear task notifications so the queue processor doesn't immediately
// start a new foreground query; forward them to the background session.
- const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification');
+ const removedNotifications = removeByFilter(
+ cmd => cmd.mode === 'task-notification',
+ )
+
void (async () => {
- const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel);
- const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]);
+ const toolUseContext = getToolUseContext(
+ messagesRef.current,
+ [],
+ new AbortController(),
+ mainLoopModel,
+ )
+
+ const [defaultSystemPrompt, userContext, systemContext] =
+ await Promise.all([
+ getSystemPrompt(
+ toolUseContext.options.tools,
+ mainLoopModel,
+ Array.from(
+ toolPermissionContext.additionalWorkingDirectories.keys(),
+ ),
+ toolUseContext.options.mcpClients,
+ ),
+ getUserContext(),
+ getSystemContext(),
+ ])
+
const systemPrompt = buildEffectiveSystemPrompt({
mainThreadAgentDefinition,
toolUseContext,
customSystemPrompt,
defaultSystemPrompt,
- appendSystemPrompt
- });
- toolUseContext.renderedSystemPrompt = systemPrompt;
- const notificationAttachments = await getQueuedCommandAttachments(removedNotifications).catch(() => []);
- const notificationMessages = notificationAttachments.map(createAttachmentMessage);
+ appendSystemPrompt,
+ })
+ toolUseContext.renderedSystemPrompt = systemPrompt
+
+ const notificationAttachments = await getQueuedCommandAttachments(
+ removedNotifications,
+ ).catch(() => [])
+ const notificationMessages = notificationAttachments.map(
+ createAttachmentMessage,
+ )
// Deduplicate: if the query loop already yielded a notification into
// messagesRef before we removed it from the queue, skip duplicates.
// We use prompt text for dedup because source_uuid is not set on
// task-notification QueuedCommands (enqueuePendingNotification callers
// don't pass uuid), so it would always be undefined.
- const existingPrompts = new Set();
+ const existingPrompts = new Set()
for (const m of messagesRef.current) {
- if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') {
- existingPrompts.add(m.attachment.prompt);
+ if (
+ m.type === 'attachment' &&
+ m.attachment.type === 'queued_command' &&
+ m.attachment.commandMode === 'task-notification' &&
+ typeof m.attachment.prompt === 'string'
+ ) {
+ existingPrompts.add(m.attachment.prompt)
}
}
- const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt)));
+ const uniqueNotifications = notificationMessages.filter(
+ m =>
+ m.attachment.type === 'queued_command' &&
+ (typeof m.attachment.prompt !== 'string' ||
+ !existingPrompts.has(m.attachment.prompt)),
+ )
+
startBackgroundSession({
messages: [...messagesRef.current, ...uniqueNotifications],
queryParams: {
@@ -2569,485 +3408,705 @@ export function REPL({
systemContext,
canUseTool,
toolUseContext,
- querySource: getQuerySourceForREPL()
+ querySource: getQuerySourceForREPL(),
},
description: terminalTitle,
setAppState,
- agentDefinition: mainThreadAgentDefinition
- });
- })();
- }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState]);
- const {
- handleBackgroundSession
- } = useSessionBackgrounding({
+ agentDefinition: mainThreadAgentDefinition,
+ })
+ })()
+ }, [
+ abortController,
+ mainLoopModel,
+ toolPermissionContext,
+ mainThreadAgentDefinition,
+ getToolUseContext,
+ customSystemPrompt,
+ appendSystemPrompt,
+ canUseTool,
+ setAppState,
+ ])
+
+ const { handleBackgroundSession } = useSessionBackgrounding({
setMessages,
setIsLoading: setIsExternalLoading,
resetLoadingState,
setAbortController,
- onBackgroundQuery: handleBackgroundQuery
- });
- const onQueryEvent = useCallback((event: Parameters[0]) => {
- handleMessageFromStream(event, newMessage => {
- if (isCompactBoundaryMessage(newMessage)) {
- // Fullscreen: keep pre-compact messages for scrollback. query.ts
- // slices at the boundary for API calls, Messages.tsx skips the
- // boundary filter in fullscreen, and useLogMessages treats this
- // as an incremental append (first uuid unchanged). Cap at one
- // compact-interval of scrollback — normalizeMessages/applyGrouping
- // are O(n) per render, so drop everything before the previous
- // boundary to keep n bounded across multi-day sessions.
- if (isFullscreenEnvEnabled()) {
- setMessages(old => [...getMessagesAfterCompactBoundary(old, {
- includeSnipped: true
- }), newMessage]);
- } else {
- setMessages(() => [newMessage]);
- }
- // Bump conversationId so Messages.tsx row keys change and
- // stale memoized rows remount with post-compact content.
- setConversationId(randomUUID());
- // Compaction succeeded — clear the context-blocked flag so ticks resume
- if (feature('PROACTIVE') || feature('KAIROS')) {
- proactiveModule?.setContextBlocked(false);
- }
- } else if ((newMessage as MessageType).type === 'progress' && isEphemeralToolProgress(((newMessage as MessageType).data as { type: string }).type)) {
- // Replace the previous ephemeral progress tick for the same tool
- // call instead of appending. Sleep/Bash emit a tick per second and
- // only the last one is rendered; appending blows up the messages
- // array (13k+ observed) and the transcript (120MB of sleep_progress
- // lines). useLogMessages tracks length, so same-length replacement
- // also skips the transcript write.
- // agent_progress / hook_progress / skill_progress are NOT ephemeral
- // — each carries distinct state the UI needs (e.g. subagent tool
- // history). Replacing those leaves the AgentTool UI stuck at
- // "Initializing…" because it renders the full progress trail.
- setMessages(oldMessages => {
- const last = oldMessages.at(-1);
- if (last?.type === 'progress' && (last as MessageType).parentToolUseID === (newMessage as MessageType).parentToolUseID && ((last as MessageType).data as { type: string }).type === ((newMessage as MessageType).data as { type: string }).type) {
- const copy = oldMessages.slice();
- copy[copy.length - 1] = newMessage;
- return copy;
- }
- return [...oldMessages, newMessage];
- });
- } else {
- setMessages(oldMessages => [...oldMessages, newMessage]);
- }
- // Block ticks on API errors to prevent tick → error → tick
- // runaway loops (e.g., auth failure, rate limit, blocking limit).
- // Cleared on compact boundary (above) or successful response (below).
- if (feature('PROACTIVE') || feature('KAIROS')) {
- if (newMessage.type === 'assistant' && 'isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) {
- proactiveModule?.setContextBlocked(true);
- } else if (newMessage.type === 'assistant') {
- proactiveModule?.setContextBlocked(false);
- }
- }
- }, newContent => {
- // setResponseLength handles updating both responseLengthRef (for
- // spinner animation) and apiMetricsRef (endResponseLength/lastTokenTime
- // for OTPS). No separate metrics update needed here.
- setResponseLength(length => length + newContent.length);
- }, setStreamMode, setStreamingToolUses, tombstonedMessage => {
- setMessages(oldMessages => oldMessages.filter(m => m !== tombstonedMessage));
- void removeTranscriptMessage(tombstonedMessage.uuid);
- }, setStreamingThinking, metrics => {
- const now = Date.now();
- const baseline = responseLengthRef.current;
- apiMetricsRef.current.push({
- ...metrics,
- firstTokenTime: now,
- lastTokenTime: now,
- responseLengthBaseline: baseline,
- endResponseLength: baseline
- });
- }, onStreamingText);
- }, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]);
- const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, effort?: EffortValue) => {
- // Prepare IDE integration for new prompt. Read mcpClients fresh from
- // store — useManageMCPConnections may have populated it since the
- // render that captured this closure (same pattern as computeTools).
- if (shouldQuery) {
- const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients);
- void diagnosticTracker.handleQueryStart(freshClients);
- const ideClient = getConnectedIdeClient(freshClients);
- if (ideClient) {
- void closeOpenDiffs(ideClient);
- }
- }
+ onBackgroundQuery: handleBackgroundQuery,
+ })
- // Mark onboarding as complete when any user message is sent to Claude
- void maybeMarkProjectOnboardingComplete();
-
- // Extract a session title from the first real user message. One-shot
- // via ref (was tengu_birch_mist experiment: first-message-only to save
- // Haiku calls). The ref replaces the old `messages.length <= 1` check,
- // which was broken by SessionStart hook messages (prepended via
- // useDeferredHookMessages) and attachment messages (appended by
- // processTextPrompt) — both pushed length past 1 on turn one, so the
- // title silently fell through to the "Claude Code" default.
- if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
- const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta);
- const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message.content as string | ContentBlockParam[]) : null;
- // Skip synthetic breadcrumbs — slash-command output, prompt-skill
- // expansions (/commit → ), local-command headers
- // (/help → ), and bash-mode (!cmd → ).
- // None of these are the user's topic; wait for real prose.
- if (text && !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) && !text.startsWith(`<${COMMAND_MESSAGE_TAG}>`) && !text.startsWith(`<${COMMAND_NAME_TAG}>`) && !text.startsWith(`<${BASH_INPUT_TAG}>`)) {
- haikuTitleAttemptedRef.current = true;
- void generateSessionTitle(text, new AbortController().signal).then(title => {
- if (title) setHaikuTitle(title);else haikuTitleAttemptedRef.current = false;
- }, () => {
- haikuTitleAttemptedRef.current = false;
- });
- }
- }
-
- // Apply slash-command-scoped allowedTools (from skill frontmatter) to the
- // store once per turn. This also covers the reset: the next non-skill turn
- // passes [] and clears it. Must run before the !shouldQuery gate: forked
- // commands (executeForkedSlashCommand) return shouldQuery=false, and
- // createGetAppStateWithAllowedTools in forkedAgent.ts reads this field, so
- // stale skill tools would otherwise leak into forked agent permissions.
- // Previously this write was hidden inside getToolUseContext's getAppState
- // (~85 calls/turn); hoisting it here makes getAppState a pure read and stops
- // ephemeral contexts (permission dialog, BackgroundTasksDialog) from
- // accidentally clearing it mid-turn.
- store.setState(prev => {
- const cur = prev.toolPermissionContext.alwaysAllowRules.command;
- if (cur === additionalAllowedTools || cur?.length === additionalAllowedTools.length && cur.every((v, i) => v === additionalAllowedTools[i])) {
- return prev;
- }
- return {
- ...prev,
- toolPermissionContext: {
- ...prev.toolPermissionContext,
- alwaysAllowRules: {
- ...prev.toolPermissionContext.alwaysAllowRules,
- command: additionalAllowedTools
- }
- }
- };
- });
-
- // The last message is an assistant message if the user input was a bash command,
- // or if the user input was an invalid slash command.
- if (!shouldQuery) {
- // Manual /compact sets messages directly (shouldQuery=false) bypassing
- // handleMessageFromStream. Clear context-blocked if a compact boundary
- // is present so proactive ticks resume after compaction.
- if (newMessages.some(isCompactBoundaryMessage)) {
- // Bump conversationId so Messages.tsx row keys change and
- // stale memoized rows remount with post-compact content.
- setConversationId(randomUUID());
- if (feature('PROACTIVE') || feature('KAIROS')) {
- proactiveModule?.setContextBlocked(false);
- }
- }
- resetLoadingState();
- setAbortController(null);
- return;
- }
- const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam);
- // getToolUseContext reads tools/mcpClients fresh from store.getState()
- // (via computeTools/mergeClients). Use those rather than the closure-
- // captured `tools`/`mcpClients` — useManageMCPConnections may have
- // flushed new MCP state between the render that captured this closure
- // and now. Turn 1 via processInitialMessage is the main beneficiary.
- const {
- tools: freshTools,
- mcpClients: freshMcpClients
- } = toolUseContext.options;
-
- // Scope the skill's effort override to this turn's context only —
- // wrapping getAppState keeps the override out of the global store so
- // background agents and UI subscribers (Spinner, LogoV2) never see it.
- if (effort !== undefined) {
- const previousGetAppState = toolUseContext.getAppState;
- toolUseContext.getAppState = () => ({
- ...previousGetAppState(),
- effortValue: effort
- });
- }
- queryCheckpoint('query_context_loading_start');
- const [,, defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
- // IMPORTANT: do this after setMessages() above, to avoid UI jank
- checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
- // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
- feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]);
- const userContext = {
- ...baseUserContext,
- ...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined),
- ...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current ? {
- terminalFocus: 'The terminal is unfocused \u2014 the user is not actively watching.'
- } : {})
- };
- queryCheckpoint('query_context_loading_end');
- const systemPrompt = buildEffectiveSystemPrompt({
- mainThreadAgentDefinition,
- toolUseContext,
- customSystemPrompt,
- defaultSystemPrompt,
- appendSystemPrompt
- });
- toolUseContext.renderedSystemPrompt = systemPrompt;
- queryCheckpoint('query_query_start');
- resetTurnHookDuration();
- resetTurnToolDuration();
- resetTurnClassifierDuration();
- for await (const event of query({
- messages: messagesIncludingNewMessages,
- systemPrompt,
- userContext,
- systemContext,
- canUseTool,
- toolUseContext,
- querySource: getQuerySourceForREPL()
- })) {
- onQueryEvent(event);
- }
- if (feature('BUDDY')) {
- triggerCompanionReaction(messagesRef.current, reaction =>
- setAppState(prev => prev.companionReaction === reaction ? prev : {
- ...prev,
- companionReaction: reaction as string | undefined,
- })
- );
- }
- queryCheckpoint('query_end');
-
- // Capture ant-only API metrics before resetLoadingState clears the ref.
- // For multi-request turns (tool use loops), compute P50 across all requests.
- if ((process.env.USER_TYPE) === 'ant' && apiMetricsRef.current.length > 0) {
- const entries = apiMetricsRef.current;
- const ttfts = entries.map(e => e.ttftMs);
- // Compute per-request OTPS using only active streaming time and
- // streaming-only content. endResponseLength tracks content added by
- // streaming deltas only, excluding subagent/compaction inflation.
- const otpsValues = entries.map(e => {
- const delta = Math.round((e.endResponseLength - e.responseLengthBaseline) / 4);
- const samplingMs = e.lastTokenTime - e.firstTokenTime;
- return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0;
- });
- const isMultiRequest = entries.length > 1;
- const hookMs = getTurnHookDurationMs();
- const hookCount = getTurnHookCount();
- const toolMs = getTurnToolDurationMs();
- const toolCount = getTurnToolCount();
- const classifierMs = getTurnClassifierDurationMs();
- const classifierCount = getTurnClassifierCount();
- const turnMs = Date.now() - loadingStartTimeRef.current;
- setMessages(prev => [...prev, createApiMetricsMessage({
- ttftMs: isMultiRequest ? median(ttfts) : ttfts[0]!,
- otps: isMultiRequest ? median(otpsValues) : otpsValues[0]!,
- isP50: isMultiRequest,
- hookDurationMs: hookMs > 0 ? hookMs : undefined,
- hookCount: hookCount > 0 ? hookCount : undefined,
- turnDurationMs: turnMs > 0 ? turnMs : undefined,
- toolDurationMs: toolMs > 0 ? toolMs : undefined,
- toolCount: toolCount > 0 ? toolCount : undefined,
- classifierDurationMs: classifierMs > 0 ? classifierMs : undefined,
- classifierCount: classifierCount > 0 ? classifierCount : undefined,
- configWriteCount: getGlobalConfigWriteCount()
- })]);
- }
- resetLoadingState();
-
- // Log query profiling report if enabled
- logQueryProfileReport();
-
- // Signal that a query turn has completed successfully
- await onTurnComplete?.(messagesRef.current);
- }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled]);
- const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise, input?: string, effort?: EffortValue): Promise => {
- // If this is a teammate, mark them as active when starting a turn
- if (isAgentSwarmsEnabled()) {
- const teamName = getTeamName();
- const agentName = getAgentName();
- if (teamName && agentName) {
- // Fire and forget - turn starts immediately, write happens in background
- void setMemberActive(teamName, agentName, true);
- }
- }
-
- // Concurrent guard via state machine. tryStart() atomically checks
- // and transitions idle→running, returning the generation number.
- // Returns null if already running — no separate check-then-set.
- const thisGeneration = queryGuard.tryStart();
- if (thisGeneration === null) {
- logEvent('tengu_concurrent_onquery_detected', {});
-
- // Extract and enqueue user message text, skipping meta messages
- // (e.g. expanded skill content, tick prompts) that should not be
- // replayed as user-visible text.
- newMessages.filter((m): m is UserMessage => m.type === 'user' && !m.isMeta).map(_ => getContentText(_.message.content as string | ContentBlockParam[])).filter(_ => _ !== null).forEach((msg, i) => {
- enqueue({
- value: msg,
- mode: 'prompt'
- });
- if (i === 0) {
- logEvent('tengu_concurrent_onquery_enqueued', {});
- }
- });
- return;
- }
- try {
- // isLoading is derived from queryGuard — tryStart() above already
- // transitioned dispatching→running, so no setter call needed here.
- resetTimingRefs();
- setMessages(oldMessages => [...oldMessages, ...newMessages]);
- responseLengthRef.current = 0;
- if (feature('TOKEN_BUDGET')) {
- const parsedBudget = input ? parseTokenBudget(input) : null;
- snapshotOutputTokensForTurn(parsedBudget ?? getCurrentTurnTokenBudget());
- }
- apiMetricsRef.current = [];
- setStreamingToolUses([]);
- setStreamingText(null);
-
- // messagesRef is updated synchronously by the setMessages wrapper
- // above, so it already includes newMessages from the append at the
- // top of this try block. No reconstruction needed, no waiting for
- // React's scheduler (previously cost 20-56ms per prompt; the 56ms
- // case was a GC pause caught during the await).
- const latestMessages = messagesRef.current;
- if (input) {
- await mrOnBeforeQuery(input, latestMessages, newMessages.length);
- }
-
- // Pass full conversation history to callback
- if (onBeforeQueryCallback && input) {
- const shouldProceed = await onBeforeQueryCallback(input, latestMessages);
- if (!shouldProceed) {
- return;
- }
- }
- await onQueryImpl(latestMessages, newMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, effort);
- } finally {
- // queryGuard.end() atomically checks generation and transitions
- // running→idle. Returns false if a newer query owns the guard
- // (cancel+resubmit race where the stale finally fires as a microtask).
- if (queryGuard.end(thisGeneration)) {
- setLastQueryCompletionTime(Date.now());
- skipIdleCheckRef.current = false;
- // Always reset loading state in finally - this ensures cleanup even
- // if onQueryImpl throws. onTurnComplete is called separately in
- // onQueryImpl only on successful completion.
- resetLoadingState();
- await mrOnTurnComplete(messagesRef.current, abortController.signal.aborted);
-
- // Notify bridge clients that the turn is complete so mobile apps
- // can stop the spark animation and show post-turn UI.
- sendBridgeResultRef.current();
-
- // Auto-hide tungsten panel content at turn end (ant-only), but keep
- // tungstenActiveSession set so the pill stays in the footer and the user
- // can reopen the panel. Background tmux tasks (e.g. /hunter) run for
- // minutes — wiping the session made the pill disappear entirely, forcing
- // the user to re-invoke Tmux just to peek. Skip on abort so the panel
- // stays open for inspection (matches the turn-duration guard below).
- if ((process.env.USER_TYPE) === 'ant' && !abortController.signal.aborted) {
- setAppState(prev => {
- if (prev.tungstenActiveSession === undefined) return prev;
- if (prev.tungstenPanelAutoHidden === true) return prev;
- return {
- ...prev,
- tungstenPanelAutoHidden: true
- };
- });
- }
-
- // Capture budget info before clearing (ant-only)
- let budgetInfo: {
- tokens: number;
- limit: number;
- nudges: number;
- } | undefined;
- if (feature('TOKEN_BUDGET')) {
- if (getCurrentTurnTokenBudget() !== null && getCurrentTurnTokenBudget()! > 0 && !abortController.signal.aborted) {
- budgetInfo = {
- tokens: getTurnOutputTokens(),
- limit: getCurrentTurnTokenBudget()!,
- nudges: getBudgetContinuationCount()
- };
- }
- snapshotOutputTokensForTurn(null);
- }
-
- // Add turn duration message for turns longer than 30s or with a budget
- // Skip if user aborted or if in loop mode (too noisy between ticks)
- // Defer if swarm teammates are still running (show when they finish)
- const turnDurationMs = Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
- if ((turnDurationMs > 30000 || budgetInfo !== undefined) && !abortController.signal.aborted && !proactiveActive) {
- const hasRunningSwarmAgents = getAllInProcessTeammateTasks(store.getState().tasks).some(t => t.status === 'running');
- if (hasRunningSwarmAgents) {
- // Only record start time on the first deferred turn
- if (swarmStartTimeRef.current === null) {
- swarmStartTimeRef.current = loadingStartTimeRef.current;
+ const onQueryEvent = useCallback(
+ (event: Parameters[0]) => {
+ handleMessageFromStream(
+ event,
+ newMessage => {
+ if (isCompactBoundaryMessage(newMessage)) {
+ // Fullscreen: keep pre-compact messages for scrollback. query.ts
+ // slices at the boundary for API calls, Messages.tsx skips the
+ // boundary filter in fullscreen, and useLogMessages treats this
+ // as an incremental append (first uuid unchanged). Cap at one
+ // compact-interval of scrollback — normalizeMessages/applyGrouping
+ // are O(n) per render, so drop everything before the previous
+ // boundary to keep n bounded across multi-day sessions.
+ if (isFullscreenEnvEnabled()) {
+ setMessages(old => [
+ ...getMessagesAfterCompactBoundary(old, {
+ includeSnipped: true,
+ }),
+ newMessage,
+ ])
+ } else {
+ setMessages(() => [newMessage])
}
- // Always update budget — later turns may carry the actual budget
- if (budgetInfo) {
- swarmBudgetInfoRef.current = budgetInfo;
+ // Bump conversationId so Messages.tsx row keys change and
+ // stale memoized rows remount with post-compact content.
+ setConversationId(randomUUID())
+ // Compaction succeeded — clear the context-blocked flag so ticks resume
+ if (feature('PROACTIVE') || feature('KAIROS')) {
+ proactiveModule?.setContextBlocked(false)
}
+ } else if (
+ newMessage.type === 'progress' &&
+ isEphemeralToolProgress(newMessage.data.type)
+ ) {
+ // Replace the previous ephemeral progress tick for the same tool
+ // call instead of appending. Sleep/Bash emit a tick per second and
+ // only the last one is rendered; appending blows up the messages
+ // array (13k+ observed) and the transcript (120MB of sleep_progress
+ // lines). useLogMessages tracks length, so same-length replacement
+ // also skips the transcript write.
+ // agent_progress / hook_progress / skill_progress are NOT ephemeral
+ // — each carries distinct state the UI needs (e.g. subagent tool
+ // history). Replacing those leaves the AgentTool UI stuck at
+ // "Initializing…" because it renders the full progress trail.
+ setMessages(oldMessages => {
+ const last = oldMessages.at(-1)
+ if (
+ last?.type === 'progress' &&
+ last.parentToolUseID === newMessage.parentToolUseID &&
+ last.data.type === newMessage.data.type
+ ) {
+ const copy = oldMessages.slice()
+ copy[copy.length - 1] = newMessage
+ return copy
+ }
+ return [...oldMessages, newMessage]
+ })
} else {
- setMessages(prev => [...prev, createTurnDurationMessage(turnDurationMs, budgetInfo, count(prev, isLoggableMessage))]);
+ setMessages(oldMessages => [...oldMessages, newMessage])
}
+ // Block ticks on API errors to prevent tick → error → tick
+ // runaway loops (e.g., auth failure, rate limit, blocking limit).
+ // Cleared on compact boundary (above) or successful response (below).
+ if (feature('PROACTIVE') || feature('KAIROS')) {
+ if (
+ newMessage.type === 'assistant' &&
+ 'isApiErrorMessage' in newMessage &&
+ newMessage.isApiErrorMessage
+ ) {
+ proactiveModule?.setContextBlocked(true)
+ } else if (newMessage.type === 'assistant') {
+ proactiveModule?.setContextBlocked(false)
+ }
+ }
+ },
+ newContent => {
+ // setResponseLength handles updating both responseLengthRef (for
+ // spinner animation) and apiMetricsRef (endResponseLength/lastTokenTime
+ // for OTPS). No separate metrics update needed here.
+ setResponseLength(length => length + newContent.length)
+ },
+ setStreamMode,
+ setStreamingToolUses,
+ tombstonedMessage => {
+ setMessages(oldMessages =>
+ oldMessages.filter(m => m !== tombstonedMessage),
+ )
+ void removeTranscriptMessage(tombstonedMessage.uuid)
+ },
+ setStreamingThinking,
+ metrics => {
+ const now = Date.now()
+ const baseline = responseLengthRef.current
+ apiMetricsRef.current.push({
+ ...metrics,
+ firstTokenTime: now,
+ lastTokenTime: now,
+ responseLengthBaseline: baseline,
+ endResponseLength: baseline,
+ })
+ },
+ onStreamingText,
+ )
+ },
+ [
+ setMessages,
+ setResponseLength,
+ setStreamMode,
+ setStreamingToolUses,
+ setStreamingThinking,
+ onStreamingText,
+ ],
+ )
+
+ const onQueryImpl = useCallback(
+ async (
+ messagesIncludingNewMessages: MessageType[],
+ newMessages: MessageType[],
+ abortController: AbortController,
+ shouldQuery: boolean,
+ additionalAllowedTools: string[],
+ mainLoopModelParam: string,
+ effort?: EffortValue,
+ ) => {
+ // Prepare IDE integration for new prompt. Read mcpClients fresh from
+ // store — useManageMCPConnections may have populated it since the
+ // render that captured this closure (same pattern as computeTools).
+ if (shouldQuery) {
+ const freshClients = mergeClients(
+ initialMcpClients,
+ store.getState().mcp.clients,
+ )
+ void diagnosticTracker.handleQueryStart(freshClients)
+ const ideClient = getConnectedIdeClient(freshClients)
+ if (ideClient) {
+ void closeOpenDiffs(ideClient)
}
- // Clear the controller so CancelRequestHandler's canCancelRunningTask
- // reads false at the idle prompt. Without this, the stale non-aborted
- // controller makes ctrl+c fire onCancel() (aborting nothing) instead of
- // propagating to the double-press exit flow.
- setAbortController(null);
}
- // Auto-restore: if the user interrupted before any meaningful response
- // arrived, rewind the conversation and restore their prompt — same as
- // opening the message selector and picking the last message.
- // This runs OUTSIDE the queryGuard.end() check because onCancel calls
- // forceEnd(), which bumps the generation so end() returns false above.
- // Guards: reason === 'user-cancel' (onCancel/Esc; programmatic aborts
- // use 'background'/'interrupt' and must not rewind — note abort() with
- // no args sets reason to a DOMException, not undefined), !isActive (no
- // newer query started — cancel+resubmit race), empty input (don't
- // clobber text typed during loading), no queued commands (user queued
- // B while A was loading → they've moved on, don't restore A; also
- // avoids removeLastFromHistory removing B's entry instead of A's),
- // not viewing a teammate (messagesRef is the main conversation — the
- // old Up-arrow quick-restore had this guard, preserve it).
- if (abortController.signal.reason === 'user-cancel' && !queryGuard.isActive && inputValueRef.current === '' && getCommandQueueLength() === 0 && !store.getState().viewingAgentTaskId) {
- const msgs = messagesRef.current;
- const lastUserMsg = msgs.findLast(selectableUserMessagesFilter);
- if (lastUserMsg) {
- const idx = msgs.lastIndexOf(lastUserMsg);
- if (messagesAfterAreOnlySynthetic(msgs, idx)) {
- // The submit is being undone — undo its history entry too,
- // otherwise Up-arrow shows the restored text twice.
- removeLastFromHistory();
- restoreMessageSyncRef.current(lastUserMsg);
+ // Mark onboarding as complete when any user message is sent to Claude
+ void maybeMarkProjectOnboardingComplete()
+
+ // Extract a session title from the first real user message. One-shot
+ // via ref (was tengu_birch_mist experiment: first-message-only to save
+ // Haiku calls). The ref replaces the old `messages.length <= 1` check,
+ // which was broken by SessionStart hook messages (prepended via
+ // useDeferredHookMessages) and attachment messages (appended by
+ // processTextPrompt) — both pushed length past 1 on turn one, so the
+ // title silently fell through to the "Claude Code" default.
+ if (
+ !titleDisabled &&
+ !sessionTitle &&
+ !agentTitle &&
+ !haikuTitleAttemptedRef.current
+ ) {
+ const firstUserMessage = newMessages.find(
+ m => m.type === 'user' && !m.isMeta,
+ )
+ const text =
+ firstUserMessage?.type === 'user'
+ ? getContentText(firstUserMessage.message.content)
+ : null
+ // Skip synthetic breadcrumbs — slash-command output, prompt-skill
+ // expansions (/commit → ), local-command headers
+ // (/help → ), and bash-mode (!cmd → ).
+ // None of these are the user's topic; wait for real prose.
+ if (
+ text &&
+ !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) &&
+ !text.startsWith(`<${COMMAND_MESSAGE_TAG}>`) &&
+ !text.startsWith(`<${COMMAND_NAME_TAG}>`) &&
+ !text.startsWith(`<${BASH_INPUT_TAG}>`)
+ ) {
+ haikuTitleAttemptedRef.current = true
+ void generateSessionTitle(text, new AbortController().signal).then(
+ title => {
+ if (title) setHaikuTitle(title)
+ else haikuTitleAttemptedRef.current = false
+ },
+ () => {
+ haikuTitleAttemptedRef.current = false
+ },
+ )
+ }
+ }
+
+ // Apply slash-command-scoped allowedTools (from skill frontmatter) to the
+ // store once per turn. This also covers the reset: the next non-skill turn
+ // passes [] and clears it. Must run before the !shouldQuery gate: forked
+ // commands (executeForkedSlashCommand) return shouldQuery=false, and
+ // createGetAppStateWithAllowedTools in forkedAgent.ts reads this field, so
+ // stale skill tools would otherwise leak into forked agent permissions.
+ // Previously this write was hidden inside getToolUseContext's getAppState
+ // (~85 calls/turn); hoisting it here makes getAppState a pure read and stops
+ // ephemeral contexts (permission dialog, BackgroundTasksDialog) from
+ // accidentally clearing it mid-turn.
+ store.setState(prev => {
+ const cur = prev.toolPermissionContext.alwaysAllowRules.command
+ if (
+ cur === additionalAllowedTools ||
+ (cur?.length === additionalAllowedTools.length &&
+ cur.every((v, i) => v === additionalAllowedTools[i]))
+ ) {
+ return prev
+ }
+ return {
+ ...prev,
+ toolPermissionContext: {
+ ...prev.toolPermissionContext,
+ alwaysAllowRules: {
+ ...prev.toolPermissionContext.alwaysAllowRules,
+ command: additionalAllowedTools,
+ },
+ },
+ }
+ })
+
+ // The last message is an assistant message if the user input was a bash command,
+ // or if the user input was an invalid slash command.
+ if (!shouldQuery) {
+ // Manual /compact sets messages directly (shouldQuery=false) bypassing
+ // handleMessageFromStream. Clear context-blocked if a compact boundary
+ // is present so proactive ticks resume after compaction.
+ if (newMessages.some(isCompactBoundaryMessage)) {
+ // Bump conversationId so Messages.tsx row keys change and
+ // stale memoized rows remount with post-compact content.
+ setConversationId(randomUUID())
+ if (feature('PROACTIVE') || feature('KAIROS')) {
+ proactiveModule?.setContextBlocked(false)
+ }
+ }
+ resetLoadingState()
+ setAbortController(null)
+ return
+ }
+
+ const toolUseContext = getToolUseContext(
+ messagesIncludingNewMessages,
+ newMessages,
+ abortController,
+ mainLoopModelParam,
+ )
+ // getToolUseContext reads tools/mcpClients fresh from store.getState()
+ // (via computeTools/mergeClients). Use those rather than the closure-
+ // captured `tools`/`mcpClients` — useManageMCPConnections may have
+ // flushed new MCP state between the render that captured this closure
+ // and now. Turn 1 via processInitialMessage is the main beneficiary.
+ const { tools: freshTools, mcpClients: freshMcpClients } =
+ toolUseContext.options
+
+ // Scope the skill's effort override to this turn's context only —
+ // wrapping getAppState keeps the override out of the global store so
+ // background agents and UI subscribers (Spinner, LogoV2) never see it.
+ if (effort !== undefined) {
+ const previousGetAppState = toolUseContext.getAppState
+ toolUseContext.getAppState = () => ({
+ ...previousGetAppState(),
+ effortValue: effort,
+ })
+ }
+
+ queryCheckpoint('query_context_loading_start')
+ const [, , defaultSystemPrompt, baseUserContext, systemContext] =
+ await Promise.all([
+ // IMPORTANT: do this after setMessages() above, to avoid UI jank
+ checkAndDisableBypassPermissionsIfNeeded(
+ toolPermissionContext,
+ setAppState,
+ ),
+ // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
+ feature('TRANSCRIPT_CLASSIFIER')
+ ? checkAndDisableAutoModeIfNeeded(
+ toolPermissionContext,
+ setAppState,
+ store.getState().fastMode,
+ )
+ : undefined,
+ getSystemPrompt(
+ freshTools,
+ mainLoopModelParam,
+ Array.from(
+ toolPermissionContext.additionalWorkingDirectories.keys(),
+ ),
+ freshMcpClients,
+ ),
+ getUserContext(),
+ getSystemContext(),
+ ])
+ const userContext = {
+ ...baseUserContext,
+ ...getCoordinatorUserContext(
+ freshMcpClients,
+ isScratchpadEnabled() ? getScratchpadDir() : undefined,
+ ),
+ ...((feature('PROACTIVE') || feature('KAIROS')) &&
+ proactiveModule?.isProactiveActive() &&
+ !terminalFocusRef.current
+ ? {
+ terminalFocus:
+ 'The terminal is unfocused \u2014 the user is not actively watching.',
+ }
+ : {}),
+ }
+ queryCheckpoint('query_context_loading_end')
+
+ const systemPrompt = buildEffectiveSystemPrompt({
+ mainThreadAgentDefinition,
+ toolUseContext,
+ customSystemPrompt,
+ defaultSystemPrompt,
+ appendSystemPrompt,
+ })
+ toolUseContext.renderedSystemPrompt = systemPrompt
+
+ queryCheckpoint('query_query_start')
+ resetTurnHookDuration()
+ resetTurnToolDuration()
+ resetTurnClassifierDuration()
+
+ for await (const event of query({
+ messages: messagesIncludingNewMessages,
+ systemPrompt,
+ userContext,
+ systemContext,
+ canUseTool,
+ toolUseContext,
+ querySource: getQuerySourceForREPL(),
+ })) {
+ onQueryEvent(event)
+ }
+
+
+ if (feature('BUDDY')) {
+ void fireCompanionObserver(messagesRef.current, reaction =>
+ setAppState(prev =>
+ prev.companionReaction === reaction
+ ? prev
+ : { ...prev, companionReaction: reaction },
+ ),
+ )
+ }
+
+ queryCheckpoint('query_end')
+
+ // Capture ant-only API metrics before resetLoadingState clears the ref.
+ // For multi-request turns (tool use loops), compute P50 across all requests.
+ if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
+ const entries = apiMetricsRef.current
+
+ const ttfts = entries.map(e => e.ttftMs)
+ // Compute per-request OTPS using only active streaming time and
+ // streaming-only content. endResponseLength tracks content added by
+ // streaming deltas only, excluding subagent/compaction inflation.
+ const otpsValues = entries.map(e => {
+ const delta = Math.round(
+ (e.endResponseLength - e.responseLengthBaseline) / 4,
+ )
+ const samplingMs = e.lastTokenTime - e.firstTokenTime
+ return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0
+ })
+
+ const isMultiRequest = entries.length > 1
+ const hookMs = getTurnHookDurationMs()
+ const hookCount = getTurnHookCount()
+ const toolMs = getTurnToolDurationMs()
+ const toolCount = getTurnToolCount()
+ const classifierMs = getTurnClassifierDurationMs()
+ const classifierCount = getTurnClassifierCount()
+ const turnMs = Date.now() - loadingStartTimeRef.current
+ setMessages(prev => [
+ ...prev,
+ createApiMetricsMessage({
+ ttftMs: isMultiRequest ? median(ttfts) : ttfts[0]!,
+ otps: isMultiRequest ? median(otpsValues) : otpsValues[0]!,
+ isP50: isMultiRequest,
+ hookDurationMs: hookMs > 0 ? hookMs : undefined,
+ hookCount: hookCount > 0 ? hookCount : undefined,
+ turnDurationMs: turnMs > 0 ? turnMs : undefined,
+ toolDurationMs: toolMs > 0 ? toolMs : undefined,
+ toolCount: toolCount > 0 ? toolCount : undefined,
+ classifierDurationMs: classifierMs > 0 ? classifierMs : undefined,
+ classifierCount: classifierCount > 0 ? classifierCount : undefined,
+ configWriteCount: getGlobalConfigWriteCount(),
+ }),
+ ])
+ }
+
+ resetLoadingState()
+
+ // Log query profiling report if enabled
+ logQueryProfileReport()
+
+ // Signal that a query turn has completed successfully
+ await onTurnComplete?.(messagesRef.current)
+ },
+ [
+ initialMcpClients,
+ resetLoadingState,
+ getToolUseContext,
+ toolPermissionContext,
+ setAppState,
+ customSystemPrompt,
+ onTurnComplete,
+ appendSystemPrompt,
+ canUseTool,
+ mainThreadAgentDefinition,
+ onQueryEvent,
+ sessionTitle,
+ titleDisabled,
+ ],
+ )
+
+ const onQuery = useCallback(
+ async (
+ newMessages: MessageType[],
+ abortController: AbortController,
+ shouldQuery: boolean,
+ additionalAllowedTools: string[],
+ mainLoopModelParam: string,
+ onBeforeQueryCallback?: (
+ input: string,
+ newMessages: MessageType[],
+ ) => Promise,
+ input?: string,
+ effort?: EffortValue,
+ ): Promise => {
+ // If this is a teammate, mark them as active when starting a turn
+ if (isAgentSwarmsEnabled()) {
+ const teamName = getTeamName()
+ const agentName = getAgentName()
+ if (teamName && agentName) {
+ // Fire and forget - turn starts immediately, write happens in background
+ void setMemberActive(teamName, agentName, true)
+ }
+ }
+
+ // Concurrent guard via state machine. tryStart() atomically checks
+ // and transitions idle→running, returning the generation number.
+ // Returns null if already running — no separate check-then-set.
+ const thisGeneration = queryGuard.tryStart()
+ if (thisGeneration === null) {
+ logEvent('tengu_concurrent_onquery_detected', {})
+
+ // Extract and enqueue user message text, skipping meta messages
+ // (e.g. expanded skill content, tick prompts) that should not be
+ // replayed as user-visible text.
+ newMessages
+ .filter((m): m is UserMessage => m.type === 'user' && !m.isMeta)
+ .map(_ => getContentText(_.message.content))
+ .filter(_ => _ !== null)
+ .forEach((msg, i) => {
+ enqueue({ value: msg, mode: 'prompt' })
+ if (i === 0) {
+ logEvent('tengu_concurrent_onquery_enqueued', {})
+ }
+ })
+ return
+ }
+
+ try {
+ // isLoading is derived from queryGuard — tryStart() above already
+ // transitioned dispatching→running, so no setter call needed here.
+ resetTimingRefs()
+ setMessages(oldMessages => [...oldMessages, ...newMessages])
+ responseLengthRef.current = 0
+ if (feature('TOKEN_BUDGET')) {
+ const parsedBudget = input ? parseTokenBudget(input) : null
+ snapshotOutputTokensForTurn(
+ parsedBudget ?? getCurrentTurnTokenBudget(),
+ )
+ }
+ apiMetricsRef.current = []
+ setStreamingToolUses([])
+ setStreamingText(null)
+
+ // messagesRef is updated synchronously by the setMessages wrapper
+ // above, so it already includes newMessages from the append at the
+ // top of this try block. No reconstruction needed, no waiting for
+ // React's scheduler (previously cost 20-56ms per prompt; the 56ms
+ // case was a GC pause caught during the await).
+ const latestMessages = messagesRef.current
+
+ if (input) {
+ await mrOnBeforeQuery(input, latestMessages, newMessages.length)
+ }
+
+ // Pass full conversation history to callback
+ if (onBeforeQueryCallback && input) {
+ const shouldProceed = await onBeforeQueryCallback(
+ input,
+ latestMessages,
+ )
+ if (!shouldProceed) {
+ return
+ }
+ }
+
+ await onQueryImpl(
+ latestMessages,
+ newMessages,
+ abortController,
+ shouldQuery,
+ additionalAllowedTools,
+ mainLoopModelParam,
+ effort,
+ )
+ } finally {
+ // queryGuard.end() atomically checks generation and transitions
+ // running→idle. Returns false if a newer query owns the guard
+ // (cancel+resubmit race where the stale finally fires as a microtask).
+ if (queryGuard.end(thisGeneration)) {
+ setLastQueryCompletionTime(Date.now())
+ skipIdleCheckRef.current = false
+ // Always reset loading state in finally - this ensures cleanup even
+ // if onQueryImpl throws. onTurnComplete is called separately in
+ // onQueryImpl only on successful completion.
+ resetLoadingState()
+
+ await mrOnTurnComplete(
+ messagesRef.current,
+ abortController.signal.aborted,
+ )
+
+ // Notify bridge clients that the turn is complete so mobile apps
+ // can stop the spark animation and show post-turn UI.
+ sendBridgeResultRef.current()
+
+ // Auto-hide tungsten panel content at turn end (ant-only), but keep
+ // tungstenActiveSession set so the pill stays in the footer and the user
+ // can reopen the panel. Background tmux tasks (e.g. /hunter) run for
+ // minutes — wiping the session made the pill disappear entirely, forcing
+ // the user to re-invoke Tmux just to peek. Skip on abort so the panel
+ // stays open for inspection (matches the turn-duration guard below).
+ if (
+ process.env.USER_TYPE === 'ant' &&
+ !abortController.signal.aborted
+ ) {
+ setAppState(prev => {
+ if (prev.tungstenActiveSession === undefined) return prev
+ if (prev.tungstenPanelAutoHidden === true) return prev
+ return { ...prev, tungstenPanelAutoHidden: true }
+ })
+ }
+
+ // Capture budget info before clearing (ant-only)
+ let budgetInfo:
+ | { tokens: number; limit: number; nudges: number }
+ | undefined
+ if (feature('TOKEN_BUDGET')) {
+ if (
+ getCurrentTurnTokenBudget() !== null &&
+ getCurrentTurnTokenBudget()! > 0 &&
+ !abortController.signal.aborted
+ ) {
+ budgetInfo = {
+ tokens: getTurnOutputTokens(),
+ limit: getCurrentTurnTokenBudget()!,
+ nudges: getBudgetContinuationCount(),
+ }
+ }
+ snapshotOutputTokensForTurn(null)
+ }
+
+ // Add turn duration message for turns longer than 30s or with a budget
+ // Skip if user aborted or if in loop mode (too noisy between ticks)
+ // Defer if swarm teammates are still running (show when they finish)
+ const turnDurationMs =
+ Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current
+ if (
+ (turnDurationMs > 30000 || budgetInfo !== undefined) &&
+ !abortController.signal.aborted &&
+ !proactiveActive
+ ) {
+ const hasRunningSwarmAgents = getAllInProcessTeammateTasks(
+ store.getState().tasks,
+ ).some(t => t.status === 'running')
+ if (hasRunningSwarmAgents) {
+ // Only record start time on the first deferred turn
+ if (swarmStartTimeRef.current === null) {
+ swarmStartTimeRef.current = loadingStartTimeRef.current
+ }
+ // Always update budget — later turns may carry the actual budget
+ if (budgetInfo) {
+ swarmBudgetInfoRef.current = budgetInfo
+ }
+ } else {
+ setMessages(prev => [
+ ...prev,
+ createTurnDurationMessage(
+ turnDurationMs,
+ budgetInfo,
+ count(prev, isLoggableMessage),
+ ),
+ ])
+ }
+ }
+ // Clear the controller so CancelRequestHandler's canCancelRunningTask
+ // reads false at the idle prompt. Without this, the stale non-aborted
+ // controller makes ctrl+c fire onCancel() (aborting nothing) instead of
+ // propagating to the double-press exit flow.
+ setAbortController(null)
+ }
+
+ // Auto-restore: if the user interrupted before any meaningful response
+ // arrived, rewind the conversation and restore their prompt — same as
+ // opening the message selector and picking the last message.
+ // This runs OUTSIDE the queryGuard.end() check because onCancel calls
+ // forceEnd(), which bumps the generation so end() returns false above.
+ // Guards: reason === 'user-cancel' (onCancel/Esc; programmatic aborts
+ // use 'background'/'interrupt' and must not rewind — note abort() with
+ // no args sets reason to a DOMException, not undefined), !isActive (no
+ // newer query started — cancel+resubmit race), empty input (don't
+ // clobber text typed during loading), no queued commands (user queued
+ // B while A was loading → they've moved on, don't restore A; also
+ // avoids removeLastFromHistory removing B's entry instead of A's),
+ // not viewing a teammate (messagesRef is the main conversation — the
+ // old Up-arrow quick-restore had this guard, preserve it).
+ if (
+ abortController.signal.reason === 'user-cancel' &&
+ !queryGuard.isActive &&
+ inputValueRef.current === '' &&
+ getCommandQueueLength() === 0 &&
+ !store.getState().viewingAgentTaskId
+ ) {
+ const msgs = messagesRef.current
+ const lastUserMsg = msgs.findLast(selectableUserMessagesFilter)
+ if (lastUserMsg) {
+ const idx = msgs.lastIndexOf(lastUserMsg)
+ if (messagesAfterAreOnlySynthetic(msgs, idx)) {
+ // The submit is being undone — undo its history entry too,
+ // otherwise Up-arrow shows the restored text twice.
+ removeLastFromHistory()
+ restoreMessageSyncRef.current(lastUserMsg)
+ }
}
}
}
- }
- }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete]);
+ },
+ [
+ onQueryImpl,
+ setAppState,
+ resetLoadingState,
+ queryGuard,
+ mrOnBeforeQuery,
+ mrOnTurnComplete,
+ ],
+ )
// Handle initial message (from CLI args or plan mode exit with context clear)
// This effect runs when isLoading becomes false and there's a pending message
- const initialMessageRef = useRef(false);
+ const initialMessageRef = useRef(false)
useEffect(() => {
- const pending = initialMessage;
- if (!pending || isLoading || initialMessageRef.current) return;
+ const pending = initialMessage
+ if (!pending || isLoading || initialMessageRef.current) return
// Mark as processing to prevent re-entry
- initialMessageRef.current = true;
- async function processInitialMessage(initialMsg: NonNullable) {
+ initialMessageRef.current = true
+
+ async function processInitialMessage(
+ initialMsg: NonNullable,
+ ) {
// Clear context if requested (plan mode exit)
if (initialMsg.clearContext) {
// Preserve the plan slug before clearing context, so the new session
// can access the same plan file after regenerateSessionId()
- const oldPlanSlug = initialMsg.message.planContent ? getPlanSlug() : undefined;
- const {
- clearConversation
- } = await import('../commands/clear/conversation.js');
+ const oldPlanSlug = initialMsg.message.planContent
+ ? getPlanSlug()
+ : undefined
+
+ const { clearConversation } = await import(
+ '../commands/clear/conversation.js'
+ )
await clearConversation({
setMessages,
readFileState: readFileState.current,
@@ -3055,66 +4114,82 @@ export function REPL({
loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current,
getAppState: () => store.getState(),
setAppState,
- setConversationId
- });
- haikuTitleAttemptedRef.current = false;
- setHaikuTitle(undefined);
- bashTools.current.clear();
- bashToolsProcessedIdx.current = 0;
+ setConversationId,
+ })
+ haikuTitleAttemptedRef.current = false
+ setHaikuTitle(undefined)
+ bashTools.current.clear()
+ bashToolsProcessedIdx.current = 0
// Restore the plan slug for the new session so getPlan() finds the file
if (oldPlanSlug) {
- setPlanSlug(getSessionId(), oldPlanSlug);
+ setPlanSlug(getSessionId(), oldPlanSlug)
}
}
// Atomically: clear initial message, set permission mode and rules, and store plan for verification
- const shouldStorePlanForVerification = initialMsg.message.planContent && (process.env.USER_TYPE) === 'ant' && isEnvTruthy(undefined);
+ const shouldStorePlanForVerification =
+ initialMsg.message.planContent &&
+ process.env.USER_TYPE === 'ant' &&
+ isEnvTruthy(undefined)
+
setAppState(prev => {
// Build and apply permission updates (mode + allowedPrompts rules)
- let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates(prev.toolPermissionContext, buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts)) : prev.toolPermissionContext;
+ let updatedToolPermissionContext = initialMsg.mode
+ ? applyPermissionUpdates(
+ prev.toolPermissionContext,
+ buildPermissionUpdates(
+ initialMsg.mode,
+ initialMsg.allowedPrompts,
+ ),
+ )
+ : prev.toolPermissionContext
// For auto, override the mode (buildPermissionUpdates maps
// it to 'default' via toExternalPermissionMode) and strip dangerous rules
if (feature('TRANSCRIPT_CLASSIFIER') && initialMsg.mode === 'auto') {
updatedToolPermissionContext = stripDangerousPermissionsForAutoMode({
...updatedToolPermissionContext,
mode: 'auto',
- prePlanMode: undefined
- });
+ prePlanMode: undefined,
+ })
}
+
return {
...prev,
initialMessage: null,
toolPermissionContext: updatedToolPermissionContext,
...(shouldStorePlanForVerification && {
pendingPlanVerification: {
- plan: initialMsg.message.planContent as string,
+ plan: initialMsg.message.planContent!,
verificationStarted: false,
- verificationCompleted: false
- }
- })
- };
- });
+ verificationCompleted: false,
+ },
+ }),
+ }
+ })
// Create file history snapshot for code rewind
if (fileHistoryEnabled()) {
- void fileHistoryMakeSnapshot((updater: (prev: FileHistoryState) => FileHistoryState) => {
- setAppState(prev => ({
- ...prev,
- fileHistory: updater(prev.fileHistory)
- }));
- }, initialMsg.message.uuid);
+ void fileHistoryMakeSnapshot(
+ (updater: (prev: FileHistoryState) => FileHistoryState) => {
+ setAppState(prev => ({
+ ...prev,
+ fileHistory: updater(prev.fileHistory),
+ }))
+ },
+ initialMsg.message.uuid,
+ )
}
// Ensure SessionStart hook context is available before the first API
// call. onSubmit calls this internally but the onQuery path below
// bypasses onSubmit — hoist here so both paths see hook messages.
- await awaitPendingHooks();
+ await awaitPendingHooks()
// Route all initial prompts through onSubmit to ensure UserPromptSubmit hooks fire
// TODO: Simplify by always routing through onSubmit once it supports
// ContentBlockParam arrays (images) as input
- const content = initialMsg.message.message.content;
+ const content = initialMsg.message.message.content
// Route all string content through onSubmit to ensure hooks fire
// For complex content (images, etc.), fall back to direct onQuery
@@ -3124,690 +4199,884 @@ export function REPL({
void onSubmit(content, {
setCursorOffset: () => {},
clearBuffer: () => {},
- resetHistory: () => {}
- });
+ resetHistory: () => {},
+ })
} else {
// Plan messages or complex content (images, etc.) - send directly to model
// Plan messages use onQuery to preserve planContent metadata for rendering
// TODO: Once onSubmit supports ContentBlockParam arrays, remove this branch
- const newAbortController = createAbortController();
- setAbortController(newAbortController);
- void onQuery([initialMsg.message], newAbortController, true,
- // shouldQuery
- [],
- // additionalAllowedTools
- mainLoopModel);
+ const newAbortController = createAbortController()
+ setAbortController(newAbortController)
+
+ void onQuery(
+ [initialMsg.message],
+ newAbortController,
+ true, // shouldQuery
+ [], // additionalAllowedTools
+ mainLoopModel,
+ )
}
// Reset ref after a delay to allow new initial messages
- setTimeout(ref => {
- ref.current = false;
- }, 100, initialMessageRef);
- }
- void processInitialMessage(pending);
- }, [initialMessage, isLoading, setMessages, setAppState, onQuery, mainLoopModel, tools]);
- const onSubmit = useCallback(async (input: string, helpers: PromptInputHelpers, speculationAccept?: {
- state: ActiveSpeculationState;
- speculationSessionTimeSavedMs: number;
- setAppState: SetAppState;
- }, options?: {
- fromKeybinding?: boolean;
- }) => {
- // Re-pin scroll to bottom on submit so the user always sees the new
- // exchange (matches OpenCode's auto-scroll behavior).
- repinScroll();
-
- // Resume loop mode if paused
- if (feature('PROACTIVE') || feature('KAIROS')) {
- proactiveModule?.resumeProactive();
+ setTimeout(
+ ref => {
+ ref.current = false
+ },
+ 100,
+ initialMessageRef,
+ )
}
- // Handle immediate commands - these bypass the queue and execute right away
- // even while Claude is processing. Commands opt-in via `immediate: true`.
- // Commands triggered via keybindings are always treated as immediate.
- if (!speculationAccept && input.trim().startsWith('/')) {
- // Expand [Pasted text #N] refs so immediate commands (e.g. /btw) receive
- // the pasted content, not the placeholder. The non-immediate path gets
- // this expansion later in handlePromptSubmit.
- const trimmedInput = expandPastedTextRefs(input, pastedContents).trim();
- const spaceIndex = trimmedInput.indexOf(' ');
- const commandName = spaceIndex === -1 ? trimmedInput.slice(1) : trimmedInput.slice(1, spaceIndex);
- const commandArgs = spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim();
+ void processInitialMessage(pending)
+ }, [
+ initialMessage,
+ isLoading,
+ setMessages,
+ setAppState,
+ onQuery,
+ mainLoopModel,
+ tools,
+ ])
- // Find matching command - treat as immediate if:
- // 1. Command has `immediate: true`, OR
- // 2. Command was triggered via keybinding (fromKeybinding option)
- const matchingCommand = commands.find(cmd => isCommandEnabled(cmd) && (cmd.name === commandName || cmd.aliases?.includes(commandName) || getCommandName(cmd) === commandName));
- if (matchingCommand?.name === 'clear' && idleHintShownRef.current) {
- logEvent('tengu_idle_return_action', {
- action: 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- variant: idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- idleMinutes: Math.round((Date.now() - lastQueryCompletionTimeRef.current) / 60_000),
- messageCount: messagesRef.current.length,
- totalInputTokens: getTotalInputTokens()
- });
- idleHintShownRef.current = false;
+ const onSubmit = useCallback(
+ async (
+ input: string,
+ helpers: PromptInputHelpers,
+ speculationAccept?: {
+ state: ActiveSpeculationState
+ speculationSessionTimeSavedMs: number
+ setAppState: SetAppState
+ },
+ options?: { fromKeybinding?: boolean },
+ ) => {
+ // Re-pin scroll to bottom on submit so the user always sees the new
+ // exchange (matches OpenCode's auto-scroll behavior).
+ repinScroll()
+
+ // Resume loop mode if paused
+ if (feature('PROACTIVE') || feature('KAIROS')) {
+ proactiveModule?.resumeProactive()
}
- const shouldTreatAsImmediate = queryGuard.isActive && (matchingCommand?.immediate || options?.fromKeybinding);
- if (matchingCommand && shouldTreatAsImmediate && matchingCommand.type === 'local-jsx') {
- // Only clear input if the submitted text matches what's in the prompt.
- // When a command keybinding fires, input is "/" but the actual
- // input value is the user's existing text - don't clear it in that case.
- if (input.trim() === inputValueRef.current.trim()) {
- setInputValue('');
- helpers.setCursorOffset(0);
- helpers.clearBuffer();
- setPastedContents({});
- }
- const pastedTextRefs = parseReferences(input).filter(r => pastedContents[r.id]?.type === 'text');
- const pastedTextCount = pastedTextRefs.length;
- const pastedTextBytes = pastedTextRefs.reduce((sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), 0);
- logEvent('tengu_paste_text', {
- pastedTextCount,
- pastedTextBytes
- });
- logEvent('tengu_immediate_command_executed', {
- commandName: matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- fromKeybinding: options?.fromKeybinding ?? false
- });
- // Execute the command directly
- const executeImmediateCommand = async (): Promise => {
- let doneWasCalled = false;
- const onDone = (result?: string, doneOptions?: {
- display?: CommandResultDisplay;
- metaMessages?: string[];
- }): void => {
- doneWasCalled = true;
- setToolJSX({
- jsx: null,
- shouldHidePromptInput: false,
- clearLocalJSX: true
- });
- const newMessages: MessageType[] = [];
- if (result && doneOptions?.display !== 'skip') {
- addNotification({
- key: `immediate-${matchingCommand.name}`,
- text: result,
- priority: 'immediate'
- });
- // In fullscreen the command just showed as a centered modal
- // pane — the notification above is enough feedback. Adding
- // "❯ /config" + "⎿ dismissed" to the transcript is clutter
- // (those messages are type:system subtype:local_command —
- // user-visible but NOT sent to the model, so skipping them
- // doesn't change model context). Outside fullscreen the
- // transcript entry stays so scrollback shows what ran.
- if (!isFullscreenEnvEnabled()) {
- newMessages.push(createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)), createCommandInputMessage(`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}${LOCAL_COMMAND_STDOUT_TAG}>`));
+ // Handle immediate commands - these bypass the queue and execute right away
+ // even while Claude is processing. Commands opt-in via `immediate: true`.
+ // Commands triggered via keybindings are always treated as immediate.
+ if (!speculationAccept && input.trim().startsWith('/')) {
+ // Expand [Pasted text #N] refs so immediate commands (e.g. /btw) receive
+ // the pasted content, not the placeholder. The non-immediate path gets
+ // this expansion later in handlePromptSubmit.
+ const trimmedInput = expandPastedTextRefs(input, pastedContents).trim()
+ const spaceIndex = trimmedInput.indexOf(' ')
+ const commandName =
+ spaceIndex === -1
+ ? trimmedInput.slice(1)
+ : trimmedInput.slice(1, spaceIndex)
+ const commandArgs =
+ spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim()
+
+ // Find matching command - treat as immediate if:
+ // 1. Command has `immediate: true`, OR
+ // 2. Command was triggered via keybinding (fromKeybinding option)
+ const matchingCommand = commands.find(
+ cmd =>
+ isCommandEnabled(cmd) &&
+ (cmd.name === commandName ||
+ cmd.aliases?.includes(commandName) ||
+ getCommandName(cmd) === commandName),
+ )
+ if (matchingCommand?.name === 'clear' && idleHintShownRef.current) {
+ logEvent('tengu_idle_return_action', {
+ action:
+ 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ variant:
+ idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ idleMinutes: Math.round(
+ (Date.now() - lastQueryCompletionTimeRef.current) / 60_000,
+ ),
+ messageCount: messagesRef.current.length,
+ totalInputTokens: getTotalInputTokens(),
+ })
+ idleHintShownRef.current = false
+ }
+
+ const shouldTreatAsImmediate =
+ queryGuard.isActive &&
+ (matchingCommand?.immediate || options?.fromKeybinding)
+
+ if (
+ matchingCommand &&
+ shouldTreatAsImmediate &&
+ matchingCommand.type === 'local-jsx'
+ ) {
+ // Only clear input if the submitted text matches what's in the prompt.
+ // When a command keybinding fires, input is "/" but the actual
+ // input value is the user's existing text - don't clear it in that case.
+ if (input.trim() === inputValueRef.current.trim()) {
+ setInputValue('')
+ helpers.setCursorOffset(0)
+ helpers.clearBuffer()
+ setPastedContents({})
+ }
+
+ const pastedTextRefs = parseReferences(input).filter(
+ r => pastedContents[r.id]?.type === 'text',
+ )
+ const pastedTextCount = pastedTextRefs.length
+ const pastedTextBytes = pastedTextRefs.reduce(
+ (sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0),
+ 0,
+ )
+ logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes })
+ logEvent('tengu_immediate_command_executed', {
+ commandName:
+ matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ fromKeybinding: options?.fromKeybinding ?? false,
+ })
+
+ // Execute the command directly
+ const executeImmediateCommand = async (): Promise => {
+ let doneWasCalled = false
+ const onDone = (
+ result?: string,
+ doneOptions?: {
+ display?: CommandResultDisplay
+ metaMessages?: string[]
+ },
+ ): void => {
+ doneWasCalled = true
+ setToolJSX({
+ jsx: null,
+ shouldHidePromptInput: false,
+ clearLocalJSX: true,
+ })
+ const newMessages: MessageType[] = []
+ if (result && doneOptions?.display !== 'skip') {
+ addNotification({
+ key: `immediate-${matchingCommand.name}`,
+ text: result,
+ priority: 'immediate',
+ })
+ // In fullscreen the command just showed as a centered modal
+ // pane — the notification above is enough feedback. Adding
+ // "❯ /config" + "⎿ dismissed" to the transcript is clutter
+ // (those messages are type:system subtype:local_command —
+ // user-visible but NOT sent to the model, so skipping them
+ // doesn't change model context). Outside fullscreen the
+ // transcript entry stays so scrollback shows what ran.
+ if (!isFullscreenEnvEnabled()) {
+ newMessages.push(
+ createCommandInputMessage(
+ formatCommandInputTags(
+ getCommandName(matchingCommand),
+ commandArgs,
+ ),
+ ),
+ createCommandInputMessage(
+ `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}${LOCAL_COMMAND_STDOUT_TAG}>`,
+ ),
+ )
+ }
+ }
+ // Inject meta messages (model-visible, user-hidden) into the transcript
+ if (doneOptions?.metaMessages?.length) {
+ newMessages.push(
+ ...doneOptions.metaMessages.map(content =>
+ createUserMessage({ content, isMeta: true }),
+ ),
+ )
+ }
+ if (newMessages.length) {
+ setMessages(prev => [...prev, ...newMessages])
+ }
+ // Restore stashed prompt after local-jsx command completes.
+ // The normal stash restoration path (below) is skipped because
+ // local-jsx commands return early from onSubmit.
+ if (stashedPrompt !== undefined) {
+ setInputValue(stashedPrompt.text)
+ helpers.setCursorOffset(stashedPrompt.cursorOffset)
+ setPastedContents(stashedPrompt.pastedContents)
+ setStashedPrompt(undefined)
}
}
- // Inject meta messages (model-visible, user-hidden) into the transcript
- if (doneOptions?.metaMessages?.length) {
- newMessages.push(...doneOptions.metaMessages.map(content => createUserMessage({
- content,
- isMeta: true
- })));
- }
- if (newMessages.length) {
- setMessages(prev => [...prev, ...newMessages]);
- }
- // Restore stashed prompt after local-jsx command completes.
- // The normal stash restoration path (below) is skipped because
- // local-jsx commands return early from onSubmit.
- if (stashedPrompt !== undefined) {
- setInputValue(stashedPrompt.text);
- helpers.setCursorOffset(stashedPrompt.cursorOffset);
- setPastedContents(stashedPrompt.pastedContents);
- setStashedPrompt(undefined);
- }
- };
- // Build context for the command (reuses existing getToolUseContext).
- // Read messages via ref to keep onSubmit stable across message
- // updates — matches the pattern at L2384/L2400/L2662 and avoids
- // pinning stale REPL render scopes in downstream closures.
- const context = getToolUseContext(messagesRef.current, [], createAbortController(), mainLoopModel);
- const mod = await matchingCommand.load();
- const jsx = await mod.call(onDone, context, commandArgs);
+ // Build context for the command (reuses existing getToolUseContext).
+ // Read messages via ref to keep onSubmit stable across message
+ // updates — matches the pattern at L2384/L2400/L2662 and avoids
+ // pinning stale REPL render scopes in downstream closures.
+ const context = getToolUseContext(
+ messagesRef.current,
+ [],
+ createAbortController(),
+ mainLoopModel,
+ )
- // Skip if onDone already fired — prevents stuck isLocalJSXCommand
- // (see processSlashCommand.tsx local-jsx case for full mechanism).
- if (jsx && !doneWasCalled) {
- // shouldHidePromptInput: false keeps Notifications mounted
- // so the onDone result isn't lost
- setToolJSX({
- jsx,
- shouldHidePromptInput: false,
- isLocalJSXCommand: true
- });
+ const mod = await matchingCommand.load()
+ const jsx = await mod.call(onDone, context, commandArgs)
+
+ // Skip if onDone already fired — prevents stuck isLocalJSXCommand
+ // (see processSlashCommand.tsx local-jsx case for full mechanism).
+ if (jsx && !doneWasCalled) {
+ // shouldHidePromptInput: false keeps Notifications mounted
+ // so the onDone result isn't lost
+ setToolJSX({
+ jsx,
+ shouldHidePromptInput: false,
+ isLocalJSXCommand: true,
+ })
+ }
}
- };
- void executeImmediateCommand();
- return; // Always return early - don't add to history or queue
- }
- }
-
- // Remote mode: skip empty input early before any state mutations
- if (activeRemote.isRemoteMode && !input.trim()) {
- return;
- }
-
- // Idle-return: prompt returning users to start fresh when the
- // conversation is large and the cache is cold. tengu_willow_mode
- // controls treatment: "dialog" (blocking), "hint" (notification), "off".
- {
- const willowMode = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off');
- const idleThresholdMin = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75);
- const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000);
- if (willowMode !== 'off' && !getGlobalConfig().idleReturnDismissed && !skipIdleCheckRef.current && !speculationAccept && !input.trim().startsWith('/') && lastQueryCompletionTimeRef.current > 0 && getTotalInputTokens() >= tokenThreshold) {
- const idleMs = Date.now() - lastQueryCompletionTimeRef.current;
- const idleMinutes = idleMs / 60_000;
- if (idleMinutes >= idleThresholdMin && willowMode === 'dialog') {
- setIdleReturnPending({
- input,
- idleMinutes
- });
- setInputValue('');
- helpers.setCursorOffset(0);
- helpers.clearBuffer();
- return;
+ void executeImmediateCommand()
+ return // Always return early - don't add to history or queue
}
}
- }
- // Add to history for direct user submissions.
- // Queued command processing (executeQueuedInput) doesn't call onSubmit,
- // so notifications and already-queued user input won't be added to history here.
- // Skip history for keybinding-triggered commands (user didn't type the command).
- if (!options?.fromKeybinding) {
- addToHistory({
- display: speculationAccept ? input : prependModeCharacterToInput(input, inputMode),
- pastedContents: speculationAccept ? {} : pastedContents
- });
- // Add the just-submitted command to the front of the ghost-text
- // cache so it's suggested immediately (not after the 60s TTL).
- if (inputMode === 'bash') {
- prependToShellHistoryCache(input.trim());
+ // Remote mode: skip empty input early before any state mutations
+ if (activeRemote.isRemoteMode && !input.trim()) {
+ return
}
- }
- // Restore stash if present, but NOT for slash commands or when loading.
- // - Slash commands (especially interactive ones like /model, /context) hide
- // the prompt and show a picker UI. Restoring the stash during a command would
- // place the text in a hidden input, and the user would lose it by typing the
- // next command. Instead, preserve the stash so it survives across command runs.
- // - When loading, the submitted input will be queued and handlePromptSubmit
- // will clear the input field (onInputChange('')), which would clobber the
- // restored stash. Defer restoration to after handlePromptSubmit (below).
- // Remote mode is exempt: it sends via WebSocket and returns early without
- // calling handlePromptSubmit, so there's no clobbering risk — restore eagerly.
- // In both deferred cases, the stash is restored after await handlePromptSubmit.
- const isSlashCommand = !speculationAccept && input.trim().startsWith('/');
- // Submit runs "now" (not queued) when not already loading, or when
- // accepting speculation, or in remote mode (which sends via WS and
- // returns early without calling handlePromptSubmit).
- const submitsNow = !isLoading || speculationAccept || activeRemote.isRemoteMode;
- if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) {
- setInputValue(stashedPrompt.text);
- helpers.setCursorOffset(stashedPrompt.cursorOffset);
- setPastedContents(stashedPrompt.pastedContents);
- setStashedPrompt(undefined);
- } else if (submitsNow) {
+ // Idle-return: prompt returning users to start fresh when the
+ // conversation is large and the cache is cold. tengu_willow_mode
+ // controls treatment: "dialog" (blocking), "hint" (notification), "off".
+ {
+ const willowMode = getFeatureValue_CACHED_MAY_BE_STALE(
+ 'tengu_willow_mode',
+ 'off',
+ )
+ const idleThresholdMin = Number(
+ process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75,
+ )
+ const tokenThreshold = Number(
+ process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000,
+ )
+ if (
+ willowMode !== 'off' &&
+ !getGlobalConfig().idleReturnDismissed &&
+ !skipIdleCheckRef.current &&
+ !speculationAccept &&
+ !input.trim().startsWith('/') &&
+ lastQueryCompletionTimeRef.current > 0 &&
+ getTotalInputTokens() >= tokenThreshold
+ ) {
+ const idleMs = Date.now() - lastQueryCompletionTimeRef.current
+ const idleMinutes = idleMs / 60_000
+ if (idleMinutes >= idleThresholdMin && willowMode === 'dialog') {
+ setIdleReturnPending({ input, idleMinutes })
+ setInputValue('')
+ helpers.setCursorOffset(0)
+ helpers.clearBuffer()
+ return
+ }
+ }
+ }
+
+ // Add to history for direct user submissions.
+ // Queued command processing (executeQueuedInput) doesn't call onSubmit,
+ // so notifications and already-queued user input won't be added to history here.
+ // Skip history for keybinding-triggered commands (user didn't type the command).
if (!options?.fromKeybinding) {
- // Clear input when not loading or accepting speculation.
- // Preserve input for keybinding-triggered commands.
- setInputValue('');
- helpers.setCursorOffset(0);
- }
- setPastedContents({});
- }
- if (submitsNow) {
- setInputMode('prompt');
- setIDESelection(undefined);
- setSubmitCount(_ => _ + 1);
- helpers.clearBuffer();
- tipPickedThisTurnRef.current = false;
-
- // Show the placeholder in the same React batch as setInputValue('').
- // Skip for slash/bash (they have their own echo), speculation and remote
- // mode (both setMessages directly with no gap to bridge).
- if (!isSlashCommand && inputMode === 'prompt' && !speculationAccept && !activeRemote.isRemoteMode) {
- setUserInputOnProcessing(input);
- // showSpinner includes userInputOnProcessing, so the spinner appears
- // on this render. Reset timing refs now (before queryGuard.reserve()
- // would) so elapsed time doesn't read as Date.now() - 0. The
- // isQueryActive transition above does the same reset — idempotent.
- resetTimingRefs();
- }
-
- // Increment prompt count for attribution tracking and save snapshot
- // The snapshot persists promptCount so it survives compaction
- if (feature('COMMIT_ATTRIBUTION')) {
- setAppState(prev => ({
- ...prev,
- attribution: incrementPromptCount(prev.attribution, snapshot => {
- void recordAttributionSnapshot(snapshot).catch(error => {
- logForDebugging(`Attribution: Failed to save snapshot: ${error}`);
- });
- })
- }));
- }
- }
-
- // Handle speculation acceptance
- if (speculationAccept) {
- const {
- queryRequired
- } = await handleSpeculationAccept(speculationAccept.state, speculationAccept.speculationSessionTimeSavedMs, speculationAccept.setAppState, input, {
- setMessages,
- readFileState,
- cwd: getOriginalCwd()
- });
- if (queryRequired) {
- const newAbortController = createAbortController();
- setAbortController(newAbortController);
- void onQuery([], newAbortController, true, [], mainLoopModel);
- }
- return;
- }
-
- // Remote mode: send input via stream-json instead of local query.
- // Permission requests from the remote are bridged into toolUseConfirmQueue
- // and rendered using the standard PermissionRequest component.
- //
- // local-jsx slash commands (e.g. /agents, /config) render UI in THIS
- // process — they have no remote equivalent. Let those fall through to
- // handlePromptSubmit so they execute locally. Prompt commands and
- // plain text go to the remote.
- if (activeRemote.isRemoteMode && !(isSlashCommand && commands.find(c => {
- const name = input.trim().slice(1).split(/\s/)[0];
- return isCommandEnabled(c) && (c.name === name || c.aliases?.includes(name!) || getCommandName(c) === name);
- })?.type === 'local-jsx')) {
- // Build content blocks when there are pasted attachments (images)
- const pastedValues = Object.values(pastedContents);
- const imageContents = pastedValues.filter(c => c.type === 'image');
- const imagePasteIds = imageContents.length > 0 ? imageContents.map(c => c.id) : undefined;
- let messageContent: string | ContentBlockParam[] = input.trim();
- let remoteContent: RemoteMessageContent = input.trim();
- if (pastedValues.length > 0) {
- const contentBlocks: ContentBlockParam[] = [];
- const remoteBlocks: Array<{
- type: string;
- [key: string]: unknown;
- }> = [];
- const trimmedInput = input.trim();
- if (trimmedInput) {
- contentBlocks.push({
- type: 'text',
- text: trimmedInput
- });
- remoteBlocks.push({
- type: 'text',
- text: trimmedInput
- });
+ addToHistory({
+ display: speculationAccept
+ ? input
+ : prependModeCharacterToInput(input, inputMode),
+ pastedContents: speculationAccept ? {} : pastedContents,
+ })
+ // Add the just-submitted command to the front of the ghost-text
+ // cache so it's suggested immediately (not after the 60s TTL).
+ if (inputMode === 'bash') {
+ prependToShellHistoryCache(input.trim())
}
- for (const pasted of pastedValues) {
- if (pasted.type === 'image') {
- const source = {
- type: 'base64' as const,
- media_type: (pasted.mediaType ?? 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
- data: pasted.content
- };
- contentBlocks.push({
- type: 'image',
- source
- });
- remoteBlocks.push({
- type: 'image',
- source
- });
- } else {
- contentBlocks.push({
- type: 'text',
- text: pasted.content
- });
- remoteBlocks.push({
- type: 'text',
- text: pasted.content
- });
+ }
+
+ // Restore stash if present, but NOT for slash commands or when loading.
+ // - Slash commands (especially interactive ones like /model, /context) hide
+ // the prompt and show a picker UI. Restoring the stash during a command would
+ // place the text in a hidden input, and the user would lose it by typing the
+ // next command. Instead, preserve the stash so it survives across command runs.
+ // - When loading, the submitted input will be queued and handlePromptSubmit
+ // will clear the input field (onInputChange('')), which would clobber the
+ // restored stash. Defer restoration to after handlePromptSubmit (below).
+ // Remote mode is exempt: it sends via WebSocket and returns early without
+ // calling handlePromptSubmit, so there's no clobbering risk — restore eagerly.
+ // In both deferred cases, the stash is restored after await handlePromptSubmit.
+ const isSlashCommand = !speculationAccept && input.trim().startsWith('/')
+ // Submit runs "now" (not queued) when not already loading, or when
+ // accepting speculation, or in remote mode (which sends via WS and
+ // returns early without calling handlePromptSubmit).
+ const submitsNow =
+ !isLoading || speculationAccept || activeRemote.isRemoteMode
+ if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) {
+ setInputValue(stashedPrompt.text)
+ helpers.setCursorOffset(stashedPrompt.cursorOffset)
+ setPastedContents(stashedPrompt.pastedContents)
+ setStashedPrompt(undefined)
+ } else if (submitsNow) {
+ if (!options?.fromKeybinding) {
+ // Clear input when not loading or accepting speculation.
+ // Preserve input for keybinding-triggered commands.
+ setInputValue('')
+ helpers.setCursorOffset(0)
+ }
+ setPastedContents({})
+ }
+
+ if (submitsNow) {
+ setInputMode('prompt')
+ setIDESelection(undefined)
+ setSubmitCount(_ => _ + 1)
+ helpers.clearBuffer()
+ tipPickedThisTurnRef.current = false
+
+ // Show the placeholder in the same React batch as setInputValue('').
+ // Skip for slash/bash (they have their own echo), speculation and remote
+ // mode (both setMessages directly with no gap to bridge).
+ if (
+ !isSlashCommand &&
+ inputMode === 'prompt' &&
+ !speculationAccept &&
+ !activeRemote.isRemoteMode
+ ) {
+ setUserInputOnProcessing(input)
+ // showSpinner includes userInputOnProcessing, so the spinner appears
+ // on this render. Reset timing refs now (before queryGuard.reserve()
+ // would) so elapsed time doesn't read as Date.now() - 0. The
+ // isQueryActive transition above does the same reset — idempotent.
+ resetTimingRefs()
+ }
+
+ // Increment prompt count for attribution tracking and save snapshot
+ // The snapshot persists promptCount so it survives compaction
+ if (feature('COMMIT_ATTRIBUTION')) {
+ setAppState(prev => ({
+ ...prev,
+ attribution: incrementPromptCount(prev.attribution, snapshot => {
+ void recordAttributionSnapshot(snapshot).catch(error => {
+ logForDebugging(
+ `Attribution: Failed to save snapshot: ${error}`,
+ )
+ })
+ }),
+ }))
+ }
+ }
+
+ // Handle speculation acceptance
+ if (speculationAccept) {
+ const { queryRequired } = await handleSpeculationAccept(
+ speculationAccept.state,
+ speculationAccept.speculationSessionTimeSavedMs,
+ speculationAccept.setAppState,
+ input,
+ {
+ setMessages,
+ readFileState,
+ cwd: getOriginalCwd(),
+ },
+ )
+ if (queryRequired) {
+ const newAbortController = createAbortController()
+ setAbortController(newAbortController)
+ void onQuery([], newAbortController, true, [], mainLoopModel)
+ }
+ return
+ }
+
+ // Remote mode: send input via stream-json instead of local query.
+ // Permission requests from the remote are bridged into toolUseConfirmQueue
+ // and rendered using the standard PermissionRequest component.
+ //
+ // local-jsx slash commands (e.g. /agents, /config) render UI in THIS
+ // process — they have no remote equivalent. Let those fall through to
+ // handlePromptSubmit so they execute locally. Prompt commands and
+ // plain text go to the remote.
+ if (
+ activeRemote.isRemoteMode &&
+ !(
+ isSlashCommand &&
+ commands.find(c => {
+ const name = input.trim().slice(1).split(/\s/)[0]
+ return (
+ isCommandEnabled(c) &&
+ (c.name === name ||
+ c.aliases?.includes(name!) ||
+ getCommandName(c) === name)
+ )
+ })?.type === 'local-jsx'
+ )
+ ) {
+ // Build content blocks when there are pasted attachments (images)
+ const pastedValues = Object.values(pastedContents)
+ const imageContents = pastedValues.filter(c => c.type === 'image')
+ const imagePasteIds =
+ imageContents.length > 0 ? imageContents.map(c => c.id) : undefined
+
+ let messageContent: string | ContentBlockParam[] = input.trim()
+ let remoteContent: RemoteMessageContent = input.trim()
+ if (pastedValues.length > 0) {
+ const contentBlocks: ContentBlockParam[] = []
+ const remoteBlocks: Array<{ type: string; [key: string]: unknown }> =
+ []
+
+ const trimmedInput = input.trim()
+ if (trimmedInput) {
+ contentBlocks.push({ type: 'text', text: trimmedInput })
+ remoteBlocks.push({ type: 'text', text: trimmedInput })
}
+
+ for (const pasted of pastedValues) {
+ if (pasted.type === 'image') {
+ const source = {
+ type: 'base64' as const,
+ media_type: (pasted.mediaType ?? 'image/png') as
+ | 'image/jpeg'
+ | 'image/png'
+ | 'image/gif'
+ | 'image/webp',
+ data: pasted.content,
+ }
+ contentBlocks.push({ type: 'image', source })
+ remoteBlocks.push({ type: 'image', source })
+ } else {
+ contentBlocks.push({ type: 'text', text: pasted.content })
+ remoteBlocks.push({ type: 'text', text: pasted.content })
+ }
+ }
+
+ messageContent = contentBlocks
+ remoteContent = remoteBlocks
}
- messageContent = contentBlocks;
- remoteContent = remoteBlocks;
+
+ // Create and add user message to UI
+ // Note: empty input already handled by early return above
+ const userMessage = createUserMessage({
+ content: messageContent,
+ imagePasteIds,
+ })
+ setMessages(prev => [...prev, userMessage])
+
+ // Send to remote session
+ await activeRemote.sendMessage(remoteContent, {
+ uuid: userMessage.uuid,
+ })
+ return
}
- // Create and add user message to UI
- // Note: empty input already handled by early return above
- const userMessage = createUserMessage({
- content: messageContent,
- imagePasteIds
- });
- setMessages(prev => [...prev, userMessage]);
+ // Ensure SessionStart hook context is available before the first API call.
+ await awaitPendingHooks()
- // Send to remote session
- await activeRemote.sendMessage(remoteContent, {
- uuid: userMessage.uuid
- });
- return;
- }
+ await handlePromptSubmit({
+ input,
+ helpers,
+ queryGuard,
+ isExternalLoading,
+ mode: inputMode,
+ commands,
+ onInputChange: setInputValue,
+ setPastedContents,
+ setToolJSX,
+ getToolUseContext,
+ messages: messagesRef.current,
+ mainLoopModel,
+ pastedContents,
+ ideSelection,
+ setUserInputOnProcessing,
+ setAbortController,
+ abortController,
+ onQuery,
+ setAppState,
+ querySource: getQuerySourceForREPL(),
+ onBeforeQuery,
+ canUseTool,
+ addNotification,
+ setMessages,
+ // Read via ref so streamMode can be dropped from onSubmit deps —
+ // handlePromptSubmit only uses it for debug log + telemetry event.
+ streamMode: streamModeRef.current,
+ hasInterruptibleToolInProgress:
+ hasInterruptibleToolInProgressRef.current,
+ })
- // Ensure SessionStart hook context is available before the first API call.
- await awaitPendingHooks();
- await handlePromptSubmit({
- input,
- helpers,
+ // Restore stash that was deferred above. Two cases:
+ // - Slash command: handlePromptSubmit awaited the full command execution
+ // (including interactive pickers). Restoring now places the stash back in
+ // the visible input.
+ // - Loading (queued): handlePromptSubmit enqueued + cleared input, then
+ // returned quickly. Restoring now places the stash back after the clear.
+ if ((isSlashCommand || isLoading) && stashedPrompt !== undefined) {
+ setInputValue(stashedPrompt.text)
+ helpers.setCursorOffset(stashedPrompt.cursorOffset)
+ setPastedContents(stashedPrompt.pastedContents)
+ setStashedPrompt(undefined)
+ }
+ },
+ [
queryGuard,
+ // isLoading is read at the !isLoading checks above for input-clearing
+ // and submitCount gating. It's derived from isQueryActive || isExternalLoading,
+ // so including it here ensures the closure captures the fresh value.
+ isLoading,
isExternalLoading,
- mode: inputMode,
+ inputMode,
commands,
- onInputChange: setInputValue,
+ setInputValue,
+ setInputMode,
setPastedContents,
+ setSubmitCount,
+ setIDESelection,
setToolJSX,
getToolUseContext,
- messages: messagesRef.current,
+ // messages is read via messagesRef.current inside the callback to
+ // keep onSubmit stable across message updates (see L2384/L2400/L2662).
+ // Without this, each setMessages call (~30× per turn) recreates
+ // onSubmit, pinning the REPL render scope (1776B) + that render's
+ // messages array in downstream closures (PromptInput, handleAutoRunIssue).
+ // Heap analysis showed ~9 REPL scopes and ~15 messages array versions
+ // accumulating after #20174/#20175, all traced to this dep.
mainLoopModel,
pastedContents,
ideSelection,
setUserInputOnProcessing,
setAbortController,
- abortController,
+ addNotification,
onQuery,
+ stashedPrompt,
+ setStashedPrompt,
setAppState,
- querySource: getQuerySourceForREPL(),
onBeforeQuery,
canUseTool,
- addNotification,
+ remoteSession,
setMessages,
- // Read via ref so streamMode can be dropped from onSubmit deps —
- // handlePromptSubmit only uses it for debug log + telemetry event.
- streamMode: streamModeRef.current,
- hasInterruptibleToolInProgress: hasInterruptibleToolInProgressRef.current
- });
-
- // Restore stash that was deferred above. Two cases:
- // - Slash command: handlePromptSubmit awaited the full command execution
- // (including interactive pickers). Restoring now places the stash back in
- // the visible input.
- // - Loading (queued): handlePromptSubmit enqueued + cleared input, then
- // returned quickly. Restoring now places the stash back after the clear.
- if ((isSlashCommand || isLoading) && stashedPrompt !== undefined) {
- setInputValue(stashedPrompt.text);
- helpers.setCursorOffset(stashedPrompt.cursorOffset);
- setPastedContents(stashedPrompt.pastedContents);
- setStashedPrompt(undefined);
- }
- }, [queryGuard,
- // isLoading is read at the !isLoading checks above for input-clearing
- // and submitCount gating. It's derived from isQueryActive || isExternalLoading,
- // so including it here ensures the closure captures the fresh value.
- isLoading, isExternalLoading, inputMode, commands, setInputValue, setInputMode, setPastedContents, setSubmitCount, setIDESelection, setToolJSX, getToolUseContext,
- // messages is read via messagesRef.current inside the callback to
- // keep onSubmit stable across message updates (see L2384/L2400/L2662).
- // Without this, each setMessages call (~30× per turn) recreates
- // onSubmit, pinning the REPL render scope (1776B) + that render's
- // messages array in downstream closures (PromptInput, handleAutoRunIssue).
- // Heap analysis showed ~9 REPL scopes and ~15 messages array versions
- // accumulating after #20174/#20175, all traced to this dep.
- mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, addNotification, onQuery, stashedPrompt, setStashedPrompt, setAppState, onBeforeQuery, canUseTool, remoteSession, setMessages, awaitPendingHooks, repinScroll]);
+ awaitPendingHooks,
+ repinScroll,
+ ],
+ )
// Callback for when user submits input while viewing a teammate's transcript
- const onAgentSubmit = useCallback(async (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => {
- if (isLocalAgentTask(task)) {
- appendMessageToLocalAgent(task.id, createUserMessage({
- content: input
- }), setAppState);
- if (task.status === 'running') {
- queuePendingMessage(task.id, input, setAppState);
- } else {
- void resumeAgentBackground({
- agentId: task.id,
- prompt: input,
- toolUseContext: getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel),
- canUseTool
- }).catch(err => {
- logForDebugging(`resumeAgentBackground failed: ${errorMessage(err)}`);
- addNotification({
- key: `resume-agent-failed-${task.id}`,
- jsx:
+ const onAgentSubmit = useCallback(
+ async (
+ input: string,
+ task: InProcessTeammateTaskState | LocalAgentTaskState,
+ helpers: PromptInputHelpers,
+ ) => {
+ if (isLocalAgentTask(task)) {
+ appendMessageToLocalAgent(
+ task.id,
+ createUserMessage({ content: input }),
+ setAppState,
+ )
+ if (task.status === 'running') {
+ queuePendingMessage(task.id, input, setAppState)
+ } else {
+ void resumeAgentBackground({
+ agentId: task.id,
+ prompt: input,
+ toolUseContext: getToolUseContext(
+ messagesRef.current,
+ [],
+ new AbortController(),
+ mainLoopModel,
+ ),
+ canUseTool,
+ }).catch(err => {
+ logForDebugging(
+ `resumeAgentBackground failed: ${errorMessage(err)}`,
+ )
+ addNotification({
+ key: `resume-agent-failed-${task.id}`,
+ jsx: (
+
Failed to resume agent: {errorMessage(err)}
- ,
- priority: 'low'
- });
- });
+
+ ),
+ priority: 'low',
+ })
+ })
+ }
+ } else {
+ injectUserMessageToTeammate(task.id, input, setAppState)
}
- } else {
- injectUserMessageToTeammate(task.id, input, setAppState);
- }
- setInputValue('');
- helpers.setCursorOffset(0);
- helpers.clearBuffer();
- }, [setAppState, setInputValue, getToolUseContext, canUseTool, mainLoopModel, addNotification]);
+ setInputValue('')
+ helpers.setCursorOffset(0)
+ helpers.clearBuffer()
+ },
+ [
+ setAppState,
+ setInputValue,
+ getToolUseContext,
+ canUseTool,
+ mainLoopModel,
+ addNotification,
+ ],
+ )
// Handlers for auto-run /issue or /good-claude (defined after onSubmit)
const handleAutoRunIssue = useCallback(() => {
- const command = autoRunIssueReason ? getAutoRunCommand(autoRunIssueReason) : '/issue';
- setAutoRunIssueReason(null); // Clear the state
+ const command = autoRunIssueReason
+ ? getAutoRunCommand(autoRunIssueReason)
+ : '/issue'
+ setAutoRunIssueReason(null) // Clear the state
onSubmit(command, {
setCursorOffset: () => {},
clearBuffer: () => {},
- resetHistory: () => {}
+ resetHistory: () => {},
}).catch(err => {
- logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`);
- });
- }, [onSubmit, autoRunIssueReason]);
+ logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`)
+ })
+ }, [onSubmit, autoRunIssueReason])
+
const handleCancelAutoRunIssue = useCallback(() => {
- setAutoRunIssueReason(null);
- }, []);
+ setAutoRunIssueReason(null)
+ }, [])
// Handler for when user presses 1 on survey thanks screen to share details
const handleSurveyRequestFeedback = useCallback(() => {
- const command = (process.env.USER_TYPE) === 'ant' ? '/issue' : '/feedback';
+ const command = process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback'
onSubmit(command, {
setCursorOffset: () => {},
clearBuffer: () => {},
- resetHistory: () => {}
+ resetHistory: () => {},
}).catch(err => {
- logForDebugging(`Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`);
- });
- }, [onSubmit]);
+ logForDebugging(
+ `Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`,
+ )
+ })
+ }, [onSubmit])
// onSubmit is unstable (deps include `messages` which changes every turn).
// `handleOpenRateLimitOptions` is prop-drilled to every MessageRow, and each
// MessageRow fiber pins the closure (and transitively the entire REPL render
// scope, ~1.8KB) at mount time. Using a ref keeps this callback stable so
// old REPL scopes can be GC'd — saves ~35MB over a 1000-turn session.
- const onSubmitRef = useRef(onSubmit);
- onSubmitRef.current = onSubmit;
+ const onSubmitRef = useRef(onSubmit)
+ onSubmitRef.current = onSubmit
const handleOpenRateLimitOptions = useCallback(() => {
void onSubmitRef.current('/rate-limit-options', {
setCursorOffset: () => {},
clearBuffer: () => {},
- resetHistory: () => {}
- });
- }, []);
+ resetHistory: () => {},
+ })
+ }, [])
+
const handleExit = useCallback(async () => {
- setIsExiting(true);
+ setIsExiting(true)
// In bg sessions, always detach instead of kill — even when a worktree is
// active. Without this guard, the worktree branch below short-circuits into
// ExitFlow (which calls gracefulShutdown) before exit.tsx is ever loaded.
if (feature('BG_SESSIONS') && isBgSession()) {
- spawnSync('tmux', ['detach-client'], {
- stdio: 'ignore'
- });
- setIsExiting(false);
- return;
+ spawnSync('tmux', ['detach-client'], { stdio: 'ignore' })
+ setIsExiting(false)
+ return
}
- const showWorktree = getCurrentWorktreeSession() !== null;
+ const showWorktree = getCurrentWorktreeSession() !== null
if (showWorktree) {
- setExitFlow( {}} onCancel={() => {
- setExitFlow(null);
- setIsExiting(false);
- }} />);
- return;
+ setExitFlow(
+ {}}
+ onCancel={() => {
+ setExitFlow(null)
+ setIsExiting(false)
+ }}
+ />,
+ )
+ return
}
- const exitMod = await exit.load();
- const exitFlowResult = await exitMod.call(() => {});
- setExitFlow(exitFlowResult);
+ const exitMod = await exit.load()
+ const exitFlowResult = await exitMod.call(() => {})
+ setExitFlow(exitFlowResult)
// If call() returned without killing the process (bg session detach),
// clear isExiting so the UI is usable on reattach. No-op on the normal
// path — gracefulShutdown's process.exit() means we never get here.
if (exitFlowResult === null) {
- setIsExiting(false);
+ setIsExiting(false)
}
- }, []);
+ }, [])
+
const handleShowMessageSelector = useCallback(() => {
- setIsMessageSelectorVisible(prev => !prev);
- }, []);
+ setIsMessageSelectorVisible(prev => !prev)
+ }, [])
// Rewind conversation state to just before `message`: slice messages,
// reset conversation ID, microcompact state, permission mode, prompt suggestion.
// Does NOT touch the prompt input. Index is computed from messagesRef (always
// fresh via the setMessages wrapper) so callers don't need to worry about
// stale closures.
- const rewindConversationTo = useCallback((message: UserMessage) => {
- const prev = messagesRef.current;
- const messageIndex = prev.lastIndexOf(message);
- if (messageIndex === -1) return;
- logEvent('tengu_conversation_rewind', {
- preRewindMessageCount: prev.length,
- postRewindMessageCount: messageIndex,
- messagesRemoved: prev.length - messageIndex,
- rewindToMessageIndex: messageIndex
- });
- setMessages(prev.slice(0, messageIndex));
- // Careful, this has to happen after setMessages
- setConversationId(randomUUID());
- // Reset cached microcompact state so stale pinned cache edits
- // don't reference tool_use_ids from truncated messages
- resetMicrocompactState();
- if (feature('CONTEXT_COLLAPSE')) {
- // Rewind truncates the REPL array. Commits whose archived span
- // was past the rewind point can't be projected anymore
- // (projectView silently skips them) but the staged queue and ID
- // maps reference stale uuids. Simplest safe reset: drop
- // everything. The ctx-agent will re-stage on the next
- // threshold crossing.
- /* eslint-disable @typescript-eslint/no-require-imports */
- ;
- (require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js')).resetContextCollapse();
- /* eslint-enable @typescript-eslint/no-require-imports */
- }
+ const rewindConversationTo = useCallback(
+ (message: UserMessage) => {
+ const prev = messagesRef.current
+ const messageIndex = prev.lastIndexOf(message)
+ if (messageIndex === -1) return
- // Restore state from the message we're rewinding to
- setAppState(prev => ({
- ...prev,
- // Restore permission mode from the message
- toolPermissionContext: message.permissionMode && prev.toolPermissionContext.mode !== message.permissionMode ? {
- ...prev.toolPermissionContext,
- mode: message.permissionMode as PermissionMode
- } : prev.toolPermissionContext,
- // Clear stale prompt suggestion from previous conversation state
- promptSuggestion: {
- text: null,
- promptId: null,
- shownAt: 0,
- acceptedAt: 0,
- generationRequestId: null
+ logEvent('tengu_conversation_rewind', {
+ preRewindMessageCount: prev.length,
+ postRewindMessageCount: messageIndex,
+ messagesRemoved: prev.length - messageIndex,
+ rewindToMessageIndex: messageIndex,
+ })
+ setMessages(prev.slice(0, messageIndex))
+ // Careful, this has to happen after setMessages
+ setConversationId(randomUUID())
+ // Reset cached microcompact state so stale pinned cache edits
+ // don't reference tool_use_ids from truncated messages
+ resetMicrocompactState()
+ if (feature('CONTEXT_COLLAPSE')) {
+ // Rewind truncates the REPL array. Commits whose archived span
+ // was past the rewind point can't be projected anymore
+ // (projectView silently skips them) but the staged queue and ID
+ // maps reference stale uuids. Simplest safe reset: drop
+ // everything. The ctx-agent will re-stage on the next
+ // threshold crossing.
+ /* eslint-disable @typescript-eslint/no-require-imports */
+ ;(
+ require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js')
+ ).resetContextCollapse()
+ /* eslint-enable @typescript-eslint/no-require-imports */
}
- }));
- }, [setMessages, setAppState]);
+
+ // Restore state from the message we're rewinding to
+ setAppState(prev => ({
+ ...prev,
+ // Restore permission mode from the message
+ toolPermissionContext:
+ message.permissionMode &&
+ prev.toolPermissionContext.mode !== message.permissionMode
+ ? {
+ ...prev.toolPermissionContext,
+ mode: message.permissionMode,
+ }
+ : prev.toolPermissionContext,
+ // Clear stale prompt suggestion from previous conversation state
+ promptSuggestion: {
+ text: null,
+ promptId: null,
+ shownAt: 0,
+ acceptedAt: 0,
+ generationRequestId: null,
+ },
+ }))
+ },
+ [setMessages, setAppState],
+ )
// Synchronous rewind + input population. Used directly by auto-restore on
// interrupt (so React batches with the abort's setMessages → single render,
// no flicker). MessageSelector wraps this in setImmediate via handleRestoreMessage.
- const restoreMessageSync = useCallback((message: UserMessage) => {
- rewindConversationTo(message);
- const r = textForResubmit(message);
- if (r) {
- setInputValue(r.text);
- setInputMode(r.mode);
- }
+ const restoreMessageSync = useCallback(
+ (message: UserMessage) => {
+ rewindConversationTo(message)
- // Restore pasted images
- if (Array.isArray(message.message.content) && message.message.content.some(block => block.type === 'image')) {
- const imageBlocks = message.message.content.filter(block => block.type === 'image') as unknown as Array;
- if (imageBlocks.length > 0) {
- const newPastedContents: Record = {};
- imageBlocks.forEach((block, index) => {
- if (block.source.type === 'base64') {
- const id = message.imagePasteIds?.[index] ?? index + 1;
- newPastedContents[id] = {
- id,
- type: 'image',
- content: block.source.data,
- mediaType: block.source.media_type
- };
- }
- });
- setPastedContents(newPastedContents);
+ const r = textForResubmit(message)
+ if (r) {
+ setInputValue(r.text)
+ setInputMode(r.mode)
}
- }
- }, [rewindConversationTo, setInputValue]);
- restoreMessageSyncRef.current = restoreMessageSync;
+
+ // Restore pasted images
+ if (
+ Array.isArray(message.message.content) &&
+ message.message.content.some(block => block.type === 'image')
+ ) {
+ const imageBlocks: Array =
+ message.message.content.filter(block => block.type === 'image')
+ if (imageBlocks.length > 0) {
+ const newPastedContents: Record = {}
+ imageBlocks.forEach((block, index) => {
+ if (block.source.type === 'base64') {
+ const id = message.imagePasteIds?.[index] ?? index + 1
+ newPastedContents[id] = {
+ id,
+ type: 'image',
+ content: block.source.data,
+ mediaType: block.source.media_type,
+ }
+ }
+ })
+ setPastedContents(newPastedContents)
+ }
+ }
+ },
+ [rewindConversationTo, setInputValue],
+ )
+ restoreMessageSyncRef.current = restoreMessageSync
// MessageSelector path: defer via setImmediate so the "Interrupted" message
// renders to static output before rewind — otherwise it remains vestigial
// at the top of the screen.
- const handleRestoreMessage = useCallback(async (message: UserMessage) => {
- setImmediate((restore, message) => restore(message), restoreMessageSync, message);
- }, [restoreMessageSync]);
+ const handleRestoreMessage = useCallback(
+ async (message: UserMessage) => {
+ setImmediate(
+ (restore, message) => restore(message),
+ restoreMessageSync,
+ message,
+ )
+ },
+ [restoreMessageSync],
+ )
// Not memoized — hook stores caps via ref, reads latest closure at dispatch.
// 24-char prefix: deriveUUID preserves first 24, renderable uuid prefix-matches raw source.
const findRawIndex = (uuid: string) => {
- const prefix = uuid.slice(0, 24);
- return messages.findIndex(m => m.uuid.slice(0, 24) === prefix);
- };
+ const prefix = uuid.slice(0, 24)
+ return messages.findIndex(m => m.uuid.slice(0, 24) === prefix)
+ }
const messageActionCaps: MessageActionCaps = {
copy: text =>
- // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only).
- void setClipboard(text).then(raw => {
- if (raw) process.stdout.write(raw);
- addNotification({
- // Same key as text-selection copy — repeated copies replace toast, don't queue.
- key: 'selection-copied',
- text: 'copied',
- color: 'success',
- priority: 'immediate',
- timeoutMs: 2000
- });
- }),
+ // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only).
+ void setClipboard(text).then(raw => {
+ if (raw) process.stdout.write(raw)
+ addNotification({
+ // Same key as text-selection copy — repeated copies replace toast, don't queue.
+ key: 'selection-copied',
+ text: 'copied',
+ color: 'success',
+ priority: 'immediate',
+ timeoutMs: 2000,
+ })
+ }),
edit: async msg => {
// Same skip-confirm check as /rewind: lossless → direct, else confirm dialog.
- const rawIdx = findRawIndex(msg.uuid);
- const raw = rawIdx >= 0 ? messages[rawIdx] : undefined;
- if (!raw || !selectableUserMessagesFilter(raw)) return;
- const noFileChanges = !(await fileHistoryHasAnyChanges(fileHistory, raw.uuid));
- const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx);
+ const rawIdx = findRawIndex(msg.uuid)
+ const raw = rawIdx >= 0 ? messages[rawIdx] : undefined
+ if (!raw || !selectableUserMessagesFilter(raw)) return
+ const noFileChanges = !(await fileHistoryHasAnyChanges(
+ fileHistory,
+ raw.uuid,
+ ))
+ const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx)
if (noFileChanges && onlySynthetic) {
// rewindConversationTo's setMessages races stream appends — cancel first (idempotent).
- onCancel();
+ onCancel()
// handleRestoreMessage also restores pasted images.
- void handleRestoreMessage(raw);
+ void handleRestoreMessage(raw)
} else {
// Dialog path: onPreRestore (= onCancel) fires when user CONFIRMS, not on nevermind.
- setMessageSelectorPreselect(raw);
- setIsMessageSelectorVisible(true);
+ setMessageSelectorPreselect(raw)
+ setIsMessageSelectorVisible(true)
}
- }
- };
- const {
- enter: enterMessageActions,
- handlers: messageActionHandlers
- } = useMessageActions(cursor, setCursor, cursorNavRef, messageActionCaps);
+ },
+ }
+ const { enter: enterMessageActions, handlers: messageActionHandlers } =
+ useMessageActions(cursor, setCursor, cursorNavRef, messageActionCaps)
+
async function onInit() {
// Always verify API key on startup, so we can show the user an error in the
// bottom right corner of the screen if the API key is invalid.
- void reverify();
+ void reverify()
// Populate readFileState with CLAUDE.md files at startup
- const memoryFiles = await getMemoryFiles();
+ const memoryFiles = await getMemoryFiles()
if (memoryFiles.length > 0) {
- const fileList = memoryFiles.map(f => ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`).join('\n');
- logForDebugging(`Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`);
+ const fileList = memoryFiles
+ .map(
+ f =>
+ ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`,
+ )
+ .join('\n')
+ logForDebugging(
+ `Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`,
+ )
} else {
- logForDebugging('No CLAUDE.md/rules files found');
+ logForDebugging('No CLAUDE.md/rules files found')
}
for (const file of memoryFiles) {
// When the injected content doesn't match disk (stripped HTML comments,
@@ -3815,33 +5084,40 @@ export function REPL({
// with isPartialView so Edit/Write require a real Read first while
// getChangedFiles + nested_memory dedup still work.
readFileState.current.set(file.path, {
- content: file.contentDiffersFromDisk ? file.rawContent ?? file.content : file.content,
+ content: file.contentDiffersFromDisk
+ ? (file.rawContent ?? file.content)
+ : file.content,
timestamp: Date.now(),
offset: undefined,
limit: undefined,
- isPartialView: file.contentDiffersFromDisk
- });
+ isPartialView: file.contentDiffersFromDisk,
+ })
}
// Initial message handling is done via the initialMessage effect
}
// Register cost summary tracker
- useCostSummary(useFpsMetrics());
+ useCostSummary(useFpsMetrics())
// Record transcripts locally, for debugging and conversation recovery
// Don't record conversation if we only have initial messages; optimizes
// the case where user resumes a conversation then quites before doing
// anything else
- useLogMessages(messages, messages.length === initialMessages?.length);
+ useLogMessages(messages, messages.length === initialMessages?.length)
// REPL Bridge: replicate user/assistant messages to the bridge session
// for remote access via claude.ai. No-op in external builds or when not enabled.
- const {
- sendBridgeResult
- } = useReplBridge(messages, setMessages, abortControllerRef, commands, mainLoopModel);
- sendBridgeResultRef.current = sendBridgeResult;
- useAfterFirstRender();
+ const { sendBridgeResult } = useReplBridge(
+ messages,
+ setMessages,
+ abortControllerRef,
+ commands,
+ mainLoopModel,
+ )
+ sendBridgeResultRef.current = sendBridgeResult
+
+ useAfterFirstRender()
// Track prompt queue usage for analytics. Fire once per transition from
// empty to non-empty, not on every length change -- otherwise a render loop
@@ -3849,229 +5125,303 @@ export function REPL({
// ELOCKED under concurrent sessions and falls back to unlocked writes.
// That write storm is the primary trigger for ~/.claude.json corruption
// (GH #3117).
- const hasCountedQueueUseRef = useRef(false);
+ const hasCountedQueueUseRef = useRef(false)
useEffect(() => {
if (queuedCommands.length < 1) {
- hasCountedQueueUseRef.current = false;
- return;
+ hasCountedQueueUseRef.current = false
+ return
}
- if (hasCountedQueueUseRef.current) return;
- hasCountedQueueUseRef.current = true;
+ if (hasCountedQueueUseRef.current) return
+ hasCountedQueueUseRef.current = true
saveGlobalConfig(current => ({
...current,
- promptQueueUseCount: (current.promptQueueUseCount ?? 0) + 1
- }));
- }, [queuedCommands.length]);
+ promptQueueUseCount: (current.promptQueueUseCount ?? 0) + 1,
+ }))
+ }, [queuedCommands.length])
// Process queued commands when query completes and queue has items
- const executeQueuedInput = useCallback(async (queuedCommands: QueuedCommand[]) => {
- await handlePromptSubmit({
- helpers: {
- setCursorOffset: () => {},
- clearBuffer: () => {},
- resetHistory: () => {}
- },
+ const executeQueuedInput = useCallback(
+ async (queuedCommands: QueuedCommand[]) => {
+ await handlePromptSubmit({
+ helpers: {
+ setCursorOffset: () => {},
+ clearBuffer: () => {},
+ resetHistory: () => {},
+ },
+ queryGuard,
+ commands,
+ onInputChange: () => {},
+ setPastedContents: () => {},
+ setToolJSX,
+ getToolUseContext,
+ messages,
+ mainLoopModel,
+ ideSelection,
+ setUserInputOnProcessing,
+ setAbortController,
+ onQuery,
+ setAppState,
+ querySource: getQuerySourceForREPL(),
+ onBeforeQuery,
+ canUseTool,
+ addNotification,
+ setMessages,
+ queuedCommands,
+ })
+ },
+ [
queryGuard,
commands,
- onInputChange: () => {},
- setPastedContents: () => {},
setToolJSX,
getToolUseContext,
messages,
mainLoopModel,
ideSelection,
setUserInputOnProcessing,
+ canUseTool,
setAbortController,
onQuery,
- setAppState,
- querySource: getQuerySourceForREPL(),
- onBeforeQuery,
- canUseTool,
addNotification,
- setMessages,
- queuedCommands
- });
- }, [queryGuard, commands, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, canUseTool, setAbortController, onQuery, addNotification, setAppState, onBeforeQuery]);
+ setAppState,
+ onBeforeQuery,
+ ],
+ )
+
useQueueProcessor({
executeQueuedInput,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
- queryGuard
- });
+ queryGuard,
+ })
// We'll use the global lastInteractionTime from state.ts
// Update last interaction time when input changes.
// Must be immediate because useEffect runs after the Ink render cycle flush.
useEffect(() => {
- activityManager.recordUserActivity();
- updateLastInteractionTime(true);
- }, [inputValue, submitCount]);
+ activityManager.recordUserActivity()
+ updateLastInteractionTime(true)
+ }, [inputValue, submitCount])
+
useEffect(() => {
if (submitCount === 1) {
- startBackgroundHousekeeping();
+ startBackgroundHousekeeping()
}
- }, [submitCount]);
+ }, [submitCount])
// Show notification when Claude is done responding and user is idle
useEffect(() => {
// Don't set up notification if Claude is busy
- if (isLoading) return;
+ if (isLoading) return
// Only enable notifications after the first new interaction in this session
- if (submitCount === 0) return;
+ if (submitCount === 0) return
// No query has completed yet
- if (lastQueryCompletionTime === 0) return;
+ if (lastQueryCompletionTime === 0) return
// Set timeout to check idle state
- const timer = setTimeout((lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal) => {
- // Check if user has interacted since the response ended
- const lastUserInteraction = getLastInteractionTime();
- if (lastUserInteraction > lastQueryCompletionTime) {
- // User has interacted since Claude finished - they're not idle, don't notify
- return;
- }
+ const timer = setTimeout(
+ (
+ lastQueryCompletionTime,
+ isLoading,
+ toolJSX,
+ focusedInputDialogRef,
+ terminal,
+ ) => {
+ // Check if user has interacted since the response ended
+ const lastUserInteraction = getLastInteractionTime()
- // User hasn't interacted since response ended, check other conditions
- const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime;
- if (!isLoading && !toolJSX &&
- // Use ref to get current dialog state, avoiding stale closure
- focusedInputDialogRef.current === undefined && idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs) {
- void sendNotification({
- message: 'Claude is waiting for your input',
- notificationType: 'idle_prompt'
- }, terminal);
- }
- }, getGlobalConfig().messageIdleNotifThresholdMs, lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal);
- return () => clearTimeout(timer);
- }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal]);
+ if (lastUserInteraction > lastQueryCompletionTime) {
+ // User has interacted since Claude finished - they're not idle, don't notify
+ return
+ }
+
+ // User hasn't interacted since response ended, check other conditions
+ const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime
+ if (
+ !isLoading &&
+ !toolJSX &&
+ // Use ref to get current dialog state, avoiding stale closure
+ focusedInputDialogRef.current === undefined &&
+ idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs
+ ) {
+ void sendNotification(
+ {
+ message: 'Claude is waiting for your input',
+ notificationType: 'idle_prompt',
+ },
+ terminal,
+ )
+ }
+ },
+ getGlobalConfig().messageIdleNotifThresholdMs,
+ lastQueryCompletionTime,
+ isLoading,
+ toolJSX,
+ focusedInputDialogRef,
+ terminal,
+ )
+
+ return () => clearTimeout(timer)
+ }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal])
// Idle-return hint: show notification when idle threshold is exceeded.
// Timer fires after the configured idle period; notification persists until
// dismissed or the user submits.
useEffect(() => {
- if (lastQueryCompletionTime === 0) return;
- if (isLoading) return;
- const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off');
- if (willowMode !== 'hint' && willowMode !== 'hint_v2') return;
- if (getGlobalConfig().idleReturnDismissed) return;
- const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000);
- if (getTotalInputTokens() < tokenThreshold) return;
- const idleThresholdMs = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000;
- const elapsed = Date.now() - lastQueryCompletionTime;
- const remaining = idleThresholdMs - elapsed;
- const timer = setTimeout((lqct, addNotif, msgsRef, mode, hintRef) => {
- if (msgsRef.current.length === 0) return;
- const totalTokens = getTotalInputTokens();
- const formattedTokens = formatTokens(totalTokens);
- const idleMinutes = (Date.now() - lqct) / 60_000;
- addNotif({
- key: 'idle-return-hint',
- jsx: mode === 'hint_v2' ? <>
+ if (lastQueryCompletionTime === 0) return
+ if (isLoading) return
+ const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE(
+ 'tengu_willow_mode',
+ 'off',
+ )
+ if (willowMode !== 'hint' && willowMode !== 'hint_v2') return
+ if (getGlobalConfig().idleReturnDismissed) return
+
+ const tokenThreshold = Number(
+ process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000,
+ )
+ if (getTotalInputTokens() < tokenThreshold) return
+
+ const idleThresholdMs =
+ Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000
+ const elapsed = Date.now() - lastQueryCompletionTime
+ const remaining = idleThresholdMs - elapsed
+
+ const timer = setTimeout(
+ (lqct, addNotif, msgsRef, mode, hintRef) => {
+ if (msgsRef.current.length === 0) return
+ const totalTokens = getTotalInputTokens()
+ const formattedTokens = formatTokens(totalTokens)
+ const idleMinutes = (Date.now() - lqct) / 60_000
+ addNotif({
+ key: 'idle-return-hint',
+ jsx:
+ mode === 'hint_v2' ? (
+ <>
new task?
/clear
to save
{formattedTokens} tokens
- > :
+ >
+ ) : (
+
new task? /clear to save {formattedTokens} tokens
- ,
- priority: 'medium',
- // Persist until submit — the hint fires at T+75min idle, user may
- // not return for hours. removeNotification in useEffect cleanup
- // handles dismissal. 0x7FFFFFFF = setTimeout max (~24.8 days).
- timeoutMs: 0x7fffffff
- });
- hintRef.current = mode;
- logEvent('tengu_idle_return_action', {
- action: 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- variant: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- idleMinutes: Math.round(idleMinutes),
- messageCount: msgsRef.current.length,
- totalInputTokens: totalTokens
- });
- }, Math.max(0, remaining), lastQueryCompletionTime, addNotification, messagesRef, willowMode, idleHintShownRef);
+
+ ),
+ priority: 'medium',
+ // Persist until submit — the hint fires at T+75min idle, user may
+ // not return for hours. removeNotification in useEffect cleanup
+ // handles dismissal. 0x7FFFFFFF = setTimeout max (~24.8 days).
+ timeoutMs: 0x7fffffff,
+ })
+ hintRef.current = mode
+ logEvent('tengu_idle_return_action', {
+ action:
+ 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ variant:
+ mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
+ idleMinutes: Math.round(idleMinutes),
+ messageCount: msgsRef.current.length,
+ totalInputTokens: totalTokens,
+ })
+ },
+ Math.max(0, remaining),
+ lastQueryCompletionTime,
+ addNotification,
+ messagesRef,
+ willowMode,
+ idleHintShownRef,
+ )
+
return () => {
- clearTimeout(timer);
- removeNotification('idle-return-hint');
- idleHintShownRef.current = false;
- };
- }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification]);
+ clearTimeout(timer)
+ removeNotification('idle-return-hint')
+ idleHintShownRef.current = false
+ }
+ }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification])
// Submits incoming prompts from teammate messages or tasks mode as new turns
// Returns true if submission succeeded, false if a query is already running
- const handleIncomingPrompt = useCallback((content: string, options?: {
- isMeta?: boolean;
- }): boolean => {
- if (queryGuard.isActive) return false;
+ const handleIncomingPrompt = useCallback(
+ (content: string, options?: { isMeta?: boolean }): boolean => {
+ if (queryGuard.isActive) return false
- // Defer to user-queued commands — user input always takes priority
- // over system messages (teammate messages, task list items, etc.)
- // Read from the module-level store at call time (not the render-time
- // snapshot) to avoid a stale closure — this callback's deps don't
- // include the queue.
- if (getCommandQueue().some(cmd => cmd.mode === 'prompt' || cmd.mode === 'bash')) {
- return false;
- }
- const newAbortController = createAbortController();
- setAbortController(newAbortController);
+ // Defer to user-queued commands — user input always takes priority
+ // over system messages (teammate messages, task list items, etc.)
+ // Read from the module-level store at call time (not the render-time
+ // snapshot) to avoid a stale closure — this callback's deps don't
+ // include the queue.
+ if (
+ getCommandQueue().some(
+ cmd => cmd.mode === 'prompt' || cmd.mode === 'bash',
+ )
+ ) {
+ return false
+ }
- // Create a user message with the formatted content (includes XML wrapper)
- const userMessage = createUserMessage({
- content,
- isMeta: options?.isMeta ? true : undefined
- });
- void onQuery([userMessage], newAbortController, true, [], mainLoopModel);
- return true;
- }, [onQuery, mainLoopModel, store]);
+ const newAbortController = createAbortController()
+ setAbortController(newAbortController)
+
+ // Create a user message with the formatted content (includes XML wrapper)
+ const userMessage = createUserMessage({
+ content,
+ isMeta: options?.isMeta ? true : undefined,
+ })
+
+ void onQuery([userMessage], newAbortController, true, [], mainLoopModel)
+ return true
+ },
+ [onQuery, mainLoopModel, store],
+ )
// Voice input integration (VOICE_MODE builds only)
- const voice = feature('VOICE_MODE') ?
- // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
- useVoiceIntegration({
- setInputValueRaw,
- inputValueRef,
- insertTextRef
- }) : {
- stripTrailing: () => 0,
- handleKeyEvent: () => {},
- resetAnchor: () => {},
- interimRange: null
- };
+ const voice = feature('VOICE_MODE')
+ ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
+ useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
+ : {
+ stripTrailing: () => 0,
+ handleKeyEvent: () => {},
+ resetAnchor: () => {},
+ interimRange: null,
+ }
+
useInboxPoller({
enabled: isAgentSwarmsEnabled(),
isLoading,
focusedInputDialog,
- onSubmitMessage: handleIncomingPrompt
- });
- useMailboxBridge({
- isLoading,
- onSubmitMessage: handleIncomingPrompt
- });
+ onSubmitMessage: handleIncomingPrompt,
+ })
+
+ useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt })
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
- {
- const assistantMode = store.getState().kairosEnabled;
- useScheduledTasks({
- isLoading,
- assistantMode,
- setMessages
- });
+ if (feature('AGENT_TRIGGERS')) {
+ // Assistant mode bypasses the isLoading gate (the proactive tick →
+ // Sleep → tick loop would otherwise starve the scheduler).
+ // kairosEnabled is set once in initialState (main.tsx) and never mutated — no
+ // subscription needed. The tengu_kairos_cron runtime gate is checked inside
+ // useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
+ // condition would break rules-of-hooks.
+ const assistantMode = store.getState().kairosEnabled
+ // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
+ useScheduledTasks!({ isLoading, assistantMode, setMessages })
}
// Note: Permission polling is now handled by useInboxPoller
// - Workers receive permission responses via mailbox messages
// - Leaders receive permission requests via mailbox messages
- if ((process.env.USER_TYPE) === 'ant') {
+ if (process.env.USER_TYPE === 'ant') {
// Tasks mode: watch for tasks and auto-process them
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
useTaskListWatcher({
taskListId,
isLoading,
- onSubmitTask: handleIncomingPrompt
- });
+ onSubmitTask: handleIncomingPrompt,
+ })
// Loop mode: auto-tick when enabled (via /job command)
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -4084,281 +5434,337 @@ export function REPL({
queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan',
- onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, {
- isMeta: true
- }),
- onQueueTick: (prompt: string) => enqueue({
- mode: 'prompt',
- value: prompt,
- isMeta: true
- })
- });
+ onSubmitTick: (prompt: string) =>
+ handleIncomingPrompt(prompt, { isMeta: true }),
+ onQueueTick: (prompt: string) =>
+ enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
+ })
}
// Abort the current operation when a 'now' priority message arrives
// (e.g. from a chat UI client via UDS).
useEffect(() => {
if (queuedCommands.some(cmd => cmd.priority === 'now')) {
- abortControllerRef.current?.abort('interrupt');
+ abortControllerRef.current?.abort('interrupt')
}
- }, [queuedCommands]);
+ }, [queuedCommands])
// Initial load
useEffect(() => {
- void onInit();
+ void onInit()
// Cleanup on unmount
return () => {
- void diagnosticTracker.shutdown();
- };
+ void diagnosticTracker.shutdown()
+ }
// TODO: fix this
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [])
// Listen for suspend/resume events
- const {
- internal_eventEmitter
- } = useStdin();
- const [remountKey, setRemountKey] = useState(0);
+ const { internal_eventEmitter } = useStdin()
+ const [remountKey, setRemountKey] = useState(0)
useEffect(() => {
const handleSuspend = () => {
// Print suspension instructions
- process.stdout.write(`\nClaude Code has been suspended. Run \`fg\` to bring Claude Code back.\nNote: ctrl + z now suspends Claude Code, ctrl + _ undoes input.\n`);
- };
+ process.stdout.write(
+ `\nClaude Code has been suspended. Run \`fg\` to bring Claude Code back.\nNote: ctrl + z now suspends Claude Code, ctrl + _ undoes input.\n`,
+ )
+ }
+
const handleResume = () => {
// Force complete component tree replacement instead of terminal clear
// Ink now handles line count reset internally on SIGCONT
- setRemountKey(prev => prev + 1);
- };
- internal_eventEmitter?.on('suspend', handleSuspend);
- internal_eventEmitter?.on('resume', handleResume);
+ setRemountKey(prev => prev + 1)
+ }
+
+ internal_eventEmitter?.on('suspend', handleSuspend)
+ internal_eventEmitter?.on('resume', handleResume)
return () => {
- internal_eventEmitter?.off('suspend', handleSuspend);
- internal_eventEmitter?.off('resume', handleResume);
- };
- }, [internal_eventEmitter]);
+ internal_eventEmitter?.off('suspend', handleSuspend)
+ internal_eventEmitter?.off('resume', handleResume)
+ }
+ }, [internal_eventEmitter])
// Derive stop hook spinner suffix from messages state
const stopHookSpinnerSuffix = useMemo(() => {
- if (!isLoading) return null;
+ if (!isLoading) return null
// Find stop hook progress messages
- const progressMsgs = messages.filter((m): m is ProgressMessage => m.type === 'progress' && (m.data as HookProgress).type === 'hook_progress' && ((m.data as HookProgress).hookEvent === 'Stop' || (m.data as HookProgress).hookEvent === 'SubagentStop'));
- if (progressMsgs.length === 0) return null;
+ const progressMsgs = messages.filter(
+ (m): m is ProgressMessage =>
+ m.type === 'progress' &&
+ m.data.type === 'hook_progress' &&
+ (m.data.hookEvent === 'Stop' || m.data.hookEvent === 'SubagentStop'),
+ )
+ if (progressMsgs.length === 0) return null
// Get the most recent stop hook execution
- const currentToolUseID = progressMsgs.at(-1)?.toolUseID;
- if (!currentToolUseID) return null;
+ const currentToolUseID = progressMsgs.at(-1)?.toolUseID
+ if (!currentToolUseID) return null
// Check if there's already a summary message for this execution (hooks completed)
- const hasSummaryForCurrentExecution = messages.some(m => m.type === 'system' && m.subtype === 'stop_hook_summary' && m.toolUseID === currentToolUseID);
- if (hasSummaryForCurrentExecution) return null;
- const currentHooks = progressMsgs.filter(p => p.toolUseID === currentToolUseID);
- const total = currentHooks.length;
+ const hasSummaryForCurrentExecution = messages.some(
+ m =>
+ m.type === 'system' &&
+ m.subtype === 'stop_hook_summary' &&
+ m.toolUseID === currentToolUseID,
+ )
+ if (hasSummaryForCurrentExecution) return null
+
+ const currentHooks = progressMsgs.filter(
+ p => p.toolUseID === currentToolUseID,
+ )
+ const total = currentHooks.length
// Count completed hooks
const completedCount = count(messages, m => {
- if (m.type !== 'attachment') return false;
- const attachment = m.attachment;
- return 'hookEvent' in attachment && (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') && 'toolUseID' in attachment && attachment.toolUseID === currentToolUseID;
- });
+ if (m.type !== 'attachment') return false
+ const attachment = m.attachment
+ return (
+ 'hookEvent' in attachment &&
+ (attachment.hookEvent === 'Stop' ||
+ attachment.hookEvent === 'SubagentStop') &&
+ 'toolUseID' in attachment &&
+ attachment.toolUseID === currentToolUseID
+ )
+ })
// Check if any hook has a custom status message
- const customMessage = currentHooks.find(p => p.data.statusMessage)?.data.statusMessage;
+ const customMessage = currentHooks.find(p => p.data.statusMessage)?.data
+ .statusMessage
+
if (customMessage) {
// Use custom message with progress counter if multiple hooks
- return total === 1 ? `${customMessage}…` : `${customMessage}… ${completedCount}/${total}`;
+ return total === 1
+ ? `${customMessage}…`
+ : `${customMessage}… ${completedCount}/${total}`
}
// Fall back to default behavior
- const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop';
- if ((process.env.USER_TYPE) === 'ant') {
- const cmd = currentHooks[completedCount]?.data.command;
- const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : '';
- return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`;
+ const hookType =
+ currentHooks[0]?.data.hookEvent === 'SubagentStop'
+ ? 'subagent stop'
+ : 'stop'
+
+ if (process.env.USER_TYPE === 'ant') {
+ const cmd = currentHooks[completedCount]?.data.command
+ const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : ''
+ return total === 1
+ ? `running ${hookType} hook${label}`
+ : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`
}
- return total === 1 ? `running ${hookType} hook` : `running stop hooks… ${completedCount}/${total}`;
- }, [messages, isLoading]);
+
+ return total === 1
+ ? `running ${hookType} hook`
+ : `running stop hooks… ${completedCount}/${total}`
+ }, [messages, isLoading])
// Callback to capture frozen state when entering transcript mode
const handleEnterTranscript = useCallback(() => {
setFrozenTranscriptState({
messagesLength: messages.length,
- streamingToolUsesLength: streamingToolUses.length
- });
- }, [messages.length, streamingToolUses.length]);
+ streamingToolUsesLength: streamingToolUses.length,
+ })
+ }, [messages.length, streamingToolUses.length])
// Callback to clear frozen state when exiting transcript mode
const handleExitTranscript = useCallback(() => {
- setFrozenTranscriptState(null);
- }, []);
+ setFrozenTranscriptState(null)
+ }, [])
// Props for GlobalKeybindingHandlers component (rendered inside KeybindingSetup)
- const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll;
+ const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll
// Transcript search state. Hooks must be unconditional so they live here
// (not inside the `if (screen === 'transcript')` branch below); isActive
// gates the useInput. Query persists across bar open/close so n/N keep
// working after Enter dismisses the bar (less semantics).
- const jumpRef = useRef(null);
- const [searchOpen, setSearchOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState('');
- const [searchCount, setSearchCount] = useState(0);
- const [searchCurrent, setSearchCurrent] = useState(0);
- const onSearchMatchesChange = useCallback((count: number, current: number) => {
- setSearchCount(count);
- setSearchCurrent(current);
- }, []);
- useInput((input, key, event) => {
- if (key.ctrl || key.meta) return;
- // No Esc handling here — less has no navigating mode. Search state
- // (highlights, n/N) is just state. Esc/q/ctrl+c → transcript:exit
- // (ungated). Highlights clear on exit via the screen-change effect.
- if (input === '/') {
- // Capture scrollTop NOW — typing is a preview, 0-matches snaps
- // back here. Synchronous ref write, fires before the bar's
- // mount-effect calls setSearchQuery.
- jumpRef.current?.setAnchor();
- setSearchOpen(true);
- event.stopImmediatePropagation();
- return;
- }
- // Held-key batching: tokenizer coalesces to 'nnn'. Same uniform-batch
- // pattern as modalPagerAction in ScrollKeybindingHandler.tsx. Each
- // repeat is a step (n isn't idempotent like g).
- const c = input[0];
- if ((c === 'n' || c === 'N') && input === c.repeat(input.length) && searchCount > 0) {
- const fn = c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch;
- if (fn) for (let i = 0; i < input.length; i++) fn();
- event.stopImmediatePropagation();
- }
- },
- // Search needs virtual scroll (jumpRef drives VirtualMessageList). [
- // kills it, so !dumpMode — after [ there's nothing to jump in.
- {
- isActive: screen === 'transcript' && virtualScrollActive && !searchOpen && !dumpMode
- });
+ const jumpRef = useRef(null)
+ const [searchOpen, setSearchOpen] = useState(false)
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchCount, setSearchCount] = useState(0)
+ const [searchCurrent, setSearchCurrent] = useState(0)
+ const onSearchMatchesChange = useCallback(
+ (count: number, current: number) => {
+ setSearchCount(count)
+ setSearchCurrent(current)
+ },
+ [],
+ )
+
+ useInput(
+ (input, key, event) => {
+ if (key.ctrl || key.meta) return
+ // No Esc handling here — less has no navigating mode. Search state
+ // (highlights, n/N) is just state. Esc/q/ctrl+c → transcript:exit
+ // (ungated). Highlights clear on exit via the screen-change effect.
+ if (input === '/') {
+ // Capture scrollTop NOW — typing is a preview, 0-matches snaps
+ // back here. Synchronous ref write, fires before the bar's
+ // mount-effect calls setSearchQuery.
+ jumpRef.current?.setAnchor()
+ setSearchOpen(true)
+ event.stopImmediatePropagation()
+ return
+ }
+ // Held-key batching: tokenizer coalesces to 'nnn'. Same uniform-batch
+ // pattern as modalPagerAction in ScrollKeybindingHandler.tsx. Each
+ // repeat is a step (n isn't idempotent like g).
+ const c = input[0]
+ if (
+ (c === 'n' || c === 'N') &&
+ input === c.repeat(input.length) &&
+ searchCount > 0
+ ) {
+ const fn =
+ c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch
+ if (fn) for (let i = 0; i < input.length; i++) fn()
+ event.stopImmediatePropagation()
+ }
+ },
+ // Search needs virtual scroll (jumpRef drives VirtualMessageList). [
+ // kills it, so !dumpMode — after [ there's nothing to jump in.
+ {
+ isActive:
+ screen === 'transcript' &&
+ virtualScrollActive &&
+ !searchOpen &&
+ !dumpMode,
+ },
+ )
const {
setQuery: setHighlight,
scanElement,
- setPositions
- } = useSearchHighlight();
+ setPositions,
+ } = useSearchHighlight()
// Resize → abort search. Positions are (msg, query, WIDTH)-keyed —
// cached positions are stale after a width change (new layout, new
// wrapping). Clearing searchQuery triggers VML's setSearchQuery('')
// which clears positionsCache + setPositions(null). Bar closes.
// User hits / again → fresh everything.
- const transcriptCols = useTerminalSize().columns;
- const prevColsRef = React.useRef(transcriptCols);
+ const transcriptCols = useTerminalSize().columns
+ const prevColsRef = React.useRef(transcriptCols)
React.useEffect(() => {
if (prevColsRef.current !== transcriptCols) {
- prevColsRef.current = transcriptCols;
+ prevColsRef.current = transcriptCols
if (searchQuery || searchOpen) {
- setSearchOpen(false);
- setSearchQuery('');
- setSearchCount(0);
- setSearchCurrent(0);
- jumpRef.current?.disarmSearch();
- setHighlight('');
+ setSearchOpen(false)
+ setSearchQuery('')
+ setSearchCount(0)
+ setSearchCurrent(0)
+ jumpRef.current?.disarmSearch()
+ setHighlight('')
}
}
- }, [transcriptCols, searchQuery, searchOpen, setHighlight]);
+ }, [transcriptCols, searchQuery, searchOpen, setHighlight])
// Transcript escape hatches. Bare letters in modal context (no prompt
// competing for input) — same class as g/G/j/k in ScrollKeybindingHandler.
- useInput((input, key, event) => {
- if (key.ctrl || key.meta) return;
- if (input === 'q') {
- // less: q quits the pager. ctrl+o toggles; q is the lineage exit.
- handleExitTranscript();
- event.stopImmediatePropagation();
- return;
- }
- if (input === '[' && !dumpMode) {
- // Force dump-to-scrollback. Also expand + uncap — no point dumping
- // a subset. Terminal/tmux cmd-F can now find anything. Guard here
- // (not in isActive) so v still works post-[ — dump-mode footer at
- // ~4898 wires editorStatus, confirming v is meant to stay live.
- setDumpMode(true);
- setShowAllInTranscript(true);
- event.stopImmediatePropagation();
- } else if (input === 'v') {
- // less-style: v opens the file in $VISUAL/$EDITOR. Render the full
- // transcript (same path /export uses), write to tmp, hand off.
- // openFileInExternalEditor handles alt-screen suspend/resume for
- // terminal editors; GUI editors spawn detached.
- event.stopImmediatePropagation();
- // Drop double-taps: the render is async and a second press before it
- // completes would run a second parallel render (double memory, two
- // tempfiles, two editor spawns). editorGenRef only guards
- // transcript-exit staleness, not same-session concurrency.
- if (editorRenderingRef.current) return;
- editorRenderingRef.current = true;
- // Capture generation + make a staleness-aware setter. Each write
- // checks gen (transcript exit bumps it → late writes from the
- // async render go silent).
- const gen = editorGenRef.current;
- const setStatus = (s: string): void => {
- if (gen !== editorGenRef.current) return;
- clearTimeout(editorTimerRef.current);
- setEditorStatus(s);
- };
- setStatus(`rendering ${deferredMessages.length} messages…`);
- void (async () => {
- try {
- // Width = terminal minus vim's line-number gutter (4 digits +
- // space + slack). Floor at 80. PassThrough has no .columns so
- // without this Ink defaults to 80. Trailing-space strip: right-
- // aligned timestamps still leave a flexbox spacer run at EOL.
- // eslint-disable-next-line custom-rules/prefer-use-terminal-size -- one-shot at keypress time, not a reactive render dep
- const w = Math.max(80, (process.stdout.columns ?? 80) - 6);
- const raw = await renderMessagesToPlainText(deferredMessages, tools, w);
- const text = raw.replace(/[ \t]+$/gm, '');
- const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`);
- await writeFile(path, text);
- const opened = openFileInExternalEditor(path);
- setStatus(opened ? `opening ${path}` : `wrote ${path} · no $VISUAL/$EDITOR set`);
- } catch (e) {
- setStatus(`render failed: ${e instanceof Error ? e.message : String(e)}`);
+ useInput(
+ (input, key, event) => {
+ if (key.ctrl || key.meta) return
+ if (input === 'q') {
+ // less: q quits the pager. ctrl+o toggles; q is the lineage exit.
+ handleExitTranscript()
+ event.stopImmediatePropagation()
+ return
+ }
+ if (input === '[' && !dumpMode) {
+ // Force dump-to-scrollback. Also expand + uncap — no point dumping
+ // a subset. Terminal/tmux cmd-F can now find anything. Guard here
+ // (not in isActive) so v still works post-[ — dump-mode footer at
+ // ~4898 wires editorStatus, confirming v is meant to stay live.
+ setDumpMode(true)
+ setShowAllInTranscript(true)
+ event.stopImmediatePropagation()
+ } else if (input === 'v') {
+ // less-style: v opens the file in $VISUAL/$EDITOR. Render the full
+ // transcript (same path /export uses), write to tmp, hand off.
+ // openFileInExternalEditor handles alt-screen suspend/resume for
+ // terminal editors; GUI editors spawn detached.
+ event.stopImmediatePropagation()
+ // Drop double-taps: the render is async and a second press before it
+ // completes would run a second parallel render (double memory, two
+ // tempfiles, two editor spawns). editorGenRef only guards
+ // transcript-exit staleness, not same-session concurrency.
+ if (editorRenderingRef.current) return
+ editorRenderingRef.current = true
+ // Capture generation + make a staleness-aware setter. Each write
+ // checks gen (transcript exit bumps it → late writes from the
+ // async render go silent).
+ const gen = editorGenRef.current
+ const setStatus = (s: string): void => {
+ if (gen !== editorGenRef.current) return
+ clearTimeout(editorTimerRef.current)
+ setEditorStatus(s)
}
- editorRenderingRef.current = false;
- if (gen !== editorGenRef.current) return;
- editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus);
- })();
- }
- },
- // !searchOpen: typing 'v' or '[' in the search bar is search input, not
- // a command. No !dumpMode here — v should work after [ (the [ handler
- // guards itself inline).
- {
- isActive: screen === 'transcript' && virtualScrollActive && !searchOpen
- });
+ setStatus(`rendering ${deferredMessages.length} messages…`)
+ void (async () => {
+ try {
+ // Width = terminal minus vim's line-number gutter (4 digits +
+ // space + slack). Floor at 80. PassThrough has no .columns so
+ // without this Ink defaults to 80. Trailing-space strip: right-
+ // aligned timestamps still leave a flexbox spacer run at EOL.
+ // eslint-disable-next-line custom-rules/prefer-use-terminal-size -- one-shot at keypress time, not a reactive render dep
+ const w = Math.max(80, (process.stdout.columns ?? 80) - 6)
+ const raw = await renderMessagesToPlainText(
+ deferredMessages,
+ tools,
+ w,
+ )
+ const text = raw.replace(/[ \t]+$/gm, '')
+ const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`)
+ await writeFile(path, text)
+ const opened = openFileInExternalEditor(path)
+ setStatus(
+ opened
+ ? `opening ${path}`
+ : `wrote ${path} · no $VISUAL/$EDITOR set`,
+ )
+ } catch (e) {
+ setStatus(
+ `render failed: ${e instanceof Error ? e.message : String(e)}`,
+ )
+ }
+ editorRenderingRef.current = false
+ if (gen !== editorGenRef.current) return
+ editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus)
+ })()
+ }
+ },
+ // !searchOpen: typing 'v' or '[' in the search bar is search input, not
+ // a command. No !dumpMode here — v should work after [ (the [ handler
+ // guards itself inline).
+ { isActive: screen === 'transcript' && virtualScrollActive && !searchOpen },
+ )
// Fresh `less` per transcript entry. Prevents stale highlights matching
// unrelated normal-mode text (overlay is alt-screen-global) and avoids
// surprise n/N on re-entry. Same exit resets [ dump mode — each ctrl+o
// entry is a fresh instance.
- const inTranscript = screen === 'transcript' && virtualScrollActive;
+ const inTranscript = screen === 'transcript' && virtualScrollActive
useEffect(() => {
if (!inTranscript) {
- setSearchQuery('');
- setSearchCount(0);
- setSearchCurrent(0);
- setSearchOpen(false);
- editorGenRef.current++;
- clearTimeout(editorTimerRef.current);
- setDumpMode(false);
- setEditorStatus('');
+ setSearchQuery('')
+ setSearchCount(0)
+ setSearchCurrent(0)
+ setSearchOpen(false)
+ editorGenRef.current++
+ clearTimeout(editorTimerRef.current)
+ setDumpMode(false)
+ setEditorStatus('')
}
- }, [inTranscript]);
+ }, [inTranscript])
useEffect(() => {
- setHighlight(inTranscript ? searchQuery : '');
+ setHighlight(inTranscript ? searchQuery : '')
// Clear the position-based CURRENT (yellow) overlay too. setHighlight
// only clears the scan-based inverse. Without this, the yellow box
// persists at its last screen coords after ctrl-c exits transcript.
- if (!inTranscript) setPositions(null);
- }, [inTranscript, searchQuery, setHighlight, setPositions]);
+ if (!inTranscript) setPositions(null)
+ }, [inTranscript, searchQuery, setHighlight, setPositions])
+
const globalKeybindingProps = {
screen,
setScreen,
@@ -4374,21 +5780,28 @@ export function REPL({
// doesn't stopPropagation, so without this gate transcript:exit
// would fire on the same Esc that cancels the bar (child registers
// first, fires first, bubbles).
- searchBarOpen: searchOpen
- };
+ searchBarOpen: searchOpen,
+ }
// Use frozen lengths to slice arrays, avoiding memory overhead of cloning
- const transcriptMessages = frozenTranscriptState ? deferredMessages.slice(0, frozenTranscriptState.messagesLength) : deferredMessages;
- const transcriptStreamingToolUses = frozenTranscriptState ? streamingToolUses.slice(0, frozenTranscriptState.streamingToolUsesLength) : streamingToolUses;
+ const transcriptMessages = frozenTranscriptState
+ ? deferredMessages.slice(0, frozenTranscriptState.messagesLength)
+ : deferredMessages
+ const transcriptStreamingToolUses = frozenTranscriptState
+ ? streamingToolUses.slice(0, frozenTranscriptState.streamingToolUsesLength)
+ : streamingToolUses
// Handle shift+down for teammate navigation and background task management.
// Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open —
// otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input.
useBackgroundTaskNavigation({
- onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true)
- });
+ onOpenBackgroundTasks: isShowingLocalJSXCommand
+ ? undefined
+ : () => setShowBashesDialog(true),
+ })
// Auto-exit viewing mode when teammate completes or errors
- useTeammateViewAutoExit();
+ useTeammateViewAutoExit()
+
if (screen === 'transcript') {
// Virtual scroll replaces the 30-message cap: everything is scrollable
// and memory is bounded by the viewport. Without it, wrapping transcript
@@ -4398,81 +5811,166 @@ export function REPL({
// scrollback, 30-cap + Ctrl+E. Reusing scrollRef is safe — normal-mode
// and transcript-mode are mutually exclusive (this early return), so
// only one ScrollBox is ever mounted at a time.
- const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined;
- const transcriptMessagesElement = ;
- const transcriptToolJSX = toolJSX &&
+ const transcriptScrollRef =
+ isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode
+ ? scrollRef
+ : undefined
+ const transcriptMessagesElement = (
+
+ )
+ const transcriptToolJSX = toolJSX && (
+
{toolJSX.jsx}
- ;
- const transcriptReturn =
-
+
+ )
+ const transcriptReturn = (
+
+
- {feature('VOICE_MODE') ? : null}
-
- {transcriptScrollRef ?
- // ScrollKeybindingHandler must mount before CancelRequestHandler so
- // ctrl+c-with-selection copies instead of cancelling the active task.
- // Its raw useInput handler only stops propagation when a selection
- // exists — without one, ctrl+c falls through to CancelRequestHandler.
- jumpRef.current?.disarmSearch()} /> : null}
+ {feature('VOICE_MODE') ? (
+
+ ) : null}
+
+ {transcriptScrollRef ? (
+ // ScrollKeybindingHandler must mount before CancelRequestHandler so
+ // ctrl+c-with-selection copies instead of cancelling the active task.
+ // Its raw useInput handler only stops propagation when a selection
+ // exists — without one, ctrl+c falls through to CancelRequestHandler.
+ jumpRef.current?.disarmSearch()}
+ />
+ ) : null}
- {transcriptScrollRef ?
+ {transcriptScrollRef ? (
+
{transcriptMessagesElement}
{transcriptToolJSX}
- >} bottom={searchOpen ? {
- // Enter — commit. 0-match guard: junk query shouldn't
- // persist (badge hidden, n/N dead anyway).
- setSearchQuery(searchCount > 0 ? q : '');
- setSearchOpen(false);
- // onCancel path: bar unmounts before its useEffect([query])
- // can fire with ''. Without this, searchCount stays stale
- // (n guard at :4956 passes) and VML's matches[] too
- // (nextMatch walks the old array). Phantom nav, no
- // highlight. onExit (Enter, q non-empty) still commits.
- if (!q) {
- setSearchCount(0);
- setSearchCurrent(0);
- jumpRef.current?.setSearchQuery('');
- }
- }} onCancel={() => {
- // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired
- // with whatever was typed. searchQuery (REPL state)
- // is unchanged since / (onClose = commit, didn't run).
- // Two VML calls: '' restores anchor (0-match else-
- // branch), then searchQuery re-scans from anchor's
- // nearest. Both synchronous — one React batch.
- // setHighlight explicit: REPL's sync-effect dep is
- // searchQuery (unchanged), wouldn't re-fire.
- setSearchOpen(false);
- jumpRef.current?.setSearchQuery('');
- jumpRef.current?.setSearchQuery(searchQuery);
- setHighlight(searchQuery);
- }} setHighlight={setHighlight} /> : 0 ? {
- current: searchCurrent,
- count: searchCount
- } : undefined} />} /> : <>
+ >
+ }
+ bottom={
+ searchOpen ? (
+ {
+ // Enter — commit. 0-match guard: junk query shouldn't
+ // persist (badge hidden, n/N dead anyway).
+ setSearchQuery(searchCount > 0 ? q : '')
+ setSearchOpen(false)
+ // onCancel path: bar unmounts before its useEffect([query])
+ // can fire with ''. Without this, searchCount stays stale
+ // (n guard at :4956 passes) and VML's matches[] too
+ // (nextMatch walks the old array). Phantom nav, no
+ // highlight. onExit (Enter, q non-empty) still commits.
+ if (!q) {
+ setSearchCount(0)
+ setSearchCurrent(0)
+ jumpRef.current?.setSearchQuery('')
+ }
+ }}
+ onCancel={() => {
+ // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired
+ // with whatever was typed. searchQuery (REPL state)
+ // is unchanged since / (onClose = commit, didn't run).
+ // Two VML calls: '' restores anchor (0-match else-
+ // branch), then searchQuery re-scans from anchor's
+ // nearest. Both synchronous — one React batch.
+ // setHighlight explicit: REPL's sync-effect dep is
+ // searchQuery (unchanged), wouldn't re-fire.
+ setSearchOpen(false)
+ jumpRef.current?.setSearchQuery('')
+ jumpRef.current?.setSearchQuery(searchQuery)
+ setHighlight(searchQuery)
+ }}
+ setHighlight={setHighlight}
+ />
+ ) : (
+ 0
+ ? { current: searchCurrent, count: searchCount }
+ : undefined
+ }
+ />
+ )
+ }
+ />
+ ) : (
+ <>
{transcriptMessagesElement}
{transcriptToolJSX}
-
- >}
- ;
+
+ >
+ )}
+
+ )
// The virtual-scroll branch (FullscreenLayout above) needs
// 's constraint — without it,
// ScrollBox's flexGrow has no ceiling, viewport = content height,
@@ -4482,20 +5980,25 @@ export function REPL({
// stays entered across toggle. The 30-cap dump branch stays
// unwrapped — it wants native terminal scrollback.
if (transcriptScrollRef) {
- return
+ return (
+
{transcriptReturn}
- ;
+
+ )
}
- return transcriptReturn;
+ return transcriptReturn
}
// Get viewed agent task (inlined from selectors for explicit data flow).
// viewedAgentTask: teammate OR local_agent — drives the boolean checks
// below. viewedTeammateTask: teammate-only narrowed, for teammate-specific
// field access (inProgressToolUseIDs).
- const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
- const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined;
- const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined);
+ const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
+ const viewedTeammateTask =
+ viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined
+ const viewedAgentTask =
+ viewedTeammateTask ??
+ (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined)
// Bypass useDeferredValue when streaming text is showing so Messages renders
// the final message in the same frame streaming text clears. Also bypass when
@@ -4503,10 +6006,14 @@ export function REPL({
// responsive); after the turn ends, showing messages immediately prevents a
// jitter gap where the spinner is gone but the answer hasn't appeared yet.
// Only reducedMotion users keep the deferred path during loading.
- const usesSyncMessages = showStreamingText || !isLoading;
+ const usesSyncMessages = showStreamingText || !isLoading
// When viewing an agent, never fall through to leader — empty until
// bootstrap/stream fills. Closes the see-leader-type-agent footgun.
- const displayedMessages = viewedAgentTask ? viewedAgentTask.messages ?? [] : usesSyncMessages ? messages : deferredMessages;
+ const displayedMessages = viewedAgentTask
+ ? (viewedAgentTask.messages ?? [])
+ : usesSyncMessages
+ ? messages
+ : deferredMessages
// Show the placeholder until the real user message appears in
// displayedMessages. userInputOnProcessing stays set for the whole turn
// (cleared in resetLoadingState); this length check hides it once
@@ -4515,20 +6022,46 @@ export function REPL({
// while deferredMessages lags behind messages. Suppressed when viewing an
// agent — displayedMessages is a different array there, and onAgentSubmit
// doesn't use the placeholder anyway.
- const placeholderText = userInputOnProcessing && !viewedAgentTask && displayedMessages.length <= userInputBaselineRef.current ? userInputOnProcessing : undefined;
- const toolPermissionOverlay = focusedInputDialog === 'tool-permission' ? setToolUseConfirmQueue(([_, ...tail]) => tail)} onReject={handleQueuedCommandOnCancel} toolUseConfirm={toolUseConfirmQueue[0]!} toolUseContext={getToolUseContext(messages, messages, abortController ?? createAbortController(), mainLoopModel)} verbose={verbose} workerBadge={toolUseConfirmQueue[0]?.workerBadge} setStickyFooter={isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined} /> : null;
+ const placeholderText =
+ userInputOnProcessing &&
+ !viewedAgentTask &&
+ displayedMessages.length <= userInputBaselineRef.current
+ ? userInputOnProcessing
+ : undefined
+
+ const toolPermissionOverlay =
+ focusedInputDialog === 'tool-permission' ? (
+ setToolUseConfirmQueue(([_, ...tail]) => tail)}
+ onReject={handleQueuedCommandOnCancel}
+ toolUseConfirm={toolUseConfirmQueue[0]!}
+ toolUseContext={getToolUseContext(
+ messages,
+ messages,
+ abortController ?? createAbortController(),
+ mainLoopModel,
+ )}
+ verbose={verbose}
+ workerBadge={toolUseConfirmQueue[0]?.workerBadge}
+ setStickyFooter={
+ isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined
+ }
+ />
+ ) : null
// Narrow terminals: companion collapses to a one-liner that REPL stacks
// on its own row (above input in fullscreen, below in scrollback) instead
// of row-beside. Wide terminals keep the row layout with sprite on the right.
- const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE;
+ const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE
// Hide the sprite when PromptInput early-returns BackgroundTasksDialog.
// The sprite sits as a row sibling of PromptInput, so the dialog's Pane
// divider draws at useTerminalSize() width but only gets terminalWidth -
// spriteWidth — divider stops short and dialog text wraps early. Don't
// check footerSelection: pill FOCUS (arrow-down to tasks pill) must keep
// the sprite visible so arrow-right can navigate to it.
- const companionVisible = !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog;
+ const companionVisible =
+ !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog
// In fullscreen, ALL local-jsx slash commands float in the modal slot —
// FullscreenLayout wraps them in an absolute-positioned bottom-anchored
@@ -4537,19 +6070,36 @@ export function REPL({
// render paths below. Commands that used to route through bottom
// (immediate: /model, /mcp, /btw, ...) and scrollable (non-immediate:
// /config, /theme, /diff, ...) both go here now.
- const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true;
- const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null;
+ const toolJsxCentered =
+ isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true
+ const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null
// at the root: everything below is inside its
// . Handlers/contexts are zero-height so ScrollBox's
// flexGrow in FullscreenLayout resolves against this Box. The transcript
// early return above wraps its virtual-scroll branch the same way; only
// the 30-cap dump branch stays unwrapped for native terminal scrollback.
- const mainReturn =
-
+ const mainReturn = (
+
+
- {feature('VOICE_MODE') ? : null}
-
+ {feature('VOICE_MODE') ? (
+
+ ) : null}
+
{/* ScrollKeybindingHandler must mount before CancelRequestHandler so
ctrl+c-with-selection copies instead of cancelling the active task.
Its raw useInput handler only stops propagation when a selection
@@ -4558,37 +6108,156 @@ export function REPL({
the modal's inner ScrollBox is not keyboard-driven. onScroll
stays suppressed while a modal is showing so scroll doesn't
stamp divider/pill state. */}
-
- {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? : null}
+
+ {feature('MESSAGE_ACTIONS') &&
+ isFullscreenEnvEnabled() &&
+ !disableMessageActions ? (
+
+ ) : null}
-
- : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => {
- setCursor(null);
- jumpToNew(scrollRef.current);
- }} scrollable={<>
+
+
+ ) : undefined
+ }
+ modal={centeredModal}
+ modalScrollRef={modalScrollRef}
+ dividerYRef={dividerYRef}
+ hidePill={!!viewedAgentTask}
+ hideSticky={!!viewedTeammateTask}
+ newMessageCount={unseenDivider?.count ?? 0}
+ onPillClick={() => {
+ setCursor(null)
+ jumpToNew(scrollRef.current)
+ }}
+ scrollable={
+ <>
-
+
{/* Hide the processing placeholder while a modal is showing —
it would sit at the last visible transcript row right above
the ▔ divider, showing "❯ /config" as redundant clutter
(the modal IS the /config UI). Outside modals it stays so
the user sees their input echoed while Claude processes. */}
- {!disabled && placeholderText && !centeredModal && }
- {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered &&
+ {!disabled && placeholderText && !centeredModal && (
+
+ )}
+ {toolJSX &&
+ !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) &&
+ !toolJsxCentered && (
+
{toolJSX.jsx}
- }
- {(process.env.USER_TYPE) === 'ant' && }
- {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null}
+
+ )}
+ {process.env.USER_TYPE === 'ant' && }
+ {feature('WEB_BROWSER_TOOL')
+ ? WebBrowserPanelModule && (
+
+ )
+ : null}
- {showSpinner && 0} leaderIsIdle={!isLoading} />}
- {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && }
+ {showSpinner && (
+ 0}
+ leaderIsIdle={!isLoading}
+ />
+ )}
+ {!showSpinner &&
+ !isLoading &&
+ !userInputOnProcessing &&
+ !hasRunningTeammates &&
+ isBriefOnly &&
+ !viewedAgentTask && }
{isFullscreenEnvEnabled() && }
- >} bottom={
- {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? : null}
+ >
+ }
+ bottom={
+
+ {feature('BUDDY') &&
+ companionNarrow &&
+ isFullscreenEnvEnabled() &&
+ companionVisible ? (
+
+ ) : null}
{permissionStickyFooter}
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
@@ -4600,406 +6269,781 @@ export function REPL({
stays in scrollable: the main loop is paused so no jiggle,
and their tall content (DiffDetailView renders up to 400
lines with no internal scroll) needs the outer ScrollBox. */}
- {toolJSX?.isLocalJSXCommand && toolJSX.isImmediate && !toolJsxCentered &&
+ {toolJSX?.isLocalJSXCommand &&
+ toolJSX.isImmediate &&
+ !toolJsxCentered && (
+
{toolJSX.jsx}
- }
- {!showSpinner && !toolJSX?.isLocalJSXCommand && showExpandedTodos && tasksV2 && tasksV2.length > 0 &&
+
+ )}
+ {!showSpinner &&
+ !toolJSX?.isLocalJSXCommand &&
+ showExpandedTodos &&
+ tasksV2 &&
+ tasksV2.length > 0 && (
+
- }
- {focusedInputDialog === 'sandbox-permission' && {
- const {
- allow,
- persistToSettings
- } = response;
- const currentRequest = sandboxPermissionRequestQueue[0];
- if (!currentRequest) return;
- const approvedHost = currentRequest.hostPattern.host;
- if (persistToSettings) {
- const update = {
- type: 'addRules' as const,
- rules: [{
- toolName: WEB_FETCH_TOOL_NAME,
- ruleContent: `domain:${approvedHost}`
- }],
- behavior: (allow ? 'allow' : 'deny') as 'allow' | 'deny',
- destination: 'localSettings' as const
- };
- setAppState(prev => ({
- ...prev,
- toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update)
- }));
- persistPermissionUpdate(update);
+
+ )}
+ {focusedInputDialog === 'sandbox-permission' && (
+ {
+ const { allow, persistToSettings } = response
+ const currentRequest = sandboxPermissionRequestQueue[0]
+ if (!currentRequest) return
- // Immediately update sandbox in-memory config to prevent race conditions
- // where pending requests slip through before settings change is detected
- SandboxManager.refreshConfig();
- }
+ const approvedHost = currentRequest.hostPattern.host
- // Resolve ALL pending requests for the same host (not just the first one)
- // This handles the case where multiple parallel requests came in for the same domain
- setSandboxPermissionRequestQueue(queue => {
- queue.filter(item => item.hostPattern.host === approvedHost).forEach(item => item.resolvePromise(allow));
- return queue.filter(item => item.hostPattern.host !== approvedHost);
- });
+ if (persistToSettings) {
+ const update = {
+ type: 'addRules' as const,
+ rules: [
+ {
+ toolName: WEB_FETCH_TOOL_NAME,
+ ruleContent: `domain:${approvedHost}`,
+ },
+ ],
+ behavior: (allow ? 'allow' : 'deny') as
+ | 'allow'
+ | 'deny',
+ destination: 'localSettings' as const,
+ }
- // Clean up bridge subscriptions and cancel remote prompts
- // for this host since the local user already responded.
- const cleanups = sandboxBridgeCleanupRef.current.get(approvedHost);
- if (cleanups) {
- for (const fn of cleanups) {
- fn();
- }
- sandboxBridgeCleanupRef.current.delete(approvedHost);
- }
- }} />}
- {focusedInputDialog === 'prompt' && {
- const item = promptQueue[0];
- if (!item) return;
- item.resolve({
- prompt_response: item.request.prompt,
- selected: selectedKey
- });
- setPromptQueue(([, ...tail]) => tail);
- }} onAbort={() => {
- const item = promptQueue[0];
- if (!item) return;
- item.reject(new Error('Prompt cancelled by user'));
- setPromptQueue(([, ...tail]) => tail);
- }} />}
+ setAppState(prev => ({
+ ...prev,
+ toolPermissionContext: applyPermissionUpdate(
+ prev.toolPermissionContext,
+ update,
+ ),
+ }))
+
+ persistPermissionUpdate(update)
+
+ // Immediately update sandbox in-memory config to prevent race conditions
+ // where pending requests slip through before settings change is detected
+ SandboxManager.refreshConfig()
+ }
+
+ // Resolve ALL pending requests for the same host (not just the first one)
+ // This handles the case where multiple parallel requests came in for the same domain
+ setSandboxPermissionRequestQueue(queue => {
+ queue
+ .filter(
+ item => item.hostPattern.host === approvedHost,
+ )
+ .forEach(item => item.resolvePromise(allow))
+ return queue.filter(
+ item => item.hostPattern.host !== approvedHost,
+ )
+ })
+
+ // Clean up bridge subscriptions and cancel remote prompts
+ // for this host since the local user already responded.
+ const cleanups =
+ sandboxBridgeCleanupRef.current.get(approvedHost)
+ if (cleanups) {
+ for (const fn of cleanups) {
+ fn()
+ }
+ sandboxBridgeCleanupRef.current.delete(approvedHost)
+ }
+ }}
+ />
+ )}
+ {focusedInputDialog === 'prompt' && (
+ {
+ const item = promptQueue[0]
+ if (!item) return
+ item.resolve({
+ prompt_response: item.request.prompt,
+ selected: selectedKey,
+ })
+ setPromptQueue(([, ...tail]) => tail)
+ }}
+ onAbort={() => {
+ const item = promptQueue[0]
+ if (!item) return
+ item.reject(new Error('Prompt cancelled by user'))
+ setPromptQueue(([, ...tail]) => tail)
+ }}
+ />
+ )}
{/* Show pending indicator on worker while waiting for leader approval */}
- {pendingWorkerRequest && }
+ {pendingWorkerRequest && (
+
+ )}
{/* Show pending indicator for sandbox permission on worker side */}
- {pendingSandboxRequest && }
+ {pendingSandboxRequest && (
+
+ )}
{/* Worker sandbox permission requests from swarm workers */}
- {focusedInputDialog === 'worker-sandbox-permission' && {
- const {
- allow,
- persistToSettings
- } = response;
- const currentRequest = workerSandboxPermissions.queue[0];
- if (!currentRequest) return;
- const approvedHost = currentRequest.host;
+ {focusedInputDialog === 'worker-sandbox-permission' && (
+ {
+ const { allow, persistToSettings } = response
+ const currentRequest = workerSandboxPermissions.queue[0]
+ if (!currentRequest) return
- // Send response via mailbox to the worker
- void sendSandboxPermissionResponseViaMailbox(currentRequest.workerName, currentRequest.requestId, approvedHost, allow, teamContext?.teamName);
- if (persistToSettings && allow) {
- const update = {
- type: 'addRules' as const,
- rules: [{
- toolName: WEB_FETCH_TOOL_NAME,
- ruleContent: `domain:${approvedHost}`
- }],
- behavior: 'allow' as const,
- destination: 'localSettings' as const
- };
- setAppState(prev => ({
- ...prev,
- toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update)
- }));
- persistPermissionUpdate(update);
- SandboxManager.refreshConfig();
- }
+ const approvedHost = currentRequest.host
- // Remove from queue
- setAppState(prev => ({
- ...prev,
- workerSandboxPermissions: {
- ...prev.workerSandboxPermissions,
- queue: prev.workerSandboxPermissions.queue.slice(1)
- }
- }));
- }} />}
- {focusedInputDialog === 'elicitation' && {
- const currentRequest = elicitation.queue[0];
- if (!currentRequest) return;
- // Call respond callback to resolve Promise
- currentRequest.respond({
- action,
- content
- });
- // For URL accept, keep in queue for phase 2
- const isUrlAccept = currentRequest.params.mode === 'url' && action === 'accept';
- if (!isUrlAccept) {
- setAppState(prev => ({
- ...prev,
- elicitation: {
- queue: prev.elicitation.queue.slice(1)
- }
- }));
- }
- }} onWaitingDismiss={action => {
- const currentRequest = elicitation.queue[0];
- // Remove from queue
- setAppState(prev => ({
- ...prev,
- elicitation: {
- queue: prev.elicitation.queue.slice(1)
- }
- }));
- currentRequest?.onWaitingDismiss?.(action);
- }} />}
- {focusedInputDialog === 'cost' && {
- setShowCostDialog(false);
- setHaveShownCostDialog(true);
- saveGlobalConfig(current => ({
- ...current,
- hasAcknowledgedCostThreshold: true
- }));
- logEvent('tengu_cost_threshold_acknowledged', {});
- }} />}
- {focusedInputDialog === 'idle-return' && idleReturnPending && {
- const pending = idleReturnPending;
- setIdleReturnPending(null);
- logEvent('tengu_idle_return_action', {
- action: action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- idleMinutes: Math.round(pending.idleMinutes),
- messageCount: messagesRef.current.length,
- totalInputTokens: getTotalInputTokens()
- });
- if (action === 'dismiss') {
- setInputValue(pending.input);
- return;
- }
- if (action === 'never') {
- saveGlobalConfig(current => {
- if (current.idleReturnDismissed) return current;
- return {
- ...current,
- idleReturnDismissed: true
- };
- });
- }
- if (action === 'clear') {
- const {
- clearConversation
- } = await import('../commands/clear/conversation.js');
- await clearConversation({
- setMessages,
- readFileState: readFileState.current,
- discoveredSkillNames: discoveredSkillNamesRef.current,
- loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current,
- getAppState: () => store.getState(),
- setAppState,
- setConversationId
- });
- haikuTitleAttemptedRef.current = false;
- setHaikuTitle(undefined);
- bashTools.current.clear();
- bashToolsProcessedIdx.current = 0;
- }
- skipIdleCheckRef.current = true;
- void onSubmitRef.current(pending.input, {
- setCursorOffset: () => {},
- clearBuffer: () => {},
- resetHistory: () => {}
- });
- }} />}
- {focusedInputDialog === 'ide-onboarding' &&