diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index 06e72d047..d8c7ae473 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -1,162 +1,114 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import React, { useEffect, useRef, useState } from 'react'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import type { AppState } from '../state/AppStateStore.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { isFullscreenActive } from '../utils/fullscreen.js'; -import type { Theme } from '../utils/theme.js'; -import { getCompanion } from './companion.js'; -import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'; -import { RARITY_COLORS } from './types.js'; -const TICK_MS = 500; -const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms -const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go -const PET_BURST_MS = 2500; // how long hearts float after /buddy pet +import { feature } from 'bun:bundle' +import figures from 'figures' +import React, { useEffect, useRef, useState } from 'react' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import type { AppState } from '../state/AppStateStore.js' +import { getGlobalConfig } from '../utils/config.js' +import { isFullscreenActive } from '../utils/fullscreen.js' +import type { Theme } from '../utils/theme.js' +import { getCompanion } from './companion.js' +import { renderFace, renderSprite, spriteFrameCount } from './sprites.js' +import { RARITY_COLORS } from './types.js' + +const TICK_MS = 500 +const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms +const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go +const PET_BURST_MS = 2500 // how long hearts float after /buddy pet // Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink. // Sequence indices map to sprite frames; -1 means "blink on frame 0". -const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; +const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0] // Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite. -const H = figures.heart; -const PET_HEARTS = [` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · ']; +const H = figures.heart +const PET_HEARTS = [ + ` ${H} ${H} `, + ` ${H} ${H} ${H} `, + ` ${H} ${H} ${H} `, + `${H} ${H} ${H} `, + '· · · ', +] + function wrap(text: string, width: number): string[] { - const words = text.split(' '); - const lines: string[] = []; - let cur = ''; + const words = text.split(' ') + const lines: string[] = [] + let cur = '' for (const w of words) { if (cur.length + w.length + 1 > width && cur) { - lines.push(cur); - cur = w; + lines.push(cur) + cur = w } else { - cur = cur ? `${cur} ${w}` : w; + cur = cur ? `${cur} ${w}` : w } } - if (cur) lines.push(cur); - return lines; + if (cur) lines.push(cur) + return lines } -function SpeechBubble(t0) { - const $ = _c(31); - const { - text, - color, - fading, - tail - } = t0; - let T0; - let borderColor; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - if ($[0] !== color || $[1] !== fading || $[2] !== text) { - const lines = wrap(text, 30); - borderColor = fading ? "inactive" : color; - T0 = Box; - t1 = "column"; - t2 = "round"; - t3 = borderColor; - t4 = 1; - t5 = 34; - let t7; - if ($[11] !== fading) { - t7 = (l, i) => {l}; - $[11] = fading; - $[12] = t7; - } else { - t7 = $[12]; - } - t6 = lines.map(t7); - $[0] = color; - $[1] = fading; - $[2] = text; - $[3] = T0; - $[4] = borderColor; - $[5] = t1; - $[6] = t2; - $[7] = t3; - $[8] = t4; - $[9] = t5; - $[10] = t6; - } else { - T0 = $[3]; - borderColor = $[4]; - t1 = $[5]; - t2 = $[6]; - t3 = $[7]; - t4 = $[8]; - t5 = $[9]; - t6 = $[10]; + +function SpeechBubble({ + text, + color, + fading, + tail, +}: { + text: string + color: keyof Theme + fading: boolean + tail: 'down' | 'right' +}): React.ReactNode { + const lines = wrap(text, 30) + const borderColor = fading ? 'inactive' : color + const bubble = ( + + {lines.map((l, i) => ( + + {l} + + ))} + + ) + if (tail === 'right') { + return ( + + {bubble} + + + ) } - let t7; - if ($[13] !== T0 || $[14] !== t1 || $[15] !== t2 || $[16] !== t3 || $[17] !== t4 || $[18] !== t5 || $[19] !== t6) { - t7 = {t6}; - $[13] = T0; - $[14] = t1; - $[15] = t2; - $[16] = t3; - $[17] = t4; - $[18] = t5; - $[19] = t6; - $[20] = t7; - } else { - t7 = $[20]; - } - const bubble = t7; - if (tail === "right") { - let t8; - if ($[21] !== borderColor) { - t8 = ; - $[21] = borderColor; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== bubble || $[24] !== t8) { - t9 = {bubble}{t8}; - $[23] = bubble; - $[24] = t8; - $[25] = t9; - } else { - t9 = $[25]; - } - return t9; - } - let t8; - if ($[26] !== borderColor) { - t8 = ; - $[26] = borderColor; - $[27] = t8; - } else { - t8 = $[27]; - } - let t9; - if ($[28] !== bubble || $[29] !== t8) { - t9 = {bubble}{t8}; - $[28] = bubble; - $[29] = t8; - $[30] = t9; - } else { - t9 = $[30]; - } - return t9; + return ( + + {bubble} + + + + + + ) } -export const MIN_COLS_FOR_FULL_SPRITE = 100; -const SPRITE_BODY_WIDTH = 12; -const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name ` -const SPRITE_PADDING_X = 2; -const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column -const NARROW_QUIP_CAP = 24; + +export const MIN_COLS_FOR_FULL_SPRITE = 100 +const SPRITE_BODY_WIDTH = 12 +const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name ` +const SPRITE_PADDING_X = 2 +const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column +const NARROW_QUIP_CAP = 24 + function spriteColWidth(nameWidth: number): number { - return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD); + return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD) } // Width the sprite area consumes. PromptInput subtracts this so text wraps @@ -164,115 +116,171 @@ function spriteColWidth(nameWidth: number): number { // width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more. // Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row // (above input in fullscreen, below in scrollback), so no reservation. -export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { - if (!feature('BUDDY')) return 0; - const companion = getCompanion(); - if (!companion || getGlobalConfig().companionMuted) return 0; - if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; - const nameWidth = stringWidth(companion.name); - const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; - return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; +export function companionReservedColumns( + terminalColumns: number, + speaking: boolean, +): number { + if (!feature('BUDDY')) return 0 + const companion = getCompanion() + if (!companion || getGlobalConfig().companionMuted) return 0 + if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0 + const nameWidth = stringWidth(companion.name) + const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0 + return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble } + export function CompanionSprite(): React.ReactNode { - const reaction = useAppState(s => s.companionReaction); - const petAt = useAppState(s => s.companionPetAt); - const focused = useAppState(s => s.footerSelection === 'companion'); - const setAppState = useSetAppState(); - const { - columns - } = useTerminalSize(); - const [tick, setTick] = useState(0); - const lastSpokeTick = useRef(0); + const reaction = useAppState(s => s.companionReaction) + const petAt = useAppState(s => s.companionPetAt) + const focused = useAppState(s => s.footerSelection === 'companion') + const setAppState = useSetAppState() + const { columns } = useTerminalSize() + const [tick, setTick] = useState(0) + const lastSpokeTick = useRef(0) // Sync-during-render (not useEffect) so the first post-pet render already // has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped. - const [{ - petStartTick, - forPetAt - }, setPetStart] = useState({ + const [{ petStartTick, forPetAt }, setPetStart] = useState({ petStartTick: 0, - forPetAt: petAt - }); + forPetAt: petAt, + }) if (petAt !== forPetAt) { - setPetStart({ - petStartTick: tick, - forPetAt: petAt - }); + setPetStart({ petStartTick: tick, forPetAt: petAt }) } + useEffect(() => { - const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick); - return () => clearInterval(timer); - }, []); + const timer = setInterval( + setT => setT((t: number) => t + 1), + TICK_MS, + setTick, + ) + return () => clearInterval(timer) + }, []) + useEffect(() => { - if (!reaction) return; - lastSpokeTick.current = tick; - const timer = setTimeout(setA => setA((prev: AppState) => prev.companionReaction === undefined ? prev : { - ...prev, - companionReaction: undefined - }), BUBBLE_SHOW * TICK_MS, setAppState); - return () => clearTimeout(timer); + if (!reaction) return + lastSpokeTick.current = tick + const timer = setTimeout( + setA => + setA((prev: AppState) => + prev.companionReaction === undefined + ? prev + : { ...prev, companionReaction: undefined }, + ), + BUBBLE_SHOW * TICK_MS, + setAppState, + ) + return () => clearTimeout(timer) // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked - }, [reaction, setAppState]); - if (!feature('BUDDY')) return null; - const companion = getCompanion(); - if (!companion || getGlobalConfig().companionMuted) return null; - const color = RARITY_COLORS[companion.rarity]; - const colWidth = spriteColWidth(stringWidth(companion.name)); - const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; - const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; - const petAge = petAt ? tick - petStartTick : Infinity; - const petting = petAge * TICK_MS < PET_BURST_MS; + }, [reaction, setAppState]) + + if (!feature('BUDDY')) return null + const companion = getCompanion() + if (!companion || getGlobalConfig().companionMuted) return null + + const color = RARITY_COLORS[companion.rarity] + const colWidth = spriteColWidth(stringWidth(companion.name)) + + const bubbleAge = reaction ? tick - lastSpokeTick.current : 0 + const fading = + reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW + + const petAge = petAt ? tick - petStartTick : Infinity + const petting = petAge * TICK_MS < PET_BURST_MS // Narrow terminals: collapse to one-line face. When speaking, the quip // replaces the name beside the face (no room for a bubble). if (columns < MIN_COLS_FOR_FULL_SPRITE) { - const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; - const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name; - return + const quip = + reaction && reaction.length > NARROW_QUIP_CAP + ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' + : reaction + const label = quip + ? `"${quip}"` + : focused + ? ` ${companion.name} ` + : companion.name + return ( + {petting && {figures.heart} } {renderFace(companion)} {' '} - + {label} - ; + + ) } - const frameCount = spriteFrameCount(companion.species); - const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; - let spriteFrame: number; - let blink = false; + const frameCount = spriteFrameCount(companion.species) + const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null + + let spriteFrame: number + let blink = false if (reaction || petting) { // Excited: cycle all fidget frames fast - spriteFrame = tick % frameCount; + spriteFrame = tick % frameCount } else { - const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!; + const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]! if (step === -1) { - spriteFrame = 0; - blink = true; + spriteFrame = 0 + blink = true } else { - spriteFrame = step % frameCount; + spriteFrame = step % frameCount } } - const body = renderSprite(companion, spriteFrame).map(line => blink ? line.replaceAll(companion.eye, '-') : line); - const sprite = heartFrame ? [heartFrame, ...body] : body; + + const body = renderSprite(companion, spriteFrame).map(line => + blink ? line.replaceAll(companion.eye, '-') : line, + ) + const sprite = heartFrame ? [heartFrame, ...body] : body // Name row doubles as hint row — unfocused shows dim name + ↓ discovery, // focused shows inverse name. The enter-to-open hint lives in // PromptInputFooter's right column so this row stays one line and the // sprite doesn't jump up when selected. flexShrink=0 stops the // inline-bubble row wrapper from squeezing the sprite to fit. - const spriteColumn = - {sprite.map((line, i) => + const spriteColumn = ( + + {sprite.map((line, i) => ( + {line} - )} - + + ))} + {focused ? ` ${companion.name} ` : companion.name} - ; + + ) + if (!reaction) { - return {spriteColumn}; + return {spriteColumn} } // Fullscreen: bubble renders separately via CompanionFloatingBubble in @@ -281,90 +289,60 @@ export function CompanionSprite(): React.ReactNode { // Non-fullscreen: bubble sits inline beside the sprite (input shrinks) // because floating into Static scrollback can't be cleared. if (isFullscreenActive()) { - return {spriteColumn}; + return {spriteColumn} } - return - + return ( + + {spriteColumn} - ; + + ) } // Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's // bottomFloat slot (outside the overflowY:hidden clip) so it can extend into // the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this // just reads companionReaction and renders the fade. -export function CompanionFloatingBubble() { - const $ = _c(8); - const reaction = useAppState(_temp); - let t0; - if ($[0] !== reaction) { - t0 = { - tick: 0, - forReaction: reaction - }; - $[0] = reaction; - $[1] = t0; - } else { - t0 = $[1]; - } - const [t1, setTick] = useState(t0); - const { - tick, - forReaction - } = t1; +export function CompanionFloatingBubble(): React.ReactNode { + const reaction = useAppState(s => s.companionReaction) + const [{ tick, forReaction }, setTick] = useState({ + tick: 0, + forReaction: reaction, + }) + + // Reset tick synchronously when reaction changes (not in useEffect, which + // runs post-render and would show one stale-faded frame). Storing the + // reaction the tick is counting FOR alongside the tick itself means the + // fade computation never sees a tick from a previous reaction. if (reaction !== forReaction) { - setTick({ - tick: 0, - forReaction: reaction - }); + setTick({ tick: 0, forReaction: reaction }) } - let t2; - let t3; - if ($[2] !== reaction) { - t2 = () => { - if (!reaction) { - return; - } - const timer = setInterval(_temp3, TICK_MS, setTick); - return () => clearInterval(timer); - }; - t3 = [reaction]; - $[2] = reaction; - $[3] = t2; - $[4] = t3; - } else { - t2 = $[3]; - t3 = $[4]; - } - useEffect(t2, t3); - if (!feature("BUDDY") || !reaction) { - return null; - } - const companion = getCompanion(); - if (!companion || getGlobalConfig().companionMuted) { - return null; - } - const t4 = tick >= BUBBLE_SHOW - FADE_WINDOW; - let t5; - if ($[5] !== reaction || $[6] !== t4) { - t5 = ; - $[5] = reaction; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; -} -function _temp3(set) { - return set(_temp2); -} -function _temp2(s_0) { - return { - ...s_0, - tick: s_0.tick + 1 - }; -} -function _temp(s) { - return s.companionReaction; + + useEffect(() => { + if (!reaction) return + const timer = setInterval( + set => set(s => ({ ...s, tick: s.tick + 1 })), + TICK_MS, + setTick, + ) + return () => clearInterval(timer) + }, [reaction]) + + if (!feature('BUDDY') || !reaction) return null + const companion = getCompanion() + if (!companion || getGlobalConfig().companionMuted) return null + + return ( + = BUBBLE_SHOW - FADE_WINDOW} + tail="down" + /> + ) } diff --git a/src/buddy/useBuddyNotification.tsx b/src/buddy/useBuddyNotification.tsx index 645316396..62d61f4cf 100644 --- a/src/buddy/useBuddyNotification.tsx +++ b/src/buddy/useBuddyNotification.tsx @@ -1,97 +1,67 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import React, { useEffect } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { getRainbowColor } from '../utils/thinking.js'; +import { feature } from 'bun:bundle' +import React, { useEffect } from 'react' +import { useNotifications } from '../context/notifications.js' +import { Text } from '../ink.js' +import { getGlobalConfig } from '../utils/config.js' +import { getRainbowColor } from '../utils/thinking.js' // Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter // buzz instead of a single UTC-midnight spike, gentler on soul-gen load. // Teaser window: April 1-7, 2026 only. Command stays live forever after. export function isBuddyTeaserWindow(): boolean { - if ((process.env.USER_TYPE) === 'ant') return true; - const d = new Date(); - return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; + if (process.env.USER_TYPE === 'ant') return true + const d = new Date() + return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7 } + export function isBuddyLive(): boolean { - if ((process.env.USER_TYPE) === 'ant') return true; - const d = new Date(); - return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3; + if (process.env.USER_TYPE === 'ant') return true + const d = new Date() + return ( + d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3) + ) } -function RainbowText(t0) { - const $ = _c(2); - const { - text - } = t0; - let t1; - if ($[0] !== text) { - t1 = <>{[...text].map(_temp)}; - $[0] = text; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + +function RainbowText({ text }: { text: string }): React.ReactNode { + return ( + <> + {[...text].map((ch, i) => ( + + {ch} + + ))} + + ) } // Rainbow /buddy teaser shown on startup when no companion hatched yet. // Idle presence and reactions are handled by CompanionSprite directly. -function _temp(ch, i) { - return {ch}; +export function useBuddyNotification(): void { + const { addNotification, removeNotification } = useNotifications() + + useEffect(() => { + if (!feature('BUDDY')) return + const config = getGlobalConfig() + if (config.companion || !isBuddyTeaserWindow()) return + addNotification({ + key: 'buddy-teaser', + jsx: , + priority: 'immediate', + timeoutMs: 15_000, + }) + return () => removeNotification('buddy-teaser') + }, [addNotification, removeNotification]) } -export function useBuddyNotification() { - const $ = _c(4); - const { - addNotification, - removeNotification - } = useNotifications(); - let t0; - let t1; - if ($[0] !== addNotification || $[1] !== removeNotification) { - t0 = () => { - if (!feature("BUDDY")) { - return; - } - const config = getGlobalConfig(); - if (config.companion || !isBuddyTeaserWindow()) { - return; - } - addNotification({ - key: "buddy-teaser", - jsx: , - priority: "immediate", - timeoutMs: 15000 - }); - return () => removeNotification("buddy-teaser"); - }; - t1 = [addNotification, removeNotification]; - $[0] = addNotification; - $[1] = removeNotification; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - useEffect(t0, t1); -} -export function findBuddyTriggerPositions(text: string): Array<{ - start: number; - end: number; -}> { - if (!feature('BUDDY')) return []; - const triggers: Array<{ - start: number; - end: number; - }> = []; - const re = /\/buddy\b/g; - let m: RegExpExecArray | null; + +export function findBuddyTriggerPositions( + text: string, +): Array<{ start: number; end: number }> { + if (!feature('BUDDY')) return [] + const triggers: Array<{ start: number; end: number }> = [] + const re = /\/buddy\b/g + let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - triggers.push({ - start: m.index, - end: m.index + m[0].length - }); + triggers.push({ start: m.index, end: m.index + m[0].length }) } - return triggers; + return triggers } diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index c144d0452..134918c75 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -3,359 +3,453 @@ * These are dynamically imported only when the corresponding `claude mcp *` command runs. */ -import { stat } from 'fs/promises'; -import pMap from 'p-map'; -import { cwd } from 'process'; -import React from 'react'; -import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'; -import { render } from '../../ink.js'; -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js'; -import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; -import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js'; -import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js'; -import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js'; -import { AppStateProvider } from '../../state/AppState.js'; -import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js'; -import { isFsInaccessible } from '../../utils/errors.js'; -import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; -import { safeParseJSON } from '../../utils/json.js'; -import { getPlatform } from '../../utils/platform.js'; -import { cliError, cliOk } from '../exit.js'; -async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise { +import { stat } from 'fs/promises' +import pMap from 'p-map' +import { cwd } from 'process' +import React from 'react' +import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js' +import { render } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { + clearMcpClientConfig, + clearServerTokensFromLocalStorage, + getMcpClientConfig, + readClientSecret, + saveMcpClientSecret, +} from '../../services/mcp/auth.js' +import { + connectToServer, + getMcpServerConnectionBatchSize, +} from '../../services/mcp/client.js' +import { + addMcpConfig, + getAllMcpConfigs, + getMcpConfigByName, + getMcpConfigsByScope, + removeMcpConfig, +} from '../../services/mcp/config.js' +import type { + ConfigScope, + ScopedMcpServerConfig, +} from '../../services/mcp/types.js' +import { + describeMcpConfigFilePath, + ensureConfigScope, + getScopeLabel, +} from '../../services/mcp/utils.js' +import { AppStateProvider } from '../../state/AppState.js' +import { + getCurrentProjectConfig, + getGlobalConfig, + saveCurrentProjectConfig, +} from '../../utils/config.js' +import { isFsInaccessible } from '../../utils/errors.js' +import { gracefulShutdown } from '../../utils/gracefulShutdown.js' +import { safeParseJSON } from '../../utils/json.js' +import { getPlatform } from '../../utils/platform.js' +import { cliError, cliOk } from '../exit.js' + +async function checkMcpServerHealth( + name: string, + server: ScopedMcpServerConfig, +): Promise { try { - const result = await connectToServer(name, server); + const result = await connectToServer(name, server) if (result.type === 'connected') { - return '✓ Connected'; + return '✓ Connected' } else if (result.type === 'needs-auth') { - return '! Needs authentication'; + return '! Needs authentication' } else { - return '✗ Failed to connect'; + return '✗ Failed to connect' } } catch (_error) { - return '✗ Connection error'; + return '✗ Connection error' } } // mcp serve (lines 4512–4532) export async function mcpServeHandler({ debug, - verbose + verbose, }: { - debug?: boolean; - verbose?: boolean; + debug?: boolean + verbose?: boolean }): Promise { - const providedCwd = cwd(); - logEvent('tengu_mcp_start', {}); + const providedCwd = cwd() + logEvent('tengu_mcp_start', {}) + try { - await stat(providedCwd); + await stat(providedCwd) } catch (error) { if (isFsInaccessible(error)) { - cliError(`Error: Directory ${providedCwd} does not exist`); + cliError(`Error: Directory ${providedCwd} does not exist`) } - throw error; + throw error } + try { - const { - setup - } = await import('../../setup.js'); - await setup(providedCwd, 'default', false, false, undefined, false); - const { - startMCPServer - } = await import('../../entrypoints/mcp.js'); - await startMCPServer(providedCwd, debug ?? false, verbose ?? false); + const { setup } = await import('../../setup.js') + await setup(providedCwd, 'default', false, false, undefined, false) + const { startMCPServer } = await import('../../entrypoints/mcp.js') + await startMCPServer(providedCwd, debug ?? false, verbose ?? false) } catch (error) { - cliError(`Error: Failed to start MCP server: ${error}`); + cliError(`Error: Failed to start MCP server: ${error}`) } } // mcp remove (lines 4545–4635) -export async function mcpRemoveHandler(name: string, options: { - scope?: string; -}): Promise { +export async function mcpRemoveHandler( + name: string, + options: { scope?: string }, +): Promise { // Look up config before removing so we can clean up secure storage - const serverBeforeRemoval = getMcpConfigByName(name); + const serverBeforeRemoval = getMcpConfigByName(name) + const cleanupSecureStorage = () => { - if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) { - clearServerTokensFromLocalStorage(name, serverBeforeRemoval); - clearMcpClientConfig(name, serverBeforeRemoval); + if ( + serverBeforeRemoval && + (serverBeforeRemoval.type === 'sse' || + serverBeforeRemoval.type === 'http') + ) { + clearServerTokensFromLocalStorage(name, serverBeforeRemoval) + clearMcpClientConfig(name, serverBeforeRemoval) } - }; + } + try { if (options.scope) { - const scope = ensureConfigScope(options.scope); + const scope = ensureConfigScope(options.scope) logEvent('tengu_mcp_delete', { name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - await removeMcpConfig(name, scope); - cleanupSecureStorage(); - process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`); - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + await removeMcpConfig(name, scope) + cleanupSecureStorage() + process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`) + cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) } // If no scope specified, check where the server exists - const projectConfig = getCurrentProjectConfig(); - const globalConfig = getGlobalConfig(); + const projectConfig = getCurrentProjectConfig() + const globalConfig = getGlobalConfig() // Check if server exists in project scope (.mcp.json) - const { - servers: projectServers - } = getMcpConfigsByScope('project'); - const mcpJsonExists = !!projectServers[name]; + const { servers: projectServers } = getMcpConfigsByScope('project') + const mcpJsonExists = !!projectServers[name] // Count how many scopes contain this server - const scopes: Array> = []; - if (projectConfig.mcpServers?.[name]) scopes.push('local'); - if (mcpJsonExists) scopes.push('project'); - if (globalConfig.mcpServers?.[name]) scopes.push('user'); + const scopes: Array> = [] + if (projectConfig.mcpServers?.[name]) scopes.push('local') + if (mcpJsonExists) scopes.push('project') + if (globalConfig.mcpServers?.[name]) scopes.push('user') + if (scopes.length === 0) { - cliError(`No MCP server found with name: "${name}"`); + cliError(`No MCP server found with name: "${name}"`) } else if (scopes.length === 1) { // Server exists in only one scope, remove it - const scope = scopes[0]!; + const scope = scopes[0]! logEvent('tengu_mcp_delete', { name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - await removeMcpConfig(name, scope); - cleanupSecureStorage(); - process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`); - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + await removeMcpConfig(name, scope) + cleanupSecureStorage() + process.stdout.write( + `Removed MCP server "${name}" from ${scope} config\n`, + ) + cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) } else { // Server exists in multiple scopes - process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`); + process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`) scopes.forEach(scope => { - process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`); - }); - process.stderr.write('\nTo remove from a specific scope, use:\n'); + process.stderr.write( + ` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`, + ) + }) + process.stderr.write('\nTo remove from a specific scope, use:\n') scopes.forEach(scope => { - process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`); - }); - cliError(); + process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`) + }) + cliError() } } catch (error) { - cliError((error as Error).message); + cliError((error as Error).message) } } // mcp list (lines 4641–4688) export async function mcpListHandler(): Promise { - logEvent('tengu_mcp_list', {}); - const { - servers: configs - } = await getAllMcpConfigs(); + logEvent('tengu_mcp_list', {}) + const { servers: configs } = await getAllMcpConfigs() if (Object.keys(configs).length === 0) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log('No MCP servers configured. Use `claude mcp add` to add a server.'); + console.log( + 'No MCP servers configured. Use `claude mcp add` to add a server.', + ) } else { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log('Checking MCP server health...\n'); + console.log('Checking MCP server health...\n') // Check servers concurrently - const entries = Object.entries(configs); - const results = await pMap(entries, async ([name, server]) => ({ - name, - server, - status: await checkMcpServerHealth(name, server) - }), { - concurrency: getMcpServerConnectionBatchSize() - }); - for (const { - name, - server, - status - } of results) { + const entries = Object.entries(configs) + const results = await pMap( + entries, + async ([name, server]) => ({ + name, + server, + status: await checkMcpServerHealth(name, server), + }), + { concurrency: getMcpServerConnectionBatchSize() }, + ) + + for (const { name, server, status } of results) { // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} (SSE) - ${status}`); + console.log(`${name}: ${server.url} (SSE) - ${status}`) } else if (server.type === 'http') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} (HTTP) - ${status}`); + console.log(`${name}: ${server.url} (HTTP) - ${status}`) } else if (server.type === 'claudeai-proxy') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} - ${status}`); + console.log(`${name}: ${server.url} - ${status}`) } else if (!server.type || server.type === 'stdio') { - const args = Array.isArray((server as any).args) ? (server as any).args : []; + const args = Array.isArray(server.args) ? server.args : [] // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${(server as any).command} ${args.join(' ')} - ${status}`); + console.log(`${name}: ${server.command} ${args.join(' ')} - ${status}`) } } } // Use gracefulShutdown to properly clean up MCP server connections // (process.exit bypasses cleanup handlers, leaving child processes orphaned) - await gracefulShutdown(0); + await gracefulShutdown(0) } // mcp get (lines 4694–4786) export async function mcpGetHandler(name: string): Promise { logEvent('tengu_mcp_get', { - name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const server = getMcpConfigByName(name); + name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const server = getMcpConfigByName(name) if (!server) { - cliError(`No MCP server found with name: ${name}`); + cliError(`No MCP server found with name: ${name}`) } // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}:`); + console.log(`${name}:`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Scope: ${getScopeLabel(server.scope)}`); + console.log(` Scope: ${getScopeLabel(server.scope)}`) // Check server health - const status = await checkMcpServerHealth(name, server); + const status = await checkMcpServerHealth(name, server) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Status: ${status}`); + console.log(` Status: ${status}`) // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: sse`); + console.log(` Type: sse`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`); + console.log(` URL: ${server.url}`) if (server.headers) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Headers:'); + console.log(' Headers:') for (const [key, value] of Object.entries(server.headers)) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}: ${value}`); + console.log(` ${key}: ${value}`) } } if (server.oauth?.clientId || server.oauth?.callbackPort) { - const parts: string[] = []; + const parts: string[] = [] if (server.oauth.clientId) { - parts.push('client_id configured'); - const clientConfig = getMcpClientConfig(name, server); - if (clientConfig?.clientSecret) parts.push('client_secret configured'); + parts.push('client_id configured') + const clientConfig = getMcpClientConfig(name, server) + if (clientConfig?.clientSecret) parts.push('client_secret configured') } - if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + if (server.oauth.callbackPort) + parts.push(`callback_port ${server.oauth.callbackPort}`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`); + console.log(` OAuth: ${parts.join(', ')}`) } } else if (server.type === 'http') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: http`); + console.log(` Type: http`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`); + console.log(` URL: ${server.url}`) if (server.headers) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Headers:'); + console.log(' Headers:') for (const [key, value] of Object.entries(server.headers)) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}: ${value}`); + console.log(` ${key}: ${value}`) } } if (server.oauth?.clientId || server.oauth?.callbackPort) { - const parts: string[] = []; + const parts: string[] = [] if (server.oauth.clientId) { - parts.push('client_id configured'); - const clientConfig = getMcpClientConfig(name, server); - if (clientConfig?.clientSecret) parts.push('client_secret configured'); + parts.push('client_id configured') + const clientConfig = getMcpClientConfig(name, server) + if (clientConfig?.clientSecret) parts.push('client_secret configured') } - if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + if (server.oauth.callbackPort) + parts.push(`callback_port ${server.oauth.callbackPort}`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`); + console.log(` OAuth: ${parts.join(', ')}`) } } else if (server.type === 'stdio') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: stdio`); + console.log(` Type: stdio`) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Command: ${server.command}`); - const args = Array.isArray(server.args) ? server.args : []; + console.log(` Command: ${server.command}`) + const args = Array.isArray(server.args) ? server.args : [] // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Args: ${args.join(' ')}`); + console.log(` Args: ${args.join(' ')}`) if (server.env) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Environment:'); + console.log(' Environment:') for (const [key, value] of Object.entries(server.env)) { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}=${value}`); + console.log(` ${key}=${value}`) } } } // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`); + console.log( + `\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`, + ) // Use gracefulShutdown to properly clean up MCP server connections // (process.exit bypasses cleanup handlers, leaving child processes orphaned) - await gracefulShutdown(0); + await gracefulShutdown(0) } // mcp add-json (lines 4801–4870) -export async function mcpAddJsonHandler(name: string, json: string, options: { - scope?: string; - clientSecret?: true; -}): Promise { +export async function mcpAddJsonHandler( + name: string, + json: string, + options: { scope?: string; clientSecret?: true }, +): Promise { try { - const scope = ensureConfigScope(options.scope); - const parsedJson = safeParseJSON(json); + const scope = ensureConfigScope(options.scope) + const parsedJson = safeParseJSON(json) // Read secret before writing config so cancellation doesn't leave partial state - const needsSecret = options.clientSecret && parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson && (parsedJson.type === 'sse' || parsedJson.type === 'http') && 'url' in parsedJson && typeof parsedJson.url === 'string' && 'oauth' in parsedJson && parsedJson.oauth && typeof parsedJson.oauth === 'object' && 'clientId' in parsedJson.oauth; - const clientSecret = needsSecret ? await readClientSecret() : undefined; - await addMcpConfig(name, parsedJson, scope); - const transportType = parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson ? String(parsedJson.type || 'stdio') : 'stdio'; - if (clientSecret && parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson && (parsedJson.type === 'sse' || parsedJson.type === 'http') && 'url' in parsedJson && typeof parsedJson.url === 'string') { - saveMcpClientSecret(name, { - type: parsedJson.type, - url: parsedJson.url - }, clientSecret); + const needsSecret = + options.clientSecret && + parsedJson && + typeof parsedJson === 'object' && + 'type' in parsedJson && + (parsedJson.type === 'sse' || parsedJson.type === 'http') && + 'url' in parsedJson && + typeof parsedJson.url === 'string' && + 'oauth' in parsedJson && + parsedJson.oauth && + typeof parsedJson.oauth === 'object' && + 'clientId' in parsedJson.oauth + const clientSecret = needsSecret ? await readClientSecret() : undefined + + await addMcpConfig(name, parsedJson, scope) + + const transportType = + parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson + ? String(parsedJson.type || 'stdio') + : 'stdio' + + if ( + clientSecret && + parsedJson && + typeof parsedJson === 'object' && + 'type' in parsedJson && + (parsedJson.type === 'sse' || parsedJson.type === 'http') && + 'url' in parsedJson && + typeof parsedJson.url === 'string' + ) { + saveMcpClientSecret( + name, + { type: parsedJson.type, url: parsedJson.url }, + clientSecret, + ) } + logEvent('tengu_mcp_add', { - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`) } catch (error) { - cliError((error as Error).message); + cliError((error as Error).message) } } // mcp add-from-claude-desktop (lines 4881–4927) export async function mcpAddFromDesktopHandler(options: { - scope?: string; + scope?: string }): Promise { try { - const scope = ensureConfigScope(options.scope); - const platform = getPlatform(); + const scope = ensureConfigScope(options.scope) + const platform = getPlatform() + logEvent('tengu_mcp_add', { - scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const { - readClaudeDesktopMcpServers - } = await import('../../utils/claudeDesktop.js'); - const servers = await readClaudeDesktopMcpServers(); + scope: + scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + platform: + platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + const { readClaudeDesktopMcpServers } = await import( + '../../utils/claudeDesktop.js' + ) + const servers = await readClaudeDesktopMcpServers() + if (Object.keys(servers).length === 0) { - cliOk('No MCP servers found in Claude Desktop configuration or configuration file does not exist.'); + cliOk( + 'No MCP servers found in Claude Desktop configuration or configuration file does not exist.', + ) } - const { - unmount - } = await render( + + const { unmount } = await render( + - { - unmount(); - }} /> + { + unmount() + }} + /> - , { - exitOnCtrlC: true - }); + , + { exitOnCtrlC: true }, + ) } catch (error) { - cliError((error as Error).message); + cliError((error as Error).message) } } // mcp reset-project-choices (lines 4935–4952) export async function mcpResetChoicesHandler(): Promise { - logEvent('tengu_mcp_reset_mcpjson_choices', {}); + logEvent('tengu_mcp_reset_mcpjson_choices', {}) saveCurrentProjectConfig(current => ({ ...current, enabledMcpjsonServers: [], disabledMcpjsonServers: [], - enableAllProjectMcpServers: false - })); - cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start Claude Code.'); + enableAllProjectMcpServers: false, + })) + cliOk( + 'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + + 'You will be prompted for approval next time you start Claude Code.', + ) } diff --git a/src/cli/handlers/util.tsx b/src/cli/handlers/util.tsx index ee042e189..c86b31737 100644 --- a/src/cli/handlers/util.tsx +++ b/src/cli/handlers/util.tsx @@ -1,34 +1,37 @@ -import { c as _c } from "react/compiler-runtime"; /** * Miscellaneous subcommand handlers — extracted from main.tsx for lazy loading. * setup-token, doctor, install */ /* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */ -import { cwd } from 'process'; -import React from 'react'; -import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'; -import { useManagePlugins } from '../../hooks/useManagePlugins.js'; -import type { Root } from '../../ink.js'; -import { Box, Text } from '../../ink.js'; -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'; -import { AppStateProvider } from '../../state/AppState.js'; -import { onChangeAppState } from '../../state/onChangeAppState.js'; -import { isAnthropicAuthEnabled } from '../../utils/auth.js'; +import { cwd } from 'process' +import React from 'react' +import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js' +import { useManagePlugins } from '../../hooks/useManagePlugins.js' +import type { Root } from '../../ink.js' +import { Box, Text } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { logEvent } from '../../services/analytics/index.js' +import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js' +import { AppStateProvider } from '../../state/AppState.js' +import { onChangeAppState } from '../../state/onChangeAppState.js' +import { isAnthropicAuthEnabled } from '../../utils/auth.js' + export async function setupTokenHandler(root: Root): Promise { - logEvent('tengu_setup_token_command', {}); - const showAuthWarning = !isAnthropicAuthEnabled(); - const { - ConsoleOAuthFlow - } = await import('../../components/ConsoleOAuthFlow.js'); + logEvent('tengu_setup_token_command', {}) + + const showAuthWarning = !isAnthropicAuthEnabled() + const { ConsoleOAuthFlow } = await import( + '../../components/ConsoleOAuthFlow.js' + ) await new Promise(resolve => { - root.render( + root.render( + - {showAuthWarning && + {showAuthWarning && ( + Warning: You already have authentication configured via environment variable or API key helper. @@ -37,73 +40,87 @@ export async function setupTokenHandler(root: Root): Promise { The setup-token command will create a new OAuth token which you can use instead. - } - { - void resolve(); - }} mode="setup-token" startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." /> + + )} + { + void resolve() + }} + mode="setup-token" + startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." + /> - ); - }); - root.unmount(); - process.exit(0); + , + ) + }) + root.unmount() + process.exit(0) } // DoctorWithPlugins wrapper + doctor handler -const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ - default: m.Doctor -}))); -function DoctorWithPlugins(t0) { - const $ = _c(2); - const { - onDone - } = t0; - useManagePlugins(); - let t1; - if ($[0] !== onDone) { - t1 = ; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const DoctorLazy = React.lazy(() => + import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })), +) + +function DoctorWithPlugins({ + onDone, +}: { + onDone: () => void +}): React.ReactNode { + useManagePlugins() + return ( + + + + ) } + export async function doctorHandler(root: Root): Promise { - logEvent('tengu_doctor_command', {}); + logEvent('tengu_doctor_command', {}) + await new Promise(resolve => { - root.render( + root.render( + - - { - void resolve(); - }} /> + + { + void resolve() + }} + /> - ); - }); - root.unmount(); - process.exit(0); + , + ) + }) + root.unmount() + process.exit(0) } // install handler -export async function installHandler(target: string | undefined, options: { - force?: boolean; -}): Promise { - const { - setup - } = await import('../../setup.js'); - await setup(cwd(), 'default', false, false, undefined, false); - const { - install - } = await import('../../commands/install.js'); +export async function installHandler( + target: string | undefined, + options: { force?: boolean }, +): Promise { + const { setup } = await import('../../setup.js') + await setup(cwd(), 'default', false, false, undefined, false) + const { install } = await import('../../commands/install.js') await new Promise(resolve => { - const args: string[] = []; - if (target) args.push(target); - if (options.force) args.push('--force'); - void install.call(result => { - void resolve(); - process.exit(result.includes('failed') ? 1 : 0); - }, {}, args); - }); + const args: string[] = [] + if (target) args.push(target) + if (options.force) args.push('--force') + + void install.call( + result => { + void resolve() + process.exit(result.includes('failed') ? 1 : 0) + }, + {}, + args, + ) + }) } diff --git a/src/context/QueuedMessageContext.tsx b/src/context/QueuedMessageContext.tsx index 670f6afb3..575fc8619 100644 --- a/src/context/QueuedMessageContext.tsx +++ b/src/context/QueuedMessageContext.tsx @@ -1,62 +1,45 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box } from '../ink.js'; +import * as React from 'react' +import { Box } from '../ink.js' + type QueuedMessageContextValue = { - isQueued: boolean; - isFirst: boolean; + isQueued: boolean + isFirst: boolean /** Width reduction for container padding (e.g., 4 for paddingX={2}) */ - paddingWidth: number; -}; -const QueuedMessageContext = React.createContext(undefined); -export function useQueuedMessage() { - return React.useContext(QueuedMessageContext); + paddingWidth: number } -const PADDING_X = 2; + +const QueuedMessageContext = React.createContext< + QueuedMessageContextValue | undefined +>(undefined) + +export function useQueuedMessage(): QueuedMessageContextValue | undefined { + return React.useContext(QueuedMessageContext) +} + +const PADDING_X = 2 + type Props = { - isFirst: boolean; - useBriefLayout?: boolean; - children: React.ReactNode; -}; -export function QueuedMessageProvider(t0) { - const $ = _c(9); - const { - isFirst, - useBriefLayout, - children - } = t0; - const padding = useBriefLayout ? 0 : PADDING_X; - const t1 = padding * 2; - let t2; - if ($[0] !== isFirst || $[1] !== t1) { - t2 = { - isQueued: true, - isFirst, - paddingWidth: t1 - }; - $[0] = isFirst; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - const value = t2; - let t3; - if ($[3] !== children || $[4] !== padding) { - t3 = {children}; - $[3] = children; - $[4] = padding; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== t3 || $[7] !== value) { - t4 = {t3}; - $[6] = t3; - $[7] = value; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + isFirst: boolean + useBriefLayout?: boolean + children: React.ReactNode +} + +export function QueuedMessageProvider({ + isFirst, + useBriefLayout, + children, +}: Props): React.ReactNode { + // Brief mode already indents via paddingLeft in HighlightedThinkingText / + // BriefTool UI — adding paddingX here would double-indent the queue. + const padding = useBriefLayout ? 0 : PADDING_X + const value = React.useMemo( + () => ({ isQueued: true, isFirst, paddingWidth: padding * 2 }), + [isFirst, padding], + ) + + return ( + + {children} + + ) } diff --git a/src/context/fpsMetrics.tsx b/src/context/fpsMetrics.tsx index a1c281005..b23a411ec 100644 --- a/src/context/fpsMetrics.tsx +++ b/src/context/fpsMetrics.tsx @@ -1,29 +1,26 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useContext } from 'react'; -import type { FpsMetrics } from '../utils/fpsTracker.js'; -type FpsMetricsGetter = () => FpsMetrics | undefined; -const FpsMetricsContext = createContext(undefined); +import React, { createContext, useContext } from 'react' +import type { FpsMetrics } from '../utils/fpsTracker.js' + +type FpsMetricsGetter = () => FpsMetrics | undefined + +const FpsMetricsContext = createContext(undefined) + type Props = { - getFpsMetrics: FpsMetricsGetter; - children: React.ReactNode; -}; -export function FpsMetricsProvider(t0) { - const $ = _c(3); - const { - getFpsMetrics, - children - } = t0; - let t1; - if ($[0] !== children || $[1] !== getFpsMetrics) { - t1 = {children}; - $[0] = children; - $[1] = getFpsMetrics; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + getFpsMetrics: FpsMetricsGetter + children: React.ReactNode } -export function useFpsMetrics() { - return useContext(FpsMetricsContext); + +export function FpsMetricsProvider({ + getFpsMetrics, + children, +}: Props): React.ReactNode { + return ( + + {children} + + ) +} + +export function useFpsMetrics(): FpsMetricsGetter | undefined { + return useContext(FpsMetricsContext) } diff --git a/src/context/mailbox.tsx b/src/context/mailbox.tsx index ac9d46b79..a02e2cb46 100644 --- a/src/context/mailbox.tsx +++ b/src/context/mailbox.tsx @@ -1,37 +1,25 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useContext, useMemo } from 'react'; -import { Mailbox } from '../utils/mailbox.js'; -const MailboxContext = createContext(undefined); +import React, { createContext, useContext, useMemo } from 'react' +import { Mailbox } from '../utils/mailbox.js' + +const MailboxContext = createContext(undefined) + type Props = { - children: React.ReactNode; -}; -export function MailboxProvider(t0) { - const $ = _c(3); - const { - children - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = new Mailbox(); - $[0] = t1; - } else { - t1 = $[0]; - } - const mailbox = t1; - let t2; - if ($[1] !== children) { - t2 = {children}; - $[1] = children; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + children: React.ReactNode } -export function useMailbox() { - const mailbox = useContext(MailboxContext); + +export function MailboxProvider({ children }: Props): React.ReactNode { + const mailbox = useMemo(() => new Mailbox(), []) + return ( + + {children} + + ) +} + +export function useMailbox(): Mailbox { + const mailbox = useContext(MailboxContext) if (!mailbox) { - throw new Error("useMailbox must be used within a MailboxProvider"); + throw new Error('useMailbox must be used within a MailboxProvider') } - return mailbox; + return mailbox } diff --git a/src/context/modalContext.tsx b/src/context/modalContext.tsx index b9b2f0d63..b2263a071 100644 --- a/src/context/modalContext.tsx +++ b/src/context/modalContext.tsx @@ -1,6 +1,5 @@ -import { c as _c } from "react/compiler-runtime"; -import { createContext, type RefObject, useContext } from 'react'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; +import { createContext, type RefObject, useContext } from 'react' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' /** * Set by FullscreenLayout when rendering content in its `modal` slot — @@ -20,13 +19,14 @@ import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; * null = not inside the modal slot. */ type ModalCtx = { - rows: number; - columns: number; - scrollRef: RefObject | null; -}; -export const ModalContext = createContext(null); -export function useIsInsideModal() { - return useContext(ModalContext) !== null; + rows: number + columns: number + scrollRef: RefObject | null +} +export const ModalContext = createContext(null) + +export function useIsInsideModal(): boolean { + return useContext(ModalContext) !== null } /** @@ -35,23 +35,14 @@ export function useIsInsideModal() { * component caps its visible content height — the modal's inner area is * smaller than the terminal. */ -export function useModalOrTerminalSize(fallback) { - const $ = _c(3); - const ctx = useContext(ModalContext); - let t0; - if ($[0] !== ctx || $[1] !== fallback) { - t0 = ctx ? { - rows: ctx.rows, - columns: ctx.columns - } : fallback; - $[0] = ctx; - $[1] = fallback; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; +export function useModalOrTerminalSize(fallback: { + rows: number + columns: number +}): { rows: number; columns: number } { + const ctx = useContext(ModalContext) + return ctx ? { rows: ctx.rows, columns: ctx.columns } : fallback } -export function useModalScrollRef() { - return useContext(ModalContext)?.scrollRef ?? null; + +export function useModalScrollRef(): RefObject | null { + return useContext(ModalContext)?.scrollRef ?? null } diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index d6281d5a3..a19d908f9 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -1,216 +1,288 @@ -import type * as React from 'react'; -import { useCallback, useEffect } from 'react'; -import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'; -import type { Theme } from '../utils/theme.js'; -type Priority = 'low' | 'medium' | 'high' | 'immediate'; +import type * as React from 'react' +import { useCallback, useEffect } from 'react' +import { useAppStateStore, useSetAppState } from 'src/state/AppState.js' +import type { Theme } from '../utils/theme.js' + +type Priority = 'low' | 'medium' | 'high' | 'immediate' + type BaseNotification = { - key: string; + key: string /** * Keys of notifications that this notification invalidates. * If a notification is invalidated, it will be removed from the queue * and, if currently displayed, cleared immediately. */ - invalidates?: string[]; - priority: Priority; - timeoutMs?: number; + invalidates?: string[] + priority: Priority + timeoutMs?: number /** * Combine notifications with the same key, like Array.reduce(). * Called as fold(accumulator, incoming) when a notification with a matching * key already exists in the queue or is currently displayed. * Returns the merged notification (should carry fold forward for future merges). */ - fold?: (accumulator: Notification, incoming: Notification) => Notification; -}; + fold?: (accumulator: Notification, incoming: Notification) => Notification +} + type TextNotification = BaseNotification & { - text: string; - color?: keyof Theme; -}; + text: string + color?: keyof Theme +} + type JSXNotification = BaseNotification & { - jsx: React.ReactNode; -}; -type AddNotificationFn = (content: Notification) => void; -type RemoveNotificationFn = (key: string) => void; -export type Notification = TextNotification | JSXNotification; -const DEFAULT_TIMEOUT_MS = 8000; + jsx: React.ReactNode +} + +type AddNotificationFn = (content: Notification) => void +type RemoveNotificationFn = (key: string) => void + +export type Notification = TextNotification | JSXNotification + +const DEFAULT_TIMEOUT_MS = 8000 // Track current timeout to clear it when immediate notifications arrive -let currentTimeoutId: NodeJS.Timeout | null = null; +let currentTimeoutId: NodeJS.Timeout | null = null + export function useNotifications(): { - addNotification: AddNotificationFn; - removeNotification: RemoveNotificationFn; + addNotification: AddNotificationFn + removeNotification: RemoveNotificationFn } { - const store = useAppStateStore(); - const setAppState = useSetAppState(); + const store = useAppStateStore() + const setAppState = useSetAppState() // Process queue when current notification finishes or queue changes const processQueue = useCallback(() => { setAppState(prev => { - const next = getNext(prev.notifications.queue); + const next = getNext(prev.notifications.queue) if (prev.notifications.current !== null || !next) { - return prev; + return prev } - currentTimeoutId = setTimeout((setAppState, nextKey, processQueue) => { - currentTimeoutId = null; - setAppState(prev => { - // Compare by key instead of reference to handle re-created notifications - if (prev.notifications.current?.key !== nextKey) { - return prev; - } - return { - ...prev, - notifications: { - queue: prev.notifications.queue, - current: null + + currentTimeoutId = setTimeout( + (setAppState, nextKey, processQueue) => { + currentTimeoutId = null + setAppState(prev => { + // Compare by key instead of reference to handle re-created notifications + if (prev.notifications.current?.key !== nextKey) { + return prev } - }; - }); - processQueue(); - }, next.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, next.key, processQueue); + return { + ...prev, + notifications: { + queue: prev.notifications.queue, + current: null, + }, + } + }) + processQueue() + }, + next.timeoutMs ?? DEFAULT_TIMEOUT_MS, + setAppState, + next.key, + processQueue, + ) + return { ...prev, notifications: { queue: prev.notifications.queue.filter(_ => _ !== next), - current: next - } - }; - }); - }, [setAppState]); - const addNotification = useCallback((notif: Notification) => { - // Handle immediate priority notifications - if (notif.priority === 'immediate') { - // Clear any existing timeout since we're showing a new immediate notification - if (currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; + current: next, + }, } + }) + }, [setAppState]) - // Set up timeout for the immediate notification - currentTimeoutId = setTimeout((setAppState, notif, processQueue) => { - currentTimeoutId = null; - setAppState(prev => { - // Compare by key instead of reference to handle re-created notifications - if (prev.notifications.current?.key !== notif.key) { - return prev; - } - return { - ...prev, - notifications: { - queue: prev.notifications.queue.filter(_ => !notif.invalidates?.includes(_.key)), - current: null - } - }; - }); - processQueue(); - }, notif.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, notif, processQueue); - - // Show the immediate notification right away - setAppState(prev => ({ - ...prev, - notifications: { - current: notif, - queue: - // Only re-queue the current notification if it's not immediate - [...(prev.notifications.current ? [prev.notifications.current] : []), ...prev.notifications.queue].filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key)) + const addNotification = useCallback( + (notif: Notification) => { + // Handle immediate priority notifications + if (notif.priority === 'immediate') { + // Clear any existing timeout since we're showing a new immediate notification + if (currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null } - })); - return; // IMPORTANT: Exit addNotification for immediate notifications - } - // Handle non-immediate notifications - setAppState(prev => { - // Check if we can fold into an existing notification with the same key - if (notif.fold) { - // Fold into current notification if keys match - if (prev.notifications.current?.key === notif.key) { - const folded = notif.fold(prev.notifications.current, notif); - // Reset timeout for the folded notification - if (currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; - } - currentTimeoutId = setTimeout((setAppState, foldedKey, processQueue) => { - currentTimeoutId = null; - setAppState(p => { - if (p.notifications.current?.key !== foldedKey) { - return p; + // Set up timeout for the immediate notification + currentTimeoutId = setTimeout( + (setAppState, notif, processQueue) => { + currentTimeoutId = null + setAppState(prev => { + // Compare by key instead of reference to handle re-created notifications + if (prev.notifications.current?.key !== notif.key) { + return prev } return { - ...p, + ...prev, notifications: { - queue: p.notifications.queue, - current: null - } - }; - }); - processQueue(); - }, folded.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, folded.key, processQueue); - return { - ...prev, - notifications: { - current: folded, - queue: prev.notifications.queue + queue: prev.notifications.queue.filter( + _ => !notif.invalidates?.includes(_.key), + ), + current: null, + }, + } + }) + processQueue() + }, + notif.timeoutMs ?? DEFAULT_TIMEOUT_MS, + setAppState, + notif, + processQueue, + ) + + // Show the immediate notification right away + setAppState(prev => ({ + ...prev, + notifications: { + current: notif, + queue: + // Only re-queue the current notification if it's not immediate + [ + ...(prev.notifications.current + ? [prev.notifications.current] + : []), + ...prev.notifications.queue, + ].filter( + _ => + _.priority !== 'immediate' && + !notif.invalidates?.includes(_.key), + ), + }, + })) + return // IMPORTANT: Exit addNotification for immediate notifications + } + + // Handle non-immediate notifications + setAppState(prev => { + // Check if we can fold into an existing notification with the same key + if (notif.fold) { + // Fold into current notification if keys match + if (prev.notifications.current?.key === notif.key) { + const folded = notif.fold(prev.notifications.current, notif) + // Reset timeout for the folded notification + if (currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null } - }; - } + currentTimeoutId = setTimeout( + (setAppState, foldedKey, processQueue) => { + currentTimeoutId = null + setAppState(p => { + if (p.notifications.current?.key !== foldedKey) { + return p + } + return { + ...p, + notifications: { + queue: p.notifications.queue, + current: null, + }, + } + }) + processQueue() + }, + folded.timeoutMs ?? DEFAULT_TIMEOUT_MS, + setAppState, + folded.key, + processQueue, + ) - // Fold into queued notification if keys match - const queueIdx = prev.notifications.queue.findIndex(_ => _.key === notif.key); - if (queueIdx !== -1) { - const folded = notif.fold(prev.notifications.queue[queueIdx]!, notif); - const newQueue = [...prev.notifications.queue]; - newQueue[queueIdx] = folded; - return { - ...prev, - notifications: { - current: prev.notifications.current, - queue: newQueue + return { + ...prev, + notifications: { + current: folded, + queue: prev.notifications.queue, + }, } - }; - } - } + } - // Only add to queue if not already present (prevent duplicates) - const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key)); - const shouldAdd = !queuedKeys.has(notif.key) && prev.notifications.current?.key !== notif.key; - if (!shouldAdd) return prev; - const invalidatesCurrent = prev.notifications.current !== null && notif.invalidates?.includes(prev.notifications.current.key); - if (invalidatesCurrent && currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; - } - return { - ...prev, - notifications: { - current: invalidatesCurrent ? null : prev.notifications.current, - queue: [...prev.notifications.queue.filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key)), notif] + // Fold into queued notification if keys match + const queueIdx = prev.notifications.queue.findIndex( + _ => _.key === notif.key, + ) + if (queueIdx !== -1) { + const folded = notif.fold( + prev.notifications.queue[queueIdx]!, + notif, + ) + const newQueue = [...prev.notifications.queue] + newQueue[queueIdx] = folded + return { + ...prev, + notifications: { + current: prev.notifications.current, + queue: newQueue, + }, + } + } } - }; - }); - // Process queue after adding the notification - processQueue(); - }, [setAppState, processQueue]); - const removeNotification = useCallback((key: string) => { - setAppState(prev => { - const isCurrent = prev.notifications.current?.key === key; - const inQueue = prev.notifications.queue.some(n => n.key === key); - if (!isCurrent && !inQueue) { - return prev; - } - if (isCurrent && currentTimeoutId) { - clearTimeout(currentTimeoutId); - currentTimeoutId = null; - } - return { - ...prev, - notifications: { - current: isCurrent ? null : prev.notifications.current, - queue: prev.notifications.queue.filter(n => n.key !== key) + // Only add to queue if not already present (prevent duplicates) + const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key)) + const shouldAdd = + !queuedKeys.has(notif.key) && + prev.notifications.current?.key !== notif.key + + if (!shouldAdd) return prev + + const invalidatesCurrent = + prev.notifications.current !== null && + notif.invalidates?.includes(prev.notifications.current.key) + + if (invalidatesCurrent && currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null } - }; - }); - processQueue(); - }, [setAppState, processQueue]); + + return { + ...prev, + notifications: { + current: invalidatesCurrent ? null : prev.notifications.current, + queue: [ + ...prev.notifications.queue.filter( + _ => + _.priority !== 'immediate' && + !notif.invalidates?.includes(_.key), + ), + notif, + ], + }, + } + }) + + // Process queue after adding the notification + processQueue() + }, + [setAppState, processQueue], + ) + + const removeNotification = useCallback( + (key: string) => { + setAppState(prev => { + const isCurrent = prev.notifications.current?.key === key + const inQueue = prev.notifications.queue.some(n => n.key === key) + + if (!isCurrent && !inQueue) { + return prev + } + + if (isCurrent && currentTimeoutId) { + clearTimeout(currentTimeoutId) + currentTimeoutId = null + } + + return { + ...prev, + notifications: { + current: isCurrent ? null : prev.notifications.current, + queue: prev.notifications.queue.filter(n => n.key !== key), + }, + } + }) + + processQueue() + }, + [setAppState, processQueue], + ) // Process queue on mount if there are notifications in the initial state. // Imperative read (not useAppState) — a subscription in a mount-only @@ -219,21 +291,22 @@ export function useNotifications(): { // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref useEffect(() => { if (store.getState().notifications.queue.length > 0) { - processQueue(); + processQueue() } - }, []); - return { - addNotification, - removeNotification - }; + }, []) + + return { addNotification, removeNotification } } + const PRIORITIES: Record = { immediate: 0, high: 1, medium: 2, - low: 3 -}; -export function getNext(queue: Notification[]): Notification | undefined { - if (queue.length === 0) return undefined; - return queue.reduce((min, n) => PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min); + low: 3, +} +export function getNext(queue: Notification[]): Notification | undefined { + if (queue.length === 0) return undefined + return queue.reduce((min, n) => + PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min, + ) } diff --git a/src/context/overlayContext.tsx b/src/context/overlayContext.tsx index 45da45425..602c1268d 100644 --- a/src/context/overlayContext.tsx +++ b/src/context/overlayContext.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Overlay tracking for Escape key coordination. * @@ -13,12 +12,12 @@ import { c as _c } from "react/compiler-runtime"; * The hook automatically registers on mount and unregisters on unmount, * so no manual cleanup or state management is needed. */ -import { useContext, useEffect, useLayoutEffect } from 'react'; -import instances from '../ink/instances.js'; -import { AppStoreContext, useAppState } from '../state/AppState.js'; +import { useContext, useEffect, useLayoutEffect } from 'react' +import instances from '../ink/instances.js' +import { AppStoreContext, useAppState } from '../state/AppState.js' // Non-modal overlays that shouldn't disable TextInput focus -const NON_MODAL_OVERLAYS = new Set(['autocomplete']); +const NON_MODAL_OVERLAYS = new Set(['autocomplete']) /** * Hook to register a component as an active overlay. @@ -35,72 +34,41 @@ const NON_MODAL_OVERLAYS = new Set(['autocomplete']); * // ... * } */ -export function useRegisterOverlay(id, t0) { - const $ = _c(8); - const enabled = t0 === undefined ? true : t0; - const store = useContext(AppStoreContext); - const setAppState = store?.setState; - let t1; - let t2; - if ($[0] !== enabled || $[1] !== id || $[2] !== setAppState) { - t1 = () => { - if (!enabled || !setAppState) { - return; - } +export function useRegisterOverlay(id: string, enabled = true): void { + // Use context directly so this is a no-op when rendered outside AppStateProvider + // (e.g., in isolated component tests that don't need the full app state tree). + const store = useContext(AppStoreContext) + const setAppState = store?.setState + useEffect(() => { + if (!enabled || !setAppState) return + setAppState(prev => { + if (prev.activeOverlays.has(id)) return prev + const next = new Set(prev.activeOverlays) + next.add(id) + return { ...prev, activeOverlays: next } + }) + return () => { setAppState(prev => { - if (prev.activeOverlays.has(id)) { - return prev; - } - const next = new Set(prev.activeOverlays); - next.add(id); - return { - ...prev, - activeOverlays: next - }; - }); - return () => { - setAppState(prev_0 => { - if (!prev_0.activeOverlays.has(id)) { - return prev_0; - } - const next_0 = new Set(prev_0.activeOverlays); - next_0.delete(id); - return { - ...prev_0, - activeOverlays: next_0 - }; - }); - }; - }; - t2 = [id, enabled, setAppState]; - $[0] = enabled; - $[1] = id; - $[2] = setAppState; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - useEffect(t1, t2); - let t3; - let t4; - if ($[5] !== enabled) { - t3 = () => { - if (!enabled) { - return; - } - return _temp; - }; - t4 = [enabled]; - $[5] = enabled; - $[6] = t3; - $[7] = t4; - } else { - t3 = $[6]; - t4 = $[7]; - } - useLayoutEffect(t3, t4); + if (!prev.activeOverlays.has(id)) return prev + const next = new Set(prev.activeOverlays) + next.delete(id) + return { ...prev, activeOverlays: next } + }) + } + }, [id, enabled, setAppState]) + + // On overlay close, force the next render to full-damage diff instead + // of blit. A tall overlay (e.g. FuzzyPicker with a 20-line preview) + // shrinks the Ink-managed region on unmount; the blit fast path can + // copy stale cells from the overlay's previous frame into rows the + // shorter layout no longer reaches, leaving a ghost title/divider. + // useLayoutEffect so cleanup runs synchronously before the microtask- + // deferred onRender (scheduleRender queues a microtask from + // resetAfterCommit; passive-effect cleanup would land after it). + useLayoutEffect(() => { + if (!enabled) return + return () => instances.get(process.stdout)?.invalidatePrevFrame() + }, [enabled]) } /** @@ -116,11 +84,8 @@ export function useRegisterOverlay(id, t0) { * useKeybinding('chat:cancel', handleCancel, { isActive }) * } */ -function _temp() { - return instances.get(process.stdout)?.invalidatePrevFrame(); -} -export function useIsOverlayActive() { - return useAppState(_temp2); +export function useIsOverlayActive(): boolean { + return useAppState(s => s.activeOverlays.size > 0) } /** @@ -134,17 +99,11 @@ export function useIsOverlayActive() { * // Use for TextInput focus - allows typing during autocomplete * focus: !isSearchingHistory && !isModalOverlayActive */ -function _temp2(s) { - return s.activeOverlays.size > 0; -} -export function useIsModalOverlayActive() { - return useAppState(_temp3); -} -function _temp3(s) { - for (const id of s.activeOverlays) { - if (!NON_MODAL_OVERLAYS.has(id)) { - return true; +export function useIsModalOverlayActive(): boolean { + return useAppState(s => { + for (const id of s.activeOverlays) { + if (!NON_MODAL_OVERLAYS.has(id)) return true } - } - return false; + return false + }) } diff --git a/src/context/promptOverlayContext.tsx b/src/context/promptOverlayContext.tsx index e68c17f73..87c97d559 100644 --- a/src/context/promptOverlayContext.tsx +++ b/src/context/promptOverlayContext.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Portal for content that floats above the prompt so it escapes * FullscreenLayout's bottom-slot `overflowY:hidden` clip. @@ -19,106 +18,78 @@ import { c as _c } from "react/compiler-runtime"; * Split into data/setter context pairs so writers never re-render on * their own writes — the setter contexts are stable. */ -import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react'; -import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js'; +import React, { + createContext, + type ReactNode, + useContext, + useEffect, + useState, +} from 'react' +import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js' + export type PromptOverlayData = { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - maxColumnWidth?: number; -}; -type Setter = (d: T | null) => void; -const DataContext = createContext(null); -const SetContext = createContext | null>(null); -const DialogContext = createContext(null); -const SetDialogContext = createContext | null>(null); -export function PromptOverlayProvider(t0) { - const $ = _c(6); - const { - children - } = t0; - const [data, setData] = useState(null); - const [dialog, setDialog] = useState(null); - let t1; - if ($[0] !== children || $[1] !== dialog) { - t1 = {children}; - $[0] = children; - $[1] = dialog; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== data || $[4] !== t1) { - t2 = {t1}; - $[3] = data; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; + suggestions: SuggestionItem[] + selectedSuggestion: number + maxColumnWidth?: number } -export function usePromptOverlay() { - return useContext(DataContext); + +type Setter = (d: T | null) => void + +const DataContext = createContext(null) +const SetContext = createContext | null>(null) +const DialogContext = createContext(null) +const SetDialogContext = createContext | null>(null) + +export function PromptOverlayProvider({ + children, +}: { + children: ReactNode +}): ReactNode { + const [data, setData] = useState(null) + const [dialog, setDialog] = useState(null) + return ( + + + + + {children} + + + + + ) } -export function usePromptOverlayDialog() { - return useContext(DialogContext); + +export function usePromptOverlay(): PromptOverlayData | null { + return useContext(DataContext) +} + +export function usePromptOverlayDialog(): ReactNode { + return useContext(DialogContext) } /** * Register suggestion data for the floating overlay. Clears on unmount. * No-op outside the provider (non-fullscreen renders inline instead). */ -export function useSetPromptOverlay(data) { - const $ = _c(4); - const set = useContext(SetContext); - let t0; - let t1; - if ($[0] !== data || $[1] !== set) { - t0 = () => { - if (!set) { - return; - } - set(data); - return () => set(null); - }; - t1 = [set, data]; - $[0] = data; - $[1] = set; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - useEffect(t0, t1); +export function useSetPromptOverlay(data: PromptOverlayData | null): void { + const set = useContext(SetContext) + useEffect(() => { + if (!set) return + set(data) + return () => set(null) + }, [set, data]) } /** * Register a dialog node to float above the prompt. Clears on unmount. * No-op outside the provider (non-fullscreen renders inline instead). */ -export function useSetPromptOverlayDialog(node) { - const $ = _c(4); - const set = useContext(SetDialogContext); - let t0; - let t1; - if ($[0] !== node || $[1] !== set) { - t0 = () => { - if (!set) { - return; - } - set(node); - return () => set(null); - }; - t1 = [set, node]; - $[0] = node; - $[1] = set; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - useEffect(t0, t1); +export function useSetPromptOverlayDialog(node: ReactNode): void { + const set = useContext(SetDialogContext) + useEffect(() => { + if (!set) return + set(node) + return () => set(null) + }, [set, node]) } diff --git a/src/context/stats.tsx b/src/context/stats.tsx index eec550768..14a21f171 100644 --- a/src/context/stats.tsx +++ b/src/context/stats.tsx @@ -1,219 +1,173 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; -import { saveCurrentProjectConfig } from '../utils/config.js'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, +} from 'react' +import { saveCurrentProjectConfig } from '../utils/config.js' + export type StatsStore = { - increment(name: string, value?: number): void; - set(name: string, value: number): void; - observe(name: string, value: number): void; - add(name: string, value: string): void; - getAll(): Record; -}; -function percentile(sorted: number[], p: number): number { - const index = p / 100 * (sorted.length - 1); - const lower = Math.floor(index); - const upper = Math.ceil(index); - if (lower === upper) { - return sorted[lower]!; - } - return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower); + increment(name: string, value?: number): void + set(name: string, value: number): void + observe(name: string, value: number): void + add(name: string, value: string): void + getAll(): Record } -const RESERVOIR_SIZE = 1024; + +function percentile(sorted: number[], p: number): number { + const index = (p / 100) * (sorted.length - 1) + const lower = Math.floor(index) + const upper = Math.ceil(index) + if (lower === upper) { + return sorted[lower]! + } + return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower) +} + +const RESERVOIR_SIZE = 1024 + type Histogram = { - reservoir: number[]; - count: number; - sum: number; - min: number; - max: number; -}; + reservoir: number[] + count: number + sum: number + min: number + max: number +} + export function createStatsStore(): StatsStore { - const metrics = new Map(); - const histograms = new Map(); - const sets = new Map>(); + const metrics = new Map() + const histograms = new Map() + const sets = new Map>() + return { increment(name: string, value = 1) { - metrics.set(name, (metrics.get(name) ?? 0) + value); + metrics.set(name, (metrics.get(name) ?? 0) + value) }, set(name: string, value: number) { - metrics.set(name, value); + metrics.set(name, value) }, observe(name: string, value: number) { - let h = histograms.get(name); + let h = histograms.get(name) if (!h) { - h = { - reservoir: [], - count: 0, - sum: 0, - min: value, - max: value - }; - histograms.set(name, h); + h = { reservoir: [], count: 0, sum: 0, min: value, max: value } + histograms.set(name, h) } - h.count++; - h.sum += value; + h.count++ + h.sum += value if (value < h.min) { - h.min = value; + h.min = value } if (value > h.max) { - h.max = value; + h.max = value } // Reservoir sampling (Algorithm R) if (h.reservoir.length < RESERVOIR_SIZE) { - h.reservoir.push(value); + h.reservoir.push(value) } else { - const j = Math.floor(Math.random() * h.count); + const j = Math.floor(Math.random() * h.count) if (j < RESERVOIR_SIZE) { - h.reservoir[j] = value; + h.reservoir[j] = value } } }, add(name: string, value: string) { - let s = sets.get(name); + let s = sets.get(name) if (!s) { - s = new Set(); - sets.set(name, s); + s = new Set() + sets.set(name, s) } - s.add(value); + s.add(value) }, getAll() { - const result: Record = Object.fromEntries(metrics); + const result: Record = Object.fromEntries(metrics) + for (const [name, h] of histograms) { if (h.count === 0) { - continue; + continue } - result[`${name}_count`] = h.count; - result[`${name}_min`] = h.min; - result[`${name}_max`] = h.max; - result[`${name}_avg`] = h.sum / h.count; - const sorted = [...h.reservoir].sort((a, b) => a - b); - result[`${name}_p50`] = percentile(sorted, 50); - result[`${name}_p95`] = percentile(sorted, 95); - result[`${name}_p99`] = percentile(sorted, 99); + result[`${name}_count`] = h.count + result[`${name}_min`] = h.min + result[`${name}_max`] = h.max + result[`${name}_avg`] = h.sum / h.count + const sorted = [...h.reservoir].sort((a, b) => a - b) + result[`${name}_p50`] = percentile(sorted, 50) + result[`${name}_p95`] = percentile(sorted, 95) + result[`${name}_p99`] = percentile(sorted, 99) } + for (const [name, s] of sets) { - result[name] = s.size; + result[name] = s.size } - return result; - } - }; + + return result + }, + } } -export const StatsContext = createContext(null); + +export const StatsContext = createContext(null) + type Props = { - store?: StatsStore; - children: React.ReactNode; -}; -export function StatsProvider(t0) { - const $ = _c(7); - const { - store: externalStore, - children - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = createStatsStore(); - $[0] = t1; - } else { - t1 = $[0]; - } - const internalStore = t1; - const store = externalStore ?? internalStore; - let t2; - let t3; - if ($[1] !== store) { - t2 = () => { - const flush = () => { - const metrics = store.getAll(); - if (Object.keys(metrics).length > 0) { - saveCurrentProjectConfig(current => ({ - ...current, - lastSessionMetrics: metrics - })); - } - }; - process.on("exit", flush); - return () => { - process.off("exit", flush); - }; - }; - t3 = [store]; - $[1] = store; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - useEffect(t2, t3); - let t4; - if ($[4] !== children || $[5] !== store) { - t4 = {children}; - $[4] = children; - $[5] = store; - $[6] = t4; - } else { - t4 = $[6]; - } - return t4; + store?: StatsStore + children: React.ReactNode } -export function useStats() { - const store = useContext(StatsContext); + +export function StatsProvider({ + store: externalStore, + children, +}: Props): React.ReactNode { + const internalStore = useMemo(() => createStatsStore(), []) + const store = externalStore ?? internalStore + + useEffect(() => { + const flush = () => { + const metrics = store.getAll() + if (Object.keys(metrics).length > 0) { + saveCurrentProjectConfig(current => ({ + ...current, + lastSessionMetrics: metrics, + })) + } + } + process.on('exit', flush) + return () => { + process.off('exit', flush) + } + }, [store]) + + return {children} +} + +export function useStats(): StatsStore { + const store = useContext(StatsContext) if (!store) { - throw new Error("useStats must be used within a StatsProvider"); + throw new Error('useStats must be used within a StatsProvider') } - return store; + return store } -export function useCounter(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.increment(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useCounter(name: string): (value?: number) => void { + const store = useStats() + return useCallback( + (value?: number) => store.increment(name, value), + [store, name], + ) } -export function useGauge(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.set(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useGauge(name: string): (value: number) => void { + const store = useStats() + return useCallback((value: number) => store.set(name, value), [store, name]) } -export function useTimer(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.observe(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useTimer(name: string): (value: number) => void { + const store = useStats() + return useCallback( + (value: number) => store.observe(name, value), + [store, name], + ) } -export function useSet(name) { - const $ = _c(3); - const store = useStats(); - let t0; - if ($[0] !== name || $[1] !== store) { - t0 = value => store.add(name, value); - $[0] = name; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + +export function useSet(name: string): (value: string) => void { + const store = useStats() + return useCallback((value: string) => store.add(name, value), [store, name]) } diff --git a/src/context/voice.tsx b/src/context/voice.tsx index 33c172c9a..3adcc4d27 100644 --- a/src/context/voice.tsx +++ b/src/context/voice.tsx @@ -1,71 +1,58 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useContext, useState, useSyncExternalStore } from 'react'; -import { createStore, type Store } from '../state/store.js'; +import React, { + createContext, + useContext, + useState, + useSyncExternalStore, +} from 'react' +import { createStore, type Store } from '../state/store.js' + export type VoiceState = { - voiceState: 'idle' | 'recording' | 'processing'; - voiceError: string | null; - voiceInterimTranscript: string; - voiceAudioLevels: number[]; - voiceWarmingUp: boolean; -}; + voiceState: 'idle' | 'recording' | 'processing' + voiceError: string | null + voiceInterimTranscript: string + voiceAudioLevels: number[] + voiceWarmingUp: boolean +} + const DEFAULT_STATE: VoiceState = { voiceState: 'idle', voiceError: null, voiceInterimTranscript: '', voiceAudioLevels: [], - voiceWarmingUp: false -}; -type VoiceStore = Store; -const VoiceContext = createContext(null); + voiceWarmingUp: false, +} + +type VoiceStore = Store + +const VoiceContext = createContext(null) + type Props = { - children: React.ReactNode; -}; -export function VoiceProvider(t0) { - const $ = _c(3); - const { - children - } = t0; - const [store] = useState(_temp); - let t1; - if ($[0] !== children || $[1] !== store) { - t1 = {children}; - $[0] = children; - $[1] = store; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + children: React.ReactNode } -function _temp() { - return createStore(DEFAULT_STATE); + +export function VoiceProvider({ children }: Props): React.ReactNode { + // Store is created once — stable context value means the provider never + // triggers re-renders. Consumers subscribe to slices via useVoiceState. + const [store] = useState(() => createStore(DEFAULT_STATE)) + return {children} } -function useVoiceStore() { - const store = useContext(VoiceContext); + +function useVoiceStore(): VoiceStore { + const store = useContext(VoiceContext) if (!store) { - throw new Error("useVoiceState must be used within a VoiceProvider"); + throw new Error('useVoiceState must be used within a VoiceProvider') } - return store; + return store } /** * Subscribe to a slice of voice state. Only re-renders when the selected * value changes (compared via Object.is). */ -export function useVoiceState(selector) { - const $ = _c(3); - const store = useVoiceStore(); - let t0; - if ($[0] !== selector || $[1] !== store) { - t0 = () => selector(store.getState()); - $[0] = selector; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - const get = t0; - return useSyncExternalStore(store.subscribe, get, get); +export function useVoiceState(selector: (state: VoiceState) => T): T { + const store = useVoiceStore() + const get = () => selector(store.getState()) + return useSyncExternalStore(store.subscribe, get, get) } /** @@ -73,8 +60,10 @@ export function useVoiceState(selector) { * store.setState is synchronous: callers can read getVoiceState() immediately * after to observe the new value (VoiceKeybindingHandler relies on this). */ -export function useSetVoiceState() { - return useVoiceStore().setState; +export function useSetVoiceState(): ( + updater: (prev: VoiceState) => VoiceState, +) => void { + return useVoiceStore().setState } /** @@ -82,6 +71,6 @@ export function useSetVoiceState() { * useVoiceState (which subscribes), this doesn't cause re-renders — use * inside event handlers that need to read state set earlier in the same tick. */ -export function useGetVoiceState() { - return useVoiceStore().getState; +export function useGetVoiceState(): () => VoiceState { + return useVoiceStore().getState } diff --git a/src/ink/Ansi.tsx b/src/ink/Ansi.tsx index 5e51a7c02..f6ff7f7de 100644 --- a/src/ink/Ansi.tsx +++ b/src/ink/Ansi.tsx @@ -1,25 +1,31 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import Link from './components/Link.js'; -import Text from './components/Text.js'; -import type { Color } from './styles.js'; -import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js'; +import React from 'react' +import Link from './components/Link.js' +import Text from './components/Text.js' +import type { Color } from './styles.js' +import { + type NamedColor, + Parser, + type Color as TermioColor, + type TextStyle, +} from './termio.js' + type Props = { - children: string; + children: string /** When true, force all text to be rendered with dim styling */ - dimColor?: boolean; -}; + dimColor?: boolean +} + type SpanProps = { - color?: Color; - backgroundColor?: Color; - dim?: boolean; - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - inverse?: boolean; - hyperlink?: string; -}; + color?: Color + backgroundColor?: Color + dim?: boolean + bold?: boolean + italic?: boolean + underline?: boolean + strikethrough?: boolean + inverse?: boolean + hyperlink?: string +} /** * Component that parses ANSI escape codes and renders them using Text components. @@ -29,145 +35,156 @@ type SpanProps = { * * Memoized to prevent re-renders when parent changes but children string is the same. */ -export const Ansi = React.memo(function Ansi(t0: { children: React.ReactNode; dimColor?: boolean }) { - const $ = _c(12); - const { - children, - dimColor - } = t0; - if (typeof children !== "string") { - let t1; - if ($[0] !== children || $[1] !== dimColor) { - t1 = dimColor ? {String(children)} : {String(children)}; - $[0] = children; - $[1] = dimColor; - $[2] = t1; - } else { - t1 = $[2]; +export const Ansi = React.memo(function Ansi({ + children, + dimColor, +}: Props): React.ReactNode { + if (typeof children !== 'string') { + return dimColor ? ( + {String(children)} + ) : ( + {String(children)} + ) + } + + if (children === '') { + return null + } + + const spans = parseToSpans(children) + + if (spans.length === 0) { + return null + } + + if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) { + return dimColor ? ( + {spans[0]!.text} + ) : ( + {spans[0]!.text} + ) + } + + const content = spans.map((span, i) => { + const hyperlink = span.props.hyperlink + // When dimColor is forced, override the span's dim prop + if (dimColor) { + span.props.dim = true } - return t1; - } - if (children === "") { - return null; - } - let t1; - let t2; - if ($[3] !== children || $[4] !== dimColor) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const spans = parseToSpans(children); - if (spans.length === 0) { - t2 = null; - break bb0; - } - if (spans.length === 1 && !hasAnyProps(spans[0].props)) { - t2 = dimColor ? {spans[0].text} : {spans[0].text}; - break bb0; - } - let t3; - if ($[7] !== dimColor) { - t3 = (span, i) => { - const hyperlink = span.props.hyperlink; - if (dimColor) { - span.props.dim = true; - } - const hasTextProps = hasAnyTextProps(span.props); - if (hyperlink) { - return hasTextProps ? {span.text} : {span.text}; - } - return hasTextProps ? {span.text} : span.text; - }; - $[7] = dimColor; - $[8] = t3; - } else { - t3 = $[8]; - } - t1 = spans.map(t3); + const hasTextProps = hasAnyTextProps(span.props) + + if (hyperlink) { + return hasTextProps ? ( + + + {span.text} + + + ) : ( + + {span.text} + + ) } - $[3] = children; - $[4] = dimColor; - $[5] = t1; - $[6] = t2; - } else { - t1 = $[5]; - t2 = $[6]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - const content = t1; - let t3; - if ($[9] !== content || $[10] !== dimColor) { - t3 = dimColor ? {content} : {content}; - $[9] = content; - $[10] = dimColor; - $[11] = t3; - } else { - t3 = $[11]; - } - return t3; -}); + + return hasTextProps ? ( + + {span.text} + + ) : ( + span.text + ) + }) + + return dimColor ? {content} : {content} +}) + type Span = { - text: string; - props: SpanProps; -}; + text: string + props: SpanProps +} /** * Parse an ANSI string into spans using the termio parser. */ function parseToSpans(input: string): Span[] { - const parser = new Parser(); - const actions = parser.feed(input); - const spans: Span[] = []; - let currentHyperlink: string | undefined; + const parser = new Parser() + const actions = parser.feed(input) + const spans: Span[] = [] + + let currentHyperlink: string | undefined + for (const action of actions) { if (action.type === 'link') { if (action.action.type === 'start') { - currentHyperlink = action.action.url; + currentHyperlink = action.action.url } else { - currentHyperlink = undefined; + currentHyperlink = undefined } - continue; + continue } + if (action.type === 'text') { - const text = action.graphemes.map(g => g.value).join(''); - if (!text) continue; - const props = textStyleToSpanProps(action.style); + const text = action.graphemes.map(g => g.value).join('') + if (!text) continue + + const props = textStyleToSpanProps(action.style) if (currentHyperlink) { - props.hyperlink = currentHyperlink; + props.hyperlink = currentHyperlink } // Try to merge with previous span if props match - const lastSpan = spans[spans.length - 1]; + const lastSpan = spans[spans.length - 1] if (lastSpan && propsEqual(lastSpan.props, props)) { - lastSpan.text += text; + lastSpan.text += text } else { - spans.push({ - text, - props - }); + spans.push({ text, props }) } } } - return spans; + + return spans } /** * Convert termio's TextStyle to SpanProps. */ function textStyleToSpanProps(style: TextStyle): SpanProps { - const props: SpanProps = {}; - if (style.bold) props.bold = true; - if (style.dim) props.dim = true; - if (style.italic) props.italic = true; - if (style.underline !== 'none') props.underline = true; - if (style.strikethrough) props.strikethrough = true; - if (style.inverse) props.inverse = true; - const fgColor = colorToString(style.fg); - if (fgColor) props.color = fgColor; - const bgColor = colorToString(style.bg); - if (bgColor) props.backgroundColor = bgColor; - return props; + const props: SpanProps = {} + + if (style.bold) props.bold = true + if (style.dim) props.dim = true + if (style.italic) props.italic = true + if (style.underline !== 'none') props.underline = true + if (style.strikethrough) props.strikethrough = true + if (style.inverse) props.inverse = true + + const fgColor = colorToString(style.fg) + if (fgColor) props.color = fgColor + + const bgColor = colorToString(style.bg) + if (bgColor) props.backgroundColor = bgColor + + return props } // Map termio named colors to the ansi: format @@ -187,8 +204,8 @@ const NAMED_COLOR_MAP: Record = { brightBlue: 'ansi:blueBright', brightMagenta: 'ansi:magentaBright', brightCyan: 'ansi:cyanBright', - brightWhite: 'ansi:whiteBright' -}; + brightWhite: 'ansi:whiteBright', +} /** * Convert termio's Color to the string format used by Ink. @@ -196,13 +213,13 @@ const NAMED_COLOR_MAP: Record = { function colorToString(color: TermioColor): Color | undefined { switch (color.type) { case 'named': - return NAMED_COLOR_MAP[color.name] as Color; + return NAMED_COLOR_MAP[color.name] as Color case 'indexed': - return `ansi256(${color.index})` as Color; + return `ansi256(${color.index})` as Color case 'rgb': - return `rgb(${color.r},${color.g},${color.b})` as Color; + return `rgb(${color.r},${color.g},${color.b})` as Color case 'default': - return undefined; + return undefined } } @@ -210,82 +227,81 @@ function colorToString(color: TermioColor): Color | undefined { * Check if two SpanProps are equal for merging. */ function propsEqual(a: SpanProps, b: SpanProps): boolean { - return a.color === b.color && a.backgroundColor === b.backgroundColor && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline && a.strikethrough === b.strikethrough && a.inverse === b.inverse && a.hyperlink === b.hyperlink; + return ( + a.color === b.color && + a.backgroundColor === b.backgroundColor && + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.strikethrough === b.strikethrough && + a.inverse === b.inverse && + a.hyperlink === b.hyperlink + ) } + function hasAnyProps(props: SpanProps): boolean { - return props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true || props.hyperlink !== undefined; + return ( + props.color !== undefined || + props.backgroundColor !== undefined || + props.dim === true || + props.bold === true || + props.italic === true || + props.underline === true || + props.strikethrough === true || + props.inverse === true || + props.hyperlink !== undefined + ) } + function hasAnyTextProps(props: SpanProps): boolean { - return props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true; + return ( + props.color !== undefined || + props.backgroundColor !== undefined || + props.dim === true || + props.bold === true || + props.italic === true || + props.underline === true || + props.strikethrough === true || + props.inverse === true + ) } // Text style props without weight (bold/dim) - these are handled separately type BaseTextStyleProps = { - color?: Color; - backgroundColor?: Color; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - inverse?: boolean; -}; + color?: Color + backgroundColor?: Color + italic?: boolean + underline?: boolean + strikethrough?: boolean + inverse?: boolean +} // Wrapper component that handles bold/dim mutual exclusivity for Text -function StyledText(t0) { - const $ = _c(14); - let bold; - let children; - let dim; - let rest; - if ($[0] !== t0) { - ({ - bold, - dim, - children, - ...rest - } = t0); - $[0] = t0; - $[1] = bold; - $[2] = children; - $[3] = dim; - $[4] = rest; - } else { - bold = $[1]; - children = $[2]; - dim = $[3]; - rest = $[4]; - } +function StyledText({ + bold, + dim, + children, + ...rest +}: BaseTextStyleProps & { + bold?: boolean + dim?: boolean + children: string +}): React.ReactNode { + // dim takes precedence over bold when both are set (terminals treat them as mutually exclusive) if (dim) { - let t1; - if ($[5] !== children || $[6] !== rest) { - t1 = {children}; - $[5] = children; - $[6] = rest; - $[7] = t1; - } else { - t1 = $[7]; - } - return t1; + return ( + + {children} + + ) } if (bold) { - let t1; - if ($[8] !== children || $[9] !== rest) { - t1 = {children}; - $[8] = children; - $[9] = rest; - $[10] = t1; - } else { - t1 = $[10]; - } - return t1; + return ( + + {children} + + ) } - let t1; - if ($[11] !== children || $[12] !== rest) { - t1 = {children}; - $[11] = children; - $[12] = rest; - $[13] = t1; - } else { - t1 = $[13]; - } - return t1; + return {children} } diff --git a/src/ink/components/AlternateScreen.tsx b/src/ink/components/AlternateScreen.tsx index 2a4dfe451..eeeb1152e 100644 --- a/src/ink/components/AlternateScreen.tsx +++ b/src/ink/components/AlternateScreen.tsx @@ -1,14 +1,23 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react'; -import instances from '../instances.js'; -import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'; -import { TerminalWriteContext } from '../useTerminalNotification.js'; -import Box from './Box.js'; -import { TerminalSizeContext } from './TerminalSizeContext.js'; +import React, { + type PropsWithChildren, + useContext, + useInsertionEffect, +} from 'react' +import instances from '../instances.js' +import { + DISABLE_MOUSE_TRACKING, + ENABLE_MOUSE_TRACKING, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, +} from '../termio/dec.js' +import { TerminalWriteContext } from '../useTerminalNotification.js' +import Box from './Box.js' +import { TerminalSizeContext } from './TerminalSizeContext.js' + type Props = PropsWithChildren<{ /** Enable SGR mouse tracking (wheel + click/drag). Default true. */ - mouseTracking?: boolean; -}>; + mouseTracking?: boolean +}> /** * Run children in the terminal's alternate screen buffer, constrained to @@ -30,50 +39,49 @@ type Props = PropsWithChildren<{ * from scrolling content) and so signal-exit cleanup can exit the alt * screen if the component's own unmount doesn't run. */ -export function AlternateScreen(t0) { - const $ = _c(7); - const { - children, - mouseTracking: t1 - } = t0; - const mouseTracking = t1 === undefined ? true : t1; - const size = useContext(TerminalSizeContext); - const writeRaw = useContext(TerminalWriteContext); - let t2; - let t3; - if ($[0] !== mouseTracking || $[1] !== writeRaw) { - t2 = () => { - const ink = instances.get(process.stdout); - if (!writeRaw) { - return; - } - writeRaw(ENTER_ALT_SCREEN + "\x1B[2J\x1B[H" + (mouseTracking ? ENABLE_MOUSE_TRACKING : "")); - ink?.setAltScreenActive(true, mouseTracking); - return () => { - ink?.setAltScreenActive(false); - ink?.clearTextSelection(); - writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : "") + EXIT_ALT_SCREEN); - }; - }; - t3 = [writeRaw, mouseTracking]; - $[0] = mouseTracking; - $[1] = writeRaw; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - useInsertionEffect(t2, t3); - const t4 = size?.rows ?? 24; - let t5; - if ($[4] !== children || $[5] !== t4) { - t5 = {children}; - $[4] = children; - $[5] = t4; - $[6] = t5; - } else { - t5 = $[6]; - } - return t5; +export function AlternateScreen({ + children, + mouseTracking = true, +}: Props): React.ReactNode { + const size = useContext(TerminalSizeContext) + const writeRaw = useContext(TerminalWriteContext) + + // useInsertionEffect (not useLayoutEffect): react-reconciler calls + // resetAfterCommit between the mutation and layout commit phases, and + // Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that + // first onRender fires BEFORE this effect — writing a full frame to the + // main screen with altScreen=false. That frame is preserved when we + // enter alt screen and revealed on exit as a broken view. Insertion + // effects fire during the mutation phase, before resetAfterCommit, so + // ENTER_ALT_SCREEN reaches the terminal before the first frame does. + // Cleanup timing is unchanged: both insertion and layout effect cleanup + // run in the mutation phase on unmount, before resetAfterCommit. + useInsertionEffect(() => { + const ink = instances.get(process.stdout) + if (!writeRaw) return + + writeRaw( + ENTER_ALT_SCREEN + + '\x1b[2J\x1b[H' + + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''), + ) + ink?.setAltScreenActive(true, mouseTracking) + + return () => { + ink?.setAltScreenActive(false) + ink?.clearTextSelection() + writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN) + } + }, [writeRaw, mouseTracking]) + + return ( + + {children} + + ) } diff --git a/src/ink/components/App.tsx b/src/ink/components/App.tsx index 8b82d1559..9bbb0c06a 100644 --- a/src/ink/components/App.tsx +++ b/src/ink/components/App.tsx @@ -1,223 +1,290 @@ -import React, { PureComponent, type ReactNode } from 'react'; -import { updateLastInteractionTime } from '../../bootstrap/state.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { isMouseClicksDisabled } from '../../utils/fullscreen.js'; -import { logError } from '../../utils/log.js'; -import { EventEmitter } from '../events/emitter.js'; -import { InputEvent } from '../events/input-event.js'; -import { TerminalFocusEvent } from '../events/terminal-focus-event.js'; -import { INITIAL_STATE, type ParsedInput, type ParsedKey, type ParsedMouse, parseMultipleKeypresses } from '../parse-keypress.js'; -import reconciler from '../reconciler.js'; -import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'; -import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'; -import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'; -import { TerminalQuerier, xtversion } from '../terminal-querier.js'; -import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT } from '../termio/csi.js'; -import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js'; -import AppContext from './AppContext.js'; -import { ClockProvider } from './ClockContext.js'; -import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js'; -import ErrorOverview from './ErrorOverview.js'; -import StdinContext from './StdinContext.js'; -import { TerminalFocusProvider } from './TerminalFocusContext.js'; -import { TerminalSizeContext } from './TerminalSizeContext.js'; +import React, { PureComponent, type ReactNode } from 'react' +import { updateLastInteractionTime } from '../../bootstrap/state.js' +import { logForDebugging } from '../../utils/debug.js' +import { stopCapturingEarlyInput } from '../../utils/earlyInput.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { isMouseClicksDisabled } from '../../utils/fullscreen.js' +import { logError } from '../../utils/log.js' +import { EventEmitter } from '../events/emitter.js' +import { InputEvent } from '../events/input-event.js' +import { TerminalFocusEvent } from '../events/terminal-focus-event.js' +import { + INITIAL_STATE, + type ParsedInput, + type ParsedKey, + type ParsedMouse, + parseMultipleKeypresses, +} from '../parse-keypress.js' +import reconciler from '../reconciler.js' +import { + finishSelection, + hasSelection, + type SelectionState, + startSelection, +} from '../selection.js' +import { + isXtermJs, + setXtversionName, + supportsExtendedKeys, +} from '../terminal.js' +import { + getTerminalFocused, + setTerminalFocused, +} from '../terminal-focus-state.js' +import { TerminalQuerier, xtversion } from '../terminal-querier.js' +import { + DISABLE_KITTY_KEYBOARD, + DISABLE_MODIFY_OTHER_KEYS, + ENABLE_KITTY_KEYBOARD, + ENABLE_MODIFY_OTHER_KEYS, + FOCUS_IN, + FOCUS_OUT, +} from '../termio/csi.js' +import { + DBP, + DFE, + DISABLE_MOUSE_TRACKING, + EBP, + EFE, + HIDE_CURSOR, + SHOW_CURSOR, +} from '../termio/dec.js' +import AppContext from './AppContext.js' +import { ClockProvider } from './ClockContext.js' +import CursorDeclarationContext, { + type CursorDeclarationSetter, +} from './CursorDeclarationContext.js' +import ErrorOverview from './ErrorOverview.js' +import StdinContext from './StdinContext.js' +import { TerminalFocusProvider } from './TerminalFocusContext.js' +import { TerminalSizeContext } from './TerminalSizeContext.js' // Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT) -const SUPPORTS_SUSPEND = process.platform !== 'win32'; +const SUPPORTS_SUSPEND = process.platform !== 'win32' // After this many milliseconds of stdin silence, the next chunk triggers // a terminal mode re-assert (mouse tracking). Catches tmux detach→attach, // ssh reconnect, and laptop wake — the terminal resets DEC private modes // but no signal reaches us. 5s is well above normal inter-keystroke gaps // but short enough that the first scroll after reattach works. -const STDIN_RESUME_GAP_MS = 5000; +const STDIN_RESUME_GAP_MS = 5000 + type Props = { - readonly children: ReactNode; - readonly stdin: NodeJS.ReadStream; - readonly stdout: NodeJS.WriteStream; - readonly stderr: NodeJS.WriteStream; - readonly exitOnCtrlC: boolean; - readonly onExit: (error?: Error) => void; - readonly terminalColumns: number; - readonly terminalRows: number; + readonly children: ReactNode + readonly stdin: NodeJS.ReadStream + readonly stdout: NodeJS.WriteStream + readonly stderr: NodeJS.WriteStream + readonly exitOnCtrlC: boolean + readonly onExit: (error?: Error) => void + readonly terminalColumns: number + readonly terminalRows: number // Text selection state. App mutates this directly from mouse events // and calls onSelectionChange to trigger a repaint. Mouse events only // arrive when (or similar) enables mouse tracking, // so the handler is always wired but dormant until tracking is on. - readonly selection: SelectionState; - readonly onSelectionChange: () => void; + readonly selection: SelectionState + readonly onSelectionChange: () => void // Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles // onClick handlers. Returns true if a DOM handler consumed the click. // No-op (returns false) outside fullscreen mode (Ink.dispatchClick // gates on altScreenActive). - readonly onClickAt: (col: number, row: number) => boolean; + readonly onClickAt: (col: number, row: number) => boolean // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over // DOM elements. Called for mode-1003 motion events with no button held. // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). - readonly onHoverAt: (col: number, row: number) => void; + readonly onHoverAt: (col: number, row: number) => void // Look up the OSC 8 hyperlink at (col, row) synchronously at click // time. Returns the URL or undefined. The browser-open is deferred by // MULTI_CLICK_TIMEOUT_MS so double-click can cancel it. - readonly getHyperlinkAt: (col: number, row: number) => string | undefined; + readonly getHyperlinkAt: (col: number, row: number) => string | undefined // Open a hyperlink URL in the browser. Called after the timer fires. - readonly onOpenHyperlink: (url: string) => void; + readonly onOpenHyperlink: (url: string) => void // Called on double/triple-click PRESS at (col, row). count=2 selects // the word under the cursor; count=3 selects the line. Ink reads the // screen buffer to find word/line boundaries and mutates selection, // setting isDragging=true so a subsequent drag extends by word/line. - readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void; + readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void // Called on drag-motion. Mode-aware: char mode updates focus to the // exact cell; word/line mode snaps to word/line boundaries. Needs // screen-buffer access (word boundaries) so lives on Ink, not here. - readonly onSelectionDrag: (col: number, row: number) => void; + readonly onSelectionDrag: (col: number, row: number) => void // Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap. // Ink re-asserts terminal modes: extended key reporting, and (when in // fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the // terminal side. Optional so testing.tsx doesn't need to stub it. - readonly onStdinResume?: () => void; + readonly onStdinResume?: () => void // Receives the declared native-cursor position from useDeclaredCursor // so ink.tsx can park the terminal cursor there after each frame. // Enables IME composition at the input caret and lets screen readers / // magnifiers track the input. Optional so testing.tsx doesn't stub it. - readonly onCursorDeclaration?: CursorDeclarationSetter; + readonly onCursorDeclaration?: CursorDeclarationSetter // Dispatch a keyboard event through the DOM tree. Called for each // parsed key alongside the legacy EventEmitter path. - readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void; -}; + readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void +} // Multi-click detection thresholds. 500ms is the macOS default; a small // position tolerance allows for trackpad jitter between clicks. -const MULTI_CLICK_TIMEOUT_MS = 500; -const MULTI_CLICK_DISTANCE = 1; +const MULTI_CLICK_TIMEOUT_MS = 500 +const MULTI_CLICK_DISTANCE = 1 + type State = { - readonly error?: Error; -}; + readonly error?: Error +} // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed // It also handles Ctrl+C exiting and cursor visibility export default class App extends PureComponent { - static displayName = 'InternalApp'; + static displayName = 'InternalApp' + static getDerivedStateFromError(error: Error) { - return { - error - }; + return { error } } + override state = { - error: undefined - }; + error: undefined, + } // Count how many components enabled raw mode to avoid disabling // raw mode until all components don't need it anymore - rawModeEnabledCount = 0; - internal_eventEmitter = new EventEmitter(); - keyParseState = INITIAL_STATE; + rawModeEnabledCount = 0 + + internal_eventEmitter = new EventEmitter() + keyParseState = INITIAL_STATE // Timer for flushing incomplete escape sequences - incompleteEscapeTimer: NodeJS.Timeout | null = null; + incompleteEscapeTimer: NodeJS.Timeout | null = null // Timeout durations for incomplete sequences (ms) - readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences - readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations + readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences + readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations // Terminal query/response dispatch. Responses arrive on stdin (parsed // out by parse-keypress) and are routed to pending promise resolvers. - querier = new TerminalQuerier(this.props.stdout); + querier = new TerminalQuerier(this.props.stdout) // Multi-click tracking for double/triple-click text selection. A click // within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous // click increments clickCount; otherwise it resets to 1. - lastClickTime = 0; - lastClickCol = -1; - lastClickRow = -1; - clickCount = 0; + lastClickTime = 0 + lastClickCol = -1 + lastClickRow = -1 + clickCount = 0 // Deferred hyperlink-open timer — cancelled if a second click arrives // within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects // the word without also opening the browser). DOM onClick dispatch is // NOT deferred — it returns true from onClickAt and skips this timer. - pendingHyperlinkTimer: ReturnType | null = null; + pendingHyperlinkTimer: ReturnType | null = null // Last mode-1003 motion position. Terminals already dedupe to cell // granularity but this also lets us skip dispatchHover entirely on // repeat events (drag-then-release at same cell, etc.). - lastHoverCol = -1; - lastHoverRow = -1; + lastHoverCol = -1 + lastHoverRow = -1 // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, // ssh reconnect, laptop wake) and trigger terminal mode re-assert. // Initialized to now so startup doesn't false-trigger. - lastStdinTime = Date.now(); + lastStdinTime = Date.now() // Determines if TTY is supported on the provided stdin isRawModeSupported(): boolean { - return this.props.stdin.isTTY; + return this.props.stdin.isTTY } + override render() { - return - - + return ( + + + - {})}> - {this.state.error ? : this.props.children} + {})} + > + {this.state.error ? ( + + ) : ( + this.props.children + )} - ; + + ) } + override componentDidMount() { // In accessibility mode, keep the native cursor visible for screen magnifiers and other tools - if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { - this.props.stdout.write(HIDE_CURSOR); + if ( + this.props.stdout.isTTY && + !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY) + ) { + this.props.stdout.write(HIDE_CURSOR) } } + override componentWillUnmount() { if (this.props.stdout.isTTY) { - this.props.stdout.write(SHOW_CURSOR); + this.props.stdout.write(SHOW_CURSOR) } // Clear any pending timers if (this.incompleteEscapeTimer) { - clearTimeout(this.incompleteEscapeTimer); - this.incompleteEscapeTimer = null; + clearTimeout(this.incompleteEscapeTimer) + this.incompleteEscapeTimer = null } if (this.pendingHyperlinkTimer) { - clearTimeout(this.pendingHyperlinkTimer); - this.pendingHyperlinkTimer = null; + clearTimeout(this.pendingHyperlinkTimer) + this.pendingHyperlinkTimer = null } // ignore calling setRawMode on an handle stdin it cannot be called if (this.isRawModeSupported()) { - this.handleSetRawMode(false); + this.handleSetRawMode(false) } } + override componentDidCatch(error: Error) { - this.handleExit(error); + this.handleExit(error) } + handleSetRawMode = (isEnabled: boolean): void => { - const { - stdin - } = this.props; + const { stdin } = this.props + if (!this.isRawModeSupported()) { if (stdin === process.stdin) { - throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); + throw new Error( + 'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', + ) } else { - throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); + throw new Error( + 'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', + ) } } - stdin.setEncoding('utf8'); + + stdin.setEncoding('utf8') + if (isEnabled) { // Ensure raw mode is enabled only once if (this.rawModeEnabledCount === 0) { @@ -225,22 +292,22 @@ export default class App extends PureComponent { // Both use the same stdin 'readable' + read() pattern, so they can't // coexist -- our handler would drain stdin before Ink's can see it. // The buffered text is preserved for REPL.tsx via consumeEarlyInput(). - stopCapturingEarlyInput(); - stdin.ref(); - stdin.setRawMode(true); - stdin.addListener('readable', this.handleReadable); + stopCapturingEarlyInput() + stdin.ref() + stdin.setRawMode(true) + stdin.addListener('readable', this.handleReadable) // Enable bracketed paste mode - this.props.stdout.write(EBP); + this.props.stdout.write(EBP) // Enable terminal focus reporting (DECSET 1004) - this.props.stdout.write(EFE); + this.props.stdout.write(EFE) // Enable extended key reporting so ctrl+shift+ is // distinguishable from ctrl+. We write both the kitty stack // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — // terminals honor whichever they implement (tmux only accepts the // latter). if (supportsExtendedKeys()) { - this.props.stdout.write(ENABLE_KITTY_KEYBOARD); - this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS); + this.props.stdout.write(ENABLE_KITTY_KEYBOARD) + this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS) } // Probe terminal identity. XTVERSION survives SSH (query/reply goes // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base @@ -251,41 +318,45 @@ export default class App extends PureComponent { // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { - void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { + void Promise.all([ + this.querier.send(xtversion()), + this.querier.flush(), + ]).then(([r]) => { if (r) { - setXtversionName(r.name); - logForDebugging(`XTVERSION: terminal identified as "${r.name}"`); + setXtversionName(r.name) + logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) } else { - logForDebugging('XTVERSION: no reply (terminal ignored query)'); + logForDebugging('XTVERSION: no reply (terminal ignored query)') } - }); - }); + }) + }) } - this.rawModeEnabledCount++; - return; + + this.rawModeEnabledCount++ + return } // Disable raw mode only when no components left that are using it if (--this.rawModeEnabledCount === 0) { - this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS); - this.props.stdout.write(DISABLE_KITTY_KEYBOARD); + this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS) + this.props.stdout.write(DISABLE_KITTY_KEYBOARD) // Disable terminal focus reporting (DECSET 1004) - this.props.stdout.write(DFE); + this.props.stdout.write(DFE) // Disable bracketed paste mode - this.props.stdout.write(DBP); - stdin.setRawMode(false); - stdin.removeListener('readable', this.handleReadable); - stdin.unref(); + this.props.stdout.write(DBP) + stdin.setRawMode(false) + stdin.removeListener('readable', this.handleReadable) + stdin.unref() } - }; + } // Helper to flush incomplete escape sequences flushIncomplete = (): void => { // Clear the timer reference - this.incompleteEscapeTimer = null; + this.incompleteEscapeTimer = null // Only proceed if we have incomplete sequences - if (!this.keyParseState.incomplete) return; + if (!this.keyParseState.incomplete) return // Fullscreen: if stdin has data waiting, it's almost certainly the // continuation of the buffered sequence (e.g. `[<64;74;16M` after a @@ -296,20 +367,23 @@ export default class App extends PureComponent { // drain stdin next and clear this timer. Prevents both the spurious // Escape key and the lost scroll event. if (this.props.stdin.readableLength > 0) { - this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT); - return; + this.incompleteEscapeTimer = setTimeout( + this.flushIncomplete, + this.NORMAL_TIMEOUT, + ) + return } // Process incomplete as a flush operation (input=null) // This reuses all existing parsing logic - this.processInput(null); - }; + this.processInput(null) + } // Process input through the parser and handle the results processInput = (input: string | Buffer | null): void => { // Parse input using our state machine - const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input); - this.keyParseState = newState; + const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input) + this.keyParseState = newState // Process ALL keys in a SINGLE discreteUpdates call to prevent // "Maximum update depth exceeded" error when many keys arrive at once @@ -317,87 +391,106 @@ export default class App extends PureComponent { // This batches all state updates from handleInput and all useInput // listeners together within one high-priority update context. if (keys.length > 0) { - reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined); + reconciler.discreteUpdates( + processKeysInBatch, + this, + keys, + undefined, + undefined, + ) } // If we have incomplete escape sequences, set a timer to flush them if (this.keyParseState.incomplete) { // Cancel any existing timer first if (this.incompleteEscapeTimer) { - clearTimeout(this.incompleteEscapeTimer); + clearTimeout(this.incompleteEscapeTimer) } - this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT); + this.incompleteEscapeTimer = setTimeout( + this.flushIncomplete, + this.keyParseState.mode === 'IN_PASTE' + ? this.PASTE_TIMEOUT + : this.NORMAL_TIMEOUT, + ) } - }; + } + handleReadable = (): void => { // Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake). // The terminal may have reset DEC private modes; re-assert mouse // tracking. Checked before the read loop so one Date.now() covers // all chunks in this readable event. - const now = Date.now(); + const now = Date.now() if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) { - this.props.onStdinResume?.(); + this.props.onStdinResume?.() } - this.lastStdinTime = now; + this.lastStdinTime = now try { - let chunk; + let chunk while ((chunk = this.props.stdin.read() as string | null) !== null) { // Process the input chunk - this.processInput(chunk); + this.processInput(chunk) } } catch (error) { // In Bun, an uncaught throw inside a stream 'readable' handler can // permanently wedge the stream: data stays buffered and 'readable' // never re-emits. Catching here ensures the stream stays healthy so // subsequent keystrokes are still delivered. - logError(error); + logError(error) // Re-attach the listener in case the exception detached it. // Bun may remove the listener after an error; without this, // the session freezes permanently (stdin reader dead, event loop alive). - const { - stdin - } = this.props; - if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) { - logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', { - level: 'warn' - }); - stdin.addListener('readable', this.handleReadable); + const { stdin } = this.props + if ( + this.rawModeEnabledCount > 0 && + !stdin.listeners('readable').includes(this.handleReadable) + ) { + logForDebugging( + 'handleReadable: re-attaching stdin readable listener after error recovery', + { level: 'warn' }, + ) + stdin.addListener('readable', this.handleReadable) } } - }; + } + handleInput = (input: string | undefined): void => { // Exit on Ctrl+C if (input === '\x03' && this.props.exitOnCtrlC) { - this.handleExit(); + this.handleExit() } // Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the // parsed key to support both raw (\x1a) and CSI u format from Kitty // keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm) - }; + } + handleExit = (error?: Error): void => { if (this.isRawModeSupported()) { - this.handleSetRawMode(false); + this.handleSetRawMode(false) } - this.props.onExit(error); - }; + + this.props.onExit(error) + } + handleTerminalFocus = (isFocused: boolean): void => { // setTerminalFocused notifies subscribers: TerminalFocusProvider (context) // and Clock (interval speed) — no App setState needed. - setTerminalFocused(isFocused); - }; + setTerminalFocused(isFocused) + } + handleSuspend = (): void => { if (!this.isRawModeSupported()) { - return; + return } // Store the exact raw mode count to restore it properly - const rawModeCountBeforeSuspend = this.rawModeEnabledCount; + const rawModeCountBeforeSuspend = this.rawModeEnabledCount // Completely disable raw mode before suspending while (this.rawModeEnabledCount > 0) { - this.handleSetRawMode(false); + this.handleSetRawMode(false) } // Show cursor, disable focus reporting, and disable mouse tracking @@ -406,108 +499,125 @@ export default class App extends PureComponent { // it, SGR mouse sequences would appear as garbled text at the // shell prompt while suspended. if (this.props.stdout.isTTY) { - this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING); + this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING) } // Emit suspend event for Claude Code to handle. Mostly just has a notification - this.internal_eventEmitter.emit('suspend'); + this.internal_eventEmitter.emit('suspend') // Set up resume handler const resumeHandler = () => { // Restore raw mode to exact previous state for (let i = 0; i < rawModeCountBeforeSuspend; i++) { if (this.isRawModeSupported()) { - this.handleSetRawMode(true); + this.handleSetRawMode(true) } } // Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming if (this.props.stdout.isTTY) { if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { - this.props.stdout.write(HIDE_CURSOR); + this.props.stdout.write(HIDE_CURSOR) } // Re-enable focus reporting to restore terminal state - this.props.stdout.write(EFE); + this.props.stdout.write(EFE) } // Emit resume event for Claude Code to handle - this.internal_eventEmitter.emit('resume'); - process.removeListener('SIGCONT', resumeHandler); - }; - process.on('SIGCONT', resumeHandler); - process.kill(process.pid, 'SIGSTOP'); - }; + this.internal_eventEmitter.emit('resume') + + process.removeListener('SIGCONT', resumeHandler) + } + + process.on('SIGCONT', resumeHandler) + process.kill(process.pid, 'SIGSTOP') + } } // Helper to process all keys within a single discrete update context. // discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d) -function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void { +function processKeysInBatch( + app: App, + items: ParsedInput[], + _unused1: undefined, + _unused2: undefined, +): void { // Update interaction time for notification timeout tracking. // This is called from the central input handler to avoid having multiple // stdin listeners that can cause race conditions and dropped input. // Terminal responses (kind: 'response') are automated, not user input. // Mode-1003 no-button motion is also excluded — passive cursor drift is // not engagement (would suppress idle notifications + defer housekeeping). - if (items.some(i => i.kind === 'key' || i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3))) { - updateLastInteractionTime(); + if ( + items.some( + i => + i.kind === 'key' || + (i.kind === 'mouse' && + !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)), + ) + ) { + updateLastInteractionTime() } + for (const item of items) { // Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user // input — route them to the querier to resolve pending promises. if (item.kind === 'response') { - app.querier.onResponse(item.response); - continue; + app.querier.onResponse(item.response) + continue } // Mouse click/drag events update selection state (fullscreen only). // Terminal sends 1-indexed col/row; convert to 0-indexed for the // screen buffer. Button bit 0x20 = drag (motion while button held). if (item.kind === 'mouse') { - handleMouseEvent(app, item); - continue; + handleMouseEvent(app, item) + continue } - const sequence = item.sequence; + + const sequence = item.sequence // Handle terminal focus events (DECSET 1004) if (sequence === FOCUS_IN) { - app.handleTerminalFocus(true); - const event = new TerminalFocusEvent('terminalfocus'); - app.internal_eventEmitter.emit('terminalfocus', event); - continue; + app.handleTerminalFocus(true) + const event = new TerminalFocusEvent('terminalfocus') + app.internal_eventEmitter.emit('terminalfocus', event) + continue } if (sequence === FOCUS_OUT) { - app.handleTerminalFocus(false); + app.handleTerminalFocus(false) // Defensive: if we lost the release event (mouse released outside // terminal window — some emulators drop it rather than capturing the // pointer), focus-out is the next observable signal that the drag is // over. Without this, drag-to-scroll's timer runs until the scroll // boundary is hit. if (app.props.selection.isDragging) { - finishSelection(app.props.selection); - app.props.onSelectionChange(); + finishSelection(app.props.selection) + app.props.onSelectionChange() } - const event = new TerminalFocusEvent('terminalblur'); - app.internal_eventEmitter.emit('terminalblur', event); - continue; + const event = new TerminalFocusEvent('terminalblur') + app.internal_eventEmitter.emit('terminalblur', event) + continue } // Failsafe: if we receive input, the terminal must be focused if (!getTerminalFocused()) { - setTerminalFocused(true); + setTerminalFocused(true) } // Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and // CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) { - app.handleSuspend(); - continue; + app.handleSuspend() + continue } - app.handleInput(sequence); - const event = new InputEvent(item); - app.internal_eventEmitter.emit('input', event); + + app.handleInput(sequence) + const event = new InputEvent(item) + app.internal_eventEmitter.emit('input', event) // Also dispatch through the DOM tree so onKeyDown handlers fire. - app.props.dispatchKeyboardEvent(item); + app.props.dispatchKeyboardEvent(item) } } @@ -515,12 +625,14 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, export function handleMouseEvent(app: App, m: ParsedMouse): void { // Allow disabling click handling while keeping wheel scroll (which goes // through the keybinding system as 'wheelup'/'wheeldown', not here). - if (isMouseClicksDisabled()) return; - const sel = app.props.selection; + if (isMouseClicksDisabled()) return + + const sel = app.props.selection // Terminal coords are 1-indexed; screen buffer is 0-indexed - const col = m.col - 1; - const row = m.row - 1; - const baseButton = m.button & 0x03; + const col = m.col - 1 + const row = m.row - 1 + const baseButton = m.button & 0x03 + if (m.action === 'press') { if ((m.button & 0x20) !== 0 && baseButton === 3) { // Mode-1003 motion with no button held. Dispatch hover; skip the @@ -533,25 +645,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // past the edge, came back" — and tmux drops focus events unless // `focus-events on` is set, so this is the more reliable signal. if (sel.isDragging) { - finishSelection(sel); - app.props.onSelectionChange(); + finishSelection(sel) + app.props.onSelectionChange() } - if (col === app.lastHoverCol && row === app.lastHoverRow) return; - app.lastHoverCol = col; - app.lastHoverRow = row; - app.props.onHoverAt(col, row); - return; + if (col === app.lastHoverCol && row === app.lastHoverRow) return + app.lastHoverCol = col + app.lastHoverRow = row + app.props.onHoverAt(col, row) + return } if (baseButton !== 0) { // Non-left press breaks the multi-click chain. - app.clickCount = 0; - return; + app.clickCount = 0 + return } if ((m.button & 0x20) !== 0) { // Drag motion: mode-aware extension (char/word/line). onSelectionDrag // calls notifySelectionChange internally — no extra onSelectionChange. - app.props.onSelectionDrag(col, row); - return; + app.props.onSelectionDrag(col, row) + return } // Lost-release fallback for mode-1002-only terminals: a fresh press // while isDragging=true means the previous release was dropped (cursor @@ -559,40 +671,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // before startSelection/onMultiClick clobbers it. Mode-1003 terminals // hit the no-button-motion recovery above instead, so this is rare. if (sel.isDragging) { - finishSelection(sel); - app.props.onSelectionChange(); + finishSelection(sel) + app.props.onSelectionChange() } // Fresh left press. Detect multi-click HERE (not on release) so the // word/line highlight appears immediately and a subsequent drag can // extend by word/line like native macOS. Previously detected on // release, which meant (a) visible latency before the word highlights // and (b) double-click+drag fell through to char-mode selection. - const now = Date.now(); - const nearLast = now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE; - app.clickCount = nearLast ? app.clickCount + 1 : 1; - app.lastClickTime = now; - app.lastClickCol = col; - app.lastClickRow = row; + const now = Date.now() + const nearLast = + now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && + Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && + Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE + app.clickCount = nearLast ? app.clickCount + 1 : 1 + app.lastClickTime = now + app.lastClickCol = col + app.lastClickRow = row if (app.clickCount >= 2) { // Cancel any pending hyperlink-open from the first click — this is // a double-click, not a single-click on a link. if (app.pendingHyperlinkTimer) { - clearTimeout(app.pendingHyperlinkTimer); - app.pendingHyperlinkTimer = null; + clearTimeout(app.pendingHyperlinkTimer) + app.pendingHyperlinkTimer = null } // Cap at 3 (line select) for quadruple+ clicks. - const count = app.clickCount === 2 ? 2 : 3; - app.props.onMultiClick(col, row, count); - return; + const count = app.clickCount === 2 ? 2 : 3 + app.props.onMultiClick(col, row, count) + return } - startSelection(sel, col, row); + startSelection(sel, col, row) // SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see // comment at the hyperlink-open guard below). On macOS xterm.js, // receiving alt means macOptionClickForcesSelection is OFF (otherwise // xterm.js would have consumed the event for native selection). - sel.lastPressHadAlt = (m.button & 0x08) !== 0; - app.props.onSelectionChange(); - return; + sel.lastPressHadAlt = (m.button & 0x08) !== 0 + app.props.onSelectionChange() + return } // Release: end the drag even for non-zero button codes. Some terminals @@ -602,12 +717,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // scroll boundary. Only act on non-left releases when we ARE dragging // (so an unrelated middle/right click-release doesn't touch selection). if (baseButton !== 0) { - if (!sel.isDragging) return; - finishSelection(sel); - app.props.onSelectionChange(); - return; + if (!sel.isDragging) return + finishSelection(sel) + app.props.onSelectionChange() + return } - finishSelection(sel); + finishSelection(sel) // NOTE: unlike the old release-based detection we do NOT reset clickCount // on release-after-drag. This aligns with NSEvent.clickCount semantics: // an intervening drag doesn't break the click chain. Practical upside: @@ -628,7 +743,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // Resolve the hyperlink URL synchronously while the screen buffer // still reflects what the user clicked — deferring only the // browser-open so double-click can cancel it. - const url = app.props.getHyperlinkAt(col, row); + const url = app.props.getHyperlinkAt(col, row) // xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link // handler that fires on Cmd+click *without consuming the mouse event* // (Linkifier._handleMouseUp calls link.activate() but never @@ -644,14 +759,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // Clear any prior pending timer — clicking a second link // supersedes the first (only the latest click opens). if (app.pendingHyperlinkTimer) { - clearTimeout(app.pendingHyperlinkTimer); + clearTimeout(app.pendingHyperlinkTimer) } - app.pendingHyperlinkTimer = setTimeout((app, url) => { - app.pendingHyperlinkTimer = null; - app.props.onOpenHyperlink(url); - }, MULTI_CLICK_TIMEOUT_MS, app, url); + app.pendingHyperlinkTimer = setTimeout( + (app, url) => { + app.pendingHyperlinkTimer = null + app.props.onOpenHyperlink(url) + }, + MULTI_CLICK_TIMEOUT_MS, + app, + url, + ) } } } - app.props.onSelectionChange(); + app.props.onSelectionChange() } diff --git a/src/ink/components/Box.tsx b/src/ink/components/Box.tsx index 27b3f8ead..42785f523 100644 --- a/src/ink/components/Box.tsx +++ b/src/ink/components/Box.tsx @@ -1,212 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren, type Ref } 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 * as warn from '../warn.js'; +import React, { type PropsWithChildren, type Ref } 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 * as warn from '../warn.js' + export type Props = Except & { - ref?: Ref; + ref?: Ref /** * Tab order index. Nodes with `tabIndex >= 0` participate in * Tab/Shift+Tab cycling; `-1` means programmatically focusable only. */ - tabIndex?: number; + tabIndex?: number /** * Focus this element when it mounts. Like the HTML `autofocus` * attribute — the FocusManager calls `focus(node)` during the * reconciler's `commitMount` phase. */ - autoFocus?: boolean; + autoFocus?: boolean /** * Fired on left-button click (press + release without drag). Only works * inside `` where mouse tracking is enabled — no-op * otherwise. The event bubbles from the deepest hit Box up through * ancestors; call `event.stopImmediatePropagation()` to stop bubbling. */ - onClick?: (event: ClickEvent) => void; - onFocus?: (event: FocusEvent) => void; - onFocusCapture?: (event: FocusEvent) => void; - onBlur?: (event: FocusEvent) => void; - onBlurCapture?: (event: FocusEvent) => void; - onKeyDown?: (event: KeyboardEvent) => void; - onKeyDownCapture?: (event: KeyboardEvent) => void; + onClick?: (event: ClickEvent) => void + onFocus?: (event: FocusEvent) => void + onFocusCapture?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void + onKeyDown?: (event: KeyboardEvent) => void + onKeyDownCapture?: (event: KeyboardEvent) => void /** * Fired when the mouse moves into this Box's rendered rect. Like DOM * `mouseenter`, does NOT bubble — moving between children does not * re-fire on the parent. Only works inside `` where * mode-1003 mouse tracking is enabled. */ - onMouseEnter?: () => void; + onMouseEnter?: () => void /** Fired when the mouse moves out of this Box's rendered rect. */ - onMouseLeave?: () => void; -}; + onMouseLeave?: () => void +} /** * `` is an essential Ink component to build your layout. It's like `
` 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>>; - children: React.ReactNode; -}; -export function KeybindingProvider(t0) { - const $ = _c(24); - const { - bindings, - pendingChordRef, - pendingChord, - setPendingChord, - activeContexts, - registerActiveContext, - unregisterActiveContext, - handlerRegistryRef, - children - } = t0; - let t1; - if ($[0] !== bindings) { - t1 = (action, context) => getBindingDisplayText(action, context, bindings); - $[0] = bindings; - $[1] = t1; - } else { - t1 = $[1]; - } - const getDisplay = t1; - let t2; - if ($[2] !== handlerRegistryRef) { - t2 = registration => { - const registry = handlerRegistryRef.current; - if (!registry) { - return _temp; - } + handlerRegistryRef: RefObject>> + children: React.ReactNode +} + +export function KeybindingProvider({ + bindings, + pendingChordRef, + pendingChord, + setPendingChord, + activeContexts, + registerActiveContext, + unregisterActiveContext, + handlerRegistryRef, + children, +}: ProviderProps): React.ReactNode { + const value = useMemo(() => { + const getDisplay = (action: string, context: KeybindingContextName) => + getBindingDisplayText(action, context, bindings) + + // Register a handler for an action + const registerHandler = (registration: HandlerRegistration) => { + const registry = handlerRegistryRef.current + if (!registry) return () => {} + if (!registry.has(registration.action)) { - registry.set(registration.action, new Set()); + registry.set(registration.action, new Set()) } - registry.get(registration.action).add(registration); + registry.get(registration.action)!.add(registration) + + // Return unregister function return () => { - const handlers = registry.get(registration.action); + const handlers = registry.get(registration.action) if (handlers) { - handlers.delete(registration); + handlers.delete(registration) if (handlers.size === 0) { - registry.delete(registration.action); + registry.delete(registration.action) } } - }; - }; - $[2] = handlerRegistryRef; - $[3] = t2; - } else { - t2 = $[3]; - } - const registerHandler = t2; - let t3; - if ($[4] !== activeContexts || $[5] !== handlerRegistryRef) { - t3 = action_0 => { - const registry_0 = handlerRegistryRef.current; - if (!registry_0) { - return false; } - const handlers_0 = registry_0.get(action_0); - if (!handlers_0 || handlers_0.size === 0) { - return false; - } - for (const registration_0 of handlers_0) { - if (activeContexts.has(registration_0.context)) { - registration_0.handler(); - return true; + } + + // Invoke all handlers for an action + const invokeAction = (action: string): boolean => { + const registry = handlerRegistryRef.current + if (!registry) return false + + const handlers = registry.get(action) + if (!handlers || handlers.size === 0) return false + + // Find handlers whose context is active + for (const registration of handlers) { + if (activeContexts.has(registration.context)) { + registration.handler() + return true } } - return false; - }; - $[4] = activeContexts; - $[5] = handlerRegistryRef; - $[6] = t3; - } else { - t3 = $[6]; - } - const invokeAction = t3; - let t4; - if ($[7] !== bindings || $[8] !== pendingChordRef) { - t4 = (input, key, contexts) => resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); - $[7] = bindings; - $[8] = pendingChordRef; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== activeContexts || $[11] !== bindings || $[12] !== getDisplay || $[13] !== invokeAction || $[14] !== pendingChord || $[15] !== registerActiveContext || $[16] !== registerHandler || $[17] !== setPendingChord || $[18] !== t4 || $[19] !== unregisterActiveContext) { - t5 = { - resolve: t4, + return false + } + + return { + // Use ref for immediate access to pending chord, avoiding React state delay + // This is critical for chord sequences where the second key might be pressed + // before React re-renders with the updated pendingChord state + resolve: (input, key, contexts) => + resolveKeyWithChordState( + input, + key, + contexts, + bindings, + pendingChordRef.current, + ), setPendingChord, getDisplayText: getDisplay, bindings, @@ -152,49 +155,42 @@ export function KeybindingProvider(t0) { registerActiveContext, unregisterActiveContext, registerHandler, - invokeAction - }; - $[10] = activeContexts; - $[11] = bindings; - $[12] = getDisplay; - $[13] = invokeAction; - $[14] = pendingChord; - $[15] = registerActiveContext; - $[16] = registerHandler; - $[17] = setPendingChord; - $[18] = t4; - $[19] = unregisterActiveContext; - $[20] = t5; - } else { - t5 = $[20]; - } - const value = t5; - let t6; - if ($[21] !== children || $[22] !== value) { - t6 = {children}; - $[21] = children; - $[22] = value; - $[23] = t6; - } else { - t6 = $[23]; - } - return t6; + invokeAction, + } + }, [ + bindings, + pendingChordRef, + pendingChord, + setPendingChord, + activeContexts, + registerActiveContext, + unregisterActiveContext, + handlerRegistryRef, + ]) + + return ( + + {children} + + ) } -function _temp() {} -export function useKeybindingContext() { - const ctx = useContext(KeybindingContext); + +export function useKeybindingContext(): KeybindingContextValue { + const ctx = useContext(KeybindingContext) if (!ctx) { - throw new Error("useKeybindingContext must be used within KeybindingProvider"); + throw new Error( + 'useKeybindingContext must be used within KeybindingProvider', + ) } - return ctx; + return ctx } /** * Optional hook that returns undefined outside of KeybindingProvider. * Useful for components that may render before provider is available. */ -export function useOptionalKeybindingContext() { - return useContext(KeybindingContext); +export function useOptionalKeybindingContext(): KeybindingContextValue | null { + return useContext(KeybindingContext) } /** @@ -212,31 +208,18 @@ export function useOptionalKeybindingContext() { * } * ``` */ -export function useRegisterKeybindingContext(context, t0) { - const $ = _c(5); - const isActive = t0 === undefined ? true : t0; - const keybindingContext = useOptionalKeybindingContext(); - let t1; - let t2; - if ($[0] !== context || $[1] !== isActive || $[2] !== keybindingContext) { - t1 = () => { - if (!keybindingContext || !isActive) { - return; - } - keybindingContext.registerActiveContext(context); - return () => { - keybindingContext.unregisterActiveContext(context); - }; - }; - t2 = [context, keybindingContext, isActive]; - $[0] = context; - $[1] = isActive; - $[2] = keybindingContext; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - useLayoutEffect(t1, t2); +export function useRegisterKeybindingContext( + context: KeybindingContextName, + isActive: boolean = true, +): void { + const keybindingContext = useOptionalKeybindingContext() + + useLayoutEffect(() => { + if (!keybindingContext || !isActive) return + + keybindingContext.registerActiveContext(context) + return () => { + keybindingContext.unregisterActiveContext(context) + } + }, [context, keybindingContext, isActive]) } diff --git a/src/keybindings/KeybindingProviderSetup.tsx b/src/keybindings/KeybindingProviderSetup.tsx index 85609e8d8..2397468c8 100644 --- a/src/keybindings/KeybindingProviderSetup.tsx +++ b/src/keybindings/KeybindingProviderSetup.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Setup utilities for integrating KeybindingProvider into the app. * @@ -7,30 +6,40 @@ import { c as _c } from "react/compiler-runtime"; * user-defined bindings from ~/.claude/keybindings.json, with hot-reload * support when the file changes. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import type { InputEvent } from '../ink/events/input-event.js'; +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useNotifications } from '../context/notifications.js' +import type { InputEvent } from '../ink/events/input-event.js' // ChordInterceptor intentionally uses useInput to intercept all keystrokes before // other handlers process them - this is required for chord sequence support // eslint-disable-next-line custom-rules/prefer-use-keybindings -import { type Key, useInput } from '../ink.js'; -import { count } from '../utils/array.js'; -import { logForDebugging } from '../utils/debug.js'; -import { plural } from '../utils/stringUtils.js'; -import { KeybindingProvider } from './KeybindingContext.js'; -import { initializeKeybindingWatcher, type KeybindingsLoadResult, loadKeybindingsSyncWithWarnings, subscribeToKeybindingChanges } from './loadUserBindings.js'; -import { resolveKeyWithChordState } from './resolver.js'; -import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; -import type { KeybindingWarning } from './validate.js'; +import { type Key, useInput } from '../ink.js' +import { count } from '../utils/array.js' +import { logForDebugging } from '../utils/debug.js' +import { plural } from '../utils/stringUtils.js' +import { KeybindingProvider } from './KeybindingContext.js' +import { + initializeKeybindingWatcher, + type KeybindingsLoadResult, + loadKeybindingsSyncWithWarnings, + subscribeToKeybindingChanges, +} from './loadUserBindings.js' +import { resolveKeyWithChordState } from './resolver.js' +import type { + KeybindingContextName, + ParsedBinding, + ParsedKeystroke, +} from './types.js' +import type { KeybindingWarning } from './validate.js' /** * Timeout for chord sequences in milliseconds. * If the user doesn't complete the chord within this time, it's cancelled. */ -const CHORD_TIMEOUT_MS = 1000; +const CHORD_TIMEOUT_MS = 1000 + type Props = { - children: React.ReactNode; -}; + children: React.ReactNode +} /** * Keybinding provider with default + user bindings and hot-reload support. @@ -56,156 +65,179 @@ type Props = { * Display keybinding warnings to the user via notifications. * Shows a brief message pointing to /doctor for details. */ -function useKeybindingWarnings(warnings, isReload) { - const $ = _c(9); - const { - addNotification, - removeNotification - } = useNotifications(); - let t0; - if ($[0] !== addNotification || $[1] !== removeNotification || $[2] !== warnings) { - t0 = () => { - if (warnings.length === 0) { - removeNotification("keybinding-config-warning"); - return; - } - const errorCount = count(warnings, _temp); - const warnCount = count(warnings, _temp2); - let message; - if (errorCount > 0 && warnCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, "error")} and ${warnCount} ${plural(warnCount, "warning")}`; - } else { - if (errorCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, "error")}`; - } else { - message = `Found ${warnCount} keybinding ${plural(warnCount, "warning")}`; - } - } - message = message + " \xB7 /doctor for details"; - addNotification({ - key: "keybinding-config-warning", - text: message, - color: errorCount > 0 ? "error" : "warning", - priority: errorCount > 0 ? "immediate" : "high", - timeoutMs: 60000 - }); - }; - $[0] = addNotification; - $[1] = removeNotification; - $[2] = warnings; - $[3] = t0; - } else { - t0 = $[3]; - } - let t1; - if ($[4] !== addNotification || $[5] !== isReload || $[6] !== removeNotification || $[7] !== warnings) { - t1 = [warnings, isReload, addNotification, removeNotification]; - $[4] = addNotification; - $[5] = isReload; - $[6] = removeNotification; - $[7] = warnings; - $[8] = t1; - } else { - t1 = $[8]; - } - useEffect(t0, t1); +function useKeybindingWarnings( + warnings: KeybindingWarning[], + isReload: boolean, +): void { + const { addNotification, removeNotification } = useNotifications() + + useEffect(() => { + const notificationKey = 'keybinding-config-warning' + + if (warnings.length === 0) { + removeNotification(notificationKey) + return + } + + const errorCount = count(warnings, w => w.severity === 'error') + const warnCount = count(warnings, w => w.severity === 'warning') + + let message: string + if (errorCount > 0 && warnCount > 0) { + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')} and ${warnCount} ${plural(warnCount, 'warning')}` + } else if (errorCount > 0) { + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')}` + } else { + message = `Found ${warnCount} keybinding ${plural(warnCount, 'warning')}` + } + message += ' · /doctor for details' + + addNotification({ + key: notificationKey, + text: message, + color: errorCount > 0 ? 'error' : 'warning', + priority: errorCount > 0 ? 'immediate' : 'high', + // Keep visible for 60 seconds like settings errors + timeoutMs: 60000, + }) + }, [warnings, isReload, addNotification, removeNotification]) } -function _temp2(w_0) { - return w_0.severity === "warning"; -} -function _temp(w) { - return w.severity === "error"; -} -export function KeybindingSetup({ - children -}: Props): React.ReactNode { + +export function KeybindingSetup({ children }: Props): React.ReactNode { // Load bindings synchronously for initial render - const [{ - bindings, - warnings - }, setLoadResult] = useState(() => { - const result = loadKeybindingsSyncWithWarnings(); - logForDebugging(`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`); - return result; - }); + const [{ bindings, warnings }, setLoadResult] = + useState(() => { + const result = loadKeybindingsSyncWithWarnings() + logForDebugging( + `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`, + ) + return result + }) // Track if this is a reload (not initial load) - const [isReload, setIsReload] = useState(false); + const [isReload, setIsReload] = useState(false) // Display warnings via notifications - useKeybindingWarnings(warnings, isReload); + useKeybindingWarnings(warnings, isReload) // Chord state management - use ref for immediate access, state for re-renders // The ref is used by resolve() to get the current value without waiting for re-render // The state is used to trigger re-renders when needed (e.g., for UI updates) - const pendingChordRef = useRef(null); - const [pendingChord, setPendingChordState] = useState(null); - const chordTimeoutRef = useRef(null); + const pendingChordRef = useRef(null) + const [pendingChord, setPendingChordState] = useState< + ParsedKeystroke[] | null + >(null) + const chordTimeoutRef = useRef(null) // Handler registry for action callbacks (used by ChordInterceptor to invoke handlers) - const handlerRegistryRef = useRef(new Map void; - }>>()); + const handlerRegistryRef = useRef( + new Map< + string, + Set<{ + action: string + context: KeybindingContextName + handler: () => void + }> + >(), + ) // Active context tracking for keybinding priority resolution // Using a ref instead of state for synchronous updates - input handlers need // to see the current value immediately, not after a React render cycle. - const activeContextsRef = useRef>(new Set()); - const registerActiveContext = useCallback((context: KeybindingContextName) => { - activeContextsRef.current.add(context); - }, []); - const unregisterActiveContext = useCallback((context_0: KeybindingContextName) => { - activeContextsRef.current.delete(context_0); - }, []); + const activeContextsRef = useRef>(new Set()) + + const registerActiveContext = useCallback( + (context: KeybindingContextName) => { + activeContextsRef.current.add(context) + }, + [], + ) + + const unregisterActiveContext = useCallback( + (context: KeybindingContextName) => { + activeContextsRef.current.delete(context) + }, + [], + ) // Clear chord timeout when component unmounts or chord changes const clearChordTimeout = useCallback(() => { if (chordTimeoutRef.current) { - clearTimeout(chordTimeoutRef.current); - chordTimeoutRef.current = null; + clearTimeout(chordTimeoutRef.current) + chordTimeoutRef.current = null } - }, []); + }, []) // Wrapper for setPendingChord that manages timeout and syncs ref+state - const setPendingChord = useCallback((pending: ParsedKeystroke[] | null) => { - clearChordTimeout(); - if (pending !== null) { - // Set timeout to cancel chord if not completed - chordTimeoutRef.current = setTimeout((pendingChordRef_0, setPendingChordState_0) => { - logForDebugging('[keybindings] Chord timeout - cancelling'); - pendingChordRef_0.current = null; - setPendingChordState_0(null); - }, CHORD_TIMEOUT_MS, pendingChordRef, setPendingChordState); - } + const setPendingChord = useCallback( + (pending: ParsedKeystroke[] | null) => { + clearChordTimeout() + + if (pending !== null) { + // Set timeout to cancel chord if not completed + chordTimeoutRef.current = setTimeout( + (pendingChordRef, setPendingChordState) => { + logForDebugging('[keybindings] Chord timeout - cancelling') + pendingChordRef.current = null + setPendingChordState(null) + }, + CHORD_TIMEOUT_MS, + pendingChordRef, + setPendingChordState, + ) + } + + // Update ref immediately for synchronous access in resolve() + pendingChordRef.current = pending + // Update state to trigger re-renders for UI updates + setPendingChordState(pending) + }, + [clearChordTimeout], + ) - // Update ref immediately for synchronous access in resolve() - pendingChordRef.current = pending; - // Update state to trigger re-renders for UI updates - setPendingChordState(pending); - }, [clearChordTimeout]); useEffect(() => { // Initialize file watcher (idempotent - only runs once) - void initializeKeybindingWatcher(); + void initializeKeybindingWatcher() // Subscribe to changes - const unsubscribe = subscribeToKeybindingChanges(result_0 => { + const unsubscribe = subscribeToKeybindingChanges(result => { // Any callback invocation is a reload since initial load happens // synchronously in useState, not via this subscription - setIsReload(true); - setLoadResult(result_0); - logForDebugging(`[keybindings] Reloaded: ${result_0.bindings.length} bindings, ${result_0.warnings.length} warnings`); - }); + setIsReload(true) + + setLoadResult(result) + logForDebugging( + `[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`, + ) + }) + return () => { - unsubscribe(); - clearChordTimeout(); - }; - }, [clearChordTimeout]); - return - + unsubscribe() + clearChordTimeout() + } + }, [clearChordTimeout]) + + return ( + + {children} - ; + + ) } /** @@ -219,89 +251,131 @@ export function KeybindingSetup({ * system could recognize it as completing a chord. */ type HandlerRegistration = { - action: string; - context: KeybindingContextName; - handler: () => void; -}; -function ChordInterceptor(t0) { - const $ = _c(6); - const { - bindings, - pendingChordRef, - setPendingChord, - activeContexts, - handlerRegistryRef - } = t0; - let t1; - if ($[0] !== activeContexts || $[1] !== bindings || $[2] !== handlerRegistryRef || $[3] !== pendingChordRef || $[4] !== setPendingChord) { - t1 = (input, key, event) => { + action: string + context: KeybindingContextName + handler: () => void +} + +function ChordInterceptor({ + bindings, + pendingChordRef, + setPendingChord, + activeContexts, + handlerRegistryRef, +}: { + bindings: ParsedBinding[] + pendingChordRef: React.RefObject + setPendingChord: (pending: ParsedKeystroke[] | null) => void + activeContexts: Set + handlerRegistryRef: React.RefObject>> +}): null { + const handleInput = useCallback( + (input: string, key: Key, event: InputEvent) => { + // Wheel events can never start chord sequences — scroll:lineUp/Down are + // single-key bindings handled by per-component useKeybindings hooks, not + // here. Skip the registry scan. Mid-chord wheel still falls through so + // scrolling cancels the pending chord like any other non-matching key. if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) { - return; + return } - const registry = handlerRegistryRef.current; - const handlerContexts = new Set(); + + // Build context list from registered handlers + activeContexts + Global + // This ensures we can resolve chords for all contexts that have handlers + const registry = handlerRegistryRef.current + const handlerContexts = new Set() if (registry) { for (const handlers of registry.values()) { for (const registration of handlers) { - handlerContexts.add(registration.context); + handlerContexts.add(registration.context) } } } - const contexts = [...handlerContexts, ...activeContexts, "Global"]; - const wasInChord = pendingChordRef.current !== null; - const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); - bb23: switch (result.type) { - case "chord_started": - { - setPendingChord(result.pending); - event.stopImmediatePropagation(); - break bb23; - } - case "match": - { - setPendingChord(null); - if (wasInChord) { - const contextsSet = new Set(contexts); - if (registry) { - const handlers_0 = registry.get(result.action); - if (handlers_0 && handlers_0.size > 0) { - for (const registration_0 of handlers_0) { - if (contextsSet.has(registration_0.context)) { - registration_0.handler(); - event.stopImmediatePropagation(); - break; - } + const contexts: KeybindingContextName[] = [ + ...handlerContexts, + ...activeContexts, + 'Global', + ] + + // Track whether we're completing a chord (pending was non-null) + const wasInChord = pendingChordRef.current !== null + + // Check if this keystroke is part of a chord sequence + const result = resolveKeyWithChordState( + input, + key, + contexts, + bindings, + pendingChordRef.current, + ) + + switch (result.type) { + case 'chord_started': + // This key starts a chord - store pending state and stop propagation + setPendingChord(result.pending) + event.stopImmediatePropagation() + break + + case 'match': { + // Clear pending state + setPendingChord(null) + + // Only invoke handlers and stop propagation for chord completions + // (multi-keystroke sequences). Single-keystroke matches should propagate + // to per-hook handlers to avoid interfering with other input handling + // (e.g., Enter needs to reach useTypeahead for autocomplete acceptance + // before the submit handler fires). + if (wasInChord) { + // Find and invoke the handler for this action + // We need to check that the handler's context is in our resolved contexts + // (which includes handlerContexts + activeContexts + Global) + const contextsSet = new Set(contexts) + if (registry) { + const handlers = registry.get(result.action) + if (handlers && handlers.size > 0) { + // Find handlers whose context is in our resolved contexts + for (const registration of handlers) { + if (contextsSet.has(registration.context)) { + registration.handler() + event.stopImmediatePropagation() + break // Only invoke the first matching handler } } } } - break bb23; } - case "chord_cancelled": - { - setPendingChord(null); - event.stopImmediatePropagation(); - break bb23; - } - case "unbound": - { - setPendingChord(null); - event.stopImmediatePropagation(); - break bb23; - } - case "none": + break + } + + case 'chord_cancelled': + // Invalid key during chord - clear pending state and swallow the + // keystroke so it doesn't propagate as a standalone action + // (e.g., ctrl+x ctrl+c should not fire app:interrupt). + setPendingChord(null) + event.stopImmediatePropagation() + break + + case 'unbound': + // Key is explicitly unbound - clear pending state and swallow + // the keystroke (it was part of a chord sequence). + setPendingChord(null) + event.stopImmediatePropagation() + break + + case 'none': + // No chord involvement - let other handlers process + break } - }; - $[0] = activeContexts; - $[1] = bindings; - $[2] = handlerRegistryRef; - $[3] = pendingChordRef; - $[4] = setPendingChord; - $[5] = t1; - } else { - t1 = $[5]; - } - const handleInput = t1; - useInput(handleInput); - return null; + }, + [ + bindings, + pendingChordRef, + setPendingChord, + activeContexts, + handlerRegistryRef, + ], + ) + + useInput(handleInput) + + return null } diff --git a/src/screens/Doctor.tsx b/src/screens/Doctor.tsx index eb511c063..d8de3714a 100644 --- a/src/screens/Doctor.tsx +++ b/src/screens/Doctor.tsx @@ -1,574 +1,516 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import { join } from 'path'; -import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react'; -import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js'; -import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js'; -import { getModelMaxOutputTokens } from 'src/utils/context.js'; -import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import { getOriginalCwd } from '../bootstrap/state.js'; -import type { CommandResultDisplay } from '../commands.js'; -import { Pane } from '../components/design-system/Pane.js'; -import { PressEnterToContinue } from '../components/PressEnterToContinue.js'; -import { SandboxDoctorSection } from '../components/sandbox/SandboxDoctorSection.js'; -import { ValidationErrorsList } from '../components/ValidationErrorsList.js'; -import { useSettingsErrors } from '../hooks/notifs/useSettingsErrors.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useAppState } from '../state/AppState.js'; -import { getPluginErrorMessage } from '../types/plugin.js'; -import { getGcsDistTags, getNpmDistTags, type NpmDistTags } from '../utils/autoUpdater.js'; -import { type ContextWarnings, checkContextWarnings } from '../utils/doctorContextWarnings.js'; -import { type DiagnosticInfo, getDoctorDiagnostic } from '../utils/doctorDiagnostic.js'; -import { validateBoundedIntEnvVar } from '../utils/envValidation.js'; -import { pathExists } from '../utils/file.js'; -import { cleanupStaleLocks, getAllLockInfo, isPidBasedLockingEnabled, type LockInfo } from '../utils/nativeInstaller/pidLock.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; -import { BASH_MAX_OUTPUT_DEFAULT, BASH_MAX_OUTPUT_UPPER_LIMIT } from '../utils/shell/outputLimits.js'; -import { TASK_MAX_OUTPUT_DEFAULT, TASK_MAX_OUTPUT_UPPER_LIMIT } from '../utils/task/outputFormatting.js'; -import { getXDGStateHome } from '../utils/xdg.js'; +import figures from 'figures' +import { join } from 'path' +import React, { + Suspense, + use, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js' +import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js' +import { getModelMaxOutputTokens } from 'src/utils/context.js' +import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js' +import type { SettingSource } from 'src/utils/settings/constants.js' +import { getOriginalCwd } from '../bootstrap/state.js' +import type { CommandResultDisplay } from '../commands.js' +import { Pane } from '../components/design-system/Pane.js' +import { PressEnterToContinue } from '../components/PressEnterToContinue.js' +import { SandboxDoctorSection } from '../components/sandbox/SandboxDoctorSection.js' +import { ValidationErrorsList } from '../components/ValidationErrorsList.js' +import { useSettingsErrors } from '../hooks/notifs/useSettingsErrors.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useAppState } from '../state/AppState.js' +import { getPluginErrorMessage } from '../types/plugin.js' +import { + getGcsDistTags, + getNpmDistTags, + type NpmDistTags, +} from '../utils/autoUpdater.js' +import { + type ContextWarnings, + checkContextWarnings, +} from '../utils/doctorContextWarnings.js' +import { + type DiagnosticInfo, + getDoctorDiagnostic, +} from '../utils/doctorDiagnostic.js' +import { validateBoundedIntEnvVar } from '../utils/envValidation.js' +import { pathExists } from '../utils/file.js' +import { + cleanupStaleLocks, + getAllLockInfo, + isPidBasedLockingEnabled, + type LockInfo, +} from '../utils/nativeInstaller/pidLock.js' +import { getInitialSettings } from '../utils/settings/settings.js' +import { + BASH_MAX_OUTPUT_DEFAULT, + BASH_MAX_OUTPUT_UPPER_LIMIT, +} from '../utils/shell/outputLimits.js' +import { + TASK_MAX_OUTPUT_DEFAULT, + TASK_MAX_OUTPUT_UPPER_LIMIT, +} from '../utils/task/outputFormatting.js' +import { getXDGStateHome } from '../utils/xdg.js' + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + type AgentInfo = { activeAgents: Array<{ - agentType: string; - source: SettingSource | 'built-in' | 'plugin'; - }>; - userAgentsDir: string; - projectAgentsDir: string; - userDirExists: boolean; - projectDirExists: boolean; - failedFiles?: Array<{ - path: string; - error: string; - }>; -}; + agentType: string + source: SettingSource | 'built-in' | 'plugin' + }> + userAgentsDir: string + projectAgentsDir: string + userDirExists: boolean + projectDirExists: boolean + failedFiles?: Array<{ path: string; error: string }> +} + type VersionLockInfo = { - enabled: boolean; - locks: LockInfo[]; - locksDir: string; - staleLocksCleaned: number; -}; -function DistTagsDisplay(t0: { promise: Promise }) { - const $ = _c(8); - const { - promise - } = t0; - const distTags = use(promise) as NpmDistTags; + enabled: boolean + locks: LockInfo[] + locksDir: string + staleLocksCleaned: number +} + +function DistTagsDisplay({ + promise, +}: { + promise: Promise +}): React.ReactNode { + const distTags = use(promise) if (!distTags.latest) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = └ Failed to fetch versions; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + return └ Failed to fetch versions } - let t1; - if ($[1] !== distTags.stable) { - t1 = distTags.stable && └ Stable version: {distTags.stable}; - $[1] = distTags.stable; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== distTags.latest) { - t2 = └ Latest version: {distTags.latest}; - $[3] = distTags.latest; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== t1 || $[6] !== t2) { - t3 = <>{t1}{t2}; - $[5] = t1; - $[6] = t2; - $[7] = t3; - } else { - t3 = $[7]; - } - return t3; + return ( + <> + {distTags.stable && └ Stable version: {distTags.stable}} + └ Latest version: {distTags.latest} + + ) } -export function Doctor(t0) { - const $ = _c(84); - const { - onDone - } = t0; - const agentDefinitions = useAppState(_temp); - const mcpTools = useAppState(_temp2); - const toolPermissionContext = useAppState(_temp3); - const pluginsErrors = useAppState(_temp4); - useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] !== mcpTools) { - t1 = mcpTools || []; - $[0] = mcpTools; - $[1] = t1; - } else { - t1 = $[1]; - } - const tools = t1; - const [diagnostic, setDiagnostic] = useState(null); - const [agentInfo, setAgentInfo] = useState(null); - const [contextWarnings, setContextWarnings] = useState(null); - const [versionLockInfo, setVersionLockInfo] = useState(null); - const validationErrors = useSettingsErrors(); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getDoctorDiagnostic().then(_temp6); - $[2] = t2; - } else { - t2 = $[2]; - } - const distTagsPromise = t2; - const autoUpdatesChannel = getInitialSettings()?.autoUpdatesChannel ?? "latest"; - let t3; - if ($[3] !== validationErrors) { - t3 = validationErrors.filter(_temp7); - $[3] = validationErrors; - $[4] = t3; - } else { - t3 = $[4]; - } - const errorsExcludingMcp = t3; - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - const envVars = [{ - name: "BASH_MAX_OUTPUT_LENGTH", - default: BASH_MAX_OUTPUT_DEFAULT, - upperLimit: BASH_MAX_OUTPUT_UPPER_LIMIT - }, { - name: "TASK_MAX_OUTPUT_LENGTH", - default: TASK_MAX_OUTPUT_DEFAULT, - upperLimit: TASK_MAX_OUTPUT_UPPER_LIMIT - }, { - name: "CLAUDE_CODE_MAX_OUTPUT_TOKENS", - ...getModelMaxOutputTokens("claude-opus-4-6") - }]; - t4 = envVars.map(_temp8).filter(_temp9); - $[5] = t4; - } else { - t4 = $[5]; - } - const envValidationErrors = t4; - let t5; - let t6; - if ($[6] !== agentDefinitions || $[7] !== toolPermissionContext || $[8] !== tools) { - t5 = () => { - getDoctorDiagnostic().then(setDiagnostic); - (async () => { - const userAgentsDir = join(getClaudeConfigHomeDir(), "agents"); - const projectAgentsDir = join(getOriginalCwd(), ".claude", "agents"); - const { + +export function Doctor({ onDone }: Props): React.ReactNode { + const agentDefinitions = useAppState(s => s.agentDefinitions) + const mcpTools = useAppState(s => s.mcp.tools) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const pluginsErrors = useAppState(s => s.plugins.errors) + useExitOnCtrlCDWithKeybindings() + + const tools = useMemo(() => { + return mcpTools || [] + }, [mcpTools]) + + const [diagnostic, setDiagnostic] = useState(null) + const [agentInfo, setAgentInfo] = useState(null) + const [contextWarnings, setContextWarnings] = + useState(null) + const [versionLockInfo, setVersionLockInfo] = + useState(null) + const validationErrors = useSettingsErrors() + + // Create promise once for dist-tags fetch (depends on diagnostic) + const distTagsPromise = useMemo( + () => + getDoctorDiagnostic().then(diag => { + const fetchDistTags = + diag.installationType === 'native' ? getGcsDistTags : getNpmDistTags + return fetchDistTags().catch(() => ({ latest: null, stable: null })) + }), + [], + ) + const autoUpdatesChannel = + getInitialSettings()?.autoUpdatesChannel ?? 'latest' + + const errorsExcludingMcp = validationErrors.filter( + error => error.mcpErrorMetadata === undefined, + ) + + const envValidationErrors = useMemo(() => { + const envVars = [ + { + name: 'BASH_MAX_OUTPUT_LENGTH', + default: BASH_MAX_OUTPUT_DEFAULT, + upperLimit: BASH_MAX_OUTPUT_UPPER_LIMIT, + }, + { + name: 'TASK_MAX_OUTPUT_LENGTH', + default: TASK_MAX_OUTPUT_DEFAULT, + upperLimit: TASK_MAX_OUTPUT_UPPER_LIMIT, + }, + { + name: 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', + // Check for values against the latest supported model + ...getModelMaxOutputTokens('claude-opus-4-6'), + }, + ] + return envVars + .map(v => { + const value = process.env[v.name] + const result = validateBoundedIntEnvVar( + v.name, + value, + v.default, + v.upperLimit, + ) + return { name: v.name, ...result } + }) + .filter(v => v.status !== 'valid') + }, []) + + useEffect(() => { + void getDoctorDiagnostic().then(setDiagnostic) + + void (async () => { + const userAgentsDir = join(getClaudeConfigHomeDir(), 'agents') + const projectAgentsDir = join(getOriginalCwd(), '.claude', 'agents') + + const { activeAgents, allAgents, failedFiles } = agentDefinitions + + const [userDirExists, projectDirExists] = await Promise.all([ + pathExists(userAgentsDir), + pathExists(projectAgentsDir), + ]) + + const agentInfoData = { + activeAgents: activeAgents.map(a => ({ + agentType: a.agentType, + source: a.source, + })), + userAgentsDir, + projectAgentsDir, + userDirExists, + projectDirExists, + failedFiles, + } + setAgentInfo(agentInfoData) + + const warnings = await checkContextWarnings( + tools, + { activeAgents, allAgents, - failedFiles - } = agentDefinitions; - const [userDirExists, projectDirExists] = await Promise.all([pathExists(userAgentsDir), pathExists(projectAgentsDir)]); - const agentInfoData = { - activeAgents: activeAgents.map(_temp0), - userAgentsDir, - projectAgentsDir, - userDirExists, - projectDirExists, - failedFiles - }; - setAgentInfo(agentInfoData); - const warnings = await checkContextWarnings(tools, { - activeAgents, - allAgents, - failedFiles - }, async () => toolPermissionContext); - setContextWarnings(warnings); - if (isPidBasedLockingEnabled()) { - const locksDir = join(getXDGStateHome(), "claude", "locks"); - const staleLocksCleaned = cleanupStaleLocks(locksDir); - const locks = getAllLockInfo(locksDir); - setVersionLockInfo({ - enabled: true, - locks, - locksDir, - staleLocksCleaned - }); - } else { - setVersionLockInfo({ - enabled: false, - locks: [], - locksDir: "", - staleLocksCleaned: 0 - }); - } - })(); - }; - t6 = [toolPermissionContext, tools, agentDefinitions]; - $[6] = agentDefinitions; - $[7] = toolPermissionContext; - $[8] = tools; - $[9] = t5; - $[10] = t6; - } else { - t5 = $[9]; - t6 = $[10]; - } - useEffect(t5, t6); - let t7; - if ($[11] !== onDone) { - t7 = () => { - onDone("Claude Code diagnostics dismissed", { - display: "system" - }); - }; - $[11] = onDone; - $[12] = t7; - } else { - t7 = $[12]; - } - const handleDismiss = t7; - let t8; - if ($[13] !== handleDismiss) { - t8 = { - "confirm:yes": handleDismiss, - "confirm:no": handleDismiss - }; - $[13] = handleDismiss; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t9 = { - context: "Confirmation" - }; - $[15] = t9; - } else { - t9 = $[15]; - } - useKeybindings(t8, t9); + failedFiles, + }, + async () => toolPermissionContext, + ) + setContextWarnings(warnings) + + // Fetch version lock info if PID-based locking is enabled + if (isPidBasedLockingEnabled()) { + const locksDir = join(getXDGStateHome(), 'claude', 'locks') + const staleLocksCleaned = cleanupStaleLocks(locksDir) + const locks = getAllLockInfo(locksDir) + setVersionLockInfo({ + enabled: true, + locks, + locksDir, + staleLocksCleaned, + }) + } else { + setVersionLockInfo({ + enabled: false, + locks: [], + locksDir: '', + staleLocksCleaned: 0, + }) + } + })() + }, [toolPermissionContext, tools, agentDefinitions]) + + const handleDismiss = useCallback(() => { + onDone('Claude Code diagnostics dismissed', { display: 'system' }) + }, [onDone]) + + // Handle dismiss via keybindings (Enter, Escape, or Ctrl+C) + useKeybindings( + { + 'confirm:yes': handleDismiss, + 'confirm:no': handleDismiss, + }, + { context: 'Confirmation' }, + ) + + // Loading state if (!diagnostic) { - let t10; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Checking installation status…; - $[16] = t10; - } else { - t10 = $[16]; - } - return t10; + return ( + + Checking installation status… + + ) } - let t10; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Diagnostics; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== diagnostic.installationType || $[19] !== diagnostic.version) { - t11 = └ Currently running: {diagnostic.installationType} ({diagnostic.version}); - $[18] = diagnostic.installationType; - $[19] = diagnostic.version; - $[20] = t11; - } else { - t11 = $[20]; - } - let t12; - if ($[21] !== diagnostic.packageManager) { - t12 = diagnostic.packageManager && └ Package manager: {diagnostic.packageManager}; - $[21] = diagnostic.packageManager; - $[22] = t12; - } else { - t12 = $[22]; - } - let t13; - if ($[23] !== diagnostic.installationPath) { - t13 = └ Path: {diagnostic.installationPath}; - $[23] = diagnostic.installationPath; - $[24] = t13; - } else { - t13 = $[24]; - } - let t14; - if ($[25] !== diagnostic.invokedBinary) { - t14 = └ Invoked: {diagnostic.invokedBinary}; - $[25] = diagnostic.invokedBinary; - $[26] = t14; - } else { - t14 = $[26]; - } - let t15; - if ($[27] !== diagnostic.configInstallMethod) { - t15 = └ Config install method: {diagnostic.configInstallMethod}; - $[27] = diagnostic.configInstallMethod; - $[28] = t15; - } else { - t15 = $[28]; - } - const t16 = diagnostic.ripgrepStatus.working ? "OK" : "Not working"; - const t17 = diagnostic.ripgrepStatus.mode === "embedded" ? "bundled" : diagnostic.ripgrepStatus.mode === "builtin" ? "vendor" : diagnostic.ripgrepStatus.systemPath || "system"; - let t18; - if ($[29] !== t16 || $[30] !== t17) { - t18 = └ Search: {t16} ({t17}); - $[29] = t16; - $[30] = t17; - $[31] = t18; - } else { - t18 = $[31]; - } - let t19; - if ($[32] !== diagnostic.recommendation) { - t19 = diagnostic.recommendation && <>Recommendation: {diagnostic.recommendation.split("\n")[0]}{diagnostic.recommendation.split("\n")[1]}; - $[32] = diagnostic.recommendation; - $[33] = t19; - } else { - t19 = $[33]; - } - let t20; - if ($[34] !== diagnostic.multipleInstallations) { - t20 = diagnostic.multipleInstallations.length > 1 && <>Warning: Multiple installations found{diagnostic.multipleInstallations.map(_temp1)}; - $[34] = diagnostic.multipleInstallations; - $[35] = t20; - } else { - t20 = $[35]; - } - let t21; - if ($[36] !== diagnostic.warnings) { - t21 = diagnostic.warnings.length > 0 && <>{diagnostic.warnings.map(_temp10)}; - $[36] = diagnostic.warnings; - $[37] = t21; - } else { - t21 = $[37]; - } - let t22; - if ($[38] !== errorsExcludingMcp) { - t22 = errorsExcludingMcp.length > 0 && Invalid Settings; - $[38] = errorsExcludingMcp; - $[39] = t22; - } else { - t22 = $[39]; - } - let t23; - if ($[40] !== t11 || $[41] !== t12 || $[42] !== t13 || $[43] !== t14 || $[44] !== t15 || $[45] !== t18 || $[46] !== t19 || $[47] !== t20 || $[48] !== t21 || $[49] !== t22) { - t23 = {t10}{t11}{t12}{t13}{t14}{t15}{t18}{t19}{t20}{t21}{t22}; - $[40] = t11; - $[41] = t12; - $[42] = t13; - $[43] = t14; - $[44] = t15; - $[45] = t18; - $[46] = t19; - $[47] = t20; - $[48] = t21; - $[49] = t22; - $[50] = t23; - } else { - t23 = $[50]; - } - let t24; - if ($[51] === Symbol.for("react.memo_cache_sentinel")) { - t24 = Updates; - $[51] = t24; - } else { - t24 = $[51]; - } - const t25 = diagnostic.packageManager ? "Managed by package manager" : diagnostic.autoUpdates; - let t26; - if ($[52] !== t25) { - t26 = └ Auto-updates:{" "}{t25}; - $[52] = t25; - $[53] = t26; - } else { - t26 = $[53]; - } - let t27; - if ($[54] !== diagnostic.hasUpdatePermissions) { - t27 = diagnostic.hasUpdatePermissions !== null && └ Update permissions:{" "}{diagnostic.hasUpdatePermissions ? "Yes" : "No (requires sudo)"}; - $[54] = diagnostic.hasUpdatePermissions; - $[55] = t27; - } else { - t27 = $[55]; - } - let t28; - if ($[56] === Symbol.for("react.memo_cache_sentinel")) { - t28 = └ Auto-update channel: {autoUpdatesChannel}; - $[56] = t28; - } else { - t28 = $[56]; - } - let t29; - if ($[57] === Symbol.for("react.memo_cache_sentinel")) { - t29 = ; - $[57] = t29; - } else { - t29 = $[57]; - } - let t30; - if ($[58] !== t26 || $[59] !== t27) { - t30 = {t24}{t26}{t27}{t28}{t29}; - $[58] = t26; - $[59] = t27; - $[60] = t30; - } else { - t30 = $[60]; - } - let t31; - let t32; - let t33; - let t34; - if ($[61] === Symbol.for("react.memo_cache_sentinel")) { - t31 = ; - t32 = ; - t33 = ; - t34 = envValidationErrors.length > 0 && Environment Variables{envValidationErrors.map(_temp11)}; - $[61] = t31; - $[62] = t32; - $[63] = t33; - $[64] = t34; - } else { - t31 = $[61]; - t32 = $[62]; - t33 = $[63]; - t34 = $[64]; - } - let t35; - if ($[65] !== versionLockInfo) { - t35 = versionLockInfo?.enabled && Version Locks{versionLockInfo.staleLocksCleaned > 0 && └ Cleaned {versionLockInfo.staleLocksCleaned} stale lock(s)}{versionLockInfo.locks.length === 0 ? └ No active version locks : versionLockInfo.locks.map(_temp12)}; - $[65] = versionLockInfo; - $[66] = t35; - } else { - t35 = $[66]; - } - let t36; - if ($[67] !== agentInfo) { - t36 = agentInfo?.failedFiles && agentInfo.failedFiles.length > 0 && Agent Parse Errors└ Failed to parse {agentInfo.failedFiles.length} agent file(s):{agentInfo.failedFiles.map(_temp13)}; - $[67] = agentInfo; - $[68] = t36; - } else { - t36 = $[68]; - } - let t37; - if ($[69] !== pluginsErrors) { - t37 = pluginsErrors.length > 0 && Plugin Errors└ {pluginsErrors.length} plugin error(s) detected:{pluginsErrors.map(_temp14)}; - $[69] = pluginsErrors; - $[70] = t37; - } else { - t37 = $[70]; - } - let t38; - if ($[71] !== contextWarnings) { - t38 = contextWarnings?.unreachableRulesWarning && Unreachable Permission Rules└{" "}{figures.warning}{" "}{contextWarnings.unreachableRulesWarning.message}{contextWarnings.unreachableRulesWarning.details.map(_temp15)}; - $[71] = contextWarnings; - $[72] = t38; - } else { - t38 = $[72]; - } - let t39; - if ($[73] !== contextWarnings) { - t39 = contextWarnings && (contextWarnings.claudeMdWarning || contextWarnings.agentWarning || contextWarnings.mcpWarning) && Context Usage Warnings{contextWarnings.claudeMdWarning && <>└{" "}{figures.warning} {contextWarnings.claudeMdWarning.message}{" "}└ Files:{contextWarnings.claudeMdWarning.details.map(_temp16)}}{contextWarnings.agentWarning && <>└{" "}{figures.warning} {contextWarnings.agentWarning.message}{" "}└ Top contributors:{contextWarnings.agentWarning.details.map(_temp17)}}{contextWarnings.mcpWarning && <>└{" "}{figures.warning} {contextWarnings.mcpWarning.message}{" "}└ MCP servers:{contextWarnings.mcpWarning.details.map(_temp18)}}; - $[73] = contextWarnings; - $[74] = t39; - } else { - t39 = $[74]; - } - let t40; - if ($[75] === Symbol.for("react.memo_cache_sentinel")) { - t40 = ; - $[75] = t40; - } else { - t40 = $[75]; - } - let t41; - if ($[76] !== t23 || $[77] !== t30 || $[78] !== t35 || $[79] !== t36 || $[80] !== t37 || $[81] !== t38 || $[82] !== t39) { - t41 = {t23}{t30}{t31}{t32}{t33}{t34}{t35}{t36}{t37}{t38}{t39}{t40}; - $[76] = t23; - $[77] = t30; - $[78] = t35; - $[79] = t36; - $[80] = t37; - $[81] = t38; - $[82] = t39; - $[83] = t41; - } else { - t41 = $[83]; - } - return t41; -} -function _temp18(detail_2, i_8) { - return {" "}└ {detail_2}; -} -function _temp17(detail_1, i_7) { - return {" "}└ {detail_1}; -} -function _temp16(detail_0, i_6) { - return {" "}└ {detail_0}; -} -function _temp15(detail, i_5) { - return {" "}└ {detail}; -} -function _temp14(error_0, i_4) { - return {" "}└ {error_0.source || "unknown"}{"plugin" in error_0 && error_0.plugin ? ` [${error_0.plugin}]` : ""}:{" "}{getPluginErrorMessage(error_0)}; -} -function _temp13(file, i_3) { - return {" "}└ {file.path}: {file.error}; -} -function _temp12(lock, i_2) { - return └ {lock.version}: PID {lock.pid}{" "}{lock.isProcessRunning ? (running) : (stale)}; -} -function _temp11(validation, i_1) { - return └ {validation.name}:{" "}{validation.message}; -} -function _temp10(warning, i_0) { - return Warning: {warning.issue}Fix: {warning.fix}; -} -function _temp1(install, i) { - return └ {install.type} at {install.path}; -} -function _temp0(a) { - return { - agentType: a.agentType, - source: a.source - }; -} -function _temp9(v_0) { - return v_0.status !== "valid"; -} -function _temp8(v) { - const value = process.env[v.name]; - const result = validateBoundedIntEnvVar(v.name, value, v.default, v.upperLimit); - return { - name: v.name, - ...result - }; -} -function _temp7(error) { - return error.mcpErrorMetadata === undefined; -} -function _temp6(diag) { - const fetchDistTags = diag.installationType === "native" ? getGcsDistTags : getNpmDistTags; - return fetchDistTags().catch(_temp5); -} -function _temp5() { - return { - latest: null, - stable: null - }; -} -function _temp4(s_2) { - return s_2.plugins.errors; -} -function _temp3(s_1) { - return s_1.toolPermissionContext; -} -function _temp2(s_0) { - return s_0.mcp.tools; -} -function _temp(s) { - return s.agentDefinitions; + + // Format the diagnostic output according to spec + return ( + + + Diagnostics + + └ Currently running: {diagnostic.installationType} ( + {diagnostic.version}) + + {diagnostic.packageManager && ( + └ Package manager: {diagnostic.packageManager} + )} + └ Path: {diagnostic.installationPath} + └ Invoked: {diagnostic.invokedBinary} + └ Config install method: {diagnostic.configInstallMethod} + + └ Search: {diagnostic.ripgrepStatus.working ? 'OK' : 'Not working'} ( + {diagnostic.ripgrepStatus.mode === 'embedded' + ? 'bundled' + : diagnostic.ripgrepStatus.mode === 'builtin' + ? 'vendor' + : diagnostic.ripgrepStatus.systemPath || 'system'} + ) + + + {/* Show recommendation if auto-updates are disabled */} + {diagnostic.recommendation && ( + <> + + + Recommendation: {diagnostic.recommendation.split('\n')[0]} + + {diagnostic.recommendation.split('\n')[1]} + + )} + + {/* Show multiple installations warning */} + {diagnostic.multipleInstallations.length > 1 && ( + <> + + Warning: Multiple installations found + {diagnostic.multipleInstallations.map((install, i) => ( + + └ {install.type} at {install.path} + + ))} + + )} + + {/* Show configuration warnings */} + {diagnostic.warnings.length > 0 && ( + <> + + {diagnostic.warnings.map((warning, i) => ( + + Warning: {warning.issue} + Fix: {warning.fix} + + ))} + + )} + + {/* Show invalid settings errors */} + {errorsExcludingMcp.length > 0 && ( + + Invalid Settings + + + )} + + + {/* Updates section */} + + Updates + + └ Auto-updates:{' '} + {diagnostic.packageManager + ? 'Managed by package manager' + : diagnostic.autoUpdates} + + {diagnostic.hasUpdatePermissions !== null && ( + + └ Update permissions:{' '} + {diagnostic.hasUpdatePermissions ? 'Yes' : 'No (requires sudo)'} + + )} + └ Auto-update channel: {autoUpdatesChannel} + + + + + + + + + + + + {/* Environment Variables */} + {envValidationErrors.length > 0 && ( + + Environment Variables + {envValidationErrors.map((validation, i) => ( + + └ {validation.name}:{' '} + + {validation.message} + + + ))} + + )} + + {/* Version Locks (PID-based locking) */} + {versionLockInfo?.enabled && ( + + Version Locks + {versionLockInfo.staleLocksCleaned > 0 && ( + + └ Cleaned {versionLockInfo.staleLocksCleaned} stale lock(s) + + )} + {versionLockInfo.locks.length === 0 ? ( + └ No active version locks + ) : ( + versionLockInfo.locks.map((lock, i) => ( + + └ {lock.version}: PID {lock.pid}{' '} + {lock.isProcessRunning ? ( + (running) + ) : ( + (stale) + )} + + )) + )} + + )} + + {agentInfo?.failedFiles && agentInfo.failedFiles.length > 0 && ( + + + Agent Parse Errors + + + └ Failed to parse {agentInfo.failedFiles.length} agent file(s): + + {agentInfo.failedFiles.map((file, i) => ( + + {' '}└ {file.path}: {file.error} + + ))} + + )} + + {/* Plugin Errors */} + {pluginsErrors.length > 0 && ( + + + Plugin Errors + + + └ {pluginsErrors.length} plugin error(s) detected: + + {pluginsErrors.map((error, i) => ( + + {' '}└ {error.source || 'unknown'} + {'plugin' in error && error.plugin ? ` [${error.plugin}]` : ''}:{' '} + {getPluginErrorMessage(error)} + + ))} + + )} + + {/* Unreachable Permission Rules Warning */} + {contextWarnings?.unreachableRulesWarning && ( + + + Unreachable Permission Rules + + + └{' '} + + {figures.warning}{' '} + {contextWarnings.unreachableRulesWarning.message} + + + {contextWarnings.unreachableRulesWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + {/* Context Usage Warnings */} + {contextWarnings && + (contextWarnings.claudeMdWarning || + contextWarnings.agentWarning || + contextWarnings.mcpWarning) && ( + + Context Usage Warnings + + {contextWarnings.claudeMdWarning && ( + <> + + └{' '} + + {figures.warning} {contextWarnings.claudeMdWarning.message} + + + {' '}└ Files: + {contextWarnings.claudeMdWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + {contextWarnings.agentWarning && ( + <> + + └{' '} + + {figures.warning} {contextWarnings.agentWarning.message} + + + {' '}└ Top contributors: + {contextWarnings.agentWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + {contextWarnings.mcpWarning && ( + <> + + └{' '} + + {figures.warning} {contextWarnings.mcpWarning.message} + + + {' '}└ MCP servers: + {contextWarnings.mcpWarning.details.map((detail, i) => ( + + {' '}└ {detail} + + ))} + + )} + + )} + + + + + + ) } diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 34e217ecf..727808374 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1,366 +1,694 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle'; -import { spawnSync } from 'child_process'; -import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens } from '../bootstrap/state.js'; -import { parseTokenBudget } from '../utils/tokenBudget.js'; -import { count } from '../utils/array.js'; -import { dirname, join } from 'path'; -import { tmpdir } from 'os'; -import figures from 'figures'; +import { feature } from 'bun:bundle' +import { spawnSync } from 'child_process' +import { + snapshotOutputTokensForTurn, + getCurrentTurnTokenBudget, + getTurnOutputTokens, + getBudgetContinuationCount, + getTotalInputTokens, +} from '../bootstrap/state.js' +import { parseTokenBudget } from '../utils/tokenBudget.js' +import { count } from '../utils/array.js' +import { dirname, join } from 'path' +import { tmpdir } from 'os' +import figures from 'figures' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler -import { useInput } from '../ink.js'; -import { useSearchInput } from '../hooks/useSearchInput.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js'; -import type { JumpHandle } from '../components/VirtualMessageList.js'; -import { renderMessagesToPlainText } from '../utils/exportRenderer.js'; -import { openFileInExternalEditor } from '../utils/editor.js'; -import { writeFile } from 'fs/promises'; -import { Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '../ink.js'; -import type { TabStatusKind } from '../ink/hooks/use-tab-status.js'; -import { CostThresholdDialog } from '../components/CostThresholdDialog.js'; -import { IdleReturnDialog } from '../components/IdleReturnDialog.js'; -import * as React from 'react'; -import { useEffect, useMemo, useRef, useState, useCallback, useDeferredValue, useLayoutEffect, type RefObject } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { sendNotification } from '../services/notifier.js'; -import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js'; -import { useTerminalNotification } from '../ink/useTerminalNotification.js'; -import { hasCursorUpViewportYankBug } from '../ink/terminal.js'; -import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE } from '../utils/fileStateCache.js'; -import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration } from '../bootstrap/state.js'; -import { asSessionId, asAgentId } from '../types/ids.js'; -import { logForDebugging } from '../utils/debug.js'; -import { QueryGuard } from '../utils/QueryGuard.js'; -import { isEnvTruthy } from '../utils/envUtils.js'; -import { formatTokens, truncateToWidth } from '../utils/format.js'; -import { consumeEarlyInput } from '../utils/earlyInput.js'; -import { setMemberActive } from '../utils/swarm/teamHelpers.js'; -import { isSwarmWorker, generateSandboxRequestId, sendSandboxPermissionRequestViaMailbox, sendSandboxPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js'; -import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js'; -import { getTeamName, getAgentName } from '../utils/teammate.js'; -import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js'; -import { injectUserMessageToTeammate, getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'; -import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext } from '../utils/swarm/leaderPermissionBridge.js'; -import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'; -import { useLogMessages } from '../hooks/useLogMessages.js'; -import { useReplBridge } from '../hooks/useReplBridge.js'; -import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js'; -import type { PromptInputMode, QueuedCommand, VimMode } from '../types/textInputTypes.js'; -import { MessageSelector, selectableUserMessagesFilter, messagesAfterAreOnlySynthetic } from '../components/MessageSelector.js'; -import { useIdeLogging } from '../hooks/useIdeLogging.js'; -import { PermissionRequest, type ToolUseConfirm } from '../components/permissions/PermissionRequest.js'; -import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js'; -import { PromptDialog } from '../components/hooks/PromptDialog.js'; -import type { PromptRequest, PromptResponse } from '../types/hooks.js'; -import PromptInput from '../components/PromptInput/PromptInput.js'; -import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js'; -import { useRemoteSession } from '../hooks/useRemoteSession.js'; -import { useDirectConnect } from '../hooks/useDirectConnect.js'; -import type { DirectConnectConfig } from '../server/directConnectManager.js'; -import { useSSHSession } from '../hooks/useSSHSession.js'; -import { useAssistantHistory } from '../hooks/useAssistantHistory.js'; -import type { SSHSession } from '../ssh/createSSHSession.js'; -import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js'; -import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js'; -import { useMoreRight } from '../moreright/useMoreRight.js'; -import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js'; -import { getSystemPrompt } from '../constants/prompts.js'; -import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js'; -import { getSystemContext, getUserContext } from '../context.js'; -import { getMemoryFiles } from '../utils/claudemd.js'; -import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js'; -import { getTotalCost, saveCurrentSessionCosts, resetCostState, getStoredSessionCosts } from '../cost-tracker.js'; -import { useCostSummary } from '../costHook.js'; -import { useFpsMetrics } from '../context/fpsMetrics.js'; -import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js'; -import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js'; -import { addToHistory, removeLastFromHistory, expandPastedTextRefs, parseReferences } from '../history.js'; -import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js'; -import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js'; -import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js'; -import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js'; -import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js'; -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; -import { CancelRequestHandler } from '../hooks/useCancelRequest.js'; -import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js'; -import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js'; -import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js'; -import { errorMessage } from '../utils/errors.js'; -import { isHumanTurn } from '../utils/messagePredicates.js'; -import { logError } from '../utils/log.js'; +import { useInput } from '../ink.js' +import { useSearchInput } from '../hooks/useSearchInput.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js' +import type { JumpHandle } from '../components/VirtualMessageList.js' +import { renderMessagesToPlainText } from '../utils/exportRenderer.js' +import { openFileInExternalEditor } from '../utils/editor.js' +import { writeFile } from 'fs/promises' +import { + Box, + Text, + useStdin, + useTheme, + useTerminalFocus, + useTerminalTitle, + useTabStatus, +} from '../ink.js' +import type { TabStatusKind } from '../ink/hooks/use-tab-status.js' +import { CostThresholdDialog } from '../components/CostThresholdDialog.js' +import { IdleReturnDialog } from '../components/IdleReturnDialog.js' +import * as React from 'react' +import { + useEffect, + useMemo, + useRef, + useState, + useCallback, + useDeferredValue, + useLayoutEffect, + type RefObject, +} from 'react' +import { useNotifications } from '../context/notifications.js' +import { sendNotification } from '../services/notifier.js' +import { + startPreventSleep, + stopPreventSleep, +} from '../services/preventSleep.js' +import { useTerminalNotification } from '../ink/useTerminalNotification.js' +import { hasCursorUpViewportYankBug } from '../ink/terminal.js' +import { + createFileStateCacheWithSizeLimit, + mergeFileStateCaches, + READ_FILE_STATE_CACHE_SIZE, +} from '../utils/fileStateCache.js' +import { + updateLastInteractionTime, + getLastInteractionTime, + getOriginalCwd, + getProjectRoot, + getSessionId, + switchSession, + setCostStateForRestore, + getTurnHookDurationMs, + getTurnHookCount, + resetTurnHookDuration, + getTurnToolDurationMs, + getTurnToolCount, + resetTurnToolDuration, + getTurnClassifierDurationMs, + getTurnClassifierCount, + resetTurnClassifierDuration, +} from '../bootstrap/state.js' +import { asSessionId, asAgentId } from '../types/ids.js' +import { logForDebugging } from '../utils/debug.js' +import { QueryGuard } from '../utils/QueryGuard.js' +import { isEnvTruthy } from '../utils/envUtils.js' +import { formatTokens, truncateToWidth } from '../utils/format.js' +import { consumeEarlyInput } from '../utils/earlyInput.js' + +import { setMemberActive } from '../utils/swarm/teamHelpers.js' +import { + isSwarmWorker, + generateSandboxRequestId, + sendSandboxPermissionRequestViaMailbox, + sendSandboxPermissionResponseViaMailbox, +} from '../utils/swarm/permissionSync.js' +import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js' +import { getTeamName, getAgentName } from '../utils/teammate.js' +import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js' +import { + injectUserMessageToTeammate, + getAllInProcessTeammateTasks, +} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import { + isLocalAgentTask, + queuePendingMessage, + appendMessageToLocalAgent, + type LocalAgentTaskState, +} from '../tasks/LocalAgentTask/LocalAgentTask.js' +import { + registerLeaderToolUseConfirmQueue, + unregisterLeaderToolUseConfirmQueue, + registerLeaderSetToolPermissionContext, + unregisterLeaderSetToolPermissionContext, +} from '../utils/swarm/leaderPermissionBridge.js' +import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js' +import { useLogMessages } from '../hooks/useLogMessages.js' +import { useReplBridge } from '../hooks/useReplBridge.js' +import { + type Command, + type CommandResultDisplay, + type ResumeEntrypoint, + getCommandName, + isCommandEnabled, +} from '../commands.js' +import type { + PromptInputMode, + QueuedCommand, + VimMode, +} from '../types/textInputTypes.js' +import { + MessageSelector, + selectableUserMessagesFilter, + messagesAfterAreOnlySynthetic, +} from '../components/MessageSelector.js' +import { useIdeLogging } from '../hooks/useIdeLogging.js' +import { + PermissionRequest, + type ToolUseConfirm, +} from '../components/permissions/PermissionRequest.js' +import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js' +import { PromptDialog } from '../components/hooks/PromptDialog.js' +import type { PromptRequest, PromptResponse } from '../types/hooks.js' +import PromptInput from '../components/PromptInput/PromptInput.js' +import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js' +import { useRemoteSession } from '../hooks/useRemoteSession.js' +import { useDirectConnect } from '../hooks/useDirectConnect.js' +import type { DirectConnectConfig } from '../server/directConnectManager.js' +import { useSSHSession } from '../hooks/useSSHSession.js' +import { useAssistantHistory } from '../hooks/useAssistantHistory.js' +import type { SSHSession } from '../ssh/createSSHSession.js' +import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js' +import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js' +import { useMoreRight } from '../moreright/useMoreRight.js' +import { + SpinnerWithVerb, + BriefIdleStatus, + type SpinnerMode, +} from '../components/Spinner.js' +import { getSystemPrompt } from '../constants/prompts.js' +import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js' +import { getSystemContext, getUserContext } from '../context.js' +import { getMemoryFiles } from '../utils/claudemd.js' +import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js' +import { + getTotalCost, + saveCurrentSessionCosts, + resetCostState, + getStoredSessionCosts, +} from '../cost-tracker.js' +import { useCostSummary } from '../costHook.js' +import { useFpsMetrics } from '../context/fpsMetrics.js' +import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js' +import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js' +import { + addToHistory, + removeLastFromHistory, + expandPastedTextRefs, + parseReferences, +} from '../history.js' +import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js' +import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js' +import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js' +import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js' +import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' +import { CancelRequestHandler } from '../hooks/useCancelRequest.js' +import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js' +import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js' +import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js' +import { errorMessage } from '../utils/errors.js' +import { isHumanTurn } from '../utils/messagePredicates.js' +import { logError } from '../utils/log.js' // Dead code elimination: conditional imports /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration : () => ({ - stripTrailing: () => 0, - handleKeyEvent: () => {}, - resetAnchor: () => {} -}); -const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null; +const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = + feature('VOICE_MODE') + ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration + : () => ({ + stripTrailing: () => 0, + handleKeyEvent: () => {}, + resetAnchor: () => {}, + }) +const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = + feature('VOICE_MODE') + ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler + : () => null // Frustration detection is ant-only (dogfooding). Conditional require so external // builds eliminate the module entirely (including its two O(n) useMemos that run // on every messages change, plus the GrowthBook fetch). -const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = (process.env.USER_TYPE) === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({ - state: 'closed', - handleTranscriptSelect: () => {} -}); +const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = + process.env.USER_TYPE === 'ant' + ? require('../components/FeedbackSurvey/useFrustrationDetection.js') + .useFrustrationDetection + : () => ({ state: 'closed', handleTranscriptSelect: () => {} }) // Ant-only org warning. Conditional require so the org UUID list is // eliminated from external builds (one UUID is on excluded-strings). -const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = (process.env.USER_TYPE) === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {}; +const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = + process.env.USER_TYPE === 'ant' + ? require('../hooks/notifs/useAntOrgWarningNotification.js') + .useAntOrgWarningNotification + : () => {} // Dead code elimination: conditional import for coordinator mode -const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{ - name: string; -}>, scratchpadDir?: string) => { - [k: string]: string; -} = feature('COORDINATOR_MODE') ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext : () => ({}); +const getCoordinatorUserContext: ( + mcpClients: ReadonlyArray<{ name: string }>, + scratchpadDir?: string, +) => { [k: string]: string } = feature('COORDINATOR_MODE') + ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext + : () => ({}) /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -import useCanUseTool from '../hooks/useCanUseTool.js'; -import type { ToolPermissionContext, Tool } from '../Tool.js'; -import { applyPermissionUpdate, applyPermissionUpdates, persistPermissionUpdate } from '../utils/permissions/PermissionUpdate.js'; -import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'; -import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js'; -import type { PermissionMode } from '../types/permissions.js'; -import { getScratchpadDir, isScratchpadEnabled } from '../utils/permissions/filesystem.js'; -import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'; -import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js'; -import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js'; -import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; -import { getGlobalConfig, saveGlobalConfig, getGlobalConfigWriteCount } from '../utils/config.js'; -import { hasConsoleBillingAccess } from '../utils/billing.js'; -import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { textForResubmit, handleMessageFromStream, type StreamingToolUse, type StreamingThinking, isCompactBoundaryMessage, getMessagesAfterCompactBoundary, getContentText, createUserMessage, createAssistantMessage, createTurnDurationMessage, createAgentsKilledMessage, createApiMetricsMessage, createSystemMessage, createCommandInputMessage, formatCommandInputTags } from '../utils/messages.js'; -import { generateSessionTitle } from '../utils/sessionTitle.js'; -import { BASH_INPUT_TAG, COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, LOCAL_COMMAND_STDOUT_TAG } from '../constants/xml.js'; -import { escapeXml } from '../utils/xml.js'; -import type { ThinkingConfig } from '../utils/thinking.js'; -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; -import { handlePromptSubmit, type PromptInputHelpers } from '../utils/handlePromptSubmit.js'; -import { useQueueProcessor } from '../hooks/useQueueProcessor.js'; -import { useMailboxBridge } from '../hooks/useMailboxBridge.js'; -import { queryCheckpoint, logQueryProfileReport } from '../utils/queryProfiler.js'; -import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection } from '../types/message.js'; -import { query } from '../query.js'; -import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js'; -import { getQuerySourceForREPL } from '../utils/promptCategory.js'; -import { useMergedTools } from '../hooks/useMergedTools.js'; -import { mergeAndFilterTools } from '../utils/toolPool.js'; -import { useMergedCommands } from '../hooks/useMergedCommands.js'; -import { useSkillsChange } from '../hooks/useSkillsChange.js'; -import { useManagePlugins } from '../hooks/useManagePlugins.js'; -import { Messages } from '../components/Messages.js'; -import { TaskListV2 } from '../components/TaskListV2.js'; -import { TeammateViewHeader } from '../components/TeammateViewHeader.js'; -import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js'; -import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'; -import type { MCPServerConnection } from '../services/mcp/types.js'; -import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { randomUUID, type UUID } from 'crypto'; -import { processSessionStartHooks } from '../utils/sessionStart.js'; -import { executeSessionEndHooks, getSessionEndHookTimeoutMs } from '../utils/hooks.js'; -import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js'; -import { getTools, assembleToolPool } from '../tools.js'; -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; -import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js'; -import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js'; -import { useMainLoopModel } from '../hooks/useMainLoopModel.js'; -import { useAppState, useSetAppState, useAppStateStore } from '../state/AppState.js'; -import type { ContentBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; -import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js'; -import type { PastedContent } from '../utils/config.js'; -import { copyPlanForFork, copyPlanForResume, getPlanSlug, setPlanSlug } from '../utils/plans.js'; -import { clearSessionMetadata, resetSessionFilePointer, adoptResumedSessionFile, removeTranscriptMessage, restoreSessionMetadata, getCurrentSessionTitle, isEphemeralToolProgress, isLoggableMessage, saveWorktreeState, getAgentTranscript } from '../utils/sessionStorage.js'; -import { deserializeMessages } from '../utils/conversationRecovery.js'; -import { extractReadFilesFromMessages, extractBashToolsFromMessages } from '../utils/queryHelpers.js'; -import { resetMicrocompactState } from '../services/compact/microCompact.js'; -import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js'; -import { provisionContentReplacementState, reconstructContentReplacementState, type ContentReplacementRecord } from '../utils/toolResultStorage.js'; -import { partialCompactConversation } from '../services/compact/compact.js'; -import type { LogOption } from '../types/logs.js'; -import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; -import { fileHistoryMakeSnapshot, type FileHistoryState, fileHistoryRewind, type FileHistorySnapshot, copyFileHistoryForResume, fileHistoryEnabled, fileHistoryHasAnyChanges } from '../utils/fileHistory.js'; -import { type AttributionState, incrementPromptCount } from '../utils/commitAttribution.js'; -import { recordAttributionSnapshot } from '../utils/sessionStorage.js'; -import { computeStandaloneAgentContext, restoreAgentFromSession, restoreSessionStateFromLog, restoreWorktreeForResume, exitRestoredWorktree } from '../utils/sessionRestore.js'; -import { isBgSession, updateSessionName, updateSessionActivity } from '../utils/concurrentSessions.js'; -import { isInProcessTeammateTask, type InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js'; -import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { useInboxPoller } from '../hooks/useInboxPoller.js'; +import useCanUseTool from '../hooks/useCanUseTool.js' +import type { ToolPermissionContext, Tool } from '../Tool.js' +import { + applyPermissionUpdate, + applyPermissionUpdates, + persistPermissionUpdate, +} from '../utils/permissions/PermissionUpdate.js' +import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js' +import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js' +import { + getScratchpadDir, + isScratchpadEnabled, +} from '../utils/permissions/filesystem.js' +import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js' +import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js' +import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js' +import type { AutoUpdaterResult } from '../utils/autoUpdater.js' +import { + getGlobalConfig, + saveGlobalConfig, + getGlobalConfigWriteCount, +} from '../utils/config.js' +import { hasConsoleBillingAccess } from '../utils/billing.js' +import { + logEvent, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, +} from 'src/services/analytics/index.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + textForResubmit, + handleMessageFromStream, + type StreamingToolUse, + type StreamingThinking, + isCompactBoundaryMessage, + getMessagesAfterCompactBoundary, + getContentText, + createUserMessage, + createAssistantMessage, + createTurnDurationMessage, + createAgentsKilledMessage, + createApiMetricsMessage, + createSystemMessage, + createCommandInputMessage, + formatCommandInputTags, +} from '../utils/messages.js' +import { generateSessionTitle } from '../utils/sessionTitle.js' +import { + BASH_INPUT_TAG, + COMMAND_MESSAGE_TAG, + COMMAND_NAME_TAG, + LOCAL_COMMAND_STDOUT_TAG, +} from '../constants/xml.js' +import { escapeXml } from '../utils/xml.js' +import type { ThinkingConfig } from '../utils/thinking.js' +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' +import { + handlePromptSubmit, + type PromptInputHelpers, +} from '../utils/handlePromptSubmit.js' +import { useQueueProcessor } from '../hooks/useQueueProcessor.js' +import { useMailboxBridge } from '../hooks/useMailboxBridge.js' +import { + queryCheckpoint, + logQueryProfileReport, +} from '../utils/queryProfiler.js' +import type { + Message as MessageType, + UserMessage, + ProgressMessage, + HookResultMessage, + PartialCompactDirection, +} from '../types/message.js' +import { query } from '../query.js' +import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js' +import { getQuerySourceForREPL } from '../utils/promptCategory.js' +import { useMergedTools } from '../hooks/useMergedTools.js' +import { mergeAndFilterTools } from '../utils/toolPool.js' +import { useMergedCommands } from '../hooks/useMergedCommands.js' +import { useSkillsChange } from '../hooks/useSkillsChange.js' +import { useManagePlugins } from '../hooks/useManagePlugins.js' +import { Messages } from '../components/Messages.js' +import { TaskListV2 } from '../components/TaskListV2.js' +import { TeammateViewHeader } from '../components/TeammateViewHeader.js' +import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js' +import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js' +import type { MCPServerConnection } from '../services/mcp/types.js' +import type { ScopedMcpServerConfig } from '../services/mcp/types.js' +import { randomUUID, type UUID } from 'crypto' +import { processSessionStartHooks } from '../utils/sessionStart.js' +import { + executeSessionEndHooks, + getSessionEndHookTimeoutMs, +} from '../utils/hooks.js' +import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js' +import { getTools, assembleToolPool } from '../tools.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js' +import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js' +import { useMainLoopModel } from '../hooks/useMainLoopModel.js' +import { + useAppState, + useSetAppState, + useAppStateStore, +} from '../state/AppState.js' +import type { + ContentBlockParam, + ImageBlockParam, +} from '@anthropic-ai/sdk/resources/messages.mjs' +import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js' +import type { PastedContent } from '../utils/config.js' +import { + copyPlanForFork, + copyPlanForResume, + getPlanSlug, + setPlanSlug, +} from '../utils/plans.js' +import { + clearSessionMetadata, + resetSessionFilePointer, + adoptResumedSessionFile, + removeTranscriptMessage, + restoreSessionMetadata, + getCurrentSessionTitle, + isEphemeralToolProgress, + isLoggableMessage, + saveWorktreeState, + getAgentTranscript, +} from '../utils/sessionStorage.js' +import { deserializeMessages } from '../utils/conversationRecovery.js' +import { + extractReadFilesFromMessages, + extractBashToolsFromMessages, +} from '../utils/queryHelpers.js' +import { resetMicrocompactState } from '../services/compact/microCompact.js' +import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js' +import { + provisionContentReplacementState, + reconstructContentReplacementState, + type ContentReplacementRecord, +} from '../utils/toolResultStorage.js' +import { partialCompactConversation } from '../services/compact/compact.js' +import type { LogOption } from '../types/logs.js' +import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' +import { + fileHistoryMakeSnapshot, + type FileHistoryState, + fileHistoryRewind, + type FileHistorySnapshot, + copyFileHistoryForResume, + fileHistoryEnabled, + fileHistoryHasAnyChanges, +} from '../utils/fileHistory.js' +import { + type AttributionState, + incrementPromptCount, +} from '../utils/commitAttribution.js' +import { recordAttributionSnapshot } from '../utils/sessionStorage.js' +import { + computeStandaloneAgentContext, + restoreAgentFromSession, + restoreSessionStateFromLog, + restoreWorktreeForResume, + exitRestoredWorktree, +} from '../utils/sessionRestore.js' +import { + isBgSession, + updateSessionName, + updateSessionActivity, +} from '../utils/concurrentSessions.js' +import { + isInProcessTeammateTask, + type InProcessTeammateTaskState, +} from '../tasks/InProcessTeammateTask/types.js' +import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { useInboxPoller } from '../hooks/useInboxPoller.js' // Dead code elimination: conditional import for loop mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; -const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; -const PROACTIVE_FALSE = () => false; -const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; -const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; -const useScheduledTasks = require('../hooks/useScheduledTasks.js').useScheduledTasks; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? require('../proactive/index.js') + : null +const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} +const PROACTIVE_FALSE = () => false +const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false +const useProactive = + feature('PROACTIVE') || feature('KAIROS') + ? require('../proactive/useProactive.js').useProactive + : null +const useScheduledTasks = feature('AGENT_TRIGGERS') + ? require('../hooks/useScheduledTasks.js').useScheduledTasks + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; -import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; -import type { SandboxAskCallback, NetworkHostPattern } from '../utils/sandbox/sandbox-adapter.js'; -import { type IDEExtensionInstallationStatus, closeOpenDiffs, getConnectedIdeClient, type IdeType } from '../utils/ide.js'; -import { useIDEIntegration } from '../hooks/useIDEIntegration.js'; -import exit from '../commands/exit/index.js'; -import { ExitFlow } from '../components/ExitFlow.js'; -import { getCurrentWorktreeSession } from '../utils/worktree.js'; -import { popAllEditable, enqueue, type SetAppState, getCommandQueue, getCommandQueueLength, removeByFilter } from '../utils/messageQueueManager.js'; -import { useCommandQueue } from '../hooks/useCommandQueue.js'; -import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js'; -import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js'; -import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js'; -import { diagnosticTracker } from '../services/diagnosticTracking.js'; -import { handleSpeculationAccept, type ActiveSpeculationState } from '../services/PromptSuggestion/speculation.js'; -import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js'; -import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCallout.js'; -import type { EffortValue } from '../utils/effort.js'; -import { RemoteCallout } from '../components/RemoteCallout.js'; +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' +import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js' +import type { + SandboxAskCallback, + NetworkHostPattern, +} from '../utils/sandbox/sandbox-adapter.js' + +import { + type IDEExtensionInstallationStatus, + closeOpenDiffs, + getConnectedIdeClient, + type IdeType, +} from '../utils/ide.js' +import { useIDEIntegration } from '../hooks/useIDEIntegration.js' +import exit from '../commands/exit/index.js' +import { ExitFlow } from '../components/ExitFlow.js' +import { getCurrentWorktreeSession } from '../utils/worktree.js' +import { + popAllEditable, + enqueue, + type SetAppState, + getCommandQueue, + getCommandQueueLength, + removeByFilter, +} from '../utils/messageQueueManager.js' +import { useCommandQueue } from '../hooks/useCommandQueue.js' +import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js' +import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js' +import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js' +import { diagnosticTracker } from '../services/diagnosticTracking.js' +import { + handleSpeculationAccept, + type ActiveSpeculationState, +} from '../services/PromptSuggestion/speculation.js' +import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js' +import { + EffortCallout, + shouldShowEffortCallout, +} from '../components/EffortCallout.js' +import type { EffortValue } from '../utils/effort.js' +import { RemoteCallout } from '../components/RemoteCallout.js' /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -const AntModelSwitchCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null; -const shouldShowAntModelSwitch = (process.env.USER_TYPE) === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false; -const UndercoverAutoCallout = (process.env.USER_TYPE) === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null; +const AntModelSwitchCallout = + process.env.USER_TYPE === 'ant' + ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout + : null +const shouldShowAntModelSwitch = + process.env.USER_TYPE === 'ant' + ? require('../components/AntModelSwitchCallout.js') + .shouldShowModelSwitchCallout + : (): boolean => false +const UndercoverAutoCallout = + process.env.USER_TYPE === 'ant' + ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout + : null /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -import { activityManager } from '../utils/activityManager.js'; -import { createAbortController } from '../utils/abortController.js'; -import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js'; -import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js'; -import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js'; -import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js'; -import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js'; -import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js'; -import { useAwaySummary } from 'src/hooks/useAwaySummary.js'; -import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js'; -import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js'; -import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js'; -import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; -import type { Theme } from 'src/utils/theme.js'; -import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js'; -import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; -import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js'; -import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js'; -import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js'; -import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js'; -import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js'; -import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js'; -import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js'; -import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js'; -import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js'; -import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js'; -import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js'; -import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js'; -import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js'; -import { DesktopUpsellStartup, shouldShowDesktopUpsellStartup } from 'src/components/DesktopUpsell/DesktopUpsellStartup.js'; -import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js'; -import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js'; -import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js'; -import { UserTextMessage } from 'src/components/messages/UserTextMessage.js'; -import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js'; -import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js'; -import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js'; -import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js'; -import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js'; -import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js'; -import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js'; -import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js'; -import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js'; -import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason } from '../utils/autoRunIssue.js'; -import type { HookProgress } from '../types/hooks.js'; -import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js'; +import { activityManager } from '../utils/activityManager.js' +import { createAbortController } from '../utils/abortController.js' +import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js' +import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js' +import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js' +import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js' +import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js' +import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js' +import { useAwaySummary } from 'src/hooks/useAwaySummary.js' +import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js' +import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js' +import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js' +import { + getTipToShowOnSpinner, + recordShownTip, +} from 'src/services/tips/tipScheduler.js' +import type { Theme } from 'src/utils/theme.js' +import { + checkAndDisableBypassPermissionsIfNeeded, + checkAndDisableAutoModeIfNeeded, + useKickOffCheckAndDisableBypassPermissionsIfNeeded, + useKickOffCheckAndDisableAutoModeIfNeeded, +} from 'src/utils/permissions/bypassPermissionsKillswitch.js' +import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' +import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js' +import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js' +import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js' +import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js' +import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js' +import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js' +import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js' +import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js' +import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js' +import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js' +import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js' +import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js' +import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js' +import { + DesktopUpsellStartup, + shouldShowDesktopUpsellStartup, +} from 'src/components/DesktopUpsell/DesktopUpsellStartup.js' +import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js' +import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js' +import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js' +import { UserTextMessage } from 'src/components/messages/UserTextMessage.js' +import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js' +import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js' +import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js' +import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js' +import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js' +import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js' +import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js' +import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js' +import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js' +import { + AutoRunIssueNotification, + shouldAutoRunIssue, + getAutoRunIssueReasonText, + getAutoRunCommand, + type AutoRunIssueReason, +} from '../utils/autoRunIssue.js' +import type { HookProgress } from '../types/hooks.js' +import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js') : null; +const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') + ? (require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'; -import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'; -import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js'; -import { triggerCompanionReaction } from '../buddy/companionReact.js'; -import { DevBar } from '../components/DevBar.js'; +import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js' +import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js' +import { + CompanionSprite, + CompanionFloatingBubble, + MIN_COLS_FOR_FULL_SPRITE, +} from '../buddy/CompanionSprite.js' +import { DevBar } from '../components/DevBar.js' // Session manager removed - using AppState now -import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'; -import { REMOTE_SAFE_COMMANDS } from '../commands.js'; -import type { RemoteMessageContent } from '../utils/teleport/api.js'; -import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js'; -import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js'; -import { AlternateScreen } from '../ink/components/AlternateScreen.js'; -import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'; -import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type MessageActionsState, type MessageActionsNav, type MessageActionCaps } from '../components/messageActions.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; +import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js' +import { REMOTE_SAFE_COMMANDS } from '../commands.js' +import type { RemoteMessageContent } from '../utils/teleport/api.js' +import { + FullscreenLayout, + useUnseenDivider, + computeUnseenDivider, +} from '../components/FullscreenLayout.js' +import { + isFullscreenEnvEnabled, + maybeGetTmuxMouseHint, + isMouseTrackingEnabled, +} from '../utils/fullscreen.js' +import { AlternateScreen } from '../ink/components/AlternateScreen.js' +import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js' +import { + useMessageActions, + MessageActionsKeybindings, + MessageActionsBar, + type MessageActionsState, + type MessageActionsNav, + type MessageActionCaps, +} from '../components/messageActions.js' +import { setClipboard } from '../ink/termio/osc.js' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import { + createAttachmentMessage, + getQueuedCommandAttachments, +} from '../utils/attachments.js' // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would // cause useEffect dependency changes and infinite re-render loops. -const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; +const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [] // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new // function identity each render, which would break composedOnScroll's memo. -const HISTORY_STUB = { - maybeLoadOlder: (_: ScrollBoxHandle) => {} -}; +const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => {} } // Window after a user-initiated scroll during which type-into-empty does NOT // repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll // up to read the start → start typing → before this fix, snapped to bottom. // https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739 -const RECENT_SCROLL_REPIN_WINDOW_MS = 3000; +const RECENT_SCROLL_REPIN_WINDOW_MS = 3000 // Use LRU cache to prevent unbounded memory growth // 100 files should be sufficient for most coding sessions while preventing // memory issues when working across many files in large projects function median(values: number[]): number { - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) : sorted[mid]!; + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) + : sorted[mid]! } /** * Small component to display transcript mode footer with dynamic keybinding. * Must be rendered inside KeybindingSetup to access keybinding context. */ -function TranscriptModeFooter(t0) { - const $ = _c(9); - const { - showAllInTranscript, - virtualScroll, - searchBadge, - suppressShowAll: t1, - status - } = t0; - const suppressShowAll = t1 === undefined ? false : t1; - const toggleShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - const showAllShortcut = useShortcutDisplay("transcript:toggleShowAll", "Transcript", "ctrl+e"); - const t2 = searchBadge ? " \xB7 n/N to navigate" : virtualScroll ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` : suppressShowAll ? "" : ` · ${showAllShortcut} to ${showAllInTranscript ? "collapse" : "show all"}`; - let t3; - if ($[0] !== t2 || $[1] !== toggleShortcut) { - t3 = Showing detailed transcript · {toggleShortcut} to toggle{t2}; - $[0] = t2; - $[1] = toggleShortcut; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== searchBadge || $[4] !== status) { - t4 = status ? <>{status} : searchBadge ? <>{searchBadge.current}/{searchBadge.count}{" "} : null; - $[3] = searchBadge; - $[4] = status; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t3 || $[7] !== t4) { - t5 = {t3}{t4}; - $[6] = t3; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - return t5; +function TranscriptModeFooter({ + showAllInTranscript, + virtualScroll, + searchBadge, + suppressShowAll = false, + status, +}: { + showAllInTranscript: boolean + virtualScroll: boolean + /** Minimap while navigating a closed-bar search. Shows n/N hints + + * right-aligned count instead of scroll hints. */ + searchBadge?: { current: number; count: number } + /** Hide the ctrl+e hint. The [ dump path shares this footer with + * env-opted dump (CLAUDE_CODE_NO_FLICKER=0 / DISABLE_VIRTUAL_SCROLL=1), + * but ctrl+e only works in the env case — useGlobalKeybindings.tsx + * gates on !virtualScrollActive which is env-derived, doesn't know + * [ happened. */ + suppressShowAll?: boolean + /** Transient status (v-for-editor progress). Notifications render inside + * PromptInput which isn't mounted in transcript — addNotification queues + * but nothing draws it. */ + status?: string +}): React.ReactNode { + const toggleShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + const showAllShortcut = useShortcutDisplay( + 'transcript:toggleShowAll', + 'Transcript', + 'ctrl+e', + ) + return ( + + + Showing detailed transcript · {toggleShortcut} to toggle + {searchBadge + ? ' · n/N to navigate' + : virtualScroll + ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` + : suppressShowAll + ? '' + : ` · ${showAllShortcut} to ${showAllInTranscript ? 'collapse' : 'show all'}`} + + {status ? ( + // v-for-editor render progress — transient, preempts the search + // badge since the user just pressed v and wants to see what's + // happening. Clears after 4s. + <> + + {status} + + ) : searchBadge ? ( + // Engine-counted — close enough for a rough location hint. May + // drift from render-count for ghost/phantom messages. + <> + + + {searchBadge.current}/{searchBadge.count} + {' '} + + + ) : null} + + ) } /** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter @@ -374,30 +702,27 @@ function TranscriptSearchBar({ onClose, onCancel, setHighlight, - initialQuery + initialQuery, }: { - jumpRef: RefObject; - count: number; - current: number; + jumpRef: RefObject + count: number + current: number /** Enter — commit. Query persists for n/N. */ - onClose: (lastQuery: string) => void; + onClose: (lastQuery: string) => void /** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */ - onCancel: () => void; - setHighlight: (query: string) => void; + onCancel: () => void + setHighlight: (query: string) => void // Seed with the previous query (less: / shows last pattern). Mount-fire // of the effect re-scans with the same query — idempotent (same matches, // nearest-ptr, same highlights). User can edit or clear. - initialQuery: string; + initialQuery: string }): React.ReactNode { - const { - query, - cursorOffset - } = useSearchInput({ + const { query, cursorOffset } = useSearchInput({ isActive: true, initialQuery, onExit: () => onClose(query), - onCancel - }); + onCancel, + }) // Index warm-up runs before the query effect so it measures the real // cost — otherwise setSearchQuery fills the cache first and warm // reports ~0ms while the user felt the actual lag. @@ -409,72 +734,89 @@ function TranscriptSearchBar({ // null initial, warmDone would be true on mount → [query] fires → // setSearchQuery fills cache → warm reports ~0ms while the user felt // the real lag. - const [indexStatus, setIndexStatus] = React.useState<'building' | { - ms: number; - } | null>('building'); + const [indexStatus, setIndexStatus] = React.useState< + 'building' | { ms: number } | null + >('building') React.useEffect(() => { - let alive = true; - const warm = jumpRef.current?.warmSearchIndex; + let alive = true + const warm = jumpRef.current?.warmSearchIndex if (!warm) { - setIndexStatus(null); // VML not mounted yet — rare, skip indicator - return; + setIndexStatus(null) // VML not mounted yet — rare, skip indicator + return } - setIndexStatus('building'); + setIndexStatus('building') warm().then(ms => { - if (!alive) return; + if (!alive) return // <20ms = imperceptible. No point showing "indexed in 3ms". if (ms < 20) { - setIndexStatus(null); + setIndexStatus(null) } else { - setIndexStatus({ - ms - }); - setTimeout(() => alive && setIndexStatus(null), 2000); + setIndexStatus({ ms }) + setTimeout(() => alive && setIndexStatus(null), 2000) } - }); + }) return () => { - alive = false; - }; + alive = false + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // mount-only: bar opens once per / + }, []) // mount-only: bar opens once per / // Gate the query effect on warm completion. setHighlight stays instant // (screen-space overlay, no indexing). setSearchQuery (the scan) waits. - const warmDone = indexStatus !== 'building'; + const warmDone = indexStatus !== 'building' useEffect(() => { - if (!warmDone) return; - jumpRef.current?.setSearchQuery(query); - setHighlight(query); + if (!warmDone) return + jumpRef.current?.setSearchQuery(query) + setHighlight(query) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, warmDone]); - const off = cursorOffset; - const cursorChar = off < query.length ? query[off] : ' '; - return + }, [query, warmDone]) + const off = cursorOffset + const cursorChar = off < query.length ? query[off] : ' ' + return ( + / {query.slice(0, off)} {cursorChar} {off < query.length && {query.slice(off + 1)}} - {indexStatus === 'building' ? indexing… : indexStatus ? indexed in {indexStatus.ms}ms : count === 0 && query ? no matches : count > 0 ? - // Engine-counted (indexOf on extractSearchText). May drift from - // render-count for ghost/phantom messages — badge is a rough - // location hint. scanElement gives exact per-message positions - // but counting ALL would cost ~1-3ms × matched-messages. - + {indexStatus === 'building' ? ( + indexing… + ) : indexStatus ? ( + indexed in {indexStatus.ms}ms + ) : count === 0 && query ? ( + no matches + ) : count > 0 ? ( + // Engine-counted (indexOf on extractSearchText). May drift from + // render-count for ghost/phantom messages — badge is a rough + // location hint. scanElement gives exact per-message positions + // but counting ALL would cost ~1-3ms × matched-messages. + {current}/{count} {' '} - : null} - ; + + ) : 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)}`)); + // 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)}`, + ), + ) + } + } + // 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' && setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />} - {(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && { - setShowModelSwitchCallout(false); - if (selection === 'switch' && modelAlias) { - setAppState(prev => ({ - ...prev, - mainLoopModel: modelAlias, - mainLoopModelForSession: null - })); - } - }} />} - {(process.env.USER_TYPE) === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && setShowUndercoverCallout(false)} />} - {focusedInputDialog === 'effort-callout' && { - setShowEffortCallout(false); - if (selection !== 'dismiss') { - setAppState(prev => ({ - ...prev, - effortValue: selection - })); - } - }} />} - {focusedInputDialog === 'remote-callout' && { - setAppState(prev => { - if (!prev.showRemoteCallout) return prev; - return { - ...prev, - showRemoteCallout: false, - ...(selection === 'enable' && { - replBridgeEnabled: true, - replBridgeExplicit: true, - replBridgeOutboundOnly: false - }) - }; - }); - }} />} + // 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() + } + + // 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' && ( + setShowIdeOnboarding(false)} + installationStatus={ideInstallationStatus} + /> + )} + {process.env.USER_TYPE === 'ant' && + focusedInputDialog === 'model-switch' && + AntModelSwitchCallout && ( + { + setShowModelSwitchCallout(false) + if (selection === 'switch' && modelAlias) { + setAppState(prev => ({ + ...prev, + mainLoopModel: modelAlias, + mainLoopModelForSession: null, + })) + } + }} + /> + )} + {process.env.USER_TYPE === 'ant' && + focusedInputDialog === 'undercover-callout' && + UndercoverAutoCallout && ( + setShowUndercoverCallout(false)} + /> + )} + {focusedInputDialog === 'effort-callout' && ( + { + setShowEffortCallout(false) + if (selection !== 'dismiss') { + setAppState(prev => ({ + ...prev, + effortValue: selection, + })) + } + }} + /> + )} + {focusedInputDialog === 'remote-callout' && ( + { + setAppState(prev => { + if (!prev.showRemoteCallout) return prev + return { + ...prev, + showRemoteCallout: false, + ...(selection === 'enable' && { + replBridgeEnabled: true, + replBridgeExplicit: true, + replBridgeOutboundOnly: false, + }), + } + }) + }} + /> + )} {exitFlow} - {focusedInputDialog === 'plugin-hint' && hintRecommendation && } + {focusedInputDialog === 'plugin-hint' && hintRecommendation && ( + + )} - {focusedInputDialog === 'lsp-recommendation' && lspRecommendation && } + {focusedInputDialog === 'lsp-recommendation' && + lspRecommendation && ( + + )} - {focusedInputDialog === 'desktop-upsell' && setShowDesktopUpsellStartup(false)} />} + {focusedInputDialog === 'desktop-upsell' && ( + setShowDesktopUpsellStartup(false)} + /> + )} - {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-choice' && ultraplanPendingChoice && store.getState()} setConversationId={setConversationId} /> : null} + {feature('ULTRAPLAN') + ? focusedInputDialog === 'ultraplan-choice' && + ultraplanPendingChoice && ( + store.getState()} + setConversationId={setConversationId} + /> + ) + : null} - {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-launch' && ultraplanLaunchPending && { - const blurb = ultraplanLaunchPending.blurb; - setAppState(prev => prev.ultraplanLaunchPending ? { - ...prev, - ultraplanLaunchPending: undefined - } : prev); - if (choice === 'cancel') return; - // Command's onDone used display:'skip', so add the - // echo here — gives immediate feedback before the - // ~5s teleportToRemote resolves. - setMessages(prev => [...prev, createCommandInputMessage(formatCommandInputTags('ultraplan', blurb))]); - const appendStdout = (msg: string) => setMessages(prev => [...prev, createCommandInputMessage(`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(msg)}`)]); - // Defer the second message if a query is mid-turn - // so it lands after the assistant reply, not - // between the user's prompt and the reply. - const appendWhenIdle = (msg: string) => { - if (!queryGuard.isActive) { - appendStdout(msg); - return; - } - const unsub = queryGuard.subscribe(() => { - if (queryGuard.isActive) return; - unsub(); - // Skip if the user stopped ultraplan while we - // were waiting — avoids a stale "Monitoring - // " message for a session that's gone. - if (!store.getState().ultraplanSessionUrl) return; - appendStdout(msg); - }); - }; - void launchUltraplan({ - blurb, - getAppState: () => store.getState(), - setAppState, - signal: createAbortController().signal, - disconnectedBridge: opts?.disconnectedBridge, - onSessionReady: appendWhenIdle - }).then(appendStdout).catch(logError); - }} /> : null} + {feature('ULTRAPLAN') + ? focusedInputDialog === 'ultraplan-launch' && + ultraplanLaunchPending && ( + { + const blurb = ultraplanLaunchPending.blurb + setAppState(prev => + prev.ultraplanLaunchPending + ? { ...prev, ultraplanLaunchPending: undefined } + : prev, + ) + if (choice === 'cancel') return + // Command's onDone used display:'skip', so add the + // echo here — gives immediate feedback before the + // ~5s teleportToRemote resolves. + setMessages(prev => [ + ...prev, + createCommandInputMessage( + formatCommandInputTags('ultraplan', blurb), + ), + ]) + const appendStdout = (msg: string) => + setMessages(prev => [ + ...prev, + createCommandInputMessage( + `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(msg)}`, + ), + ]) + // Defer the second message if a query is mid-turn + // so it lands after the assistant reply, not + // between the user's prompt and the reply. + const appendWhenIdle = (msg: string) => { + if (!queryGuard.isActive) { + appendStdout(msg) + return + } + const unsub = queryGuard.subscribe(() => { + if (queryGuard.isActive) return + unsub() + // Skip if the user stopped ultraplan while we + // were waiting — avoids a stale "Monitoring + // " message for a session that's gone. + if (!store.getState().ultraplanSessionUrl) return + appendStdout(msg) + }) + } + void launchUltraplan({ + blurb, + getAppState: () => store.getState(), + setAppState, + signal: createAbortController().signal, + disconnectedBridge: opts?.disconnectedBridge, + onSessionReady: appendWhenIdle, + }) + .then(appendStdout) + .catch(logError) + }} + /> + ) + : null} {mrRender()} - {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && <> - {autoRunIssueReason && } - {postCompactSurvey.state !== 'closed' ? : memorySurvey.state !== 'closed' ? : } + {!toolJSX?.shouldHidePromptInput && + !focusedInputDialog && + !isExiting && + !disabled && + !cursor && ( + <> + {autoRunIssueReason && ( + + )} + {postCompactSurvey.state !== 'closed' ? ( + + ) : memorySurvey.state !== 'closed' ? ( + + ) : ( + + )} {/* Frustration-triggered transcript sharing prompt */} - {frustrationDetection.state !== 'closed' && {}} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />} + {frustrationDetection.state !== 'closed' && ( + {}} + handleTranscriptSelect={ + frustrationDetection.handleTranscriptSelect + } + inputValue={inputValue} + setInputValue={setInputValue} + /> + )} {/* Skill improvement survey - appears when improvements detected (ant-only) */} - {(process.env.USER_TYPE) === 'ant' && skillImprovementSurvey.suggestion && } + {process.env.USER_TYPE === 'ant' && + skillImprovementSurvey.suggestion && ( + + )} {showIssueFlagBanner && } - {} - - - } - {cursor && - // inputValue is REPL state; typed text survives the round-trip. - } - {focusedInputDialog === 'message-selector' && { - await fileHistoryRewind((updater: (prev: FileHistoryState) => FileHistoryState) => { - setAppState(prev => ({ - ...prev, - fileHistory: updater(prev.fileHistory) - })); - }, message.uuid); - }} onSummarize={async (message: UserMessage, feedback?: string, direction: PartialCompactDirection = 'from') => { - // Project snipped messages so the compact model - // doesn't summarize content that was intentionally removed. - const compactMessages = getMessagesAfterCompactBoundary(messages); - const messageIndex = compactMessages.indexOf(message); - if (messageIndex === -1) { - // Selected a snipped or pre-compact message that the - // selector still shows (REPL keeps full history for - // scrollback). Surface why nothing happened instead - // of silently no-oping. - setMessages(prev => [...prev, createSystemMessage('That message is no longer in the active context (snipped or pre-compact). Choose a more recent message.', 'warning')]); - return; - } - const newAbortController = createAbortController(); - const context = getToolUseContext(compactMessages, [], newAbortController, mainLoopModel); - const appState = context.getAppState(); - const defaultSysPrompt = await getSystemPrompt(context.options.tools, context.options.mainLoopModel, Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()), context.options.mcpClients); - const systemPrompt = buildEffectiveSystemPrompt({ - mainThreadAgentDefinition: undefined, - toolUseContext: context, - customSystemPrompt: context.options.customSystemPrompt, - defaultSystemPrompt: defaultSysPrompt, - appendSystemPrompt: context.options.appendSystemPrompt - }); - const [userContext, systemContext] = await Promise.all([getUserContext(), getSystemContext()]); - const result = await partialCompactConversation(compactMessages, messageIndex, context, { - systemPrompt, - userContext, - systemContext, - toolUseContext: context, - forkContextMessages: compactMessages - }, feedback, direction); - const kept = result.messagesToKeep ?? []; - const ordered = direction === 'up_to' ? [...result.summaryMessages, ...kept] : [...kept, ...result.summaryMessages]; - const postCompact = [result.boundaryMarker, ...ordered, ...result.attachments, ...result.hookResults]; - // Fullscreen 'from' keeps scrollback; 'up_to' must not - // (old[0] unchanged + grown array means incremental - // useLogMessages path, so boundary never persisted). - // Find by uuid since old is raw REPL history and snipped - // entries can shift the projected messageIndex. - if (isFullscreenEnvEnabled() && direction === 'from') { - setMessages(old => { - const rawIdx = old.findIndex(m => m.uuid === message.uuid); - return [...old.slice(0, rawIdx === -1 ? 0 : rawIdx), ...postCompact]; - }); - } else { - setMessages(postCompact); - } - // Partial compact bypasses handleMessageFromStream — clear - // the context-blocked flag so proactive ticks resume. - if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.setContextBlocked(false); - } - setConversationId(randomUUID()); - runPostCompactCleanup(context.options.querySource); - if (direction === 'from') { - const r = textForResubmit(message); - if (r) { - setInputValue(r.text); - setInputMode(r.mode); - } - } + { + } + + + + )} + {cursor && ( + // inputValue is REPL state; typed text survives the round-trip. + + )} + {focusedInputDialog === 'message-selector' && ( + { + await fileHistoryRewind( + ( + updater: (prev: FileHistoryState) => FileHistoryState, + ) => { + setAppState(prev => ({ + ...prev, + fileHistory: updater(prev.fileHistory), + })) + }, + message.uuid, + ) + }} + onSummarize={async ( + message: UserMessage, + feedback?: string, + direction: PartialCompactDirection = 'from', + ) => { + // Project snipped messages so the compact model + // doesn't summarize content that was intentionally removed. + const compactMessages = + getMessagesAfterCompactBoundary(messages) - // Show notification with ctrl+o hint - const historyShortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); - addNotification({ - key: 'summarize-ctrl-o-hint', - text: `Conversation summarized (${historyShortcut} for history)`, - priority: 'medium', - timeoutMs: 8000 - }); - }} onRestoreMessage={handleRestoreMessage} onClose={() => { - setIsMessageSelectorVisible(false); - setMessageSelectorPreselect(undefined); - }} />} - {(process.env.USER_TYPE) === 'ant' && } + const messageIndex = compactMessages.indexOf(message) + if (messageIndex === -1) { + // Selected a snipped or pre-compact message that the + // selector still shows (REPL keeps full history for + // scrollback). Surface why nothing happened instead + // of silently no-oping. + setMessages(prev => [ + ...prev, + createSystemMessage( + 'That message is no longer in the active context (snipped or pre-compact). Choose a more recent message.', + 'warning', + ), + ]) + return + } + + const newAbortController = createAbortController() + const context = getToolUseContext( + compactMessages, + [], + newAbortController, + mainLoopModel, + ) + + const appState = context.getAppState() + const defaultSysPrompt = await getSystemPrompt( + context.options.tools, + context.options.mainLoopModel, + Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ), + context.options.mcpClients, + ) + const systemPrompt = buildEffectiveSystemPrompt({ + mainThreadAgentDefinition: undefined, + toolUseContext: context, + customSystemPrompt: context.options.customSystemPrompt, + defaultSystemPrompt: defaultSysPrompt, + appendSystemPrompt: context.options.appendSystemPrompt, + }) + const [userContext, systemContext] = await Promise.all([ + getUserContext(), + getSystemContext(), + ]) + + const result = await partialCompactConversation( + compactMessages, + messageIndex, + context, + { + systemPrompt, + userContext, + systemContext, + toolUseContext: context, + forkContextMessages: compactMessages, + }, + feedback, + direction, + ) + + const kept = result.messagesToKeep ?? [] + const ordered = + direction === 'up_to' + ? [...result.summaryMessages, ...kept] + : [...kept, ...result.summaryMessages] + const postCompact = [ + result.boundaryMarker, + ...ordered, + ...result.attachments, + ...result.hookResults, + ] + // Fullscreen 'from' keeps scrollback; 'up_to' must not + // (old[0] unchanged + grown array means incremental + // useLogMessages path, so boundary never persisted). + // Find by uuid since old is raw REPL history and snipped + // entries can shift the projected messageIndex. + if (isFullscreenEnvEnabled() && direction === 'from') { + setMessages(old => { + const rawIdx = old.findIndex( + m => m.uuid === message.uuid, + ) + return [ + ...old.slice(0, rawIdx === -1 ? 0 : rawIdx), + ...postCompact, + ] + }) + } else { + setMessages(postCompact) + } + // Partial compact bypasses handleMessageFromStream — clear + // the context-blocked flag so proactive ticks resume. + if (feature('PROACTIVE') || feature('KAIROS')) { + proactiveModule?.setContextBlocked(false) + } + setConversationId(randomUUID()) + runPostCompactCleanup(context.options.querySource) + + if (direction === 'from') { + const r = textForResubmit(message) + if (r) { + setInputValue(r.text) + setInputMode(r.mode) + } + } + + // Show notification with ctrl+o hint + const historyShortcut = getShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + addNotification({ + key: 'summarize-ctrl-o-hint', + text: `Conversation summarized (${historyShortcut} for history)`, + priority: 'medium', + timeoutMs: 8000, + }) + }} + onRestoreMessage={handleRestoreMessage} + onClose={() => { + setIsMessageSelectorVisible(false) + setMessageSelectorPreselect(undefined) + }} + /> + )} + {process.env.USER_TYPE === 'ant' && } - {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} - } /> + {feature('BUDDY') && + !(companionNarrow && isFullscreenEnvEnabled()) && + companionVisible ? ( + + ) : null} + + } + /> - ; + + ) if (isFullscreenEnvEnabled()) { - return + return ( + {mainReturn} - ; + + ) } - return mainReturn; + return mainReturn } diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index 71f947c43..019327ff3 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -1,69 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import { dirname } from 'path'; -import React from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { getOriginalCwd, switchSession } from '../bootstrap/state.js'; -import type { Command } from '../commands.js'; -import { LogSelector } from '../components/LogSelector.js'; -import { Spinner } from '../components/Spinner.js'; -import { restoreCostStateForSession } from '../cost-tracker.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; -import type { MCPServerConnection, ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import type { Tool } from '../Tool.js'; -import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; -import { asSessionId } from '../types/ids.js'; -import type { LogOption } from '../types/logs.js'; -import type { Message } from '../types/message.js'; -import { agenticSessionSearch } from '../utils/agenticSessionSearch.js'; -import { renameRecordingForSession } from '../utils/asciicast.js'; -import { updateSessionName } from '../utils/concurrentSessions.js'; -import { loadConversationForResume } from '../utils/conversationRecovery.js'; -import { checkCrossProjectResume } from '../utils/crossProjectResume.js'; -import type { FileHistorySnapshot } from '../utils/fileHistory.js'; -import { logError } from '../utils/log.js'; -import { createSystemMessage } from '../utils/messages.js'; -import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; -import { adoptResumedSessionFile, enrichLogs, isCustomTitleEnabled, loadAllProjectsMessageLogsProgressive, loadSameRepoMessageLogsProgressive, recordContentReplacement, resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult } from '../utils/sessionStorage.js'; -import type { ThinkingConfig } from '../utils/thinking.js'; -import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'; -import { REPL } from './REPL.js'; +import { feature } from 'bun:bundle' +import { dirname } from 'path' +import React from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { getOriginalCwd, switchSession } from '../bootstrap/state.js' +import type { Command } from '../commands.js' +import { LogSelector } from '../components/LogSelector.js' +import { Spinner } from '../components/Spinner.js' +import { restoreCostStateForSession } from '../cost-tracker.js' +import { setClipboard } from '../ink/termio/osc.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import type { + MCPServerConnection, + ScopedMcpServerConfig, +} from '../services/mcp/types.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import type { Tool } from '../Tool.js' +import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import { asSessionId } from '../types/ids.js' +import type { LogOption } from '../types/logs.js' +import type { Message } from '../types/message.js' +import { agenticSessionSearch } from '../utils/agenticSessionSearch.js' +import { renameRecordingForSession } from '../utils/asciicast.js' +import { updateSessionName } from '../utils/concurrentSessions.js' +import { loadConversationForResume } from '../utils/conversationRecovery.js' +import { checkCrossProjectResume } from '../utils/crossProjectResume.js' +import type { FileHistorySnapshot } from '../utils/fileHistory.js' +import { logError } from '../utils/log.js' +import { createSystemMessage } from '../utils/messages.js' +import { + computeStandaloneAgentContext, + restoreAgentFromSession, + restoreWorktreeForResume, +} from '../utils/sessionRestore.js' +import { + adoptResumedSessionFile, + enrichLogs, + isCustomTitleEnabled, + loadAllProjectsMessageLogsProgressive, + loadSameRepoMessageLogsProgressive, + recordContentReplacement, + resetSessionFilePointer, + restoreSessionMetadata, + type SessionLogResult, +} from '../utils/sessionStorage.js' +import type { ThinkingConfig } from '../utils/thinking.js' +import type { ContentReplacementRecord } from '../utils/toolResultStorage.js' +import { REPL } from './REPL.js' + function parsePrIdentifier(value: string): number | null { - const directNumber = parseInt(value, 10); + const directNumber = parseInt(value, 10) if (!isNaN(directNumber) && directNumber > 0) { - return directNumber; + return directNumber } - const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); + const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/) if (urlMatch?.[1]) { - return parseInt(urlMatch[1], 10); + return parseInt(urlMatch[1], 10) } - return null; + return null } + type Props = { - commands: Command[]; - worktreePaths: string[]; - initialTools: Tool[]; - mcpClients?: MCPServerConnection[]; - dynamicMcpConfig?: Record; - debug: boolean; - mainThreadAgentDefinition?: AgentDefinition; - autoConnectIdeFlag?: boolean; - strictMcpConfig?: boolean; - systemPrompt?: string; - appendSystemPrompt?: string; - initialSearchQuery?: string; - disableSlashCommands?: boolean; - forkSession?: boolean; - taskListId?: string; - filterByPr?: boolean | number | string; - thinkingConfig: ThinkingConfig; - onTurnComplete?: (messages: Message[]) => void | Promise; -}; + commands: Command[] + worktreePaths: string[] + initialTools: Tool[] + mcpClients?: MCPServerConnection[] + dynamicMcpConfig?: Record + debug: boolean + mainThreadAgentDefinition?: AgentDefinition + autoConnectIdeFlag?: boolean + strictMcpConfig?: boolean + systemPrompt?: string + appendSystemPrompt?: string + initialSearchQuery?: string + disableSlashCommands?: boolean + forkSession?: boolean + taskListId?: string + filterByPr?: boolean | number | string + thinkingConfig: ThinkingConfig + onTurnComplete?: (messages: Message[]) => void | Promise +} + export function ResumeConversation({ commands, worktreePaths, @@ -82,317 +104,365 @@ export function ResumeConversation({ taskListId, filterByPr, thinkingConfig, - onTurnComplete + onTurnComplete, }: Props): React.ReactNode { - const { - rows - } = useTerminalSize(); - const agentDefinitions = useAppState(s => s.agentDefinitions); - const setAppState = useSetAppState(); - const [logs, setLogs] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [resuming, setResuming] = React.useState(false); - const [showAllProjects, setShowAllProjects] = React.useState(false); + const { rows } = useTerminalSize() + const agentDefinitions = useAppState(s => s.agentDefinitions) + const setAppState = useSetAppState() + const [logs, setLogs] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [resuming, setResuming] = React.useState(false) + const [showAllProjects, setShowAllProjects] = React.useState(false) const [resumeData, setResumeData] = React.useState<{ - messages: Message[]; - fileHistorySnapshots?: FileHistorySnapshot[]; - contentReplacements?: ContentReplacementRecord[]; - agentName?: string; - agentColor?: AgentColorName; - mainThreadAgentDefinition?: AgentDefinition; - } | null>(null); - const [crossProjectCommand, setCrossProjectCommand] = React.useState(null); - const sessionLogResultRef = React.useRef(null); + messages: Message[] + fileHistorySnapshots?: FileHistorySnapshot[] + contentReplacements?: ContentReplacementRecord[] + agentName?: string + agentColor?: AgentColorName + mainThreadAgentDefinition?: AgentDefinition + } | null>(null) + const [crossProjectCommand, setCrossProjectCommand] = React.useState< + string | null + >(null) + const sessionLogResultRef = React.useRef(null) // Mirror of logs.length so loadMoreLogs can compute value indices outside // the setLogs updater (keeping it pure per React's contract). - const logCountRef = React.useRef(0); + const logCountRef = React.useRef(0) + const filteredLogs = React.useMemo(() => { - let result = logs.filter(l => !l.isSidechain); + let result = logs.filter(l => !l.isSidechain) if (filterByPr !== undefined) { if (filterByPr === true) { - result = result.filter(l_0 => l_0.prNumber !== undefined); + result = result.filter(l => l.prNumber !== undefined) } else if (typeof filterByPr === 'number') { - result = result.filter(l_1 => l_1.prNumber === filterByPr); + result = result.filter(l => l.prNumber === filterByPr) } else if (typeof filterByPr === 'string') { - const prNumber = parsePrIdentifier(filterByPr); + const prNumber = parsePrIdentifier(filterByPr) if (prNumber !== null) { - result = result.filter(l_2 => l_2.prNumber === prNumber); + result = result.filter(l => l.prNumber === prNumber) } } } - return result; - }, [logs, filterByPr]); - const isResumeWithRenameEnabled = isCustomTitleEnabled(); + return result + }, [logs, filterByPr]) + const isResumeWithRenameEnabled = isCustomTitleEnabled() + React.useEffect(() => { - loadSameRepoMessageLogsProgressive(worktreePaths).then(result_0 => { - sessionLogResultRef.current = result_0; - logCountRef.current = result_0.logs.length; - setLogs(result_0.logs); - setLoading(false); - }).catch(error => { - logError(error); - setLoading(false); - }); - }, [worktreePaths]); + loadSameRepoMessageLogsProgressive(worktreePaths) + .then(result => { + sessionLogResultRef.current = result + logCountRef.current = result.logs.length + setLogs(result.logs) + setLoading(false) + }) + .catch(error => { + logError(error) + setLoading(false) + }) + }, [worktreePaths]) + const loadMoreLogs = React.useCallback((count: number) => { - const ref = sessionLogResultRef.current; - if (!ref || ref.nextIndex >= ref.allStatLogs.length) return; - void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result_1 => { - ref.nextIndex = result_1.nextIndex; - if (result_1.logs.length > 0) { + const ref = sessionLogResultRef.current + if (!ref || ref.nextIndex >= ref.allStatLogs.length) return + + void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => { + ref.nextIndex = result.nextIndex + if (result.logs.length > 0) { // enrichLogs returns fresh unshared objects — safe to mutate in place. // Offset comes from logCountRef so the setLogs updater stays pure. - const offset = logCountRef.current; - result_1.logs.forEach((log, i) => { - log.value = offset + i; - }); - setLogs(prev => prev.concat(result_1.logs)); - logCountRef.current += result_1.logs.length; + const offset = logCountRef.current + result.logs.forEach((log, i) => { + log.value = offset + i + }) + setLogs(prev => prev.concat(result.logs)) + logCountRef.current += result.logs.length } else if (ref.nextIndex < ref.allStatLogs.length) { - loadMoreLogs(count); + loadMoreLogs(count) } - }); - }, []); - const loadLogs = React.useCallback((allProjects: boolean) => { - setLoading(true); - const promise = allProjects ? loadAllProjectsMessageLogsProgressive() : loadSameRepoMessageLogsProgressive(worktreePaths); - promise.then(result_2 => { - sessionLogResultRef.current = result_2; - logCountRef.current = result_2.logs.length; - setLogs(result_2.logs); - }).catch(error_0 => { - logError(error_0); - }).finally(() => { - setLoading(false); - }); - }, [worktreePaths]); + }) + }, []) + + const loadLogs = React.useCallback( + (allProjects: boolean) => { + setLoading(true) + const promise = allProjects + ? loadAllProjectsMessageLogsProgressive() + : loadSameRepoMessageLogsProgressive(worktreePaths) + promise + .then(result => { + sessionLogResultRef.current = result + logCountRef.current = result.logs.length + setLogs(result.logs) + }) + .catch(error => { + logError(error) + }) + .finally(() => { + setLoading(false) + }) + }, + [worktreePaths], + ) + const handleToggleAllProjects = React.useCallback(() => { - const newValue = !showAllProjects; - setShowAllProjects(newValue); - loadLogs(newValue); - }, [showAllProjects, loadLogs]); + const newValue = !showAllProjects + setShowAllProjects(newValue) + loadLogs(newValue) + }, [showAllProjects, loadLogs]) + function onCancel() { // eslint-disable-next-line custom-rules/no-process-exit - process.exit(1); + process.exit(1) } - async function onSelect(log_0: LogOption) { - setResuming(true); - const resumeStart = performance.now(); - const crossProjectCheck = checkCrossProjectResume(log_0, showAllProjects, worktreePaths); + + async function onSelect(log: LogOption) { + setResuming(true) + const resumeStart = performance.now() + + const crossProjectCheck = checkCrossProjectResume( + log, + showAllProjects, + worktreePaths, + ) if (crossProjectCheck.isCrossProject) { if (!crossProjectCheck.isSameRepoWorktree) { - const raw = await setClipboard((crossProjectCheck as any).command); - if (raw) process.stdout.write(raw); - setCrossProjectCommand((crossProjectCheck as any).command); - return; + const raw = await setClipboard(crossProjectCheck.command) + if (raw) process.stdout.write(raw) + setCrossProjectCommand(crossProjectCheck.command) + return } } + try { - const result_3 = await loadConversationForResume(log_0, undefined); - if (!result_3) { - throw new Error('Failed to load conversation'); + const result = await loadConversationForResume(log, undefined) + if (!result) { + throw new Error('Failed to load conversation') } + if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); + const coordinatorModule = + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') /* eslint-enable @typescript-eslint/no-require-imports */ - const warning = coordinatorModule.matchSessionMode(result_3.mode); + const warning = coordinatorModule.matchSessionMode(result.mode) if (warning) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - getAgentDefinitionsWithOverrides, - getActiveAgentsFromList - } = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js'); + 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_0 => ({ - ...prev_0, + getAgentDefinitionsWithOverrides.cache.clear?.() + const freshAgentDefs = await getAgentDefinitionsWithOverrides( + getOriginalCwd(), + ) + setAppState(prev => ({ + ...prev, agentDefinitions: { ...freshAgentDefs, allAgents: freshAgentDefs.allAgents, - activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents) - } - })); - result_3.messages.push(createSystemMessage(warning, 'warning')); + activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), + }, + })) + result.messages.push(createSystemMessage(warning, 'warning')) } } - if (result_3.sessionId && !forkSession) { - switchSession(asSessionId(result_3.sessionId), log_0.fullPath ? dirname(log_0.fullPath) : null); - await renameRecordingForSession(); - await resetSessionFilePointer(); - restoreCostStateForSession(result_3.sessionId); - } else if (forkSession && result_3.contentReplacements?.length) { - await recordContentReplacement(result_3.contentReplacements); + + if (result.sessionId && !forkSession) { + switchSession( + asSessionId(result.sessionId), + log.fullPath ? dirname(log.fullPath) : null, + ) + await renameRecordingForSession() + await resetSessionFilePointer() + restoreCostStateForSession(result.sessionId) + } else if (forkSession && result.contentReplacements?.length) { + await recordContentReplacement(result.contentReplacements) } - const { - agentDefinition: resolvedAgentDef - } = restoreAgentFromSession(result_3.agentSetting, mainThreadAgentDefinition, agentDefinitions); - setAppState(prev_1 => ({ - ...prev_1, - agent: resolvedAgentDef?.agentType - })); + + const { agentDefinition: resolvedAgentDef } = restoreAgentFromSession( + result.agentSetting, + mainThreadAgentDefinition, + agentDefinitions, + ) + setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType })) + 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'); + 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'); + saveMode(isCoordinatorMode() ? 'coordinator' : 'normal') } - const standaloneAgentContext = computeStandaloneAgentContext(result_3.agentName, result_3.agentColor); + + const standaloneAgentContext = computeStandaloneAgentContext( + result.agentName, + result.agentColor, + ) if (standaloneAgentContext) { - setAppState(prev_2 => ({ - ...prev_2, - standaloneAgentContext - })); + setAppState(prev => ({ ...prev, standaloneAgentContext })) } - void updateSessionName(result_3.agentName); - restoreSessionMetadata(forkSession ? { - ...result_3, - worktreeSession: undefined - } : result_3); + void updateSessionName(result.agentName) + + restoreSessionMetadata( + forkSession ? { ...result, worktreeSession: undefined } : result, + ) + if (!forkSession) { - restoreWorktreeForResume(result_3.worktreeSession); - if (result_3.sessionId) { - adoptResumedSessionFile(); + restoreWorktreeForResume(result.worktreeSession) + if (result.sessionId) { + adoptResumedSessionFile() } } + if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - ; - (require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js')).restoreFromEntries(result_3.contextCollapseCommits ?? [], result_3.contextCollapseSnapshot); + ;( + require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js') + ).restoreFromEntries( + result.contextCollapseCommits ?? [], + result.contextCollapseSnapshot, + ) /* eslint-enable @typescript-eslint/no-require-imports */ } + logEvent('tengu_session_resumed', { - entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: + 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); - setLogs([]); + resume_duration_ms: Math.round(performance.now() - resumeStart), + }) + + setLogs([]) setResumeData({ - messages: result_3.messages, - fileHistorySnapshots: result_3.fileHistorySnapshots, - contentReplacements: result_3.contentReplacements, - agentName: result_3.agentName, - agentColor: (result_3.agentColor === 'default' ? undefined : result_3.agentColor) as AgentColorName | undefined, - mainThreadAgentDefinition: resolvedAgentDef - }); + messages: result.messages, + fileHistorySnapshots: result.fileHistorySnapshots, + contentReplacements: result.contentReplacements, + agentName: result.agentName, + agentColor: (result.agentColor === 'default' + ? undefined + : result.agentColor) as AgentColorName | undefined, + mainThreadAgentDefinition: resolvedAgentDef, + }) } catch (e) { logEvent('tengu_session_resumed', { - entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false - }); - logError(e as Error); - throw e; + entrypoint: + 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }) + logError(e as Error) + throw e } } + if (crossProjectCommand) { - return ; + return } + if (resumeData) { - return ; + return ( + + ) } + if (loading) { - return + return ( + Loading conversations… - ; + + ) } + if (resuming) { - return + return ( + Resuming conversation… - ; + + ) } + if (filteredLogs.length === 0) { - return ; + return } - return loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />; + + return ( + loadLogs(showAllProjects) : undefined + } + onLoadMore={loadMoreLogs} + initialSearchQuery={initialSearchQuery} + showAllProjects={showAllProjects} + onToggleAllProjects={handleToggleAllProjects} + onAgenticSearch={agenticSessionSearch} + /> + ) } -function NoConversationsMessage() { - const $ = _c(2); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Global" - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useKeybinding("app:interrupt", _temp, t0); - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = No conversations found to resume.Press Ctrl+C to exit and start a new conversation.; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + +function NoConversationsMessage(): React.ReactNode { + useKeybinding( + 'app:interrupt', + () => { + // eslint-disable-next-line custom-rules/no-process-exit + process.exit(1) + }, + { context: 'Global' }, + ) + + return ( + + No conversations found to resume. + Press Ctrl+C to exit and start a new conversation. + + ) } -function _temp() { - process.exit(1); -} -function CrossProjectMessage(t0) { - const $ = _c(8); - const { - command - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp3, t1); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = This conversation is from a different directory.; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = To resume, run:; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== command) { - t4 = {t3} {command}; - $[3] = command; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = (Command copied to clipboard); - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== t4) { - t6 = {t2}{t4}{t5}; - $[6] = t4; - $[7] = t6; - } else { - t6 = $[7]; - } - return t6; -} -function _temp3() { - const timeout = setTimeout(_temp2, 100); - return () => clearTimeout(timeout); -} -function _temp2() { - process.exit(0); + +function CrossProjectMessage({ + command, +}: { + command: string +}): React.ReactNode { + React.useEffect(() => { + const timeout = setTimeout(() => { + // eslint-disable-next-line custom-rules/no-process-exit + process.exit(0) + }, 100) + return () => clearTimeout(timeout) + }, []) + + return ( + + This conversation is from a different directory. + + To resume, run: + {command} + + (Command copied to clipboard) + + ) } diff --git a/src/services/mcp/MCPConnectionManager.tsx b/src/services/mcp/MCPConnectionManager.tsx index 3c5bb4d23..46c56b689 100644 --- a/src/services/mcp/MCPConnectionManager.tsx +++ b/src/services/mcp/MCPConnectionManager.tsx @@ -1,72 +1,74 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, type ReactNode, useContext, useMemo } from 'react'; -import type { Command } from '../../commands.js'; -import type { Tool } from '../../Tool.js'; -import type { MCPServerConnection, ScopedMcpServerConfig, ServerResource } from './types.js'; -import { useManageMCPConnections } from './useManageMCPConnections.js'; +import React, { + createContext, + type ReactNode, + useContext, + useMemo, +} from 'react' +import type { Command } from '../../commands.js' +import type { Tool } from '../../Tool.js' +import type { + MCPServerConnection, + ScopedMcpServerConfig, + ServerResource, +} from './types.js' +import { useManageMCPConnections } from './useManageMCPConnections.js' + interface MCPConnectionContextValue { reconnectMcpServer: (serverName: string) => Promise<{ - client: MCPServerConnection; - tools: Tool[]; - commands: Command[]; - resources?: ServerResource[]; - }>; - toggleMcpServer: (serverName: string) => Promise; + client: MCPServerConnection + tools: Tool[] + commands: Command[] + resources?: ServerResource[] + }> + toggleMcpServer: (serverName: string) => Promise } -const MCPConnectionContext = createContext(null); + +const MCPConnectionContext = createContext( + null, +) + export function useMcpReconnect() { - const context = useContext(MCPConnectionContext); + const context = useContext(MCPConnectionContext) if (!context) { - throw new Error("useMcpReconnect must be used within MCPConnectionManager"); + throw new Error('useMcpReconnect must be used within MCPConnectionManager') } - return context.reconnectMcpServer; + return context.reconnectMcpServer } + export function useMcpToggleEnabled() { - const context = useContext(MCPConnectionContext); + const context = useContext(MCPConnectionContext) if (!context) { - throw new Error("useMcpToggleEnabled must be used within MCPConnectionManager"); + throw new Error( + 'useMcpToggleEnabled must be used within MCPConnectionManager', + ) } - return context.toggleMcpServer; + return context.toggleMcpServer } + interface MCPConnectionManagerProps { - children: ReactNode; - dynamicMcpConfig: Record | undefined; - isStrictMcpConfig: boolean; + children: ReactNode + dynamicMcpConfig: Record | undefined + isStrictMcpConfig: boolean } // TODO (ollie): We may be able to get rid of this context by putting these function on app state -export function MCPConnectionManager(t0) { - const $ = _c(6); - const { - children, +export function MCPConnectionManager({ + children, + dynamicMcpConfig, + isStrictMcpConfig, +}: MCPConnectionManagerProps): React.ReactNode { + const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections( dynamicMcpConfig, - isStrictMcpConfig - } = t0; - const { - reconnectMcpServer, - toggleMcpServer - } = useManageMCPConnections(dynamicMcpConfig, isStrictMcpConfig); - let t1; - if ($[0] !== reconnectMcpServer || $[1] !== toggleMcpServer) { - t1 = { - reconnectMcpServer, - toggleMcpServer - }; - $[0] = reconnectMcpServer; - $[1] = toggleMcpServer; - $[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; + isStrictMcpConfig, + ) + const value = useMemo( + () => ({ reconnectMcpServer, toggleMcpServer }), + [reconnectMcpServer, toggleMcpServer], + ) + + return ( + + {children} + + ) } diff --git a/src/services/mcpServerApproval.tsx b/src/services/mcpServerApproval.tsx index 9b5a8bf5e..4b92d1280 100644 --- a/src/services/mcpServerApproval.tsx +++ b/src/services/mcpServerApproval.tsx @@ -1,11 +1,11 @@ -import React from 'react'; -import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js'; -import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js'; -import type { Root } from '../ink.js'; -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; -import { AppStateProvider } from '../state/AppState.js'; -import { getMcpConfigsByScope } from './mcp/config.js'; -import { getProjectMcpServerStatus } from './mcp/utils.js'; +import React from 'react' +import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js' +import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js' +import type { Root } from '../ink.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../state/AppState.js' +import { getMcpConfigsByScope } from './mcp/config.js' +import { getProjectMcpServerStatus } from './mcp/utils.js' /** * Show MCP server approval dialogs for pending project servers. @@ -13,28 +13,37 @@ import { getProjectMcpServerStatus } from './mcp/utils.js'; * from main.tsx instead of creating a separate one). */ export async function handleMcpjsonServerApprovals(root: Root): Promise { - const { - servers: projectServers - } = getMcpConfigsByScope('project'); - const pendingServers = Object.keys(projectServers).filter(serverName => getProjectMcpServerStatus(serverName) === 'pending'); + const { servers: projectServers } = getMcpConfigsByScope('project') + const pendingServers = Object.keys(projectServers).filter( + serverName => getProjectMcpServerStatus(serverName) === 'pending', + ) + if (pendingServers.length === 0) { - return; + return } + await new Promise(resolve => { - const done = (): void => void resolve(); + const done = (): void => void resolve() if (pendingServers.length === 1 && pendingServers[0] !== undefined) { - const serverName = pendingServers[0]; - root.render( + const serverName = pendingServers[0] + root.render( + - ); + , + ) } else { - root.render( + root.render( + - + - ); + , + ) } - }); + }) } diff --git a/src/services/remoteManagedSettings/securityCheck.tsx b/src/services/remoteManagedSettings/securityCheck.tsx index c622481e1..857103408 100644 --- a/src/services/remoteManagedSettings/securityCheck.tsx +++ b/src/services/remoteManagedSettings/securityCheck.tsx @@ -1,15 +1,20 @@ -import React from 'react'; -import { getIsInteractive } from '../../bootstrap/state.js'; -import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js'; -import { extractDangerousSettings, hasDangerousSettings, hasDangerousSettingsChanged } from '../../components/ManagedSettingsSecurityDialog/utils.js'; -import { render } from '../../ink.js'; -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; -import { AppStateProvider } from '../../state/AppState.js'; -import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; -import { getBaseRenderOptions } from '../../utils/renderOptions.js'; -import type { SettingsJson } from '../../utils/settings/types.js'; -import { logEvent } from '../analytics/index.js'; -export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'; +import React from 'react' +import { getIsInteractive } from '../../bootstrap/state.js' +import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js' +import { + extractDangerousSettings, + hasDangerousSettings, + hasDangerousSettingsChanged, +} from '../../components/ManagedSettingsSecurityDialog/utils.js' +import { render } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../../state/AppState.js' +import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js' +import { getBaseRenderOptions } from '../../utils/renderOptions.js' +import type { SettingsJson } from '../../utils/settings/types.js' +import { logEvent } from '../analytics/index.js' + +export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed' /** * Check if new remote managed settings contain dangerous settings that require user approval. @@ -19,55 +24,68 @@ export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'; * @param newSettings The new settings fetched from the API * @returns 'approved' if user accepts, 'rejected' if user declines, 'no_check_needed' if no dangerous changes */ -export async function checkManagedSettingsSecurity(cachedSettings: SettingsJson | null, newSettings: SettingsJson | null): Promise { +export async function checkManagedSettingsSecurity( + cachedSettings: SettingsJson | null, + newSettings: SettingsJson | null, +): Promise { // If new settings don't have dangerous settings, no check needed - if (!newSettings || !hasDangerousSettings(extractDangerousSettings(newSettings))) { - return 'no_check_needed'; + if ( + !newSettings || + !hasDangerousSettings(extractDangerousSettings(newSettings)) + ) { + return 'no_check_needed' } // If dangerous settings haven't changed, no check needed if (!hasDangerousSettingsChanged(cachedSettings, newSettings)) { - return 'no_check_needed'; + return 'no_check_needed' } // Skip dialog in non-interactive mode (consistent with trust dialog behavior) if (!getIsInteractive()) { - return 'no_check_needed'; + return 'no_check_needed' } // Log that dialog is being shown - logEvent('tengu_managed_settings_security_dialog_shown', {}); + logEvent('tengu_managed_settings_security_dialog_shown', {}) // Show blocking dialog return new Promise(resolve => { void (async () => { - const { - unmount - } = await render( + const { unmount } = await render( + - { - logEvent('tengu_managed_settings_security_dialog_accepted', {}); - unmount(); - void resolve('approved'); - }} onReject={() => { - logEvent('tengu_managed_settings_security_dialog_rejected', {}); - unmount(); - void resolve('rejected'); - }} /> + { + logEvent('tengu_managed_settings_security_dialog_accepted', {}) + unmount() + void resolve('approved') + }} + onReject={() => { + logEvent('tengu_managed_settings_security_dialog_rejected', {}) + unmount() + void resolve('rejected') + }} + /> - , getBaseRenderOptions(false)); - })(); - }); + , + getBaseRenderOptions(false), + ) + })() + }) } /** * Handle the security check result by exiting if rejected * Returns true if we should continue, false if we should stop */ -export function handleSecurityCheckResult(result: SecurityCheckResult): boolean { +export function handleSecurityCheckResult( + result: SecurityCheckResult, +): boolean { if (result === 'rejected') { - gracefulShutdownSync(1); - return false; + gracefulShutdownSync(1) + return false } - return true; + return true } diff --git a/src/state/AppState.tsx b/src/state/AppState.tsx index e5b01fb26..783170cc3 100644 --- a/src/state/AppState.tsx +++ b/src/state/AppState.tsx @@ -1,126 +1,134 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react'; -import { MailboxProvider } from '../context/mailbox.js'; -import { useSettingsChange } from '../hooks/useSettingsChange.js'; -import { logForDebugging } from '../utils/debug.js'; -import { createDisabledBypassPermissionsContext, isBypassPermissionsModeDisabled } from '../utils/permissions/permissionSetup.js'; -import { applySettingsChange } from '../utils/settings/applySettingsChange.js'; -import type { SettingSource } from '../utils/settings/constants.js'; -import { createStore } from './store.js'; +import { feature } from 'bun:bundle' +import React, { + useContext, + useEffect, + useEffectEvent, + useState, + useSyncExternalStore, +} from 'react' +import { MailboxProvider } from '../context/mailbox.js' +import { useSettingsChange } from '../hooks/useSettingsChange.js' +import { logForDebugging } from '../utils/debug.js' +import { + createDisabledBypassPermissionsContext, + isBypassPermissionsModeDisabled, +} from '../utils/permissions/permissionSetup.js' +import { applySettingsChange } from '../utils/settings/applySettingsChange.js' +import type { SettingSource } from '../utils/settings/constants.js' +import { createStore } from './store.js' // DCE: voice context is ant-only. External builds get a passthrough. /* eslint-disable @typescript-eslint/no-require-imports */ -const VoiceProvider: (props: { - children: React.ReactNode; -}) => React.ReactNode = feature('VOICE_MODE') ? require('../context/voice.js').VoiceProvider : ({ - children -}) => children; +const VoiceProvider: (props: { children: React.ReactNode }) => React.ReactNode = + feature('VOICE_MODE') + ? require('../context/voice.js').VoiceProvider + : ({ children }) => children /* eslint-enable @typescript-eslint/no-require-imports */ -import { type AppState, type AppStateStore, getDefaultAppState } from './AppStateStore.js'; +import { + type AppState, + type AppStateStore, + getDefaultAppState, +} from './AppStateStore.js' // TODO: Remove these re-exports once all callers import directly from // ./AppStateStore.js. Kept for back-compat during migration so .ts callers // can incrementally move off the .tsx import and stop pulling React. -export { type AppState, type AppStateStore, type CompletionBoundary, getDefaultAppState, IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState } from './AppStateStore.js'; -export const AppStoreContext = React.createContext(null); +export { + type AppState, + type AppStateStore, + type CompletionBoundary, + getDefaultAppState, + IDLE_SPECULATION_STATE, + type SpeculationResult, + type SpeculationState, +} from './AppStateStore.js' + +export const AppStoreContext = React.createContext(null) + type Props = { - children: React.ReactNode; - initialState?: AppState; - onChangeAppState?: (args: { - newState: AppState; - oldState: AppState; - }) => void; -}; -const HasAppStateContext = React.createContext(false); -export function AppStateProvider(t0) { - const $ = _c(13); - const { - children, - initialState, - onChangeAppState - } = t0; - const hasAppStateContext = useContext(HasAppStateContext); + children: React.ReactNode + initialState?: AppState + onChangeAppState?: (args: { newState: AppState; oldState: AppState }) => void +} + +const HasAppStateContext = React.createContext(false) + +export function AppStateProvider({ + children, + initialState, + onChangeAppState, +}: Props): React.ReactNode { + // Don't allow nested AppStateProviders. + const hasAppStateContext = useContext(HasAppStateContext) if (hasAppStateContext) { - throw new Error("AppStateProvider can not be nested within another AppStateProvider"); + throw new Error( + 'AppStateProvider can not be nested within another AppStateProvider', + ) } - let t1; - if ($[0] !== initialState || $[1] !== onChangeAppState) { - t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState); - $[0] = initialState; - $[1] = onChangeAppState; - $[2] = t1; - } else { - t1 = $[2]; - } - const [store] = useState(t1); - let t2; - if ($[3] !== store) { - t2 = () => { - const { - toolPermissionContext - } = store.getState(); - if (toolPermissionContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) { - logForDebugging("Disabling bypass permissions mode on mount (remote settings loaded before mount)"); - store.setState(_temp); - } - }; - $[3] = store; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = []; - $[5] = t3; - } else { - t3 = $[5]; - } - useEffect(t2, t3); - let t4; - if ($[6] !== store.setState) { - t4 = source => applySettingsChange(source, store.setState); - $[6] = store.setState; - $[7] = t4; - } else { - t4 = $[7]; - } - const onSettingsChange = useEffectEvent(t4); - useSettingsChange(onSettingsChange); - let t5; - if ($[8] !== children) { - t5 = {children}; - $[8] = children; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== store || $[11] !== t5) { - t6 = {t5}; - $[10] = store; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; -} -function _temp(prev) { - return { - ...prev, - toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext) - }; + + // Store is created once and never changes -- stable context value means + // the provider never triggers re-renders. Consumers subscribe to slices + // via useSyncExternalStore in useAppState(selector). + const [store] = useState(() => + createStore( + initialState ?? getDefaultAppState(), + onChangeAppState, + ), + ) + + // Check on mount if bypass mode should be disabled + // This handles the race condition where remote settings load BEFORE this component mounts, + // meaning the settings change notification was sent when no listeners were subscribed. + // On subsequent sessions, the cached remote-settings.json is read during initial setup, + // but on the first session the remote fetch may complete before React mounts. + useEffect(() => { + const { toolPermissionContext } = store.getState() + if ( + toolPermissionContext.isBypassPermissionsModeAvailable && + isBypassPermissionsModeDisabled() + ) { + logForDebugging( + 'Disabling bypass permissions mode on mount (remote settings loaded before mount)', + ) + store.setState(prev => ({ + ...prev, + toolPermissionContext: createDisabledBypassPermissionsContext( + prev.toolPermissionContext, + ), + })) + } + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect + }, []) + + // Listen for external settings changes and sync to AppState. + // This ensures file watcher changes propagate through the app -- + // shared with the headless/SDK path via applySettingsChange. + const onSettingsChange = useEffectEvent((source: SettingSource) => + applySettingsChange(source, store.setState), + ) + useSettingsChange(onSettingsChange) + + return ( + + + + {children} + + + + ) } + function useAppStore(): AppStateStore { // eslint-disable-next-line react-hooks/rules-of-hooks - const store = useContext(AppStoreContext); + const store = useContext(AppStoreContext) if (!store) { - throw new ReferenceError('useAppState/useSetAppState cannot be called outside of an '); + throw new ReferenceError( + 'useAppState/useSetAppState cannot be called outside of an ', + ) } - return store; + return store } /** @@ -139,27 +147,23 @@ function useAppStore(): AppStateStore { * const { text, promptId } = useAppState(s => s.promptSuggestion) // good * ``` */ -export function useAppState(selector: (state: AppState) => R): R { - const $ = _c(3); - const store = useAppStore(); - let t0; - if ($[0] !== selector || $[1] !== store) { - t0 = () => { - const state = store.getState(); - const selected = selector(state); - if (false && state === selected) { - throw new Error(`Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`); - } - return selected; - }; - $[0] = selector; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; +export function useAppState(selector: (state: AppState) => T): T { + const store = useAppStore() + + const get = () => { + const state = store.getState() + const selected = selector(state) + + if (process.env.USER_TYPE === 'ant' && state === selected) { + throw new Error( + `Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`, + ) + } + + return selected } - const get = t0; - return useSyncExternalStore(store.subscribe, get, get); + + return useSyncExternalStore(store.subscribe, get, get) } /** @@ -167,33 +171,30 @@ export function useAppState(selector: (state: AppState) => R): R { * Returns a stable reference that never changes -- components using only * this hook will never re-render from state changes. */ -export function useSetAppState() { - return useAppStore().setState; +export function useSetAppState(): ( + updater: (prev: AppState) => AppState, +) => void { + return useAppStore().setState } /** * Get the store directly (for passing getState/setState to non-React code). */ -export function useAppStateStore() { - return useAppStore(); +export function useAppStateStore(): AppStateStore { + return useAppStore() } -const NOOP_SUBSCRIBE = () => () => {}; + +const NOOP_SUBSCRIBE = () => () => {} /** * Safe version of useAppState that returns undefined if called outside of AppStateProvider. * Useful for components that may be rendered in contexts where AppStateProvider isn't available. */ -export function useAppStateMaybeOutsideOfProvider(selector: (state: AppState) => R): R | undefined { - const $ = _c(3); - const store = useContext(AppStoreContext); - let t0; - if ($[0] !== selector || $[1] !== store) { - t0 = () => store ? selector(store.getState()) : undefined; - $[0] = selector; - $[1] = store; - $[2] = t0; - } else { - t0 = $[2]; - } - return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, t0); +export function useAppStateMaybeOutsideOfProvider( + selector: (state: AppState) => T, +): T | undefined { + const store = useContext(AppStoreContext) + return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, () => + store ? selector(store.getState()) : undefined, + ) } diff --git a/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx b/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx index 6c78aa032..0fbdc1052 100644 --- a/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +++ b/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx @@ -9,14 +9,19 @@ * 4. Can be idle (waiting for work) or active (processing) */ -import { isTerminalTaskStatus, type SetAppState, type Task, type TaskStateBase } from '../../Task.js'; -import type { Message } from '../../types/message.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { createUserMessage } from '../../utils/messages.js'; -import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js'; -import { updateTaskState } from '../../utils/task/framework.js'; -import type { InProcessTeammateTaskState } from './types.js'; -import { appendCappedMessage, isInProcessTeammateTask } from './types.js'; +import { + isTerminalTaskStatus, + type SetAppState, + type Task, + type TaskStateBase, +} from '../../Task.js' +import type { Message } from '../../types/message.js' +import { logForDebugging } from '../../utils/debug.js' +import { createUserMessage } from '../../utils/messages.js' +import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js' +import { updateTaskState } from '../../utils/task/framework.js' +import type { InProcessTeammateTaskState } from './types.js' +import { appendCappedMessage, isInProcessTeammateTask } from './types.js' /** * InProcessTeammateTask - Handles in-process teammate execution. @@ -25,39 +30,48 @@ export const InProcessTeammateTask: Task = { name: 'InProcessTeammateTask', type: 'in_process_teammate', async kill(taskId, setAppState) { - killInProcessTeammate(taskId, setAppState); - } -}; + killInProcessTeammate(taskId, setAppState) + }, +} /** * Request shutdown for a teammate. */ -export function requestTeammateShutdown(taskId: string, setAppState: SetAppState): void { +export function requestTeammateShutdown( + taskId: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running' || task.shutdownRequested) { - return task; + return task } + return { ...task, - shutdownRequested: true - }; - }); + shutdownRequested: true, + } + }) } /** * Append a message to a teammate's conversation history. * Used for zoomed view to show the teammate's conversation. */ -export function appendTeammateMessage(taskId: string, message: Message, setAppState: SetAppState): void { +export function appendTeammateMessage( + taskId: string, + message: Message, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } + return { ...task, - messages: appendCappedMessage(task.messages, message) - }; - }); + messages: appendCappedMessage(task.messages, message), + } + }) } /** @@ -65,22 +79,30 @@ export function appendTeammateMessage(taskId: string, message: Message, setAppSt * Used when viewing a teammate's transcript to send typed messages to them. * Also adds the message to task.messages so it appears immediately in the transcript. */ -export function injectUserMessageToTeammate(taskId: string, message: string, setAppState: SetAppState): void { +export function injectUserMessageToTeammate( + taskId: string, + message: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { // Allow message injection when teammate is running or idle (waiting for input) // Only reject if teammate is in a terminal state if (isTerminalTaskStatus(task.status)) { - logForDebugging(`Dropping message for teammate task ${taskId}: task status is "${task.status}"`); - return task; + logForDebugging( + `Dropping message for teammate task ${taskId}: task status is "${task.status}"`, + ) + return task } + return { ...task, pendingUserMessages: [...task.pendingUserMessages, message], - messages: appendCappedMessage(task.messages, createUserMessage({ - content: message - })) - }; - }); + messages: appendCappedMessage( + task.messages, + createUserMessage({ content: message }), + ), + } + }) } /** @@ -89,29 +111,34 @@ export function injectUserMessageToTeammate(taskId: string, message: string, set * with the same agentId exist. * Returns undefined if not found. */ -export function findTeammateTaskByAgentId(agentId: string, tasks: Record): InProcessTeammateTaskState | undefined { - let fallback: InProcessTeammateTaskState | undefined; +export function findTeammateTaskByAgentId( + agentId: string, + tasks: Record, +): InProcessTeammateTaskState | undefined { + let fallback: InProcessTeammateTaskState | undefined for (const task of Object.values(tasks)) { if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) { // Prefer running tasks in case old killed tasks still exist in AppState // alongside new running ones with the same agentId if (task.status === 'running') { - return task; + return task } // Keep first match as fallback in case no running task exists if (!fallback) { - fallback = task; + fallback = task } } } - return fallback; + return fallback } /** * Get all in-process teammate tasks from AppState. */ -export function getAllInProcessTeammateTasks(tasks: Record): InProcessTeammateTaskState[] { - return Object.values(tasks).filter(isInProcessTeammateTask); +export function getAllInProcessTeammateTasks( + tasks: Record, +): InProcessTeammateTaskState[] { + return Object.values(tasks).filter(isInProcessTeammateTask) } /** @@ -120,6 +147,10 @@ export function getAllInProcessTeammateTasks(tasks: Record): InProcessTeammateTaskState[] { - return getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running').sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)); +export function getRunningTeammatesSorted( + tasks: Record, +): InProcessTeammateTaskState[] { + return getAllInProcessTeammateTasks(tasks) + .filter(t => t.status === 'running') + .sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)) } diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index 2eb9d6f9d..af26854c0 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -1,62 +1,89 @@ -import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; -import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG, WORKTREE_BRANCH_TAG, WORKTREE_PATH_TAG, WORKTREE_TAG } from '../../constants/xml.js'; -import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; -import type { AppState } from '../../state/AppState.js'; -import type { SetAppState, Task, TaskStateBase } from '../../Task.js'; -import { createTaskStateBase } from '../../Task.js'; -import type { Tools } from '../../Tool.js'; -import { findToolByName } from '../../Tool.js'; -import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js'; -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; -import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js'; -import { asAgentId } from '../../types/ids.js'; -import type { Message } from '../../types/message.js'; -import { createAbortController, createChildAbortController } from '../../utils/abortController.js'; -import { registerCleanup } from '../../utils/cleanupRegistry.js'; -import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js'; -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; -import { getAgentTranscriptPath } from '../../utils/sessionStorage.js'; -import { evictTaskOutput, getTaskOutputPath, initTaskOutputAsSymlink } from '../../utils/task/diskOutput.js'; -import { PANEL_GRACE_MS, registerTask, updateTaskState } from '../../utils/task/framework.js'; -import { emitTaskProgress } from '../../utils/task/sdkProgress.js'; -import type { TaskState } from '../types.js'; +import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js' +import { + OUTPUT_FILE_TAG, + STATUS_TAG, + SUMMARY_TAG, + TASK_ID_TAG, + TASK_NOTIFICATION_TAG, + TOOL_USE_ID_TAG, + WORKTREE_BRANCH_TAG, + WORKTREE_PATH_TAG, + WORKTREE_TAG, +} from '../../constants/xml.js' +import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js' +import type { AppState } from '../../state/AppState.js' +import type { SetAppState, Task, TaskStateBase } from '../../Task.js' +import { createTaskStateBase } from '../../Task.js' +import type { Tools } from '../../Tool.js' +import { findToolByName } from '../../Tool.js' +import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' +import { asAgentId } from '../../types/ids.js' +import type { Message } from '../../types/message.js' +import { + createAbortController, + createChildAbortController, +} from '../../utils/abortController.js' +import { registerCleanup } from '../../utils/cleanupRegistry.js' +import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js' +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' +import { getAgentTranscriptPath } from '../../utils/sessionStorage.js' +import { + evictTaskOutput, + getTaskOutputPath, + initTaskOutputAsSymlink, +} from '../../utils/task/diskOutput.js' +import { + PANEL_GRACE_MS, + registerTask, + updateTaskState, +} from '../../utils/task/framework.js' +import { emitTaskProgress } from '../../utils/task/sdkProgress.js' +import type { TaskState } from '../types.js' + export type ToolActivity = { - toolName: string; - input: Record; + toolName: string + input: Record /** Pre-computed activity description from the tool, e.g. "Reading src/foo.ts" */ - activityDescription?: string; + activityDescription?: string /** Pre-computed: true if this is a search operation (Grep, Glob, etc.) */ - isSearch?: boolean; + isSearch?: boolean /** Pre-computed: true if this is a read operation (Read, cat, etc.) */ - isRead?: boolean; -}; + isRead?: boolean +} + export type AgentProgress = { - toolUseCount: number; - tokenCount: number; - lastActivity?: ToolActivity; - recentActivities?: ToolActivity[]; - summary?: string; -}; -const MAX_RECENT_ACTIVITIES = 5; + toolUseCount: number + tokenCount: number + lastActivity?: ToolActivity + recentActivities?: ToolActivity[] + summary?: string +} + +const MAX_RECENT_ACTIVITIES = 5 + export type ProgressTracker = { - toolUseCount: number; + toolUseCount: number // Track input and output separately to avoid double-counting. // input_tokens in Claude API is cumulative per turn (includes all previous context), // so we keep the latest value. output_tokens is per-turn, so we sum those. - latestInputTokens: number; - cumulativeOutputTokens: number; - recentActivities: ToolActivity[]; -}; + latestInputTokens: number + cumulativeOutputTokens: number + recentActivities: ToolActivity[] +} + export function createProgressTracker(): ProgressTracker { return { toolUseCount: 0, latestInputTokens: 0, cumulativeOutputTokens: 0, - recentActivities: [] - }; + recentActivities: [], + } } + export function getTokenCountFromTracker(tracker: ProgressTracker): number { - return tracker.latestInputTokens + tracker.cumulativeOutputTokens; + return tracker.latestInputTokens + tracker.cumulativeOutputTokens } /** @@ -64,91 +91,120 @@ export function getTokenCountFromTracker(tracker: ProgressTracker): number { * for a given tool name and input. Used to pre-compute descriptions * from Tool.getActivityDescription() at recording time. */ -export type ActivityDescriptionResolver = (toolName: string, input: Record) => string | undefined; -export function updateProgressFromMessage(tracker: ProgressTracker, message: Message, resolveActivityDescription?: ActivityDescriptionResolver, tools?: Tools): void { +export type ActivityDescriptionResolver = ( + toolName: string, + input: Record, +) => string | undefined + +export function updateProgressFromMessage( + tracker: ProgressTracker, + message: Message, + resolveActivityDescription?: ActivityDescriptionResolver, + tools?: Tools, +): void { if (message.type !== 'assistant') { - return; + return } - const usage = message.message.usage as { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }; + const usage = message.message.usage // Keep latest input (it's cumulative in the API), sum outputs - tracker.latestInputTokens = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); - tracker.cumulativeOutputTokens += usage.output_tokens; - const contentBlocks = message.message.content as Array<{ type: string; name?: string; input?: unknown }>; - for (const content of contentBlocks) { + tracker.latestInputTokens = + usage.input_tokens + + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + tracker.cumulativeOutputTokens += usage.output_tokens + for (const content of message.message.content) { if (content.type === 'tool_use') { - tracker.toolUseCount++; + tracker.toolUseCount++ // Omit StructuredOutput from preview - it's an internal tool if (content.name !== SYNTHETIC_OUTPUT_TOOL_NAME) { - const input = content.input as Record; - const classification = tools ? getToolSearchOrReadInfo(content.name!, input, tools) : undefined; + const input = content.input as Record + const classification = tools + ? getToolSearchOrReadInfo(content.name, input, tools) + : undefined tracker.recentActivities.push({ - toolName: content.name!, + toolName: content.name, input, - activityDescription: resolveActivityDescription?.(content.name!, input), + activityDescription: resolveActivityDescription?.( + content.name, + input, + ), isSearch: classification?.isSearch, - isRead: classification?.isRead - }); + isRead: classification?.isRead, + }) } } } while (tracker.recentActivities.length > MAX_RECENT_ACTIVITIES) { - tracker.recentActivities.shift(); + tracker.recentActivities.shift() } } + export function getProgressUpdate(tracker: ProgressTracker): AgentProgress { return { toolUseCount: tracker.toolUseCount, tokenCount: getTokenCountFromTracker(tracker), - lastActivity: tracker.recentActivities.length > 0 ? tracker.recentActivities[tracker.recentActivities.length - 1] : undefined, - recentActivities: [...tracker.recentActivities] - }; + lastActivity: + tracker.recentActivities.length > 0 + ? tracker.recentActivities[tracker.recentActivities.length - 1] + : undefined, + recentActivities: [...tracker.recentActivities], + } } /** * Creates an ActivityDescriptionResolver from a tools list. * Looks up the tool by name and calls getActivityDescription if available. */ -export function createActivityDescriptionResolver(tools: Tools): ActivityDescriptionResolver { +export function createActivityDescriptionResolver( + tools: Tools, +): ActivityDescriptionResolver { return (toolName, input) => { - const tool = findToolByName(tools, toolName); - return tool?.getActivityDescription?.(input) ?? undefined; - }; + const tool = findToolByName(tools, toolName) + return tool?.getActivityDescription?.(input) ?? undefined + } } + export type LocalAgentTaskState = TaskStateBase & { - type: 'local_agent'; - agentId: string; - prompt: string; - selectedAgent?: AgentDefinition; - agentType: string; - model?: string; - abortController?: AbortController; - unregisterCleanup?: () => void; - error?: string; - result?: AgentToolResult; - progress?: AgentProgress; - retrieved: boolean; - messages?: Message[]; + type: 'local_agent' + agentId: string + prompt: string + selectedAgent?: AgentDefinition + agentType: string + model?: string + abortController?: AbortController + unregisterCleanup?: () => void + error?: string + result?: AgentToolResult + progress?: AgentProgress + retrieved: boolean + messages?: Message[] // Track what we last reported for computing deltas - lastReportedToolCount: number; - lastReportedTokenCount: number; + lastReportedToolCount: number + lastReportedTokenCount: number // Whether the task has been backgrounded (false = foreground running, true = backgrounded) - isBackgrounded: boolean; + isBackgrounded: boolean // Messages queued mid-turn via SendMessage, drained at tool-round boundaries - pendingMessages: string[]; + pendingMessages: string[] // UI is holding this task: blocks eviction, enables stream-append, triggers // disk bootstrap. Set by enterTeammateView. Separate from viewingAgentTaskId // (which is "what am I LOOKING at") — retain is "what am I HOLDING." - retain: boolean; + retain: boolean // Bootstrap has read the sidechain JSONL and UUID-merged into messages. // One-shot per retain cycle; stream appends from there. - diskLoaded: boolean; + diskLoaded: boolean // Panel visibility deadline. undefined = no deadline (running or retained); // timestamp = hide + GC-eligible after this time. Set at terminal transition // and on unselect; cleared on retain. - evictAfter?: number; -}; + evictAfter?: number +} + export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { - return typeof task === 'object' && task !== null && 'type' in task && task.type === 'local_agent'; + return ( + typeof task === 'object' && + task !== null && + 'type' in task && + task.type === 'local_agent' + ) } /** @@ -158,13 +214,18 @@ export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { * the gate changes, change it here. */ export function isPanelAgentTask(t: unknown): t is LocalAgentTaskState { - return isLocalAgentTask(t) && t.agentType !== 'main-session'; + return isLocalAgentTask(t) && t.agentType !== 'main-session' } -export function queuePendingMessage(taskId: string, msg: string, setAppState: (f: (prev: AppState) => AppState) => void): void { + +export function queuePendingMessage( + taskId: string, + msg: string, + setAppState: (f: (prev: AppState) => AppState) => void, +): void { updateTaskState(taskId, setAppState, task => ({ ...task, - pendingMessages: [...task.pendingMessages, msg] - })); + pendingMessages: [...task.pendingMessages, msg], + })) } /** @@ -173,23 +234,32 @@ export function queuePendingMessage(taskId: string, msg: string, setAppState: (f * queuePendingMessage and resumeAgentBackground route the prompt to the * agent's API input but don't touch the display. */ -export function appendMessageToLocalAgent(taskId: string, message: Message, setAppState: (f: (prev: AppState) => AppState) => void): void { +export function appendMessageToLocalAgent( + taskId: string, + message: Message, + setAppState: (f: (prev: AppState) => AppState) => void, +): void { updateTaskState(taskId, setAppState, task => ({ ...task, - messages: [...(task.messages ?? []), message] - })); + messages: [...(task.messages ?? []), message], + })) } -export function drainPendingMessages(taskId: string, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): string[] { - const task = getAppState().tasks[taskId]; + +export function drainPendingMessages( + taskId: string, + getAppState: () => AppState, + setAppState: (f: (prev: AppState) => AppState) => void, +): string[] { + const task = getAppState().tasks[taskId] if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) { - return []; + return [] } - const drained = task.pendingMessages; + const drained = task.pendingMessages updateTaskState(taskId, setAppState, t => ({ ...t, - pendingMessages: [] - })); - return drained; + pendingMessages: [], + })) + return drained } /** @@ -205,61 +275,74 @@ export function enqueueAgentNotification({ usage, toolUseId, worktreePath, - worktreeBranch + worktreeBranch, }: { - taskId: string; - description: string; - status: 'completed' | 'failed' | 'killed'; - error?: string; - setAppState: SetAppState; - finalMessage?: string; + taskId: string + description: string + status: 'completed' | 'failed' | 'killed' + error?: string + setAppState: SetAppState + finalMessage?: string usage?: { - totalTokens: number; - toolUses: number; - durationMs: number; - }; - toolUseId?: string; - worktreePath?: string; - worktreeBranch?: string; + totalTokens: number + toolUses: number + durationMs: number + } + toolUseId?: string + worktreePath?: string + worktreeBranch?: string }): void { // Atomically check and set notified flag to prevent duplicate notifications. // If the task was already marked as notified (e.g., by TaskStopTool), skip // enqueueing to avoid sending redundant messages to the model. - let shouldEnqueue = false; + let shouldEnqueue = false updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } - shouldEnqueue = true; + shouldEnqueue = true return { ...task, - notified: true - }; - }); + notified: true, + } + }) + if (!shouldEnqueue) { - return; + return } // Abort any active speculation — background task state changed, so speculated // results may reference stale task output. The prompt suggestion text is // preserved; only the pre-computed response is discarded. - abortSpeculation(setAppState); - const summary = status === 'completed' ? `Agent "${description}" completed` : status === 'failed' ? `Agent "${description}" failed: ${error || 'Unknown error'}` : `Agent "${description}" was stopped`; - const outputPath = getTaskOutputPath(taskId); - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; - const resultSection = finalMessage ? `\n${finalMessage}` : ''; - const usageSection = usage ? `\n${usage.totalTokens}${usage.toolUses}${usage.durationMs}` : ''; - const worktreeSection = worktreePath ? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}` : ''}` : ''; + abortSpeculation(setAppState) + + const summary = + status === 'completed' + ? `Agent "${description}" completed` + : status === 'failed' + ? `Agent "${description}" failed: ${error || 'Unknown error'}` + : `Agent "${description}" was stopped` + + const outputPath = getTaskOutputPath(taskId) + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' + const resultSection = finalMessage ? `\n${finalMessage}` : '' + const usageSection = usage + ? `\n${usage.totalTokens}${usage.toolUses}${usage.durationMs}` + : '' + const worktreeSection = worktreePath + ? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}` : ''}` + : '' + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>${summary}${resultSection}${usageSection}${worktreeSection} -`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** @@ -271,23 +354,24 @@ export function enqueueAgentNotification({ export const LocalAgentTask: Task = { name: 'LocalAgentTask', type: 'local_agent', + async kill(taskId, setAppState) { - killAsyncAgent(taskId, setAppState); - } -}; + killAsyncAgent(taskId, setAppState) + }, +} /** * Kill an agent task. No-op if already killed/completed. */ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { - let killed = false; + let killed = false updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - killed = true; - task.abortController?.abort(); - task.unregisterCleanup?.(); + killed = true + task.abortController?.abort() + task.unregisterCleanup?.() return { ...task, status: 'killed', @@ -295,11 +379,11 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, unregisterCleanup: undefined, - selectedAgent: undefined - }; - }); + selectedAgent: undefined, + } + }) if (killed) { - void evictTaskOutput(taskId); + void evictTaskOutput(taskId) } } @@ -307,10 +391,13 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { * Kill all running agent tasks. * Used by ESC cancellation in coordinator mode to stop all subagents. */ -export function killAllRunningAgentTasks(tasks: Record, setAppState: SetAppState): void { +export function killAllRunningAgentTasks( + tasks: Record, + setAppState: SetAppState, +): void { for (const [taskId, task] of Object.entries(tasks)) { if (task.type === 'local_agent' && task.status === 'running') { - killAsyncAgent(taskId, setAppState); + killAsyncAgent(taskId, setAppState) } } } @@ -320,16 +407,19 @@ export function killAllRunningAgentTasks(tasks: Record, setAp * Used by chat:killAgents bulk kill to suppress per-agent async notifications * when a single aggregate message is sent instead. */ -export function markAgentsNotified(taskId: string, setAppState: SetAppState): void { +export function markAgentsNotified( + taskId: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } return { ...task, - notified: true - }; - }); + notified: true, + } + }) } /** @@ -337,64 +427,70 @@ export function markAgentsNotified(taskId: string, setAppState: SetAppState): vo * Preserves the existing summary field so that background summarization * results are not clobbered by progress updates from assistant messages. */ -export function updateAgentProgress(taskId: string, progress: AgentProgress, setAppState: SetAppState): void { +export function updateAgentProgress( + taskId: string, + progress: AgentProgress, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - const existingSummary = task.progress?.summary; + + const existingSummary = task.progress?.summary return { ...task, - progress: existingSummary ? { - ...progress, - summary: existingSummary - } : progress - }; - }); + progress: existingSummary + ? { ...progress, summary: existingSummary } + : progress, + } + }) } /** * Update the background summary for an agent task. * Called by the periodic summarization service to store a 1-2 sentence progress summary. */ -export function updateAgentSummary(taskId: string, summary: string, setAppState: SetAppState): void { +export function updateAgentSummary( + taskId: string, + summary: string, + setAppState: SetAppState, +): void { let captured: { - tokenCount: number; - toolUseCount: number; - startTime: number; - toolUseId: string | undefined; - } | null = null; + tokenCount: number + toolUseCount: number + startTime: number + toolUseId: string | undefined + } | null = null + updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } + captured = { tokenCount: task.progress?.tokenCount ?? 0, toolUseCount: task.progress?.toolUseCount ?? 0, startTime: task.startTime, - toolUseId: task.toolUseId - }; + toolUseId: task.toolUseId, + } + return { ...task, progress: { ...task.progress, toolUseCount: task.progress?.toolUseCount ?? 0, tokenCount: task.progress?.tokenCount ?? 0, - summary - } - }; - }); + summary, + }, + } + }) // Emit summary to SDK consumers (e.g. VS Code subagent panel). No-op in TUI. // Gate on the SDK option so coordinator-mode sessions without the flag don't // leak summary events to consumers who didn't opt in. if (captured && getSdkAgentProgressSummariesEnabled()) { - const { - tokenCount, - toolUseCount, - startTime, - toolUseId - } = captured; + const { tokenCount, toolUseCount, startTime, toolUseId } = captured emitTaskProgress({ taskId, toolUseId, @@ -402,21 +498,26 @@ export function updateAgentSummary(taskId: string, summary: string, setAppState: startTime, totalTokens: tokenCount, toolUses: toolUseCount, - summary - }); + summary, + }) } } /** * Complete an agent task with result. */ -export function completeAgentTask(result: AgentToolResult, setAppState: SetAppState): void { - const taskId = result.agentId; +export function completeAgentTask( + result: AgentToolResult, + setAppState: SetAppState, +): void { + const taskId = result.agentId updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - task.unregisterCleanup?.(); + + task.unregisterCleanup?.() + return { ...task, status: 'completed', @@ -425,22 +526,28 @@ export function completeAgentTask(result: AgentToolResult, setAppState: SetAppSt evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, unregisterCleanup: undefined, - selectedAgent: undefined - }; - }); - void evictTaskOutput(taskId); + selectedAgent: undefined, + } + }) + void evictTaskOutput(taskId) // Note: Notification is sent by AgentTool via enqueueAgentNotification } /** * Fail an agent task with error. */ -export function failAgentTask(taskId: string, error: string, setAppState: SetAppState): void { +export function failAgentTask( + taskId: string, + error: string, + setAppState: SetAppState, +): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - task.unregisterCleanup?.(); + + task.unregisterCleanup?.() + return { ...task, status: 'failed', @@ -449,10 +556,10 @@ export function failAgentTask(taskId: string, error: string, setAppState: SetApp evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, unregisterCleanup: undefined, - selectedAgent: undefined - }; - }); - void evictTaskOutput(taskId); + selectedAgent: undefined, + } + }) + void evictTaskOutput(taskId) // Note: Notification is sent by AgentTool via enqueueAgentNotification } @@ -471,20 +578,26 @@ export function registerAsyncAgent({ selectedAgent, setAppState, parentAbortController, - toolUseId + toolUseId, }: { - agentId: string; - description: string; - prompt: string; - selectedAgent: AgentDefinition; - setAppState: SetAppState; - parentAbortController?: AbortController; - toolUseId?: string; + agentId: string + description: string + prompt: string + selectedAgent: AgentDefinition + setAppState: SetAppState + parentAbortController?: AbortController + toolUseId?: string }): LocalAgentTaskState { - void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); + void initTaskOutputAsSymlink( + agentId, + getAgentTranscriptPath(asAgentId(agentId)), + ) // Create abort controller - if parent provided, create child that auto-aborts with parent - const abortController = parentAbortController ? createChildAbortController(parentAbortController) : createAbortController(); + const abortController = parentAbortController + ? createChildAbortController(parentAbortController) + : createAbortController() + const taskState: LocalAgentTaskState = { ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), type: 'local_agent', @@ -497,27 +610,28 @@ export function registerAsyncAgent({ retrieved: false, lastReportedToolCount: 0, lastReportedTokenCount: 0, - isBackgrounded: true, - // registerAsyncAgent immediately backgrounds + isBackgrounded: true, // registerAsyncAgent immediately backgrounds pendingMessages: [], retain: false, - diskLoaded: false - }; + diskLoaded: false, + } // Register cleanup handler const unregisterCleanup = registerCleanup(async () => { - killAsyncAgent(agentId, setAppState); - }); - taskState.unregisterCleanup = unregisterCleanup; + killAsyncAgent(agentId, setAppState) + }) + + taskState.unregisterCleanup = unregisterCleanup // Register task in AppState - registerTask(taskState, setAppState); - return taskState; + registerTask(taskState, setAppState) + + return taskState } // Map of taskId -> resolve function for background signals // When backgroundAgentTask is called, it resolves the corresponding promise -const backgroundSignalResolvers = new Map void>(); +const backgroundSignalResolvers = new Map void>() /** * Register a foreground agent task that could be backgrounded later. @@ -531,25 +645,31 @@ export function registerAgentForeground({ selectedAgent, setAppState, autoBackgroundMs, - toolUseId + toolUseId, }: { - agentId: string; - description: string; - prompt: string; - selectedAgent: AgentDefinition; - setAppState: SetAppState; - autoBackgroundMs?: number; - toolUseId?: string; + agentId: string + description: string + prompt: string + selectedAgent: AgentDefinition + setAppState: SetAppState + autoBackgroundMs?: number + toolUseId?: string }): { - taskId: string; - backgroundSignal: Promise; - cancelAutoBackground?: () => void; + taskId: string + backgroundSignal: Promise + cancelAutoBackground?: () => void } { - void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); - const abortController = createAbortController(); + void initTaskOutputAsSymlink( + agentId, + getAgentTranscriptPath(asAgentId(agentId)), + ) + + const abortController = createAbortController() + const unregisterCleanup = registerCleanup(async () => { - killAsyncAgent(agentId, setAppState); - }); + killAsyncAgent(agentId, setAppState) + }) + const taskState: LocalAgentTaskState = { ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), type: 'local_agent', @@ -563,121 +683,122 @@ export function registerAgentForeground({ retrieved: false, lastReportedToolCount: 0, lastReportedTokenCount: 0, - isBackgrounded: false, - // Not yet backgrounded - running in foreground + isBackgrounded: false, // Not yet backgrounded - running in foreground pendingMessages: [], retain: false, - diskLoaded: false - }; + diskLoaded: false, + } // Create background signal promise - let resolveBackgroundSignal: () => void; + let resolveBackgroundSignal: () => void const backgroundSignal = new Promise(resolve => { - resolveBackgroundSignal = resolve; - }); - backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!); - registerTask(taskState, setAppState); + resolveBackgroundSignal = resolve + }) + backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!) + + registerTask(taskState, setAppState) // Auto-background after timeout if configured - let cancelAutoBackground: (() => void) | undefined; + let cancelAutoBackground: (() => void) | undefined if (autoBackgroundMs !== undefined && autoBackgroundMs > 0) { - const timer = setTimeout((setAppState, agentId) => { - // Mark task as backgrounded and resolve the signal - setAppState(prev => { - const prevTask = prev.tasks[agentId]; - if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) { - return prev; - } - return { - ...prev, - tasks: { - ...prev.tasks, - [agentId]: { - ...prevTask, - isBackgrounded: true - } + const timer = setTimeout( + (setAppState, agentId) => { + // Mark task as backgrounded and resolve the signal + setAppState(prev => { + const prevTask = prev.tasks[agentId] + if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) { + return prev } - }; - }); - const resolver = backgroundSignalResolvers.get(agentId); - if (resolver) { - resolver(); - backgroundSignalResolvers.delete(agentId); - } - }, autoBackgroundMs, setAppState, agentId); - cancelAutoBackground = () => clearTimeout(timer); + return { + ...prev, + tasks: { + ...prev.tasks, + [agentId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) + const resolver = backgroundSignalResolvers.get(agentId) + if (resolver) { + resolver() + backgroundSignalResolvers.delete(agentId) + } + }, + autoBackgroundMs, + setAppState, + agentId, + ) + cancelAutoBackground = () => clearTimeout(timer) } - return { - taskId: agentId, - backgroundSignal, - cancelAutoBackground - }; + + return { taskId: agentId, backgroundSignal, cancelAutoBackground } } /** * Background a specific foreground agent task. * @returns true if backgrounded successfully, false otherwise */ -export function backgroundAgentTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { - const state = getAppState(); - const task = state.tasks[taskId]; +export function backgroundAgentTask( + taskId: string, + getAppState: () => AppState, + setAppState: SetAppState, +): boolean { + const state = getAppState() + const task = state.tasks[taskId] if (!isLocalAgentTask(task) || task.isBackgrounded) { - return false; + return false } // Update state to mark as backgrounded setAppState(prev => { - const prevTask = prev.tasks[taskId]; + const prevTask = prev.tasks[taskId] if (!isLocalAgentTask(prevTask)) { - return prev; + return prev } return { ...prev, tasks: { ...prev.tasks, - [taskId]: { - ...prevTask, - isBackgrounded: true - } - } - }; - }); + [taskId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) // Resolve the background signal to interrupt the agent loop - const resolver = backgroundSignalResolvers.get(taskId); + const resolver = backgroundSignalResolvers.get(taskId) if (resolver) { - resolver(); - backgroundSignalResolvers.delete(taskId); + resolver() + backgroundSignalResolvers.delete(taskId) } - return true; + + return true } /** * Unregister a foreground agent task when the agent completes without being backgrounded. */ -export function unregisterAgentForeground(taskId: string, setAppState: SetAppState): void { +export function unregisterAgentForeground( + taskId: string, + setAppState: SetAppState, +): void { // Clean up the background signal resolver - backgroundSignalResolvers.delete(taskId); - let cleanupFn: (() => void) | undefined; + backgroundSignalResolvers.delete(taskId) + + let cleanupFn: (() => void) | undefined + setAppState(prev => { - const task = prev.tasks[taskId]; + const task = prev.tasks[taskId] // Only remove if it's a foreground task (not backgrounded) if (!isLocalAgentTask(task) || task.isBackgrounded) { - return prev; + return prev } // Capture cleanup function to call outside of updater - cleanupFn = task.unregisterCleanup; - const { - [taskId]: removed, - ...rest - } = prev.tasks; - return { - ...prev, - tasks: rest - }; - }); + cleanupFn = task.unregisterCleanup + + const { [taskId]: removed, ...rest } = prev.tasks + return { ...prev, tasks: rest } + }) // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.(); + cleanupFn?.() } diff --git a/src/tasks/LocalShellTask/LocalShellTask.tsx b/src/tasks/LocalShellTask/LocalShellTask.tsx index 595518275..22810bff1 100644 --- a/src/tasks/LocalShellTask/LocalShellTask.tsx +++ b/src/tasks/LocalShellTask/LocalShellTask.tsx @@ -1,83 +1,119 @@ -import { feature } from 'bun:bundle'; -import { stat } from 'fs/promises'; -import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG } from '../../constants/xml.js'; -import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; -import type { AppState } from '../../state/AppState.js'; -import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js'; -import { createTaskStateBase } from '../../Task.js'; -import type { AgentId } from '../../types/ids.js'; -import { registerCleanup } from '../../utils/cleanupRegistry.js'; -import { tailFile } from '../../utils/fsOperations.js'; -import { logError } from '../../utils/log.js'; -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; -import type { ShellCommand } from '../../utils/ShellCommand.js'; -import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { registerTask, updateTaskState } from '../../utils/task/framework.js'; -import { escapeXml } from '../../utils/xml.js'; -import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js'; -import { isMainSessionTask } from '../LocalMainSessionTask.js'; -import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js'; -import { killTask } from './killShellTasks.js'; +import { feature } from 'bun:bundle' +import { stat } from 'fs/promises' +import { + OUTPUT_FILE_TAG, + STATUS_TAG, + SUMMARY_TAG, + TASK_ID_TAG, + TASK_NOTIFICATION_TAG, + TOOL_USE_ID_TAG, +} from '../../constants/xml.js' +import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js' +import type { AppState } from '../../state/AppState.js' +import type { + LocalShellSpawnInput, + SetAppState, + Task, + TaskContext, + TaskHandle, +} from '../../Task.js' +import { createTaskStateBase } from '../../Task.js' +import type { AgentId } from '../../types/ids.js' +import { registerCleanup } from '../../utils/cleanupRegistry.js' +import { tailFile } from '../../utils/fsOperations.js' +import { logError } from '../../utils/log.js' +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' +import type { ShellCommand } from '../../utils/ShellCommand.js' +import { + evictTaskOutput, + getTaskOutputPath, +} from '../../utils/task/diskOutput.js' +import { registerTask, updateTaskState } from '../../utils/task/framework.js' +import { escapeXml } from '../../utils/xml.js' +import { + backgroundAgentTask, + isLocalAgentTask, +} from '../LocalAgentTask/LocalAgentTask.js' +import { isMainSessionTask } from '../LocalMainSessionTask.js' +import { + type BashTaskKind, + isLocalShellTask, + type LocalShellTaskState, +} from './guards.js' +import { killTask } from './killShellTasks.js' /** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */ -export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '; -const STALL_CHECK_INTERVAL_MS = 5_000; -const STALL_THRESHOLD_MS = 45_000; -const STALL_TAIL_BYTES = 1024; +export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command ' + +const STALL_CHECK_INTERVAL_MS = 5_000 +const STALL_THRESHOLD_MS = 45_000 +const STALL_TAIL_BYTES = 1024 // Last-line patterns that suggest a command is blocked waiting for keyboard // input. Used to gate the stall notification — we stay silent on commands that // are merely slow (git log -S, long builds) and only notify when the tail // looks like an interactive prompt the model can act on. See CC-1175. -const PROMPT_PATTERNS = [/\(y\/n\)/i, -// (Y/n), (y/N) -/\[y\/n\]/i, -// [Y/n], [y/N] -/\(yes\/no\)/i, /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, -// directed questions -/Press (any key|Enter)/i, /Continue\?/i, /Overwrite\?/i]; +const PROMPT_PATTERNS = [ + /\(y\/n\)/i, // (Y/n), (y/N) + /\[y\/n\]/i, // [Y/n], [y/N] + /\(yes\/no\)/i, + /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, // directed questions + /Press (any key|Enter)/i, + /Continue\?/i, + /Overwrite\?/i, +] + export function looksLikePrompt(tail: string): boolean { - const lastLine = tail.trimEnd().split('\n').pop() ?? ''; - return PROMPT_PATTERNS.some(p => p.test(lastLine)); + const lastLine = tail.trimEnd().split('\n').pop() ?? '' + return PROMPT_PATTERNS.some(p => p.test(lastLine)) } // Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot // notification if output stops growing and the tail looks like a prompt. -function startStallWatchdog(taskId: string, description: string, kind: BashTaskKind | undefined, toolUseId?: string, agentId?: AgentId): () => void { - if (kind === 'monitor') return () => {}; - const outputPath = getTaskOutputPath(taskId); - let lastSize = 0; - let lastGrowth = Date.now(); - let cancelled = false; +function startStallWatchdog( + taskId: string, + description: string, + kind: BashTaskKind | undefined, + toolUseId?: string, + agentId?: AgentId, +): () => void { + if (kind === 'monitor') return () => {} + const outputPath = getTaskOutputPath(taskId) + let lastSize = 0 + let lastGrowth = Date.now() + let cancelled = false + const timer = setInterval(() => { - void stat(outputPath).then(s => { - if (s.size > lastSize) { - lastSize = s.size; - lastGrowth = Date.now(); - return; - } - if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return; - void tailFile(outputPath, STALL_TAIL_BYTES).then(({ - content - }) => { - if (cancelled) return; - if (!looksLikePrompt(content)) { - // Not a prompt — keep watching. Reset so the next check is - // 45s out instead of re-reading the tail on every tick. - lastGrowth = Date.now(); - return; + void stat(outputPath).then( + s => { + if (s.size > lastSize) { + lastSize = s.size + lastGrowth = Date.now() + return } - // Latch before the async-boundary-visible side effects so an - // overlapping tick's callback sees cancelled=true and bails. - cancelled = true; - clearInterval(timer); - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; - const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`; - // No tag — print.ts treats as a terminal - // signal and an unknown value falls through to 'completed', - // falsely closing the task for SDK consumers. Statusless - // notifications are skipped by the SDK emitter (progress ping). - const message = `<${TASK_NOTIFICATION_TAG}> + if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return + void tailFile(outputPath, STALL_TAIL_BYTES).then( + ({ content }) => { + if (cancelled) return + if (!looksLikePrompt(content)) { + // Not a prompt — keep watching. Reset so the next check is + // 45s out instead of re-reading the tail on every tick. + lastGrowth = Date.now() + return + } + // Latch before the async-boundary-visible side effects so an + // overlapping tick's callback sees cancelled=true and bails. + cancelled = true + clearInterval(timer) + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' + const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input` + // No tag — print.ts treats as a terminal + // signal and an unknown value falls through to 'completed', + // falsely closing the task for SDK consumers. Statusless + // notifications are skipped by the SDK emitter (progress ping). + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${SUMMARY_TAG}>${escapeXml(summary)} @@ -85,47 +121,60 @@ function startStallWatchdog(taskId: string, description: string, kind: BashTaskK Last output: ${content.trimEnd()} -The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification', - priority: 'next', - agentId - }); - }, () => {}); - }, () => {} // File may not exist yet - ); - }, STALL_CHECK_INTERVAL_MS); - timer.unref(); +The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.` + enqueuePendingNotification({ + value: message, + mode: 'task-notification', + priority: 'next', + agentId, + }) + }, + () => {}, + ) + }, + () => {}, // File may not exist yet + ) + }, STALL_CHECK_INTERVAL_MS) + timer.unref() + return () => { - cancelled = true; - clearInterval(timer); - }; + cancelled = true + clearInterval(timer) + } } -function enqueueShellNotification(taskId: string, description: string, status: 'completed' | 'failed' | 'killed', exitCode: number | undefined, setAppState: SetAppState, toolUseId?: string, kind: BashTaskKind = 'bash', agentId?: AgentId): void { + +function enqueueShellNotification( + taskId: string, + description: string, + status: 'completed' | 'failed' | 'killed', + exitCode: number | undefined, + setAppState: SetAppState, + toolUseId?: string, + kind: BashTaskKind = 'bash', + agentId?: AgentId, +): void { // Atomically check and set notified flag to prevent duplicate notifications. // If the task was already marked as notified (e.g., by TaskStopTool), skip // enqueueing to avoid sending redundant messages to the model. - let shouldEnqueue = false; - updateTaskState(taskId, setAppState, task => { + let shouldEnqueue = false + updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } - shouldEnqueue = true; - return { - ...task, - notified: true - }; - }); + shouldEnqueue = true + return { ...task, notified: true } + }) + if (!shouldEnqueue) { - return; + return } // Abort any active speculation — background task state changed, so speculated // results may reference stale task output. The prompt suggestion text is // preserved; only the pre-computed response is discarded. - abortSpeculation(setAppState); - let summary: string; + abortSpeculation(setAppState) + + let summary: string if (feature('MONITOR_TOOL') && kind === 'monitor') { // Monitor is streaming-only (post-#22764) — the script exiting means // the stream ended, not "condition met". Distinct from the bash prefix @@ -133,73 +182,71 @@ function enqueueShellNotification(taskId: string, description: string, status: ' // completed" collapse. switch (status) { case 'completed': - summary = `Monitor "${description}" stream ended`; - break; + summary = `Monitor "${description}" stream ended` + break case 'failed': - summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`; - break; + summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}` + break case 'killed': - summary = `Monitor "${description}" stopped`; - break; + summary = `Monitor "${description}" stopped` + break } } else { switch (status) { case 'completed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`; - break; + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}` + break case 'failed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`; - break; + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}` + break case 'killed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`; - break; + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped` + break } } - const outputPath = getTaskOutputPath(taskId); - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; + + const outputPath = getTaskOutputPath(taskId) + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>${escapeXml(summary)} -`; +` + enqueuePendingNotification({ value: message, mode: 'task-notification', priority: feature('MONITOR_TOOL') ? 'next' : 'later', - agentId - }); + agentId, + }) } + export const LocalShellTask: Task = { name: 'LocalShellTask', type: 'local_bash', async kill(taskId, setAppState) { - killTask(taskId, setAppState); - } -}; -export async function spawnShellTask(input: LocalShellSpawnInput & { - shellCommand: ShellCommand; -}, context: TaskContext): Promise { - const { - command, - description, - shellCommand, - toolUseId, - agentId, - kind - } = input; - const { - setAppState - } = context; + killTask(taskId, setAppState) + }, +} + +export async function spawnShellTask( + input: LocalShellSpawnInput & { shellCommand: ShellCommand }, + context: TaskContext, +): Promise { + const { command, description, shellCommand, toolUseId, agentId, kind } = input + const { setAppState } = context // TaskOutput owns the data — use its taskId so disk writes are consistent - const { - taskOutput - } = shellCommand; - const taskId = taskOutput.taskId; + const { taskOutput } = shellCommand + const taskId = taskOutput.taskId + const unregisterCleanup = registerCleanup(async () => { - killTask(taskId, setAppState); - }); + killTask(taskId, setAppState) + }) + const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), type: 'local_bash', @@ -211,44 +258,64 @@ export async function spawnShellTask(input: LocalShellSpawnInput & { lastReportedTotalLines: 0, isBackgrounded: true, agentId, - kind - }; - registerTask(taskState, setAppState); + kind, + } + + registerTask(taskState, setAppState) // Data flows through TaskOutput automatically — no stream listeners needed. // Just transition to backgrounded state so the process keeps running. - shellCommand.background(taskId); - const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); + shellCommand.background(taskId) + + const cancelStallWatchdog = startStallWatchdog( + taskId, + description, + kind, + toolUseId, + agentId, + ) + void shellCommand.result.then(async result => { - cancelStallWatchdog(); - await flushAndCleanup(shellCommand); - let wasKilled = false; + cancelStallWatchdog() + await flushAndCleanup(shellCommand) + let wasKilled = false + updateTaskState(taskId, setAppState, task => { if (task.status === 'killed') { - wasKilled = true; - return task; + wasKilled = true + return task } + return { ...task, status: result.code === 0 ? 'completed' : 'failed', - result: { - code: result.code, - interrupted: result.interrupted - }, + result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, - endTime: Date.now() - }; - }); - enqueueShellNotification(taskId, description, wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', result.code, setAppState, toolUseId, kind, agentId); - void evictTaskOutput(taskId); - }); + endTime: Date.now(), + } + }) + + enqueueShellNotification( + taskId, + description, + wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', + result.code, + setAppState, + toolUseId, + kind, + agentId, + ) + + void evictTaskOutput(taskId) + }) + return { taskId, cleanup: () => { - unregisterCleanup(); - } - }; + unregisterCleanup() + }, + } } /** @@ -256,19 +323,19 @@ export async function spawnShellTask(input: LocalShellSpawnInput & { * Called when a bash command has been running long enough to show the BackgroundHint. * @returns taskId for the registered task */ -export function registerForeground(input: LocalShellSpawnInput & { - shellCommand: ShellCommand; -}, setAppState: SetAppState, toolUseId?: string): string { - const { - command, - description, - shellCommand, - agentId - } = input; - const taskId = shellCommand.taskOutput.taskId; +export function registerForeground( + input: LocalShellSpawnInput & { shellCommand: ShellCommand }, + setAppState: SetAppState, + toolUseId?: string, +): string { + const { command, description, shellCommand, agentId } = input + + const taskId = shellCommand.taskOutput.taskId + const unregisterCleanup = registerCleanup(async () => { - killTask(taskId, setAppState); - }); + killTask(taskId, setAppState) + }) + const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), type: 'local_bash', @@ -278,93 +345,119 @@ export function registerForeground(input: LocalShellSpawnInput & { shellCommand, unregisterCleanup, lastReportedTotalLines: 0, - isBackgrounded: false, - // Not yet backgrounded - running in foreground - agentId - }; - registerTask(taskState, setAppState); - return taskId; + isBackgrounded: false, // Not yet backgrounded - running in foreground + agentId, + } + + registerTask(taskState, setAppState) + return taskId } /** * Background a specific foreground task. * @returns true if backgrounded successfully, false otherwise */ -function backgroundTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { +function backgroundTask( + taskId: string, + getAppState: () => AppState, + setAppState: SetAppState, +): boolean { // Step 1: Get the task and shell command from current state - const state = getAppState(); - const task = state.tasks[taskId]; + const state = getAppState() + const task = state.tasks[taskId] if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) { - return false; + return false } - const shellCommand = task.shellCommand; - const description = task.description; - const { - toolUseId, - kind, - agentId - } = task; + + const shellCommand = task.shellCommand + const description = task.description + const { toolUseId, kind, agentId } = task // Transition to backgrounded — TaskOutput continues receiving data automatically if (!shellCommand.background(taskId)) { - return false; + return false } + setAppState(prev => { - const prevTask = prev.tasks[taskId]; + const prevTask = prev.tasks[taskId] if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { - return prev; + return prev } return { ...prev, tasks: { ...prev.tasks, - [taskId]: { - ...prevTask, - isBackgrounded: true - } - } - }; - }); - const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); + [taskId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) + + const cancelStallWatchdog = startStallWatchdog( + taskId, + description, + kind, + toolUseId, + agentId, + ) // Set up result handler void shellCommand.result.then(async result => { - cancelStallWatchdog(); - await flushAndCleanup(shellCommand); - let wasKilled = false; - let cleanupFn: (() => void) | undefined; + cancelStallWatchdog() + await flushAndCleanup(shellCommand) + let wasKilled = false + let cleanupFn: (() => void) | undefined + updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { - wasKilled = true; - return t; + wasKilled = true + return t } // Capture cleanup function to call outside of updater - cleanupFn = t.unregisterCleanup; + cleanupFn = t.unregisterCleanup + return { ...t, status: result.code === 0 ? 'completed' : 'failed', - result: { - code: result.code, - interrupted: result.interrupted - }, + result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, - endTime: Date.now() - }; - }); + endTime: Date.now(), + } + }) // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.(); + cleanupFn?.() + if (wasKilled) { - enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId); + enqueueShellNotification( + taskId, + description, + 'killed', + result.code, + setAppState, + toolUseId, + kind, + agentId, + ) } else { - const finalStatus = result.code === 0 ? 'completed' : 'failed'; - enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId); + const finalStatus = result.code === 0 ? 'completed' : 'failed' + enqueueShellNotification( + taskId, + description, + finalStatus, + result.code, + setAppState, + toolUseId, + kind, + agentId, + ) } - void evictTaskOutput(taskId); - }); - return true; + + void evictTaskOutput(taskId) + }) + + return true } /** @@ -378,34 +471,42 @@ function backgroundTask(taskId: string, getAppState: () => AppState, setAppState export function hasForegroundTasks(state: AppState): boolean { return Object.values(state.tasks).some(task => { if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) { - return true; + return true } // Exclude main session tasks - they display in the main view, not as foreground tasks - if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) { - return true; + if ( + isLocalAgentTask(task) && + !task.isBackgrounded && + !isMainSessionTask(task) + ) { + return true } - return false; - }); + return false + }) } -export function backgroundAll(getAppState: () => AppState, setAppState: SetAppState): void { - const state = getAppState(); + +export function backgroundAll( + getAppState: () => AppState, + setAppState: SetAppState, +): void { + const state = getAppState() // Background all foreground bash tasks const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => { - const task = state.tasks[id]; - return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand; - }); + const task = state.tasks[id] + return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand + }) for (const taskId of foregroundBashTaskIds) { - backgroundTask(taskId, getAppState, setAppState); + backgroundTask(taskId, getAppState, setAppState) } // Background all foreground agent tasks const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => { - const task = state.tasks[id]; - return isLocalAgentTask(task) && !task.isBackgrounded; - }); + const task = state.tasks[id] + return isLocalAgentTask(task) && !task.isBackgrounded + }) for (const taskId of foregroundAgentTaskIds) { - backgroundAgentTask(taskId, getAppState, setAppState); + backgroundAgentTask(taskId, getAppState, setAppState) } } @@ -417,60 +518,86 @@ export function backgroundAll(getAppState: () => AppState, setAppState: SetAppSt * already registered the task (avoiding duplicate task_started SDK events * and leaked cleanup callbacks). */ -export function backgroundExistingForegroundTask(taskId: string, shellCommand: ShellCommand, description: string, setAppState: SetAppState, toolUseId?: string): boolean { +export function backgroundExistingForegroundTask( + taskId: string, + shellCommand: ShellCommand, + description: string, + setAppState: SetAppState, + toolUseId?: string, +): boolean { if (!shellCommand.background(taskId)) { - return false; + return false } - let agentId: AgentId | undefined; + + let agentId: AgentId | undefined setAppState(prev => { - const prevTask = prev.tasks[taskId]; + const prevTask = prev.tasks[taskId] if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { - return prev; + return prev } - agentId = prevTask.agentId; + agentId = prevTask.agentId return { ...prev, tasks: { ...prev.tasks, - [taskId]: { - ...prevTask, - isBackgrounded: true - } - } - }; - }); - const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId); + [taskId]: { ...prevTask, isBackgrounded: true }, + }, + } + }) + + const cancelStallWatchdog = startStallWatchdog( + taskId, + description, + undefined, + toolUseId, + agentId, + ) // Set up result handler (mirrors backgroundTask's handler) void shellCommand.result.then(async result => { - cancelStallWatchdog(); - await flushAndCleanup(shellCommand); - let wasKilled = false; - let cleanupFn: (() => void) | undefined; + cancelStallWatchdog() + await flushAndCleanup(shellCommand) + let wasKilled = false + let cleanupFn: (() => void) | undefined + updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { - wasKilled = true; - return t; + wasKilled = true + return t } - cleanupFn = t.unregisterCleanup; + cleanupFn = t.unregisterCleanup return { ...t, status: result.code === 0 ? 'completed' : 'failed', - result: { - code: result.code, - interrupted: result.interrupted - }, + result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, - endTime: Date.now() - }; - }); - cleanupFn?.(); - const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed'; - enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId); - void evictTaskOutput(taskId); - }); - return true; + endTime: Date.now(), + } + }) + + cleanupFn?.() + + const finalStatus = wasKilled + ? 'killed' + : result.code === 0 + ? 'completed' + : 'failed' + enqueueShellNotification( + taskId, + description, + finalStatus, + result.code, + setAppState, + toolUseId, + undefined, + agentId, + ) + + void evictTaskOutput(taskId) + }) + + return true } /** @@ -478,45 +605,47 @@ export function backgroundExistingForegroundTask(taskId: string, shellCommand: S * Used when backgrounding raced with completion — the tool result already * carries the full output, so the would be redundant. */ -export function markTaskNotified(taskId: string, setAppState: SetAppState): void { - updateTaskState(taskId, setAppState, t => t.notified ? t : { - ...t, - notified: true - }); +export function markTaskNotified( + taskId: string, + setAppState: SetAppState, +): void { + updateTaskState(taskId, setAppState, t => + t.notified ? t : { ...t, notified: true }, + ) } /** * Unregister a foreground task when the command completes without being backgrounded. */ -export function unregisterForeground(taskId: string, setAppState: SetAppState): void { - let cleanupFn: (() => void) | undefined; +export function unregisterForeground( + taskId: string, + setAppState: SetAppState, +): void { + let cleanupFn: (() => void) | undefined + setAppState(prev => { - const task = prev.tasks[taskId]; + const task = prev.tasks[taskId] // Only remove if it's a foreground task (not backgrounded) if (!isLocalShellTask(task) || task.isBackgrounded) { - return prev; + return prev } // Capture cleanup function to call outside of updater - cleanupFn = task.unregisterCleanup; - const { - [taskId]: removed, - ...rest - } = prev.tasks; - return { - ...prev, - tasks: rest - }; - }); + cleanupFn = task.unregisterCleanup + + const { [taskId]: removed, ...rest } = prev.tasks + return { ...prev, tasks: rest } + }) // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.(); + cleanupFn?.() } + async function flushAndCleanup(shellCommand: ShellCommand): Promise { try { - await shellCommand.taskOutput.flush(); - shellCommand.cleanup(); + await shellCommand.taskOutput.flush() + shellCommand.cleanup() } catch (error) { - logError(error); + logError(error) } } diff --git a/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx b/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx index 39f5ba3b2..8755837e4 100644 --- a/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx +++ b/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx @@ -1,106 +1,156 @@ -import type { ToolUseBlock } from '@anthropic-ai/sdk/resources'; -import { getRemoteSessionUrl } from '../../constants/product.js'; -import { OUTPUT_FILE_TAG, REMOTE_REVIEW_PROGRESS_TAG, REMOTE_REVIEW_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TASK_TYPE_TAG, TOOL_USE_ID_TAG, ULTRAPLAN_TAG } from '../../constants/xml.js'; -import type { SDKAssistantMessage, SDKMessage } from '../../entrypoints/agentSdkTypes.js'; -import type { SetAppState, Task, TaskContext, TaskStateBase } from '../../Task.js'; -import { createTaskStateBase, generateTaskId } from '../../Task.js'; -import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js'; -import { type BackgroundRemoteSessionPrecondition, checkBackgroundRemoteSessionEligibility } from '../../utils/background/remote/remoteSession.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { logError } from '../../utils/log.js'; -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; -import { extractTag, extractTextContent } from '../../utils/messages.js'; -import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js'; -import { deleteRemoteAgentMetadata, listRemoteAgentMetadata, type RemoteAgentMetadata, writeRemoteAgentMetadata } from '../../utils/sessionStorage.js'; -import { jsonStringify } from '../../utils/slowOperations.js'; -import { appendTaskOutput, evictTaskOutput, getTaskOutputPath, initTaskOutput } from '../../utils/task/diskOutput.js'; -import { registerTask, updateTaskState } from '../../utils/task/framework.js'; -import { fetchSession } from '../../utils/teleport/api.js'; -import { archiveRemoteSession, pollRemoteSessionEvents } from '../../utils/teleport.js'; -import type { TodoList } from '../../utils/todo/types.js'; -import type { UltraplanPhase } from '../../utils/ultraplan/ccrSession.js'; - -/** Helper to access the `message` property on SDK messages that use `[key: string]: unknown` index signatures. */ -type SDKMessageWithMessage = { message: { content: ContentBlockLike[] }; [key: string]: unknown }; -type ContentBlockLike = { type: string; text?: string; name?: string; input?: unknown; id?: string; [key: string]: unknown }; -/** Helper to access `stdout`/`subtype` on SDK system messages. */ -type SDKSystemMessageWithFields = { type: 'system'; subtype: string; stdout: string; [key: string]: unknown }; +import type { ToolUseBlock } from '@anthropic-ai/sdk/resources' +import { getRemoteSessionUrl } from '../../constants/product.js' +import { + OUTPUT_FILE_TAG, + REMOTE_REVIEW_PROGRESS_TAG, + REMOTE_REVIEW_TAG, + STATUS_TAG, + SUMMARY_TAG, + TASK_ID_TAG, + TASK_NOTIFICATION_TAG, + TASK_TYPE_TAG, + TOOL_USE_ID_TAG, + ULTRAPLAN_TAG, +} from '../../constants/xml.js' +import type { + SDKAssistantMessage, + SDKMessage, +} from '../../entrypoints/agentSdkTypes.js' +import type { + SetAppState, + Task, + TaskContext, + TaskStateBase, +} from '../../Task.js' +import { createTaskStateBase, generateTaskId } from '../../Task.js' +import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js' +import { + type BackgroundRemoteSessionPrecondition, + checkBackgroundRemoteSessionEligibility, +} from '../../utils/background/remote/remoteSession.js' +import { logForDebugging } from '../../utils/debug.js' +import { logError } from '../../utils/log.js' +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' +import { extractTag, extractTextContent } from '../../utils/messages.js' +import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js' +import { + deleteRemoteAgentMetadata, + listRemoteAgentMetadata, + type RemoteAgentMetadata, + writeRemoteAgentMetadata, +} from '../../utils/sessionStorage.js' +import { jsonStringify } from '../../utils/slowOperations.js' +import { + appendTaskOutput, + evictTaskOutput, + getTaskOutputPath, + initTaskOutput, +} from '../../utils/task/diskOutput.js' +import { registerTask, updateTaskState } from '../../utils/task/framework.js' +import { fetchSession } from '../../utils/teleport/api.js' +import { + archiveRemoteSession, + pollRemoteSessionEvents, +} from '../../utils/teleport.js' +import type { TodoList } from '../../utils/todo/types.js' +import type { UltraplanPhase } from '../../utils/ultraplan/ccrSession.js' export type RemoteAgentTaskState = TaskStateBase & { - type: 'remote_agent'; - remoteTaskType: RemoteTaskType; + type: 'remote_agent' + remoteTaskType: RemoteTaskType /** Task-specific metadata (PR number, repo, etc.). */ - remoteTaskMetadata?: RemoteTaskMetadata; - sessionId: string; // Original session ID for API calls - command: string; - title: string; - todoList: TodoList; - log: SDKMessage[]; + remoteTaskMetadata?: RemoteTaskMetadata + sessionId: string // Original session ID for API calls + command: string + title: string + todoList: TodoList + log: SDKMessage[] /** * Long-running agent that will not be marked as complete after the first `result`. */ - isLongRunning?: boolean; + isLongRunning?: boolean /** * When the local poller started watching this task (at spawn or on restore). * Review timeout clocks from here so a restore doesn't immediately time out * a task spawned >30min ago. */ - pollStartedAt: number; + pollStartedAt: number /** True when this task was created by a teleported /ultrareview command. */ - isRemoteReview?: boolean; + isRemoteReview?: boolean /** Parsed from the orchestrator's heartbeat echoes. */ reviewProgress?: { - stage?: 'finding' | 'verifying' | 'synthesizing'; - bugsFound: number; - bugsVerified: number; - bugsRefuted: number; - }; - isUltraplan?: boolean; + stage?: 'finding' | 'verifying' | 'synthesizing' + bugsFound: number + bugsVerified: number + bugsRefuted: number + } + isUltraplan?: boolean /** * Scanner-derived pill state. Undefined = running. `needs_input` when the * remote asked a clarifying question and is idle; `plan_ready` when * ExitPlanMode is awaiting browser approval. Surfaced in the pill badge * and detail dialog status line. */ - ultraplanPhase?: Exclude; -}; -const REMOTE_TASK_TYPES = ['remote-agent', 'ultraplan', 'ultrareview', 'autofix-pr', 'background-pr'] as const; -export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number]; -function isRemoteTaskType(v: string | undefined): v is RemoteTaskType { - return (REMOTE_TASK_TYPES as readonly string[]).includes(v ?? ''); + ultraplanPhase?: Exclude } + +const REMOTE_TASK_TYPES = [ + 'remote-agent', + 'ultraplan', + 'ultrareview', + 'autofix-pr', + 'background-pr', +] as const +export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number] + +function isRemoteTaskType(v: string | undefined): v is RemoteTaskType { + return (REMOTE_TASK_TYPES as readonly string[]).includes(v ?? '') +} + export type AutofixPrRemoteTaskMetadata = { - owner: string; - repo: string; - prNumber: number; -}; -export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata; + owner: string + repo: string + prNumber: number +} + +export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata /** * Called on every poll tick for tasks with a matching remoteTaskType. Return a * non-null string to complete the task (string becomes the notification text), * or null to keep polling. Checkers that hit external APIs should self-throttle. */ -export type RemoteTaskCompletionChecker = (remoteTaskMetadata: RemoteTaskMetadata | undefined) => Promise; -const completionCheckers = new Map(); +export type RemoteTaskCompletionChecker = ( + remoteTaskMetadata: RemoteTaskMetadata | undefined, +) => Promise + +const completionCheckers = new Map< + RemoteTaskType, + RemoteTaskCompletionChecker +>() /** * Register a completion checker for a remote task type. Invoked on every poll * tick; survives --resume via the sidecar's remoteTaskType + remoteTaskMetadata. */ -export function registerCompletionChecker(remoteTaskType: RemoteTaskType, checker: RemoteTaskCompletionChecker): void { - completionCheckers.set(remoteTaskType, checker); +export function registerCompletionChecker( + remoteTaskType: RemoteTaskType, + checker: RemoteTaskCompletionChecker, +): void { + completionCheckers.set(remoteTaskType, checker) } /** * Persist a remote-agent metadata entry to the session sidecar. * Fire-and-forget — persistence failures must not block task registration. */ -async function persistRemoteAgentMetadata(meta: RemoteAgentMetadata): Promise { +async function persistRemoteAgentMetadata( + meta: RemoteAgentMetadata, +): Promise { try { - await writeRemoteAgentMetadata(meta.taskId, meta); + await writeRemoteAgentMetadata(meta.taskId, meta) } catch (e) { - logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`); + logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`) } } @@ -111,82 +161,93 @@ async function persistRemoteAgentMetadata(meta: RemoteAgentMetadata): Promise { try { - await deleteRemoteAgentMetadata(taskId); + await deleteRemoteAgentMetadata(taskId) } catch (e) { - logForDebugging(`removeRemoteAgentMetadata failed: ${String(e)}`); + logForDebugging(`removeRemoteAgentMetadata failed: ${String(e)}`) } } // Precondition error result -export type RemoteAgentPreconditionResult = { - eligible: true; -} | { - eligible: false; - errors: BackgroundRemoteSessionPrecondition[]; -}; +export type RemoteAgentPreconditionResult = + | { + eligible: true + } + | { + eligible: false + errors: BackgroundRemoteSessionPrecondition[] + } /** * Check eligibility for creating a remote agent session. */ export async function checkRemoteAgentEligibility({ - skipBundle = false + skipBundle = false, }: { - skipBundle?: boolean; + skipBundle?: boolean } = {}): Promise { - const errors = await checkBackgroundRemoteSessionEligibility({ - skipBundle - }); + const errors = await checkBackgroundRemoteSessionEligibility({ skipBundle }) if (errors.length > 0) { - return { - eligible: false, - errors - }; + return { eligible: false, errors } } - return { - eligible: true - }; + return { eligible: true } } /** * Format precondition error for display. */ -export function formatPreconditionError(error: BackgroundRemoteSessionPrecondition): string { +export function formatPreconditionError( + error: BackgroundRemoteSessionPrecondition, +): string { switch (error.type) { case 'not_logged_in': - return 'Please run /login and sign in with your Claude.ai account (not Console).'; + return 'Please run /login and sign in with your Claude.ai account (not Console).' case 'no_remote_environment': - return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup'; + return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup' case 'not_in_git_repo': - return 'Background tasks require a git repository. Initialize git or run from a git repository.'; + return 'Background tasks require a git repository. Initialize git or run from a git repository.' case 'no_git_remote': - return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.'; + return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.' case 'github_app_not_installed': - return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new'; + return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new' case 'policy_blocked': - return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them."; + return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them." } } /** * Enqueue a remote task notification to the message queue. */ -function enqueueRemoteNotification(taskId: string, title: string, status: 'completed' | 'failed' | 'killed', setAppState: SetAppState, toolUseId?: string): void { +function enqueueRemoteNotification( + taskId: string, + title: string, + status: 'completed' | 'failed' | 'killed', + setAppState: SetAppState, + toolUseId?: string, +): void { // Atomically check and set notified flag to prevent duplicate notifications. - if (!markTaskNotified(taskId, setAppState)) return; - const statusText = status === 'completed' ? 'completed successfully' : status === 'failed' ? 'failed' : 'was stopped'; - const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; - const outputPath = getTaskOutputPath(taskId); + if (!markTaskNotified(taskId, setAppState)) return + + const statusText = + status === 'completed' + ? 'completed successfully' + : status === 'failed' + ? 'failed' + : 'was stopped' + + const toolUseIdLine = toolUseId + ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` + : '' + + const outputPath = getTaskOutputPath(taskId) const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${TASK_TYPE_TAG}>remote_agent <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>Remote task "${title}" ${statusText} -`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** @@ -194,18 +255,15 @@ function enqueueRemoteNotification(taskId: string, title: string, status: 'compl * flag (caller should enqueue), false if already notified (caller should skip). */ function markTaskNotified(taskId: string, setAppState: SetAppState): boolean { - let shouldEnqueue = false; - updateTaskState(taskId, setAppState, task => { + let shouldEnqueue = false + updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task; + return task } - shouldEnqueue = true; - return { - ...task, - notified: true - }; - }); - return shouldEnqueue; + shouldEnqueue = true + return { ...task, notified: true } + }) + return shouldEnqueue } /** @@ -215,13 +273,13 @@ function markTaskNotified(taskId: string, setAppState: SetAppState): boolean { export function extractPlanFromLog(log: SDKMessage[]): string | null { // Walk backwards through assistant messages to find content for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type !== 'assistant') continue; - const fullText = extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n'); - const plan = extractTag(fullText, ULTRAPLAN_TAG); - if (plan?.trim()) return plan.trim(); + const msg = log[i] + if (msg?.type !== 'assistant') continue + const fullText = extractTextContent(msg.message.content, '\n') + const plan = extractTag(fullText, ULTRAPLAN_TAG) + if (plan?.trim()) return plan.trim() } - return null; + return null } /** @@ -229,20 +287,24 @@ export function extractPlanFromLog(log: SDKMessage[]): string | null { * this does NOT instruct the model to read the raw output file (a JSONL dump that is * useless for plan extraction). */ -export function enqueueUltraplanFailureNotification(taskId: string, sessionId: string, reason: string, setAppState: SetAppState): void { - if (!markTaskNotified(taskId, setAppState)) return; - const sessionUrl = getRemoteTaskSessionUrl(sessionId); +export function enqueueUltraplanFailureNotification( + taskId: string, + sessionId: string, + reason: string, + setAppState: SetAppState, +): void { + if (!markTaskNotified(taskId, setAppState)) return + + const sessionUrl = getRemoteTaskSessionUrl(sessionId) const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} <${TASK_TYPE_TAG}>remote_agent <${STATUS_TAG}>failed <${SUMMARY_TAG}>Ultraplan failed: ${reason} -The remote Ultraplan session did not produce a plan (${reason}). Inspect the session at ${sessionUrl} and tell the user to retry locally with plan mode.`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +The remote Ultraplan session did not produce a plan (${reason}). Inspect the session at ${sessionUrl} and tell the user to retry locally with plan mode.` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** @@ -260,33 +322,49 @@ The remote Ultraplan session did not produce a plan (${reason}). Inspect the ses */ function extractReviewFromLog(log: SDKMessage[]): string | null { for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; + const msg = log[i] // The final echo before hook exit may land in either the last // hook_progress or the terminal hook_response depending on buffering; // both have flat stdout. - if (msg?.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')) { - const tagged = extractTag((msg as SDKSystemMessageWithFields).stdout, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + if ( + msg?.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response') + ) { + const tagged = extractTag(msg.stdout, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } } + for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type !== 'assistant') continue; - const fullText = extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n'); - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + const msg = log[i] + if (msg?.type !== 'assistant') continue + const fullText = extractTextContent(msg.message.content, '\n') + const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } // Hook-stdout concat fallback: a single echo should land in one event, but // large JSON payloads can flush across two if the pipe buffer fills // mid-write. Per-message scan above misses a tag split across events. - const hookStdout = log.filter(msg => msg.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')).map(msg => (msg as SDKSystemMessageWithFields).stdout).join(''); - const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); - if (hookTagged?.trim()) return hookTagged.trim(); + const hookStdout = log + .filter( + msg => + msg.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response'), + ) + .map(msg => msg.stdout) + .join('') + const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG) + if (hookTagged?.trim()) return hookTagged.trim() // Fallback: concatenate all assistant text in chronological order. - const allText = log.filter((msg): msg is SDKAssistantMessage => msg.type === 'assistant').map(msg => extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n')).join('\n').trim(); - return allText || null; + const allText = log + .filter((msg): msg is SDKAssistantMessage => msg.type === 'assistant') + .map(msg => extractTextContent(msg.message.content, '\n')) + .join('\n') + .trim() + + return allText || null } /** @@ -302,27 +380,38 @@ function extractReviewFromLog(log: SDKMessage[]): string | null { function extractReviewTagFromLog(log: SDKMessage[]): string | null { // hook_progress / hook_response per-message scan (bughunter path) for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')) { - const tagged = extractTag((msg as SDKSystemMessageWithFields).stdout, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + const msg = log[i] + if ( + msg?.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response') + ) { + const tagged = extractTag(msg.stdout, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } } // assistant text per-message scan (prompt mode) for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i]; - if (msg?.type !== 'assistant') continue; - const fullText = extractTextContent((msg as unknown as SDKMessageWithMessage).message.content, '\n'); - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); - if (tagged?.trim()) return tagged.trim(); + const msg = log[i] + if (msg?.type !== 'assistant') continue + const fullText = extractTextContent(msg.message.content, '\n') + const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) + if (tagged?.trim()) return tagged.trim() } // Hook-stdout concat fallback for split tags - const hookStdout = log.filter(msg => msg.type === 'system' && ((msg as SDKSystemMessageWithFields).subtype === 'hook_progress' || (msg as SDKSystemMessageWithFields).subtype === 'hook_response')).map(msg => (msg as SDKSystemMessageWithFields).stdout).join(''); - const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); - if (hookTagged?.trim()) return hookTagged.trim(); - return null; + const hookStdout = log + .filter( + msg => + msg.type === 'system' && + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response'), + ) + .map(msg => msg.stdout) + .join('') + const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG) + if (hookTagged?.trim()) return hookTagged.trim() + + return null } /** @@ -331,8 +420,13 @@ function extractReviewTagFromLog(log: SDKMessage[]): string | null { * turn — no file indirection, no mode change. Session is kept alive so the * claude.ai URL stays a durable record the user can revisit; TTL handles cleanup. */ -function enqueueRemoteReviewNotification(taskId: string, reviewContent: string, setAppState: SetAppState): void { - if (!markTaskNotified(taskId, setAppState)) return; +function enqueueRemoteReviewNotification( + taskId: string, + reviewContent: string, + setAppState: SetAppState, +): void { + if (!markTaskNotified(taskId, setAppState)) return + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} <${TASK_TYPE_TAG}>remote_agent @@ -341,48 +435,61 @@ function enqueueRemoteReviewNotification(taskId: string, reviewContent: string, The remote review produced the following findings: -${reviewContent}`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +${reviewContent}` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** * Enqueue a remote-review failure notification. */ -function enqueueRemoteReviewFailureNotification(taskId: string, reason: string, setAppState: SetAppState): void { - if (!markTaskNotified(taskId, setAppState)) return; +function enqueueRemoteReviewFailureNotification( + taskId: string, + reason: string, + setAppState: SetAppState, +): void { + if (!markTaskNotified(taskId, setAppState)) return + const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} <${TASK_TYPE_TAG}>remote_agent <${STATUS_TAG}>failed <${SUMMARY_TAG}>Remote review failed: ${reason} -Remote review did not produce output (${reason}). Tell the user to retry /ultrareview, or use /review for a local review instead.`; - enqueuePendingNotification({ - value: message, - mode: 'task-notification' - }); +Remote review did not produce output (${reason}). Tell the user to retry /ultrareview, or use /review for a local review instead.` + + enqueuePendingNotification({ value: message, mode: 'task-notification' }) } /** * Extract todo list from SDK messages (finds last TodoWrite tool use). */ function extractTodoListFromLog(log: SDKMessage[]): TodoList { - const todoListMessage = log.findLast((msg): msg is SDKAssistantMessage => msg.type === 'assistant' && (msg as unknown as SDKMessageWithMessage).message.content.some(block => block.type === 'tool_use' && block.name === TodoWriteTool.name)); + const todoListMessage = log.findLast( + (msg): msg is SDKAssistantMessage => + msg.type === 'assistant' && + msg.message.content.some( + block => block.type === 'tool_use' && block.name === TodoWriteTool.name, + ), + ) if (!todoListMessage) { - return []; + return [] } - const input = (todoListMessage as unknown as SDKMessageWithMessage).message.content.find(block => block.type === 'tool_use' && block.name === TodoWriteTool.name)?.input; + + const input = todoListMessage.message.content.find( + (block): block is ToolUseBlock => + block.type === 'tool_use' && block.name === TodoWriteTool.name, + )?.input if (!input) { - return []; + return [] } - const parsedInput = TodoWriteTool.inputSchema.safeParse(input); + + const parsedInput = TodoWriteTool.inputSchema.safeParse(input) if (!parsedInput.success) { - return []; + return [] } - return parsedInput.data.todos; + + return parsedInput.data.todos } /** @@ -391,22 +498,19 @@ function extractTodoListFromLog(log: SDKMessage[]): TodoList { * Callers remain responsible for custom pre-registration logic (git dialogs, transcript upload, teleport options). */ export function registerRemoteAgentTask(options: { - remoteTaskType: RemoteTaskType; - session: { - id: string; - title: string; - }; - command: string; - context: TaskContext; - toolUseId?: string; - isRemoteReview?: boolean; - isUltraplan?: boolean; - isLongRunning?: boolean; - remoteTaskMetadata?: RemoteTaskMetadata; + remoteTaskType: RemoteTaskType + session: { id: string; title: string } + command: string + context: TaskContext + toolUseId?: string + isRemoteReview?: boolean + isUltraplan?: boolean + isLongRunning?: boolean + remoteTaskMetadata?: RemoteTaskMetadata }): { - taskId: string; - sessionId: string; - cleanup: () => void; + taskId: string + sessionId: string + cleanup: () => void } { const { remoteTaskType, @@ -417,14 +521,15 @@ export function registerRemoteAgentTask(options: { isRemoteReview, isUltraplan, isLongRunning, - remoteTaskMetadata - } = options; - const taskId = generateTaskId('remote_agent'); + remoteTaskMetadata, + } = options + const taskId = generateTaskId('remote_agent') // Create the output file before registering the task. // RemoteAgentTask uses appendTaskOutput() (not TaskOutput), so // the file must exist for readers before any output arrives. - void initTaskOutput(taskId); + void initTaskOutput(taskId) + const taskState: RemoteAgentTaskState = { ...createTaskStateBase(taskId, 'remote_agent', session.title, toolUseId), type: 'remote_agent', @@ -439,9 +544,10 @@ export function registerRemoteAgentTask(options: { isUltraplan, isLongRunning, pollStartedAt: Date.now(), - remoteTaskMetadata - }; - registerTask(taskState, context.setAppState); + remoteTaskMetadata, + } + + registerTask(taskState, context.setAppState) // Persist identity to the session sidecar so --resume can reconnect to // still-running remote sessions. Status is not stored — it's fetched @@ -457,19 +563,20 @@ export function registerRemoteAgentTask(options: { isUltraplan, isRemoteReview, isLongRunning, - remoteTaskMetadata - }); + remoteTaskMetadata, + }) // Ultraplan lifecycle is owned by startDetachedPoll in ultraplan.tsx. Generic // polling still runs so session.log populates for the detail view's progress // counts; the result-lookup guard below prevents early completion. // TODO(#23985): fold ExitPlanModeScanner into this poller, drop startDetachedPoll. - const stopPolling = startRemoteSessionPolling(taskId, context); + const stopPolling = startRemoteSessionPolling(taskId, context) + return { taskId, sessionId: session.id, - cleanup: stopPolling - }; + cleanup: stopPolling, + } } /** @@ -481,21 +588,27 @@ export function registerRemoteAgentTask(options: { * removed. Must run after switchSession() so getSessionId() points at the * resumed session's sidecar directory. */ -export async function restoreRemoteAgentTasks(context: TaskContext): Promise { +export async function restoreRemoteAgentTasks( + context: TaskContext, +): Promise { try { - await restoreRemoteAgentTasksImpl(context); + await restoreRemoteAgentTasksImpl(context) } catch (e) { - logForDebugging(`restoreRemoteAgentTasks failed: ${String(e)}`); + logForDebugging(`restoreRemoteAgentTasks failed: ${String(e)}`) } } -async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise { - const persisted = await listRemoteAgentMetadata(); - if (persisted.length === 0) return; + +async function restoreRemoteAgentTasksImpl( + context: TaskContext, +): Promise { + const persisted = await listRemoteAgentMetadata() + if (persisted.length === 0) return + for (const meta of persisted) { - let remoteStatus: string; + let remoteStatus: string try { - const session = await fetchSession(meta.sessionId); - remoteStatus = session.session_status; + const session = await fetchSession(meta.sessionId) + remoteStatus = session.session_status } catch (e) { // Only 404 means the CCR session is truly gone. Auth errors (401, // missing OAuth token) are recoverable via /login — the remote @@ -503,22 +616,35 @@ async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise // 4xx (validateStatus treats <500 as success), so isTransientNetworkError // can't distinguish them; match the 404 message instead. if (e instanceof Error && e.message.startsWith('Session not found:')) { - logForDebugging(`restoreRemoteAgentTasks: dropping ${meta.taskId} (404: ${String(e)})`); - void removeRemoteAgentMetadata(meta.taskId); + logForDebugging( + `restoreRemoteAgentTasks: dropping ${meta.taskId} (404: ${String(e)})`, + ) + void removeRemoteAgentMetadata(meta.taskId) } else { - logForDebugging(`restoreRemoteAgentTasks: skipping ${meta.taskId} (recoverable: ${String(e)})`); + logForDebugging( + `restoreRemoteAgentTasks: skipping ${meta.taskId} (recoverable: ${String(e)})`, + ) } - continue; + continue } + if (remoteStatus === 'archived') { // Session ended while the local client was offline. Don't resurrect. - void removeRemoteAgentMetadata(meta.taskId); - continue; + void removeRemoteAgentMetadata(meta.taskId) + continue } + const taskState: RemoteAgentTaskState = { - ...createTaskStateBase(meta.taskId, 'remote_agent', meta.title, meta.toolUseId), + ...createTaskStateBase( + meta.taskId, + 'remote_agent', + meta.title, + meta.toolUseId, + ), type: 'remote_agent', - remoteTaskType: isRemoteTaskType(meta.remoteTaskType) ? meta.remoteTaskType : 'remote-agent', + remoteTaskType: isRemoteTaskType(meta.remoteTaskType) + ? meta.remoteTaskType + : 'remote-agent', status: 'running', sessionId: meta.sessionId, command: meta.command, @@ -530,11 +656,14 @@ async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise isLongRunning: meta.isLongRunning, startTime: meta.spawnedAt, pollStartedAt: Date.now(), - remoteTaskMetadata: meta.remoteTaskMetadata as RemoteTaskMetadata | undefined - }; - registerTask(taskState, context.setAppState); - void initTaskOutput(meta.taskId); - startRemoteSessionPolling(meta.taskId, context); + remoteTaskMetadata: meta.remoteTaskMetadata as + | RemoteTaskMetadata + | undefined, + } + + registerTask(taskState, context.setAppState) + void initTaskOutput(meta.taskId) + startRemoteSessionPolling(meta.taskId, context) } } @@ -542,71 +671,102 @@ async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise * Start polling for remote session updates. * Returns a cleanup function to stop polling. */ -function startRemoteSessionPolling(taskId: string, context: TaskContext): () => void { - let isRunning = true; - const POLL_INTERVAL_MS = 1000; - const REMOTE_REVIEW_TIMEOUT_MS = 30 * 60 * 1000; +function startRemoteSessionPolling( + taskId: string, + context: TaskContext, +): () => void { + let isRunning = true + const POLL_INTERVAL_MS = 1000 + const REMOTE_REVIEW_TIMEOUT_MS = 30 * 60 * 1000 // Remote sessions flip to 'idle' between tool turns. With 100+ rapid // turns, a 1s poll WILL catch a transient idle mid-run. Require stable // idle (no log growth for N consecutive polls) before believing it. - const STABLE_IDLE_POLLS = 5; - let consecutiveIdlePolls = 0; - let lastEventId: string | null = null; - let accumulatedLog: SDKMessage[] = []; + const STABLE_IDLE_POLLS = 5 + let consecutiveIdlePolls = 0 + let lastEventId: string | null = null + let accumulatedLog: SDKMessage[] = [] // Cached across ticks so we don't re-scan the full log. Tag appears once // at end of run; scanning only the delta (response.newEvents) is O(new). - let cachedReviewContent: string | null = null; + let cachedReviewContent: string | null = null + const poll = async (): Promise => { - if (!isRunning) return; + if (!isRunning) return + try { - const appState = context.getAppState(); - const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; + const appState = context.getAppState() + const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined if (!task || task.status !== 'running') { // Task was killed externally (TaskStopTool) or already terminal. // Session left alive so the claude.ai URL stays valid — the run_hunt.sh // post_stage() calls land as assistant events there, and the user may // want to revisit them after closing the terminal. TTL reaps it. - return; + return } - const response = await pollRemoteSessionEvents(task.sessionId, lastEventId); - lastEventId = response.lastEventId; - const logGrew = response.newEvents.length > 0; + + const response = await pollRemoteSessionEvents( + task.sessionId, + lastEventId, + ) + lastEventId = response.lastEventId + const logGrew = response.newEvents.length > 0 if (logGrew) { - accumulatedLog = [...accumulatedLog, ...response.newEvents]; - const deltaText = response.newEvents.map(msg => { - if (msg.type === 'assistant') { - return (msg as unknown as SDKMessageWithMessage).message.content.filter(block => block.type === 'text').map(block => 'text' in block ? block.text : '').join('\n'); - } - return jsonStringify(msg); - }).join('\n'); + accumulatedLog = [...accumulatedLog, ...response.newEvents] + const deltaText = response.newEvents + .map(msg => { + if (msg.type === 'assistant') { + return msg.message.content + .filter(block => block.type === 'text') + .map(block => ('text' in block ? block.text : '')) + .join('\n') + } + return jsonStringify(msg) + }) + .join('\n') if (deltaText) { - appendTaskOutput(taskId, deltaText + '\n'); + appendTaskOutput(taskId, deltaText + '\n') } } + if (response.sessionStatus === 'archived') { - updateTaskState(taskId, context.setAppState, t => t.status === 'running' ? { - ...t, - status: 'completed', - endTime: Date.now() - } : t); - enqueueRemoteNotification(taskId, task.title, 'completed', context.setAppState, task.toolUseId); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; + updateTaskState(taskId, context.setAppState, t => + t.status === 'running' + ? { ...t, status: 'completed', endTime: Date.now() } + : t, + ) + enqueueRemoteNotification( + taskId, + task.title, + 'completed', + context.setAppState, + task.toolUseId, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return } - const checker = completionCheckers.get(task.remoteTaskType); + + const checker = completionCheckers.get(task.remoteTaskType) if (checker) { - const completionResult = await checker(task.remoteTaskMetadata); + const completionResult = await checker(task.remoteTaskMetadata) if (completionResult !== null) { - updateTaskState(taskId, context.setAppState, t => t.status === 'running' ? { - ...t, - status: 'completed', - endTime: Date.now() - } : t); - enqueueRemoteNotification(taskId, completionResult, 'completed', context.setAppState, task.toolUseId); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; + updateTaskState( + taskId, + context.setAppState, + t => + t.status === 'running' + ? { ...t, status: 'completed', endTime: Date.now() } + : t, + ) + enqueueRemoteNotification( + taskId, + completionResult, + 'completed', + context.setAppState, + task.toolUseId, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return } } @@ -614,7 +774,10 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // drive completion — startDetachedPoll owns that via ExitPlanMode scan. // Long-running monitors (autofix-pr) emit result per notification cycle, // so the same skip applies. - const result = task.isUltraplan || task.isLongRunning ? undefined : accumulatedLog.findLast(msg => msg.type === 'result'); + const result = + task.isUltraplan || task.isLongRunning + ? undefined + : accumulatedLog.findLast(msg => msg.type === 'result') // For remote-review: in hook_progress stdout is the // bughunter path's completion signal. Scan only the delta to stay O(new); @@ -624,36 +787,41 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // nothing. Require STABLE_IDLE_POLLS consecutive idle polls with no log // growth. if (task.isRemoteReview && logGrew && cachedReviewContent === null) { - cachedReviewContent = extractReviewTagFromLog(response.newEvents); + cachedReviewContent = extractReviewTagFromLog(response.newEvents) } // Parse live progress counts from the orchestrator's heartbeat echoes. // hook_progress stdout is cumulative (every echo since hook start), so // each event contains all progress tags. Grab the LAST occurrence — // extractTag returns the first match which would always be the earliest // value (0/0). - let newProgress: RemoteAgentTaskState['reviewProgress']; + let newProgress: RemoteAgentTaskState['reviewProgress'] if (task.isRemoteReview && logGrew) { - const open = `<${REMOTE_REVIEW_PROGRESS_TAG}>`; - const close = ``; + const open = `<${REMOTE_REVIEW_PROGRESS_TAG}>` + const close = `` for (const ev of response.newEvents) { - if (ev.type === 'system' && ((ev as SDKSystemMessageWithFields).subtype === 'hook_progress' || (ev as SDKSystemMessageWithFields).subtype === 'hook_response')) { - const s = (ev as SDKSystemMessageWithFields).stdout; - const closeAt = s.lastIndexOf(close); - const openAt = closeAt === -1 ? -1 : s.lastIndexOf(open, closeAt); + if ( + ev.type === 'system' && + (ev.subtype === 'hook_progress' || ev.subtype === 'hook_response') + ) { + const s = ev.stdout + const closeAt = s.lastIndexOf(close) + const openAt = closeAt === -1 ? -1 : s.lastIndexOf(open, closeAt) if (openAt !== -1 && closeAt > openAt) { try { - const p = JSON.parse(s.slice(openAt + open.length, closeAt)) as { - stage?: 'finding' | 'verifying' | 'synthesizing'; - bugs_found?: number; - bugs_verified?: number; - bugs_refuted?: number; - }; + const p = JSON.parse( + s.slice(openAt + open.length, closeAt), + ) as { + stage?: 'finding' | 'verifying' | 'synthesizing' + bugs_found?: number + bugs_verified?: number + bugs_refuted?: number + } newProgress = { stage: p.stage, bugsFound: p.bugs_found ?? 0, bugsVerified: p.bugs_verified ?? 0, - bugsRefuted: p.bugs_refuted ?? 0 - }; + bugsRefuted: p.bugs_refuted ?? 0, + } } catch { // ignore malformed progress } @@ -664,13 +832,20 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // Hook events count as output only for remote-review — bughunter's // SessionStart hook produces zero assistant turns so stableIdle would // never arm without this. - const hasAnyOutput = accumulatedLog.some(msg => msg.type === 'assistant' || task.isRemoteReview && msg.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')); + const hasAnyOutput = accumulatedLog.some( + msg => + msg.type === 'assistant' || + (task.isRemoteReview && + msg.type === 'system' && + (msg.subtype === 'hook_progress' || + msg.subtype === 'hook_response')), + ) if (response.sessionStatus === 'idle' && !logGrew && hasAnyOutput) { - consecutiveIdlePolls++; + consecutiveIdlePolls++ } else { - consecutiveIdlePolls = 0; + consecutiveIdlePolls = 0 } - const stableIdle = consecutiveIdlePolls >= STABLE_IDLE_POLLS; + const stableIdle = consecutiveIdlePolls >= STABLE_IDLE_POLLS // stableIdle is a prompt-mode completion signal (Claude stops writing // → session idles → done). In bughunter mode the session is "idle" the // entire time the SessionStart hook runs; the previous guard checked @@ -685,50 +860,79 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // in prompt mode from blocking stableIdle — the code_review container // only registers SessionStart, but the 30min-hang failure mode is // worth defending against. - const hasSessionStartHook = accumulatedLog.some(m => m.type === 'system' && (m.subtype === 'hook_started' || m.subtype === 'hook_progress' || m.subtype === 'hook_response') && (m as { - hook_event?: string; - }).hook_event === 'SessionStart'); - const hasAssistantEvents = accumulatedLog.some(m => m.type === 'assistant'); - const sessionDone = task.isRemoteReview && (cachedReviewContent !== null || !hasSessionStartHook && stableIdle && hasAssistantEvents); - const reviewTimedOut = task.isRemoteReview && Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS; - const newStatus = result ? result.subtype === 'success' ? 'completed' as const : 'failed' as const : sessionDone || reviewTimedOut ? 'completed' as const : accumulatedLog.length > 0 ? 'running' as const : 'starting' as const; + const hasSessionStartHook = accumulatedLog.some( + m => + m.type === 'system' && + (m.subtype === 'hook_started' || + m.subtype === 'hook_progress' || + m.subtype === 'hook_response') && + (m as { hook_event?: string }).hook_event === 'SessionStart', + ) + const hasAssistantEvents = accumulatedLog.some( + m => m.type === 'assistant', + ) + const sessionDone = + task.isRemoteReview && + (cachedReviewContent !== null || + (!hasSessionStartHook && stableIdle && hasAssistantEvents)) + const reviewTimedOut = + task.isRemoteReview && + Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS + const newStatus = result + ? result.subtype === 'success' + ? ('completed' as const) + : ('failed' as const) + : sessionDone || reviewTimedOut + ? ('completed' as const) + : accumulatedLog.length > 0 + ? ('running' as const) + : ('starting' as const) // Update task state. Guard against terminal states — if stopTask raced // while pollRemoteSessionEvents was in-flight (status set to 'killed', // notified set to true), bail without overwriting status or proceeding to // side effects (notification, permission-mode flip). - let raceTerminated = false; - updateTaskState(taskId, context.setAppState, prevTask => { - if (prevTask.status !== 'running') { - raceTerminated = true; - return prevTask; - } - // No log growth and status unchanged → nothing to report. Return - // same ref so updateTaskState skips the spread and 18 s.tasks - // subscribers (REPL, Spinner, PromptInput, ...) don't re-render. - // newProgress only arrives via log growth (heartbeat echo is a - // hook_progress event), so !logGrew already covers no-update. - const statusUnchanged = newStatus === 'running' || newStatus === 'starting'; - if (!logGrew && statusUnchanged) { - return prevTask; - } - return { - ...prevTask, - status: newStatus === 'starting' ? 'running' : newStatus, - log: accumulatedLog, - // Only re-scan for TodoWrite when log grew — log is append-only, - // so no growth means no new tool_use blocks. Avoids findLast + - // some + find + safeParse every second when idle. - todoList: logGrew ? extractTodoListFromLog(accumulatedLog) : prevTask.todoList, - reviewProgress: newProgress ?? prevTask.reviewProgress, - endTime: result || sessionDone || reviewTimedOut ? Date.now() : undefined - }; - }); - if (raceTerminated) return; + let raceTerminated = false + updateTaskState( + taskId, + context.setAppState, + prevTask => { + if (prevTask.status !== 'running') { + raceTerminated = true + return prevTask + } + // No log growth and status unchanged → nothing to report. Return + // same ref so updateTaskState skips the spread and 18 s.tasks + // subscribers (REPL, Spinner, PromptInput, ...) don't re-render. + // newProgress only arrives via log growth (heartbeat echo is a + // hook_progress event), so !logGrew already covers no-update. + const statusUnchanged = + newStatus === 'running' || newStatus === 'starting' + if (!logGrew && statusUnchanged) { + return prevTask + } + return { + ...prevTask, + status: newStatus === 'starting' ? 'running' : newStatus, + log: accumulatedLog, + // Only re-scan for TodoWrite when log grew — log is append-only, + // so no growth means no new tool_use blocks. Avoids findLast + + // some + find + safeParse every second when idle. + todoList: logGrew + ? extractTodoListFromLog(accumulatedLog) + : prevTask.todoList, + reviewProgress: newProgress ?? prevTask.reviewProgress, + endTime: + result || sessionDone || reviewTimedOut ? Date.now() : undefined, + } + }, + ) + if (raceTerminated) return // Send notification if task completed or timed out if (result || sessionDone || reviewTimedOut) { - const finalStatus = result && result.subtype !== 'success' ? 'failed' : 'completed'; + const finalStatus = + result && result.subtype !== 'success' ? 'failed' : 'completed' // For remote-review tasks: inject the review text directly into the // message queue. No mode change, no file indirection — the local model @@ -740,50 +944,81 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // cachedReviewContent hit the tag in the delta scan. Full-log scan // catches the stableIdle path where the tag arrived in an earlier // tick but the delta scan wasn't wired yet (first poll after resume). - const reviewContent = cachedReviewContent ?? extractReviewFromLog(accumulatedLog); + const reviewContent = + cachedReviewContent ?? extractReviewFromLog(accumulatedLog) if (reviewContent && finalStatus === 'completed') { - enqueueRemoteReviewNotification(taskId, reviewContent, context.setAppState); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + enqueueRemoteReviewNotification( + taskId, + reviewContent, + context.setAppState, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } // No output or remote error — mark failed with a review-specific message. - updateTaskState(taskId, context.setAppState, t => ({ + updateTaskState(taskId, context.setAppState, t => ({ ...t, - status: 'failed' - })); - const reason = result && result.subtype !== 'success' ? 'remote session returned an error' : reviewTimedOut && !sessionDone ? 'remote session exceeded 30 minutes' : 'no review output — orchestrator may have exited early'; - enqueueRemoteReviewFailureNotification(taskId, reason, context.setAppState); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + status: 'failed', + })) + const reason = + result && result.subtype !== 'success' + ? 'remote session returned an error' + : reviewTimedOut && !sessionDone + ? 'remote session exceeded 30 minutes' + : 'no review output — orchestrator may have exited early' + enqueueRemoteReviewFailureNotification( + taskId, + reason, + context.setAppState, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } - enqueueRemoteNotification(taskId, task.title, finalStatus, context.setAppState, task.toolUseId); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + + enqueueRemoteNotification( + taskId, + task.title, + finalStatus, + context.setAppState, + task.toolUseId, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } } catch (error) { - logError(error); + logError(error) // Reset so an API error doesn't let non-consecutive idle polls accumulate. - consecutiveIdlePolls = 0; + consecutiveIdlePolls = 0 // Check review timeout even when the API call fails — without this, // persistent API errors skip the timeout check and poll forever. try { - const appState = context.getAppState(); - const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; - if (task?.isRemoteReview && task.status === 'running' && Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS) { - updateTaskState(taskId, context.setAppState, t => ({ + const appState = context.getAppState() + const task = appState.tasks?.[taskId] as + | RemoteAgentTaskState + | undefined + if ( + task?.isRemoteReview && + task.status === 'running' && + Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS + ) { + updateTaskState(taskId, context.setAppState, t => ({ ...t, status: 'failed', - endTime: Date.now() - })); - enqueueRemoteReviewFailureNotification(taskId, 'remote session exceeded 30 minutes', context.setAppState); - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - return; // Stop polling + endTime: Date.now(), + })) + enqueueRemoteReviewFailureNotification( + taskId, + 'remote session exceeded 30 minutes', + context.setAppState, + ) + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + return // Stop polling } } catch { // Best effort — if getAppState fails, continue polling @@ -792,17 +1027,17 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () => // Continue polling if (isRunning) { - setTimeout(poll, POLL_INTERVAL_MS); + setTimeout(poll, POLL_INTERVAL_MS) } - }; + } // Start polling - void poll(); + void poll() // Return cleanup function return () => { - isRunning = false; - }; + isRunning = false + } } /** @@ -816,47 +1051,52 @@ export const RemoteAgentTask: Task = { name: 'RemoteAgentTask', type: 'remote_agent', async kill(taskId, setAppState) { - let toolUseId: string | undefined; - let description: string | undefined; - let sessionId: string | undefined; - let killed = false; + let toolUseId: string | undefined + let description: string | undefined + let sessionId: string | undefined + let killed = false updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task; + return task } - toolUseId = task.toolUseId; - description = task.description; - sessionId = task.sessionId; - killed = true; + toolUseId = task.toolUseId + description = task.description + sessionId = task.sessionId + killed = true return { ...task, status: 'killed', notified: true, - endTime: Date.now() - }; - }); + endTime: Date.now(), + } + }) // Close the task_started bookend for SDK consumers. The poll loop's // early-return when status!=='running' won't emit a notification. if (killed) { emitTaskTerminatedSdk(taskId, 'stopped', { toolUseId, - summary: description - }); + summary: description, + }) // Archive the remote session so it stops consuming cloud resources. if (sessionId) { - void archiveRemoteSession(sessionId).catch(e => logForDebugging(`RemoteAgentTask archive failed: ${String(e)}`)); + void archiveRemoteSession(sessionId).catch(e => + logForDebugging(`RemoteAgentTask archive failed: ${String(e)}`), + ) } } - void evictTaskOutput(taskId); - void removeRemoteAgentMetadata(taskId); - logForDebugging(`RemoteAgentTask ${taskId} killed, archiving session ${sessionId ?? 'unknown'}`); - } -}; + + void evictTaskOutput(taskId) + void removeRemoteAgentMetadata(taskId) + logForDebugging( + `RemoteAgentTask ${taskId} killed, archiving session ${sessionId ?? 'unknown'}`, + ) + }, +} /** * Get the session URL for a remote task. */ export function getRemoteTaskSessionUrl(sessionId: string): string { - return getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL); + return getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL) }