From db1f53169156607f9cce53768a99df6002fe786a Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 4 Apr 2026 21:56:11 +0800 Subject: [PATCH 1/9] =?UTF-8?q?style(B1-1):=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=20ink/buddy/cli/context/screens/tasks/services/keybindings/sta?= =?UTF-8?q?te=20(43=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化:移除分号、React Compiler import、import 多行展开。 修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。 Co-Authored-By: Claude Opus 4.6 --- src/buddy/CompanionSprite.tsx | 556 +- src/buddy/useBuddyNotification.tsx | 132 +- src/cli/handlers/mcp.tsx | 478 +- src/cli/handlers/util.tsx | 165 +- src/context/QueuedMessageContext.tsx | 97 +- src/context/fpsMetrics.tsx | 49 +- src/context/mailbox.tsx | 52 +- src/context/modalContext.tsx | 47 +- src/context/notifications.tsx | 431 +- src/context/overlayContext.tsx | 133 +- src/context/promptOverlayContext.tsx | 147 +- src/context/stats.tsx | 298 +- src/context/voice.tsx | 99 +- src/ink/Ansi.tsx | 408 +- src/ink/components/AlternateScreen.tsx | 118 +- src/ink/components/App.tsx | 616 +- src/ink/components/Box.tsx | 274 +- src/ink/components/Button.tsx | 269 +- src/ink/components/ClockContext.tsx | 134 +- src/ink/components/ErrorOverview.tsx | 136 +- src/ink/components/Link.tsx | 64 +- src/ink/components/Newline.tsx | 33 +- src/ink/components/NoSelect.tsx | 52 +- src/ink/components/RawAnsi.tsx | 45 +- src/ink/components/ScrollBox.tsx | 311 +- src/ink/components/Spacer.tsx | 15 +- src/ink/components/TerminalFocusContext.tsx | 84 +- src/ink/components/TerminalSizeContext.tsx | 12 +- src/ink/components/Text.tsx | 231 +- src/ink/ink.tsx | 1658 +-- src/keybindings/KeybindingContext.tsx | 323 +- src/keybindings/KeybindingProviderSetup.tsx | 484 +- src/screens/Doctor.tsx | 1070 +- src/screens/REPL.tsx | 9052 ++++++++++------- src/screens/ResumeConversation.tsx | 660 +- src/services/mcp/MCPConnectionManager.tsx | 112 +- src/services/mcpServerApproval.tsx | 51 +- .../remoteManagedSettings/securityCheck.tsx | 92 +- src/state/AppState.tsx | 289 +- .../InProcessTeammateTask.tsx | 107 +- src/tasks/LocalAgentTask/LocalAgentTask.tsx | 733 +- src/tasks/LocalShellTask/LocalShellTask.tsx | 709 +- src/tasks/RemoteAgentTask/RemoteAgentTask.tsx | 990 +- 43 files changed, 12284 insertions(+), 9532 deletions(-) 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]; - } - 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]; + +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} + + + ) } - 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 - }); - } - 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]; + setTick({ tick: 0, forReaction: reaction }) } - 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() { - 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 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 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 MailboxProvider({ children }: Props): React.ReactNode { + const mailbox = useMemo(() => new Mailbox(), []) + return ( + + {children} + + ) } -export function useMailbox() { - const mailbox = useContext(MailboxContext); + +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 + } + return { + ...prev, + notifications: { + queue: prev.notifications.queue, + current: null, + }, } - }; - }); - processQueue(); - }, next.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, next.key, processQueue); + }) + 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, + }, } - }; + } + + // 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, + }, + } + } } - } - // 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] + // 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 } - }; - }); - // 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) + 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]); + }) + + 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 -}; + 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); + 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; -}; + 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); + 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]! } - return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower); + return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower) } -const RESERVOIR_SIZE = 1024; + +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]; - } - return t1; +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; - } - 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); - } - $[3] = children; - $[4] = dimColor; - $[5] = t1; - $[6] = t2; - } else { - t1 = $[5]; - t2 = $[6]; + + if (children === '') { + return null } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; + + const spans = parseToSpans(children) + + if (spans.length === 0) { + return null } - const content = t1; - let t3; - if ($[9] !== content || $[10] !== dimColor) { - t3 = dimColor ? {content} : {content}; - $[9] = content; - $[10] = dimColor; - $[11] = t3; - } else { - t3 = $[11]; + + if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) { + return dimColor ? ( + {spans[0]!.text} + ) : ( + {spans[0]!.text} + ) } - return t3; -}); + + 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 + } + const hasTextProps = hasAnyTextProps(span.props) + + if (hyperlink) { + return hasTextProps ? ( + + + {span.text} + + + ) : ( + + {span.text} + + ) + } + + 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; - } - let t1; - if ($[11] !== children || $[12] !== rest) { - t1 = {children}; - $[11] = children; - $[12] = rest; - $[13] = t1; - } else { - t1 = $[13]; + return ( + + {children} + + ) } - 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); + children: ((state: ButtonState) => React.ReactNode) | React.ReactNode +} + +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, + ) } - }; - $[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]; + }, + [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 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; -} -function _temp(setter) { - return setter(false); + 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; + 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()) { - 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]; + // Wrap in Text to ensure we're in a text context + // (ink-link is a text element like ink-text) + return ( + + {content} + + ) } - return t2; + + 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; - } - 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 null } - 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; - } - let t6; - if ($[0] !== color) { - t6 = color && { - color - }; - $[0] = color; - $[1] = t6; - } else { - t6 = $[1]; - } - let t7; - if ($[2] !== backgroundColor) { - t7 = backgroundColor && { - backgroundColor - }; - $[2] = backgroundColor; - $[3] = t7; - } else { - t7 = $[3]; + return null } - 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]; + + // 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 }), } - 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 - }; + 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') { + } + + // @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; - }; + setRawMode?: (mode: boolean) => void + } if (stdinWithRaw.setRawMode) { - stdinWithRaw.setRawMode(true); + stdinWithRaw.setRawMode(true) } - this.wasRawMode = false; + 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 _temp2(w_0) { - return w_0.severity === "warning"; -} -function _temp(w) { - return w.severity === "error"; +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]) } -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; - } - 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 └ Failed to fetch versions + } + 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 { - 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, { + +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 - }, 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; - } - 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; + return ( + + Checking installation status… + + ) + } + + // 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; - } + 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; + // 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 } - // 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([]); + // 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); - - // 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 + 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) + + // 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)); - } + // 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, + }) - // Restore file history and attribution state from the resumed conversation - restoreSessionStateFromLog(log, setAppState); - if (log.fileHistorySnapshots) { - void copyFileHistoryForResume(log); - } + // Process session start hooks for resume + const hookMessages = await processSessionStartHooks('resume', { + sessionId, + agentType: mainThreadAgentDefinition?.agentType, + model: mainLoopModel, + }) - // 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 - })); + // 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 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); - } + // Restore file history and attribution state from the resumed conversation + restoreSessionStateFromLog(log, setAppState) + if (log.fileHistorySnapshots) { + void copyFileHistoryForResume(log) + } - // 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 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) + } - // Restore target session's costs from the data we read earlier - if (targetSessionCosts) { - setCostStateForRestore(targetSessionCosts); - } + // 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') + } - // 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 ?? []); - } + // Restore target session's costs from the data we read earlier + if (targetSessionCosts) { + setCostStateForRestore(targetSessionCosts) + } - // 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]); + // 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], + ) // 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(); - - // 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; - } + }, [messages, showCostDialog, haveShownCostDialog]) - // Register the callback for when the leader responds - registerSandboxPermissionCallback({ + 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() + + // 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); + // 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) + } + + // 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) } - }); - - // 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); + const existing = + sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? [] + existing.push(cleanup) + sandboxBridgeCleanupRef.current.set(hostPattern.host, existing) + } } - } - }); - }, [setAppState, store]); + }) + }, + [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(); - - // 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' + 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() + + // 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, + reverify, + addNotification, 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, + 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]); + 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.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(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) } - // 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); + } + + // 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 + }, + ) } - } 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); + + // 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 } - }, 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); + + 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, + }) } - } - // 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; - }); + 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') - // 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; + 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) } - 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); - } + + + if (feature('BUDDY')) { + void fireCompanionObserver(messagesRef.current, reaction => + setAppState(prev => + prev.companionReaction === reaction + ? prev + : { ...prev, companionReaction: reaction }, + ), + ) } - 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 }) - ); - } - 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); + + 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(), + }), + ]) } - } - // 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', {}); + 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) } - }); - 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); + + // 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 } - // Pass full conversation history to callback - if (onBeforeQueryCallback && input) { - const shouldProceed = await onBeforeQueryCallback(input, latestMessages); - if (!shouldProceed) { - 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(), + ) } - } - 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 - }; - }); + 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) } - // 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() - }; + // Pass full conversation history to callback + if (onBeforeQueryCallback && input) { + const shouldProceed = await onBeforeQueryCallback( + input, + latestMessages, + ) + if (!shouldProceed) { + return } - 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; + 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(), + } } - // Always update budget — later turns may carry the actual budget - if (budgetInfo) { - swarmBudgetInfoRef.current = budgetInfo; + 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), + ), + ]) } - } 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) } - // 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); + // 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(); - - // 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; + 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() } - 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({}); + + // 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 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)}`)); + + 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) + + // 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, + }) } - }; - - // 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); - - // 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 + 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; - } + // Remote mode: skip empty input early before any state mutations + if (activeRemote.isRemoteMode && !input.trim()) { + 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) { - 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()); + // 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 + } + } } - } - // 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) { + // 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(); + 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()) + } } - // 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}`); - }); - }) - })); + // 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({}) } - } - // 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); + 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}`, + ) + }) + }), + })) + } } - 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 - }); + // 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) } - 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 - }); + 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]); - - // Send to remote session - await activeRemote.sendMessage(remoteContent, { - uuid: userMessage.uuid - }); - return; - } + // Ensure SessionStart hook context is available before the first API call. + await awaitPendingHooks() - // Ensure SessionStart hook context is available before the first API call. - await awaitPendingHooks(); - await handlePromptSubmit({ - input, - helpers, + 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, + }) + + // 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 */ - } - - // 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 + 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 */ } - })); - }, [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) + + const r = textForResubmit(message) + if (r) { + setInputValue(r.text) + setInputMode(r.mode) + } - // 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); + // 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; + }, + [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, + addNotification, setAppState, - querySource: getQuerySourceForREPL(), onBeforeQuery, - canUseTool, - addNotification, - setMessages, - queuedCommands - }); - }, [queryGuard, commands, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, canUseTool, setAbortController, onQuery, addNotification, 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() + + 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]); + // 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; - - // 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); - - // 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 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) + + // 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); - - // 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); - }} />} + + )} + {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) + + // 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; - - // 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 - }) - }; - }); - }} />} + {focusedInputDialog === 'worker-sandbox-permission' && ( + { + const { allow, persistToSettings } = response + const currentRequest = workerSandboxPermissions.queue[0] + if (!currentRequest) return + + const approvedHost = currentRequest.host + + // 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 === 'lsp-recommendation' && lspRecommendation && } - - {focusedInputDialog === 'desktop-upsell' && setShowDesktopUpsellStartup(false)} />} - - {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} + {focusedInputDialog === 'plugin-hint' && hintRecommendation && ( + + )} + + {focusedInputDialog === 'lsp-recommendation' && + lspRecommendation && ( + + )} + + {focusedInputDialog === 'desktop-upsell' && ( + setShowDesktopUpsellStartup(false)} + /> + )} + + {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} {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); - } - } - - // 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' && } + { + } + + + + )} + {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) + } + } + + // 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 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 _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]; + return } - 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; + + return ( + loadLogs(showAllProjects) : undefined + } + onLoadMore={loadMoreLogs} + initialSearchQuery={initialSearchQuery} + showAllProjects={showAllProjects} + onToggleAllProjects={handleToggleAllProjects} + onAgenticSearch={agenticSessionSearch} + /> + ) } -function _temp3() { - const timeout = setTimeout(_temp2, 100); - return () => clearTimeout(timeout); + +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 _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"); - } - let t1; - if ($[0] !== initialState || $[1] !== onChangeAppState) { - t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState); - $[0] = initialState; - $[1] = onChangeAppState; - $[2] = t1; - } else { - t1 = $[2]; + throw new Error( + 'AppStateProvider can not be nested within another AppStateProvider', + ) } - 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]; + 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 ?? ''); + 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) } From 141ed7a76ee1f011bdfe4e5d7d82daaa03951434 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 4 Apr 2026 21:56:36 +0800 Subject: [PATCH 2/9] =?UTF-8?q?style(B1-2):=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=20commands=20(79=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 --- src/commands/add-dir/add-dir.tsx | 215 +- src/commands/agents/agents.tsx | 25 +- src/commands/bridge/bridge.tsx | 683 ++-- src/commands/btw/btw.tsx | 385 +- src/commands/chrome/chrome.tsx | 506 ++- src/commands/config/config.tsx | 11 +- src/commands/context/context.tsx | 79 +- src/commands/copy/copy.tsx | 555 ++- src/commands/desktop/desktop.tsx | 18 +- src/commands/diff/diff.tsx | 13 +- src/commands/doctor/doctor.tsx | 11 +- src/commands/effort/effort.tsx | 251 +- src/commands/exit/exit.tsx | 56 +- src/commands/export/export.tsx | 137 +- src/commands/extra-usage/extra-usage.tsx | 39 +- src/commands/fast/fast.tsx | 462 ++- src/commands/feedback/feedback.tsx | 66 +- src/commands/help/help.tsx | 20 +- src/commands/hooks/hooks.tsx | 23 +- src/commands/ide/ide.tsx | 1079 +++--- .../install-github-app/ApiKeyStep.tsx | 370 +- .../CheckExistingSecretStep.tsx | 289 +- .../install-github-app/CheckGitHubStep.tsx | 16 +- .../install-github-app/ChooseRepoStep.tsx | 329 +- .../install-github-app/CreatingStep.tsx | 132 +- src/commands/install-github-app/ErrorStep.tsx | 129 +- .../ExistingWorkflowStep.tsx | 162 +- .../install-github-app/InstallAppStep.tsx | 140 +- .../install-github-app/OAuthFlowStep.tsx | 390 +- .../install-github-app/SuccessStep.tsx | 156 +- .../install-github-app/WarningsStep.tsx | 125 +- .../install-github-app/install-github-app.tsx | 930 +++-- src/commands/install.tsx | 368 +- src/commands/login/login.tsx | 212 +- src/commands/logout/logout.tsx | 110 +- src/commands/mcp/mcp.tsx | 153 +- src/commands/memory/memory.tsx | 115 +- src/commands/mobile/mobile.tsx | 374 +- src/commands/model/model.tsx | 479 +-- src/commands/output-style/output-style.tsx | 10 +- src/commands/passes/passes.tsx | 33 +- src/commands/permissions/permissions.tsx | 25 +- src/commands/plan/plan.tsx | 180 +- src/commands/plugin/AddMarketplace.tsx | 209 +- src/commands/plugin/BrowseMarketplace.tsx | 1158 +++--- src/commands/plugin/DiscoverPlugins.tsx | 1184 +++--- src/commands/plugin/ManageMarketplaces.tsx | 1145 +++--- src/commands/plugin/ManagePlugins.tsx | 3183 ++++++++++------- src/commands/plugin/PluginErrors.tsx | 145 +- src/commands/plugin/PluginOptionsDialog.tsx | 508 +-- src/commands/plugin/PluginOptionsFlow.tsx | 140 +- src/commands/plugin/PluginSettings.tsx | 1722 +++++---- src/commands/plugin/PluginTrustWarning.tsx | 49 +- src/commands/plugin/UnifiedInstalledCell.tsx | 695 +--- src/commands/plugin/ValidatePlugin.tsx | 186 +- src/commands/plugin/index.tsx | 10 +- src/commands/plugin/plugin.tsx | 15 +- src/commands/plugin/pluginDetailsHelpers.tsx | 165 +- .../privacy-settings/privacy-settings.tsx | 103 +- .../rate-limit-options/rate-limit-options.tsx | 354 +- src/commands/remote-env/remote-env.tsx | 13 +- src/commands/remote-setup/remote-setup.tsx | 256 +- src/commands/resume/resume.tsx | 420 ++- .../review/UltrareviewOverageDialog.tsx | 158 +- src/commands/review/ultrareviewCommand.tsx | 104 +- .../sandbox-toggle/sandbox-toggle.tsx | 131 +- src/commands/session/session.tsx | 212 +- src/commands/skills/skills.tsx | 16 +- src/commands/stats/stats.tsx | 11 +- src/commands/status/status.tsx | 16 +- src/commands/statusline.tsx | 36 +- src/commands/tag/tag.tsx | 355 +- src/commands/tasks/tasks.tsx | 16 +- src/commands/terminalSetup/terminalSetup.tsx | 658 ++-- src/commands/theme/theme.tsx | 86 +- src/commands/thinkback/thinkback.tsx | 832 +++-- src/commands/ultraplan.tsx | 531 +-- src/commands/upgrade/upgrade.tsx | 80 +- src/commands/usage/usage.tsx | 11 +- 79 files changed, 12636 insertions(+), 12138 deletions(-) diff --git a/src/commands/add-dir/add-dir.tsx b/src/commands/add-dir/add-dir.tsx index 49304d2dc..cfb6c6687 100644 --- a/src/commands/add-dir/add-dir.tsx +++ b/src/commands/add-dir/add-dir.tsx @@ -1,125 +1,154 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import React, { useEffect } from 'react'; -import { getAdditionalDirectoriesForClaudeMd, setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js'; -import { Box, Text } from '../../ink.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import { applyPermissionUpdate, persistPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { addDirHelpMessage, validateDirectoryForWorkspace } from './validation.js'; -function AddDirError(t0) { - const $ = _c(10); - const { - message, - args, - onDone - } = t0; - let t1; - let t2; - if ($[0] !== onDone) { - t1 = () => { - const timer = setTimeout(onDone, 0); - return () => clearTimeout(timer); - }; - t2 = [onDone]; - $[0] = onDone; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - let t3; - if ($[3] !== args) { - t3 = {figures.pointer} /add-dir {args}; - $[3] = args; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== message) { - t4 = {message}; - $[5] = message; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t3 || $[8] !== t4) { - t5 = {t3}{t4}; - $[7] = t3; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; +import chalk from 'chalk' +import figures from 'figures' +import React, { useEffect } from 'react' +import { + getAdditionalDirectoriesForClaudeMd, + setAdditionalDirectoriesForClaudeMd, +} from '../../bootstrap/state.js' +import type { LocalJSXCommandContext } from '../../commands.js' +import { MessageResponse } from '../../components/MessageResponse.js' +import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js' +import { Box, Text } from '../../ink.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import { + applyPermissionUpdate, + persistPermissionUpdate, +} from '../../utils/permissions/PermissionUpdate.js' +import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { + addDirHelpMessage, + validateDirectoryForWorkspace, +} from './validation.js' + +function AddDirError({ + message, + args, + onDone, +}: { + message: string + args: string + onDone: () => void +}): React.ReactNode { + useEffect(() => { + // We need to defer calling onDone to avoid the "return null" bug where + // the component unmounts before React can render the error message. + // Using setTimeout ensures the error displays before the command exits. + const timer = setTimeout(onDone, 0) + return () => clearTimeout(timer) + }, [onDone]) + + return ( + + + {figures.pointer} /add-dir {args} + + + {message} + + + ) } -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args?: string): Promise { - const directoryPath = (args ?? '').trim(); - const appState = context.getAppState(); + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, + args?: string, +): Promise { + const directoryPath = (args ?? '').trim() + const appState = context.getAppState() // Helper to handle adding a directory (shared by both with-path and no-path cases) const handleAddDirectory = async (path: string, remember = false) => { - const destination: PermissionUpdateDestination = remember ? 'localSettings' : 'session'; + const destination: PermissionUpdateDestination = remember + ? 'localSettings' + : 'session' + const permissionUpdate = { type: 'addDirectories' as const, directories: [path], - destination - }; + destination, + } // Apply to session context - const latestAppState = context.getAppState(); - const updatedContext = applyPermissionUpdate(latestAppState.toolPermissionContext, permissionUpdate); + const latestAppState = context.getAppState() + const updatedContext = applyPermissionUpdate( + latestAppState.toolPermissionContext, + permissionUpdate, + ) context.setAppState(prev => ({ ...prev, - toolPermissionContext: updatedContext - })); + toolPermissionContext: updatedContext, + })) // Update sandbox config so Bash commands can access the new directory. // Bootstrap state is the source of truth for session-only dirs; persisted // dirs are picked up via the settings subscription, but we refresh // eagerly here to avoid a race when the user acts immediately. - const currentDirs = getAdditionalDirectoriesForClaudeMd(); + const currentDirs = getAdditionalDirectoriesForClaudeMd() if (!currentDirs.includes(path)) { - setAdditionalDirectoriesForClaudeMd([...currentDirs, path]); + setAdditionalDirectoriesForClaudeMd([...currentDirs, path]) } - SandboxManager.refreshConfig(); - let message: string; + SandboxManager.refreshConfig() + + let message: string + if (remember) { try { - persistPermissionUpdate(permissionUpdate); - message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`; + persistPermissionUpdate(permissionUpdate) + message = `Added ${chalk.bold(path)} as a working directory and saved to local settings` } catch (error) { - message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`; + message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}` } } else { - message = `Added ${chalk.bold(path)} as a working directory for this session`; + message = `Added ${chalk.bold(path)} as a working directory for this session` } - const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`; - onDone(messageWithHint); - }; + + const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}` + onDone(messageWithHint) + } // When no path is provided, show AddWorkspaceDirectory input form directly // and return to REPL after confirmation if (!directoryPath) { - return { - onDone('Did not add a working directory.'); - }} />; + return ( + { + onDone('Did not add a working directory.') + }} + /> + ) } - const result = await validateDirectoryForWorkspace(directoryPath, appState.toolPermissionContext); + + const result = await validateDirectoryForWorkspace( + directoryPath, + appState.toolPermissionContext, + ) + if (result.resultType !== 'success') { - const message = addDirHelpMessage(result); - return onDone(message)} />; + const message = addDirHelpMessage(result) + + return ( + onDone(message)} + /> + ) } - return { - onDone(`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`); - }} />; + + return ( + { + onDone( + `Did not add ${chalk.bold(result.absolutePath)} as a working directory.`, + ) + }} + /> + ) } diff --git a/src/commands/agents/agents.tsx b/src/commands/agents/agents.tsx index 1d2c55974..6a5931756 100644 --- a/src/commands/agents/agents.tsx +++ b/src/commands/agents/agents.tsx @@ -1,11 +1,16 @@ -import * as React from 'react'; -import { AgentsMenu } from '../../components/agents/AgentsMenu.js'; -import type { ToolUseContext } from '../../Tool.js'; -import { getTools } from '../../tools.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise { - const appState = context.getAppState(); - const permissionContext = appState.toolPermissionContext; - const tools = getTools(permissionContext); - return ; +import * as React from 'react' +import { AgentsMenu } from '../../components/agents/AgentsMenu.js' +import type { ToolUseContext } from '../../Tool.js' +import { getTools } from '../../tools.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext, +): Promise { + const appState = context.getAppState() + const permissionContext = appState.toolPermissionContext + const tools = getTools(permissionContext) + + return } diff --git a/src/commands/bridge/bridge.tsx b/src/commands/bridge/bridge.tsx index dadf1c89f..33a681202 100644 --- a/src/commands/bridge/bridge.tsx +++ b/src/commands/bridge/bridge.tsx @@ -1,27 +1,40 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import { toString as qrToString } from 'qrcode'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js'; -import { checkBridgeMinVersion, getBridgeDisabledReason, isEnvLessBridgeEnabled } from '../../bridge/bridgeEnabled.js'; -import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js'; -import { BRIDGE_LOGIN_INSTRUCTION, REMOTE_CONTROL_DISCONNECTED_MSG } from '../../bridge/types.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { ListItem } from '../../components/design-system/ListItem.js'; -import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { useAppState, useSetAppState } from '../../state/AppState.js'; -import type { ToolUseContext } from '../../Tool.js'; -import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; -import { logForDebugging } from '../../utils/debug.js'; +import { feature } from 'bun:bundle' +import { toString as qrToString } from 'qrcode' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js' +import { + checkBridgeMinVersion, + getBridgeDisabledReason, + isEnvLessBridgeEnabled, +} from '../../bridge/bridgeEnabled.js' +import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js' +import { + BRIDGE_LOGIN_INSTRUCTION, + REMOTE_CONTROL_DISCONNECTED_MSG, +} from '../../bridge/types.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { ListItem } from '../../components/design-system/ListItem.js' +import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' +import type { ToolUseContext } from '../../Tool.js' +import type { + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../../types/command.js' +import { logForDebugging } from '../../utils/debug.js' + type Props = { - onDone: LocalJSXCommandOnDone; - name?: string; -}; + onDone: LocalJSXCommandOnDone + name?: string +} /** * /remote-control command — manages the bidirectional bridge connection. @@ -35,392 +48,194 @@ type Props = { * Running /remote-control when already connected shows a dialog with the session * URL and options to disconnect or continue. */ -function BridgeToggle(t0) { - const $ = _c(10); - const { - onDone, - name - } = t0; - const setAppState = useSetAppState(); - const replBridgeConnected = useAppState(_temp); - const replBridgeEnabled = useAppState(_temp2); - const replBridgeOutboundOnly = useAppState(_temp3); - const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); - let t1; - if ($[0] !== name || $[1] !== onDone || $[2] !== replBridgeConnected || $[3] !== replBridgeEnabled || $[4] !== replBridgeOutboundOnly || $[5] !== setAppState) { - t1 = () => { - if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) { - setShowDisconnectDialog(true); - return; +function BridgeToggle({ onDone, name }: Props): React.ReactNode { + const setAppState = useSetAppState() + const replBridgeConnected = useAppState(s => s.replBridgeConnected) + const replBridgeEnabled = useAppState(s => s.replBridgeEnabled) + const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly) + const [showDisconnectDialog, setShowDisconnectDialog] = useState(false) + + // biome-ignore lint/correctness/useExhaustiveDependencies: bridge starts once, should not restart on state changes + useEffect(() => { + // If already connected or enabled in full bidirectional mode, show + // disconnect confirmation. Outbound-only (CCR mirror) doesn't count — + // /remote-control upgrades it to full RC instead. + if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) { + setShowDisconnectDialog(true) + return + } + + let cancelled = false + void (async () => { + // Pre-flight checks before enabling (awaits GrowthBook init if disk + // cache is stale — so Max users don't get a false "not enabled" error) + const error = await checkBridgePrerequisites() + if (cancelled) return + if (error) { + logEvent('tengu_bridge_command', { + action: + 'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + onDone(error, { display: 'system' }) + return } - let cancelled = false; - (async () => { - const error = await checkBridgePrerequisites(); - if (cancelled) { - return; - } - if (error) { - logEvent("tengu_bridge_command", { - action: "preflight_failed" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - onDone(error, { - display: "system" - }); - return; - } - if (shouldShowRemoteCallout()) { - setAppState(prev => { - if (prev.showRemoteCallout) { - return prev; - } - return { - ...prev, - showRemoteCallout: true, - replBridgeInitialName: name - }; - }); - onDone("", { - display: "system" - }); - return; - } - logEvent("tengu_bridge_command", { - action: "connect" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - setAppState(prev_0 => { - if (prev_0.replBridgeEnabled && !prev_0.replBridgeOutboundOnly) { - return prev_0; - } + + // Show first-time remote dialog if not yet seen. + // Store the name now so it's in AppState when the callout handler later + // enables the bridge (the handler only sets replBridgeEnabled, not the name). + if (shouldShowRemoteCallout()) { + setAppState(prev => { + if (prev.showRemoteCallout) return prev return { - ...prev_0, - replBridgeEnabled: true, - replBridgeExplicit: true, - replBridgeOutboundOnly: false, - replBridgeInitialName: name - }; - }); - onDone("Remote Control connecting\u2026", { - display: "system" - }); - })(); - return () => { - cancelled = true; - }; - }; - $[0] = name; - $[1] = onDone; - $[2] = replBridgeConnected; - $[3] = replBridgeEnabled; - $[4] = replBridgeOutboundOnly; - $[5] = setAppState; - $[6] = t1; - } else { - t1 = $[6]; - } - let t2; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[7] = t2; - } else { - t2 = $[7]; - } - useEffect(t1, t2); - if (showDisconnectDialog) { - let t3; - if ($[8] !== onDone) { - t3 = ; - $[8] = onDone; - $[9] = t3; - } else { - t3 = $[9]; + ...prev, + showRemoteCallout: true, + replBridgeInitialName: name, + } + }) + onDone('', { display: 'system' }) + return + } + + // Enable the bridge — useReplBridge in REPL.tsx handles the rest: + // registers environment, creates session with conversation, connects WebSocket + logEvent('tengu_bridge_command', { + action: + 'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + setAppState(prev => { + if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev + return { + ...prev, + replBridgeEnabled: true, + replBridgeExplicit: true, + replBridgeOutboundOnly: false, + replBridgeInitialName: name, + } + }) + onDone('Remote Control connecting\u2026', { + display: 'system', + }) + })() + + return () => { + cancelled = true } - return t3; + }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount + + if (showDisconnectDialog) { + return } - return null; + + return null } /** * Dialog shown when /remote-control is used while the bridge is already connected. * Shows the session URL and lets the user disconnect or continue. */ -function _temp3(s_1) { - return s_1.replBridgeOutboundOnly; -} -function _temp2(s_0) { - return s_0.replBridgeEnabled; -} -function _temp(s) { - return s.replBridgeConnected; -} -function BridgeDisconnectDialog(t0) { - const $ = _c(61); - const { - onDone - } = t0; - useRegisterOverlay("bridge-disconnect-dialog", undefined); - const setAppState = useSetAppState(); - const sessionUrl = useAppState(_temp4); - const connectUrl = useAppState(_temp5); - const sessionActive = useAppState(_temp6); - const [focusIndex, setFocusIndex] = useState(2); - const [showQR, setShowQR] = useState(false); - const [qrText, setQrText] = useState(""); - const displayUrl = sessionActive ? sessionUrl : connectUrl; - let t1; - let t2; - if ($[0] !== displayUrl || $[1] !== showQR) { - t1 = () => { - if (!showQR || !displayUrl) { - setQrText(""); - return; +function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { + useRegisterOverlay('bridge-disconnect-dialog') + const setAppState = useSetAppState() + const sessionUrl = useAppState(s => s.replBridgeSessionUrl) + const connectUrl = useAppState(s => s.replBridgeConnectUrl) + const sessionActive = useAppState(s => s.replBridgeSessionActive) + const [focusIndex, setFocusIndex] = useState(2) + const [showQR, setShowQR] = useState(false) + const [qrText, setQrText] = useState('') + + const displayUrl = sessionActive ? sessionUrl : connectUrl + + // Generate QR code when URL changes or QR is toggled on + useEffect(() => { + if (!showQR || !displayUrl) { + setQrText('') + return + } + qrToString(displayUrl, { + type: 'utf8', + errorCorrectionLevel: 'L', + small: true, + }) + .then(setQrText) + .catch(() => setQrText('')) + }, [showQR, displayUrl]) + + function handleDisconnect(): void { + setAppState(prev => { + if (!prev.replBridgeEnabled) return prev + return { + ...prev, + replBridgeEnabled: false, + replBridgeExplicit: false, + replBridgeOutboundOnly: false, } - qrToString(displayUrl, { - type: "utf8", - errorCorrectionLevel: "L", - small: true - }).then(setQrText).catch(() => setQrText("")); - }; - t2 = [showQR, displayUrl]; - $[0] = displayUrl; - $[1] = showQR; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== onDone || $[5] !== setAppState) { - t3 = function handleDisconnect() { - setAppState(_temp7); - logEvent("tengu_bridge_command", { - action: "disconnect" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { - display: "system" - }); - }; - $[4] = onDone; - $[5] = setAppState; - $[6] = t3; - } else { - t3 = $[6]; + }) + logEvent('tengu_bridge_command', { + action: + 'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' }) } - const handleDisconnect = t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = function handleShowQR() { - setShowQR(_temp8); - }; - $[7] = t4; - } else { - t4 = $[7]; - } - const handleShowQR = t4; - let t5; - if ($[8] !== onDone) { - t5 = function handleContinue() { - onDone(undefined, { - display: "skip" - }); - }; - $[8] = onDone; - $[9] = t5; - } else { - t5 = $[9]; + + function handleShowQR(): void { + setShowQR(prev => !prev) } - const handleContinue = t5; - let t6; - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => setFocusIndex(_temp9); - t7 = () => setFocusIndex(_temp0); - $[10] = t6; - $[11] = t7; - } else { - t6 = $[10]; - t7 = $[11]; + + function handleContinue(): void { + onDone(undefined, { display: 'skip' }) } - let t8; - if ($[12] !== focusIndex || $[13] !== handleContinue || $[14] !== handleDisconnect) { - t8 = { - "select:next": t6, - "select:previous": t7, - "select:accept": () => { + + const ITEM_COUNT = 3 + + useKeybindings( + { + 'select:next': () => setFocusIndex(i => (i + 1) % ITEM_COUNT), + 'select:previous': () => + setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT), + 'select:accept': () => { if (focusIndex === 0) { - handleDisconnect(); + handleDisconnect() + } else if (focusIndex === 1) { + handleShowQR() } else { - if (focusIndex === 1) { - handleShowQR(); - } else { - handleContinue(); - } + handleContinue() } - } - }; - $[12] = focusIndex; - $[13] = handleContinue; - $[14] = handleDisconnect; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t9 = { - context: "Select" - }; - $[16] = t9; - } else { - t9 = $[16]; - } - useKeybindings(t8, t9); - let T0; - let T1; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - if ($[17] !== displayUrl || $[18] !== handleContinue || $[19] !== qrText || $[20] !== showQR) { - const qrLines = qrText ? qrText.split("\n").filter(_temp1) : []; - T1 = Dialog; - t14 = "Remote Control"; - t15 = handleContinue; - t16 = true; - T0 = Box; - t10 = "column"; - t11 = 1; - const t17 = displayUrl ? ` at ${displayUrl}` : ""; - if ($[30] !== t17) { - t12 = This session is available via Remote Control{t17}.; - $[30] = t17; - $[31] = t12; - } else { - t12 = $[31]; - } - t13 = showQR && qrLines.length > 0 && {qrLines.map(_temp10)}; - $[17] = displayUrl; - $[18] = handleContinue; - $[19] = qrText; - $[20] = showQR; - $[21] = T0; - $[22] = T1; - $[23] = t10; - $[24] = t11; - $[25] = t12; - $[26] = t13; - $[27] = t14; - $[28] = t15; - $[29] = t16; - } else { - T0 = $[21]; - T1 = $[22]; - t10 = $[23]; - t11 = $[24]; - t12 = $[25]; - t13 = $[26]; - t14 = $[27]; - t15 = $[28]; - t16 = $[29]; - } - const t17 = focusIndex === 0; - let t18; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t18 = Disconnect this session; - $[32] = t18; - } else { - t18 = $[32]; - } - let t19; - if ($[33] !== t17) { - t19 = {t18}; - $[33] = t17; - $[34] = t19; - } else { - t19 = $[34]; - } - const t20 = focusIndex === 1; - const t21 = showQR ? "Hide QR code" : "Show QR code"; - let t22; - if ($[35] !== t21) { - t22 = {t21}; - $[35] = t21; - $[36] = t22; - } else { - t22 = $[36]; - } - let t23; - if ($[37] !== t20 || $[38] !== t22) { - t23 = {t22}; - $[37] = t20; - $[38] = t22; - $[39] = t23; - } else { - t23 = $[39]; - } - const t24 = focusIndex === 2; - let t25; - if ($[40] === Symbol.for("react.memo_cache_sentinel")) { - t25 = Continue; - $[40] = t25; - } else { - t25 = $[40]; - } - let t26; - if ($[41] !== t24) { - t26 = {t25}; - $[41] = t24; - $[42] = t26; - } else { - t26 = $[42]; - } - let t27; - if ($[43] !== t19 || $[44] !== t23 || $[45] !== t26) { - t27 = {t19}{t23}{t26}; - $[43] = t19; - $[44] = t23; - $[45] = t26; - $[46] = t27; - } else { - t27 = $[46]; - } - let t28; - if ($[47] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Enter to select · Esc to continue; - $[47] = t28; - } else { - t28 = $[47]; - } - let t29; - if ($[48] !== T0 || $[49] !== t10 || $[50] !== t11 || $[51] !== t12 || $[52] !== t13 || $[53] !== t27) { - t29 = {t12}{t13}{t27}{t28}; - $[48] = T0; - $[49] = t10; - $[50] = t11; - $[51] = t12; - $[52] = t13; - $[53] = t27; - $[54] = t29; - } else { - t29 = $[54]; - } - let t30; - if ($[55] !== T1 || $[56] !== t14 || $[57] !== t15 || $[58] !== t16 || $[59] !== t29) { - t30 = {t29}; - $[55] = T1; - $[56] = t14; - $[57] = t15; - $[58] = t16; - $[59] = t29; - $[60] = t30; - } else { - t30 = $[60]; - } - return t30; + }, + }, + { context: 'Select' }, + ) + + const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [] + + return ( + + + + This session is available via Remote Control + {displayUrl ? ` at ${displayUrl}` : ''}. + + {showQR && qrLines.length > 0 && ( + + {qrLines.map((line, i) => ( + {line} + ))} + + )} + + + Disconnect this session + + + {showQR ? 'Hide QR code' : 'Show QR code'} + + + Continue + + + Enter to select · Esc to continue + + + ) } /** @@ -429,80 +244,52 @@ function BridgeDisconnectDialog(t0) { * cache is stale, so a user who just became entitled (e.g. upgraded to Max, * or the flag just launched) gets an accurate result on the first try. */ -function _temp10(line, i_1) { - return {line}; -} -function _temp1(l) { - return l.length > 0; -} -function _temp0(i_0) { - return (i_0 - 1 + 3) % 3; -} -function _temp9(i) { - return (i + 1) % 3; -} -function _temp8(prev_0) { - return !prev_0; -} -function _temp7(prev) { - if (!prev.replBridgeEnabled) { - return prev; - } - return { - ...prev, - replBridgeEnabled: false, - replBridgeExplicit: false, - replBridgeOutboundOnly: false - }; -} -function _temp6(s_1) { - return s_1.replBridgeSessionActive; -} -function _temp5(s_0) { - return s_0.replBridgeConnectUrl; -} -function _temp4(s) { - return s.replBridgeSessionUrl; -} async function checkBridgePrerequisites(): Promise { // Check organization policy — remote control may be disabled - const { - waitForPolicyLimitsToLoad, - isPolicyAllowed - } = await import('../../services/policyLimits/index.js'); - await waitForPolicyLimitsToLoad(); + const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import( + '../../services/policyLimits/index.js' + ) + await waitForPolicyLimitsToLoad() if (!isPolicyAllowed('allow_remote_control')) { - return "Remote Control is disabled by your organization's policy."; + return "Remote Control is disabled by your organization's policy." } - const disabledReason = await getBridgeDisabledReason(); + + const disabledReason = await getBridgeDisabledReason() if (disabledReason) { - return disabledReason; + return disabledReason } // Mirror the v1/v2 branching logic in initReplBridge: env-less (v2) is used // only when the flag is on AND the session is not perpetual. In assistant // mode (KAIROS) useReplBridge sets perpetual=true, which forces // initReplBridge onto the v1 path — so the prerequisite check must match. - let useV2 = isEnvLessBridgeEnabled(); + let useV2 = isEnvLessBridgeEnabled() if (feature('KAIROS') && useV2) { - const { - isAssistantMode - } = await import('../../assistant/index.js'); + const { isAssistantMode } = await import('../../assistant/index.js') if (isAssistantMode()) { - useV2 = false; + useV2 = false } } - const versionError = useV2 ? await checkEnvLessBridgeMinVersion() : checkBridgeMinVersion(); + const versionError = useV2 + ? await checkEnvLessBridgeMinVersion() + : checkBridgeMinVersion() if (versionError) { - return versionError; + return versionError } + if (!getBridgeAccessToken()) { - return BRIDGE_LOGIN_INSTRUCTION; + return BRIDGE_LOGIN_INSTRUCTION } - logForDebugging('[bridge] Prerequisites passed, enabling bridge'); - return null; + + logForDebugging('[bridge] Prerequisites passed, enabling bridge') + return null } -export async function call(onDone: LocalJSXCommandOnDone, _context: ToolUseContext & LocalJSXCommandContext, args: string): Promise { - const name = args.trim() || undefined; - return ; + +export async function call( + onDone: LocalJSXCommandOnDone, + _context: ToolUseContext & LocalJSXCommandContext, + args: string, +): Promise { + const name = args.trim() || undefined + return } diff --git a/src/commands/btw/btw.tsx b/src/commands/btw/btw.tsx index bf5daca30..28a83946b 100644 --- a/src/commands/btw/btw.tsx +++ b/src/commands/btw/btw.tsx @@ -1,183 +1,151 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { useInterval } from 'usehooks-ts'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Markdown } from '../../components/Markdown.js'; -import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js'; -import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js'; -import { getSystemPrompt } from '../../constants/prompts.js'; -import { useModalOrTerminalSize } from '../../context/modalContext.js'; -import { getSystemContext, getUserContext } from '../../context.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import ScrollBox, { type ScrollBoxHandle } from '../../ink/components/ScrollBox.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import type { Message } from '../../types/message.js'; -import { createAbortController } from '../../utils/abortController.js'; -import { saveGlobalConfig } from '../../utils/config.js'; -import { errorMessage } from '../../utils/errors.js'; -import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js'; -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; -import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; -import { runSideQuestion } from '../../utils/sideQuestion.js'; -import { asSystemPrompt } from '../../utils/systemPromptType.js'; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { useInterval } from 'usehooks-ts' +import type { CommandResultDisplay } from '../../commands.js' +import { Markdown } from '../../components/Markdown.js' +import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js' +import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js' +import { getSystemPrompt } from '../../constants/prompts.js' +import { useModalOrTerminalSize } from '../../context/modalContext.js' +import { getSystemContext, getUserContext } from '../../context.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import ScrollBox, { + type ScrollBoxHandle, +} from '../../ink/components/ScrollBox.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { Message } from '../../types/message.js' +import { createAbortController } from '../../utils/abortController.js' +import { saveGlobalConfig } from '../../utils/config.js' +import { errorMessage } from '../../utils/errors.js' +import { + type CacheSafeParams, + getLastCacheSafeParams, +} from '../../utils/forkedAgent.js' +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' +import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' +import { runSideQuestion } from '../../utils/sideQuestion.js' +import { asSystemPrompt } from '../../utils/systemPromptType.js' + type BtwComponentProps = { - question: string; - context: ProcessUserInputContext; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -const CHROME_ROWS = 5; -const OUTER_CHROME_ROWS = 6; -const SCROLL_LINES = 3; -function BtwSideQuestion(t0) { - const $ = _c(25); - const { - question, - context, - onDone - } = t0; - const [response, setResponse] = useState(null); - const [error, setError] = useState(null); - const [frame, setFrame] = useState(0); - const scrollRef = useRef(null); - const { - rows - } = useModalOrTerminalSize(useTerminalSize()); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setFrame(_temp); - $[0] = t1; - } else { - t1 = $[0]; - } - useInterval(t1, response || error ? null : 80); - let t2; - if ($[1] !== onDone) { - t2 = function handleKeyDown(e) { - if (e.key === "escape" || e.key === "return" || e.key === " " || e.ctrl && (e.key === "c" || e.key === "d")) { - e.preventDefault(); - onDone(undefined, { - display: "skip" - }); - return; - } - if (e.key === "up" || e.ctrl && e.key === "p") { - e.preventDefault(); - scrollRef.current?.scrollBy(-SCROLL_LINES); - } - if (e.key === "down" || e.ctrl && e.key === "n") { - e.preventDefault(); - scrollRef.current?.scrollBy(SCROLL_LINES); - } - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; + question: string + context: ProcessUserInputContext + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +const CHROME_ROWS = 5 +const OUTER_CHROME_ROWS = 6 +const SCROLL_LINES = 3 + +function BtwSideQuestion({ + question, + context, + onDone, +}: BtwComponentProps): React.ReactNode { + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [frame, setFrame] = useState(0) + const scrollRef = useRef(null) + const { rows } = useModalOrTerminalSize(useTerminalSize()) + + // Animate spinner while loading + useInterval(() => setFrame(f => f + 1), response || error ? null : 80) + + function handleKeyDown(e: KeyboardEvent): void { + if ( + e.key === 'escape' || + e.key === 'return' || + e.key === ' ' || + (e.ctrl && (e.key === 'c' || e.key === 'd')) + ) { + e.preventDefault() + onDone(undefined, { display: 'skip' }) + return + } + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + scrollRef.current?.scrollBy(-SCROLL_LINES) + } + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + scrollRef.current?.scrollBy(SCROLL_LINES) + } } - const handleKeyDown = t2; - let t3; - let t4; - if ($[3] !== context || $[4] !== question) { - t3 = () => { - const abortController = createAbortController(); - const fetchResponse = async function fetchResponse() { - ; - try { - const cacheSafeParams = await buildCacheSafeParams(context); - const result = await runSideQuestion({ - question, - cacheSafeParams - }); - if (!abortController.signal.aborted) { - if (result.response) { - setResponse(result.response); - } else { - setError("No response received"); - } - } - } catch (t5) { - const err = t5; - if (!abortController.signal.aborted) { - setError(errorMessage(err) || "Failed to get response"); + + useEffect(() => { + const abortController = createAbortController() + + async function fetchResponse(): Promise { + try { + const cacheSafeParams = await buildCacheSafeParams(context) + const result = await runSideQuestion({ question, cacheSafeParams }) + + if (!abortController.signal.aborted) { + if (result.response) { + setResponse(result.response) + } else { + setError('No response received') } } - }; - fetchResponse(); - return () => { - abortController.abort(); - }; - }; - t4 = [question, context]; - $[3] = context; - $[4] = question; - $[5] = t3; - $[6] = t4; - } else { - t3 = $[5]; - t4 = $[6]; - } - useEffect(t3, t4); - const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS); - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = /btw{" "}; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== question) { - t6 = {t5}{question}; - $[8] = question; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== error || $[11] !== frame || $[12] !== response) { - t7 = {error ? {error} : response ? {response} : Answering...}; - $[10] = error; - $[11] = frame; - $[12] = response; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] !== maxContentHeight || $[15] !== t7) { - t8 = {t7}; - $[14] = maxContentHeight; - $[15] = t7; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== error || $[18] !== response) { - t9 = (response || error) && {UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to dismiss; - $[17] = error; - $[18] = response; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== handleKeyDown || $[21] !== t6 || $[22] !== t8 || $[23] !== t9) { - t10 = {t6}{t8}{t9}; - $[20] = handleKeyDown; - $[21] = t6; - $[22] = t8; - $[23] = t9; - $[24] = t10; - } else { - t10 = $[24]; - } - return t10; + } catch (err) { + if (!abortController.signal.aborted) { + setError(errorMessage(err) || 'Failed to get response') + } + } + } + + void fetchResponse() + + return () => { + abortController.abort() + } + }, [question, context]) + + const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS) + + return ( + + + + /btw{' '} + + {question} + + + + {error ? ( + {error} + ) : response ? ( + {response} + ) : ( + + + Answering... + + )} + + + {(response || error) && ( + + + {UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to + dismiss + + + )} + + ) } /** @@ -195,48 +163,67 @@ function BtwSideQuestion(t0) { * applied buildEffectiveSystemPrompt extras (--agent, --system-prompt, * --append-system-prompt, coordinator mode). */ -function _temp(f) { - return f + 1; -} function stripInProgressAssistantMessage(messages: Message[]): Message[] { - const last = messages.at(-1); + const last = messages.at(-1) if (last?.type === 'assistant' && last.message.stop_reason === null) { - return messages.slice(0, -1); + return messages.slice(0, -1) } - return messages; + return messages } -async function buildCacheSafeParams(context: ProcessUserInputContext): Promise { - const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages)); - const saved = getLastCacheSafeParams(); + +async function buildCacheSafeParams( + context: ProcessUserInputContext, +): Promise { + const forkContextMessages = getMessagesAfterCompactBoundary( + stripInProgressAssistantMessage(context.messages), + ) + const saved = getLastCacheSafeParams() if (saved) { return { systemPrompt: saved.systemPrompt, userContext: saved.userContext, systemContext: saved.systemContext, toolUseContext: context, - forkContextMessages - }; + forkContextMessages, + } } - const [rawSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(context.options.tools, context.options.mainLoopModel, [], context.options.mcpClients), getUserContext(), getSystemContext()]); + const [rawSystemPrompt, userContext, systemContext] = await Promise.all([ + getSystemPrompt( + context.options.tools, + context.options.mainLoopModel, + [], + context.options.mcpClients, + ), + getUserContext(), + getSystemContext(), + ]) return { systemPrompt: asSystemPrompt(rawSystemPrompt), userContext, systemContext, toolUseContext: context, - forkContextMessages - }; + forkContextMessages, + } } -export async function call(onDone: LocalJSXCommandOnDone, context: ProcessUserInputContext, args: string): Promise { - const question = args?.trim(); + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ProcessUserInputContext, + args: string, +): Promise { + const question = args?.trim() + if (!question) { - onDone('Usage: /btw ', { - display: 'system' - }); - return null; + onDone('Usage: /btw ', { display: 'system' }) + return null } + saveGlobalConfig(current => ({ ...current, - btwUseCount: current.btwUseCount + 1 - })); - return ; + btwUseCount: current.btwUseCount + 1, + })) + + return ( + + ) } diff --git a/src/commands/chrome/chrome.tsx b/src/commands/chrome/chrome.tsx index b659c2e80..3fd0dbca3 100644 --- a/src/commands/chrome/chrome.tsx +++ b/src/commands/chrome/chrome.tsx @@ -1,284 +1,240 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useState } from 'react'; -import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { Box, Text } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import { isClaudeAISubscriber } from '../../utils/auth.js'; -import { openBrowser } from '../../utils/browser.js'; -import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, openInChrome } from '../../utils/claudeInChrome/common.js'; -import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { env } from '../../utils/env.js'; -import { isRunningOnHomespace } from '../../utils/envUtils.js'; -const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'; -const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'; -const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect'; -type MenuAction = 'install-extension' | 'reconnect' | 'manage-permissions' | 'toggle-default'; +import React, { useState } from 'react' +import { + type OptionWithDescription, + Select, +} from '../../components/CustomSelect/select.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { Box, Text } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import { isClaudeAISubscriber } from '../../utils/auth.js' +import { openBrowser } from '../../utils/browser.js' +import { + CLAUDE_IN_CHROME_MCP_SERVER_NAME, + openInChrome, +} from '../../utils/claudeInChrome/common.js' +import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { env } from '../../utils/env.js' +import { isRunningOnHomespace } from '../../utils/envUtils.js' + +const CHROME_EXTENSION_URL = 'https://claude.ai/chrome' +const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions' +const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect' + +type MenuAction = + | 'install-extension' + | 'reconnect' + | 'manage-permissions' + | 'toggle-default' + type Props = { - onDone: (result?: string) => void; - isExtensionInstalled: boolean; - configEnabled: boolean | undefined; - isClaudeAISubscriber: boolean; - isWSL: boolean; -}; -function ClaudeInChromeMenu(t0) { - const $ = _c(41); - const { - onDone, - isExtensionInstalled: installed, - configEnabled, - isClaudeAISubscriber, - isWSL - } = t0; - const mcpClients = useAppState(_temp); - const [selectKey, setSelectKey] = useState(0); - const [enabledByDefault, setEnabledByDefault] = useState(configEnabled ?? false); - const [showInstallHint, setShowInstallHint] = useState(false); - const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = false && isRunningOnHomespace(); - $[0] = t1; - } else { - t1 = $[0]; - } - const isHomespace = t1; - let t2; - if ($[1] !== mcpClients) { - t2 = mcpClients.find(_temp2); - $[1] = mcpClients; - $[2] = t2; - } else { - t2 = $[2]; - } - const chromeClient = t2; - const isConnected = chromeClient?.type === "connected"; - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = function openUrl(url) { - if (isHomespace) { - openBrowser(url); - } else { - openInChrome(url); - } - }; - $[3] = t3; - } else { - t3 = $[3]; + onDone: (result?: string) => void + isExtensionInstalled: boolean + configEnabled: boolean | undefined + isClaudeAISubscriber: boolean + isWSL: boolean +} + +function ClaudeInChromeMenu({ + onDone, + isExtensionInstalled: installed, + configEnabled, + isClaudeAISubscriber, + isWSL, +}: Props): React.ReactNode { + const mcpClients = useAppState(s => s.mcp.clients) + const [selectKey, setSelectKey] = useState(0) + const [enabledByDefault, setEnabledByDefault] = useState( + configEnabled ?? false, + ) + const [showInstallHint, setShowInstallHint] = useState(false) + const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed) + + const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace() + + const chromeClient = mcpClients.find( + c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, + ) + const isConnected = chromeClient?.type === 'connected' + + function openUrl(url: string): void { + if (isHomespace) { + void openBrowser(url) + } else { + void openInChrome(url) + } } - const openUrl = t3; - let t4; - if ($[4] !== enabledByDefault) { - t4 = function handleAction(action) { - bb22: switch (action) { - case "install-extension": - { - setSelectKey(_temp3); - setShowInstallHint(true); - openUrl(CHROME_EXTENSION_URL); - break bb22; - } - case "reconnect": - { - setSelectKey(_temp4); - isChromeExtensionInstalled().then(installed_0 => { - setIsExtensionInstalled(installed_0); - if (installed_0) { - setShowInstallHint(false); - } - }); - openUrl(CHROME_RECONNECT_URL); - break bb22; - } - case "manage-permissions": - { - setSelectKey(_temp5); - openUrl(CHROME_PERMISSIONS_URL); - break bb22; + + function handleAction(action: MenuAction): void { + switch (action) { + case 'install-extension': + setSelectKey(k => k + 1) + setShowInstallHint(true) + openUrl(CHROME_EXTENSION_URL) + break + case 'reconnect': + setSelectKey(k => k + 1) + void isChromeExtensionInstalled().then(installed => { + setIsExtensionInstalled(installed) + if (installed) { + setShowInstallHint(false) } - case "toggle-default": - { - const newValue = !enabledByDefault; - saveGlobalConfig(current => ({ - ...current, - claudeInChromeDefaultEnabled: newValue - })); - setEnabledByDefault(newValue); - } - } - }; - $[4] = enabledByDefault; - $[5] = t4; - } else { - t4 = $[5]; - } - const handleAction = t4; - let options; - if ($[6] !== enabledByDefault || $[7] !== isExtensionInstalled) { - options = []; - const requiresExtensionSuffix = isExtensionInstalled ? "" : " (requires extension)"; - if (!isExtensionInstalled && !isHomespace) { - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Install Chrome extension", - value: "install-extension" - }; - $[9] = t5; - } else { - t5 = $[9]; + }) + openUrl(CHROME_RECONNECT_URL) + break + case 'manage-permissions': + setSelectKey(k => k + 1) + openUrl(CHROME_PERMISSIONS_URL) + break + case 'toggle-default': { + const newValue = !enabledByDefault + saveGlobalConfig(current => ({ + ...current, + claudeInChromeDefaultEnabled: newValue, + })) + setEnabledByDefault(newValue) + break } - options.push(t5); - } - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Manage permissions; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== requiresExtensionSuffix) { - t6 = { - label: <>{t5}{requiresExtensionSuffix}, - value: "manage-permissions" - }; - $[11] = requiresExtensionSuffix; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Reconnect extension; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] !== requiresExtensionSuffix) { - t8 = { - label: <>{t7}{requiresExtensionSuffix}, - value: "reconnect" - }; - $[14] = requiresExtensionSuffix; - $[15] = t8; - } else { - t8 = $[15]; - } - const t9 = `Enabled by default: ${enabledByDefault ? "Yes" : "No"}`; - let t10; - if ($[16] !== t9) { - t10 = { - label: t9, - value: "toggle-default" - }; - $[16] = t9; - $[17] = t10; - } else { - t10 = $[17]; } - options.push(t6, t8, t10); - $[6] = enabledByDefault; - $[7] = isExtensionInstalled; - $[8] = options; - } else { - options = $[8]; - } - const isDisabled = isWSL; - let t5; - if ($[18] !== onDone) { - t5 = () => onDone(); - $[18] = onDone; - $[19] = t5; - } else { - t5 = $[19]; - } - let t6; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.; - $[20] = t6; - } else { - t6 = $[20]; - } - let t7; - if ($[21] !== isWSL) { - t7 = isWSL && Claude in Chrome is not supported in WSL at this time.; - $[21] = isWSL; - $[22] = t7; - } else { - t7 = $[22]; } - let t8; - if ($[23] !== isClaudeAISubscriber) { - t8 = false; - $[23] = isClaudeAISubscriber; - $[24] = t8; - } else { - t8 = $[24]; + + const options: OptionWithDescription[] = [] + const requiresExtensionSuffix = isExtensionInstalled + ? '' + : ' (requires extension)' + + if (!isExtensionInstalled && !isHomespace) { + options.push({ + label: 'Install Chrome extension', + value: 'install-extension', + }) } - let t9; - if ($[25] !== handleAction || $[26] !== isConnected || $[27] !== isDisabled || $[28] !== isExtensionInstalled || $[29] !== options || $[30] !== selectKey || $[31] !== showInstallHint) { - t9 = !isDisabled && <>{!isHomespace && Status:{" "}{isConnected ? Enabled : Disabled}Extension:{" "}{isExtensionInstalled ? Installed : Not detected}} + + {showInstallHint && ( + + Once installed, select {'"Reconnect extension"'} to connect. + + )} + + + Usage: + claude --chrome + or + claude --no-chrome + + + + Site-level permissions are inherited from the Chrome extension. + Manage permissions in the Chrome extension settings to control + which sites Claude can browse, click, and type on. + + + )} + Learn more: https://code.claude.com/docs/en/chrome + + + ) } -function _temp(s) { - return s.mcp.clients; + +export const call = async function ( + onDone: (result?: string) => void, +): Promise { + const isExtensionInstalled = await isChromeExtensionInstalled() + const config = getGlobalConfig() + const isSubscriber = isClaudeAISubscriber() + const isWSL = env.isWslEnvironment() + + return ( + + ) } -export const call = async function (onDone: (result?: string) => void): Promise { - const isExtensionInstalled = await isChromeExtensionInstalled(); - const config = getGlobalConfig(); - const isSubscriber = isClaudeAISubscriber(); - const isWSL = env.isWslEnvironment(); - return ; -}; diff --git a/src/commands/config/config.tsx b/src/commands/config/config.tsx index b263e37ba..d4e216c38 100644 --- a/src/commands/config/config.tsx +++ b/src/commands/config/config.tsx @@ -1,6 +1,7 @@ -import * as React from 'react'; -import { Settings } from '../../components/Settings/Settings.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; +import * as React from 'react' +import { Settings } from '../../components/Settings/Settings.js' +import type { LocalJSXCommandCall } from '../../types/command.js' + export const call: LocalJSXCommandCall = async (onDone, context) => { - return ; -}; + return +} diff --git a/src/commands/context/context.tsx b/src/commands/context/context.tsx index 595a3c594..747c5a9de 100644 --- a/src/commands/context/context.tsx +++ b/src/commands/context/context.tsx @@ -1,13 +1,13 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { ContextVisualization } from '../../components/ContextVisualization.js'; -import { microcompactMessages } from '../../services/compact/microCompact.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import type { Message } from '../../types/message.js'; -import { analyzeContextUsage } from '../../utils/analyzeContext.js'; -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; -import { renderToAnsiString } from '../../utils/staticRender.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import type { LocalJSXCommandContext } from '../../commands.js' +import { ContextVisualization } from '../../components/ContextVisualization.js' +import { microcompactMessages } from '../../services/compact/microCompact.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { Message } from '../../types/message.js' +import { analyzeContextUsage } from '../../utils/analyzeContext.js' +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' +import { renderToAnsiString } from '../../utils/staticRender.js' /** * Apply the same context transforms query.ts does before the API call, so @@ -16,48 +16,53 @@ import { renderToAnsiString } from '../../utils/staticRender.js'; * was collapsed — user sees "180k, 3 spans collapsed" when the API sees 120k. */ function toApiView(messages: Message[]): Message[] { - let view = getMessagesAfterCompactBoundary(messages); + let view = getMessagesAfterCompactBoundary(messages) if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - projectView - } = require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js'); + const { projectView } = + require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js') /* eslint-enable @typescript-eslint/no-require-imports */ - view = projectView(view); + view = projectView(view) } - return view; + return view } -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, +): Promise { const { messages, getAppState, - options: { - mainLoopModel, - tools - } - } = context; - const apiView = toApiView(messages); + options: { mainLoopModel, tools }, + } = context + + const apiView = toApiView(messages) // Apply microcompact to get accurate representation of messages sent to API - const { - messages: compactedMessages - } = await microcompactMessages(apiView); + const { messages: compactedMessages } = await microcompactMessages(apiView) // Get terminal width for responsive sizing - const terminalWidth = process.stdout.columns || 80; - const appState = getAppState(); + const terminalWidth = process.stdout.columns || 80 + + const appState = getAppState() // Analyze context with compacted messages // Pass original messages as last parameter for accurate API usage extraction - const data = await analyzeContextUsage(compactedMessages, mainLoopModel, async () => appState.toolPermissionContext, tools, appState.agentDefinitions, terminalWidth, context, - // Pass full context for system prompt calculation - undefined, - // mainThreadAgentDefinition - apiView // Original messages for API usage extraction - ); + const data = await analyzeContextUsage( + compactedMessages, + mainLoopModel, + async () => appState.toolPermissionContext, + tools, + appState.agentDefinitions, + terminalWidth, + context, // Pass full context for system prompt calculation + undefined, // mainThreadAgentDefinition + apiView, // Original messages for API usage extraction + ) // Render to ANSI string to preserve colors and pass to onDone like local commands do - const output = await renderToAnsiString(); - onDone(output); - return null; + const output = await renderToAnsiString() + onDone(output) + return null } diff --git a/src/commands/copy/copy.tsx b/src/commands/copy/copy.tsx index f9fc720d0..d5196de20 100644 --- a/src/commands/copy/copy.tsx +++ b/src/commands/copy/copy.tsx @@ -1,45 +1,44 @@ -import { c as _c } from "react/compiler-runtime"; -import { mkdir, writeFile } from 'fs/promises'; -import { marked, type Tokens } from 'marked'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import React, { useRef } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import type { OptionWithDescription } from '../../components/CustomSelect/select.js'; -import { Select } from '../../components/CustomSelect/select.js'; -import { Byline } from '../../components/design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; -import { Pane } from '../../components/design-system/Pane.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { setClipboard } from '../../ink/termio/osc.js'; -import { Box, Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -import type { AssistantMessage, Message } from '../../types/message.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js'; -import { countCharInString } from '../../utils/stringUtils.js'; -const COPY_DIR = join(tmpdir(), 'claude'); -const RESPONSE_FILENAME = 'response.md'; -const MAX_LOOKBACK = 20; +import { mkdir, writeFile } from 'fs/promises' +import { marked, type Tokens } from 'marked' +import { tmpdir } from 'os' +import { join } from 'path' +import React, { useRef } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import type { OptionWithDescription } from '../../components/CustomSelect/select.js' +import { Select } from '../../components/CustomSelect/select.js' +import { Byline } from '../../components/design-system/Byline.js' +import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js' +import { Pane } from '../../components/design-system/Pane.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { setClipboard } from '../../ink/termio/osc.js' +import { Box, Text } from '../../ink.js' +import { logEvent } from '../../services/analytics/index.js' +import type { LocalJSXCommandCall } from '../../types/command.js' +import type { AssistantMessage, Message } from '../../types/message.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js' +import { countCharInString } from '../../utils/stringUtils.js' + +const COPY_DIR = join(tmpdir(), 'claude') +const RESPONSE_FILENAME = 'response.md' +const MAX_LOOKBACK = 20 + type CodeBlock = { - code: string; - lang: string | undefined; -}; + code: string + lang: string | undefined +} + function extractCodeBlocks(markdown: string): CodeBlock[] { - const tokens = marked.lexer(stripPromptXMLTags(markdown)); - const blocks: CodeBlock[] = []; + const tokens = marked.lexer(stripPromptXMLTags(markdown)) + const blocks: CodeBlock[] = [] for (const token of tokens) { if (token.type === 'code') { - const codeToken = token as Tokens.Code; - blocks.push({ - code: codeToken.text, - lang: codeToken.lang - }); + const codeToken = token as Tokens.Code + blocks.push({ code: codeToken.text, lang: codeToken.lang }) } } - return blocks; + return blocks } /** @@ -48,323 +47,267 @@ function extractCodeBlocks(markdown: string): CodeBlock[] { * Index 0 = latest, 1 = second-to-latest, etc. Caps at MAX_LOOKBACK. */ export function collectRecentAssistantTexts(messages: Message[]): string[] { - const texts: string[] = []; - for (let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i--) { - const msg = messages[i]; - if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue; - const content = (msg as AssistantMessage).message.content; - if (!Array.isArray(content)) continue; - const text = extractTextContent(content, '\n\n'); - if (text) texts.push(text); + const texts: string[] = [] + for ( + let i = messages.length - 1; + i >= 0 && texts.length < MAX_LOOKBACK; + i-- + ) { + const msg = messages[i] + if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue + const content = (msg as AssistantMessage).message.content + if (!Array.isArray(content)) continue + const text = extractTextContent(content, '\n\n') + if (text) texts.push(text) } - return texts; + return texts } + export function fileExtension(lang: string | undefined): string { if (lang) { // Sanitize to prevent path traversal (e.g. ```../../etc/passwd) // Language identifiers are alphanumeric: python, tsx, jsonc, etc. - const sanitized = lang.replace(/[^a-zA-Z0-9]/g, ''); + const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '') if (sanitized && sanitized !== 'plaintext') { - return `.${sanitized}`; + return `.${sanitized}` } } - return '.txt'; + return '.txt' } + async function writeToFile(text: string, filename: string): Promise { - const filePath = join(COPY_DIR, filename); - await mkdir(COPY_DIR, { - recursive: true - }); - await writeFile(filePath, text, 'utf-8'); - return filePath; + const filePath = join(COPY_DIR, filename) + await mkdir(COPY_DIR, { recursive: true }) + await writeFile(filePath, text, 'utf-8') + return filePath } -async function copyOrWriteToFile(text: string, filename: string): Promise { - const raw = await setClipboard(text); - if (raw) process.stdout.write(raw); - const lineCount = countCharInString(text, '\n') + 1; - const charCount = text.length; + +async function copyOrWriteToFile( + text: string, + filename: string, +): Promise { + const raw = await setClipboard(text) + if (raw) process.stdout.write(raw) + const lineCount = countCharInString(text, '\n') + 1 + const charCount = text.length // Also write to a temp file — clipboard paths are best-effort (OSC 52 needs // terminal support), so the file provides a reliable fallback. try { - const filePath = await writeToFile(text, filename); - return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`; + const filePath = await writeToFile(text, filename) + return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}` } catch { - return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`; + return `Copied to clipboard (${charCount} characters, ${lineCount} lines)` } } + function truncateLine(text: string, maxLen: number): string { - const firstLine = text.split('\n')[0] ?? ''; + const firstLine = text.split('\n')[0] ?? '' if (stringWidth(firstLine) <= maxLen) { - return firstLine; + return firstLine } - let result = ''; - let width = 0; - const targetWidth = maxLen - 1; + let result = '' + let width = 0 + const targetWidth = maxLen - 1 for (const char of firstLine) { - const charWidth = stringWidth(char); - if (width + charWidth > targetWidth) break; - result += char; - width += charWidth; + const charWidth = stringWidth(char) + if (width + charWidth > targetWidth) break + result += char + width += charWidth } - return result + '\u2026'; + return result + '\u2026' } + type PickerProps = { - fullText: string; - codeBlocks: CodeBlock[]; - messageAge: number; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -type PickerSelection = number | 'full' | 'always'; -function CopyPicker(t0) { - const $ = _c(33); - const { - fullText, - codeBlocks, - messageAge, - onDone - } = t0; - const focusedRef = useRef("full"); - const t1 = `${fullText.length} chars, ${countCharInString(fullText, "\n") + 1} lines`; - let t2; - if ($[0] !== t1) { - t2 = { - label: "Full response", - value: "full" as const, - description: t1 - }; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== codeBlocks || $[3] !== t2) { - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Always copy full response", - value: "always" as const, - description: "Skip this picker in the future (revert via /config)" - }; - $[5] = t4; - } else { - t4 = $[5]; - } - t3 = [t2, ...codeBlocks.map(_temp), t4]; - $[2] = codeBlocks; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - const options = t3; - let t4; - if ($[6] !== codeBlocks || $[7] !== fullText) { - t4 = function getSelectionContent(selected) { - if (selected === "full" || selected === "always") { - return { - text: fullText, - filename: RESPONSE_FILENAME - }; - } - const block_0 = codeBlocks[selected]; + fullText: string + codeBlocks: CodeBlock[] + messageAge: number + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type PickerSelection = number | 'full' | 'always' + +function CopyPicker({ + fullText, + codeBlocks, + messageAge, + onDone, +}: PickerProps): React.ReactNode { + const focusedRef = useRef('full') + + const options: OptionWithDescription[] = [ + { + label: 'Full response', + value: 'full' as const, + description: `${fullText.length} chars, ${countCharInString(fullText, '\n') + 1} lines`, + }, + ...codeBlocks.map((block, index) => { + const blockLines = countCharInString(block.code, '\n') + 1 return { - text: block_0.code, - filename: `copy${fileExtension(block_0.lang)}`, - blockIndex: selected - }; - }; - $[6] = codeBlocks; - $[7] = fullText; - $[8] = t4; - } else { - t4 = $[8]; - } - const getSelectionContent = t4; - let t5; - if ($[9] !== codeBlocks.length || $[10] !== getSelectionContent || $[11] !== messageAge || $[12] !== onDone) { - t5 = async function handleSelect(selected_0) { - const content = getSelectionContent(selected_0); - if (selected_0 === "always") { - if (!getGlobalConfig().copyFullResponse) { - saveGlobalConfig(_temp2); - } - logEvent("tengu_copy", { - block_count: codeBlocks.length, - always: true, - message_age: messageAge - }); - const result = await copyOrWriteToFile(content.text, content.filename); - onDone(`${result}\nPreference saved. Use /config to change copyFullResponse`); - return; + label: truncateLine(block.code, 60), + value: index, + description: + [block.lang, blockLines > 1 ? `${blockLines} lines` : undefined] + .filter(Boolean) + .join(', ') || undefined, } - logEvent("tengu_copy", { - selected_block: content.blockIndex, - block_count: codeBlocks.length, - message_age: messageAge - }); - const result_0 = await copyOrWriteToFile(content.text, content.filename); - onDone(result_0); - }; - $[9] = codeBlocks.length; - $[10] = getSelectionContent; - $[11] = messageAge; - $[12] = onDone; - $[13] = t5; - } else { - t5 = $[13]; + }), + { + label: 'Always copy full response', + value: 'always' as const, + description: 'Skip this picker in the future (revert via /config)', + }, + ] + + function getSelectionContent(selected: PickerSelection): { + text: string + filename: string + blockIndex?: number + } { + if (selected === 'full' || selected === 'always') { + return { text: fullText, filename: RESPONSE_FILENAME } + } + const block = codeBlocks[selected]! + return { + text: block.code, + filename: `copy${fileExtension(block.lang)}`, + blockIndex: selected, + } } - const handleSelect = t5; - let t6; - if ($[14] !== codeBlocks.length || $[15] !== getSelectionContent || $[16] !== messageAge || $[17] !== onDone) { - const handleWrite = async function handleWrite(selected_1) { - const content_0 = getSelectionContent(selected_1); - logEvent("tengu_copy", { - selected_block: content_0.blockIndex, + + async function handleSelect(selected: PickerSelection): Promise { + const content = getSelectionContent(selected) + if (selected === 'always') { + if (!getGlobalConfig().copyFullResponse) { + saveGlobalConfig(c => ({ ...c, copyFullResponse: true })) + } + logEvent('tengu_copy', { block_count: codeBlocks.length, + always: true, message_age: messageAge, - write_shortcut: true - }); - ; - try { - const filePath = await writeToFile(content_0.text, content_0.filename); - onDone(`Written to ${filePath}`); - } catch (t7) { - const e = t7; - onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`); - } - }; - t6 = function handleKeyDown(e_0) { - if (e_0.key === "w") { - e_0.preventDefault(); - handleWrite(focusedRef.current); - } - }; - $[14] = codeBlocks.length; - $[15] = getSelectionContent; - $[16] = messageAge; - $[17] = onDone; - $[18] = t6; - } else { - t6 = $[18]; - } - const handleKeyDown = t6; - let t7; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Select content to copy:; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t8 = value => { - focusedRef.current = value; - }; - $[20] = t8; - } else { - t8 = $[20]; - } - let t9; - if ($[21] !== handleSelect) { - t9 = selected_2 => { - handleSelect(selected_2); - }; - $[21] = handleSelect; - $[22] = t9; - } else { - t9 = $[22]; - } - let t10; - if ($[23] !== onDone) { - t10 = () => { - onDone("Copy cancelled", { - display: "system" - }); - }; - $[23] = onDone; - $[24] = t10; - } else { - t10 = $[24]; - } - let t11; - if ($[25] !== options || $[26] !== t10 || $[27] !== t9) { - t11 = { - setSelectedValue(value_0); - handleSelectIDE(value_0); - }} />; - $[19] = availableIDEs.length; - $[20] = handleSelectIDE; - $[21] = options; - $[22] = selectedValue; - $[23] = t6; - } else { - t6 = $[23]; - } - let t7; - if ($[24] !== availableIDEs) { - t7 = availableIDEs.length !== 0 && availableIDEs.some(_temp2) && Note: Only one Claude Code instance can be connected to VS Code at a time.; - $[24] = availableIDEs; - $[25] = t7; - } else { - t7 = $[25]; - } - let t8; - if ($[26] !== availableIDEs.length) { - t8 = availableIDEs.length !== 0 && !isSupportedTerminal() && Tip: You can enable auto-connect to IDE in /config or with the --ide flag; - $[26] = availableIDEs.length; - $[27] = t8; - } else { - t8 = $[27]; - } - let t9; - if ($[28] !== unavailableIDEs) { - t9 = unavailableIDEs.length > 0 && Found {unavailableIDEs.length} other running IDE(s). However, their workspace/project directories do not match the current cwd.{unavailableIDEs.map(_temp3)}; - $[28] = unavailableIDEs; - $[29] = t9; - } else { - t9 = $[29]; + return ( + { + // Always disconnect when user selects "None", regardless of their + // choice about disabling auto-connect + onSelect(undefined) + }} + /> + ) } - let t10; - if ($[30] !== t5 || $[31] !== t6 || $[32] !== t7 || $[33] !== t8 || $[34] !== t9) { - t10 = {t5}{t6}{t7}{t8}{t9}; - $[30] = t5; - $[31] = t6; - $[32] = t7; - $[33] = t8; - $[34] = t9; - $[35] = t10; - } else { - t10 = $[35]; - } - let t11; - if ($[36] !== onClose || $[37] !== t10) { - t11 = {t10}; - $[36] = onClose; - $[37] = t10; - $[38] = t11; - } else { - t11 = $[38]; - } - return t11; -} -function _temp3(ide_3, index) { - return • {ide_3.name}: {formatWorkspaceFolders(ide_3.workspaceFolders)}; -} -function _temp2(ide_2) { - return ide_2.name === "VS Code" || ide_2.name === "Visual Studio Code"; -} -function _temp(acc, ide_0) { - acc[ide_0.name] = (acc[ide_0.name] || 0) + 1; - return acc; + + return ( + + + {availableIDEs.length === 0 && ( + + {isSupportedJetBrainsTerminal() + ? 'No available IDEs detected. Please install the plugin and restart your IDE:\n' + + 'https://docs.claude.com/s/claude-code-jetbrains' + : 'No available IDEs detected. Make sure your IDE has the Claude Code extension or plugin installed and is running.'} + + )} + + {availableIDEs.length !== 0 && ( + ; - $[11] = options; - $[12] = selectedValue; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] !== handleCancel || $[16] !== t6) { - t7 = {t6}; - $[15] = handleCancel; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; - } - return t7; + availableIDEs: DetectedIDEInfo[] + onSelectIDE: (ide?: DetectedIDEInfo) => void + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void } -function _temp4(ide_0) { - return { - label: ide_0.name, - value: ide_0.port.toString() - }; -} -function RunningIDESelector(t0) { - const $ = _c(15); - const { - runningIDEs, - onSelectIDE, - onDone - } = t0; - const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? ""); - let t1; - if ($[0] !== onSelectIDE) { - t1 = value => { - onSelectIDE(value as IdeType); - }; - $[0] = onSelectIDE; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSelectIDE = t1; - let t2; - if ($[2] !== runningIDEs) { - t2 = runningIDEs.map(_temp5); - $[2] = runningIDEs; - $[3] = t2; - } else { - t2 = $[3]; - } - const options = t2; - let t3; - if ($[4] !== onDone) { - t3 = function handleCancel() { - onDone("IDE selection cancelled", { - display: "system" - }); - }; - $[4] = onDone; - $[5] = t3; - } else { - t3 = $[5]; - } - const handleCancel = t3; - let t4; - if ($[6] !== handleSelectIDE) { - t4 = value_0 => { - setSelectedValue(value_0); - handleSelectIDE(value_0); - }; - $[6] = handleSelectIDE; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== options || $[9] !== selectedValue || $[10] !== t4) { - t5 = { + setSelectedValue(value) + handleSelectIDE(value) + }} + /> + + ) } -function _temp5(ide) { - return { + +function RunningIDESelector({ + runningIDEs, + onSelectIDE, + onDone, +}: { + runningIDEs: IdeType[] + onSelectIDE: (ide: IdeType) => void + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +}): React.ReactNode { + const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '') + + const handleSelectIDE = useCallback( + (value: string) => { + onSelectIDE(value as IdeType) + }, + [onSelectIDE], + ) + + const options = runningIDEs.map(ide => ({ label: toIDEDisplayName(ide), - value: ide - }; -} -function InstallOnMount(t0) { - const $ = _c(4); - const { - ide, - onInstall - } = t0; - let t1; - let t2; - if ($[0] !== ide || $[1] !== onInstall) { - t1 = () => { - onInstall(ide); - }; - t2 = [ide, onInstall]; - $[0] = ide; - $[1] = onInstall; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; + value: ide, + })) + + function handleCancel(): void { + onDone('IDE selection cancelled', { display: 'system' }) } - useEffect(t1, t2); - return null; + + return ( + + ; - $[9] = handleCancel; - $[10] = handleSelect; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t8 = View the latest workflow template at:{" "}https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] !== t5 || $[14] !== t7) { - t9 = {t5}{t6}{t7}{t8}; - $[13] = t5; - $[14] = t7; - $[15] = t9; - } else { - t9 = $[15]; - } - return t9; + + return ( + + + Existing Workflow Found + Repository: {repoName} + + + + + A Claude workflow file already exists at{' '} + .github/workflows/claude.yml + + What would you like to do? + + + + ; - $[19] = handleSelect; - $[20] = options; - $[21] = t6; - } else { - t6 = $[21]; - } - let t7; - if ($[22] !== handleCancel || $[23] !== t6) { - t7 = {t6}; - $[22] = handleCancel; - $[23] = t6; - $[24] = t7; - } else { - t7 = $[24]; + return subCommandJSX } - return t7; + + return ( + + + options={options} + onChange={handleSelect} + visibleOptionCount={options.length} + /> + + ) } -export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext): Promise { - return ; + +export async function call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext & LocalJSXCommandContext, +): Promise { + return } diff --git a/src/commands/remote-env/remote-env.tsx b/src/commands/remote-env/remote-env.tsx index 65e0a5cb6..1c5f3feb6 100644 --- a/src/commands/remote-env/remote-env.tsx +++ b/src/commands/remote-env/remote-env.tsx @@ -1,6 +1,9 @@ -import * as React from 'react'; -import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone): Promise { - return ; +import * as React from 'react' +import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, +): Promise { + return } diff --git a/src/commands/remote-setup/remote-setup.tsx b/src/commands/remote-setup/remote-setup.tsx index e51f2e8d7..05813453d 100644 --- a/src/commands/remote-setup/remote-setup.tsx +++ b/src/commands/remote-setup/remote-setup.tsx @@ -1,163 +1,162 @@ -import { execa } from 'execa'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Select } from '../../components/CustomSelect/index.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { LoadingState } from '../../components/design-system/LoadingState.js'; -import { Box, Text } from '../../ink.js'; -import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString } from '../../services/analytics/index.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import { openBrowser } from '../../utils/browser.js'; -import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js'; -import { createDefaultEnvironment, getCodeWebUrl, type ImportTokenError, importGithubToken, isSignedIn, RedactedGithubToken } from './api.js'; -type CheckResult = { - status: 'not_signed_in'; -} | { - status: 'has_gh_token'; - token: RedactedGithubToken; -} | { - status: 'gh_not_installed'; -} | { - status: 'gh_not_authenticated'; -}; +import { execa } from 'execa' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Select } from '../../components/CustomSelect/index.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { LoadingState } from '../../components/design-system/LoadingState.js' +import { Box, Text } from '../../ink.js' +import { + logEvent, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString, +} from '../../services/analytics/index.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import { openBrowser } from '../../utils/browser.js' +import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js' +import { + createDefaultEnvironment, + getCodeWebUrl, + type ImportTokenError, + importGithubToken, + isSignedIn, + RedactedGithubToken, +} from './api.js' + +type CheckResult = + | { status: 'not_signed_in' } + | { status: 'has_gh_token'; token: RedactedGithubToken } + | { status: 'gh_not_installed' } + | { status: 'gh_not_authenticated' } + async function checkLoginState(): Promise { if (!(await isSignedIn())) { - return { - status: 'not_signed_in' - }; + return { status: 'not_signed_in' } } - const ghStatus = await getGhAuthStatus(); + + const ghStatus = await getGhAuthStatus() if (ghStatus === 'not_installed') { - return { - status: 'gh_not_installed' - }; + return { status: 'gh_not_installed' } } if (ghStatus === 'not_authenticated') { - return { - status: 'gh_not_authenticated' - }; + return { status: 'gh_not_authenticated' } } // ghStatus === 'authenticated'. getGhAuthStatus spawns with stdout:'ignore' // (telemetry-safe); spawn once more with stdout:'pipe' to read the token. - const { - stdout - } = await execa('gh', ['auth', 'token'], { + const { stdout } = await execa('gh', ['auth', 'token'], { stdout: 'pipe', stderr: 'ignore', timeout: 5000, - reject: false - }); - const trimmed = stdout.trim(); + reject: false, + }) + const trimmed = stdout.trim() if (!trimmed) { - return { - status: 'gh_not_authenticated' - }; + return { status: 'gh_not_authenticated' } } - return { - status: 'has_gh_token', - token: new RedactedGithubToken(trimmed) - }; + return { status: 'has_gh_token', token: new RedactedGithubToken(trimmed) } } + function errorMessage(err: ImportTokenError, codeUrl: string): string { switch (err.kind) { case 'not_signed_in': - return `Login failed. Please visit ${codeUrl} and login using the GitHub App`; + return `Login failed. Please visit ${codeUrl} and login using the GitHub App` case 'invalid_token': - return 'GitHub rejected that token. Run `gh auth login` and try again.'; + return 'GitHub rejected that token. Run `gh auth login` and try again.' case 'server': - return `Server error (${err.status}). Try again in a moment.`; + return `Server error (${err.status}). Try again in a moment.` case 'network': - return "Couldn't reach the server. Check your connection."; + return "Couldn't reach the server. Check your connection." } } -type Step = { - name: 'checking'; -} | { - name: 'confirm'; - token: RedactedGithubToken; -} | { - name: 'uploading'; -}; -function Web({ - onDone -}: { - onDone: LocalJSXCommandOnDone; -}) { - const [step, setStep] = useState({ - name: 'checking' - }); + +type Step = + | { name: 'checking' } + | { name: 'confirm'; token: RedactedGithubToken } + | { name: 'uploading' } + +function Web({ onDone }: { onDone: LocalJSXCommandOnDone }) { + const [step, setStep] = useState({ name: 'checking' }) + useEffect(() => { - logEvent('tengu_remote_setup_started', {}); + logEvent('tengu_remote_setup_started', {}) void checkLoginState().then(async result => { switch (result.status) { case 'not_signed_in': logEvent('tengu_remote_setup_result', { - result: 'not_signed_in' as SafeString - }); - onDone('Not signed in to Claude. Run /login first.'); - return; + result: 'not_signed_in' as SafeString, + }) + onDone('Not signed in to Claude. Run /login first.') + return case 'gh_not_installed': - case 'gh_not_authenticated': - { - const url = `${getCodeWebUrl()}/onboarding?step=alt-auth`; - await openBrowser(url); - logEvent('tengu_remote_setup_result', { - result: result.status as SafeString - }); - onDone(result.status === 'gh_not_installed' ? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}` : `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`); - return; - } + case 'gh_not_authenticated': { + const url = `${getCodeWebUrl()}/onboarding?step=alt-auth` + await openBrowser(url) + logEvent('tengu_remote_setup_result', { + result: result.status as SafeString, + }) + onDone( + result.status === 'gh_not_installed' + ? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}` + : `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`, + ) + return + } case 'has_gh_token': - setStep({ - name: 'confirm', - token: result.token - }); + setStep({ name: 'confirm', token: result.token }) } - }); + }) // onDone is stable across renders; intentionally not in deps. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []) + const handleCancel = () => { logEvent('tengu_remote_setup_result', { - result: 'cancelled' as SafeString - }); - onDone(); - }; + result: 'cancelled' as SafeString, + }) + onDone() + } + const handleConfirm = async (token: RedactedGithubToken) => { - setStep({ - name: 'uploading' - }); - const result = await importGithubToken(token); + setStep({ name: 'uploading' }) + + const result = await importGithubToken(token) if (!result.ok) { - const importErr = (result as { ok: false; error: ImportTokenError }).error; logEvent('tengu_remote_setup_result', { result: 'import_failed' as SafeString, - error_kind: importErr.kind as SafeString - }); - onDone(errorMessage(importErr, getCodeWebUrl())); - return; + error_kind: result.error.kind as SafeString, + }) + onDone(errorMessage(result.error, getCodeWebUrl())) + return } // Token import succeeded. Environment creation is best-effort — if it // fails, the web state machine routes to env-setup on landing, which is // one extra click but still better than the OAuth dance. - await createDefaultEnvironment(); - const url = getCodeWebUrl(); - await openBrowser(url); + await createDefaultEnvironment() + + const url = getCodeWebUrl() + await openBrowser(url) + logEvent('tengu_remote_setup_result', { - result: 'success' as SafeString - }); - onDone(`Connected as ${result.result.github_username}. Opened ${url}`); - }; + result: 'success' as SafeString, + }) + onDone(`Connected as ${result.result.github_username}. Opened ${url}`) + } + if (step.name === 'checking') { - return ; + return } + if (step.name === 'uploading') { - return ; + return } - const token = step.token; - return + + const token = step.token + return ( + Claude on the web requires connecting to your GitHub account to clone @@ -167,21 +166,26 @@ function Web({ Your local credentials are used to authenticate with GitHub - { + if (value === 'send') { + void handleConfirm(token) + } else { + handleCancel() + } + }} + onCancel={handleCancel} + /> + + ) } -export async function call(onDone: LocalJSXCommandOnDone): Promise { - return ; + +export async function call( + onDone: LocalJSXCommandOnDone, +): Promise { + return } diff --git a/src/commands/resume/resume.tsx b/src/commands/resume/resume.tsx index 4764089c8..f66d654c6 100644 --- a/src/commands/resume/resume.tsx +++ b/src/commands/resume/resume.tsx @@ -1,257 +1,300 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import type { UUID } from 'crypto'; -import figures from 'figures'; -import * as React from 'react'; -import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'; -import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js'; -import { LogSelector } from '../../components/LogSelector.js'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { Spinner } from '../../components/Spinner.js'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { setClipboard } from '../../ink/termio/osc.js'; -import { Box, Text } from '../../ink.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -import type { LogOption } from '../../types/logs.js'; -import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js'; -import { checkCrossProjectResume } from '../../utils/crossProjectResume.js'; -import { getWorktreePaths } from '../../utils/getWorktreePaths.js'; -import { logError } from '../../utils/log.js'; -import { getLastSessionLog, getSessionIdFromLog, isCustomTitleEnabled, isLiteLog, loadAllProjectsMessageLogs, loadFullLog, loadSameRepoMessageLogs, searchSessionsByCustomTitle } from '../../utils/sessionStorage.js'; -import { validateUuid } from '../../utils/uuid.js'; -type ResumeResult = { - resultType: 'sessionNotFound'; - arg: string; -} | { - resultType: 'multipleMatches'; - arg: string; - count: number; -}; +import chalk from 'chalk' +import type { UUID } from 'crypto' +import figures from 'figures' +import * as React from 'react' +import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' +import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js' +import { LogSelector } from '../../components/LogSelector.js' +import { MessageResponse } from '../../components/MessageResponse.js' +import { Spinner } from '../../components/Spinner.js' +import { useIsInsideModal } from '../../context/modalContext.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { setClipboard } from '../../ink/termio/osc.js' +import { Box, Text } from '../../ink.js' +import type { LocalJSXCommandCall } from '../../types/command.js' +import type { LogOption } from '../../types/logs.js' +import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js' +import { checkCrossProjectResume } from '../../utils/crossProjectResume.js' +import { getWorktreePaths } from '../../utils/getWorktreePaths.js' +import { logError } from '../../utils/log.js' +import { + getLastSessionLog, + getSessionIdFromLog, + isCustomTitleEnabled, + isLiteLog, + loadAllProjectsMessageLogs, + loadFullLog, + loadSameRepoMessageLogs, + searchSessionsByCustomTitle, +} from '../../utils/sessionStorage.js' +import { validateUuid } from '../../utils/uuid.js' + +type ResumeResult = + | { resultType: 'sessionNotFound'; arg: string } + | { resultType: 'multipleMatches'; arg: string; count: number } + function resumeHelpMessage(result: ResumeResult): string { switch (result.resultType) { case 'sessionNotFound': - return `Session ${chalk.bold(result.arg)} was not found.`; + return `Session ${chalk.bold(result.arg)} was not found.` case 'multipleMatches': - return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`; + return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.` } } -function ResumeError(t0) { - const $ = _c(10); - const { - message, - args, - onDone - } = t0; - let t1; - let t2; - if ($[0] !== onDone) { - t1 = () => { - const timer = setTimeout(onDone, 0); - return () => clearTimeout(timer); - }; - t2 = [onDone]; - $[0] = onDone; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - React.useEffect(t1, t2); - let t3; - if ($[3] !== args) { - t3 = {figures.pointer} /resume {args}; - $[3] = args; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== message) { - t4 = {message}; - $[5] = message; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t3 || $[8] !== t4) { - t5 = {t3}{t4}; - $[7] = t3; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + +function ResumeError({ + message, + args, + onDone, +}: { + message: string + args: string + onDone: () => void +}): React.ReactNode { + React.useEffect(() => { + const timer = setTimeout(onDone, 0) + return () => clearTimeout(timer) + }, [onDone]) + + return ( + + + {figures.pointer} /resume {args} + + + {message} + + + ) } + function ResumeCommand({ onDone, - onResume + onResume, }: { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onResume: (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => Promise; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onResume: ( + sessionId: UUID, + log: LogOption, + entrypoint: ResumeEntrypoint, + ) => Promise }): React.ReactNode { - const [logs, setLogs] = React.useState([]); - const [worktreePaths, setWorktreePaths] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [resuming, setResuming] = React.useState(false); - const [showAllProjects, setShowAllProjects] = React.useState(false); - const { - rows - } = useTerminalSize(); - const insideModal = useIsInsideModal(); - const loadLogs = React.useCallback(async (allProjects: boolean, paths: string[]) => { - setLoading(true); - try { - const allLogs = allProjects ? await loadAllProjectsMessageLogs() : await loadSameRepoMessageLogs(paths); - const resumable = filterResumableSessions(allLogs, getSessionId()); - if (resumable.length === 0) { - onDone('No conversations found to resume'); - return; + const [logs, setLogs] = React.useState([]) + const [worktreePaths, setWorktreePaths] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [resuming, setResuming] = React.useState(false) + const [showAllProjects, setShowAllProjects] = React.useState(false) + const { rows } = useTerminalSize() + const insideModal = useIsInsideModal() + + const loadLogs = React.useCallback( + async (allProjects: boolean, paths: string[]) => { + setLoading(true) + try { + const allLogs = allProjects + ? await loadAllProjectsMessageLogs() + : await loadSameRepoMessageLogs(paths) + const resumable = filterResumableSessions(allLogs, getSessionId()) + if (resumable.length === 0) { + onDone('No conversations found to resume') + return + } + setLogs(resumable) + } catch (_err) { + onDone('Failed to load conversations') + } finally { + setLoading(false) } - setLogs(resumable); - } catch (_err) { - onDone('Failed to load conversations'); - } finally { - setLoading(false); - } - }, [onDone]); + }, + [onDone], + ) + React.useEffect(() => { async function init() { - const paths_0 = await getWorktreePaths(getOriginalCwd()); - setWorktreePaths(paths_0); - void loadLogs(false, paths_0); + const paths = await getWorktreePaths(getOriginalCwd()) + setWorktreePaths(paths) + void loadLogs(false, paths) } - void init(); - }, [loadLogs]); + void init() + }, [loadLogs]) + const handleToggleAllProjects = React.useCallback(() => { - const newValue = !showAllProjects; - setShowAllProjects(newValue); - void loadLogs(newValue, worktreePaths); - }, [showAllProjects, loadLogs, worktreePaths]); + const newValue = !showAllProjects + setShowAllProjects(newValue) + void loadLogs(newValue, worktreePaths) + }, [showAllProjects, loadLogs, worktreePaths]) + async function handleSelect(log: LogOption) { - const sessionId = validateUuid(getSessionIdFromLog(log)); + const sessionId = validateUuid(getSessionIdFromLog(log)) if (!sessionId) { - onDone('Failed to resume conversation'); - return; + onDone('Failed to resume conversation') + return } // Load full messages for lite logs - const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log // Check if this conversation is from a different directory - const crossProjectCheck = checkCrossProjectResume(fullLog, showAllProjects, worktreePaths); + const crossProjectCheck = checkCrossProjectResume( + fullLog, + showAllProjects, + worktreePaths, + ) if (crossProjectCheck.isCrossProject) { if (crossProjectCheck.isSameRepoWorktree) { // Same repo worktree - can resume directly - setResuming(true); - void onResume(sessionId, fullLog, 'slash_command_picker'); - return; + setResuming(true) + void onResume(sessionId, fullLog, 'slash_command_picker') + return } // Different project - show command instead of resuming - const crossCmd = (crossProjectCheck as { isCrossProject: true; isSameRepoWorktree: false; command: string }).command; - const raw = await setClipboard(crossCmd); - if (raw) process.stdout.write(raw); + const raw = await setClipboard(crossProjectCheck.command) + if (raw) process.stdout.write(raw) // Format the output message - const message = ['', 'This conversation is from a different directory.', '', 'To resume, run:', ` ${crossCmd}`, '', '(Command copied to clipboard)', ''].join('\n'); - onDone(message, { - display: 'user' - }); - return; + const message = [ + '', + 'This conversation is from a different directory.', + '', + 'To resume, run:', + ` ${crossProjectCheck.command}`, + '', + '(Command copied to clipboard)', + '', + ].join('\n') + + onDone(message, { display: 'user' }) + return } // Same directory - proceed with resume - setResuming(true); - void onResume(sessionId, fullLog, 'slash_command_picker'); + setResuming(true) + void onResume(sessionId, fullLog, 'slash_command_picker') } + function handleCancel() { - onDone('Resume cancelled', { - display: 'system' - }); + onDone('Resume cancelled', { display: 'system' }) } + if (loading) { - return + return ( + Loading conversations… - ; + + ) } + if (resuming) { - return + return ( + Resuming conversation… - ; + + ) } - return loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />; + + return ( + loadLogs(showAllProjects, worktreePaths)} + showAllProjects={showAllProjects} + onToggleAllProjects={handleToggleAllProjects} + onAgenticSearch={agenticSessionSearch} + /> + ) } -export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] { - return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId); + +export function filterResumableSessions( + logs: LogOption[], + currentSessionId: string, +): LogOption[] { + return logs.filter( + l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId, + ) } + export const call: LocalJSXCommandCall = async (onDone, context, args) => { - const onResume = async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { + const onResume = async ( + sessionId: UUID, + log: LogOption, + entrypoint: ResumeEntrypoint, + ) => { try { - await context.resume?.(sessionId, log, entrypoint); - onDone(undefined, { - display: 'skip' - }); + await context.resume?.(sessionId, log, entrypoint) + onDone(undefined, { display: 'skip' }) } catch (error) { - logError(error as Error); - onDone(`Failed to resume: ${(error as Error).message}`); + logError(error as Error) + onDone(`Failed to resume: ${(error as Error).message}`) } - }; - const arg = args?.trim(); + } + + const arg = args?.trim() // No argument provided - show picker if (!arg) { - return ; + return ( + + ) } // Load logs to search (includes same-repo worktrees) - const worktreePaths = await getWorktreePaths(getOriginalCwd()); - const logs = await loadSameRepoMessageLogs(worktreePaths); + const worktreePaths = await getWorktreePaths(getOriginalCwd()) + const logs = await loadSameRepoMessageLogs(worktreePaths) if (logs.length === 0) { - const message = 'No conversations found to resume.'; - return onDone(message)} />; + const message = 'No conversations found to resume.' + return ( + onDone(message)} + /> + ) } // First, check if arg is a valid UUID - const maybeSessionId = validateUuid(arg); + const maybeSessionId = validateUuid(arg) if (maybeSessionId) { - const matchingLogs = logs.filter(l => getSessionIdFromLog(l) === maybeSessionId).sort((a, b) => b.modified.getTime() - a.modified.getTime()); + const matchingLogs = logs + .filter(l => getSessionIdFromLog(l) === maybeSessionId) + .sort((a, b) => b.modified.getTime() - a.modified.getTime()) + if (matchingLogs.length > 0) { - const log = matchingLogs[0]!; - const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; - void onResume(maybeSessionId, fullLog, 'slash_command_session_id'); - return null; + const log = matchingLogs[0]! + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log + void onResume(maybeSessionId, fullLog, 'slash_command_session_id') + return null } // Enriched logs didn't find it — try direct file lookup. This handles // sessions filtered out by enrichLogs (e.g., first message >16KB makes // firstPrompt extraction fail, causing the session to be dropped). - const directLog = await getLastSessionLog(maybeSessionId); + const directLog = await getLastSessionLog(maybeSessionId) if (directLog) { - void onResume(maybeSessionId, directLog, 'slash_command_session_id'); - return null; + void onResume(maybeSessionId, directLog, 'slash_command_session_id') + return null } } // Next, try exact custom title match (only if feature is enabled) if (isCustomTitleEnabled()) { const titleMatches = await searchSessionsByCustomTitle(arg, { - exact: true - }); + exact: true, + }) if (titleMatches.length === 1) { - const log = titleMatches[0]!; - const sessionId = getSessionIdFromLog(log); + const log = titleMatches[0]! + const sessionId = getSessionIdFromLog(log) if (sessionId) { - const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; - void onResume(sessionId, fullLog, 'slash_command_title'); - return null; + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log + void onResume(sessionId, fullLog, 'slash_command_title') + return null } } @@ -260,16 +303,21 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => { const message = resumeHelpMessage({ resultType: 'multipleMatches', arg, - count: titleMatches.length - }); - return onDone(message)} />; + count: titleMatches.length, + }) + return ( + onDone(message)} + /> + ) } } // No match found - show error - const message = resumeHelpMessage({ - resultType: 'sessionNotFound', - arg - }); - return onDone(message)} />; -}; + const message = resumeHelpMessage({ resultType: 'sessionNotFound', arg }) + return ( + onDone(message)} /> + ) +} diff --git a/src/commands/review/UltrareviewOverageDialog.tsx b/src/commands/review/UltrareviewOverageDialog.tsx index 46cb40a02..020db57f8 100644 --- a/src/commands/review/UltrareviewOverageDialog.tsx +++ b/src/commands/review/UltrareviewOverageDialog.tsx @@ -1,95 +1,71 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useRef, useState } from 'react'; -import { Select } from '../../components/CustomSelect/select.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { Box, Text } from '../../ink.js'; +import React, { useCallback, useRef, useState } from 'react' +import { Select } from '../../components/CustomSelect/select.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { Box, Text } from '../../ink.js' + type Props = { - onProceed: (signal: AbortSignal) => Promise; - onCancel: () => void; -}; -export function UltrareviewOverageDialog(t0) { - const $ = _c(15); - const { - onProceed, - onCancel - } = t0; - const [isLaunching, setIsLaunching] = useState(false); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = new AbortController(); - $[0] = t1; - } else { - t1 = $[0]; - } - const abortControllerRef = useRef(t1); - let t2; - if ($[1] !== onCancel || $[2] !== onProceed) { - t2 = value => { - if (value === "proceed") { - setIsLaunching(true); - onProceed(abortControllerRef.current.signal).catch(() => setIsLaunching(false)); + onProceed: (signal: AbortSignal) => Promise + onCancel: () => void +} + +export function UltrareviewOverageDialog({ + onProceed, + onCancel, +}: Props): React.ReactNode { + const [isLaunching, setIsLaunching] = useState(false) + const abortControllerRef = useRef(new AbortController()) + + const handleSelect = useCallback( + (value: string) => { + if (value === 'proceed') { + setIsLaunching(true) + // If onProceed rejects (e.g. launchRemoteReview throws), onDone is + // never called and the dialog stays mounted — restore the Select so + // the user can retry or cancel instead of staring at "Launching…". + void onProceed(abortControllerRef.current.signal).catch(() => + setIsLaunching(false), + ) } else { - onCancel(); + onCancel() } - }; - $[1] = onCancel; - $[2] = onProceed; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleSelect = t2; - let t3; - if ($[4] !== onCancel) { - t3 = () => { - abortControllerRef.current.abort(); - onCancel(); - }; - $[4] = onCancel; - $[5] = t3; - } else { - t3 = $[5]; - } - const handleCancel = t3; - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Proceed with Extra Usage billing", - value: "proceed" - }, { - label: "Cancel", - value: "cancel" - }]; - $[6] = t4; - } else { - t4 = $[6]; - } - const options = t4; - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Your free ultrareviews for this organization are used. Further reviews bill as Extra Usage (pay-per-use).; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== handleCancel || $[9] !== handleSelect || $[10] !== isLaunching) { - t6 = {t5}{isLaunching ? Launching… : + )} + + + ) } diff --git a/src/commands/review/ultrareviewCommand.tsx b/src/commands/review/ultrareviewCommand.tsx index 56e92fdf1..faad0fc2f 100644 --- a/src/commands/review/ultrareviewCommand.tsx +++ b/src/commands/review/ultrareviewCommand.tsx @@ -1,57 +1,89 @@ -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'; -import React from 'react'; -import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'; -import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js'; -import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js' +import React from 'react' +import type { + LocalJSXCommandCall, + LocalJSXCommandOnDone, +} from '../../types/command.js' +import { + checkOverageGate, + confirmOverage, + launchRemoteReview, +} from './reviewRemote.js' +import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js' + function contentBlocksToString(blocks: ContentBlockParam[]): string { - return blocks.map(b => b.type === 'text' ? b.text : '').filter(Boolean).join('\n'); + return blocks + .map(b => (b.type === 'text' ? b.text : '')) + .filter(Boolean) + .join('\n') } -async function launchAndDone(args: string, context: Parameters[1], onDone: LocalJSXCommandOnDone, billingNote: string, signal?: AbortSignal): Promise { - const result = await launchRemoteReview(args, context, billingNote); + +async function launchAndDone( + args: string, + context: Parameters[1], + onDone: LocalJSXCommandOnDone, + billingNote: string, + signal?: AbortSignal, +): Promise { + const result = await launchRemoteReview(args, context, billingNote) // User hit Escape during the ~5s launch — the dialog already showed // "cancelled" and unmounted, so skip onDone (would write to a dead // transcript slot) and let the caller skip confirmOverage. - if (signal?.aborted) return; + if (signal?.aborted) return if (result) { - onDone(contentBlocksToString(result), { - shouldQuery: true - }); + onDone(contentBlocksToString(result), { shouldQuery: true }) } else { // Precondition failures now return specific ContentBlockParam[] above. // null only reaches here on teleport failure (PR mode) or non-github // repo — both are CCR/repo connectivity issues. - onDone('Ultrareview failed to launch the remote session. Check that this is a GitHub repo and try again.', { - display: 'system' - }); + onDone( + 'Ultrareview failed to launch the remote session. Check that this is a GitHub repo and try again.', + { display: 'system' }, + ) } } + export const call: LocalJSXCommandCall = async (onDone, context, args) => { - const gate = await checkOverageGate(); + const gate = await checkOverageGate() + if (gate.kind === 'not-enabled') { - onDone('Free ultrareviews used. Enable Extra Usage at https://claude.ai/settings/billing to continue.', { - display: 'system' - }); - return null; + onDone( + 'Free ultrareviews used. Enable Extra Usage at https://claude.ai/settings/billing to continue.', + { display: 'system' }, + ) + return null } + if (gate.kind === 'low-balance') { - onDone(`Balance too low to launch ultrareview ($${gate.available.toFixed(2)} available, $10 minimum). Top up at https://claude.ai/settings/billing`, { - display: 'system' - }); - return null; + onDone( + `Balance too low to launch ultrareview ($${gate.available.toFixed(2)} available, $10 minimum). Top up at https://claude.ai/settings/billing`, + { display: 'system' }, + ) + return null } + if (gate.kind === 'needs-confirm') { - return { - await launchAndDone(args, context, onDone, ' This review bills as Extra Usage.', signal); - // Only persist the confirmation flag after a non-aborted launch — - // otherwise Escape-during-launch would leave the flag set and - // skip this dialog on the next attempt. - if (!signal.aborted) confirmOverage(); - }} onCancel={() => onDone('Ultrareview cancelled.', { - display: 'system' - })} />; + return ( + { + await launchAndDone( + args, + context, + onDone, + ' This review bills as Extra Usage.', + signal, + ) + // Only persist the confirmation flag after a non-aborted launch — + // otherwise Escape-during-launch would leave the flag set and + // skip this dialog on the next attempt. + if (!signal.aborted) confirmOverage() + }} + onCancel={() => onDone('Ultrareview cancelled.', { display: 'system' })} + /> + ) } // gate.kind === 'proceed' - await launchAndDone(args, context, onDone, gate.billingNote); - return null; -}; + await launchAndDone(args, context, onDone, gate.billingNote) + return null +} diff --git a/src/commands/sandbox-toggle/sandbox-toggle.tsx b/src/commands/sandbox-toggle/sandbox-toggle.tsx index dc70b194c..157961ad5 100644 --- a/src/commands/sandbox-toggle/sandbox-toggle.tsx +++ b/src/commands/sandbox-toggle/sandbox-toggle.tsx @@ -1,82 +1,127 @@ -import { relative } from 'path'; -import React from 'react'; -import { getCwdState } from '../../bootstrap/state.js'; -import { SandboxSettings } from '../../components/sandbox/SandboxSettings.js'; -import { color } from '../../ink.js'; -import { getPlatform } from '../../utils/platform.js'; -import { addToExcludedCommands, SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { getSettings_DEPRECATED, getSettingsFilePathForSource } from '../../utils/settings/settings.js'; -import type { ThemeName } from '../../utils/theme.js'; -export async function call(onDone: (result?: string) => void, _context: unknown, args?: string): Promise { - const settings = getSettings_DEPRECATED(); - const themeName: ThemeName = settings.theme as ThemeName || 'light'; - const platform = getPlatform(); +import { relative } from 'path' +import React from 'react' +import { getCwdState } from '../../bootstrap/state.js' +import { SandboxSettings } from '../../components/sandbox/SandboxSettings.js' +import { color } from '../../ink.js' +import { getPlatform } from '../../utils/platform.js' +import { + addToExcludedCommands, + SandboxManager, +} from '../../utils/sandbox/sandbox-adapter.js' +import { + getSettings_DEPRECATED, + getSettingsFilePathForSource, +} from '../../utils/settings/settings.js' +import type { ThemeName } from '../../utils/theme.js' + +export async function call( + onDone: (result?: string) => void, + _context: unknown, + args?: string, +): Promise { + const settings = getSettings_DEPRECATED() + const themeName: ThemeName = (settings.theme as ThemeName) || 'light' + + const platform = getPlatform() + if (!SandboxManager.isSupportedPlatform()) { // WSL1 users will see this since isSupportedPlatform returns false for WSL1 - const errorMessage = platform === 'wsl' ? 'Error: Sandboxing requires WSL2. WSL1 is not supported.' : 'Error: Sandboxing is currently only supported on macOS, Linux, and WSL2.'; - const message = color('error', themeName)(errorMessage); - onDone(message); - return null; + const errorMessage = + platform === 'wsl' + ? 'Error: Sandboxing requires WSL2. WSL1 is not supported.' + : 'Error: Sandboxing is currently only supported on macOS, Linux, and WSL2.' + const message = color('error', themeName)(errorMessage) + onDone(message) + return null } // Check dependencies - get structured result with errors/warnings - const depCheck = SandboxManager.checkDependencies(); + const depCheck = SandboxManager.checkDependencies() // Check if platform is in enabledPlatforms list (undocumented enterprise setting) if (!SandboxManager.isPlatformInEnabledList()) { - const message = color('error', themeName)(`Error: Sandboxing is disabled for this platform (${platform}) via the enabledPlatforms setting.`); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + `Error: Sandboxing is disabled for this platform (${platform}) via the enabledPlatforms setting.`, + ) + onDone(message) + return null } // Check if sandbox settings are locked by higher-priority settings if (SandboxManager.areSandboxSettingsLockedByPolicy()) { - const message = color('error', themeName)('Error: Sandbox settings are overridden by a higher-priority configuration and cannot be changed locally.'); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + 'Error: Sandbox settings are overridden by a higher-priority configuration and cannot be changed locally.', + ) + onDone(message) + return null } // Parse the arguments - const trimmedArgs = args?.trim() || ''; + const trimmedArgs = args?.trim() || '' // If no args, show the interactive menu if (!trimmedArgs) { - return ; + return } // Handle subcommands if (trimmedArgs) { - const parts = trimmedArgs.split(' '); - const subcommand = parts[0]; + const parts = trimmedArgs.split(' ') + const subcommand = parts[0] + if (subcommand === 'exclude') { // Handle exclude subcommand - const commandPattern = trimmedArgs.slice('exclude '.length).trim(); + const commandPattern = trimmedArgs.slice('exclude '.length).trim() + if (!commandPattern) { - const message = color('error', themeName)('Error: Please provide a command pattern to exclude (e.g., /sandbox exclude "npm run test:*")'); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + 'Error: Please provide a command pattern to exclude (e.g., /sandbox exclude "npm run test:*")', + ) + onDone(message) + return null } // Remove quotes if present - const cleanPattern = commandPattern.replace(/^["']|["']$/g, ''); + const cleanPattern = commandPattern.replace(/^["']|["']$/g, '') // Add to excludedCommands - addToExcludedCommands(cleanPattern); + addToExcludedCommands(cleanPattern) // Get the local settings path and make it relative to cwd - const localSettingsPath = getSettingsFilePathForSource('localSettings'); - const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.claude/settings.local.json'; - const message = color('success', themeName)(`Added "${cleanPattern}" to excluded commands in ${relativePath}`); - onDone(message); - return null; + const localSettingsPath = getSettingsFilePathForSource('localSettings') + const relativePath = localSettingsPath + ? relative(getCwdState(), localSettingsPath) + : '.claude/settings.local.json' + + const message = color( + 'success', + themeName, + )(`Added "${cleanPattern}" to excluded commands in ${relativePath}`) + + onDone(message) + return null } else { // Unknown subcommand - const message = color('error', themeName)(`Error: Unknown subcommand "${subcommand}". Available subcommand: exclude`); - onDone(message); - return null; + const message = color( + 'error', + themeName, + )( + `Error: Unknown subcommand "${subcommand}". Available subcommand: exclude`, + ) + onDone(message) + return null } } // Should never reach here since we handle all cases above - return null; + return null } diff --git a/src/commands/session/session.tsx b/src/commands/session/session.tsx index b7ae9fa1c..82135a3fa 100644 --- a/src/commands/session/session.tsx +++ b/src/commands/session/session.tsx @@ -1,139 +1,83 @@ -import { c as _c } from "react/compiler-runtime"; -import { toString as qrToString } from 'qrcode'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Pane } from '../../components/design-system/Pane.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { useAppState } from '../../state/AppState.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -import { logForDebugging } from '../../utils/debug.js'; +import { toString as qrToString } from 'qrcode' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Pane } from '../../components/design-system/Pane.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { useAppState } from '../../state/AppState.js' +import type { LocalJSXCommandCall } from '../../types/command.js' +import { logForDebugging } from '../../utils/debug.js' + type Props = { - onDone: () => void; -}; -function SessionInfo(t0) { - const $ = _c(19); - const { - onDone - } = t0; - const remoteSessionUrl = useAppState(_temp); - const [qrCode, setQrCode] = useState(""); - let t1; - let t2; - if ($[0] !== remoteSessionUrl) { - t1 = () => { - if (!remoteSessionUrl) { - return; - } - const url = remoteSessionUrl; - const generateQRCode = async function generateQRCode() { - const qr = await qrToString(url, { - type: "utf8", - errorCorrectionLevel: "L" - }); - setQrCode(qr); - }; - generateQRCode().catch(_temp2); - }; - t2 = [remoteSessionUrl]; - $[0] = remoteSessionUrl; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybinding("confirm:no", onDone, t3); - if (!remoteSessionUrl) { - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Not in remote mode. Start with `claude --remote` to use this command.(press esc to close); - $[4] = t4; - } else { - t4 = $[4]; - } - return t4; - } - let T0; - let t4; - let t5; - if ($[5] !== qrCode) { - const lines = qrCode.split("\n").filter(_temp3); - const isLoading = lines.length === 0; - T0 = Pane; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Remote session; - $[9] = t4; - } else { - t4 = $[9]; + onDone: () => void +} + +function SessionInfo({ onDone }: Props): React.ReactNode { + const remoteSessionUrl = useAppState(s => s.remoteSessionUrl) + const [qrCode, setQrCode] = useState('') + + // Generate QR code when URL is available + useEffect(() => { + if (!remoteSessionUrl) return + + const url = remoteSessionUrl + async function generateQRCode(): Promise { + const qr = await qrToString(url, { + type: 'utf8', + errorCorrectionLevel: 'L', + }) + setQrCode(qr) } - t5 = isLoading ? Generating QR code… : lines.map(_temp4); - $[5] = qrCode; - $[6] = T0; - $[7] = t4; - $[8] = t5; - } else { - T0 = $[6]; - t4 = $[7]; - t5 = $[8]; - } - let t6; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Open in browser: ; - $[10] = t6; - } else { - t6 = $[10]; - } - let t7; - if ($[11] !== remoteSessionUrl) { - t7 = {t6}{remoteSessionUrl}; - $[11] = remoteSessionUrl; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t8 = (press esc to close); - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== T0 || $[15] !== t4 || $[16] !== t5 || $[17] !== t7) { - t9 = {t4}{t5}{t7}{t8}; - $[14] = T0; - $[15] = t4; - $[16] = t5; - $[17] = t7; - $[18] = t9; - } else { - t9 = $[18]; + // Intentionally silent fail - URL is still shown so QR is non-critical + generateQRCode().catch(e => { + logForDebugging('QR code generation failed', e) + }) + }, [remoteSessionUrl]) + + // Handle ESC to dismiss + useKeybinding('confirm:no', onDone, { context: 'Confirmation' }) + + // Not in remote mode + if (!remoteSessionUrl) { + return ( + + + Not in remote mode. Start with `claude --remote` to use this command. + + (press esc to close) + + ) } - return t9; -} -function _temp4(line_0, i) { - return {line_0}; -} -function _temp3(line) { - return line.length > 0; -} -function _temp2(e) { - logForDebugging("QR code generation failed", e); -} -function _temp(s) { - return s.remoteSessionUrl; + + const lines = qrCode.split('\n').filter(line => line.length > 0) + const isLoading = lines.length === 0 + + return ( + + + Remote session + + + {/* QR Code - silently fails if generation errors, URL is still shown */} + {isLoading ? ( + Generating QR code… + ) : ( + lines.map((line, i) => {line}) + )} + + {/* URL */} + + Open in browser: + {remoteSessionUrl} + + + + (press esc to close) + + + ) } + export const call: LocalJSXCommandCall = async onDone => { - return ; -}; + return +} diff --git a/src/commands/skills/skills.tsx b/src/commands/skills/skills.tsx index a765951c3..568efdc52 100644 --- a/src/commands/skills/skills.tsx +++ b/src/commands/skills/skills.tsx @@ -1,7 +1,11 @@ -import * as React from 'react'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { SkillsMenu } from '../../components/skills/SkillsMenu.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { - return ; +import * as React from 'react' +import type { LocalJSXCommandContext } from '../../commands.js' +import { SkillsMenu } from '../../components/skills/SkillsMenu.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, +): Promise { + return } diff --git a/src/commands/stats/stats.tsx b/src/commands/stats/stats.tsx index 2fe5ca9d7..b467ee3d6 100644 --- a/src/commands/stats/stats.tsx +++ b/src/commands/stats/stats.tsx @@ -1,6 +1,7 @@ -import * as React from 'react'; -import { Stats } from '../../components/Stats.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; +import * as React from 'react' +import { Stats } from '../../components/Stats.js' +import type { LocalJSXCommandCall } from '../../types/command.js' + export const call: LocalJSXCommandCall = async onDone => { - return ; -}; + return +} diff --git a/src/commands/status/status.tsx b/src/commands/status/status.tsx index 6e0d9c342..25bb4b107 100644 --- a/src/commands/status/status.tsx +++ b/src/commands/status/status.tsx @@ -1,7 +1,11 @@ -import * as React from 'react'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { Settings } from '../../components/Settings/Settings.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { - return ; +import * as React from 'react' +import type { LocalJSXCommandContext } from '../../commands.js' +import { Settings } from '../../components/Settings/Settings.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, +): Promise { + return } diff --git a/src/commands/statusline.tsx b/src/commands/statusline.tsx index 2e5778156..d12f4ad2d 100644 --- a/src/commands/statusline.tsx +++ b/src/commands/statusline.tsx @@ -1,23 +1,31 @@ -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import type { Command } from '../commands.js'; -import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import type { Command } from '../commands.js' +import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js' + const statusline = { type: 'prompt', description: "Set up Claude Code's status line UI", - contentLength: 0, - // Dynamic content + contentLength: 0, // Dynamic content aliases: [], name: 'statusline', progressMessage: 'setting up statusLine', - allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.claude/settings.json)'], + allowedTools: [ + AGENT_TOOL_NAME, + 'Read(~/**)', + 'Edit(~/.claude/settings.json)', + ], source: 'builtin', disableNonInteractive: true, async getPromptForCommand(args): Promise { - const prompt = args.trim() || 'Configure my statusLine from my shell PS1 configuration'; - return [{ - type: 'text', - text: `Create an ${AGENT_TOOL_NAME} with subagent_type "statusline-setup" and the prompt "${prompt}"` - }]; - } -} satisfies Command; -export default statusline; + const prompt = + args.trim() || 'Configure my statusLine from my shell PS1 configuration' + return [ + { + type: 'text', + text: `Create an ${AGENT_TOOL_NAME} with subagent_type "statusline-setup" and the prompt "${prompt}"`, + }, + ] + }, +} satisfies Command + +export default statusline diff --git a/src/commands/tag/tag.tsx b/src/commands/tag/tag.tsx index e399248a2..c9d0c6524 100644 --- a/src/commands/tag/tag.tsx +++ b/src/commands/tag/tag.tsx @@ -1,214 +1,167 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import type { UUID } from 'crypto'; -import * as React from 'react'; -import { getSessionId } from '../../bootstrap/state.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Select } from '../../components/CustomSelect/select.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'; -import { Box, Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import { recursivelySanitizeUnicode } from '../../utils/sanitization.js'; -import { getCurrentSessionTag, getTranscriptPath, saveTag } from '../../utils/sessionStorage.js'; -function ConfirmRemoveTag(t0) { - const $ = _c(11); - const { - tagName, - onConfirm, - onCancel - } = t0; - const t1 = `Current tag: #${tagName}`; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = This will remove the tag from the current session.; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== onCancel || $[2] !== onConfirm) { - t3 = value => value === "yes" ? onConfirm() : onCancel(); - $[1] = onCancel; - $[2] = onConfirm; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Yes, remove tag", - value: "yes" - }, { - label: "No, keep tag", - value: "no" - }]; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== t3) { - t5 = {t2}; - $[10] = handleSelect; - $[11] = options; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - let t7; - if ($[16] !== handleCancel || $[17] !== t6) { - t7 = {t6}; - $[16] = handleCancel; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - return t7; + + return ( + + + {/* Description for first-time users */} + {!hasGenerated && ( + + Relive your year of coding with Claude. + + { + "We'll create a personalized ASCII animation celebrating your journey." + } + + + )} + + {/* Menu */} + { - switch (value) { - case 'auth': - await handleAuthenticate(); - break; - case 'back': - onCancel(); - break; - } - }} onCancel={onCancel} /> + { - switch (value_0) { - case 'tools': - onViewTools(); - break; - case 'auth': - case 'reauth': - await handleAuthenticate(); - break; - case 'clear-auth': - await handleClearAuth(); - break; - case 'claudeai-auth': - await handleClaudeAIAuth(); - break; - case 'claudeai-clear-auth': - handleClaudeAIClearAuth(); - break; - case 'reconnectMcpServer': - setIsReconnecting(true); - try { - const result_1 = await reconnectMcpServer(server.name); - if (server.config.type === 'claudeai-proxy') { - logEvent('tengu_claudeai_mcp_reconnect', { - success: result_1.client.type === 'connected' - }); - } - const { - message: message_0 - } = handleReconnectResult(result_1, server.name); - onComplete?.(message_0); - } catch (err_2) { - if (server.config.type === 'claudeai-proxy') { - logEvent('tengu_claudeai_mcp_reconnect', { - success: false - }); + + )} + + {menuOptions.length > 0 && ( + + { - if (value === 'tools') { - onViewTools(); - } else if (value === 'reconnectMcpServer') { - setIsReconnecting(true); - try { - const result = await reconnectMcpServer(server.name); - const { - message - } = handleReconnectResult(result, server.name); - onComplete?.(message); - } catch (err_0) { - onComplete?.(handleReconnectError(err_0, server.name)); - } finally { - setIsReconnecting(false); - } - } else if (value === 'toggle-enabled') { - await handleToggleEnabled(); - } else if (value === 'back') { - onCancel(); - } - }} onCancel={onCancel} /> - } + {menuOptions.length > 0 && ( + + { - const index_0 = parseInt(value); - const tool_0 = serverTools[index_0]; - if (tool_0) { - onSelectTool(tool_0, index_0); + }) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + + + + + ) } - }} onCancel={onBack} />; - $[11] = onBack; - $[12] = onSelectTool; - $[13] = serverTools; - $[14] = toolOptions; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== onBack || $[17] !== t3 || $[18] !== t6 || $[19] !== t7) { - t8 = {t7}; - $[16] = onBack; - $[17] = t3; - $[18] = t6; - $[19] = t7; - $[20] = t8; - } else { - t8 = $[20]; - } - return t8; -} -function _temp2(exitState) { - return exitState.pending ? Press {exitState.keyName} again to exit : ; -} -function _temp(s) { - return s.mcp.tools; + > + {serverTools.length === 0 ? ( + No tools available + ) : ( + { - onUpdateQuestionState(questionText, { - selectedValue: value_1 - }, false); - const textInput_0 = value_1 === "__other__" ? questionStates[questionText]?.textInputValue : undefined; - onAnswer(questionText, value_1, textInput_0); - }} onFocus={handleFocus} onCancel={onCancel} onDownFromLastItem={handleDownFromLastItem} isDisabled={isFooterFocused} layout="compact-vertical" onOpenEditor={handleOpenEditor} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} />}; - $[58] = currentQuestionIndex; - $[59] = handleFocus; - $[60] = handleOpenEditor; - $[61] = isFooterFocused; - $[62] = onAnswer; - $[63] = onCancel; - $[64] = onImagePaste; - $[65] = onRemoveImage; - $[66] = onSubmit; - $[67] = onUpdateQuestionState; - $[68] = options; - $[69] = pastedContents; - $[70] = question.multiSelect; - $[71] = question.question; - $[72] = questionStates; - $[73] = questionText; - $[74] = questions.length; - $[75] = t12; - } else { - t12 = $[75]; - } - let t13; - if ($[76] === Symbol.for("react.memo_cache_sentinel")) { - t13 = ; - $[76] = t13; - } else { - t13 = $[76]; + return ( + + ) } - let t14; - if ($[77] !== footerIndex || $[78] !== isFooterFocused) { - t14 = isFooterFocused && footerIndex === 0 ? {figures.pointer} : ; - $[77] = footerIndex; - $[78] = isFooterFocused; - $[79] = t14; - } else { - t14 = $[79]; - } - const t15 = isFooterFocused && footerIndex === 0 ? "suggestion" : undefined; - const t16 = options.length + 1; - let t17; - if ($[80] !== t15 || $[81] !== t16) { - t17 = {t16}. Chat about this; - $[80] = t15; - $[81] = t16; - $[82] = t17; - } else { - t17 = $[82]; - } - let t18; - if ($[83] !== t14 || $[84] !== t17) { - t18 = {t14}{t17}; - $[83] = t14; - $[84] = t17; - $[85] = t18; - } else { - t18 = $[85]; - } - let t19; - if ($[86] !== footerIndex || $[87] !== isFooterFocused || $[88] !== isInPlanMode || $[89] !== options.length) { - t19 = isInPlanMode && {isFooterFocused && footerIndex === 1 ? {figures.pointer} : }{options.length + 2}. Skip interview and plan immediately; - $[86] = footerIndex; - $[87] = isFooterFocused; - $[88] = isInPlanMode; - $[89] = options.length; - $[90] = t19; - } else { - t19 = $[90]; - } - let t20; - if ($[91] !== t18 || $[92] !== t19) { - t20 = {t13}{t18}{t19}; - $[91] = t18; - $[92] = t19; - $[93] = t20; - } else { - t20 = $[93]; - } - let t21; - if ($[94] !== questions.length) { - t21 = questions.length === 1 ? <>{figures.arrowUp}/{figures.arrowDown} to navigate : "Tab/Arrow keys to navigate"; - $[94] = questions.length; - $[95] = t21; - } else { - t21 = $[95]; - } - let t22; - if ($[96] !== isOtherFocused) { - t22 = isOtherFocused && editorName && <> · ctrl+g to edit in {editorName}; - $[96] = isOtherFocused; - $[97] = t22; - } else { - t22 = $[97]; - } - let t23; - if ($[98] !== t21 || $[99] !== t22) { - t23 = Enter to select ·{" "}{t21}{t22}{" "}· Esc to cancel; - $[98] = t21; - $[99] = t22; - $[100] = t23; - } else { - t23 = $[100]; - } - let t24; - if ($[101] !== minContentHeight || $[102] !== t12 || $[103] !== t20 || $[104] !== t23) { - t24 = {t12}{t20}{t23}; - $[101] = minContentHeight; - $[102] = t12; - $[103] = t20; - $[104] = t23; - $[105] = t24; - } else { - t24 = $[105]; - } - let t25; - if ($[106] !== t10 || $[107] !== t11 || $[108] !== t24) { - t25 = {t10}{t11}{t24}; - $[106] = t10; - $[107] = t11; - $[108] = t24; - $[109] = t25; - } else { - t25 = $[109]; - } - let t26; - if ($[110] !== handleKeyDown || $[111] !== t25 || $[112] !== t8) { - t26 = {t8}{t9}{t25}; - $[110] = handleKeyDown; - $[111] = t25; - $[112] = t8; - $[113] = t26; - } else { - t26 = $[113]; - } - return t26; -} -function _temp4(v) { - return v !== "__other__"; -} -function _temp3(opt_0) { - return opt_0.preview; -} -function _temp2(opt) { - return { - type: "text" as const, - value: opt.label, - label: opt.label, - description: opt.description - }; -} -function _temp(s) { - return s.toolPermissionContext.mode; + + return ( + + {isInPlanMode && planFilePath && ( + + + + Planning: + + + )} + + + + + + + + + + {question.multiSelect ? ( + { + onUpdateQuestionState( + questionText, + { selectedValue: values }, + true, + ) + const textInput = values.includes('__other__') + ? questionStates[questionText]?.textInputValue + : undefined + const finalValues = values + .filter(v => v !== '__other__') + .concat(textInput ? [textInput] : []) + onAnswer(questionText, finalValues, undefined, false) + }} + onFocus={handleFocus} + onCancel={onCancel} + submitButtonText={ + currentQuestionIndex === questions.length - 1 + ? 'Submit' + : 'Next' + } + onSubmit={onSubmit} + onDownFromLastItem={handleDownFromLastItem} + isDisabled={isFooterFocused} + onOpenEditor={handleOpenEditor} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + /> + ) : ( + onFinalResponse(value as 'submit' | 'cancel')} onCancel={() => onFinalResponse("cancel")} />; - $[16] = onFinalResponse; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== minContentHeight || $[19] !== t10 || $[20] !== t4 || $[21] !== t5 || $[22] !== t6) { - t11 = {t4}{t5}{t6}{t7}{t10}; - $[18] = minContentHeight; - $[19] = t10; - $[20] = t4; - $[21] = t5; - $[22] = t6; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== t11 || $[25] !== t2) { - t12 = {t1}{t2}{t3}{t11}; - $[24] = t11; - $[25] = t2; - $[26] = t12; - } else { - t12 = $[26]; - } - return t12; + questions: Question[] + currentQuestionIndex: number + answers: Record + allQuestionsAnswered: boolean + permissionResult: PermissionDecision + minContentHeight?: number + onFinalResponse: (value: 'submit' | 'cancel') => void +} + +export function SubmitQuestionsView({ + questions, + currentQuestionIndex, + answers, + allQuestionsAnswered, + permissionResult, + minContentHeight, + onFinalResponse, +}: Props): React.ReactNode { + return ( + + + + + + + {!allQuestionsAnswered && ( + + + {figures.warning} You have not answered all questions + + + )} + {Object.keys(answers).length > 0 && ( + + {questions + .filter((q: Question) => q?.question && answers[q.question]) + .map((q: Question) => { + const answer = answers[q?.question] + + return ( + + + {figures.bullet} {q?.question || 'Question'} + + + + {figures.arrowRight} {answer} + + + + ) + })} + + )} + + + Ready to submit your answers? + + ({ - ...o, - disabled: true - })) : options : options} isDisabled={feature('BASH_CLASSIFIER') ? toolUseConfirm.classifierAutoApproved : false} inlineDescriptions onChange={onSelect} onCancel={() => handleReject()} onFocus={handleFocus} onInputModeToggle={handleInputModeToggle} /> + ; - $[16] = onChange; - $[17] = onDone; - $[18] = options; - $[19] = t8; - } else { - t8 = $[19]; } - let t9; - if ($[20] !== t6 || $[21] !== t8) { - t9 = {t6}{t7}{t8}; - $[20] = t6; - $[21] = t8; - $[22] = t9; - } else { - t9 = $[22]; - } - let t10; - if ($[23] !== onDone || $[24] !== t9) { - t10 = {t9}; - $[23] = onDone; - $[24] = t9; - $[25] = t10; - } else { - t10 = $[25]; - } - return t10; + + return ( + + + + + Accessibility:{' '} + {tccState.accessibility + ? `${figures.tick} granted` + : `${figures.cross} not granted`} + + + Screen Recording:{' '} + {tccState.screenRecording + ? `${figures.tick} granted` + : `${figures.cross} not granted`} + + + + Grant the missing permissions in System Settings, then select + "Try again". macOS may require you to restart Claude Code + after granting Screen Recording. + + ; - $[35] = options; - $[36] = t17; - $[37] = t18; - $[38] = t19; - } else { - t19 = $[38]; - } - let t20; - if ($[39] !== t12 || $[40] !== t14 || $[41] !== t15 || $[42] !== t16 || $[43] !== t19) { - t20 = {t12}{t14}{t15}{t16}{t19}; - $[39] = t12; - $[40] = t14; - $[41] = t15; - $[42] = t16; - $[43] = t19; - $[44] = t20; - } else { - t20 = $[44]; - } - let t21; - if ($[45] !== t11 || $[46] !== t20) { - t21 = {t20}; - $[45] = t11; - $[46] = t20; - $[47] = t21; - } else { - t21 = $[47]; + const now = Date.now() + const granted = request.apps.flatMap(a => + a.resolved && checked.has(a.resolved.bundleId) + ? [ + { + bundleId: a.resolved.bundleId, + displayName: a.resolved.displayName, + grantedAt: now, + }, + ] + : [], + ) + const denied = request.apps + .filter(a => !a.resolved || !checked.has(a.resolved.bundleId)) + .map(a => ({ + bundleId: a.resolved?.bundleId ?? a.requestedName, + reason: a.resolved + ? ('user_denied' as const) + : ('not_installed' as const), + })) + // Grant all requested flags on allow — per-flag toggles are a follow-up. + const flags = { + ...DEFAULT_GRANT_FLAGS, + ...Object.fromEntries(requestedFlagKeys.map(k => [k, true] as const)), + } + onDone({ granted, denied, flags }) } - return t21; -} -function _temp4(flag) { - return {" "}· {flag}; -} -function _temp3(k_0) { - return [k_0, true] as const; -} -function _temp2(a_2) { - return { - bundleId: a_2.resolved?.bundleId ?? a_2.requestedName, - reason: a_2.resolved ? "user_denied" as const : "not_installed" as const - }; -} -function _temp(a) { - return a.resolved && !a.alreadyGranted ? [a.resolved.bundleId] : []; + + return ( + respond(false)} + > + + {request.reason ? {request.reason} : null} + + + {request.apps.map(a => { + const resolved = a.resolved + if (!resolved) { + return ( + + {' '} + {figures.circle} {a.requestedName}{' '} + (not installed) + + ) + } + if (a.alreadyGranted) { + return ( + + {' '} + {figures.tick} {resolved.displayName}{' '} + (already granted) + + ) + } + const sentinel = getSentinelCategory(resolved.bundleId) + const isChecked = checked.has(resolved.bundleId) + return ( + + + {' '} + {isChecked ? figures.circleFilled : figures.circle}{' '} + {resolved.displayName} + + {sentinel ? ( + + {' '} + {figures.warning} {SENTINEL_WARNING[sentinel]} + + ) : null} + + ) + })} + + + {requestedFlagKeys.length > 0 ? ( + + Also requested: + {requestedFlagKeys.map(flag => ( + + {' '}· {flag} + + ))} + + ) : null} + + {request.willHide && request.willHide.length > 0 ? ( + + {request.willHide.length} other{' '} + {plural(request.willHide.length, 'app')} will be hidden while Claude + works. + + ) : null} + + ; - $[12] = handleResponse; - $[13] = t7; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== t8 || $[16] !== workerBadge) { - t9 = {t8}; - $[15] = t8; - $[16] = workerBadge; - $[17] = t9; - } else { - t9 = $[17]; - } - return t9; -} -function _temp(s) { - return s.toolPermissionContext.mode; + + return ( + + + + Claude wants to enter plan mode to explore and design an + implementation approach. + + + + In plan mode, Claude will: + · Explore the codebase thoroughly + · Identify existing patterns + · Design an implementation strategy + · Present a plan for your approval + + + + + No code changes will be made until you approve the plan. + + + + + void handleResponseRef.current(v)} onCancel={() => handleCancelRef.current?.()} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} /> + { - logEvent('tengu_plan_exit', { - planLengthChars: 0, - outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - planStructureVariant - }); - onDone(); - onReject(); - toolUseConfirm.onReject(); - }} /> + handleCancelRef.current?.()} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} /> + { - const selected = options.find(opt => opt.value === value); - if (selected) { - // For reject option - if (selected.option.type === 'reject') { - const trimmedFeedback = rejectFeedback.trim(); - onChange(selected.option, trimmedFeedback || undefined); - return; - } - // For accept-once option, pass accept feedback if present - if (selected.option.type === 'accept-once') { - const trimmedFeedback_0 = acceptFeedback.trim(); - onChange(selected.option, trimmedFeedback_0 || undefined); - return; - } - onChange(selected.option); - } - }} onCancel={() => onChange({ - type: 'reject' - })} onFocus={value_0 => setFocusedOption(value_0)} onInputModeToggle={handleInputModeToggle} /> + ; - $[42] = handleCancel; - $[43] = handleInputModeToggle; - $[44] = handleSelect; - $[45] = selectOptions; - $[46] = t9; - $[47] = t10; - } else { - t10 = $[47]; - } - const t11 = showTabHint && " \xB7 Tab to amend"; - let t12; - if ($[48] !== t11) { - t12 = Esc to cancel{t11}; - $[48] = t11; - $[49] = t12; - } else { - t12 = $[49]; - } - let t13; - if ($[50] !== t10 || $[51] !== t12 || $[52] !== t8) { - t13 = {t8}{t10}{t12}; - $[50] = t10; - $[51] = t12; - $[52] = t8; - $[53] = t13; - } else { - t13 = $[53]; - } - return t13; -} -function _temp(prev) { - return { - ...prev, - attribution: { - ...prev.attribution, - escapeCount: prev.attribution.escapeCount + 1 } - }; + return handlers + }, [options, handleSelect]) + + useKeybindings(keybindingHandlers, { context: 'Confirmation' }) + + // Handle cancel (Esc) + const handleCancel = useCallback(() => { + logEvent('tengu_permission_request_escape', {}) + // Increment escape count for attribution tracking + setAppState(prev => ({ + ...prev, + attribution: { + ...prev.attribution, + escapeCount: prev.attribution.escapeCount + 1, + }, + })) + onCancel?.() + }, [onCancel, setAppState]) + + return ( + + {typeof question === 'string' ? {question} : question} + = { - toolUseConfirm: ToolUseConfirm; - toolUseContext: ToolUseContext; - onDone(): void; - onReject(): void; - verbose: boolean; - workerBadge: WorkerBadgeProps | undefined; + toolUseConfirm: ToolUseConfirm + toolUseContext: ToolUseContext + onDone(): void + onReject(): void + verbose: boolean + workerBadge: WorkerBadgeProps | undefined /** * Register JSX to render in a sticky footer below the scrollable area. * Fullscreen mode only (non-fullscreen has no sticky area — terminal @@ -98,119 +131,102 @@ export type PermissionRequestProps = { * to avoid stale closures (React reconciles the JSX, preserving Select's * internal focus/input state). */ - setStickyFooter?: (jsx: React.ReactNode | null) => void; -}; + setStickyFooter?: (jsx: React.ReactNode | null) => void +} + export type ToolUseConfirm = { - assistantMessage: AssistantMessage; - tool: Tool; - description: string; - input: z.infer; - toolUseContext: ToolUseContext; - toolUseID: string; - permissionResult: PermissionDecision; - permissionPromptStartTimeMs: number; + assistantMessage: AssistantMessage + tool: Tool + description: string + input: z.infer + toolUseContext: ToolUseContext + toolUseID: string + permissionResult: PermissionDecision + permissionPromptStartTimeMs: number /** * Called when user interacts with the permission dialog (e.g., arrow keys, tab, typing). * This prevents async auto-approval mechanisms (like the bash classifier) from * dismissing the dialog while the user is actively engaging with it. */ - classifierCheckInProgress?: boolean; - classifierAutoApproved?: boolean; - classifierMatchedRule?: string; - workerBadge?: WorkerBadgeProps; - onUserInteraction(): void; - onAbort(): void; - onDismissCheckmark?(): void; - onAllow(updatedInput: z.infer, permissionUpdates: PermissionUpdate[], feedback?: string, contentBlocks?: ContentBlockParam[]): void; - onReject(feedback?: string, contentBlocks?: ContentBlockParam[]): void; - recheckPermission(): Promise; -}; + classifierCheckInProgress?: boolean + classifierAutoApproved?: boolean + classifierMatchedRule?: string + workerBadge?: WorkerBadgeProps + onUserInteraction(): void + onAbort(): void + onDismissCheckmark?(): void + onAllow( + updatedInput: z.infer, + permissionUpdates: PermissionUpdate[], + feedback?: string, + contentBlocks?: ContentBlockParam[], + ): void + onReject(feedback?: string, contentBlocks?: ContentBlockParam[]): void + recheckPermission(): Promise +} + function getNotificationMessage(toolUseConfirm: ToolUseConfirm): string { - const toolName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); + const toolName = toolUseConfirm.tool.userFacingName( + toolUseConfirm.input as never, + ) + if (toolUseConfirm.tool === ExitPlanModeV2Tool) { - return 'Claude Code needs your approval for the plan'; + return 'Claude Code needs your approval for the plan' } + if (toolUseConfirm.tool === EnterPlanModeTool) { - return 'Claude Code wants to enter plan mode'; + return 'Claude Code wants to enter plan mode' } - if (feature('REVIEW_ARTIFACT') && toolUseConfirm.tool === ReviewArtifactTool) { - return 'Claude needs your approval for a review artifact'; + + if ( + feature('REVIEW_ARTIFACT') && + toolUseConfirm.tool === ReviewArtifactTool + ) { + return 'Claude needs your approval for a review artifact' } + if (!toolName || toolName.trim() === '') { - return 'Claude Code needs your attention'; + return 'Claude Code needs your attention' } - return `Claude needs your permission to use ${toolName}`; + + return `Claude needs your permission to use ${toolName}` } // TODO: Move this to Tool.renderPermissionRequest -export function PermissionRequest(t0) { - const $ = _c(18); - const { - toolUseConfirm, - toolUseContext, - onDone, - onReject, - verbose, - workerBadge, - setStickyFooter - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== onReject || $[2] !== toolUseConfirm) { - t1 = () => { - onDone(); - onReject(); - toolUseConfirm.onReject(); - }; - $[0] = onDone; - $[1] = onReject; - $[2] = toolUseConfirm; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[4] = t2; - } else { - t2 = $[4]; - } - useKeybinding("app:interrupt", t1, t2); - let t3; - if ($[5] !== toolUseConfirm) { - t3 = getNotificationMessage(toolUseConfirm); - $[5] = toolUseConfirm; - $[6] = t3; - } else { - t3 = $[6]; - } - const notificationMessage = t3; - useNotifyAfterTimeout(notificationMessage, "permission_prompt"); - let t4; - if ($[7] !== toolUseConfirm.tool) { - t4 = permissionComponentForTool(toolUseConfirm.tool); - $[7] = toolUseConfirm.tool; - $[8] = t4; - } else { - t4 = $[8]; - } - const PermissionComponent = t4; - let t5; - if ($[9] !== PermissionComponent || $[10] !== onDone || $[11] !== onReject || $[12] !== setStickyFooter || $[13] !== toolUseConfirm || $[14] !== toolUseContext || $[15] !== verbose || $[16] !== workerBadge) { - t5 = ; - $[9] = PermissionComponent; - $[10] = onDone; - $[11] = onReject; - $[12] = setStickyFooter; - $[13] = toolUseConfirm; - $[14] = toolUseContext; - $[15] = verbose; - $[16] = workerBadge; - $[17] = t5; - } else { - t5 = $[17]; - } - return t5; +export function PermissionRequest({ + toolUseConfirm, + toolUseContext, + onDone, + onReject, + verbose, + workerBadge, + setStickyFooter, +}: PermissionRequestProps): React.ReactNode { + // Handle Ctrl+C (app:interrupt) to reject + useKeybinding( + 'app:interrupt', + () => { + onDone() + onReject() + toolUseConfirm.onReject() + }, + { context: 'Confirmation' }, + ) + + const notificationMessage = getNotificationMessage(toolUseConfirm) + useNotifyAfterTimeout(notificationMessage, 'permission_prompt') + + const PermissionComponent = permissionComponentForTool(toolUseConfirm.tool) + + return ( + + ) } diff --git a/src/components/permissions/PermissionRequestTitle.tsx b/src/components/permissions/PermissionRequestTitle.tsx index a324d3e97..953cca22b 100644 --- a/src/components/permissions/PermissionRequestTitle.tsx +++ b/src/components/permissions/PermissionRequestTitle.tsx @@ -1,65 +1,41 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; -import type { WorkerBadgeProps } from './WorkerBadge.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' +import type { WorkerBadgeProps } from './WorkerBadge.js' + type Props = { - title: string; - subtitle?: React.ReactNode; - color?: keyof Theme; - workerBadge?: WorkerBadgeProps; -}; -export function PermissionRequestTitle(t0) { - const $ = _c(13); - const { - title, - subtitle, - color: t1, - workerBadge - } = t0; - const color = t1 === undefined ? "permission" : t1; - let t2; - if ($[0] !== color || $[1] !== title) { - t2 = {title}; - $[0] = color; - $[1] = title; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== workerBadge) { - t3 = workerBadge && {"\xB7 "}@{workerBadge.name}; - $[3] = workerBadge; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== subtitle) { - t5 = subtitle != null && (typeof subtitle === "string" ? {subtitle} : subtitle); - $[8] = subtitle; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t4 || $[11] !== t5) { - t6 = {t4}{t5}; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; + title: string + subtitle?: React.ReactNode + color?: keyof Theme + workerBadge?: WorkerBadgeProps +} + +export function PermissionRequestTitle({ + title, + subtitle, + color = 'permission', + workerBadge, +}: Props): React.ReactNode { + return ( + + + + {title} + + {workerBadge && ( + + {'· '}@{workerBadge.name} + + )} + + {subtitle != null && + (typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + ))} + + ) } diff --git a/src/components/permissions/PermissionRuleExplanation.tsx b/src/components/permissions/PermissionRuleExplanation.tsx index 32e6a944b..406f7e3b8 100644 --- a/src/components/permissions/PermissionRuleExplanation.tsx +++ b/src/components/permissions/PermissionRuleExplanation.tsx @@ -1,120 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import React from 'react'; -import { Ansi, Box, Text } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import type { PermissionDecision, PermissionDecisionReason } from '../../utils/permissions/PermissionResult.js'; -import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js'; -import type { Theme } from '../../utils/theme.js'; -import ThemedText from '../design-system/ThemedText.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import React from 'react' +import { Ansi, Box, Text } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import type { + PermissionDecision, + PermissionDecisionReason, +} from '../../utils/permissions/PermissionResult.js' +import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' +import type { Theme } from '../../utils/theme.js' +import ThemedText from '../design-system/ThemedText.js' + export type PermissionRuleExplanationProps = { - permissionResult: PermissionDecision; - toolType: 'tool' | 'command' | 'edit' | 'read'; -}; + permissionResult: PermissionDecision + toolType: 'tool' | 'command' | 'edit' | 'read' +} + type DecisionReasonStrings = { - reasonString: string; - configString?: string; + reasonString: string + configString?: string /** When set, reasonString is plain text rendered with this theme color instead of . */ - themeColor?: keyof Theme; -}; -function stringsForDecisionReason(reason: PermissionDecisionReason | undefined, toolType: 'tool' | 'command' | 'edit' | 'read'): DecisionReasonStrings | null { + themeColor?: keyof Theme +} + +function stringsForDecisionReason( + reason: PermissionDecisionReason | undefined, + toolType: 'tool' | 'command' | 'edit' | 'read', +): DecisionReasonStrings | null { if (!reason) { - return null; + return null } - if ((feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && reason.type === 'classifier') { + if ( + (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && + reason.type === 'classifier' + ) { if (reason.classifier === 'auto-mode') { return { reasonString: `Auto mode classifier requires confirmation for this ${toolType}.\n${reason.reason}`, configString: undefined, - themeColor: 'error' - }; + themeColor: 'error', + } } return { reasonString: `Classifier ${chalk.bold(reason.classifier)} requires confirmation for this ${toolType}.\n${reason.reason}`, - configString: undefined - }; + configString: undefined, + } } switch (reason.type) { case 'rule': return { - reasonString: `Permission rule ${chalk.bold(permissionRuleValueToString(reason.rule.ruleValue))} requires confirmation for this ${toolType}.`, - configString: reason.rule.source === 'policySettings' ? undefined : '/permissions to update rules' - }; - case 'hook': - { - const hookReasonString = reason.reason ? `:\n${reason.reason}` : '.'; - const sourceLabel = reason.hookSource ? ` ${chalk.dim(`[${reason.hookSource}]`)}` : ''; - return { - reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`, - configString: '/hooks to update' - }; + reasonString: `Permission rule ${chalk.bold( + permissionRuleValueToString(reason.rule.ruleValue), + )} requires confirmation for this ${toolType}.`, + configString: + reason.rule.source === 'policySettings' + ? undefined + : '/permissions to update rules', } + case 'hook': { + const hookReasonString = reason.reason ? `:\n${reason.reason}` : '.' + const sourceLabel = reason.hookSource + ? ` ${chalk.dim(`[${reason.hookSource}]`)}` + : '' + return { + reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`, + configString: '/hooks to update', + } + } case 'safetyCheck': case 'other': return { reasonString: reason.reason, - configString: undefined - }; + configString: undefined, + } case 'workingDir': return { reasonString: reason.reason, - configString: '/permissions to update rules' - }; + configString: '/permissions to update rules', + } default: - return null; + return null } } -export function PermissionRuleExplanation(t0) { - const $ = _c(11); - const { - permissionResult, - toolType - } = t0; - const permissionMode = useAppState(_temp); - const t1 = permissionResult?.decisionReason; - let t2; - if ($[0] !== t1 || $[1] !== toolType) { - t2 = stringsForDecisionReason(t1, toolType); - $[0] = t1; - $[1] = toolType; - $[2] = t2; - } else { - t2 = $[2]; - } - const strings = t2; + +export function PermissionRuleExplanation({ + permissionResult, + toolType, +}: PermissionRuleExplanationProps): React.ReactNode { + const permissionMode = useAppState(s => s.toolPermissionContext.mode) + const strings = stringsForDecisionReason( + permissionResult?.decisionReason, + toolType, + ) if (!strings) { - return null; - } - const themeColor = strings.themeColor ?? (permissionResult?.decisionReason?.type === "hook" && permissionMode === "auto" ? "warning" : undefined); - let t3; - if ($[3] !== strings.reasonString || $[4] !== themeColor) { - t3 = themeColor ? {strings.reasonString} : {strings.reasonString}; - $[3] = strings.reasonString; - $[4] = themeColor; - $[5] = t3; - } else { - t3 = $[5]; + return null } - let t4; - if ($[6] !== strings.configString) { - t4 = strings.configString && {strings.configString}; - $[6] = strings.configString; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = {t3}{t4}; - $[8] = t3; - $[9] = t4; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; -} -function _temp(s) { - return s.toolPermissionContext.mode; + + const themeColor = + strings.themeColor ?? + (permissionResult?.decisionReason?.type === 'hook' && + permissionMode === 'auto' + ? 'warning' + : undefined) + + return ( + + {themeColor ? ( + {strings.reasonString} + ) : ( + + {strings.reasonString} + + )} + {strings.configString && {strings.configString}} + + ) } diff --git a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx index 89604c4e9..5dcd0e488 100644 --- a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx @@ -1,43 +1,48 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../../services/analytics/index.js'; -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; -import { getDestructiveCommandWarning } from '../../../tools/PowerShellTool/destructiveCommandWarning.js'; -import { PowerShellTool } from '../../../tools/PowerShellTool/PowerShellTool.js'; -import { isAllowlistedCommand } from '../../../tools/PowerShellTool/readOnlyValidation.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { getCompoundCommandPrefixesStatic } from '../../../utils/powershell/staticPrefix.js'; -import { Select } from '../../CustomSelect/select.js'; -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import { PermissionExplainerContent, usePermissionExplainerUI } from '../PermissionExplanation.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js'; -import { logUnaryPermissionEvent } from '../utils.js'; -import { powershellToolUseOptions } from './powershellToolUseOptions.js'; -export function PowerShellPermissionRequest(props: PermissionRequestProps): React.ReactNode { - const { - toolUseConfirm, - toolUseContext, - onDone, - onReject, - workerBadge - } = props; - const { - command, - description - } = PowerShellTool.inputSchema.parse(toolUseConfirm.input); - const [theme] = useTheme(); +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Box, Text, useTheme } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../../services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' +import { getDestructiveCommandWarning } from '../../../tools/PowerShellTool/destructiveCommandWarning.js' +import { PowerShellTool } from '../../../tools/PowerShellTool/PowerShellTool.js' +import { isAllowlistedCommand } from '../../../tools/PowerShellTool/readOnlyValidation.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { getCompoundCommandPrefixesStatic } from '../../../utils/powershell/staticPrefix.js' +import { Select } from '../../CustomSelect/select.js' +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' +import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js' +import { PermissionDialog } from '../PermissionDialog.js' +import { + PermissionExplainerContent, + usePermissionExplainerUI, +} from '../PermissionExplanation.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js' +import { logUnaryPermissionEvent } from '../utils.js' +import { powershellToolUseOptions } from './powershellToolUseOptions.js' + +export function PowerShellPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const { toolUseConfirm, toolUseContext, onDone, onReject, workerBadge } = + props + + const { command, description } = PowerShellTool.inputSchema.parse( + toolUseConfirm.input, + ) + + const [theme] = useTheme() const explainerState = usePermissionExplainerUI({ toolName: toolUseConfirm.tool.name, toolInput: toolUseConfirm.input, toolDescription: toolUseConfirm.description, - messages: toolUseContext.messages - }); + messages: toolUseContext.messages, + }) const { yesInputMode, noInputMode, @@ -50,15 +55,21 @@ export function PowerShellPermissionRequest(props: PermissionRequestProps): Reac focusedOption, handleInputModeToggle, handleReject, - handleFocus + handleFocus, } = useShellPermissionFeedback({ toolUseConfirm, onDone, onReject, - explainerVisible: explainerState.visible - }); - const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false) ? getDestructiveCommandWarning(command) : null; - const [showPermissionDebug, setShowPermissionDebug] = useState(false); + explainerVisible: explainerState.visible, + }) + const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_destructive_command_warning', + false, + ) + ? getDestructiveCommandWarning(command) + : null + + const [showPermissionDebug, setShowPermissionDebug] = useState(false) // Editable prefix — compute static prefix locally (no LLM call). // Initialize synchronously to the raw command for single-line commands so @@ -69,166 +80,233 @@ export function PowerShellPermissionRequest(props: PermissionRequestProps): Reac // corpus shows 14 multiline rules, zero match twice). For compound commands, // computes a prefix per subcommand, excluding subcommands that are already // auto-allowed (read-only). - const [editablePrefix, setEditablePrefix] = useState(command.includes('\n') ? undefined : command); - const hasUserEditedPrefix = useRef(false); + const [editablePrefix, setEditablePrefix] = useState( + command.includes('\n') ? undefined : command, + ) + const hasUserEditedPrefix = useRef(false) useEffect(() => { - let cancelled = false; + let cancelled = false // Filter receives ParsedCommandElement — isAllowlistedCommand works from // element.name/nameType/args directly. isReadOnlyCommand(text) would need // to reparse (pwsh.exe spawn per subcommand) and returns false without the // full parsed AST, making the filter a no-op. - getCompoundCommandPrefixesStatic(command, element => isAllowlistedCommand(element, element.text)).then(prefixes => { - if (cancelled || hasUserEditedPrefix.current) return; - if (prefixes.length > 0) { - setEditablePrefix(`${prefixes[0]}:*`); - } - }).catch(() => {}); + getCompoundCommandPrefixesStatic(command, element => + isAllowlistedCommand(element, element.text), + ) + .then(prefixes => { + if (cancelled || hasUserEditedPrefix.current) return + if (prefixes.length > 0) { + setEditablePrefix(`${prefixes[0]}:*`) + } + }) + .catch(() => {}) return () => { - cancelled = true; - }; + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [command]); + }, [command]) + const onEditablePrefixChange = useCallback((value: string) => { - hasUserEditedPrefix.current = true; - setEditablePrefix(value); - }, []); - const unaryEvent = useMemo(() => ({ - completion_type: 'tool_use_single', - language_name: 'none' - }), []); - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - const options = useMemo(() => powershellToolUseOptions({ - suggestions: toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined, - onRejectFeedbackChange: setRejectFeedback, - onAcceptFeedbackChange: setAcceptFeedback, - yesInputMode, - noInputMode, - editablePrefix, - onEditablePrefixChange - }), [toolUseConfirm, yesInputMode, noInputMode, editablePrefix, onEditablePrefixChange]); + hasUserEditedPrefix.current = true + setEditablePrefix(value) + }, []) + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const options = useMemo( + () => + powershellToolUseOptions({ + suggestions: + toolUseConfirm.permissionResult.behavior === 'ask' + ? toolUseConfirm.permissionResult.suggestions + : undefined, + onRejectFeedbackChange: setRejectFeedback, + onAcceptFeedbackChange: setAcceptFeedback, + yesInputMode, + noInputMode, + editablePrefix, + onEditablePrefixChange, + }), + [ + toolUseConfirm, + yesInputMode, + noInputMode, + editablePrefix, + onEditablePrefixChange, + ], + ) // Toggle permission debug info with keybinding const handleToggleDebug = useCallback(() => { - setShowPermissionDebug(prev => !prev); - }, []); + setShowPermissionDebug(prev => !prev) + }, []) useKeybinding('permission:toggleDebug', handleToggleDebug, { - context: 'Confirmation' - }); + context: 'Confirmation', + }) + function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) const optionIndex: Record = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, - no: 3 - }; + no: 3, + } logEvent('tengu_permission_request_option_selected', { option_index: optionIndex[value], - explainer_visible: explainerState.visible - }); - const toolNameForAnalytics = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; + explainer_visible: explainerState.visible, + }) + + const toolNameForAnalytics = sanitizeToolNameForAnalytics( + toolUseConfirm.tool.name, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + if (value === 'yes-prefix-edited') { - const trimmedPrefix = (editablePrefix ?? '').trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + const trimmedPrefix = (editablePrefix ?? '').trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') if (!trimmedPrefix) { - toolUseConfirm.onAllow(toolUseConfirm.input, []); + toolUseConfirm.onAllow(toolUseConfirm.input, []) } else { - const prefixUpdates: PermissionUpdate[] = [{ - type: 'addRules', - rules: [{ - toolName: PowerShellTool.name, - ruleContent: trimmedPrefix - }], - behavior: 'allow', - destination: 'localSettings' - }]; - toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates); + const prefixUpdates: PermissionUpdate[] = [ + { + type: 'addRules', + rules: [ + { + toolName: PowerShellTool.name, + ruleContent: trimmedPrefix, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ] + toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates) } - onDone(); - return; + onDone() + return } + switch (value) { - case 'yes': - { - const trimmedFeedback = acceptFeedback.trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Log accept submission with feedback context - logEvent('tengu_accept_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: yesFeedbackModeEntered - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], trimmedFeedback || undefined); - onDone(); - break; - } - case 'yes-apply-suggestions': - { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) - const permissionUpdates = 'suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions || [] : []; - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates); - onDone(); - break; - } - case 'no': - { - const trimmedFeedback = rejectFeedback.trim(); - - // Log reject submission with feedback context - logEvent('tengu_reject_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: noFeedbackModeEntered - }); - - // Process rejection (with or without feedback) - handleReject(trimmedFeedback || undefined); - break; - } + case 'yes': { + const trimmedFeedback = acceptFeedback.trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Log accept submission with feedback context + logEvent('tengu_accept_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: yesFeedbackModeEntered, + }) + toolUseConfirm.onAllow( + toolUseConfirm.input, + [], + trimmedFeedback || undefined, + ) + onDone() + break + } + case 'yes-apply-suggestions': { + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) + const permissionUpdates = + 'suggestions' in toolUseConfirm.permissionResult + ? toolUseConfirm.permissionResult.suggestions || [] + : [] + toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates) + onDone() + break + } + case 'no': { + const trimmedFeedback = rejectFeedback.trim() + + // Log reject submission with feedback context + logEvent('tengu_reject_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: noFeedbackModeEntered, + }) + + // Process rejection (with or without feedback) + handleReject(trimmedFeedback || undefined) + break + } } } - return + + return ( + - {PowerShellTool.renderToolUseMessage({ - command, - description - }, { - theme, - verbose: true - } // always show the full command - )} + {PowerShellTool.renderToolUseMessage( + { command, description }, + { theme, verbose: true }, // always show the full command + )} - {!explainerState.visible && {toolUseConfirm.description}} - + {!explainerState.visible && ( + {toolUseConfirm.description} + )} + - {showPermissionDebug ? <> - - {toolUseContext.options.debug && + {showPermissionDebug ? ( + <> + + {toolUseContext.options.debug && ( + Ctrl-D to hide debug info - } - : <> + + )} + + ) : ( + <> - - {destructiveWarning && + + {destructiveWarning && ( + {destructiveWarning} - } + + )} Do you want to proceed? - handleReject()} + onFocus={handleFocus} + onInputModeToggle={handleInputModeToggle} + /> Esc to cancel - {(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'} - {explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} + {explainerState.enabled && + ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} - {toolUseContext.options.debug && Ctrl+d to show debug info} + {toolUseContext.options.debug && ( + Ctrl+d to show debug info + )} - } - ; + + )} + + ) } diff --git a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx index d1daa5124..2ad089efe 100644 --- a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx @@ -1,9 +1,15 @@ -import { POWERSHELL_TOOL_NAME } from '../../../tools/PowerShellTool/toolName.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js'; -export type PowerShellToolUseOption = 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited' | 'no'; +import { POWERSHELL_TOOL_NAME } from '../../../tools/PowerShellTool/toolName.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js' + +export type PowerShellToolUseOption = + | 'yes' + | 'yes-apply-suggestions' + | 'yes-prefix-edited' + | 'no' + export function powershellToolUseOptions({ suggestions = [], onRejectFeedbackChange, @@ -11,17 +17,18 @@ export function powershellToolUseOptions({ yesInputMode = false, noInputMode = false, editablePrefix, - onEditablePrefixChange + onEditablePrefixChange, }: { - suggestions?: PermissionUpdate[]; - onRejectFeedbackChange: (value: string) => void; - onAcceptFeedbackChange: (value: string) => void; - yesInputMode?: boolean; - noInputMode?: boolean; - editablePrefix?: string; - onEditablePrefixChange?: (value: string) => void; + suggestions?: PermissionUpdate[] + onRejectFeedbackChange: (value: string) => void + onAcceptFeedbackChange: (value: string) => void + yesInputMode?: boolean + noInputMode?: boolean + editablePrefix?: string + onEditablePrefixChange?: (value: string) => void }): OptionWithDescription[] { - const options: OptionWithDescription[] = []; + const options: OptionWithDescription[] = [] + if (yesInputMode) { options.push({ type: 'input', @@ -29,13 +36,13 @@ export function powershellToolUseOptions({ value: 'yes', placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'Yes', - value: 'yes' - }); + value: 'yes', + }) } // Note: No sandbox toggle for PowerShell - sandbox is not supported on Windows @@ -47,8 +54,17 @@ export function powershellToolUseOptions({ // directory permissions or Read-tool rules, so fall back to the label when // those are present. if (shouldShowAlwaysAllowOptions() && suggestions.length > 0) { - const hasNonPowerShellSuggestions = suggestions.some(s => s.type === 'addDirectories' || s.type === 'addRules' && s.rules?.some(r => r.toolName !== POWERSHELL_TOOL_NAME)); - if (editablePrefix !== undefined && onEditablePrefixChange && !hasNonPowerShellSuggestions) { + const hasNonPowerShellSuggestions = suggestions.some( + s => + s.type === 'addDirectories' || + (s.type === 'addRules' && + s.rules?.some(r => r.toolName !== POWERSHELL_TOOL_NAME)), + ) + if ( + editablePrefix !== undefined && + onEditablePrefixChange && + !hasNonPowerShellSuggestions + ) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -59,18 +75,22 @@ export function powershellToolUseOptions({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - resetCursorOnUpdate: true - }); + resetCursorOnUpdate: true, + }) } else { - const label = generateShellSuggestionsLabel(suggestions, POWERSHELL_TOOL_NAME); + const label = generateShellSuggestionsLabel( + suggestions, + POWERSHELL_TOOL_NAME, + ) if (label) { options.push({ label, - value: 'yes-apply-suggestions' - }); + value: 'yes-apply-suggestions', + }) } } } + if (noInputMode) { options.push({ type: 'input', @@ -78,13 +98,14 @@ export function powershellToolUseOptions({ value: 'no', placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'No', - value: 'no' - }); + value: 'no', + }) } - return options; + + return options } diff --git a/src/components/permissions/SandboxPermissionRequest.tsx b/src/components/permissions/SandboxPermissionRequest.tsx index affbc35fb..9dc4d6629 100644 --- a/src/components/permissions/SandboxPermissionRequest.tsx +++ b/src/components/permissions/SandboxPermissionRequest.tsx @@ -1,162 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; -import { type NetworkHostPattern, shouldAllowManagedSandboxDomainsOnly } from 'src/utils/sandbox/sandbox-adapter.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { Select } from '../CustomSelect/select.js'; -import { PermissionDialog } from './PermissionDialog.js'; +import * as React from 'react' +import { Box, Text } from 'src/ink.js' +import { + type NetworkHostPattern, + shouldAllowManagedSandboxDomainsOnly, +} from 'src/utils/sandbox/sandbox-adapter.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { Select } from '../CustomSelect/select.js' +import { PermissionDialog } from './PermissionDialog.js' + export type SandboxPermissionRequestProps = { - hostPattern: NetworkHostPattern; + hostPattern: NetworkHostPattern onUserResponse: (response: { - allow: boolean; - persistToSettings: boolean; - }) => void; -}; -export function SandboxPermissionRequest(t0) { - const $ = _c(22); - const { - hostPattern: t1, - onUserResponse - } = t0; - const { - host - } = t1; - let t2; - if ($[0] !== onUserResponse) { - t2 = function onSelect(value) { - bb4: switch (value) { - case "yes": - { - onUserResponse({ - allow: true, - persistToSettings: false - }); - break bb4; - } - case "yes-dont-ask-again": - { - onUserResponse({ - allow: true, - persistToSettings: true - }); - break bb4; - } - case "no": - { - onUserResponse({ - allow: false, - persistToSettings: false - }); - } - } - }; - $[0] = onUserResponse; - $[1] = t2; - } else { - t2 = $[1]; - } - const onSelect = t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldAllowManagedSandboxDomainsOnly(); - $[2] = t3; - } else { - t3 = $[2]; - } - const managedDomainsOnly = t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Yes", - value: "yes" - }; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== host) { - t5 = !managedDomainsOnly ? [{ - label: Yes, and don't ask again for {host}, - value: "yes-dont-ask-again" - }] : []; - $[4] = host; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: No, and tell Claude what to do differently (esc), - value: "no" - }; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] !== t5) { - t7 = [t4, ...t5, t6]; - $[7] = t5; - $[8] = t7; - } else { - t7 = $[8]; - } - const options = t7; - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Host:; - $[9] = t8; - } else { - t8 = $[9]; - } - let t9; - if ($[10] !== host) { - t9 = {t8} {host}; - $[10] = host; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Do you want to allow this connection?; - $[12] = t10; - } else { - t10 = $[12]; - } - let t11; - if ($[13] !== onUserResponse) { - t11 = () => { - onUserResponse({ - allow: false, - persistToSettings: false - }); - }; - $[13] = onUserResponse; - $[14] = t11; - } else { - t11 = $[14]; - } - let t12; - if ($[15] !== onSelect || $[16] !== options || $[17] !== t11) { - t12 = { + if (process.env.USER_TYPE === 'ant') { + logEvent('tengu_sandbox_network_dialog_result', { + host: host as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + result: + 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + } + onUserResponse({ allow: false, persistToSettings: false }) + }} + /> + + + + ) } diff --git a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx index 999a63f53..209cd08f4 100644 --- a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx +++ b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx @@ -1,229 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename, relative } from 'path'; -import React, { Suspense, use, useMemo } from 'react'; -import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { isENOENT } from 'src/utils/errors.js'; -import { detectEncodingForResolvedPath } from 'src/utils/fileRead.js'; -import { getFsImplementation } from 'src/utils/fsOperations.js'; -import { Text } from '../../../ink.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import { applySedSubstitution, type SedEditInfo } from '../../../tools/BashTool/sedEditParser.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { basename, relative } from 'path' +import React, { Suspense, use, useMemo } from 'react' +import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js' +import { getCwd } from 'src/utils/cwd.js' +import { isENOENT } from 'src/utils/errors.js' +import { detectEncodingForResolvedPath } from 'src/utils/fileRead.js' +import { getFsImplementation } from 'src/utils/fsOperations.js' +import { Text } from '../../../ink.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { + applySedSubstitution, + type SedEditInfo, +} from '../../../tools/BashTool/sedEditParser.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' + type SedEditPermissionRequestProps = PermissionRequestProps & { - sedInfo: SedEditInfo; -}; -type FileReadResult = { - oldContent: string; - fileExists: boolean; -}; -export function SedEditPermissionRequest(t0) { - const $ = _c(9); - let props; - let sedInfo; - if ($[0] !== t0) { - ({ - sedInfo, - ...props - } = t0); - $[0] = t0; - $[1] = props; - $[2] = sedInfo; - } else { - props = $[1]; - sedInfo = $[2]; - } - const { - filePath - } = sedInfo; - let t1; - if ($[3] !== filePath) { - t1 = (async () => { - const encoding = detectEncodingForResolvedPath(filePath); - const raw = await getFsImplementation().readFile(filePath, { - encoding - }); - return { - oldContent: raw.replaceAll("\r\n", "\n"), - fileExists: true - }; - })().catch(_temp); - $[3] = filePath; - $[4] = t1; - } else { - t1 = $[4]; - } - const contentPromise = t1; - let t2; - if ($[5] !== contentPromise || $[6] !== props || $[7] !== sedInfo) { - t2 = ; - $[5] = contentPromise; - $[6] = props; - $[7] = sedInfo; - $[8] = t2; - } else { - t2 = $[8]; - } - return t2; + sedInfo: SedEditInfo } -function _temp(e) { - if (!isENOENT(e)) { - throw e; - } - return { - oldContent: "", - fileExists: false - }; + +type FileReadResult = { oldContent: string; fileExists: boolean } + +export function SedEditPermissionRequest({ + sedInfo, + ...props +}: SedEditPermissionRequestProps): React.ReactNode { + const { filePath } = sedInfo + + // Read file content async so mount doesn't block React commit on disk I/O. + // Large files would otherwise hang the dialog before it renders. + // Memoized on filePath so we don't re-read on every render. + const contentPromise = useMemo( + () => + (async (): Promise => { + // Detect encoding first (sync 4KB read — negligible) so UTF-16LE BOMs + // render correctly. This matches what readFileSync did before the + // async conversion. + const encoding = detectEncodingForResolvedPath(filePath) + const raw = await getFsImplementation().readFile(filePath, { encoding }) + return { + oldContent: raw.replaceAll('\r\n', '\n'), + fileExists: true, + } + })().catch((e: unknown): FileReadResult => { + if (!isENOENT(e)) throw e + return { oldContent: '', fileExists: false } + }), + [filePath], + ) + + return ( + + + + ) } -function SedEditPermissionRequestInner(t0) { - const $ = _c(35); - let contentPromise; - let props; - let sedInfo; - if ($[0] !== t0) { - ({ - sedInfo, - contentPromise, - ...props - } = t0); - $[0] = t0; - $[1] = contentPromise; - $[2] = props; - $[3] = sedInfo; - } else { - contentPromise = $[1]; - props = $[2]; - sedInfo = $[3]; - } - const { - filePath - } = sedInfo; - const { - oldContent, - fileExists - } = use(contentPromise) as any; - let t1; - if ($[4] !== oldContent || $[5] !== sedInfo) { - t1 = applySedSubstitution(oldContent, sedInfo); - $[4] = oldContent; - $[5] = sedInfo; - $[6] = t1; - } else { - t1 = $[6]; - } - const newContent = t1; - let t2; - bb0: { + +function SedEditPermissionRequestInner({ + sedInfo, + contentPromise, + ...props +}: SedEditPermissionRequestProps & { + contentPromise: Promise +}): React.ReactNode { + const { filePath } = sedInfo + const { oldContent, fileExists } = use(contentPromise) + + // Compute the new content by applying the sed substitution + const newContent = useMemo(() => { + return applySedSubstitution(oldContent, sedInfo) + }, [oldContent, sedInfo]) + + // Create the edit representation for the diff + const edits = useMemo(() => { if (oldContent === newContent) { - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = []; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = t3; - break bb0; + return [] } - let t3; - if ($[8] !== newContent || $[9] !== oldContent) { - t3 = [{ + return [ + { old_string: oldContent, new_string: newContent, - replace_all: false - }]; - $[8] = newContent; - $[9] = oldContent; - $[10] = t3; - } else { - t3 = $[10]; - } - t2 = t3; - } - const edits = t2; - let t3; - bb1: { + replace_all: false, + }, + ] + }, [oldContent, newContent]) + + // Determine appropriate message when no changes + const noChangesMessage = useMemo(() => { if (!fileExists) { - t3 = "File does not exist"; - break bb1; + return 'File does not exist' + } + return 'Pattern did not match any content' + }, [fileExists]) + + // Parse input and add _simulatedSedEdit to ensure what user previewed + // is exactly what gets written (prevents sed/JS regex differences) + const parseInput = (input: unknown) => { + const parsed = BashTool.inputSchema.parse(input) + return { + ...parsed, + _simulatedSedEdit: { + filePath, + newContent, + }, } - t3 = "Pattern did not match any content"; - } - const noChangesMessage = t3; - let t4; - if ($[11] !== filePath || $[12] !== newContent) { - t4 = input => { - const parsed = BashTool.inputSchema.parse(input); - return { - ...parsed, - _simulatedSedEdit: { - filePath, - newContent - } - }; - }; - $[11] = filePath; - $[12] = newContent; - $[13] = t4; - } else { - t4 = $[13]; - } - const parseInput = t4; - const t5 = props.toolUseConfirm; - const t6 = props.toolUseContext; - const t7 = props.onDone; - const t8 = props.onReject; - let t9; - if ($[14] !== filePath) { - t9 = relative(getCwd(), filePath); - $[14] = filePath; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== filePath) { - t10 = basename(filePath); - $[16] = filePath; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== t10) { - t11 = Do you want to make this edit to{" "}{t10}?; - $[18] = t10; - $[19] = t11; - } else { - t11 = $[19]; - } - let t12; - if ($[20] !== edits || $[21] !== filePath || $[22] !== noChangesMessage) { - t12 = edits.length > 0 ? : {noChangesMessage}; - $[20] = edits; - $[21] = filePath; - $[22] = noChangesMessage; - $[23] = t12; - } else { - t12 = $[23]; - } - let t13; - if ($[24] !== filePath || $[25] !== parseInput || $[26] !== props.onDone || $[27] !== props.onReject || $[28] !== props.toolUseConfirm || $[29] !== props.toolUseContext || $[30] !== props.workerBadge || $[31] !== t11 || $[32] !== t12 || $[33] !== t9) { - t13 = ; - $[24] = filePath; - $[25] = parseInput; - $[26] = props.onDone; - $[27] = props.onReject; - $[28] = props.toolUseConfirm; - $[29] = props.toolUseContext; - $[30] = props.workerBadge; - $[31] = t11; - $[32] = t12; - $[33] = t9; - $[34] = t13; - } else { - t13 = $[34]; } - return t13; + + return ( + + Do you want to make this edit to{' '} + {basename(filePath)}? + + } + content={ + edits.length > 0 ? ( + + ) : ( + {noChangesMessage} + ) + } + path={filePath} + completionType="str_replace_single" + parseInput={parseInput} + workerBadge={props.workerBadge} + /> + ) } diff --git a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx index ef66df877..799c88705 100644 --- a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx +++ b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx @@ -1,368 +1,253 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useMemo } from 'react'; -import { logError } from 'src/utils/log.js'; -import { getOriginalCwd } from '../../../bootstrap/state.js'; -import { Box, Text } from '../../../ink.js'; -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; -import { SKILL_TOOL_NAME } from '../../../tools/SkillTool/constants.js'; -import { SkillTool } from '../../../tools/SkillTool/SkillTool.js'; -import { env } from '../../../utils/env.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import { logUnaryEvent } from '../../../utils/unaryLogging.js'; -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import { PermissionPrompt, type PermissionPromptOption, type ToolAnalyticsContext } from '../PermissionPrompt.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -type SkillOptionValue = 'yes' | 'yes-exact' | 'yes-prefix' | 'no'; -export function SkillPermissionRequest(props) { - const $ = _c(51); +import React, { useCallback, useMemo } from 'react' +import { logError } from 'src/utils/log.js' +import { getOriginalCwd } from '../../../bootstrap/state.js' +import { Box, Text } from '../../../ink.js' +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' +import { SKILL_TOOL_NAME } from '../../../tools/SkillTool/constants.js' +import { SkillTool } from '../../../tools/SkillTool/SkillTool.js' +import { env } from '../../../utils/env.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import { logUnaryEvent } from '../../../utils/unaryLogging.js' +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import { + PermissionPrompt, + type PermissionPromptOption, + type ToolAnalyticsContext, +} from '../PermissionPrompt.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' + +type SkillOptionValue = 'yes' | 'yes-exact' | 'yes-prefix' | 'no' + +export function SkillPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { const { toolUseConfirm, onDone, onReject, - workerBadge - } = props; - const parseInput = _temp; - let t0; - if ($[0] !== toolUseConfirm.input) { - t0 = parseInput(toolUseConfirm.input); - $[0] = toolUseConfirm.input; - $[1] = t0; - } else { - t0 = $[1]; - } - const skill = t0; - const commandObj = toolUseConfirm.permissionResult.behavior === "ask" && toolUseConfirm.permissionResult.metadata && "command" in toolUseConfirm.permissionResult.metadata ? toolUseConfirm.permissionResult.metadata.command : undefined; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[2] = t1; - } else { - t1 = $[2]; - } - const unaryEvent = t1; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getOriginalCwd(); - $[3] = t2; - } else { - t2 = $[3]; - } - const originalCwd = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldShowAlwaysAllowOptions(); - $[4] = t3; - } else { - t3 = $[4]; - } - const showAlwaysAllowOptions = t3; - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Yes", - value: "yes", - feedbackConfig: { - type: "accept" - } - }]; - $[5] = t4; - } else { - t4 = $[5]; - } - const baseOptions = t4; - let alwaysAllowOptions; - if ($[6] !== skill) { - alwaysAllowOptions = []; + verbose: _verbose, + workerBadge, + } = props + const parseInput = (input: unknown): string => { + const result = SkillTool.inputSchema.safeParse(input) + if (!result.success) { + logError( + new Error(`Failed to parse skill tool input: ${result.error.message}`), + ) + return '' + } + return result.data.skill + } + + const skill = parseInput(toolUseConfirm.input) + + // Check if this is a command using metadata from checkPermissions + const commandObj = + toolUseConfirm.permissionResult.behavior === 'ask' && + toolUseConfirm.permissionResult.metadata && + 'command' in toolUseConfirm.permissionResult.metadata + ? toolUseConfirm.permissionResult.metadata.command + : undefined + + const unaryEvent = useMemo( + () => ({ + completion_type: 'tool_use_single', + language_name: 'none', + }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const originalCwd = getOriginalCwd() + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): PermissionPromptOption[] => { + const baseOptions: PermissionPromptOption[] = [ + { + label: 'Yes', + value: 'yes', + feedbackConfig: { type: 'accept' }, + }, + ] + + // Only add "always allow" options when not restricted by allowManagedPermissionRulesOnly + const alwaysAllowOptions: PermissionPromptOption[] = [] if (showAlwaysAllowOptions) { - const t5 = {skill}; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {originalCwd}; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== t5) { - t7 = { - label: Yes, and don't ask again for {t5} in{" "}{t6}, - value: "yes-exact" - }; - $[9] = t5; - $[10] = t7; - } else { - t7 = $[10]; - } - alwaysAllowOptions.push(t7); - const spaceIndex = skill.indexOf(" "); + // Add exact match option + alwaysAllowOptions.push({ + label: ( + + Yes, and don't ask again for {skill} in{' '} + {originalCwd} + + ), + value: 'yes-exact', + }) + + // Add prefix option if the skill has arguments + const spaceIndex = skill.indexOf(' ') if (spaceIndex > 0) { - const commandPrefix = skill.substring(0, spaceIndex); - const t8 = commandPrefix + ":*"; - let t9; - if ($[11] !== t8) { - t9 = {t8}; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {originalCwd}; - $[13] = t10; - } else { - t10 = $[13]; - } - let t11; - if ($[14] !== t9) { - t11 = { - label: Yes, and don't ask again for{" "}{t9} commands in{" "}{t10}, - value: "yes-prefix" - }; - $[14] = t9; - $[15] = t11; - } else { - t11 = $[15]; - } - alwaysAllowOptions.push(t11); + const commandPrefix = skill.substring(0, spaceIndex) + alwaysAllowOptions.push({ + label: ( + + Yes, and don't ask again for{' '} + {commandPrefix + ':*'} commands in{' '} + {originalCwd} + + ), + value: 'yes-prefix', + }) } } - $[6] = skill; - $[7] = alwaysAllowOptions; - } else { - alwaysAllowOptions = $[7]; - } - let t5; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "No", - value: "no", - feedbackConfig: { - type: "reject" - } - }; - $[16] = t5; - } else { - t5 = $[16]; - } - const noOption = t5; - let t6; - if ($[17] !== alwaysAllowOptions) { - t6 = [...baseOptions, ...alwaysAllowOptions, noOption]; - $[17] = alwaysAllowOptions; - $[18] = t6; - } else { - t6 = $[18]; - } - const options = t6; - let t7; - if ($[19] !== toolUseConfirm.tool.name) { - t7 = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name); - $[19] = toolUseConfirm.tool.name; - $[20] = t7; - } else { - t7 = $[20]; - } - const t8 = toolUseConfirm.tool.isMcp ?? false; - let t9; - if ($[21] !== t7 || $[22] !== t8) { - t9 = { - toolName: t7, - isMcp: t8 - }; - $[21] = t7; - $[22] = t8; - $[23] = t9; - } else { - t9 = $[23]; - } - const toolAnalyticsContext = t9; - let t10; - if ($[24] !== onDone || $[25] !== onReject || $[26] !== skill || $[27] !== toolUseConfirm) { - t10 = (value, feedback) => { - bb33: switch (value) { - case "yes": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); - onDone(); - break bb33; - } - case "yes-exact": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: SKILL_TOOL_NAME, - ruleContent: skill - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb33; - } - case "yes-prefix": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - const spaceIndex_0 = skill.indexOf(" "); - const commandPrefix_0 = spaceIndex_0 > 0 ? skill.substring(0, spaceIndex_0) : skill; - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: SKILL_TOOL_NAME, - ruleContent: `${commandPrefix_0}:*` - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb33; - } - case "no": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onReject(feedback); - onReject(); - onDone(); - } - } - }; - $[24] = onDone; - $[25] = onReject; - $[26] = skill; - $[27] = toolUseConfirm; - $[28] = t10; - } else { - t10 = $[28]; - } - const handleSelect = t10; - let t11; - if ($[29] !== onDone || $[30] !== onReject || $[31] !== toolUseConfirm) { - t11 = () => { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform + + const noOption: PermissionPromptOption = { + label: 'No', + value: 'no', + feedbackConfig: { type: 'reject' }, + } + + return [...baseOptions, ...alwaysAllowOptions, noOption] + }, [skill, originalCwd, showAlwaysAllowOptions]) + + const toolAnalyticsContext = useMemo( + (): ToolAnalyticsContext => ({ + toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name), + isMcp: toolUseConfirm.tool.isMcp ?? false, + }), + [toolUseConfirm.tool.name, toolUseConfirm.tool.isMcp], + ) + + const handleSelect = useCallback( + (value: SkillOptionValue, feedback?: string) => { + switch (value) { + case 'yes': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) + onDone() + break + case 'yes-exact': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: SKILL_TOOL_NAME, + ruleContent: skill, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }); - toolUseConfirm.onReject(); - onReject(); - onDone(); - }; - $[29] = onDone; - $[30] = onReject; - $[31] = toolUseConfirm; - $[32] = t11; - } else { - t11 = $[32]; - } - const handleCancel = t11; - const t12 = `Use skill "${skill}"?`; - let t13; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Claude may use instructions, code, or files from this Skill.; - $[33] = t13; - } else { - t13 = $[33]; - } - const t14 = commandObj?.description; - let t15; - if ($[34] !== t14) { - t15 = {t14}; - $[34] = t14; - $[35] = t15; - } else { - t15 = $[35]; - } - let t16; - if ($[36] !== toolUseConfirm.permissionResult) { - t16 = ; - $[36] = toolUseConfirm.permissionResult; - $[37] = t16; - } else { - t16 = $[37]; - } - let t17; - if ($[38] !== handleCancel || $[39] !== handleSelect || $[40] !== options || $[41] !== toolAnalyticsContext) { - t17 = ; - $[38] = handleCancel; - $[39] = handleSelect; - $[40] = options; - $[41] = toolAnalyticsContext; - $[42] = t17; - } else { - t17 = $[42]; - } - let t18; - if ($[43] !== t16 || $[44] !== t17) { - t18 = {t16}{t17}; - $[43] = t16; - $[44] = t17; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] !== t12 || $[47] !== t15 || $[48] !== t18 || $[49] !== workerBadge) { - t19 = {t13}{t15}{t18}; - $[46] = t12; - $[47] = t15; - $[48] = t18; - $[49] = workerBadge; - $[50] = t19; - } else { - t19 = $[50]; - } - return t19; -} -function _temp(input) { - const result = SkillTool.inputSchema.safeParse(input); - if (!result.success) { - logError(new Error(`Failed to parse skill tool input: ${result.error.message}`)); - return ""; - } - return result.data.skill; + case 'yes-prefix': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + + // Extract the skill prefix (everything before the first space) + const spaceIndex = skill.indexOf(' ') + const commandPrefix = + spaceIndex > 0 ? skill.substring(0, spaceIndex) : skill + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: SKILL_TOOL_NAME, + ruleContent: `${commandPrefix}:*`, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break + } + case 'no': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject(feedback) + onReject() + onDone() + break + } + }, + [toolUseConfirm, onDone, onReject, skill], + ) + + const handleCancel = useCallback(() => { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onReject() + onDone() + }, [toolUseConfirm, onDone, onReject]) + + return ( + + Claude may use instructions, code, or files from this Skill. + + {commandObj?.description} + + + + + + + + ) } diff --git a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx index 2a93fd5c9..da2498885 100644 --- a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx +++ b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx @@ -1,257 +1,148 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import { type OptionWithDescription, Select } from '../../CustomSelect/select.js'; -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -import { logUnaryPermissionEvent } from '../utils.js'; -function inputToPermissionRuleContent(input: { - [k: string]: unknown; -}): string { +import React, { useMemo } from 'react' +import { Box, Text, useTheme } from '../../../ink.js' +import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import { + type OptionWithDescription, + Select, +} from '../../CustomSelect/select.js' +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { logUnaryPermissionEvent } from '../utils.js' + +function inputToPermissionRuleContent(input: { [k: string]: unknown }): string { try { - const parsedInput = WebFetchTool.inputSchema.safeParse(input); + const parsedInput = WebFetchTool.inputSchema.safeParse(input) if (!parsedInput.success) { - return `input:${input.toString()}`; + return `input:${input.toString()}` } - const { - url - } = parsedInput.data; - const hostname = new URL(url).hostname; - return `domain:${hostname}`; + const { url } = parsedInput.data + const hostname = new URL(url).hostname + return `domain:${hostname}` } catch { - return `input:${input.toString()}`; + return `input:${input.toString()}` } } -export function WebFetchPermissionRequest(t0) { - const $ = _c(41); - const { - toolUseConfirm, - onDone, - onReject, - verbose, - workerBadge - } = t0; - const [theme] = useTheme(); - const { - url - } = toolUseConfirm.input as { - url: string; - }; - let t1; - if ($[0] !== url) { - t1 = new URL(url); - $[0] = url; - $[1] = t1; - } else { - t1 = $[1]; - } - const hostname = t1.hostname; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - const unaryEvent = t2; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldShowAlwaysAllowOptions(); - $[3] = t3; - } else { - t3 = $[3]; - } - const showAlwaysAllowOptions = t3; - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Yes", - value: "yes" - }; - $[4] = t4; - } else { - t4 = $[4]; - } - let result; - if ($[5] !== hostname) { - result = [t4]; + +export function WebFetchPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + verbose, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [theme] = useTheme() + // url is already validated by the input schema + const { url } = toolUseConfirm.input as { url: string } + + // Extract hostname from URL + const hostname = new URL(url).hostname + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + // Generate permission options specific to domains + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): OptionWithDescription[] => { + const result: OptionWithDescription[] = [ + { + label: 'Yes', + value: 'yes', + }, + ] + if (showAlwaysAllowOptions) { - const t5 = {hostname}; - let t6; - if ($[7] !== t5) { - t6 = { - label: Yes, and don't ask again for {t5}, - value: "yes-dont-ask-again-domain" - }; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - result.push(t6); + result.push({ + label: ( + + Yes, and don't ask again for {hostname} + + ), + value: 'yes-dont-ask-again-domain', + }) } - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: No, and tell Claude what to do differently (esc), - value: "no" - }; - $[9] = t5; - } else { - t5 = $[9]; - } - result.push(t5); - $[5] = hostname; - $[6] = result; - } else { - result = $[6]; - } - const options = result; - let t5; - if ($[10] !== onDone || $[11] !== onReject || $[12] !== toolUseConfirm) { - t5 = function onChange(newValue) { - bb8: switch (newValue) { - case "yes": - { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "accept"); - toolUseConfirm.onAllow(toolUseConfirm.input, []); - onDone(); - break bb8; - } - case "yes-dont-ask-again-domain": + + result.push({ + label: ( + + No, and tell Claude what to do differently (esc) + + ), + value: 'no', + }) + + return result + }, [hostname, showAlwaysAllowOptions]) + + function onChange(newValue: string) { + switch (newValue) { + case 'yes': + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + toolUseConfirm.onAllow(toolUseConfirm.input, []) + onDone() + break + case 'yes-dont-ask-again-domain': { + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + const ruleContent = inputToPermissionRuleContent(toolUseConfirm.input) + const ruleValue = { + toolName: toolUseConfirm.tool.name, + ruleContent, + } + + // Pass permission update directly to onAllow + toolUseConfirm.onAllow(toolUseConfirm.input, [ { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "accept"); - const ruleContent = inputToPermissionRuleContent(toolUseConfirm.input); - const ruleValue = { - toolName: toolUseConfirm.tool.name, - ruleContent - }; - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [ruleValue], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb8; - } - case "no": - { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "reject"); - toolUseConfirm.onReject(); - onReject(); - onDone(); - } + type: 'addRules', + rules: [ruleValue], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }; - $[10] = onDone; - $[11] = onReject; - $[12] = toolUseConfirm; - $[13] = t5; - } else { - t5 = $[13]; - } - const onChange = t5; - let t6; - if ($[14] !== theme || $[15] !== toolUseConfirm.input || $[16] !== verbose) { - t6 = WebFetchTool.renderToolUseMessage(toolUseConfirm.input as { - url: string; - prompt: string; - }, { - theme, - verbose - }); - $[14] = theme; - $[15] = toolUseConfirm.input; - $[16] = verbose; - $[17] = t6; - } else { - t6 = $[17]; - } - let t7; - if ($[18] !== t6) { - t7 = {t6}; - $[18] = t6; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] !== toolUseConfirm.description) { - t8 = {toolUseConfirm.description}; - $[20] = toolUseConfirm.description; - $[21] = t8; - } else { - t8 = $[21]; - } - let t9; - if ($[22] !== t7 || $[23] !== t8) { - t9 = {t7}{t8}; - $[22] = t7; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - let t10; - if ($[25] !== toolUseConfirm.permissionResult) { - t10 = ; - $[25] = toolUseConfirm.permissionResult; - $[26] = t10; - } else { - t10 = $[26]; - } - let t11; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Do you want to allow Claude to fetch this content?; - $[27] = t11; - } else { - t11 = $[27]; - } - let t12; - if ($[28] !== onChange) { - t12 = () => onChange("no"); - $[28] = onChange; - $[29] = t12; - } else { - t12 = $[29]; - } - let t13; - if ($[30] !== onChange || $[31] !== options || $[32] !== t12) { - t13 = onChange('no')} + /> + + + ) } diff --git a/src/components/permissions/WorkerBadge.tsx b/src/components/permissions/WorkerBadge.tsx index bc6bb357f..61d5873ab 100644 --- a/src/components/permissions/WorkerBadge.tsx +++ b/src/components/permissions/WorkerBadge.tsx @@ -1,48 +1,27 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; -import { toInkColor } from '../../utils/ink.js'; +import * as React from 'react' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' +import { toInkColor } from '../../utils/ink.js' + export type WorkerBadgeProps = { - name: string; - color: string; -}; + name: string + color: string +} /** * Renders a colored badge showing the worker's name for permission prompts. * Used to indicate which swarm worker is requesting the permission. */ -export function WorkerBadge(t0) { - const $ = _c(7); - const { - name, - color - } = t0; - let t1; - if ($[0] !== color) { - t1 = toInkColor(color); - $[0] = color; - $[1] = t1; - } else { - t1 = $[1]; - } - const inkColor = t1; - let t2; - if ($[2] !== name) { - t2 = @{name}; - $[2] = name; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== inkColor || $[5] !== t2) { - t3 = {BLACK_CIRCLE} {t2}; - $[4] = inkColor; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; +export function WorkerBadge({ + name, + color, +}: WorkerBadgeProps): React.ReactNode { + const inkColor = toInkColor(color) + return ( + + + {BLACK_CIRCLE} @{name} + + + ) } diff --git a/src/components/permissions/WorkerPendingPermission.tsx b/src/components/permissions/WorkerPendingPermission.tsx index 7caad36c2..06aab0334 100644 --- a/src/components/permissions/WorkerPendingPermission.tsx +++ b/src/components/permissions/WorkerPendingPermission.tsx @@ -1,104 +1,70 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getAgentName, getTeammateColor, getTeamName } from '../../utils/teammate.js'; -import { Spinner } from '../Spinner.js'; -import { WorkerBadge } from './WorkerBadge.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + getAgentName, + getTeammateColor, + getTeamName, +} from '../../utils/teammate.js' +import { Spinner } from '../Spinner.js' +import { WorkerBadge } from './WorkerBadge.js' + type Props = { - toolName: string; - description: string; -}; + toolName: string + description: string +} /** * Visual indicator shown on workers while waiting for leader to approve a permission request. * Displays the pending tool with a spinner and information about what's being requested. */ -export function WorkerPendingPermission(t0) { - const $ = _c(15); - const { - toolName, - description - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTeamName(); - $[0] = t1; - } else { - t1 = $[0]; - } - const teamName = t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getAgentName(); - $[1] = t2; - } else { - t2 = $[1]; - } - const agentName = t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = getTeammateColor(); - $[2] = t3; - } else { - t3 = $[2]; - } - const agentColor = t3; - let t4; - let t5; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {" "}Waiting for team lead approval; - t5 = agentName && agentColor && ; - $[3] = t4; - $[4] = t5; - } else { - t4 = $[3]; - t5 = $[4]; - } - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Tool: ; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== toolName) { - t7 = {t6}{toolName}; - $[6] = toolName; - $[7] = t7; - } else { - t7 = $[7]; - } - let t8; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Action: ; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] !== description) { - t9 = {t8}{description}; - $[9] = description; - $[10] = t9; - } else { - t9 = $[10]; - } - let t10; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t10 = teamName && Permission request sent to team {"\""}{teamName}{"\""} leader; - $[11] = t10; - } else { - t10 = $[11]; - } - let t11; - if ($[12] !== t7 || $[13] !== t9) { - t11 = {t4}{t5}{t7}{t9}{t10}; - $[12] = t7; - $[13] = t9; - $[14] = t11; - } else { - t11 = $[14]; - } - return t11; +export function WorkerPendingPermission({ + toolName, + description, +}: Props): React.ReactNode { + const teamName = getTeamName() + const agentName = getAgentName() + const agentColor = getTeammateColor() + + return ( + + + + + {' '} + Waiting for team lead approval + + + + {agentName && agentColor && ( + + + + )} + + + Tool: + {toolName} + + + + Action: + {description} + + + {teamName && ( + + + Permission request sent to team {'"'} + {teamName} + {'"'} leader + + + )} + + ) } diff --git a/src/components/permissions/rules/AddPermissionRules.tsx b/src/components/permissions/rules/AddPermissionRules.tsx index 5de1bf288..6e48e1dcb 100644 --- a/src/components/permissions/rules/AddPermissionRules.tsx +++ b/src/components/permissions/rules/AddPermissionRules.tsx @@ -1,179 +1,165 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback } from 'react'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import type { PermissionBehavior, PermissionRule, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { applyPermissionUpdate, persistPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js'; -import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; -import { detectUnreachableRules, type UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js'; -import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js'; -import { type EditableSettingSource, SOURCES } from '../../../utils/settings/constants.js'; -import { getRelativeSettingsFilePathForSource } from '../../../utils/settings/settings.js'; -import { plural } from '../../../utils/stringUtils.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { Dialog } from '../../design-system/Dialog.js'; -import { PermissionRuleDescription } from './PermissionRuleDescription.js'; -export function optionForPermissionSaveDestination(saveDestination: EditableSettingSource): OptionWithDescription { +import * as React from 'react' +import { useCallback } from 'react' +import { Select } from '../../../components/CustomSelect/select.js' +import { Box, Text } from '../../../ink.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import type { + PermissionBehavior, + PermissionRule, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { + applyPermissionUpdate, + persistPermissionUpdate, +} from '../../../utils/permissions/PermissionUpdate.js' +import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js' +import { + detectUnreachableRules, + type UnreachableRule, +} from '../../../utils/permissions/shadowedRuleDetection.js' +import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js' +import { + type EditableSettingSource, + SOURCES, +} from '../../../utils/settings/constants.js' +import { getRelativeSettingsFilePathForSource } from '../../../utils/settings/settings.js' +import { plural } from '../../../utils/stringUtils.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { Dialog } from '../../design-system/Dialog.js' +import { PermissionRuleDescription } from './PermissionRuleDescription.js' + +export function optionForPermissionSaveDestination( + saveDestination: EditableSettingSource, +): OptionWithDescription { switch (saveDestination) { case 'localSettings': return { label: 'Project settings (local)', description: `Saved in ${getRelativeSettingsFilePathForSource('localSettings')}`, - value: saveDestination - }; + value: saveDestination, + } case 'projectSettings': return { label: 'Project settings', description: `Checked in at ${getRelativeSettingsFilePathForSource('projectSettings')}`, - value: saveDestination - }; + value: saveDestination, + } case 'userSettings': return { label: 'User settings', description: `Saved in at ~/.claude/settings.json`, - value: saveDestination - }; + value: saveDestination, + } } } + type Props = { - onAddRules: (rules: PermissionRule[], unreachable?: UnreachableRule[]) => void; - onCancel: () => void; - ruleValues: PermissionRuleValue[]; - ruleBehavior: PermissionBehavior; - initialContext: ToolPermissionContext; - setToolPermissionContext: (newContext: ToolPermissionContext) => void; -}; -export function AddPermissionRules(t0) { - const $ = _c(26); - const { - onAddRules, - onCancel, - ruleValues, - ruleBehavior, - initialContext, - setToolPermissionContext - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = SOURCES.map(optionForPermissionSaveDestination); - $[0] = t1; - } else { - t1 = $[0]; - } - const allOptions = t1; - let t2; - if ($[1] !== initialContext || $[2] !== onAddRules || $[3] !== onCancel || $[4] !== ruleBehavior || $[5] !== ruleValues || $[6] !== setToolPermissionContext) { - t2 = selectedValue => { - if (selectedValue === "cancel") { - onCancel(); - return; - } else { - if ((SOURCES as readonly string[]).includes(selectedValue)) { - const destination = selectedValue as EditableSettingSource; - const updatedContext = applyPermissionUpdate(initialContext, { - type: "addRules", - rules: ruleValues, - behavior: ruleBehavior, - destination - }); - persistPermissionUpdate({ - type: "addRules", - rules: ruleValues, - behavior: ruleBehavior, - destination - }); - setToolPermissionContext(updatedContext); - const rules = ruleValues.map(ruleValue => ({ - ruleValue, - ruleBehavior, - source: destination - })); - const sandboxAutoAllowEnabled = SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const allUnreachable = detectUnreachableRules(updatedContext, { - sandboxAutoAllowEnabled - }); - const newUnreachable = allUnreachable.filter(u => ruleValues.some(rv => rv.toolName === u.rule.ruleValue.toolName && rv.ruleContent === u.rule.ruleValue.ruleContent)); - onAddRules(rules, newUnreachable.length > 0 ? newUnreachable : undefined); - } - } - }; - $[1] = initialContext; - $[2] = onAddRules; - $[3] = onCancel; - $[4] = ruleBehavior; - $[5] = ruleValues; - $[6] = setToolPermissionContext; - $[7] = t2; - } else { - t2 = $[7]; - } - const onSelect = t2; - let t3; - if ($[8] !== ruleValues.length) { - t3 = plural(ruleValues.length, "rule"); - $[8] = ruleValues.length; - $[9] = t3; - } else { - t3 = $[9]; - } - const title = `Add ${ruleBehavior} permission ${t3}`; - let t4; - if ($[10] !== ruleValues) { - t4 = ruleValues.map(_temp); - $[10] = ruleValues; - $[11] = t4; - } else { - t4 = $[11]; - } - let t5; - if ($[12] !== t4) { - t5 = {t4}; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - const t6 = ruleValues.length === 1 ? "Where should this rule be saved?" : "Where should these rules be saved?"; - let t7; - if ($[14] !== t6) { - t7 = {t6}; - $[14] = t6; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== onSelect) { - t8 = + + + ) } diff --git a/src/components/permissions/rules/AddWorkspaceDirectory.tsx b/src/components/permissions/rules/AddWorkspaceDirectory.tsx index 3ff73d080..07d0a00ef 100644 --- a/src/components/permissions/rules/AddWorkspaceDirectory.tsx +++ b/src/components/permissions/rules/AddWorkspaceDirectory.tsx @@ -1,339 +1,292 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDebounceCallback } from 'usehooks-ts'; -import { addDirHelpMessage, validateDirectoryForWorkspace } from '../../../commands/add-dir/validation.js'; -import TextInput from '../../../components/TextInput.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { getDirectoryCompletions } from '../../../utils/suggestions/directoryCompletion.js'; -import { ConfigurableShortcutHint } from '../../ConfigurableShortcutHint.js'; -import { Select } from '../../CustomSelect/select.js'; -import { Byline } from '../../design-system/Byline.js'; -import { Dialog } from '../../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../../design-system/KeyboardShortcutHint.js'; -import { PromptInputFooterSuggestions, type SuggestionItem } from '../../PromptInput/PromptInputFooterSuggestions.js'; +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useDebounceCallback } from 'usehooks-ts' +import { + addDirHelpMessage, + validateDirectoryForWorkspace, +} from '../../../commands/add-dir/validation.js' +import TextInput from '../../../components/TextInput.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { getDirectoryCompletions } from '../../../utils/suggestions/directoryCompletion.js' +import { ConfigurableShortcutHint } from '../../ConfigurableShortcutHint.js' +import { Select } from '../../CustomSelect/select.js' +import { Byline } from '../../design-system/Byline.js' +import { Dialog } from '../../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../../design-system/KeyboardShortcutHint.js' +import { + PromptInputFooterSuggestions, + type SuggestionItem, +} from '../../PromptInput/PromptInputFooterSuggestions.js' + type Props = { - onAddDirectory: (path: string, remember?: boolean) => void; - onCancel: () => void; - permissionContext: ToolPermissionContext; - directoryPath?: string; // When directoryPath is provided, show selection options instead of input -}; -type RememberDirectoryOption = 'yes-session' | 'yes-remember' | 'no'; + onAddDirectory: (path: string, remember?: boolean) => void + onCancel: () => void + permissionContext: ToolPermissionContext + directoryPath?: string // When directoryPath is provided, show selection options instead of input +} + +type RememberDirectoryOption = 'yes-session' | 'yes-remember' | 'no' + const REMEMBER_DIRECTORY_OPTIONS: Array<{ - value: RememberDirectoryOption; - label: string; -}> = [{ - value: 'yes-session', - label: 'Yes, for this session' -}, { - value: 'yes-remember', - label: 'Yes, and remember this directory' -}, { - value: 'no', - label: 'No' -}]; -function PermissionDescription() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Claude Code will be able to read files in this directory and make edits when auto-accept edits is on.; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; + value: RememberDirectoryOption + label: string +}> = [ + { + value: 'yes-session', + label: 'Yes, for this session', + }, + { + value: 'yes-remember', + label: 'Yes, and remember this directory', + }, + { + value: 'no', + label: 'No', + }, +] + +function PermissionDescription(): React.ReactNode { + return ( + + Claude Code will be able to read files in this directory and make edits + when auto-accept edits is on. + + ) } -function DirectoryDisplay(t0) { - const $ = _c(5); - const { - path - } = t0; - let t1; - if ($[0] !== path) { - t1 = {path}; - $[0] = path; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t1) { - t3 = {t1}{t2}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + +function DirectoryDisplay({ path }: { path: string }): React.ReactNode { + return ( + + {path} + + + ) } -function DirectoryInput(t0) { - const $ = _c(14); - const { - value, - onChange, - onSubmit, - error, - suggestions, - selectedSuggestion - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Enter the path to the directory:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== onChange || $[2] !== onSubmit || $[3] !== value) { - t2 = ; - $[1] = onChange; - $[2] = onSubmit; - $[3] = value; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== selectedSuggestion || $[6] !== suggestions) { - t3 = suggestions.length > 0 && ; - $[5] = selectedSuggestion; - $[6] = suggestions; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== error) { - t4 = error && {error}; - $[8] = error; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== t2 || $[11] !== t3 || $[12] !== t4) { - t5 = {t1}{t2}{t3}{t4}; - $[10] = t2; - $[11] = t3; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - return t5; + +function DirectoryInput({ + value, + onChange, + onSubmit, + error, + suggestions, + selectedSuggestion, +}: { + value: string + onChange: (value: string) => void + onSubmit: (value: string) => void + error: string | null + suggestions: SuggestionItem[] + selectedSuggestion: number +}): React.ReactNode { + return ( + + Enter the path to the directory: + + {}} + /> + + {suggestions.length > 0 && ( + + + + )} + {error && {error}} + + ) } -function _temp() {} -export function AddWorkspaceDirectory(t0) { - const $ = _c(34); - const { - onAddDirectory, - onCancel, - permissionContext, - directoryPath - } = t0; - const [directoryInput, setDirectoryInput] = useState(""); - const [error, setError] = useState(null); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [suggestions, setSuggestions] = useState(t1); - const [selectedSuggestion, setSelectedSuggestion] = useState(0); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = async path => { - if (!path) { - setSuggestions([]); - setSelectedSuggestion(0); - return; - } - const completions = await getDirectoryCompletions(path); - setSuggestions(completions); - setSelectedSuggestion(0); - }; - $[1] = t2; - } else { - t2 = $[1]; - } - const fetchSuggestions = t2; - const debouncedFetchSuggestions = useDebounceCallback(fetchSuggestions, 100); - let t3; - let t4; - if ($[2] !== debouncedFetchSuggestions || $[3] !== directoryInput) { - t3 = () => { - debouncedFetchSuggestions(directoryInput); - }; - t4 = [directoryInput, debouncedFetchSuggestions]; - $[2] = debouncedFetchSuggestions; - $[3] = directoryInput; - $[4] = t3; - $[5] = t4; - } else { - t3 = $[4]; - t4 = $[5]; - } - useEffect(t3, t4); - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = suggestion => { - const newPath = suggestion.id + "/"; - setDirectoryInput(newPath); - setError(null); - }; - $[6] = t5; - } else { - t5 = $[6]; - } - const applySuggestion = t5; - let t6; - if ($[7] !== onAddDirectory || $[8] !== permissionContext) { - t6 = async newPath_0 => { - const result = await validateDirectoryForWorkspace(newPath_0, permissionContext); - if (result.resultType === "success") { - onAddDirectory(result.absolutePath, false); + +export function AddWorkspaceDirectory({ + onAddDirectory, + onCancel, + permissionContext, + directoryPath, +}: Props): React.ReactNode { + const [directoryInput, setDirectoryInput] = useState('') + const [error, setError] = useState(null) + const [suggestions, setSuggestions] = useState([]) + const [selectedSuggestion, setSelectedSuggestion] = useState(0) + const options = useMemo(() => REMEMBER_DIRECTORY_OPTIONS, []) + + // Fetch directory completions + const fetchSuggestions = useCallback(async (path: string) => { + if (!path) { + setSuggestions([]) + setSelectedSuggestion(0) + return + } + const completions = await getDirectoryCompletions(path) + setSuggestions(completions) + setSelectedSuggestion(0) + }, []) + + const debouncedFetchSuggestions = useDebounceCallback(fetchSuggestions, 100) + + useEffect(() => { + void debouncedFetchSuggestions(directoryInput) + }, [directoryInput, debouncedFetchSuggestions]) + + const applySuggestion = useCallback((suggestion: SuggestionItem) => { + const newPath = suggestion.id + '/' + setDirectoryInput(newPath) + setError(null) + // Suggestions will update via the useEffect + }, []) + + // Handle directory submission from input + const handleSubmit = useCallback( + async (newPath: string) => { + const result = await validateDirectoryForWorkspace( + newPath, + permissionContext, + ) + + if (result.resultType === 'success') { + onAddDirectory(result.absolutePath, false) } else { - setError(addDirHelpMessage(result)); + setError(addDirHelpMessage(result)) } - }; - $[7] = onAddDirectory; - $[8] = permissionContext; - $[9] = t6; - } else { - t6 = $[9]; - } - const handleSubmit = t6; - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - context: "Settings" - }; - $[10] = t7; - } else { - t7 = $[10]; - } - useKeybinding("confirm:no", onCancel, t7); - let t8; - if ($[11] !== handleSubmit || $[12] !== selectedSuggestion || $[13] !== suggestions) { - t8 = e => { + }, + [permissionContext, onAddDirectory], + ) + + // Handle Esc to cancel (Ctrl+C handled by global keybindings) + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { if (suggestions.length > 0) { - if (e.key === "tab") { - e.preventDefault(); - const suggestion_0 = suggestions[selectedSuggestion]; - if (suggestion_0) { - applySuggestion(suggestion_0); + // Tab: accept selected suggestion and continue (for drilling into subdirs) + if (e.key === 'tab') { + e.preventDefault() + const suggestion = suggestions[selectedSuggestion] + if (suggestion) { + applySuggestion(suggestion) } - return; + return } - if (e.key === "return") { - e.preventDefault(); - const suggestion_1 = suggestions[selectedSuggestion]; - if (suggestion_1) { - handleSubmit(suggestion_1.id + "/"); + + // Enter: apply selected suggestion and submit + if (e.key === 'return') { + e.preventDefault() + const suggestion = suggestions[selectedSuggestion] + if (suggestion) { + void handleSubmit(suggestion.id + '/') } - return; + return } - if (e.key === "up" || e.ctrl && e.key === "p") { - e.preventDefault(); - setSelectedSuggestion(prev => prev <= 0 ? suggestions.length - 1 : prev - 1); - return; + + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + setSelectedSuggestion(prev => + prev <= 0 ? suggestions.length - 1 : prev - 1, + ) + return } - if (e.key === "down" || e.ctrl && e.key === "n") { - e.preventDefault(); - setSelectedSuggestion(prev_0 => prev_0 >= suggestions.length - 1 ? 0 : prev_0 + 1); - return; + + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + setSelectedSuggestion(prev => + prev >= suggestions.length - 1 ? 0 : prev + 1, + ) + return } } - }; - $[11] = handleSubmit; - $[12] = selectedSuggestion; - $[13] = suggestions; - $[14] = t8; - } else { - t8 = $[14]; - } - const handleKeyDown = t8; - let t9; - if ($[15] !== directoryPath || $[16] !== onAddDirectory || $[17] !== onCancel) { - t9 = value => { - if (!directoryPath) { - return; - } - const selectionValue = value as RememberDirectoryOption; - bb64: switch (selectionValue) { - case "yes-session": - { - onAddDirectory(directoryPath, false); - break bb64; - } - case "yes-remember": - { - onAddDirectory(directoryPath, true); - break bb64; - } - case "no": - { - onCancel(); - } + }, + [suggestions, selectedSuggestion, applySuggestion, handleSubmit], + ) + + const handleSelect = useCallback( + (value: string) => { + if (!directoryPath) return + + const selectionValue = value as RememberDirectoryOption + + switch (selectionValue) { + case 'yes-session': + onAddDirectory(directoryPath, false) + break + case 'yes-remember': + onAddDirectory(directoryPath, true) + break + case 'no': + onCancel() + break } - }; - $[15] = directoryPath; - $[16] = onAddDirectory; - $[17] = onCancel; - $[18] = t9; - } else { - t9 = $[18]; - } - const handleSelect = t9; - const t10 = directoryPath ? undefined : _temp2; - let t11; - if ($[19] !== directoryInput || $[20] !== directoryPath || $[21] !== error || $[22] !== handleSelect || $[23] !== handleSubmit || $[24] !== selectedSuggestion || $[25] !== suggestions) { - t11 = directoryPath ? handleSelect('no')} + /> + + ) : ( + + + + + )} + + + ) } diff --git a/src/components/permissions/rules/PermissionRuleDescription.tsx b/src/components/permissions/rules/PermissionRuleDescription.tsx index 57cb34279..ac8f0cd23 100644 --- a/src/components/permissions/rules/PermissionRuleDescription.tsx +++ b/src/components/permissions/rules/PermissionRuleDescription.tsx @@ -1,75 +1,46 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../../../ink.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import type { PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; +import * as React from 'react' +import { Text } from '../../../ink.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import type { PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js' + type RuleSubtitleProps = { - ruleValue: PermissionRuleValue; -}; -export function PermissionRuleDescription(t0) { - const $ = _c(9); - const { - ruleValue - } = t0; + ruleValue: PermissionRuleValue +} + +export function PermissionRuleDescription({ + ruleValue, +}: RuleSubtitleProps): React.ReactNode { switch (ruleValue.toolName) { - case BashTool.name: - { - if (ruleValue.ruleContent) { - if (ruleValue.ruleContent.endsWith(":*")) { - let t1; - if ($[0] !== ruleValue.ruleContent) { - t1 = ruleValue.ruleContent.slice(0, -2); - $[0] = ruleValue.ruleContent; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = Any Bash command starting with{" "}{t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; - } else { - let t1; - if ($[4] !== ruleValue.ruleContent) { - t1 = The Bash command {ruleValue.ruleContent}; - $[4] = ruleValue.ruleContent; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } + case BashTool.name: { + if (ruleValue.ruleContent) { + if (ruleValue.ruleContent.endsWith(':*')) { + return ( + + Any Bash command starting with{' '} + {ruleValue.ruleContent.slice(0, -2)} + + ) } else { - let t1; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Any Bash command; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + return ( + + The Bash command {ruleValue.ruleContent} + + ) } + } else { + return Any Bash command } - default: - { - if (!ruleValue.ruleContent) { - let t1; - if ($[7] !== ruleValue.toolName) { - t1 = Any use of the {ruleValue.toolName} tool; - $[7] = ruleValue.toolName; - $[8] = t1; - } else { - t1 = $[8]; - } - return t1; - } else { - return null; - } + } + default: { + if (!ruleValue.ruleContent) { + return ( + + Any use of the {ruleValue.toolName} tool + + ) + } else { + return null } + } } } diff --git a/src/components/permissions/rules/PermissionRuleInput.tsx b/src/components/permissions/rules/PermissionRuleInput.tsx index dbfca58c2..36dfb6b63 100644 --- a/src/components/permissions/rules/PermissionRuleInput.tsx +++ b/src/components/permissions/rules/PermissionRuleInput.tsx @@ -1,137 +1,107 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useState } from 'react'; -import TextInput from '../../../components/TextInput.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { Box, Newline, Text } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js'; -import type { PermissionBehavior, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { permissionRuleValueFromString, permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; +import figures from 'figures' +import * as React from 'react' +import { useState } from 'react' +import TextInput from '../../../components/TextInput.js' +import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { Box, Newline, Text } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js' +import type { + PermissionBehavior, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { + permissionRuleValueFromString, + permissionRuleValueToString, +} from '../../../utils/permissions/permissionRuleParser.js' + export type PermissionRuleInputProps = { - onCancel: () => void; - onSubmit: (ruleValue: PermissionRuleValue, ruleBehavior: PermissionBehavior) => void; - ruleBehavior: PermissionBehavior; -}; -export function PermissionRuleInput(t0) { - const $ = _c(24); - const { - onCancel, - onSubmit, - ruleBehavior - } = t0; - const [inputValue, setInputValue] = useState(""); - const [cursorOffset, setCursorOffset] = useState(0); - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Settings" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - const { - columns - } = useTerminalSize(); - const textInputColumns = columns - 6; - let t2; - if ($[1] !== onSubmit || $[2] !== ruleBehavior) { - t2 = value => { - const trimmedValue = value.trim(); - if (trimmedValue.length === 0) { - return; - } - const ruleValue = permissionRuleValueFromString(trimmedValue); - onSubmit(ruleValue, ruleBehavior); - }; - $[1] = onSubmit; - $[2] = ruleBehavior; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleSubmit = t2; - let t3; - if ($[4] !== ruleBehavior) { - t3 = Add {ruleBehavior} permission rule; - $[4] = ruleBehavior; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {permissionRuleValueToString({ - toolName: WebFetchTool.name - })}; - t6 = or ; - $[7] = t5; - $[8] = t6; - } else { - t5 = $[7]; - t6 = $[8]; - } - let t7; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Permission rules are a tool name, optionally followed by a specifier in parentheses.{t4}e.g.,{" "}{t5}{t6}{permissionRuleValueToString({ - toolName: BashTool.name, - ruleContent: "ls:*" - })}; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] !== cursorOffset || $[11] !== handleSubmit || $[12] !== inputValue || $[13] !== textInputColumns) { - t8 = {t7}; - $[10] = cursorOffset; - $[11] = handleSubmit; - $[12] = inputValue; - $[13] = textInputColumns; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== t3 || $[16] !== t8) { - t9 = {t3}{t8}; - $[15] = t3; - $[16] = t8; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] !== exitState.keyName || $[19] !== exitState.pending) { - t10 = {exitState.pending ? Press {exitState.keyName} again to exit : Enter to submit · Esc to cancel}; - $[18] = exitState.keyName; - $[19] = exitState.pending; - $[20] = t10; - } else { - t10 = $[20]; - } - let t11; - if ($[21] !== t10 || $[22] !== t9) { - t11 = <>{t9}{t10}; - $[21] = t10; - $[22] = t9; - $[23] = t11; - } else { - t11 = $[23]; + onCancel: () => void + onSubmit: ( + ruleValue: PermissionRuleValue, + ruleBehavior: PermissionBehavior, + ) => void + ruleBehavior: PermissionBehavior +} + +export function PermissionRuleInput({ + onCancel, + onSubmit, + ruleBehavior, +}: PermissionRuleInputProps): React.ReactNode { + const [inputValue, setInputValue] = useState('') + const [cursorOffset, setCursorOffset] = useState(0) + const exitState = useExitOnCtrlCDWithKeybindings() + + // Use configurable keybinding for ESC to cancel + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + const { columns } = useTerminalSize() + const textInputColumns = columns - 6 + + const handleSubmit = (value: string) => { + const trimmedValue = value.trim() + if (trimmedValue.length === 0) { + return + } + const ruleValue = permissionRuleValueFromString(trimmedValue) + onSubmit(ruleValue, ruleBehavior) } - return t11; + + return ( + <> + + + Add {ruleBehavior} permission rule + + + + Permission rules are a tool name, optionally followed by a specifier + in parentheses. + + e.g.,{' '} + + {permissionRuleValueToString({ toolName: WebFetchTool.name })} + + or + + {permissionRuleValueToString({ + toolName: BashTool.name, + ruleContent: 'ls:*', + })} + + + + + + + + + {exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + Enter to submit · Esc to cancel + )} + + + ) } diff --git a/src/components/permissions/rules/PermissionRuleList.tsx b/src/components/permissions/rules/PermissionRuleList.tsx index d935a8cc0..129b58083 100644 --- a/src/components/permissions/rules/PermissionRuleList.tsx +++ b/src/components/permissions/rules/PermissionRuleList.tsx @@ -1,272 +1,183 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { applyPermissionUpdate, persistPermissionUpdate } from 'src/utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdateDestination } from 'src/utils/permissions/PermissionUpdateSchema.js'; -import type { CommandResultDisplay } from '../../../commands.js'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useSearchInput } from '../../../hooks/useSearchInput.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text, useTerminalFocus } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { type AutoModeDenial, getAutoModeDenials } from '../../../utils/autoModeDenials.js'; -import type { PermissionBehavior, PermissionRule, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; -import { deletePermissionRule, getAllowRules, getAskRules, getDenyRules, permissionRuleSourceDisplayString } from '../../../utils/permissions/permissions.js'; -import type { UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js'; -import { jsonStringify } from '../../../utils/slowOperations.js'; -import { Pane } from '../../design-system/Pane.js'; -import { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '../../design-system/Tabs.js'; -import { SearchBox } from '../../SearchBox.js'; -import type { Option } from '../../ui/option.js'; -import { AddPermissionRules } from './AddPermissionRules.js'; -import { AddWorkspaceDirectory } from './AddWorkspaceDirectory.js'; -import { PermissionRuleDescription } from './PermissionRuleDescription.js'; -import { PermissionRuleInput } from './PermissionRuleInput.js'; -import { RecentDenialsTab } from './RecentDenialsTab.js'; -import { RemoveWorkspaceDirectory } from './RemoveWorkspaceDirectory.js'; -import { WorkspaceTab } from './WorkspaceTab.js'; -type TabType = 'recent' | 'allow' | 'ask' | 'deny' | 'workspace'; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + applyPermissionUpdate, + persistPermissionUpdate, +} from 'src/utils/permissions/PermissionUpdate.js' +import type { PermissionUpdateDestination } from 'src/utils/permissions/PermissionUpdateSchema.js' +import type { CommandResultDisplay } from '../../../commands.js' +import { Select } from '../../../components/CustomSelect/select.js' +import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useSearchInput } from '../../../hooks/useSearchInput.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text, useTerminalFocus } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { + type AutoModeDenial, + getAutoModeDenials, +} from '../../../utils/autoModeDenials.js' +import type { + PermissionBehavior, + PermissionRule, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js' +import { + deletePermissionRule, + getAllowRules, + getAskRules, + getDenyRules, + permissionRuleSourceDisplayString, +} from '../../../utils/permissions/permissions.js' +import type { UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js' +import { jsonStringify } from '../../../utils/slowOperations.js' +import { Pane } from '../../design-system/Pane.js' +import { + Tab, + Tabs, + useTabHeaderFocus, + useTabsWidth, +} from '../../design-system/Tabs.js' +import { SearchBox } from '../../SearchBox.js' +import type { Option } from '../../ui/option.js' +import { AddPermissionRules } from './AddPermissionRules.js' +import { AddWorkspaceDirectory } from './AddWorkspaceDirectory.js' +import { PermissionRuleDescription } from './PermissionRuleDescription.js' +import { PermissionRuleInput } from './PermissionRuleInput.js' +import { RecentDenialsTab } from './RecentDenialsTab.js' +import { RemoveWorkspaceDirectory } from './RemoveWorkspaceDirectory.js' +import { WorkspaceTab } from './WorkspaceTab.js' + +type TabType = 'recent' | 'allow' | 'ask' | 'deny' | 'workspace' + type RuleSourceTextProps = { - rule: PermissionRule; -}; -function RuleSourceText(t0) { - const $ = _c(4); - const { - rule - } = t0; - let t1; - if ($[0] !== rule.source) { - t1 = permissionRuleSourceDisplayString(rule.source); - $[0] = rule.source; - $[1] = t1; - } else { - t1 = $[1]; - } - const t2 = `From ${t1}`; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; + rule: PermissionRule +} +function RuleSourceText({ rule }: RuleSourceTextProps): React.ReactNode { + return ( + {`From ${permissionRuleSourceDisplayString(rule.source)}`} + ) } // Helper function to get the appropriate label for rule behavior function getRuleBehaviorLabel(ruleBehavior: PermissionBehavior): string { switch (ruleBehavior) { case 'allow': - return 'allowed'; + return 'allowed' case 'deny': - return 'denied'; + return 'denied' case 'ask': - return 'ask'; + return 'ask' } } // Component for showing tool details and managing the interactive deletion workflow -function RuleDetails(t0) { - const $ = _c(42); - const { - rule, - onDelete, - onCancel - } = t0; - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - let t2; - if ($[1] !== rule.ruleValue) { - t2 = permissionRuleValueToString(rule.ruleValue); - $[1] = rule.ruleValue; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== rule.ruleValue) { - t4 = ; - $[5] = rule.ruleValue; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== rule) { - t5 = ; - $[7] = rule; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== t3 || $[10] !== t4 || $[11] !== t5) { - t6 = {t3}{t4}{t5}; - $[9] = t3; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - const ruleDescription = t6; - let t7; - if ($[13] !== exitState.keyName || $[14] !== exitState.pending) { - t7 = {exitState.pending ? Press {exitState.keyName} again to exit : Esc to cancel}; - $[13] = exitState.keyName; - $[14] = exitState.pending; - $[15] = t7; - } else { - t7 = $[15]; - } - const footer = t7; - if (rule.source === "policySettings") { - let t8; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Rule details; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t9 = This rule is configured by managed settings and cannot be modified.{"\n"}Contact your system administrator for more information.; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] !== ruleDescription) { - t10 = {t8}{ruleDescription}{t9}; - $[18] = ruleDescription; - $[19] = t10; - } else { - t10 = $[19]; - } - let t11; - if ($[20] !== footer || $[21] !== t10) { - t11 = <>{t10}{footer}; - $[20] = footer; - $[21] = t10; - $[22] = t11; - } else { - t11 = $[22]; - } - return t11; - } - let t8; - if ($[23] !== rule.ruleBehavior) { - t8 = getRuleBehaviorLabel(rule.ruleBehavior); - $[23] = rule.ruleBehavior; - $[24] = t8; - } else { - t8 = $[24]; - } - let t9; - if ($[25] !== t8) { - t9 = Delete {t8} tool?; - $[25] = t8; - $[26] = t9; - } else { - t9 = $[26]; - } - let t10; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Are you sure you want to delete this permission rule?; - $[27] = t10; - } else { - t10 = $[27]; - } - let t11; - if ($[28] !== onCancel || $[29] !== onDelete) { - t11 = _ => _ === "yes" ? onDelete() : onCancel(); - $[28] = onCancel; - $[29] = onDelete; - $[30] = t11; - } else { - t11 = $[30]; - } - let t12; - if ($[31] === Symbol.for("react.memo_cache_sentinel")) { - t12 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[31] = t12; - } else { - t12 = $[31]; - } - let t13; - if ($[32] !== onCancel || $[33] !== t11) { - t13 = (_ === 'yes' ? onDelete() : onCancel())} + onCancel={onCancel} + options={[ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ]} + /> + + {footer} + + ) } + type RulesTabContentProps = { - options: Option[]; - searchQuery: string; - isSearchMode: boolean; - isFocused: boolean; - onSelect: (value: string) => void; - onCancel: () => void; - lastFocusedRuleKey: string | undefined; - cursorOffset?: number; - onHeaderFocusChange?: (focused: boolean) => void; -}; + options: Option[] + searchQuery: string + isSearchMode: boolean + isFocused: boolean + onSelect: (value: string) => void + onCancel: () => void + lastFocusedRuleKey: string | undefined + cursorOffset?: number + onHeaderFocusChange?: (focused: boolean) => void +} // Component for rendering rules tab content with full width support -function RulesTabContent(props) { - const $ = _c(26); +function RulesTabContent(props: RulesTabContentProps): React.ReactNode { const { options, searchQuery, @@ -276,903 +187,613 @@ function RulesTabContent(props) { onCancel, lastFocusedRuleKey, cursorOffset, - onHeaderFocusChange - } = props; - const tabWidth = useTabsWidth(); - const { - headerFocused, - focusHeader, - blurHeader - } = useTabHeaderFocus(); - let t0; - let t1; - if ($[0] !== blurHeader || $[1] !== headerFocused || $[2] !== isSearchMode) { - t0 = () => { - if (isSearchMode && headerFocused) { - blurHeader(); - } - }; - t1 = [isSearchMode, headerFocused, blurHeader]; - $[0] = blurHeader; - $[1] = headerFocused; - $[2] = isSearchMode; - $[3] = t0; - $[4] = t1; - } else { - t0 = $[3]; - t1 = $[4]; - } - useEffect(t0, t1); - let t2; - let t3; - if ($[5] !== headerFocused || $[6] !== onHeaderFocusChange) { - t2 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t3 = [headerFocused, onHeaderFocusChange]; - $[5] = headerFocused; - $[6] = onHeaderFocusChange; - $[7] = t2; - $[8] = t3; - } else { - t2 = $[7]; - t3 = $[8]; - } - useEffect(t2, t3); - const t4 = isSearchMode && !headerFocused; - let t5; - if ($[9] !== cursorOffset || $[10] !== isFocused || $[11] !== searchQuery || $[12] !== t4 || $[13] !== tabWidth) { - t5 = ; - $[9] = cursorOffset; - $[10] = isFocused; - $[11] = searchQuery; - $[12] = t4; - $[13] = tabWidth; - $[14] = t5; - } else { - t5 = $[14]; - } - const t6 = Math.min(10, options.length); - const t7 = isSearchMode || headerFocused; - let t8; - if ($[15] !== focusHeader || $[16] !== lastFocusedRuleKey || $[17] !== onCancel || $[18] !== onSelect || $[19] !== options || $[20] !== t6 || $[21] !== t7) { - t8 = + + ) } // Composes the subtitle + search + Select for a single allow/ask/deny tab. -function PermissionRulesTab(t0) { - const $ = _c(27); - let T0; - let T1; - let handleToolSelect; - let rulesProps; - let t1; - let t2; - let t3; - let t4; - let tab; - if ($[0] !== t0) { - const { - tab: t5, - getRulesOptions, - handleToolSelect: t6, - ...t7 - } = t0; - tab = t5; - handleToolSelect = t6; - rulesProps = t7; - T1 = Box; - t2 = "column"; - t3 = tab === "allow" ? 0 : undefined; - let t8; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - allow: "Claude Code won't ask before using allowed tools.", - ask: "Claude Code will always ask for confirmation before using these tools.", - deny: "Claude Code will always reject requests to use denied tools." - }; - $[10] = t8; - } else { - t8 = $[10]; - } - const t9 = t8[tab]; - if ($[11] !== t9) { - t4 = {t9}; - $[11] = t9; - $[12] = t4; - } else { - t4 = $[12]; - } - T0 = RulesTabContent; - t1 = getRulesOptions(tab, rulesProps.searchQuery); - $[0] = t0; - $[1] = T0; - $[2] = T1; - $[3] = handleToolSelect; - $[4] = rulesProps; - $[5] = t1; - $[6] = t2; - $[7] = t3; - $[8] = t4; - $[9] = tab; - } else { - T0 = $[1]; - T1 = $[2]; - handleToolSelect = $[3]; - rulesProps = $[4]; - t1 = $[5]; - t2 = $[6]; - t3 = $[7]; - t4 = $[8]; - tab = $[9]; - } - let t5; - if ($[13] !== handleToolSelect || $[14] !== tab) { - t5 = v => handleToolSelect(v, tab); - $[13] = handleToolSelect; - $[14] = tab; - $[15] = t5; - } else { - t5 = $[15]; - } - let t6; - if ($[16] !== T0 || $[17] !== rulesProps || $[18] !== t1.options || $[19] !== t5) { - t6 = ; - $[16] = T0; - $[17] = rulesProps; - $[18] = t1.options; - $[19] = t5; - $[20] = t6; - } else { - t6 = $[20]; - } - let t7; - if ($[21] !== T1 || $[22] !== t2 || $[23] !== t3 || $[24] !== t4 || $[25] !== t6) { - t7 = {t4}{t6}; - $[21] = T1; - $[22] = t2; - $[23] = t3; - $[24] = t4; - $[25] = t6; - $[26] = t7; - } else { - t7 = $[26]; - } - return t7; +function PermissionRulesTab({ + tab, + getRulesOptions, + handleToolSelect, + ...rulesProps +}: { + tab: 'allow' | 'ask' | 'deny' + getRulesOptions: (tab: TabType, query?: string) => { options: Option[] } + handleToolSelect: (value: string, tab: TabType) => void +} & Omit): React.ReactNode { + return ( + + + { + { + allow: "Claude Code won't ask before using allowed tools.", + ask: 'Claude Code will always ask for confirmation before using these tools.', + deny: 'Claude Code will always reject requests to use denied tools.', + }[tab] + } + + handleToolSelect(v, tab)} + {...rulesProps} + /> + + ) } + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - shouldQuery?: boolean; - metaMessages?: string[]; - }) => void; - initialTab?: TabType; - onRetryDenials?: (commands: string[]) => void; -}; -export function PermissionRuleList(t0) { - const $ = _c(113); - const { - onExit, - initialTab, - onRetryDenials - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getAutoModeDenials(); - $[0] = t1; - } else { - t1 = $[0]; - } - const hasDenials = t1.length > 0; - const defaultTab = initialTab ?? (hasDenials ? "recent" : "allow"); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [changes, setChanges] = useState(t2); - const toolPermissionContext = useAppState(_temp); - const setAppState = useSetAppState(); - const isTerminalFocused = useTerminalFocus(); - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - approved: new Set(), - retry: new Set(), - denials: [] - }; - $[2] = t3; - } else { - t3 = $[2]; - } - const denialStateRef = useRef(t3); - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = s_0 => { - denialStateRef.current = s_0; - }; - $[3] = t4; - } else { - t4 = $[3]; - } - const handleDenialStateChange = t4; - const [selectedRule, setSelectedRule] = useState(); - const [lastFocusedRuleKey, setLastFocusedRuleKey] = useState(); - const [addingRuleToTab, setAddingRuleToTab] = useState(null); - const [validatedRule, setValidatedRule] = useState(null); - const [isAddingWorkspaceDirectory, setIsAddingWorkspaceDirectory] = useState(false); - const [removingDirectory, setRemovingDirectory] = useState(null); - const [isSearchMode, setIsSearchMode] = useState(false); - const [headerFocused, setHeaderFocused] = useState(true); - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = focused => { - setHeaderFocused(focused); - }; - $[4] = t5; - } else { - t5 = $[4]; - } - const handleHeaderFocusChange = t5; - let map; - if ($[5] !== toolPermissionContext) { - map = new Map(); + onExit: ( + result?: string, + options?: { + display?: CommandResultDisplay + shouldQuery?: boolean + metaMessages?: string[] + }, + ) => void + initialTab?: TabType + onRetryDenials?: (commands: string[]) => void +} + +export function PermissionRuleList({ + onExit, + initialTab, + onRetryDenials, +}: Props): React.ReactNode { + const hasDenials = getAutoModeDenials().length > 0 + const defaultTab: TabType = initialTab ?? (hasDenials ? 'recent' : 'allow') + const [changes, setChanges] = useState([]) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const setAppState = useSetAppState() + const isTerminalFocused = useTerminalFocus() + + // Ref not state: RecentDenialsTab updates don't need to trigger parent + // re-render (only read on exit), and re-renders trip the modal ScrollBox + // collapse bug from #23592 in fullscreen. + const denialStateRef = useRef<{ + approved: Set + retry: Set + denials: readonly AutoModeDenial[] + }>({ approved: new Set(), retry: new Set(), denials: [] }) + const handleDenialStateChange = useCallback( + (s: typeof denialStateRef.current) => { + denialStateRef.current = s + }, + [], + ) + + const [selectedRule, setSelectedRule] = useState() + // Track the key of the last focused rule to restore position after deletion + const [lastFocusedRuleKey, setLastFocusedRuleKey] = useState< + string | undefined + >() + const [addingRuleToTab, setAddingRuleToTab] = useState(null) + const [validatedRule, setValidatedRule] = useState<{ + ruleBehavior: PermissionBehavior + ruleValue: PermissionRuleValue + } | null>(null) + const [isAddingWorkspaceDirectory, setIsAddingWorkspaceDirectory] = + useState(false) + const [removingDirectory, setRemovingDirectory] = useState( + null, + ) + const [isSearchMode, setIsSearchMode] = useState(false) + const [headerFocused, setHeaderFocused] = useState(true) + const handleHeaderFocusChange = useCallback((focused: boolean) => { + setHeaderFocused(focused) + }, []) + + const allowRulesByKey = useMemo(() => { + const map = new Map() getAllowRules(toolPermissionContext).forEach(rule => { - map.set(jsonStringify(rule), rule); - }); - $[5] = toolPermissionContext; - $[6] = map; - } else { - map = $[6]; - } - const allowRulesByKey = map; - let map_0; - if ($[7] !== toolPermissionContext) { - map_0 = new Map(); - getDenyRules(toolPermissionContext).forEach(rule_0 => { - map_0.set(jsonStringify(rule_0), rule_0); - }); - $[7] = toolPermissionContext; - $[8] = map_0; - } else { - map_0 = $[8]; - } - const denyRulesByKey = map_0; - let map_1; - if ($[9] !== toolPermissionContext) { - map_1 = new Map(); - getAskRules(toolPermissionContext).forEach(rule_1 => { - map_1.set(jsonStringify(rule_1), rule_1); - }); - $[9] = toolPermissionContext; - $[10] = map_1; - } else { - map_1 = $[10]; - } - const askRulesByKey = map_1; - let t6; - if ($[11] !== allowRulesByKey || $[12] !== askRulesByKey || $[13] !== denyRulesByKey) { - t6 = (tab, t7) => { - const query = t7 === undefined ? "" : t7; + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const denyRulesByKey = useMemo(() => { + const map = new Map() + getDenyRules(toolPermissionContext).forEach(rule => { + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const askRulesByKey = useMemo(() => { + const map = new Map() + getAskRules(toolPermissionContext).forEach(rule => { + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const getRulesOptions = useCallback( + (tab: TabType, query: string = '') => { const rulesByKey = (() => { switch (tab) { - case "allow": - { - return allowRulesByKey; - } - case "deny": - { - return denyRulesByKey; - } - case "ask": - { - return askRulesByKey; - } - case "workspace": - case "recent": - { - return new Map(); - } + case 'allow': + return allowRulesByKey + case 'deny': + return denyRulesByKey + case 'ask': + return askRulesByKey + case 'workspace': + case 'recent': + return new Map() } - })(); - const options = []; - if (tab !== "workspace" && tab !== "recent" && !query) { + })() + + const options: Option[] = [] + + // Only show "Add a new rule" for allow and deny tabs (and not when searching) + if (tab !== 'workspace' && tab !== 'recent' && !query) { options.push({ label: `Add a new rule${figures.ellipsis}`, - value: "add-new-rule" - }); + value: 'add-new-rule', + }) } + + // Get all rule keys and sort them alphabetically based on rule's formatted value const sortedRuleKeys = Array.from(rulesByKey.keys()).sort((a, b) => { - const ruleA = rulesByKey.get(a); - const ruleB = rulesByKey.get(b); + const ruleA = rulesByKey.get(a) + const ruleB = rulesByKey.get(b) if (ruleA && ruleB) { - const ruleAString = permissionRuleValueToString(ruleA.ruleValue).toLowerCase(); - const ruleBString = permissionRuleValueToString(ruleB.ruleValue).toLowerCase(); - return ruleAString.localeCompare(ruleBString); + const ruleAString = permissionRuleValueToString( + ruleA.ruleValue, + ).toLowerCase() + const ruleBString = permissionRuleValueToString( + ruleB.ruleValue, + ).toLowerCase() + return ruleAString.localeCompare(ruleBString) } - return 0; - }); - const lowerQuery = query.toLowerCase(); + return 0 + }) + + // Build options from sorted keys, filtering by search query + const lowerQuery = query.toLowerCase() for (const ruleKey of sortedRuleKeys) { - const rule_2 = rulesByKey.get(ruleKey); - if (rule_2) { - const ruleString = permissionRuleValueToString(rule_2.ruleValue); + const rule = rulesByKey.get(ruleKey) + if (rule) { + const ruleString = permissionRuleValueToString(rule.ruleValue) + // Filter by search query if provided if (query && !ruleString.toLowerCase().includes(lowerQuery)) { - continue; + continue } options.push({ label: ruleString, - value: ruleKey - }); + value: ruleKey, + }) } } - return { - options, - rulesByKey - }; - }; - $[11] = allowRulesByKey; - $[12] = askRulesByKey; - $[13] = denyRulesByKey; - $[14] = t6; - } else { - t6 = $[14]; - } - const getRulesOptions = t6; - const exitState = useExitOnCtrlCDWithKeybindings(); - const isSearchModeActive = !selectedRule && !addingRuleToTab && !validatedRule && !isAddingWorkspaceDirectory && !removingDirectory; - const t7 = isSearchModeActive && isSearchMode; - let t8; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t8 = () => { - setIsSearchMode(false); - }; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] !== t7) { - t9 = { - isActive: t7, - onExit: t8 - }; - $[16] = t7; - $[17] = t9; - } else { - t9 = $[17]; - } + + return { options, rulesByKey } + }, + [allowRulesByKey, denyRulesByKey, askRulesByKey], + ) + + const exitState = useExitOnCtrlCDWithKeybindings() + + const isSearchModeActive = + !selectedRule && + !addingRuleToTab && + !validatedRule && + !isAddingWorkspaceDirectory && + !removingDirectory + const { query: searchQuery, setQuery: setSearchQuery, - cursorOffset: searchCursorOffset - } = useSearchInput(t9); - let t10; - if ($[18] !== isSearchMode || $[19] !== isSearchModeActive || $[20] !== setSearchQuery) { - t10 = e => { - if (!isSearchModeActive) { - return; - } - if (isSearchMode) { - return; - } - if (e.ctrl || e.meta) { - return; - } - if (e.key === "/") { - e.preventDefault(); - setIsSearchMode(true); - setSearchQuery(""); - } else { - if (e.key.length === 1 && e.key !== "j" && e.key !== "k" && e.key !== "m" && e.key !== "i" && e.key !== "r" && e.key !== " ") { - e.preventDefault(); - setIsSearchMode(true); - setSearchQuery(e.key); - } + cursorOffset: searchCursorOffset, + } = useSearchInput({ + isActive: isSearchModeActive && isSearchMode, + onExit: () => { + setIsSearchMode(false) + }, + }) + + // Handle entering search mode + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isSearchModeActive) return + if (isSearchMode) return + if (e.ctrl || e.meta) return + + // Enter search mode with '/' or any printable character. + // e.key.length === 1 filters out special keys (down, return, escape, + // etc.) — previously the raw escape sequence leaked through and + // triggered search mode with garbage on arrow-key press. + if (e.key === '/') { + e.preventDefault() + setIsSearchMode(true) + setSearchQuery('') + } else if ( + e.key.length === 1 && + // Don't enter search mode for vim-nav / space / retry key + e.key !== 'j' && + e.key !== 'k' && + e.key !== 'm' && + e.key !== 'i' && + e.key !== 'r' && + e.key !== ' ' + ) { + e.preventDefault() + setIsSearchMode(true) + setSearchQuery(e.key) } - }; - $[18] = isSearchMode; - $[19] = isSearchModeActive; - $[20] = setSearchQuery; - $[21] = t10; - } else { - t10 = $[21]; - } - const handleKeyDown = t10; - let t11; - if ($[22] !== getRulesOptions) { - t11 = (selectedValue, tab_0) => { - const { - rulesByKey: rulesByKey_0 - } = getRulesOptions(tab_0); - if (selectedValue === "add-new-rule") { - setAddingRuleToTab(tab_0); - return; + }, + [isSearchModeActive, isSearchMode, setSearchQuery], + ) + + const handleToolSelect = useCallback( + (selectedValue: string, tab: TabType) => { + const { rulesByKey } = getRulesOptions(tab) + if (selectedValue === 'add-new-rule') { + setAddingRuleToTab(tab) + return } else { - setSelectedRule(rulesByKey_0.get(selectedValue)); - return; + setSelectedRule(rulesByKey.get(selectedValue)) + return } - }; - $[22] = getRulesOptions; - $[23] = t11; - } else { - t11 = $[23]; - } - const handleToolSelect = t11; - let t12; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t12 = () => { - setAddingRuleToTab(null); - }; - $[24] = t12; - } else { - t12 = $[24]; - } - const handleRuleInputCancel = t12; - let t13; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t13 = (ruleValue, ruleBehavior) => { - setValidatedRule({ - ruleValue, - ruleBehavior - }); - setAddingRuleToTab(null); - }; - $[25] = t13; - } else { - t13 = $[25]; - } - const handleRuleInputSubmit = t13; - let t14; - if ($[26] === Symbol.for("react.memo_cache_sentinel")) { - t14 = (rules, unreachable) => { - setValidatedRule(null); - for (const rule_3 of rules) { - setChanges(prev => [...prev, `Added ${rule_3.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(rule_3.ruleValue))}`]); + }, + [getRulesOptions], + ) + + const handleRuleInputCancel = useCallback(() => { + setAddingRuleToTab(null) + }, []) + + const handleRuleInputSubmit = useCallback( + (ruleValue: PermissionRuleValue, ruleBehavior: PermissionBehavior) => { + setValidatedRule({ ruleValue, ruleBehavior }) + setAddingRuleToTab(null) + }, + [], + ) + + const handleAddRulesSuccess = useCallback( + (rules: PermissionRule[], unreachable?: UnreachableRule[]) => { + setValidatedRule(null) + for (const rule of rules) { + setChanges(prev => [ + ...prev, + `Added ${rule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(rule.ruleValue))}`, + ]) } + + // Show warnings for any unreachable rules we just added if (unreachable && unreachable.length > 0) { for (const u of unreachable) { - const severity = u.shadowType === "deny" ? "blocked" : "shadowed"; - setChanges(prev_0 => [...prev_0, chalk.yellow(`${figures.warning} Warning: ${permissionRuleValueToString(u.rule.ruleValue)} is ${severity}`), chalk.dim(` ${u.reason}`), chalk.dim(` Fix: ${u.fix}`)]); + const severity = u.shadowType === 'deny' ? 'blocked' : 'shadowed' + setChanges(prev => [ + ...prev, + chalk.yellow( + `${figures.warning} Warning: ${permissionRuleValueToString(u.rule.ruleValue)} is ${severity}`, + ), + chalk.dim(` ${u.reason}`), + chalk.dim(` Fix: ${u.fix}`), + ]) } } - }; - $[26] = t14; - } else { - t14 = $[26]; - } - const handleAddRulesSuccess = t14; - let t15; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t15 = () => { - setValidatedRule(null); - }; - $[27] = t15; - } else { - t15 = $[27]; - } - const handleAddRuleCancel = t15; - let t16; - if ($[28] === Symbol.for("react.memo_cache_sentinel")) { - t16 = () => setIsAddingWorkspaceDirectory(true); - $[28] = t16; - } else { - t16 = $[28]; - } - const handleRequestAddDirectory = t16; - let t17; - if ($[29] === Symbol.for("react.memo_cache_sentinel")) { - t17 = path => setRemovingDirectory(path); - $[29] = t17; - } else { - t17 = $[29]; - } - const handleRequestRemoveDirectory = t17; - let t18; - if ($[30] !== changes || $[31] !== onExit || $[32] !== onRetryDenials) { - t18 = () => { - const s_1 = denialStateRef.current; - const denialsFor = (set: Set) => Array.from(set).map(idx => s_1.denials[idx]).filter(_temp2); - const retryDenials = denialsFor(s_1.retry); - if (retryDenials.length > 0) { - const commands = retryDenials.map(_temp3); - onRetryDenials?.(commands); - onExit(undefined, { - shouldQuery: true, - metaMessages: [`Permission granted for: ${commands.join(", ")}. You may now retry ${commands.length === 1 ? "this command" : "these commands"} if you would like.`] - }); - return; - } - const approvedDenials = denialsFor(s_1.approved); - if (approvedDenials.length > 0 || changes.length > 0) { - const approvedMsg = approvedDenials.length > 0 ? [`Approved ${approvedDenials.map(_temp4).join(", ")}`] : []; - onExit([...approvedMsg, ...changes].join("\n")); - } else { - onExit("Permissions dialog dismissed", { - display: "system" - }); - } - }; - $[30] = changes; - $[31] = onExit; - $[32] = onRetryDenials; - $[33] = t18; - } else { - t18 = $[33]; - } - const handleRulesCancel = t18; - const t19 = isSearchModeActive && !isSearchMode; - let t20; - if ($[34] !== t19) { - t20 = { - context: "Settings", - isActive: t19 - }; - $[34] = t19; - $[35] = t20; - } else { - t20 = $[35]; - } - useKeybinding("confirm:no", handleRulesCancel, t20); - let t21; - if ($[36] !== getRulesOptions || $[37] !== selectedRule || $[38] !== setAppState || $[39] !== toolPermissionContext) { - t21 = () => { - if (!selectedRule) { - return; - } - const { - options: options_0 - } = getRulesOptions(selectedRule.ruleBehavior as TabType); - const selectedKey = jsonStringify(selectedRule); - const ruleKeys = options_0.filter(_temp5).map(_temp6); - const currentIndex = ruleKeys.indexOf(selectedKey); - let nextFocusKey; - if (currentIndex !== -1) { - if (currentIndex < ruleKeys.length - 1) { - nextFocusKey = ruleKeys[currentIndex + 1]; - } else { - if (currentIndex > 0) { - nextFocusKey = ruleKeys[currentIndex - 1]; - } - } - } - setLastFocusedRuleKey(nextFocusKey); - deletePermissionRule({ - rule: selectedRule, - initialContext: toolPermissionContext, - setToolPermissionContext(toolPermissionContext_0) { - setAppState(prev_1 => ({ - ...prev_1, - toolPermissionContext: toolPermissionContext_0 - })); - } - }); - setChanges(prev_2 => [...prev_2, `Deleted ${selectedRule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(selectedRule.ruleValue))}`]); - setSelectedRule(undefined); - }; - $[36] = getRulesOptions; - $[37] = selectedRule; - $[38] = setAppState; - $[39] = toolPermissionContext; - $[40] = t21; - } else { - t21 = $[40]; - } - const handleDeleteRule = t21; - if (selectedRule) { - let t22; - if ($[41] === Symbol.for("react.memo_cache_sentinel")) { - t22 = () => setSelectedRule(undefined); - $[41] = t22; - } else { - t22 = $[41]; + }, + [], + ) + + const handleAddRuleCancel = useCallback(() => { + setValidatedRule(null) + }, []) + + const handleRequestAddDirectory = useCallback( + () => setIsAddingWorkspaceDirectory(true), + [], + ) + const handleRequestRemoveDirectory = useCallback( + (path: string) => setRemovingDirectory(path), + [], + ) + const handleRulesCancel = useCallback(() => { + const s = denialStateRef.current + const denialsFor = (set: Set) => + Array.from(set) + .map(idx => s.denials[idx]) + .filter((d): d is AutoModeDenial => d !== undefined) + + const retryDenials = denialsFor(s.retry) + if (retryDenials.length > 0) { + const commands = retryDenials.map(d => d.display) + onRetryDenials?.(commands) + onExit(undefined, { + shouldQuery: true, + metaMessages: [ + `Permission granted for: ${commands.join(', ')}. You may now retry ${commands.length === 1 ? 'this command' : 'these commands'} if you would like.`, + ], + }) + return } - let t23; - if ($[42] !== handleDeleteRule || $[43] !== selectedRule) { - t23 = ; - $[42] = handleDeleteRule; - $[43] = selectedRule; - $[44] = t23; + + const approvedDenials = denialsFor(s.approved) + if (approvedDenials.length > 0 || changes.length > 0) { + const approvedMsg = + approvedDenials.length > 0 + ? [ + `Approved ${approvedDenials.map(d => chalk.bold(d.display)).join(', ')}`, + ] + : [] + onExit([...approvedMsg, ...changes].join('\n')) } else { - t23 = $[44]; + onExit('Permissions dialog dismissed', { + display: 'system', + }) } - return t23; - } - if (addingRuleToTab && addingRuleToTab !== "workspace" && addingRuleToTab !== "recent") { - let t22; - if ($[45] !== addingRuleToTab) { - t22 = ; - $[45] = addingRuleToTab; - $[46] = t22; - } else { - t22 = $[46]; + }, [changes, onExit, onRetryDenials]) + + // Handle Escape at the top level so it works even when header is focused + // (which disables the Select component and its select:cancel keybinding). + // Mirrors the pattern in Settings.tsx. + useKeybinding('confirm:no', handleRulesCancel, { + context: 'Settings', + isActive: isSearchModeActive && !isSearchMode, + }) + + const handleDeleteRule = () => { + if (!selectedRule) return + + // Find the adjacent rule to focus on after deletion + const { options } = getRulesOptions(selectedRule.ruleBehavior as TabType) + const selectedKey = jsonStringify(selectedRule) + const ruleKeys = options + .filter(opt => opt.value !== 'add-new-rule') + .map(opt => opt.value) + const currentIndex = ruleKeys.indexOf(selectedKey) + + // Try to focus on the next rule, or the previous if deleting the last one + let nextFocusKey: string | undefined + if (currentIndex !== -1) { + if (currentIndex < ruleKeys.length - 1) { + // Focus on the next rule + nextFocusKey = ruleKeys[currentIndex + 1] + } else if (currentIndex > 0) { + // Focus on the previous rule (we're deleting the last one) + nextFocusKey = ruleKeys[currentIndex - 1] + } } - return t22; + setLastFocusedRuleKey(nextFocusKey) + + void deletePermissionRule({ + rule: selectedRule, + initialContext: toolPermissionContext, + setToolPermissionContext(toolPermissionContext) { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }, + }) + + setChanges(prev => [ + ...prev, + `Deleted ${selectedRule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(selectedRule.ruleValue))}`, + ]) + setSelectedRule(undefined) } + + if (selectedRule) { + return ( + setSelectedRule(undefined)} + /> + ) + } + + if ( + addingRuleToTab && + addingRuleToTab !== 'workspace' && + addingRuleToTab !== 'recent' + ) { + return ( + + ) + } + if (validatedRule) { - let t22; - if ($[47] !== validatedRule.ruleValue) { - t22 = [validatedRule.ruleValue]; - $[47] = validatedRule.ruleValue; - $[48] = t22; - } else { - t22 = $[48]; - } - let t23; - if ($[49] !== setAppState) { - t23 = toolPermissionContext_1 => { - setAppState(prev_3 => ({ - ...prev_3, - toolPermissionContext: toolPermissionContext_1 - })); - }; - $[49] = setAppState; - $[50] = t23; - } else { - t23 = $[50]; - } - let t24; - if ($[51] !== t22 || $[52] !== t23 || $[53] !== toolPermissionContext || $[54] !== validatedRule.ruleBehavior) { - t24 = ; - $[51] = t22; - $[52] = t23; - $[53] = toolPermissionContext; - $[54] = validatedRule.ruleBehavior; - $[55] = t24; - } else { - t24 = $[55]; - } - return t24; + return ( + { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }} + /> + ) } + if (isAddingWorkspaceDirectory) { - let t22; - if ($[56] !== setAppState || $[57] !== toolPermissionContext) { - t22 = (path_0, remember) => { - const destination: PermissionUpdateDestination = remember ? "localSettings" : "session"; - const permissionUpdate = { - type: "addDirectories" as const, - directories: [path_0], - destination - }; - const updatedContext = applyPermissionUpdate(toolPermissionContext, permissionUpdate); - setAppState(prev_4 => ({ - ...prev_4, - toolPermissionContext: updatedContext - })); - if (remember) { - persistPermissionUpdate(permissionUpdate); - } - setChanges(prev_5 => [...prev_5, `Added directory ${chalk.bold(path_0)} to workspace${remember ? " and saved to local settings" : " for this session"}`]); - setIsAddingWorkspaceDirectory(false); - }; - $[56] = setAppState; - $[57] = toolPermissionContext; - $[58] = t22; - } else { - t22 = $[58]; - } - let t23; - if ($[59] === Symbol.for("react.memo_cache_sentinel")) { - t23 = () => setIsAddingWorkspaceDirectory(false); - $[59] = t23; - } else { - t23 = $[59]; - } - let t24; - if ($[60] !== t22 || $[61] !== toolPermissionContext) { - t24 = ; - $[60] = t22; - $[61] = toolPermissionContext; - $[62] = t24; - } else { - t24 = $[62]; - } - return t24; + return ( + { + // Apply the permission update to add the directory + const destination: PermissionUpdateDestination = remember + ? 'localSettings' + : 'session' + + const permissionUpdate = { + type: 'addDirectories' as const, + directories: [path], + destination, + } + + const updatedContext = applyPermissionUpdate( + toolPermissionContext, + permissionUpdate, + ) + setAppState(prev => ({ + ...prev, + toolPermissionContext: updatedContext, + })) + + // Persist if remember is true + if (remember) { + persistPermissionUpdate(permissionUpdate) + } + + setChanges(prev => [ + ...prev, + `Added directory ${chalk.bold(path)} to workspace${remember ? ' and saved to local settings' : ' for this session'}`, + ]) + setIsAddingWorkspaceDirectory(false) + }} + onCancel={() => setIsAddingWorkspaceDirectory(false)} + permissionContext={toolPermissionContext} + /> + ) } + if (removingDirectory) { - let t22; - if ($[63] !== removingDirectory) { - t22 = () => { - setChanges(prev_6 => [...prev_6, `Removed directory ${chalk.bold(removingDirectory)} from workspace`]); - setRemovingDirectory(null); - }; - $[63] = removingDirectory; - $[64] = t22; - } else { - t22 = $[64]; - } - let t23; - if ($[65] === Symbol.for("react.memo_cache_sentinel")) { - t23 = () => setRemovingDirectory(null); - $[65] = t23; - } else { - t23 = $[65]; - } - let t24; - if ($[66] !== setAppState) { - t24 = toolPermissionContext_2 => { - setAppState(prev_7 => ({ - ...prev_7, - toolPermissionContext: toolPermissionContext_2 - })); - }; - $[66] = setAppState; - $[67] = t24; - } else { - t24 = $[67]; - } - let t25; - if ($[68] !== removingDirectory || $[69] !== t22 || $[70] !== t24 || $[71] !== toolPermissionContext) { - t25 = ; - $[68] = removingDirectory; - $[69] = t22; - $[70] = t24; - $[71] = toolPermissionContext; - $[72] = t25; - } else { - t25 = $[72]; - } - return t25; - } - let t22; - if ($[73] !== getRulesOptions || $[74] !== handleRulesCancel || $[75] !== handleToolSelect || $[76] !== isSearchMode || $[77] !== isTerminalFocused || $[78] !== lastFocusedRuleKey || $[79] !== searchCursorOffset || $[80] !== searchQuery) { - t22 = { - searchQuery, - isSearchMode, - isFocused: isTerminalFocused, - onCancel: handleRulesCancel, - lastFocusedRuleKey, - cursorOffset: searchCursorOffset, - getRulesOptions, - handleToolSelect, - onHeaderFocusChange: handleHeaderFocusChange - }; - $[73] = getRulesOptions; - $[74] = handleRulesCancel; - $[75] = handleToolSelect; - $[76] = isSearchMode; - $[77] = isTerminalFocused; - $[78] = lastFocusedRuleKey; - $[79] = searchCursorOffset; - $[80] = searchQuery; - $[81] = t22; - } else { - t22 = $[81]; - } - const sharedRulesProps = t22; - const isHidden = !!selectedRule || !!addingRuleToTab || !!validatedRule || isAddingWorkspaceDirectory || !!removingDirectory; - const t23 = !isSearchMode; - let t24; - if ($[82] === Symbol.for("react.memo_cache_sentinel")) { - t24 = ; - $[82] = t24; - } else { - t24 = $[82]; - } - let t25; - if ($[83] !== sharedRulesProps) { - t25 = ; - $[83] = sharedRulesProps; - $[84] = t25; - } else { - t25 = $[84]; - } - let t26; - if ($[85] !== sharedRulesProps) { - t26 = ; - $[85] = sharedRulesProps; - $[86] = t26; - } else { - t26 = $[86]; - } - let t27; - if ($[87] !== sharedRulesProps) { - t27 = ; - $[87] = sharedRulesProps; - $[88] = t27; - } else { - t27 = $[88]; + return ( + { + setChanges(prev => [ + ...prev, + `Removed directory ${chalk.bold(removingDirectory)} from workspace`, + ]) + setRemovingDirectory(null) + }} + onCancel={() => setRemovingDirectory(null)} + permissionContext={toolPermissionContext} + setPermissionContext={toolPermissionContext => { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }} + /> + ) } - let t28; - if ($[89] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Claude Code can read files in the workspace, and make edits when auto-accept edits is on.; - $[89] = t28; - } else { - t28 = $[89]; - } - let t29; - if ($[90] !== onExit || $[91] !== toolPermissionContext) { - t29 = {t28}; - $[90] = onExit; - $[91] = toolPermissionContext; - $[92] = t29; - } else { - t29 = $[92]; - } - let t30; - if ($[93] !== defaultTab || $[94] !== isHidden || $[95] !== t23 || $[96] !== t25 || $[97] !== t26 || $[98] !== t27 || $[99] !== t29) { - t30 = ; - $[93] = defaultTab; - $[94] = isHidden; - $[95] = t23; - $[96] = t25; - $[97] = t26; - $[98] = t27; - $[99] = t29; - $[100] = t30; - } else { - t30 = $[100]; - } - let t31; - if ($[101] !== defaultTab || $[102] !== exitState.keyName || $[103] !== exitState.pending || $[104] !== headerFocused || $[105] !== isSearchMode) { - t31 = {exitState.pending ? <>Press {exitState.keyName} again to exit : headerFocused ? <>←/→ tab switch · ↓ return · Esc cancel : isSearchMode ? <>Type to filter · Enter/↓ select · ↑ tabs · Esc clear : hasDenials && defaultTab === "recent" ? <>Enter approve · r retry · ↑↓ navigate · ←/→ switch · Esc cancel : <>↑↓ navigate · Enter select · Type to search · ←/→ switch · Esc cancel}; - $[101] = defaultTab; - $[102] = exitState.keyName; - $[103] = exitState.pending; - $[104] = headerFocused; - $[105] = isSearchMode; - $[106] = t31; - } else { - t31 = $[106]; - } - let t32; - if ($[107] !== t30 || $[108] !== t31) { - t32 = {t30}{t31}; - $[107] = t30; - $[108] = t31; - $[109] = t32; - } else { - t32 = $[109]; - } - let t33; - if ($[110] !== handleKeyDown || $[111] !== t32) { - t33 = {t32}; - $[110] = handleKeyDown; - $[111] = t32; - $[112] = t33; - } else { - t33 = $[112]; + + const sharedRulesProps = { + searchQuery, + isSearchMode, + isFocused: isTerminalFocused, + onCancel: handleRulesCancel, + lastFocusedRuleKey, + cursorOffset: searchCursorOffset, + getRulesOptions, + handleToolSelect, + onHeaderFocusChange: handleHeaderFocusChange, } - return t33; -} -function _temp6(opt_0) { - return opt_0.value; -} -function _temp5(opt) { - return opt.value !== "add-new-rule"; -} -function _temp4(d_1) { - return chalk.bold(d_1.display); -} -function _temp3(d_0) { - return d_0.display; -} -function _temp2(d) { - return d !== undefined; -} -function _temp(s) { - return s.toolPermissionContext; + + const isHidden = + !!selectedRule || + !!addingRuleToTab || + !!validatedRule || + isAddingWorkspaceDirectory || + !!removingDirectory + + return ( + + + + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : headerFocused ? ( + <>←/→ tab switch · ↓ return · Esc cancel + ) : isSearchMode ? ( + <>Type to filter · Enter/↓ select · ↑ tabs · Esc clear + ) : hasDenials && defaultTab === 'recent' ? ( + <> + Enter approve · r retry · ↑↓ navigate · ←/→ switch · Esc cancel + + ) : ( + <> + ↑↓ navigate · Enter select · Type to search · ←/→ switch · Esc + cancel + + )} + + + + + ) } diff --git a/src/components/permissions/rules/RecentDenialsTab.tsx b/src/components/permissions/rules/RecentDenialsTab.tsx index cba81a4ea..17c13844d 100644 --- a/src/components/permissions/rules/RecentDenialsTab.tsx +++ b/src/components/permissions/rules/RecentDenialsTab.tsx @@ -1,206 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- 'r' is a view-specific key, not a global keybinding -import { Box, Text, useInput } from '../../../ink.js'; -import { type AutoModeDenial, getAutoModeDenials } from '../../../utils/autoModeDenials.js'; -import { Select } from '../../CustomSelect/select.js'; -import { StatusIcon } from '../../design-system/StatusIcon.js'; -import { useTabHeaderFocus } from '../../design-system/Tabs.js'; +import { Box, Text, useInput } from '../../../ink.js' +import { + type AutoModeDenial, + getAutoModeDenials, +} from '../../../utils/autoModeDenials.js' +import { Select } from '../../CustomSelect/select.js' +import { StatusIcon } from '../../design-system/StatusIcon.js' +import { useTabHeaderFocus } from '../../design-system/Tabs.js' + type Props = { - onHeaderFocusChange?: (focused: boolean) => void; + onHeaderFocusChange?: (focused: boolean) => void /** Called when approved/retry state changes so parent can act on exit */ onStateChange: (state: { - approved: Set; - retry: Set; - denials: readonly AutoModeDenial[]; - }) => void; -}; -export function RecentDenialsTab(t0) { - const $ = _c(30); - const { - onHeaderFocusChange, - onStateChange - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - let t2; - if ($[0] !== headerFocused || $[1] !== onHeaderFocusChange) { - t1 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t2 = [headerFocused, onHeaderFocusChange]; - $[0] = headerFocused; - $[1] = onHeaderFocusChange; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - const [denials] = useState(_temp); - const [approved, setApproved] = useState(_temp2); - const [retry, setRetry] = useState(_temp3); - const [focusedIdx, setFocusedIdx] = useState(0); - let t3; - let t4; - if ($[4] !== approved || $[5] !== denials || $[6] !== onStateChange || $[7] !== retry) { - t3 = () => { - onStateChange({ - approved, - retry, - denials - }); - }; - t4 = [approved, retry, denials, onStateChange]; - $[4] = approved; - $[5] = denials; - $[6] = onStateChange; - $[7] = retry; - $[8] = t3; - $[9] = t4; - } else { - t3 = $[8]; - t4 = $[9]; - } - useEffect(t3, t4); - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = value => { - const idx = Number(value); - setApproved(prev => { - const next = new Set(prev); - if (next.has(idx)) { - next.delete(idx); - } else { - next.add(idx); - } - return next; - }); - }; - $[10] = t5; - } else { - t5 = $[10]; - } - const handleSelect = t5; - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = value_0 => { - setFocusedIdx(Number(value_0)); - }; - $[11] = t6; - } else { - t6 = $[11]; - } - const handleFocus = t6; - let t7; - if ($[12] !== focusedIdx) { - t7 = (input, _key) => { - if (input === "r") { - setRetry(prev_0 => { - const next_0 = new Set(prev_0); - if (next_0.has(focusedIdx)) { - next_0.delete(focusedIdx); - } else { - next_0.add(focusedIdx); - } - return next_0; - }); - setApproved(prev_1 => { - if (prev_1.has(focusedIdx)) { - return prev_1; - } - const next_1 = new Set(prev_1); - next_1.add(focusedIdx); - return next_1; - }); + approved: Set + retry: Set + denials: readonly AutoModeDenial[] + }) => void +} + +export function RecentDenialsTab({ + onHeaderFocusChange, + onStateChange, +}: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + useEffect(() => { + onHeaderFocusChange?.(headerFocused) + }, [headerFocused, onHeaderFocusChange]) + + // Snapshot on mount — approved/retry Sets key by index, and the live store + // prepends. A concurrent denial would shift all indices mid-edit. + const [denials] = useState(() => getAutoModeDenials()) + + const [approved, setApproved] = useState>(() => new Set()) + const [retry, setRetry] = useState>(() => new Set()) + const [focusedIdx, setFocusedIdx] = useState(0) + + useEffect(() => { + onStateChange({ approved, retry, denials }) + }, [approved, retry, denials, onStateChange]) + + const handleSelect = useCallback((value: string) => { + const idx = Number(value) + setApproved(prev => { + const next = new Set(prev) + if (next.has(idx)) next.delete(idx) + else next.add(idx) + return next + }) + }, []) + + const handleFocus = useCallback((value: string) => { + setFocusedIdx(Number(value)) + }, []) + + useInput( + (input, _key) => { + if (input === 'r') { + setRetry(prev => { + const next = new Set(prev) + if (next.has(focusedIdx)) next.delete(focusedIdx) + else next.add(focusedIdx) + return next + }) + // Retry implies approve + setApproved(prev => { + if (prev.has(focusedIdx)) return prev + const next = new Set(prev) + next.add(focusedIdx) + return next + }) } - }; - $[12] = focusedIdx; - $[13] = t7; - } else { - t7 = $[13]; - } - const t8 = denials.length > 0; - let t9; - if ($[14] !== t8) { - t9 = { - isActive: t8 - }; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - useInput(t7, t9); + }, + { isActive: denials.length > 0 }, + ) + if (denials.length === 0) { - let t10; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t10 = No recent denials. Commands denied by the auto mode classifier will appear here.; - $[16] = t10; - } else { - t10 = $[16]; - } - return t10; + return ( + + No recent denials. Commands denied by the auto mode classifier will + appear here. + + ) } - let t10; - if ($[17] !== approved || $[18] !== denials || $[19] !== retry) { - let t11; - if ($[21] !== approved || $[22] !== retry) { - t11 = (d, idx_0) => { - const isApproved = approved.has(idx_0); - const suffix = retry.has(idx_0) ? " (retry)" : ""; - return { - label: {d.display}{suffix}, - value: String(idx_0) - }; - }; - $[21] = approved; - $[22] = retry; - $[23] = t11; - } else { - t11 = $[23]; + + const options = denials.map((d, idx) => { + const isApproved = approved.has(idx) + const suffix = retry.has(idx) ? ' (retry)' : '' + return { + label: ( + + + {d.display} + {suffix} + + ), + value: String(idx), } - t10 = denials.map(t11); - $[17] = approved; - $[18] = denials; - $[19] = retry; - $[20] = t10; - } else { - t10 = $[20]; - } - const options = t10; - let t11; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Commands recently denied by the auto mode classifier.; - $[24] = t11; - } else { - t11 = $[24]; - } - const t12 = Math.min(10, options.length); - let t13; - if ($[25] !== focusHeader || $[26] !== headerFocused || $[27] !== options || $[28] !== t12) { - t13 = {t11} + + + ) } diff --git a/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx b/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx index ffdf65799..e6eefade2 100644 --- a/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx +++ b/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx @@ -1,109 +1,68 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback } from 'react'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { applyPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js'; -import { Dialog } from '../../design-system/Dialog.js'; +import * as React from 'react' +import { useCallback } from 'react' +import { Select } from '../../../components/CustomSelect/select.js' +import { Box, Text } from '../../../ink.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { applyPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js' +import { Dialog } from '../../design-system/Dialog.js' + type Props = { - directoryPath: string; - onRemove: () => void; - onCancel: () => void; - permissionContext: ToolPermissionContext; - setPermissionContext: (context: ToolPermissionContext) => void; -}; -export function RemoveWorkspaceDirectory(t0) { - const $ = _c(19); - const { - directoryPath, - onRemove, - onCancel, - permissionContext, - setPermissionContext - } = t0; - let t1; - if ($[0] !== directoryPath || $[1] !== onRemove || $[2] !== permissionContext || $[3] !== setPermissionContext) { - t1 = () => { - const updatedContext = applyPermissionUpdate(permissionContext, { - type: "removeDirectories", - directories: [directoryPath], - destination: "session" - }); - setPermissionContext(updatedContext); - onRemove(); - }; - $[0] = directoryPath; - $[1] = onRemove; - $[2] = permissionContext; - $[3] = setPermissionContext; - $[4] = t1; - } else { - t1 = $[4]; - } - const handleRemove = t1; - let t2; - if ($[5] !== handleRemove || $[6] !== onCancel) { - t2 = value => { - if (value === "yes") { - handleRemove(); + directoryPath: string + onRemove: () => void + onCancel: () => void + permissionContext: ToolPermissionContext + setPermissionContext: (context: ToolPermissionContext) => void +} + +export function RemoveWorkspaceDirectory({ + directoryPath, + onRemove, + onCancel, + permissionContext, + setPermissionContext, +}: Props): React.ReactNode { + const handleRemove = useCallback(() => { + const updatedContext = applyPermissionUpdate(permissionContext, { + type: 'removeDirectories', + directories: [directoryPath], + destination: 'session', + }) + + setPermissionContext(updatedContext) + onRemove() + }, [directoryPath, permissionContext, setPermissionContext, onRemove]) + + const handleSelect = useCallback( + (value: string) => { + if (value === 'yes') { + handleRemove() } else { - onCancel(); + onCancel() } - }; - $[5] = handleRemove; - $[6] = onCancel; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleSelect = t2; - let t3; - if ($[8] !== directoryPath) { - t3 = {directoryPath}; - $[8] = directoryPath; - $[9] = t3; - } else { - t3 = $[9]; - } - let t4; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Claude Code will no longer have access to files in this directory.; - $[10] = t4; - } else { - t4 = $[10]; - } - let t5; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t5 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== handleSelect || $[13] !== onCancel) { - t6 = + + ) } diff --git a/src/components/permissions/rules/WorkspaceTab.tsx b/src/components/permissions/rules/WorkspaceTab.tsx index 8ed8a09c6..0dab0c7d0 100644 --- a/src/components/permissions/rules/WorkspaceTab.tsx +++ b/src/components/permissions/rules/WorkspaceTab.tsx @@ -1,149 +1,105 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect } from 'react'; -import { getOriginalCwd } from '../../../bootstrap/state.js'; -import type { CommandResultDisplay } from '../../../commands.js'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { useTabHeaderFocus } from '../../design-system/Tabs.js'; +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect } from 'react' +import { getOriginalCwd } from '../../../bootstrap/state.js' +import type { CommandResultDisplay } from '../../../commands.js' +import { Select } from '../../../components/CustomSelect/select.js' +import { Box, Text } from '../../../ink.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { useTabHeaderFocus } from '../../design-system/Tabs.js' + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - toolPermissionContext: ToolPermissionContext; - onRequestAddDirectory: () => void; - onRequestRemoveDirectory: (path: string) => void; - onHeaderFocusChange?: (focused: boolean) => void; -}; + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + toolPermissionContext: ToolPermissionContext + onRequestAddDirectory: () => void + onRequestRemoveDirectory: (path: string) => void + onHeaderFocusChange?: (focused: boolean) => void +} + type DirectoryItem = { - path: string; - isCurrent: boolean; - isDeletable: boolean; -}; -export function WorkspaceTab(t0) { - const $ = _c(23); - const { - onExit, - toolPermissionContext, - onRequestAddDirectory, - onRequestRemoveDirectory, - onHeaderFocusChange - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - let t2; - if ($[0] !== headerFocused || $[1] !== onHeaderFocusChange) { - t1 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t2 = [headerFocused, onHeaderFocusChange]; - $[0] = headerFocused; - $[1] = onHeaderFocusChange; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== toolPermissionContext.additionalWorkingDirectories) { - t3 = Array.from(toolPermissionContext.additionalWorkingDirectories.keys()).map(_temp); - $[4] = toolPermissionContext.additionalWorkingDirectories; - $[5] = t3; - } else { - t3 = $[5]; - } - const additionalDirectories = t3; - let t4; - if ($[6] !== additionalDirectories || $[7] !== onRequestAddDirectory || $[8] !== onRequestRemoveDirectory) { - t4 = selectedValue => { - if (selectedValue === "add-directory") { - onRequestAddDirectory(); - return; + path: string + isCurrent: boolean + isDeletable: boolean +} + +export function WorkspaceTab({ + onExit, + toolPermissionContext, + onRequestAddDirectory, + onRequestRemoveDirectory, + onHeaderFocusChange, +}: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + useEffect(() => { + onHeaderFocusChange?.(headerFocused) + }, [headerFocused, onHeaderFocusChange]) + // Get only additional workspace directories (not the current working directory) + const additionalDirectories = React.useMemo((): DirectoryItem[] => { + return Array.from( + toolPermissionContext.additionalWorkingDirectories.keys(), + ).map(path => ({ + path, + isCurrent: false, + isDeletable: true, + })) + }, [toolPermissionContext.additionalWorkingDirectories]) + + const handleDirectorySelect = useCallback( + (selectedValue: string) => { + if (selectedValue === 'add-directory') { + onRequestAddDirectory() + return } - const directory = additionalDirectories.find(d => d.path === selectedValue); + + const directory = additionalDirectories.find( + d => d.path === selectedValue, + ) if (directory && directory.isDeletable) { - onRequestRemoveDirectory(directory.path); + onRequestRemoveDirectory(directory.path) } - }; - $[6] = additionalDirectories; - $[7] = onRequestAddDirectory; - $[8] = onRequestRemoveDirectory; - $[9] = t4; - } else { - t4 = $[9]; - } - const handleDirectorySelect = t4; - let t5; - if ($[10] !== onExit) { - t5 = () => onExit("Workspace dialog dismissed", { - display: "system" - }); - $[10] = onExit; - $[11] = t5; - } else { - t5 = $[11]; - } - const handleCancel = t5; - let opts; - if ($[12] !== additionalDirectories) { - opts = additionalDirectories.map(_temp2); - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: `Add directory${figures.ellipsis}`, - value: "add-directory" - }; - $[14] = t6; - } else { - t6 = $[14]; - } - opts.push(t6); - $[12] = additionalDirectories; - $[13] = opts; - } else { - opts = $[13]; - } - const options = opts; - let t6; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {`- ${getOriginalCwd()}`}(Original working directory); - $[15] = t6; - } else { - t6 = $[15]; - } - const t7 = Math.min(10, options.length); - let t8; - if ($[16] !== focusHeader || $[17] !== handleCancel || $[18] !== handleDirectorySelect || $[19] !== headerFocused || $[20] !== options || $[21] !== t7) { - t8 = {t6} + + ) } diff --git a/src/components/permissions/shellPermissionHelpers.tsx b/src/components/permissions/shellPermissionHelpers.tsx index e7b5ef621..2c7a2db95 100644 --- a/src/components/permissions/shellPermissionHelpers.tsx +++ b/src/components/permissions/shellPermissionHelpers.tsx @@ -1,59 +1,73 @@ -import { basename, sep } from 'path'; -import React, { type ReactNode } from 'react'; -import { getOriginalCwd } from '../../bootstrap/state.js'; -import { Text } from '../../ink.js'; -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; -import { permissionRuleExtractPrefix } from '../../utils/permissions/shellRuleMatching.js'; +import { basename, sep } from 'path' +import React, { type ReactNode } from 'react' +import { getOriginalCwd } from '../../bootstrap/state.js' +import { Text } from '../../ink.js' +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' +import { permissionRuleExtractPrefix } from '../../utils/permissions/shellRuleMatching.js' + function commandListDisplay(commands: string[]): ReactNode { switch (commands.length) { case 0: - return ''; + return '' case 1: - return {commands[0]}; + return {commands[0]} case 2: - return + return ( + {commands[0]} and {commands[1]} - ; + + ) default: - return + return ( + {commands.slice(0, -1).join(', ')}, and{' '} {commands.slice(-1)[0]} - ; + + ) } } + function commandListDisplayTruncated(commands: string[]): ReactNode { // Check if the plain text representation would be too long - const plainText = commands.join(', '); + const plainText = commands.join(', ') if (plainText.length > 50) { - return 'similar'; + return 'similar' } - return commandListDisplay(commands); + return commandListDisplay(commands) } + function formatPathList(paths: string[]): ReactNode { - if (paths.length === 0) return ''; + if (paths.length === 0) return '' // Extract directory names from paths - const names = paths.map(p => basename(p) || p); + const names = paths.map(p => basename(p) || p) + if (names.length === 1) { - return + return ( + {names[0]} {sep} - ; + + ) } if (names.length === 2) { - return + return ( + {names[0]} {sep} and {names[1]} {sep} - ; + + ) } // For 3+, show first two with "and N more" - return + return ( + {names[0]} {sep}, {names[1]} {sep} and {paths.length - 2} more - ; + + ) } /** @@ -62,102 +76,138 @@ function formatPathList(paths: string[]): ReactNode { * and an optional command transform (e.g., Bash strips output redirections so * filenames don't show as commands). */ -export function generateShellSuggestionsLabel(suggestions: PermissionUpdate[], shellToolName: string, commandTransform?: (command: string) => string): ReactNode | null { +export function generateShellSuggestionsLabel( + suggestions: PermissionUpdate[], + shellToolName: string, + commandTransform?: (command: string) => string, +): ReactNode | null { // Collect all rules for display - const allRules = suggestions.filter(s => s.type === 'addRules').flatMap(s => s.rules || []); + const allRules = suggestions + .filter(s => s.type === 'addRules') + .flatMap(s => s.rules || []) // Separate Read rules from shell rules - const readRules = allRules.filter(r => r.toolName === 'Read'); - const shellRules = allRules.filter(r => r.toolName === shellToolName); + const readRules = allRules.filter(r => r.toolName === 'Read') + const shellRules = allRules.filter(r => r.toolName === shellToolName) // Get directory info - const directories = suggestions.filter(s => s.type === 'addDirectories').flatMap(s => s.directories || []); + const directories = suggestions + .filter(s => s.type === 'addDirectories') + .flatMap(s => s.directories || []) // Extract paths from Read rules (keep separate from directories) - const readPaths = readRules.map(r => r.ruleContent?.replace('/**', '') || '').filter(p => p); + const readPaths = readRules + .map(r => r.ruleContent?.replace('/**', '') || '') + .filter(p => p) // Extract shell command prefixes, optionally transforming for display - const shellCommands = [...new Set(shellRules.flatMap(rule => { - if (!rule.ruleContent) return []; - const command = permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent; - return commandTransform ? commandTransform(command) : command; - }))]; + const shellCommands = [ + ...new Set( + shellRules.flatMap(rule => { + if (!rule.ruleContent) return [] + const command = + permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent + return commandTransform ? commandTransform(command) : command + }), + ), + ] // Check what we have - const hasDirectories = directories.length > 0; - const hasReadPaths = readPaths.length > 0; - const hasCommands = shellCommands.length > 0; + const hasDirectories = directories.length > 0 + const hasReadPaths = readPaths.length > 0 + const hasCommands = shellCommands.length > 0 // Handle single type cases if (hasReadPaths && !hasDirectories && !hasCommands) { // Only Read rules - use "reading from" language if (readPaths.length === 1) { - const firstPath = readPaths[0]!; - const dirName = basename(firstPath) || firstPath; - return + const firstPath = readPaths[0]! + const dirName = basename(firstPath) || firstPath + return ( + Yes, allow reading from {dirName} {sep} from this project - ; + + ) } // Multiple read paths - return + return ( + Yes, allow reading from {formatPathList(readPaths)} from this project - ; + + ) } + if (hasDirectories && !hasReadPaths && !hasCommands) { // Only directory permissions - use "access to" language if (directories.length === 1) { - const firstDir = directories[0]!; - const dirName = basename(firstDir) || firstDir; - return + const firstDir = directories[0]! + const dirName = basename(firstDir) || firstDir + return ( + Yes, and always allow access to {dirName} {sep} from this project - ; + + ) } // Multiple directories - return + return ( + Yes, and always allow access to {formatPathList(directories)} from this project - ; + + ) } + if (hasCommands && !hasDirectories && !hasReadPaths) { // Only shell command permissions - return + return ( + {"Yes, and don't ask again for "} {commandListDisplayTruncated(shellCommands)} commands in{' '} {getOriginalCwd()} - ; + + ) } // Handle mixed cases if ((hasDirectories || hasReadPaths) && !hasCommands) { // Combine directories and read paths since they're both path access - const allPaths = [...directories, ...readPaths]; + const allPaths = [...directories, ...readPaths] if (hasDirectories && hasReadPaths) { // Mixed - use generic "access to" - return + return ( + Yes, and always allow access to {formatPathList(allPaths)} from this project - ; + + ) } } + if ((hasDirectories || hasReadPaths) && hasCommands) { // Build descriptive message for both types - const allPaths = [...directories, ...readPaths]; + const allPaths = [...directories, ...readPaths] // Keep it concise but informative if (allPaths.length === 1 && shellCommands.length === 1) { - return + return ( + Yes, and allow access to {formatPathList(allPaths)} and{' '} {commandListDisplayTruncated(shellCommands)} commands - ; + + ) } - return + + return ( + Yes, and allow {formatPathList(allPaths)} access and{' '} {commandListDisplayTruncated(shellCommands)} commands - ; + + ) } - return null; + + return null } diff --git a/src/components/sandbox/SandboxConfigTab.tsx b/src/components/sandbox/SandboxConfigTab.tsx index 50d77344b..58bfba688 100644 --- a/src/components/sandbox/SandboxConfigTab.tsx +++ b/src/components/sandbox/SandboxConfigTab.tsx @@ -1,44 +1,135 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { SandboxManager, shouldAllowManagedSandboxDomainsOnly } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxConfigTab() { - const $ = _c(3); - const isEnabled = SandboxManager.isSandboxingEnabled(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - const depCheck = SandboxManager.checkDependencies(); - t0 = depCheck.warnings.length > 0 ? {depCheck.warnings.map(_temp)} : null; - $[0] = t0; - } else { - t0 = $[0]; - } - const warningsNote = t0; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + SandboxManager, + shouldAllowManagedSandboxDomainsOnly, +} from '../../utils/sandbox/sandbox-adapter.js' + +export function SandboxConfigTab(): React.ReactNode { + const isEnabled = SandboxManager.isSandboxingEnabled() + + // Show warnings (e.g., seccomp not available on Linux) + const depCheck = SandboxManager.checkDependencies() + const warningsNote = + depCheck.warnings.length > 0 ? ( + + {depCheck.warnings.map((w, i) => ( + + {w} + + ))} + + ) : null + if (!isEnabled) { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sandbox is not enabled{warningsNote}; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - const fsReadConfig = SandboxManager.getFsReadConfig(); - const fsWriteConfig = SandboxManager.getFsWriteConfig(); - const networkConfig = SandboxManager.getNetworkRestrictionConfig(); - const allowUnixSockets = SandboxManager.getAllowUnixSockets(); - const excludedCommands = SandboxManager.getExcludedCommands(); - const globPatternWarnings = SandboxManager.getLinuxGlobPatternWarnings(); - t1 = Excluded Commands:{excludedCommands.length > 0 ? excludedCommands.join(", ") : "None"}{fsReadConfig.denyOnly.length > 0 && Filesystem Read Restrictions:Denied: {fsReadConfig.denyOnly.join(", ")}{fsReadConfig.allowWithinDeny && fsReadConfig.allowWithinDeny.length > 0 && Allowed within denied: {fsReadConfig.allowWithinDeny.join(", ")}}}{fsWriteConfig.allowOnly.length > 0 && Filesystem Write Restrictions:Allowed: {fsWriteConfig.allowOnly.join(", ")}{fsWriteConfig.denyWithinAllow.length > 0 && Denied within allowed: {fsWriteConfig.denyWithinAllow.join(", ")}}}{(networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0 || networkConfig.deniedHosts && networkConfig.deniedHosts.length > 0) && Network Restrictions{shouldAllowManagedSandboxDomainsOnly() ? " (Managed)" : ""}:{networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0 && Allowed: {networkConfig.allowedHosts.join(", ")}}{networkConfig.deniedHosts && networkConfig.deniedHosts.length > 0 && Denied: {networkConfig.deniedHosts.join(", ")}}}{allowUnixSockets && allowUnixSockets.length > 0 && Allowed Unix Sockets:{allowUnixSockets.join(", ")}}{globPatternWarnings.length > 0 && ⚠ Warning: Glob patterns not fully supported on LinuxThe following patterns will be ignored:{" "}{globPatternWarnings.slice(0, 3).join(", ")}{globPatternWarnings.length > 3 && ` (${globPatternWarnings.length - 3} more)`}}{warningsNote}; - $[2] = t1; - } else { - t1 = $[2]; + return ( + + Sandbox is not enabled + {warningsNote} + + ) } - return t1; -} -function _temp(w, i) { - return {w}; + + const fsReadConfig = SandboxManager.getFsReadConfig() + const fsWriteConfig = SandboxManager.getFsWriteConfig() + const networkConfig = SandboxManager.getNetworkRestrictionConfig() + const allowUnixSockets = SandboxManager.getAllowUnixSockets() + const excludedCommands = SandboxManager.getExcludedCommands() + const globPatternWarnings = SandboxManager.getLinuxGlobPatternWarnings() + + return ( + + {/* Excluded Commands */} + + + Excluded Commands: + + + {excludedCommands.length > 0 ? excludedCommands.join(', ') : 'None'} + + + + {/* Filesystem Read Restrictions */} + {fsReadConfig.denyOnly.length > 0 && ( + + + Filesystem Read Restrictions: + + Denied: {fsReadConfig.denyOnly.join(', ')} + {fsReadConfig.allowWithinDeny && + fsReadConfig.allowWithinDeny.length > 0 && ( + + Allowed within denied: {fsReadConfig.allowWithinDeny.join(', ')} + + )} + + )} + + {/* Filesystem Write Restrictions */} + {fsWriteConfig.allowOnly.length > 0 && ( + + + Filesystem Write Restrictions: + + Allowed: {fsWriteConfig.allowOnly.join(', ')} + {fsWriteConfig.denyWithinAllow.length > 0 && ( + + Denied within allowed: {fsWriteConfig.denyWithinAllow.join(', ')} + + )} + + )} + + {/* Network Restrictions */} + {((networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0) || + (networkConfig.deniedHosts && + networkConfig.deniedHosts.length > 0)) && ( + + + Network Restrictions + {shouldAllowManagedSandboxDomainsOnly() ? ' (Managed)' : ''}: + + {networkConfig.allowedHosts && + networkConfig.allowedHosts.length > 0 && ( + + Allowed: {networkConfig.allowedHosts.join(', ')} + + )} + {networkConfig.deniedHosts && + networkConfig.deniedHosts.length > 0 && ( + + Denied: {networkConfig.deniedHosts.join(', ')} + + )} + + )} + + {/* Unix Sockets */} + {allowUnixSockets && allowUnixSockets.length > 0 && ( + + + Allowed Unix Sockets: + + {allowUnixSockets.join(', ')} + + )} + + {/* Linux Glob Pattern Warning */} + {globPatternWarnings.length > 0 && ( + + + ⚠ Warning: Glob patterns not fully supported on Linux + + + The following patterns will be ignored:{' '} + {globPatternWarnings.slice(0, 3).join(', ')} + {globPatternWarnings.length > 3 && + ` (${globPatternWarnings.length - 3} more)`} + + + )} + + {warningsNote} + + ) } diff --git a/src/components/sandbox/SandboxDependenciesTab.tsx b/src/components/sandbox/SandboxDependenciesTab.tsx index 53cff39f4..75091910d 100644 --- a/src/components/sandbox/SandboxDependenciesTab.tsx +++ b/src/components/sandbox/SandboxDependenciesTab.tsx @@ -1,119 +1,124 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getPlatform } from '../../utils/platform.js'; -import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { getPlatform } from '../../utils/platform.js' +import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' + type Props = { - depCheck: SandboxDependencyCheck; -}; -export function SandboxDependenciesTab(t0) { - const $ = _c(24); - const { - depCheck - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getPlatform(); - $[0] = t1; - } else { - t1 = $[0]; - } - const platform = t1; - const isMac = platform === "macos"; - let t2; - if ($[1] !== depCheck.errors) { - t2 = depCheck.errors.some(_temp); - $[1] = depCheck.errors; - $[2] = t2; - } else { - t2 = $[2]; - } - const rgMissing = t2; - let t3; - if ($[3] !== depCheck.errors) { - t3 = depCheck.errors.some(_temp2); - $[3] = depCheck.errors; - $[4] = t3; - } else { - t3 = $[4]; - } - const bwrapMissing = t3; - let t4; - if ($[5] !== depCheck.errors) { - t4 = depCheck.errors.some(_temp3); - $[5] = depCheck.errors; - $[6] = t4; - } else { - t4 = $[6]; - } - const socatMissing = t4; - const seccompMissing = depCheck.warnings.length > 0; - let t5; - if ($[7] !== bwrapMissing || $[8] !== depCheck.errors || $[9] !== rgMissing || $[10] !== seccompMissing || $[11] !== socatMissing) { - const otherErrors = depCheck.errors.filter(_temp4); - const rgInstallHint = isMac ? "brew install ripgrep" : "apt install ripgrep"; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = isMac && seatbelt: built-in (macOS); - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - let t8; - if ($[14] !== rgMissing) { - t7 = ripgrep (rg):{" "}{rgMissing ? not found : found}; - t8 = rgMissing && {" "}· {rgInstallHint}; - $[14] = rgMissing; - $[15] = t7; - $[16] = t8; - } else { - t7 = $[15]; - t8 = $[16]; - } - let t9; - if ($[17] !== t7 || $[18] !== t8) { - t9 = {t7}{t8}; - $[17] = t7; - $[18] = t8; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== bwrapMissing || $[21] !== seccompMissing || $[22] !== socatMissing) { - t10 = !isMac && <>bubblewrap (bwrap):{" "}{bwrapMissing ? not installed : installed}{bwrapMissing && {" "}· apt install bubblewrap}socat:{" "}{socatMissing ? not installed : installed}{socatMissing && {" "}· apt install socat}seccomp filter:{" "}{seccompMissing ? not installed : installed}{seccompMissing && (required to block unix domain sockets)}{seccompMissing && {" "}· npm install -g @anthropic-ai/sandbox-runtime{" "}· or copy vendor/seccomp/* from sandbox-runtime and set{" "}sandbox.seccomp.bpfPath and applyPath in settings.json}; - $[20] = bwrapMissing; - $[21] = seccompMissing; - $[22] = socatMissing; - $[23] = t10; - } else { - t10 = $[23]; - } - t5 = {t6}{t9}{t10}{otherErrors.map(_temp5)}; - $[7] = bwrapMissing; - $[8] = depCheck.errors; - $[9] = rgMissing; - $[10] = seccompMissing; - $[11] = socatMissing; - $[12] = t5; - } else { - t5 = $[12]; - } - return t5; + depCheck: SandboxDependencyCheck } -function _temp5(err) { - return {err}; -} -function _temp4(e_2) { - return !e_2.includes("ripgrep") && !e_2.includes("bwrap") && !e_2.includes("socat"); -} -function _temp3(e_1) { - return e_1.includes("socat"); -} -function _temp2(e_0) { - return e_0.includes("bwrap"); -} -function _temp(e) { - return e.includes("ripgrep"); + +export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { + const platform = getPlatform() + const isMac = platform === 'macos' + + // ripgrep is required on all platforms (used to scan for dangerous dirs). + // On macOS, seatbelt is built into the OS — ripgrep is the only runtime dep. + // On Linux/WSL, bwrap + socat are required, seccomp is optional. + // + // #31804: previously this tab unconditionally rendered Linux deps (bwrap, + // socat, seccomp). When ripgrep was missing on macOS, users saw confusing + // Linux install instructions and no mention of the actual problem. + const rgMissing = depCheck.errors.some(e => e.includes('ripgrep')) + const bwrapMissing = depCheck.errors.some(e => e.includes('bwrap')) + const socatMissing = depCheck.errors.some(e => e.includes('socat')) + const seccompMissing = depCheck.warnings.length > 0 + + // Any errors we don't have a dedicated row for — render verbatim so they + // aren't silently swallowed (e.g. "Unsupported platform" or future deps). + const otherErrors = depCheck.errors.filter( + e => !e.includes('ripgrep') && !e.includes('bwrap') && !e.includes('socat'), + ) + + const rgInstallHint = isMac ? 'brew install ripgrep' : 'apt install ripgrep' + + return ( + + {isMac && ( + + + seatbelt: built-in (macOS) + + + )} + + + + ripgrep (rg):{' '} + {rgMissing ? ( + not found + ) : ( + found + )} + + {rgMissing && ( + + {' '}· {rgInstallHint} + + )} + + + {!isMac && ( + <> + + + bubblewrap (bwrap):{' '} + {bwrapMissing ? ( + not installed + ) : ( + installed + )} + + {bwrapMissing && ( + {' '}· apt install bubblewrap + )} + + + + + socat:{' '} + {socatMissing ? ( + not installed + ) : ( + installed + )} + + {socatMissing && {' '}· apt install socat} + + + + + seccomp filter:{' '} + {seccompMissing ? ( + not installed + ) : ( + installed + )} + {seccompMissing && ( + (required to block unix domain sockets) + )} + + {seccompMissing && ( + + + {' '}· npm install -g @anthropic-ai/sandbox-runtime + + + {' '}· or copy vendor/seccomp/* from sandbox-runtime and set + + + {' '}sandbox.seccomp.bpfPath and applyPath in settings.json + + + )} + + + )} + + {otherErrors.map(err => ( + + {err} + + ))} + + ) } diff --git a/src/components/sandbox/SandboxDoctorSection.tsx b/src/components/sandbox/SandboxDoctorSection.tsx index 747369108..5e7198c38 100644 --- a/src/components/sandbox/SandboxDoctorSection.tsx +++ b/src/components/sandbox/SandboxDoctorSection.tsx @@ -1,45 +1,48 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxDoctorSection() { - const $ = _c(2); +import React from 'react' +import { Box, Text } from '../../ink.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' + +export function SandboxDoctorSection(): React.ReactNode { if (!SandboxManager.isSupportedPlatform()) { - return null; + return null } + if (!SandboxManager.isSandboxEnabledInSettings()) { - return null; + return null } - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const depCheck = SandboxManager.checkDependencies(); - const hasErrors = depCheck.errors.length > 0; - const hasWarnings = depCheck.warnings.length > 0; - if (!hasErrors && !hasWarnings) { - t1 = null; - break bb0; - } - const statusColor = hasErrors ? "error" as const : "warning" as const; - const statusText = hasErrors ? "Missing dependencies" : "Available (with warnings)"; - t0 = Sandbox└ Status: {statusText}{depCheck.errors.map(_temp)}{depCheck.warnings.map(_temp2)}{hasErrors && └ Run /sandbox for install instructions}; - } - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; + + const depCheck = SandboxManager.checkDependencies() + const hasErrors = depCheck.errors.length > 0 + const hasWarnings = depCheck.warnings.length > 0 + + if (!hasErrors && !hasWarnings) { + return null } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - return t0; -} -function _temp2(w, i_0) { - return └ {w}; -} -function _temp(e, i) { - return └ {e}; + + const statusColor = hasErrors ? ('error' as const) : ('warning' as const) + const statusText = hasErrors + ? 'Missing dependencies' + : 'Available (with warnings)' + + return ( + + Sandbox + + └ Status: {statusText} + + {depCheck.errors.map((e, i) => ( + + └ {e} + + ))} + {depCheck.warnings.map((w, i) => ( + + └ {w} + + ))} + {hasErrors && ( + └ Run /sandbox for install instructions + )} + + ) } diff --git a/src/components/sandbox/SandboxOverridesTab.tsx b/src/components/sandbox/SandboxOverridesTab.tsx index c13eb0a8e..74c6d224b 100644 --- a/src/components/sandbox/SandboxOverridesTab.tsx +++ b/src/components/sandbox/SandboxOverridesTab.tsx @@ -1,192 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import type { CommandResultDisplay } from '../../types/command.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { Select } from '../CustomSelect/select.js'; -import { useTabHeaderFocus } from '../design-system/Tabs.js'; +import React from 'react' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import type { CommandResultDisplay } from '../../types/command.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { Select } from '../CustomSelect/select.js' +import { useTabHeaderFocus } from '../design-system/Tabs.js' + type Props = { - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -type OverrideMode = 'open' | 'closed'; -export function SandboxOverridesTab(t0) { - const $ = _c(5); - const { - onComplete - } = t0; - const isEnabled = SandboxManager.isSandboxingEnabled(); - const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy(); - const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed(); + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type OverrideMode = 'open' | 'closed' + +export function SandboxOverridesTab({ onComplete }: Props): React.ReactNode { + const isEnabled = SandboxManager.isSandboxingEnabled() + const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy() + const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed() + if (!isEnabled) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sandbox is not enabled. Enable sandbox to configure override settings.; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + return ( + + + Sandbox is not enabled. Enable sandbox to configure override settings. + + + ) } + if (isLocked) { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Override settings are managed by a higher-priority configuration and cannot be changed locally.; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {t1}Current setting:{" "}{currentAllowUnsandboxed ? "Allow unsandboxed fallback" : "Strict sandbox mode"}; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; - } - let t1; - if ($[3] !== onComplete) { - t1 = ; - $[3] = onComplete; - $[4] = t1; - } else { - t1 = $[4]; + return ( + + + Override settings are managed by a higher-priority configuration and + cannot be changed locally. + + + + Current setting:{' '} + {currentAllowUnsandboxed + ? 'Allow unsandboxed fallback' + : 'Strict sandbox mode'} + + + + ) } - return t1; + + return ( + + ) } // Split so useTabHeaderFocus() only runs when the Select renders. Calling it // above the early returns registers a down-arrow opt-in even when we return // static text — pressing ↓ then blurs the header with no way back. -function OverridesSelect(t0) { - const $ = _c(25); - const { - onComplete, - currentMode - } = t0; - const [theme] = useTheme(); - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - if ($[0] !== theme) { - t1 = color("success", theme)("(current)"); - $[0] = theme; - $[1] = t1; - } else { - t1 = $[1]; - } - const currentIndicator = t1; - const t2 = currentMode === "open" ? `Allow unsandboxed fallback ${currentIndicator}` : "Allow unsandboxed fallback"; - let t3; - if ($[2] !== t2) { - t3 = { - label: t2, - value: "open" - }; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const t4 = currentMode === "closed" ? `Strict sandbox mode ${currentIndicator}` : "Strict sandbox mode"; - let t5; - if ($[4] !== t4) { - t5 = { - label: t4, - value: "closed" - }; - $[4] = t4; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== t3 || $[7] !== t5) { - t6 = [t3, t5]; - $[6] = t3; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - const options = t6; - let t7; - if ($[9] !== onComplete) { - t7 = async function handleSelect(value) { - const mode = value as OverrideMode; - await SandboxManager.setSandboxSettings({ - allowUnsandboxedCommands: mode === "open" - }); - const message = mode === "open" ? "\u2713 Unsandboxed fallback allowed - commands can run outside sandbox when necessary" : "\u2713 Strict sandbox mode - all commands must run in sandbox or be excluded via the `excludedCommands` option"; - onComplete(message); - }; - $[9] = onComplete; - $[10] = t7; - } else { - t7 = $[10]; - } - const handleSelect = t7; - let t8; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Configure Overrides:; - $[11] = t8; - } else { - t8 = $[11]; - } - let t9; - if ($[12] !== onComplete) { - t9 = () => onComplete(undefined, { - display: "skip" - }); - $[12] = onComplete; - $[13] = t9; - } else { - t9 = $[13]; - } - let t10; - if ($[14] !== focusHeader || $[15] !== handleSelect || $[16] !== headerFocused || $[17] !== options || $[18] !== t9) { - t10 = onComplete(undefined, { display: 'skip' })} + onUpFromFirstItem={focusHeader} + isDisabled={headerFocused} + /> + + + + Allow unsandboxed fallback: + {' '} + When a command fails due to sandbox restrictions, Claude can retry + with dangerouslyDisableSandbox to run outside the sandbox (falling + back to default permissions). + + + + Strict sandbox mode: + {' '} + All bash commands invoked by the model must run in the sandbox unless + they are explicitly listed in excludedCommands. + + + Learn more:{' '} + + code.claude.com/docs/en/sandboxing#configure-sandboxing + + + + + ) } diff --git a/src/components/sandbox/SandboxSettings.tsx b/src/components/sandbox/SandboxSettings.tsx index b8c403efb..05998577b 100644 --- a/src/components/sandbox/SandboxSettings.tsx +++ b/src/components/sandbox/SandboxSettings.tsx @@ -1,295 +1,211 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { CommandResultDisplay } from '../../types/command.js'; -import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'; -import { Select } from '../CustomSelect/select.js'; -import { Pane } from '../design-system/Pane.js'; -import { Tab, Tabs, useTabHeaderFocus } from '../design-system/Tabs.js'; -import { SandboxConfigTab } from './SandboxConfigTab.js'; -import { SandboxDependenciesTab } from './SandboxDependenciesTab.js'; -import { SandboxOverridesTab } from './SandboxOverridesTab.js'; +import React from 'react' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { CommandResultDisplay } from '../../types/command.js' +import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' +import { Select } from '../CustomSelect/select.js' +import { Pane } from '../design-system/Pane.js' +import { Tab, Tabs, useTabHeaderFocus } from '../design-system/Tabs.js' +import { SandboxConfigTab } from './SandboxConfigTab.js' +import { SandboxDependenciesTab } from './SandboxDependenciesTab.js' +import { SandboxOverridesTab } from './SandboxOverridesTab.js' + type Props = { - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - depCheck: SandboxDependencyCheck; -}; -type SandboxMode = 'auto-allow' | 'regular' | 'disabled'; -export function SandboxSettings(t0) { - const $ = _c(34); - const { - onComplete, - depCheck - } = t0; - const [theme] = useTheme(); - const currentEnabled = SandboxManager.isSandboxingEnabled(); - const currentAutoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const hasWarnings = depCheck.warnings.length > 0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getSettings_DEPRECATED(); - $[0] = t1; - } else { - t1 = $[0]; - } - const settings = t1; - const allowAllUnixSockets = settings.sandbox?.network?.allowAllUnixSockets; - const showSocketWarning = hasWarnings && !allowAllUnixSockets; - const getCurrentMode = () => { - if (!currentEnabled) { - return "disabled"; - } - if (currentAutoAllow) { - return "auto-allow"; + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + depCheck: SandboxDependencyCheck +} + +type SandboxMode = 'auto-allow' | 'regular' | 'disabled' + +export function SandboxSettings({ + onComplete, + depCheck, +}: Props): React.ReactNode { + const [theme] = useTheme() + const currentEnabled = SandboxManager.isSandboxingEnabled() + const currentAutoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled() + const hasWarnings = depCheck.warnings.length > 0 + const settings = getSettings_DEPRECATED() + const allowAllUnixSockets = settings.sandbox?.network?.allowAllUnixSockets + // Show warning if seccomp missing AND user hasn't allowed all unix sockets + const showSocketWarning = hasWarnings && !allowAllUnixSockets + + // Determine current mode + const getCurrentMode = (): SandboxMode => { + if (!currentEnabled) return 'disabled' + if (currentAutoAllow) return 'auto-allow' + return 'regular' + } + + const currentMode = getCurrentMode() + const currentIndicator = color('success', theme)(`(current)`) + + const options = [ + { + label: + currentMode === 'auto-allow' + ? `Sandbox BashTool, with auto-allow ${currentIndicator}` + : 'Sandbox BashTool, with auto-allow', + value: 'auto-allow', + }, + { + label: + currentMode === 'regular' + ? `Sandbox BashTool, with regular permissions ${currentIndicator}` + : 'Sandbox BashTool, with regular permissions', + value: 'regular', + }, + { + label: + currentMode === 'disabled' + ? `No Sandbox ${currentIndicator}` + : 'No Sandbox', + value: 'disabled', + }, + ] + + async function handleSelect(value: string) { + const mode = value as SandboxMode + + switch (mode) { + case 'auto-allow': + await SandboxManager.setSandboxSettings({ + enabled: true, + autoAllowBashIfSandboxed: true, + }) + onComplete('✓ Sandbox enabled with auto-allow for bash commands') + break + case 'regular': + await SandboxManager.setSandboxSettings({ + enabled: true, + autoAllowBashIfSandboxed: false, + }) + onComplete('✓ Sandbox enabled with regular bash permissions') + break + case 'disabled': + await SandboxManager.setSandboxSettings({ + enabled: false, + autoAllowBashIfSandboxed: false, + }) + onComplete('○ Sandbox disabled') + break } - return "regular"; - }; - const currentMode = getCurrentMode(); - let t2; - if ($[1] !== theme) { - t2 = color("success", theme)("(current)"); - $[1] = theme; - $[2] = t2; - } else { - t2 = $[2]; - } - const currentIndicator = t2; - const t3 = currentMode === "auto-allow" ? `Sandbox BashTool, with auto-allow ${currentIndicator}` : "Sandbox BashTool, with auto-allow"; - let t4; - if ($[3] !== t3) { - t4 = { - label: t3, - value: "auto-allow" - }; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - const t5 = currentMode === "regular" ? `Sandbox BashTool, with regular permissions ${currentIndicator}` : "Sandbox BashTool, with regular permissions"; - let t6; - if ($[5] !== t5) { - t6 = { - label: t5, - value: "regular" - }; - $[5] = t5; - $[6] = t6; - } else { - t6 = $[6]; - } - const t7 = currentMode === "disabled" ? `No Sandbox ${currentIndicator}` : "No Sandbox"; - let t8; - if ($[7] !== t7) { - t8 = { - label: t7, - value: "disabled" - }; - $[7] = t7; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] !== t4 || $[10] !== t6 || $[11] !== t8) { - t9 = [t4, t6, t8]; - $[9] = t4; - $[10] = t6; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - const options = t9; - let t10; - if ($[13] !== onComplete) { - t10 = async function handleSelect(value) { - const mode = value as SandboxMode; - bb33: switch (mode) { - case "auto-allow": - { - await SandboxManager.setSandboxSettings({ - enabled: true, - autoAllowBashIfSandboxed: true - }); - onComplete("\u2713 Sandbox enabled with auto-allow for bash commands"); - break bb33; - } - case "regular": - { - await SandboxManager.setSandboxSettings({ - enabled: true, - autoAllowBashIfSandboxed: false - }); - onComplete("\u2713 Sandbox enabled with regular bash permissions"); - break bb33; - } - case "disabled": - { - await SandboxManager.setSandboxSettings({ - enabled: false, - autoAllowBashIfSandboxed: false - }); - onComplete("\u25CB Sandbox disabled"); - } - } - }; - $[13] = onComplete; - $[14] = t10; - } else { - t10 = $[14]; - } - const handleSelect = t10; - let t11; - if ($[15] !== onComplete) { - t11 = { - "confirm:no": () => onComplete(undefined, { - display: "skip" - }) - }; - $[15] = onComplete; - $[16] = t11; - } else { - t11 = $[16]; - } - let t12; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t12 = { - context: "Settings" - }; - $[17] = t12; - } else { - t12 = $[17]; - } - useKeybindings(t11, t12); - let t13; - if ($[18] !== handleSelect || $[19] !== onComplete || $[20] !== options || $[21] !== showSocketWarning) { - t13 = ; - $[18] = handleSelect; - $[19] = onComplete; - $[20] = options; - $[21] = showSocketWarning; - $[22] = t13; - } else { - t13 = $[22]; } - const modeTab = t13; - let t14; - if ($[23] !== onComplete) { - t14 = ; - $[23] = onComplete; - $[24] = t14; - } else { - t14 = $[24]; - } - const overridesTab = t14; - let t15; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t15 = ; - $[25] = t15; - } else { - t15 = $[25]; - } - const configTab = t15; - const hasErrors = depCheck.errors.length > 0; - let t16; - if ($[26] !== depCheck || $[27] !== hasErrors || $[28] !== hasWarnings || $[29] !== modeTab || $[30] !== overridesTab) { - t16 = hasErrors ? [] : [modeTab, ...(hasWarnings ? [] : []), overridesTab, configTab]; - $[26] = depCheck; - $[27] = hasErrors; - $[28] = hasWarnings; - $[29] = modeTab; - $[30] = overridesTab; - $[31] = t16; - } else { - t16 = $[31]; - } - const tabs = t16; - let t17; - if ($[32] !== tabs) { - t17 = {tabs}; - $[32] = tabs; - $[33] = t17; - } else { - t17 = $[33]; - } - return t17; + + useKeybindings( + { + 'confirm:no': () => onComplete(undefined, { display: 'skip' }), + }, + { context: 'Settings' }, + ) + + const modeTab = ( + + + + ) + + const overridesTab = ( + + + + ) + + const configTab = ( + + + + ) + + const hasErrors = depCheck.errors.length > 0 + + // If required deps missing, only show Dependencies tab + // If only optional deps missing, show all tabs + const tabs = hasErrors + ? [ + + + , + ] + : [ + modeTab, + ...(hasWarnings + ? [ + + + , + ] + : []), + overridesTab, + configTab, + ] + + return ( + + + {tabs} + + + ) } -function SandboxModeTab(t0) { - const $ = _c(16); - const { - showSocketWarning, - options, - onSelect, - onComplete - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - if ($[0] !== showSocketWarning) { - t1 = showSocketWarning && Cannot block unix domain sockets (see Dependencies tab); - $[0] = showSocketWarning; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Configure Mode:; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onComplete) { - t3 = () => onComplete(undefined, { - display: "skip" - }); - $[3] = onComplete; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== focusHeader || $[6] !== headerFocused || $[7] !== onSelect || $[8] !== options || $[9] !== t3) { - t4 = onComplete(undefined, { display: 'skip' })} + onUpFromFirstItem={focusHeader} + isDisabled={headerFocused} + /> + + + + Auto-allow mode: + {' '} + Commands will try to run in the sandbox automatically, and attempts to + run outside of the sandbox fallback to regular permissions. Explicit + ask/deny rules are always respected. + + + Learn more:{' '} + + code.claude.com/docs/en/sandboxing + + + + + ) } diff --git a/src/components/shell/ExpandShellOutputContext.tsx b/src/components/shell/ExpandShellOutputContext.tsx index 271d9f313..cc6628b64 100644 --- a/src/components/shell/ExpandShellOutputContext.tsx +++ b/src/components/shell/ExpandShellOutputContext.tsx @@ -1,6 +1,5 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useContext } from 'react'; +import * as React from 'react' +import { useContext } from 'react' /** * Context to indicate that shell output should be shown in full (not truncated). @@ -9,27 +8,24 @@ import { useContext } from 'react'; * This follows the same pattern as MessageResponseContext and SubAgentContext - * a boolean context that child components can check to modify their behavior. */ -const ExpandShellOutputContext = React.createContext(false); -export function ExpandShellOutputProvider(t0) { - const $ = _c(2); - const { - children - } = t0; - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const ExpandShellOutputContext = React.createContext(false) + +export function ExpandShellOutputProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + + {children} + + ) } /** * Returns true if this component is rendered inside an ExpandShellOutputProvider, * indicating the shell output should be shown in full rather than truncated. */ -export function useExpandShellOutput() { - return useContext(ExpandShellOutputContext); +export function useExpandShellOutput(): boolean { + return useContext(ExpandShellOutputContext) } diff --git a/src/components/shell/OutputLine.tsx b/src/components/shell/OutputLine.tsx index 16832239d..cf72760db 100644 --- a/src/components/shell/OutputLine.tsx +++ b/src/components/shell/OutputLine.tsx @@ -1,106 +1,98 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useMemo } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Ansi, Text } from '../../ink.js'; -import { createHyperlink } from '../../utils/hyperlink.js'; -import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'; -import { renderTruncatedContent } from '../../utils/terminal.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { InVirtualListContext } from '../messageActions.js'; -import { useExpandShellOutput } from './ExpandShellOutputContext.js'; +import * as React from 'react' +import { useMemo } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Ansi, Text } from '../../ink.js' +import { createHyperlink } from '../../utils/hyperlink.js' +import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' +import { renderTruncatedContent } from '../../utils/terminal.js' +import { MessageResponse } from '../MessageResponse.js' +import { InVirtualListContext } from '../messageActions.js' +import { useExpandShellOutput } from './ExpandShellOutputContext.js' + export function tryFormatJson(line: string): string { try { - const parsed = jsonParse(line); - const stringified = jsonStringify(parsed); + const parsed = jsonParse(line) + const stringified = jsonStringify(parsed) // Check if precision was lost during JSON round-trip // This happens when large integers exceed Number.MAX_SAFE_INTEGER // We normalize both strings by removing whitespace and unnecessary // escapes (\/ is valid but optional in JSON) for comparison - const normalizedOriginal = line.replace(/\\\//g, '/').replace(/\s+/g, ''); - const normalizedStringified = stringified.replace(/\s+/g, ''); + const normalizedOriginal = line.replace(/\\\//g, '/').replace(/\s+/g, '') + const normalizedStringified = stringified.replace(/\s+/g, '') + if (normalizedOriginal !== normalizedStringified) { // Precision loss detected - return original line unformatted - return line; + return line } - return jsonStringify(parsed, null, 2); + + return jsonStringify(parsed, null, 2) } catch { - return line; + return line } } -const MAX_JSON_FORMAT_LENGTH = 10_000; + +const MAX_JSON_FORMAT_LENGTH = 10_000 + export function tryJsonFormatContent(content: string): string { if (content.length > MAX_JSON_FORMAT_LENGTH) { - return content; + return content } - const allLines = content.split('\n'); - return allLines.map(tryFormatJson).join('\n'); + const allLines = content.split('\n') + return allLines.map(tryFormatJson).join('\n') } // Match http(s) URLs inside JSON string values. Conservative: no quotes, // no whitespace, no trailing comma/brace that'd be JSON structure. -const URL_IN_JSON = /https?:\/\/[^\s"'<>\\]+/g; +const URL_IN_JSON = /https?:\/\/[^\s"'<>\\]+/g + export function linkifyUrlsInText(content: string): string { - return content.replace(URL_IN_JSON, url => createHyperlink(url)); + return content.replace(URL_IN_JSON, url => createHyperlink(url)) } -export function OutputLine(t0) { - const $ = _c(11); - const { - content, - verbose, - isError, - isWarning, - linkifyUrls - } = t0; - const { - columns - } = useTerminalSize(); - const expandShellOutput = useExpandShellOutput(); - const inVirtualList = React.useContext(InVirtualListContext); - const shouldShowFull = verbose || expandShellOutput; - let t1; - if ($[0] !== columns || $[1] !== content || $[2] !== inVirtualList || $[3] !== linkifyUrls || $[4] !== shouldShowFull) { - bb0: { - let formatted = tryJsonFormatContent(content); - if (linkifyUrls) { - formatted = linkifyUrlsInText(formatted); - } - if (shouldShowFull) { - t1 = stripUnderlineAnsi(formatted); - break bb0; - } - t1 = stripUnderlineAnsi(renderTruncatedContent(formatted, columns, inVirtualList)); + +export function OutputLine({ + content, + verbose, + isError, + isWarning, + linkifyUrls, +}: { + content: string + verbose: boolean + isError?: boolean + isWarning?: boolean + linkifyUrls?: boolean +}): React.ReactNode { + const { columns } = useTerminalSize() + // Context-based expansion for latest user shell output (from ! commands) + const expandShellOutput = useExpandShellOutput() + const inVirtualList = React.useContext(InVirtualListContext) + + // Show full output if verbose mode OR if this is the latest user shell output + const shouldShowFull = verbose || expandShellOutput + + const formattedContent = useMemo(() => { + let formatted = tryJsonFormatContent(content) + if (linkifyUrls) { + formatted = linkifyUrlsInText(formatted) } - $[0] = columns; - $[1] = content; - $[2] = inVirtualList; - $[3] = linkifyUrls; - $[4] = shouldShowFull; - $[5] = t1; - } else { - t1 = $[5]; - } - const formattedContent = t1; - const color = isError ? "error" : isWarning ? "warning" : undefined; - let t2; - if ($[6] !== formattedContent) { - t2 = {formattedContent}; - $[6] = formattedContent; - $[7] = t2; - } else { - t2 = $[7]; - } - let t3; - if ($[8] !== color || $[9] !== t2) { - t3 = {t2}; - $[8] = color; - $[9] = t2; - $[10] = t3; - } else { - t3 = $[10]; - } - return t3; + if (shouldShowFull) { + return stripUnderlineAnsi(formatted) + } + return stripUnderlineAnsi( + renderTruncatedContent(formatted, columns, inVirtualList), + ) + }, [content, shouldShowFull, columns, linkifyUrls, inVirtualList]) + + const color = isError ? 'error' : isWarning ? 'warning' : undefined + + return ( + + + {formattedContent} + + + ) } /** @@ -112,6 +104,8 @@ export function OutputLine(t0) { */ export function stripUnderlineAnsi(content: string): string { return content.replace( - // eslint-disable-next-line no-control-regex - /\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g, ''); + // eslint-disable-next-line no-control-regex + /\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g, + '', + ) } diff --git a/src/components/shell/ShellProgressMessage.tsx b/src/components/shell/ShellProgressMessage.tsx index 45d07ff56..99da5ac3b 100644 --- a/src/components/shell/ShellProgressMessage.tsx +++ b/src/components/shell/ShellProgressMessage.tsx @@ -1,149 +1,87 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import stripAnsi from 'strip-ansi'; -import { Box, Text } from '../../ink.js'; -import { formatFileSize } from '../../utils/format.js'; -import { MessageResponse } from '../MessageResponse.js'; -import { OffscreenFreeze } from '../OffscreenFreeze.js'; -import { ShellTimeDisplay } from './ShellTimeDisplay.js'; +import React from 'react' +import stripAnsi from 'strip-ansi' +import { Box, Text } from '../../ink.js' +import { formatFileSize } from '../../utils/format.js' +import { MessageResponse } from '../MessageResponse.js' +import { OffscreenFreeze } from '../OffscreenFreeze.js' +import { ShellTimeDisplay } from './ShellTimeDisplay.js' + type Props = { - output: string; - fullOutput: string; - elapsedTimeSeconds?: number; - totalLines?: number; - totalBytes?: number; - timeoutMs?: number; - taskId?: string; - verbose: boolean; -}; -export function ShellProgressMessage(t0) { - const $ = _c(30); - const { - output, - fullOutput, - elapsedTimeSeconds, - totalLines, - totalBytes, - timeoutMs, - verbose - } = t0; - let t1; - if ($[0] !== fullOutput) { - t1 = stripAnsi(fullOutput.trim()); - $[0] = fullOutput; - $[1] = t1; - } else { - t1 = $[1]; - } - const strippedFullOutput = t1; - let lines; - let t2; - if ($[2] !== output || $[3] !== strippedFullOutput || $[4] !== verbose) { - const strippedOutput = stripAnsi(output.trim()); - lines = strippedOutput.split("\n").filter(_temp); - t2 = verbose ? strippedFullOutput : lines.slice(-5).join("\n"); - $[2] = output; - $[3] = strippedFullOutput; - $[4] = verbose; - $[5] = lines; - $[6] = t2; - } else { - lines = $[5]; - t2 = $[6]; - } - const displayLines = t2; + output: string + fullOutput: string + elapsedTimeSeconds?: number + totalLines?: number + totalBytes?: number + timeoutMs?: number + taskId?: string + verbose: boolean +} + +export function ShellProgressMessage({ + output, + fullOutput, + elapsedTimeSeconds, + totalLines, + totalBytes, + timeoutMs, + verbose, +}: Props): React.ReactNode { + const strippedFullOutput = stripAnsi(fullOutput.trim()) + const strippedOutput = stripAnsi(output.trim()) + const lines = strippedOutput.split('\n').filter(line => line) + const displayLines = verbose ? strippedFullOutput : lines.slice(-5).join('\n') + + // OffscreenFreeze: BashTool yields progress (elapsedTimeSeconds) every second. + // If this line scrolls into scrollback, each tick forces a full terminal reset. + // A foreground `sleep 600` on a 29-row terminal with 4000 rows of history + // produced 507 resets over 10 minutes (go/ccshare/maxk-20260226-190348). if (!lines.length) { - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Running… ; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== elapsedTimeSeconds || $[9] !== timeoutMs) { - t4 = {t3}; - $[8] = elapsedTimeSeconds; - $[9] = timeoutMs; - $[10] = t4; - } else { - t4 = $[10]; - } - return t4; + return ( + + + Running… + + + + ) } - const extraLines = totalLines ? Math.max(0, totalLines - 5) : 0; - let lineStatus = ""; + + // Not truncated: "+2 lines" (total exceeds displayed 5) + // Truncated: "~2000 lines" (extrapolated estimate from tail sample) + const extraLines = totalLines ? Math.max(0, totalLines - 5) : 0 + let lineStatus = '' if (!verbose && totalBytes && totalLines) { - lineStatus = `~${totalLines} lines`; - } else { - if (!verbose && extraLines > 0) { - lineStatus = `+${extraLines} lines`; - } - } - const t3 = verbose ? undefined : Math.min(5, lines.length); - let t4; - if ($[11] !== displayLines) { - t4 = {displayLines}; - $[11] = displayLines; - $[12] = t4; - } else { - t4 = $[12]; - } - let t5; - if ($[13] !== t3 || $[14] !== t4) { - t5 = {t4}; - $[13] = t3; - $[14] = t4; - $[15] = t5; - } else { - t5 = $[15]; - } - let t6; - if ($[16] !== lineStatus) { - t6 = lineStatus ? {lineStatus} : null; - $[16] = lineStatus; - $[17] = t6; - } else { - t6 = $[17]; + lineStatus = `~${totalLines} lines` + } else if (!verbose && extraLines > 0) { + lineStatus = `+${extraLines} lines` } - let t7; - if ($[18] !== elapsedTimeSeconds || $[19] !== timeoutMs) { - t7 = ; - $[18] = elapsedTimeSeconds; - $[19] = timeoutMs; - $[20] = t7; - } else { - t7 = $[20]; - } - let t8; - if ($[21] !== totalBytes) { - t8 = totalBytes ? {formatFileSize(totalBytes)} : null; - $[21] = totalBytes; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== t6 || $[24] !== t7 || $[25] !== t8) { - t9 = {t6}{t7}{t8}; - $[23] = t6; - $[24] = t7; - $[25] = t8; - $[26] = t9; - } else { - t9 = $[26]; - } - let t10; - if ($[27] !== t5 || $[28] !== t9) { - t10 = {t5}{t9}; - $[27] = t5; - $[28] = t9; - $[29] = t10; - } else { - t10 = $[29]; - } - return t10; -} -function _temp(line) { - return line; + + return ( + + + + + {displayLines} + + + {lineStatus ? {lineStatus} : null} + + {totalBytes ? ( + {formatFileSize(totalBytes)} + ) : null} + + + + + ) } diff --git a/src/components/shell/ShellTimeDisplay.tsx b/src/components/shell/ShellTimeDisplay.tsx index 6830a3af7..7e619dfba 100644 --- a/src/components/shell/ShellTimeDisplay.tsx +++ b/src/components/shell/ShellTimeDisplay.tsx @@ -1,73 +1,28 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import { formatDuration } from '../../utils/format.js'; +import React from 'react' +import { Text } from '../../ink.js' +import { formatDuration } from '../../utils/format.js' + type Props = { - elapsedTimeSeconds?: number; - timeoutMs?: number; -}; -export function ShellTimeDisplay(t0) { - const $ = _c(10); - const { - elapsedTimeSeconds, - timeoutMs - } = t0; + elapsedTimeSeconds?: number + timeoutMs?: number +} + +export function ShellTimeDisplay({ + elapsedTimeSeconds, + timeoutMs, +}: Props): React.ReactNode { if (elapsedTimeSeconds === undefined && !timeoutMs) { - return null; - } - let t1; - if ($[0] !== timeoutMs) { - t1 = timeoutMs ? formatDuration(timeoutMs, { - hideTrailingZeros: true - }) : undefined; - $[0] = timeoutMs; - $[1] = t1; - } else { - t1 = $[1]; + return null } - const timeout = t1; + const timeout = timeoutMs + ? formatDuration(timeoutMs, { hideTrailingZeros: true }) + : undefined if (elapsedTimeSeconds === undefined) { - const t2 = `(timeout ${timeout})`; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; + return {`(timeout ${timeout})`} } - const t2 = elapsedTimeSeconds * 1000; - let t3; - if ($[4] !== t2) { - t3 = formatDuration(t2); - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const elapsed = t3; + const elapsed = formatDuration(elapsedTimeSeconds * 1000) if (timeout) { - const t4 = `(${elapsed} · timeout ${timeout})`; - let t5; - if ($[6] !== t4) { - t5 = {t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; - } - const t4 = `(${elapsed})`; - let t5; - if ($[8] !== t4) { - t5 = {t4}; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; + return {`(${elapsed} · timeout ${timeout})`} } - return t5; + return {`(${elapsed})`} } From 9ba95d209e22a651d53669f0f2e6d31e03da49f9 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 4 Apr 2026 22:50:05 +0800 Subject: [PATCH 4/9] =?UTF-8?q?style(B1-4):=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=20components/PromptInput,FeedbackSurvey,tasks,agents,skills,de?= =?UTF-8?q?sign-system,wizard=20(73=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 --- .../FeedbackSurvey/FeedbackSurvey.tsx | 308 +- .../FeedbackSurvey/FeedbackSurveyView.tsx | 163 +- .../FeedbackSurvey/TranscriptSharePrompt.tsx | 149 +- .../FeedbackSurvey/useFeedbackSurvey.tsx | 557 ++- .../FeedbackSurvey/useMemorySurvey.tsx | 369 +- .../FeedbackSurvey/usePostCompactSurvey.tsx | 376 +- .../FeedbackSurvey/useSurveyState.tsx | 211 +- .../PromptInput/HistorySearchInput.tsx | 84 +- .../PromptInput/IssueFlagBanner.tsx | 27 +- src/components/PromptInput/Notifications.tsx | 571 +-- src/components/PromptInput/PromptInput.tsx | 3451 ++++++++++------- .../PromptInput/PromptInputFooter.tsx | 309 +- .../PromptInput/PromptInputFooterLeftSide.tsx | 898 +++-- .../PromptInputFooterSuggestions.tsx | 476 ++- .../PromptInput/PromptInputHelpMenu.tsx | 494 +-- .../PromptInput/PromptInputModeIndicator.tsx | 150 +- .../PromptInput/PromptInputQueuedCommands.tsx | 164 +- .../PromptInput/PromptInputStashNotice.tsx | 37 +- .../PromptInput/SandboxPromptFooterHint.tsx | 114 +- src/components/PromptInput/ShimmeredInput.tsx | 231 +- src/components/PromptInput/VoiceIndicator.tsx | 165 +- src/components/agents/AgentDetail.tsx | 353 +- src/components/agents/AgentEditor.tsx | 357 +- .../agents/AgentNavigationFooter.tsx | 44 +- src/components/agents/AgentsList.tsx | 749 ++-- src/components/agents/AgentsMenu.tsx | 1134 ++---- src/components/agents/ColorPicker.tsx | 211 +- src/components/agents/ModelSelector.tsx | 107 +- src/components/agents/ToolSelector.tsx | 933 ++--- .../new-agent-creation/CreateAgentWizard.tsx | 160 +- .../wizard-steps/ColorStep.tsx | 143 +- .../wizard-steps/ConfirmStep.tsx | 535 +-- .../wizard-steps/ConfirmStepWrapper.tsx | 167 +- .../wizard-steps/DescriptionStep.tsx | 210 +- .../wizard-steps/GenerateStep.tsx | 221 +- .../wizard-steps/LocationStep.tsx | 132 +- .../wizard-steps/MemoryStep.tsx | 208 +- .../wizard-steps/MethodStep.tsx | 140 +- .../wizard-steps/ModelStep.tsx | 89 +- .../wizard-steps/PromptStep.tsx | 218 +- .../wizard-steps/ToolsStep.tsx | 106 +- .../wizard-steps/TypeStep.tsx | 177 +- src/components/design-system/Byline.tsx | 67 +- src/components/design-system/Dialog.tsx | 213 +- src/components/design-system/Divider.tsx | 133 +- src/components/design-system/FuzzyPicker.tsx | 437 ++- .../design-system/KeyboardShortcutHint.tsx | 74 +- src/components/design-system/ListItem.tsx | 251 +- src/components/design-system/LoadingState.tsx | 81 +- src/components/design-system/Pane.tsx | 81 +- src/components/design-system/ProgressBar.tsx | 101 +- src/components/design-system/Ratchet.tsx | 120 +- src/components/design-system/StatusIcon.tsx | 93 +- src/components/design-system/Tabs.tsx | 562 +-- .../design-system/ThemeProvider.tsx | 233 +- src/components/design-system/ThemedBox.tsx | 229 +- src/components/design-system/ThemedText.tsx | 143 +- src/components/skills/SkillsMenu.tsx | 379 +- .../tasks/AsyncAgentDetailDialog.tsx | 422 +- src/components/tasks/BackgroundTask.tsx | 480 +-- src/components/tasks/BackgroundTaskStatus.tsx | 698 ++-- .../tasks/BackgroundTasksDialog.tsx | 1217 ++++-- src/components/tasks/DreamDetailDialog.tsx | 374 +- .../tasks/InProcessTeammateDetailDialog.tsx | 450 +-- .../tasks/RemoteSessionDetailDialog.tsx | 1269 +++--- .../tasks/RemoteSessionProgress.tsx | 357 +- src/components/tasks/ShellDetailDialog.tsx | 612 ++- src/components/tasks/ShellProgress.tsx | 128 +- src/components/tasks/renderToolActivity.tsx | 45 +- src/components/tasks/taskStatusUtils.tsx | 139 +- src/components/wizard/WizardDialogLayout.tsx | 98 +- .../wizard/WizardNavigationFooter.tsx | 42 +- src/components/wizard/WizardProvider.tsx | 303 +- 73 files changed, 12290 insertions(+), 13239 deletions(-) diff --git a/src/components/FeedbackSurvey/FeedbackSurvey.tsx b/src/components/FeedbackSurvey/FeedbackSurvey.tsx index 8a5ccbfcf..2f9c8e47d 100644 --- a/src/components/FeedbackSurvey/FeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey/FeedbackSurvey.tsx @@ -1,173 +1,167 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { Box, Text } from '../../ink.js'; -import { FeedbackSurveyView, isValidResponseInput } from './FeedbackSurveyView.js'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import { TranscriptSharePrompt } from './TranscriptSharePrompt.js'; -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; -import type { FeedbackSurveyResponse } from './utils.js'; +import React from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { Box, Text } from '../../ink.js' +import { + FeedbackSurveyView, + isValidResponseInput, +} from './FeedbackSurveyView.js' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import { TranscriptSharePrompt } from './TranscriptSharePrompt.js' +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' +import type { FeedbackSurveyResponse } from './utils.js' + type Props = { - state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; - lastResponse: FeedbackSurveyResponse | null; - handleSelect: (selected: FeedbackSurveyResponse) => void; - handleTranscriptSelect?: (selected: TranscriptShareResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; - onRequestFeedback?: () => void; - message?: string; -}; -export function FeedbackSurvey(t0) { - const $ = _c(16); - const { - state, - lastResponse, - handleSelect, - handleTranscriptSelect, - inputValue, - setInputValue, - onRequestFeedback, - message - } = t0; - if (state === "closed") { - return null; + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => void + handleTranscriptSelect?: (selected: TranscriptShareResponse) => void + inputValue: string + setInputValue: (value: string) => void + onRequestFeedback?: () => void + message?: string +} + +export function FeedbackSurvey({ + state, + lastResponse, + handleSelect, + handleTranscriptSelect, + inputValue, + setInputValue, + onRequestFeedback, + message, +}: Props): React.ReactNode { + if (state === 'closed') { + return null } - if (state === "thanks") { - let t1; - if ($[0] !== inputValue || $[1] !== lastResponse || $[2] !== onRequestFeedback || $[3] !== setInputValue) { - t1 = ; - $[0] = inputValue; - $[1] = lastResponse; - $[2] = onRequestFeedback; - $[3] = setInputValue; - $[4] = t1; - } else { - t1 = $[4]; - } - return t1; + + if (state === 'thanks') { + return ( + + ) } - if (state === "submitted") { - let t1; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {"\u2713"} Thanks for sharing your transcript!; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; + + if (state === 'submitted') { + return ( + + + {'\u2713'} Thanks for sharing your transcript! + + + ) } - if (state === "submitting") { - let t1; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sharing transcript{"\u2026"}; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + + if (state === 'submitting') { + return ( + + Sharing transcript{'\u2026'} + + ) } - if (state === "transcript_prompt") { + + if (state === 'transcript_prompt') { if (!handleTranscriptSelect) { - return null; - } - if (inputValue && !["1", "2", "3"].includes(inputValue)) { - return null; + return null } - let t1; - if ($[7] !== handleTranscriptSelect || $[8] !== inputValue || $[9] !== setInputValue) { - t1 = ; - $[7] = handleTranscriptSelect; - $[8] = inputValue; - $[9] = setInputValue; - $[10] = t1; - } else { - t1 = $[10]; + // Hide prompt if user is typing non-response characters + if (inputValue && !['1', '2', '3'].includes(inputValue)) { + return null } - return t1; + return ( + + ) } + + // state === 'open' + // Hide the survey if the user is typing anything other than a survey response. + // This prevents the survey from showing up when the user is typing a message, + // which can result in accidental survey submissions (e.g. "s3cmd"). if (inputValue && !isValidResponseInput(inputValue)) { - return null; + return null } - let t1; - if ($[11] !== handleSelect || $[12] !== inputValue || $[13] !== message || $[14] !== setInputValue) { - t1 = ; - $[11] = handleSelect; - $[12] = inputValue; - $[13] = message; - $[14] = setInputValue; - $[15] = t1; - } else { - t1 = $[15]; - } - return t1; + + return ( + + ) } + type ThanksProps = { - lastResponse: FeedbackSurveyResponse | null; - inputValue: string; - setInputValue: (value: string) => void; - onRequestFeedback?: () => void; -}; -const isFollowUpDigit = (char: string): char is '1' => char === '1'; -function FeedbackSurveyThanks(t0) { - const $ = _c(12); - const { - lastResponse, + lastResponse: FeedbackSurveyResponse | null + inputValue: string + setInputValue: (value: string) => void + onRequestFeedback?: () => void +} + +const isFollowUpDigit = (char: string): char is '1' => char === '1' + +function FeedbackSurveyThanks({ + lastResponse, + inputValue, + setInputValue, + onRequestFeedback, +}: ThanksProps): React.ReactNode { + const showFollowUp = onRequestFeedback && lastResponse === 'good' + + // Listen for "1" keypress to launch /feedback + useDebouncedDigitInput({ inputValue, setInputValue, - onRequestFeedback - } = t0; - const showFollowUp = onRequestFeedback && lastResponse === "good"; - const t1 = Boolean(showFollowUp); - let t2; - if ($[0] !== lastResponse || $[1] !== onRequestFeedback) { - t2 = () => { - logEvent("tengu_feedback_survey_event", { - event_type: "followup_accepted" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - onRequestFeedback?.(); - }; - $[0] = lastResponse; - $[1] = onRequestFeedback; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== inputValue || $[4] !== setInputValue || $[5] !== t1 || $[6] !== t2) { - t3 = { - inputValue, - setInputValue, - isValidDigit: isFollowUpDigit, - enabled: t1, - once: true, - onDigit: t2 - }; - $[3] = inputValue; - $[4] = setInputValue; - $[5] = t1; - $[6] = t2; - $[7] = t3; - } else { - t3 = $[7]; - } - useDebouncedDigitInput(t3); - const feedbackCommand = false ? "/issue" : "/feedback"; - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Thanks for the feedback!; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== lastResponse || $[10] !== showFollowUp) { - t5 = {t4}{showFollowUp ? (Optional) Press [1] to tell us what went well {" \xB7 "}{feedbackCommand} : lastResponse === "bad" ? Use /issue to report model behavior issues. : Use {feedbackCommand} to share detailed feedback anytime.}; - $[9] = lastResponse; - $[10] = showFollowUp; - $[11] = t5; - } else { - t5 = $[11]; - } - return t5; + isValidDigit: isFollowUpDigit, + enabled: Boolean(showFollowUp), + once: true, + onDigit: () => { + logEvent('tengu_feedback_survey_event', { + event_type: + 'followup_accepted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + onRequestFeedback?.() + }, + }) + + const feedbackCommand = + process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback' + + return ( + + Thanks for the feedback! + {showFollowUp ? ( + + (Optional) Press [1] to tell us what + went well {' \u00b7 '} + {feedbackCommand} + + ) : lastResponse === 'bad' ? ( + Use /issue to report model behavior issues. + ) : ( + + Use {feedbackCommand} to share detailed feedback anytime. + + )} + + ) } diff --git a/src/components/FeedbackSurvey/FeedbackSurveyView.tsx b/src/components/FeedbackSurvey/FeedbackSurveyView.tsx index 74a6f6bfa..a8eadf3ba 100644 --- a/src/components/FeedbackSurvey/FeedbackSurveyView.tsx +++ b/src/components/FeedbackSurvey/FeedbackSurveyView.tsx @@ -1,107 +1,72 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; -import type { FeedbackSurveyResponse } from './utils.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' +import type { FeedbackSurveyResponse } from './utils.js' + type Props = { - onSelect: (option: FeedbackSurveyResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; - message?: string; -}; -const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const; -type ResponseInput = (typeof RESPONSE_INPUTS)[number]; + onSelect: (option: FeedbackSurveyResponse) => void + inputValue: string + setInputValue: (value: string) => void + message?: string +} + +const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const +type ResponseInput = (typeof RESPONSE_INPUTS)[number] + const inputToResponse: Record = { '0': 'dismissed', '1': 'bad', '2': 'fine', - '3': 'good' -} as const; -export const isValidResponseInput = (input: string): input is ResponseInput => (RESPONSE_INPUTS as readonly string[]).includes(input); -const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)'; -export function FeedbackSurveyView(t0) { - const $ = _c(15); - const { - onSelect, + '3': 'good', +} as const + +export const isValidResponseInput = (input: string): input is ResponseInput => + (RESPONSE_INPUTS as readonly string[]).includes(input) + +const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)' + +export function FeedbackSurveyView({ + onSelect, + inputValue, + setInputValue, + message = DEFAULT_MESSAGE, +}: Props): React.ReactNode { + useDebouncedDigitInput({ inputValue, setInputValue, - message: t1 - } = t0; - const message = t1 === undefined ? DEFAULT_MESSAGE : t1; - let t2; - if ($[0] !== onSelect) { - t2 = digit => onSelect(inputToResponse[digit]); - $[0] = onSelect; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== inputValue || $[3] !== setInputValue || $[4] !== t2) { - t3 = { - inputValue, - setInputValue, - isValidDigit: isValidResponseInput, - onDigit: t2 - }; - $[2] = inputValue; - $[3] = setInputValue; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - useDebouncedDigitInput(t3); - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== message) { - t5 = {t4}{message}; - $[7] = message; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = 1: Bad; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = 2: Fine; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t8 = 3: Good; - $[11] = t8; - } else { - t8 = $[11]; - } - let t9; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t9 = {t6}{t7}{t8}0: Dismiss; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] !== t5) { - t10 = {t5}{t9}; - $[13] = t5; - $[14] = t10; - } else { - t10 = $[14]; - } - return t10; + isValidDigit: isValidResponseInput, + onDigit: digit => onSelect(inputToResponse[digit]), + }) + + return ( + + + + {message} + + + + + + 1: Bad + + + + + 2: Fine + + + + + 3: Good + + + + + 0: Dismiss + + + + + ) } diff --git a/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx b/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx index da3893a76..ec7a974f5 100644 --- a/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx +++ b/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx @@ -1,87 +1,74 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; -export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again'; +import React from 'react' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' + +export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again' + type Props = { - onSelect: (option: TranscriptShareResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; -}; -const RESPONSE_INPUTS = ['1', '2', '3'] as const; -type ResponseInput = (typeof RESPONSE_INPUTS)[number]; + onSelect: (option: TranscriptShareResponse) => void + inputValue: string + setInputValue: (value: string) => void +} + +const RESPONSE_INPUTS = ['1', '2', '3'] as const +type ResponseInput = (typeof RESPONSE_INPUTS)[number] + const inputToResponse: Record = { '1': 'yes', '2': 'no', - '3': 'dont_ask_again' -} as const; -const isValidResponseInput = (input: string): input is ResponseInput => (RESPONSE_INPUTS as readonly string[]).includes(input); -export function TranscriptSharePrompt(t0) { - const $ = _c(11); - const { - onSelect, + '3': 'dont_ask_again', +} as const + +const isValidResponseInput = (input: string): input is ResponseInput => + (RESPONSE_INPUTS as readonly string[]).includes(input) + +export function TranscriptSharePrompt({ + onSelect, + inputValue, + setInputValue, +}: Props): React.ReactNode { + useDebouncedDigitInput({ inputValue, - setInputValue - } = t0; - let t1; - if ($[0] !== onSelect) { - t1 = digit => onSelect(inputToResponse[digit]); - $[0] = onSelect; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== inputValue || $[3] !== setInputValue || $[4] !== t1) { - t2 = { - inputValue, - setInputValue, - isValidDigit: isValidResponseInput, - onDigit: t1 - }; - $[2] = inputValue; - $[3] = setInputValue; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - useDebouncedDigitInput(t2); - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {BLACK_CIRCLE} Can Anthropic look at your session transcript to help us improve Claude Code?; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Learn more: https://code.claude.com/docs/en/data-usage#session-quality-surveys; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = 1: Yes; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = 2: No; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = {t3}{t4}{t5}{t6}3: Don't ask again; - $[10] = t7; - } else { - t7 = $[10]; - } - return t7; + setInputValue, + isValidDigit: isValidResponseInput, + onDigit: digit => onSelect(inputToResponse[digit]), + }) + + return ( + + + {BLACK_CIRCLE} + + Can Anthropic look at your session transcript to help us improve + Claude Code? + + + + + + Learn more: + https://code.claude.com/docs/en/data-usage#session-quality-surveys + + + + + + + 1: Yes + + + + + 2: No + + + + + 3: Don't ask again + + + + + ) } diff --git a/src/components/FeedbackSurvey/useFeedbackSurvey.tsx b/src/components/FeedbackSurvey/useFeedbackSurvey.tsx index d19cd0300..166c2dcdd 100644 --- a/src/components/FeedbackSurvey/useFeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey/useFeedbackSurvey.tsx @@ -1,32 +1,41 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js'; -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { isPolicyAllowed } from '../../services/policyLimits/index.js'; -import type { Message } from '../../types/message.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { getLastAssistantMessage } from '../../utils/messages.js'; -import { getMainLoopModel } from '../../utils/model/model.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { logOTelEvent } from '../../utils/telemetry/events.js'; -import { submitTranscriptShare, type TranscriptShareTrigger } from './submitTranscriptShare.js'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import { useSurveyState } from './useSurveyState.js'; -import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js' +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { isPolicyAllowed } from '../../services/policyLimits/index.js' +import type { Message } from '../../types/message.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { getLastAssistantMessage } from '../../utils/messages.js' +import { getMainLoopModel } from '../../utils/model/model.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { logOTelEvent } from '../../utils/telemetry/events.js' +import { + submitTranscriptShare, + type TranscriptShareTrigger, +} from './submitTranscriptShare.js' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import { useSurveyState } from './useSurveyState.js' +import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js' + type FeedbackSurveyConfig = { - minTimeBeforeFeedbackMs: number; - minTimeBetweenFeedbackMs: number; - minTimeBetweenGlobalFeedbackMs: number; - minUserTurnsBeforeFeedback: number; - minUserTurnsBetweenFeedback: number; - hideThanksAfterMs: number; - onForModels: string[]; - probability: number; -}; + minTimeBeforeFeedbackMs: number + minTimeBetweenFeedbackMs: number + minTimeBetweenGlobalFeedbackMs: number + minUserTurnsBeforeFeedback: number + minUserTurnsBetweenFeedback: number + hideThanksAfterMs: number + onForModels: string[] + probability: number +} + type TranscriptAskConfig = { - probability: number; -}; + probability: number +} + const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = { minTimeBeforeFeedbackMs: 600000, minTimeBetweenFeedbackMs: 3600000, @@ -35,261 +44,381 @@ const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = { minUserTurnsBetweenFeedback: 10, hideThanksAfterMs: 3000, onForModels: ['*'], - probability: 0.005 -}; + probability: 0.005, +} + const DEFAULT_TRANSCRIPT_ASK_CONFIG: TranscriptAskConfig = { - probability: 0 -}; -export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submitCount: number, surveyType: FeedbackSurveyType = 'session', hasActivePrompt: boolean = false): { - state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; - lastResponse: FeedbackSurveyResponse | null; - handleSelect: (selected: FeedbackSurveyResponse) => boolean; - handleTranscriptSelect: (selected: TranscriptShareResponse) => void; + probability: 0, +} + +export function useFeedbackSurvey( + messages: Message[], + isLoading: boolean, + submitCount: number, + surveyType: FeedbackSurveyType = 'session', + hasActivePrompt: boolean = false, +): { + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => boolean + handleTranscriptSelect: (selected: TranscriptShareResponse) => void } { - const lastAssistantMessageIdRef = useRef('unknown'); - lastAssistantMessageIdRef.current = getLastAssistantMessage(messages)?.message?.id || 'unknown'; + const lastAssistantMessageIdRef = useRef('unknown') + lastAssistantMessageIdRef.current = + getLastAssistantMessage(messages)?.message?.id || 'unknown' const [feedbackSurvey, setFeedbackSurvey] = useState<{ - timeLastShown: number | null; - submitCountAtLastAppearance: number | null; - }>(() => ({ - timeLastShown: null, - submitCountAtLastAppearance: null - })); - const config = useDynamicConfig('tengu_feedback_survey_config', DEFAULT_FEEDBACK_SURVEY_CONFIG); - const badTranscriptAskConfig = useDynamicConfig('tengu_bad_survey_transcript_ask_config', DEFAULT_TRANSCRIPT_ASK_CONFIG); - const goodTranscriptAskConfig = useDynamicConfig('tengu_good_survey_transcript_ask_config', DEFAULT_TRANSCRIPT_ASK_CONFIG); - const settingsRate = getInitialSettings().feedbackSurveyRate; - const sessionStartTime = useRef(Date.now()); - const submitCountAtSessionStart = useRef(submitCount); - const submitCountRef = useRef(submitCount); - submitCountRef.current = submitCount; - const messagesRef = useRef(messages); - messagesRef.current = messages; + timeLastShown: number | null + submitCountAtLastAppearance: number | null + }>(() => ({ timeLastShown: null, submitCountAtLastAppearance: null })) + const config = useDynamicConfig( + 'tengu_feedback_survey_config', + DEFAULT_FEEDBACK_SURVEY_CONFIG, + ) + const badTranscriptAskConfig = useDynamicConfig( + 'tengu_bad_survey_transcript_ask_config', + DEFAULT_TRANSCRIPT_ASK_CONFIG, + ) + const goodTranscriptAskConfig = useDynamicConfig( + 'tengu_good_survey_transcript_ask_config', + DEFAULT_TRANSCRIPT_ASK_CONFIG, + ) + const settingsRate = getInitialSettings().feedbackSurveyRate + const sessionStartTime = useRef(Date.now()) + const submitCountAtSessionStart = useRef(submitCount) + const submitCountRef = useRef(submitCount) + submitCountRef.current = submitCount + const messagesRef = useRef(messages) + messagesRef.current = messages // Probability gate: roll once when eligibility conditions are met, not on every // useMemo re-evaluation. Without this, each dependency change (submitCount, // isLoading toggle, etc.) re-rolls Math.random(), making the survey almost // certain to appear after enough renders. - const probabilityPassedRef = useRef(false); - const lastEligibleSubmitCountRef = useRef(null); - const updateLastShownTime = useCallback((timestamp: number, submitCountValue: number) => { - setFeedbackSurvey(prev => { - if (prev.timeLastShown === timestamp && prev.submitCountAtLastAppearance === submitCountValue) { - return prev; - } - return { - timeLastShown: timestamp, - submitCountAtLastAppearance: submitCountValue - }; - }); - // Persist cross-session pacing state (previously done by onChangeAppState observer) - if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) { - saveGlobalConfig(current => ({ - ...current, - feedbackSurveyState: { - lastShownTime: timestamp + const probabilityPassedRef = useRef(false) + const lastEligibleSubmitCountRef = useRef(null) + + const updateLastShownTime = useCallback( + (timestamp: number, submitCountValue: number) => { + setFeedbackSurvey(prev => { + if ( + prev.timeLastShown === timestamp && + prev.submitCountAtLastAppearance === submitCountValue + ) { + return prev } - })); - } - }, []); - const onOpen = useCallback((appearanceId: string) => { - updateLastShownTime(Date.now(), submitCountRef.current); - logEvent('tengu_feedback_survey_event', { - event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'appeared', - appearance_id: appearanceId, - survey_type: surveyType - }); - }, [updateLastShownTime, surveyType]); - const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => { - updateLastShownTime(Date.now(), submitCountRef.current); - logEvent('tengu_feedback_survey_event', { - event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'responded', - appearance_id: appearanceId_0, - response: selected, - survey_type: surveyType - }); - }, [updateLastShownTime, surveyType]); - const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => { - // Only bad and good ratings trigger the transcript ask - if (selected_0 !== 'bad' && selected_0 !== 'good') { - return false; - } + return { + timeLastShown: timestamp, + submitCountAtLastAppearance: submitCountValue, + } + }) + // Persist cross-session pacing state (previously done by onChangeAppState observer) + if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) { + saveGlobalConfig(current => ({ + ...current, + feedbackSurveyState: { + lastShownTime: timestamp, + }, + })) + } + }, + [], + ) - // Don't show if user previously chose "Don't ask again" - if (getGlobalConfig().transcriptShareDismissed) { - return false; - } + const onOpen = useCallback( + (appearanceId: string) => { + updateLastShownTime(Date.now(), submitCountRef.current) + logEvent('tengu_feedback_survey_event', { + event_type: + 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'appeared', + appearance_id: appearanceId, + survey_type: surveyType, + }) + }, + [updateLastShownTime, surveyType], + ) - // Don't show if product feedback is blocked by org policy (ZDR) - if (!isPolicyAllowed('allow_product_feedback')) { - return false; - } + const onSelect = useCallback( + (appearanceId: string, selected: FeedbackSurveyResponse) => { + updateLastShownTime(Date.now(), submitCountRef.current) + logEvent('tengu_feedback_survey_event', { + event_type: + 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: surveyType, + }) + }, + [updateLastShownTime, surveyType], + ) - // Probability gate from GrowthBook config (separate per rating) - const probability = selected_0 === 'bad' ? badTranscriptAskConfig.probability : goodTranscriptAskConfig.probability; - return Math.random() <= probability; - }, [badTranscriptAskConfig.probability, goodTranscriptAskConfig.probability]); - const onTranscriptPromptShown = useCallback((appearanceId_1: string, surveyResponse: FeedbackSurveyResponse) => { - const trigger: TranscriptShareTrigger = surveyResponse === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey'; - logEvent('tengu_feedback_survey_event', { - event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'transcript_prompt_appeared', - appearance_id: appearanceId_1, - survey_type: surveyType - }); - }, [surveyType]); - const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse, surveyResponse_0: FeedbackSurveyResponse | null): Promise => { - const trigger_0: TranscriptShareTrigger = surveyResponse_0 === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey'; - logEvent('tengu_feedback_survey_event', { - event_type: `transcript_share_${selected_1}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: trigger_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - if (selected_1 === 'dont_ask_again') { - saveGlobalConfig(current_0 => ({ - ...current_0, - transcriptShareDismissed: true - })); - } - if (selected_1 === 'yes') { - const result = await submitTranscriptShare(messagesRef.current, trigger_0, appearanceId_2); + const shouldShowTranscriptPrompt = useCallback( + (selected: FeedbackSurveyResponse) => { + // Only bad and good ratings trigger the transcript ask + if (selected !== 'bad' && selected !== 'good') { + return false + } + + // Don't show if user previously chose "Don't ask again" + if (getGlobalConfig().transcriptShareDismissed) { + return false + } + + // Don't show if product feedback is blocked by org policy (ZDR) + if (!isPolicyAllowed('allow_product_feedback')) { + return false + } + + // Probability gate from GrowthBook config (separate per rating) + const probability = + selected === 'bad' + ? badTranscriptAskConfig.probability + : goodTranscriptAskConfig.probability + return Math.random() <= probability + }, + [badTranscriptAskConfig.probability, goodTranscriptAskConfig.probability], + ) + + const onTranscriptPromptShown = useCallback( + (appearanceId: string, surveyResponse: FeedbackSurveyResponse) => { + const trigger: TranscriptShareTrigger = + surveyResponse === 'good' + ? 'good_feedback_survey' + : 'bad_feedback_survey' logEvent('tengu_feedback_survey_event', { - event_type: (result.success ? 'transcript_share_submitted' : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: trigger_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - return result.success; - } - return false; - }, [surveyType]); - const { - state, - lastResponse, - open, - handleSelect, - handleTranscriptSelect - } = useSurveyState({ - hideThanksAfterMs: config.hideThanksAfterMs, - onOpen, - onSelect, - shouldShowTranscriptPrompt, - onTranscriptPromptShown, - onTranscriptSelect - }); - const currentModel = getMainLoopModel(); + event_type: + 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'transcript_prompt_appeared', + appearance_id: appearanceId, + survey_type: surveyType, + }) + }, + [surveyType], + ) + + const onTranscriptSelect = useCallback( + async ( + appearanceId: string, + selected: TranscriptShareResponse, + surveyResponse: FeedbackSurveyResponse | null, + ): Promise => { + const trigger: TranscriptShareTrigger = + surveyResponse === 'good' + ? 'good_feedback_survey' + : 'bad_feedback_survey' + + logEvent('tengu_feedback_survey_event', { + event_type: + `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + if (selected === 'dont_ask_again') { + saveGlobalConfig(current => ({ + ...current, + transcriptShareDismissed: true, + })) + } + + if (selected === 'yes') { + const result = await submitTranscriptShare( + messagesRef.current, + trigger, + appearanceId, + ) + logEvent('tengu_feedback_survey_event', { + event_type: (result.success + ? 'transcript_share_submitted' + : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return result.success + } + + return false + }, + [surveyType], + ) + + const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = + useSurveyState({ + hideThanksAfterMs: config.hideThanksAfterMs, + onOpen, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + onTranscriptSelect, + }) + + const currentModel = getMainLoopModel() const isModelAllowed = useMemo(() => { if (config.onForModels.length === 0) { - return false; + return false } if (config.onForModels.includes('*')) { - return true; + return true } - return config.onForModels.includes(currentModel); - }, [config.onForModels, currentModel]); + return config.onForModels.includes(currentModel) + }, [config.onForModels, currentModel]) + const shouldOpen = useMemo(() => { if (state !== 'closed') { - return false; + return false } + if (isLoading) { - return false; + return false } // Don't show survey when permission or ask question prompts are visible if (hasActivePrompt) { - return false; + return false } // Force display for testing - if (process.env.CLAUDE_FORCE_DISPLAY_SURVEY && !feedbackSurvey.timeLastShown) { - return true; + if ( + process.env.CLAUDE_FORCE_DISPLAY_SURVEY && + !feedbackSurvey.timeLastShown + ) { + return true } + if (!isModelAllowed) { - return false; + return false } + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return false; + return false } + if (isFeedbackSurveyDisabled()) { - return false; + return false } // Check if product feedback is allowed by org policy if (!isPolicyAllowed('allow_product_feedback')) { - return false; + return false } // Check session-local pacing if (feedbackSurvey.timeLastShown) { // Check time elapsed since last appearance in this session - const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown; + const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown if (timeSinceLastShown < config.minTimeBetweenFeedbackMs) { - return false; + return false } // Check user turn requirement for subsequent appearances - if (feedbackSurvey.submitCountAtLastAppearance !== null && submitCount < feedbackSurvey.submitCountAtLastAppearance + config.minUserTurnsBetweenFeedback) { - return false; + if ( + feedbackSurvey.submitCountAtLastAppearance !== null && + submitCount < + feedbackSurvey.submitCountAtLastAppearance + + config.minUserTurnsBetweenFeedback + ) { + return false } } else { // First appearance in this session - const timeSinceSessionStart = Date.now() - sessionStartTime.current; + const timeSinceSessionStart = Date.now() - sessionStartTime.current if (timeSinceSessionStart < config.minTimeBeforeFeedbackMs) { - return false; + return false } - if (submitCount < submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback) { - return false; + if ( + submitCount < + submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback + ) { + return false } } // Probability check: roll once per eligibility window to avoid re-rolling // on every useMemo re-evaluation (which would make triggering near-certain). if (lastEligibleSubmitCountRef.current !== submitCount) { - lastEligibleSubmitCountRef.current = submitCount; - probabilityPassedRef.current = Math.random() <= (settingsRate ?? config.probability); + lastEligibleSubmitCountRef.current = submitCount + probabilityPassedRef.current = + Math.random() <= (settingsRate ?? config.probability) } if (!probabilityPassedRef.current) { - return false; + return false } // Check global pacing (across all sessions) // Leave this till last because it reads from the filesystem which is expensive. - const globalFeedbackState = getGlobalConfig().feedbackSurveyState; + const globalFeedbackState = getGlobalConfig().feedbackSurveyState if (globalFeedbackState?.lastShownTime) { - const timeSinceGlobalLastShown = Date.now() - globalFeedbackState.lastShownTime; + const timeSinceGlobalLastShown = + Date.now() - globalFeedbackState.lastShownTime if (timeSinceGlobalLastShown < config.minTimeBetweenGlobalFeedbackMs) { - return false; + return false } } - return true; - }, [state, isLoading, hasActivePrompt, isModelAllowed, feedbackSurvey.timeLastShown, feedbackSurvey.submitCountAtLastAppearance, submitCount, config.minTimeBetweenFeedbackMs, config.minTimeBetweenGlobalFeedbackMs, config.minUserTurnsBetweenFeedback, config.minTimeBeforeFeedbackMs, config.minUserTurnsBeforeFeedback, config.probability, settingsRate]); + + return true + }, [ + state, + isLoading, + hasActivePrompt, + isModelAllowed, + feedbackSurvey.timeLastShown, + feedbackSurvey.submitCountAtLastAppearance, + submitCount, + config.minTimeBetweenFeedbackMs, + config.minTimeBetweenGlobalFeedbackMs, + config.minUserTurnsBetweenFeedback, + config.minTimeBeforeFeedbackMs, + config.minUserTurnsBeforeFeedback, + config.probability, + settingsRate, + ]) + useEffect(() => { if (shouldOpen) { - open(); + open() } - }, [shouldOpen, open]); - return { - state, - lastResponse, - handleSelect, - handleTranscriptSelect - }; + }, [shouldOpen, open]) + + return { state, lastResponse, handleSelect, handleTranscriptSelect } } diff --git a/src/components/FeedbackSurvey/useMemorySurvey.tsx b/src/components/FeedbackSurvey/useMemorySurvey.tsx index bd28ee699..2c96b2b9e 100644 --- a/src/components/FeedbackSurvey/useMemorySurvey.tsx +++ b/src/components/FeedbackSurvey/useMemorySurvey.tsx @@ -1,212 +1,283 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { isAutoMemoryEnabled } from '../../memdir/paths.js'; -import { isPolicyAllowed } from '../../services/policyLimits/index.js'; -import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'; -import type { Message } from '../../types/message.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js'; -import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js'; -import { logOTelEvent } from '../../utils/telemetry/events.js'; -import { submitTranscriptShare } from './submitTranscriptShare.js'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import { useSurveyState } from './useSurveyState.js'; -import type { FeedbackSurveyResponse } from './utils.js'; -const HIDE_THANKS_AFTER_MS = 3000; -const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell'; -const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event'; -const SURVEY_PROBABILITY = 0.2; -const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey'; -const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i; +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { isAutoMemoryEnabled } from '../../memdir/paths.js' +import { isPolicyAllowed } from '../../services/policyLimits/index.js' +import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' +import type { Message } from '../../types/message.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js' +import { + extractTextContent, + getLastAssistantMessage, +} from '../../utils/messages.js' +import { logOTelEvent } from '../../utils/telemetry/events.js' +import { submitTranscriptShare } from './submitTranscriptShare.js' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import { useSurveyState } from './useSurveyState.js' +import type { FeedbackSurveyResponse } from './utils.js' + +const HIDE_THANKS_AFTER_MS = 3000 +const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell' +const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event' +const SURVEY_PROBABILITY = 0.2 +const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey' + +const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i + function hasMemoryFileRead(messages: Message[]): boolean { for (const message of messages) { if (message.type !== 'assistant') { - continue; + continue } - const content = message.message.content; + const content = message.message.content if (!Array.isArray(content)) { - continue; + continue } for (const block of content) { if (block.type !== 'tool_use' || block.name !== FILE_READ_TOOL_NAME) { - continue; + continue } - const input = block.input as { - file_path?: unknown; - }; - if (typeof input.file_path === 'string' && isAutoManagedMemoryFile(input.file_path)) { - return true; + const input = block.input as { file_path?: unknown } + if ( + typeof input.file_path === 'string' && + isAutoManagedMemoryFile(input.file_path) + ) { + return true } } } - return false; + return false } -export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActivePrompt = false, { - enabled = true -}: { - enabled?: boolean; -} = {}): { - state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; - lastResponse: FeedbackSurveyResponse | null; - handleSelect: (selected: FeedbackSurveyResponse) => void; - handleTranscriptSelect: (selected: TranscriptShareResponse) => void; + +export function useMemorySurvey( + messages: Message[], + isLoading: boolean, + hasActivePrompt = false, + { enabled = true }: { enabled?: boolean } = {}, +): { + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => void + handleTranscriptSelect: (selected: TranscriptShareResponse) => void } { // Track assistant message UUIDs that were already evaluated so we don't // re-roll probability on re-renders or re-scan messages for the same turn. - const seenAssistantUuids = useRef>(new Set()); + const seenAssistantUuids = useRef>(new Set()) // Once a memory file read is observed it stays true for the session — // skip the O(n) scan on subsequent turns. - const memoryReadSeen = useRef(false); - const messagesRef = useRef(messages); - messagesRef.current = messages; + const memoryReadSeen = useRef(false) + const messagesRef = useRef(messages) + messagesRef.current = messages + const onOpen = useCallback((appearanceId: string) => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + event_type: + 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) void logOTelEvent('feedback_survey', { event_type: 'appeared', appearance_id: appearanceId, - survey_type: 'memory' - }); - }, []); - const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => { - logEvent(MEMORY_SURVEY_EVENT, { - event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'responded', - appearance_id: appearanceId_0, - response: selected, - survey_type: 'memory' - }); - }, []); - const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => { - if ((process.env.USER_TYPE) !== 'ant') { - return false; - } - if (selected_0 !== 'bad' && selected_0 !== 'good') { - return false; - } - if (getGlobalConfig().transcriptShareDismissed) { - return false; - } - if (!isPolicyAllowed('allow_product_feedback')) { - return false; - } - return true; - }, []); - const onTranscriptPromptShown = useCallback((appearanceId_1: string) => { + survey_type: 'memory', + }) + }, []) + + const onSelect = useCallback( + (appearanceId: string, selected: FeedbackSurveyResponse) => { + logEvent(MEMORY_SURVEY_EVENT, { + event_type: + 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: 'memory', + }) + }, + [], + ) + + const shouldShowTranscriptPrompt = useCallback( + (selected: FeedbackSurveyResponse) => { + if (process.env.USER_TYPE !== 'ant') { + return false + } + if (selected !== 'bad' && selected !== 'good') { + return false + } + if (getGlobalConfig().transcriptShareDismissed) { + return false + } + if (!isPolicyAllowed('allow_product_feedback')) { + return false + } + return true + }, + [], + ) + + const onTranscriptPromptShown = useCallback((appearanceId: string) => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + event_type: + 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) void logOTelEvent('feedback_survey', { event_type: 'transcript_prompt_appeared', - appearance_id: appearanceId_1, - survey_type: 'memory' - }); - }, []); - const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse): Promise => { - logEvent(MEMORY_SURVEY_EVENT, { - event_type: `transcript_share_${selected_1}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - if (selected_1 === 'dont_ask_again') { - saveGlobalConfig(current => ({ - ...current, - transcriptShareDismissed: true - })); - } - if (selected_1 === 'yes') { - const result = await submitTranscriptShare(messagesRef.current, TRANSCRIPT_SHARE_TRIGGER, appearanceId_2); + appearance_id: appearanceId, + survey_type: 'memory', + }) + }, []) + + const onTranscriptSelect = useCallback( + async ( + appearanceId: string, + selected: TranscriptShareResponse, + ): Promise => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: (result.success ? 'transcript_share_submitted' : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - return result.success; - } - return false; - }, []); - const { - state, - lastResponse, - open, - handleSelect, - handleTranscriptSelect - } = useSurveyState({ - hideThanksAfterMs: HIDE_THANKS_AFTER_MS, - onOpen, - onSelect, - shouldShowTranscriptPrompt, - onTranscriptPromptShown, - onTranscriptSelect - }); - const lastAssistant = useMemo(() => getLastAssistantMessage(messages), [messages]); + event_type: + `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + if (selected === 'dont_ask_again') { + saveGlobalConfig(current => ({ + ...current, + transcriptShareDismissed: true, + })) + } + + if (selected === 'yes') { + const result = await submitTranscriptShare( + messagesRef.current, + TRANSCRIPT_SHARE_TRIGGER, + appearanceId, + ) + logEvent(MEMORY_SURVEY_EVENT, { + event_type: (result.success + ? 'transcript_share_submitted' + : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return result.success + } + + return false + }, + [], + ) + + const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = + useSurveyState({ + hideThanksAfterMs: HIDE_THANKS_AFTER_MS, + onOpen, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + onTranscriptSelect, + }) + + const lastAssistant = useMemo( + () => getLastAssistantMessage(messages), + [messages], + ) + useEffect(() => { - if (!enabled) return; + if (!enabled) return // /clear resets messages but REPL stays mounted — reset refs so a memory // read from the previous conversation doesn't leak into the new one. if (messages.length === 0) { - memoryReadSeen.current = false; - seenAssistantUuids.current.clear(); - return; + memoryReadSeen.current = false + seenAssistantUuids.current.clear() + return } + if (state !== 'closed' || isLoading || hasActivePrompt) { - return; + return } // 3P default: survey off (no GrowthBook on Bedrock/Vertex/Foundry). if (!getFeatureValue_CACHED_MAY_BE_STALE(MEMORY_SURVEY_GATE, false)) { - return; + return } + if (!isAutoMemoryEnabled()) { - return; + return } + if (isFeedbackSurveyDisabled()) { - return; + return } + if (!isPolicyAllowed('allow_product_feedback')) { - return; + return } + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return; + return } + if (!lastAssistant || seenAssistantUuids.current.has(lastAssistant.uuid)) { - return; + return } - const text = extractTextContent(Array.isArray(lastAssistant.message.content) ? lastAssistant.message.content : [], ' '); + + const text = extractTextContent(lastAssistant.message.content, ' ') if (!MEMORY_WORD_RE.test(text)) { - return; + return } // Mark as evaluated before the memory-read scan so a turn that mentions // "memory" but has no memory read doesn't trigger repeated O(n) scans // on subsequent renders with the same last assistant message. - seenAssistantUuids.current.add(lastAssistant.uuid); + seenAssistantUuids.current.add(lastAssistant.uuid) + if (!memoryReadSeen.current) { - memoryReadSeen.current = hasMemoryFileRead(messages); + memoryReadSeen.current = hasMemoryFileRead(messages) } if (!memoryReadSeen.current) { - return; + return } + if (Math.random() < SURVEY_PROBABILITY) { - open(); + open() } - }, [enabled, state, isLoading, hasActivePrompt, lastAssistant, messages, open]); - return { + }, [ + enabled, state, - lastResponse, - handleSelect, - handleTranscriptSelect - }; + isLoading, + hasActivePrompt, + lastAssistant, + messages, + open, + ]) + + return { state, lastResponse, handleSelect, handleTranscriptSelect } } diff --git a/src/components/FeedbackSurvey/usePostCompactSurvey.tsx b/src/components/FeedbackSurvey/usePostCompactSurvey.tsx index ee8e31e79..99e80dfed 100644 --- a/src/components/FeedbackSurvey/usePostCompactSurvey.tsx +++ b/src/components/FeedbackSurvey/usePostCompactSurvey.tsx @@ -1,205 +1,195 @@ -import { c as _c } from "react/compiler-runtime"; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; -import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'; -import type { Message } from '../../types/message.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { isCompactBoundaryMessage } from '../../utils/messages.js'; -import { logOTelEvent } from '../../utils/telemetry/events.js'; -import { useSurveyState } from './useSurveyState.js'; -import type { FeedbackSurveyResponse } from './utils.js'; -const HIDE_THANKS_AFTER_MS = 3000; -const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey'; -const SURVEY_PROBABILITY = 0.2; // Show survey 20% of the time after compaction - -function hasMessageAfterBoundary(messages: Message[], boundaryUuid: string): boolean { - const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid); +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' +import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js' +import type { Message } from '../../types/message.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { isCompactBoundaryMessage } from '../../utils/messages.js' +import { logOTelEvent } from '../../utils/telemetry/events.js' +import { useSurveyState } from './useSurveyState.js' +import type { FeedbackSurveyResponse } from './utils.js' + +const HIDE_THANKS_AFTER_MS = 3000 +const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey' +const SURVEY_PROBABILITY = 0.2 // Show survey 20% of the time after compaction + +function hasMessageAfterBoundary( + messages: Message[], + boundaryUuid: string, +): boolean { + const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid) if (boundaryIndex === -1) { - return false; + return false } // Check if there's a user or assistant message after the boundary for (let i = boundaryIndex + 1; i < messages.length; i++) { - const msg = messages[i]; + const msg = messages[i] if (msg && (msg.type === 'user' || msg.type === 'assistant')) { - return true; + return true } } - return false; + return false } -export function usePostCompactSurvey(messages, isLoading, t0, t1) { - const $ = _c(23); - const hasActivePrompt = t0 === undefined ? false : t0; - let t2; - if ($[0] !== t1) { - t2 = t1 === undefined ? {} : t1; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - const { - enabled: t3 - } = t2; - const enabled = t3 === undefined ? true : t3; - const [gateEnabled, setGateEnabled] = useState(null); - let t4; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t4 = new Set(); - $[2] = t4; - } else { - t4 = $[2]; - } - const seenCompactBoundaries = useRef(t4); - const pendingCompactBoundaryUuid = useRef(null); - const onOpen = _temp; - const onSelect = _temp2; - let t5; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - hideThanksAfterMs: HIDE_THANKS_AFTER_MS, - onOpen, - onSelect - }; - $[3] = t5; - } else { - t5 = $[3]; - } - const { - state, - lastResponse, - open, - handleSelect - } = useSurveyState(t5); - let t6; - let t7; - if ($[4] !== enabled) { - t6 = () => { - if (!enabled) { - return; - } - setGateEnabled(checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE)); - }; - t7 = [enabled]; - $[4] = enabled; - $[5] = t6; - $[6] = t7; - } else { - t6 = $[5]; - t7 = $[6]; - } - useEffect(t6, t7); - let t8; - if ($[7] !== messages) { - t8 = new Set(messages.filter(_temp3).map(_temp4)); - $[7] = messages; - $[8] = t8; - } else { - t8 = $[8]; - } - const currentCompactBoundaries = t8; - let t10; - let t9; - if ($[9] !== currentCompactBoundaries || $[10] !== enabled || $[11] !== gateEnabled || $[12] !== hasActivePrompt || $[13] !== isLoading || $[14] !== messages || $[15] !== open || $[16] !== state) { - t9 = () => { - if (!enabled) { - return; - } - if (state !== "closed" || isLoading) { - return; - } - if (hasActivePrompt) { - return; - } - if (gateEnabled !== true) { - return; - } - if (isFeedbackSurveyDisabled()) { - return; - } - if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return; - } - if (pendingCompactBoundaryUuid.current !== null) { - if (hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current)) { - pendingCompactBoundaryUuid.current = null; - if (Math.random() < SURVEY_PROBABILITY) { - open(); - } - return; + +export function usePostCompactSurvey( + messages: Message[], + isLoading: boolean, + hasActivePrompt = false, + { enabled = true }: { enabled?: boolean } = {}, +): { + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => void +} { + const [gateEnabled, setGateEnabled] = useState(null) + const seenCompactBoundaries = useRef>(new Set()) + // Track the compact boundary we're waiting on (to show survey after next message) + const pendingCompactBoundaryUuid = useRef(null) + + const onOpen = useCallback((appearanceId: string) => { + const smCompactionEnabled = shouldUseSessionMemoryCompaction() + logEvent('tengu_post_compact_survey_event', { + event_type: + 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_memory_compaction_enabled: + smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'appeared', + appearance_id: appearanceId, + survey_type: 'post_compact', + }) + }, []) + + const onSelect = useCallback( + (appearanceId: string, selected: FeedbackSurveyResponse) => { + const smCompactionEnabled = shouldUseSessionMemoryCompaction() + logEvent('tengu_post_compact_survey_event', { + event_type: + 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_memory_compaction_enabled: + smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: 'post_compact', + }) + }, + [], + ) + + const { state, lastResponse, open, handleSelect } = useSurveyState({ + hideThanksAfterMs: HIDE_THANKS_AFTER_MS, + onOpen, + onSelect, + }) + + // Check the feature gate on mount + useEffect(() => { + if (!enabled) return + setGateEnabled( + checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE), + ) + }, [enabled]) + + // Find compact boundary messages + const currentCompactBoundaries = useMemo( + () => + new Set( + messages + .filter(msg => isCompactBoundaryMessage(msg)) + .map(msg => msg.uuid), + ), + [messages], + ) + + // Detect new compact boundaries and defer showing survey until next message + useEffect(() => { + if (!enabled) return + + // Don't process if already showing + if (state !== 'closed' || isLoading) { + return + } + + // Don't show survey when permission or ask question prompts are visible + if (hasActivePrompt) { + return + } + + // Check if the gate is enabled + if (gateEnabled !== true) { + return + } + + if (isFeedbackSurveyDisabled()) { + return + } + + // Check if survey is explicitly disabled + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { + return + } + + // First, check if we have a pending compact and a new message has arrived + if (pendingCompactBoundaryUuid.current !== null) { + if ( + hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current) + ) { + // A new message arrived after the compact - decide whether to show survey + pendingCompactBoundaryUuid.current = null + + // Only show survey 20% of the time + if (Math.random() < SURVEY_PROBABILITY) { + open() } + return } - const newBoundaries = Array.from(currentCompactBoundaries).filter(uuid => !seenCompactBoundaries.current.has(uuid)); - if (newBoundaries.length > 0) { - seenCompactBoundaries.current = new Set(currentCompactBoundaries); - pendingCompactBoundaryUuid.current = newBoundaries[newBoundaries.length - 1]; - } - }; - t10 = [enabled, currentCompactBoundaries, state, isLoading, hasActivePrompt, gateEnabled, messages, open]; - $[9] = currentCompactBoundaries; - $[10] = enabled; - $[11] = gateEnabled; - $[12] = hasActivePrompt; - $[13] = isLoading; - $[14] = messages; - $[15] = open; - $[16] = state; - $[17] = t10; - $[18] = t9; - } else { - t10 = $[17]; - t9 = $[18]; - } - useEffect(t9, t10); - let t11; - if ($[19] !== handleSelect || $[20] !== lastResponse || $[21] !== state) { - t11 = { - state, - lastResponse, - handleSelect - }; - $[19] = handleSelect; - $[20] = lastResponse; - $[21] = state; - $[22] = t11; - } else { - t11 = $[22]; - } - return t11; -} -function _temp4(msg_0) { - return msg_0.uuid; -} -function _temp3(msg) { - return isCompactBoundaryMessage(msg); -} -function _temp2(appearanceId_0, selected) { - const smCompactionEnabled_0 = shouldUseSessionMemoryCompaction(); - logEvent("tengu_post_compact_survey_event", { - event_type: "responded" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_memory_compaction_enabled: smCompactionEnabled_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - logOTelEvent("feedback_survey", { - event_type: "responded", - appearance_id: appearanceId_0, - response: selected, - survey_type: "post_compact" - }); -} -function _temp(appearanceId) { - const smCompactionEnabled = shouldUseSessionMemoryCompaction(); - logEvent("tengu_post_compact_survey_event", { - event_type: "appeared" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_memory_compaction_enabled: smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - logOTelEvent("feedback_survey", { - event_type: "appeared", - appearance_id: appearanceId, - survey_type: "post_compact" - }); + } + + // Find new compact boundaries that we haven't seen yet + const newBoundaries = Array.from(currentCompactBoundaries).filter( + uuid => !seenCompactBoundaries.current.has(uuid), + ) + + if (newBoundaries.length > 0) { + // Mark these boundaries as seen + seenCompactBoundaries.current = new Set(currentCompactBoundaries) + + // Don't show survey immediately - wait for next message + // Store the most recent new boundary UUID + pendingCompactBoundaryUuid.current = + newBoundaries[newBoundaries.length - 1]! + } + }, [ + enabled, + currentCompactBoundaries, + state, + isLoading, + hasActivePrompt, + gateEnabled, + messages, + open, + ]) + + return { state, lastResponse, handleSelect } } diff --git a/src/components/FeedbackSurvey/useSurveyState.tsx b/src/components/FeedbackSurvey/useSurveyState.tsx index d98e6d655..e00c82c0d 100644 --- a/src/components/FeedbackSurvey/useSurveyState.tsx +++ b/src/components/FeedbackSurvey/useSurveyState.tsx @@ -1,99 +1,144 @@ -import { randomUUID } from 'crypto'; -import { useCallback, useRef, useState } from 'react'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import type { FeedbackSurveyResponse } from './utils.js'; -type SurveyState = 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; +import { randomUUID } from 'crypto' +import { useCallback, useRef, useState } from 'react' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import type { FeedbackSurveyResponse } from './utils.js' + +type SurveyState = + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + type UseSurveyStateOptions = { - hideThanksAfterMs: number; - onOpen: (appearanceId: string) => void | Promise; - onSelect: (appearanceId: string, selected: FeedbackSurveyResponse) => void | Promise; - shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean; - onTranscriptPromptShown?: (appearanceId: string, surveyResponse: FeedbackSurveyResponse) => void; - onTranscriptSelect?: (appearanceId: string, selected: TranscriptShareResponse, surveyResponse: FeedbackSurveyResponse | null) => boolean | Promise; -}; + hideThanksAfterMs: number + onOpen: (appearanceId: string) => void | Promise + onSelect: ( + appearanceId: string, + selected: FeedbackSurveyResponse, + ) => void | Promise + shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean + onTranscriptPromptShown?: ( + appearanceId: string, + surveyResponse: FeedbackSurveyResponse, + ) => void + onTranscriptSelect?: ( + appearanceId: string, + selected: TranscriptShareResponse, + surveyResponse: FeedbackSurveyResponse | null, + ) => boolean | Promise +} + export function useSurveyState({ hideThanksAfterMs, onOpen, onSelect, shouldShowTranscriptPrompt, onTranscriptPromptShown, - onTranscriptSelect + onTranscriptSelect, }: UseSurveyStateOptions): { - state: SurveyState; - lastResponse: FeedbackSurveyResponse | null; - open: () => void; - handleSelect: (selected: FeedbackSurveyResponse) => boolean; - handleTranscriptSelect: (selected: TranscriptShareResponse) => void; + state: SurveyState + lastResponse: FeedbackSurveyResponse | null + open: () => void + handleSelect: (selected: FeedbackSurveyResponse) => boolean + handleTranscriptSelect: (selected: TranscriptShareResponse) => void } { - const [state, setState] = useState('closed'); - const [lastResponse, setLastResponse] = useState(null); - const appearanceId = useRef(randomUUID()); - const lastResponseRef = useRef(null); + const [state, setState] = useState('closed') + const [lastResponse, setLastResponse] = + useState(null) + const appearanceId = useRef(randomUUID()) + const lastResponseRef = useRef(null) + const showThanksThenClose = useCallback(() => { - setState('thanks'); - setTimeout((setState_0, setLastResponse_0) => { - setState_0('closed'); - setLastResponse_0(null); - }, hideThanksAfterMs, setState, setLastResponse); - }, [hideThanksAfterMs]); + setState('thanks') + setTimeout( + (setState, setLastResponse) => { + setState('closed') + setLastResponse(null) + }, + hideThanksAfterMs, + setState, + setLastResponse, + ) + }, [hideThanksAfterMs]) + const showSubmittedThenClose = useCallback(() => { - setState('submitted'); - setTimeout(setState, hideThanksAfterMs, 'closed'); - }, [hideThanksAfterMs]); + setState('submitted') + setTimeout(setState, hideThanksAfterMs, 'closed') + }, [hideThanksAfterMs]) + const open = useCallback(() => { if (state !== 'closed') { - return; - } - setState('open'); - appearanceId.current = randomUUID(); - void onOpen(appearanceId.current); - }, [state, onOpen]); - const handleSelect = useCallback((selected: FeedbackSurveyResponse): boolean => { - setLastResponse(selected); - lastResponseRef.current = selected; - // Always fire the survey response event first - void onSelect(appearanceId.current, selected); - if (selected === 'dismissed') { - setState('closed'); - setLastResponse(null); - } else if (shouldShowTranscriptPrompt?.(selected)) { - setState('transcript_prompt'); - onTranscriptPromptShown?.(appearanceId.current, selected); - return true; - } else { - showThanksThenClose(); + return } - return false; - }, [showThanksThenClose, onSelect, shouldShowTranscriptPrompt, onTranscriptPromptShown]); - const handleTranscriptSelect = useCallback((selected_0: TranscriptShareResponse) => { - switch (selected_0) { - case 'yes': - setState('submitting'); - void (async () => { - try { - const success = await onTranscriptSelect?.(appearanceId.current, selected_0, lastResponseRef.current); - if (success) { - showSubmittedThenClose(); - } else { - showThanksThenClose(); + setState('open') + appearanceId.current = randomUUID() + void onOpen(appearanceId.current) + }, [state, onOpen]) + + const handleSelect = useCallback( + (selected: FeedbackSurveyResponse): boolean => { + setLastResponse(selected) + lastResponseRef.current = selected + // Always fire the survey response event first + void onSelect(appearanceId.current, selected) + + if (selected === 'dismissed') { + setState('closed') + setLastResponse(null) + } else if (shouldShowTranscriptPrompt?.(selected)) { + setState('transcript_prompt') + onTranscriptPromptShown?.(appearanceId.current, selected) + return true + } else { + showThanksThenClose() + } + return false + }, + [ + showThanksThenClose, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + ], + ) + + const handleTranscriptSelect = useCallback( + (selected: TranscriptShareResponse) => { + switch (selected) { + case 'yes': + setState('submitting') + void (async () => { + try { + const success = await onTranscriptSelect?.( + appearanceId.current, + selected, + lastResponseRef.current, + ) + if (success) { + showSubmittedThenClose() + } else { + showThanksThenClose() + } + } catch { + showThanksThenClose() } - } catch { - showThanksThenClose(); - } - })(); - break; - case 'no': - case 'dont_ask_again': - void onTranscriptSelect?.(appearanceId.current, selected_0, lastResponseRef.current); - showThanksThenClose(); - break; - } - }, [showThanksThenClose, showSubmittedThenClose, onTranscriptSelect]); - return { - state, - lastResponse, - open, - handleSelect, - handleTranscriptSelect - }; + })() + break + case 'no': + case 'dont_ask_again': + void onTranscriptSelect?.( + appearanceId.current, + selected, + lastResponseRef.current, + ) + showThanksThenClose() + break + } + }, + [showThanksThenClose, showSubmittedThenClose, onTranscriptSelect], + ) + + return { state, lastResponse, open, handleSelect, handleTranscriptSelect } } diff --git a/src/components/PromptInput/HistorySearchInput.tsx b/src/components/PromptInput/HistorySearchInput.tsx index dba1f4cda..22830119d 100644 --- a/src/components/PromptInput/HistorySearchInput.tsx +++ b/src/components/PromptInput/HistorySearchInput.tsx @@ -1,50 +1,38 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import TextInput from '../TextInput.js'; +import * as React from 'react' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import TextInput from '../TextInput.js' + type Props = { - value: string; - onChange: (value: string) => void; - historyFailedMatch: boolean; -}; -function HistorySearchInput(t0) { - const $ = _c(9); - const { - value, - onChange, - historyFailedMatch - } = t0; - const t1 = historyFailedMatch ? "no matching prompt:" : "search prompts:"; - let t2; - if ($[0] !== t1) { - t2 = {t1}; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - const t3 = stringWidth(value) + 1; - let t4; - if ($[2] !== onChange || $[3] !== t3 || $[4] !== value) { - t4 = ; - $[2] = onChange; - $[3] = t3; - $[4] = value; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t2 || $[7] !== t4) { - t5 = {t2}{t4}; - $[6] = t2; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - return t5; + value: string + onChange: (value: string) => void + historyFailedMatch: boolean } -function _temp() {} -export default HistorySearchInput; + +function HistorySearchInput({ + value, + onChange, + historyFailedMatch, +}: Props): React.ReactNode { + return ( + + + {historyFailedMatch ? 'no matching prompt:' : 'search prompts:'} + + {}} + columns={stringWidth(value) + 1} + focus={true} + showCursor={true} + multiline={false} + dimColor={true} + /> + + ) +} + +export default HistorySearchInput diff --git a/src/components/PromptInput/IssueFlagBanner.tsx b/src/components/PromptInput/IssueFlagBanner.tsx index bb5d6b5d8..723678eaf 100644 --- a/src/components/PromptInput/IssueFlagBanner.tsx +++ b/src/components/PromptInput/IssueFlagBanner.tsx @@ -1,11 +1,28 @@ -import * as React from 'react'; -import { FLAG_ICON } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; +import * as React from 'react' +import { FLAG_ICON } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' /** * ANT-ONLY: Banner shown in the transcript that prompts users to report * issues via /issue. Appears when friction is detected in the conversation. */ -export function IssueFlagBanner() { - return null; +export function IssueFlagBanner(): React.ReactNode { + if (process.env.USER_TYPE !== 'ant') { + return null + } + + return ( + + + {FLAG_ICON} + + + [ANT-ONLY] + + Something off with Claude? + + /issue to report it + + + ) } diff --git a/src/components/PromptInput/Notifications.tsx b/src/components/PromptInput/Notifications.tsx index 5203318e3..d89d596b3 100644 --- a/src/components/PromptInput/Notifications.tsx +++ b/src/components/PromptInput/Notifications.tsx @@ -1,218 +1,201 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { type ReactNode, useEffect, useMemo, useState } from 'react'; -import { type Notification, useNotifications } from 'src/context/notifications.js'; -import { logEvent } from 'src/services/analytics/index.js'; -import { useAppState } from 'src/state/AppState.js'; -import { useVoiceState } from '../../context/voice.js'; -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; -import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js'; -import type { IDESelection } from '../../hooks/useIdeSelection.js'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'; -import { Box, Text } from '../../ink.js'; -import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js'; -import { calculateTokenWarningState } from '../../services/compact/autoCompact.js'; -import type { MCPServerConnection } from '../../services/mcp/types.js'; -import type { Message } from '../../types/message.js'; -import { getApiKeyHelperElapsedMs, getConfiguredApiKeyHelper, getSubscriptionType } from '../../utils/auth.js'; -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; -import { getExternalEditor } from '../../utils/editor.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { formatDuration } from '../../utils/format.js'; -import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js'; -import { toIDEDisplayName } from '../../utils/ide.js'; -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; -import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js'; -import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { IdeStatusIndicator } from '../IdeStatusIndicator.js'; -import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js'; -import { SentryErrorBoundary } from '../SentryErrorBoundary.js'; -import { TokenWarning } from '../TokenWarning.js'; -import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { type ReactNode, useEffect, useMemo, useState } from 'react' +import { + type Notification, + useNotifications, +} from 'src/context/notifications.js' +import { logEvent } from 'src/services/analytics/index.js' +import { useAppState } from 'src/state/AppState.js' +import { useVoiceState } from '../../context/voice.js' +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' +import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js' +import type { IDESelection } from '../../hooks/useIdeSelection.js' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js' +import { Box, Text } from '../../ink.js' +import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js' +import { calculateTokenWarningState } from '../../services/compact/autoCompact.js' +import type { MCPServerConnection } from '../../services/mcp/types.js' +import type { Message } from '../../types/message.js' +import { + getApiKeyHelperElapsedMs, + getConfiguredApiKeyHelper, + getSubscriptionType, +} from '../../utils/auth.js' +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' +import { getExternalEditor } from '../../utils/editor.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { formatDuration } from '../../utils/format.js' +import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js' +import { toIDEDisplayName } from '../../utils/ide.js' +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' +import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js' +import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { IdeStatusIndicator } from '../IdeStatusIndicator.js' +import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js' +import { SentryErrorBoundary } from '../SentryErrorBoundary.js' +import { TokenWarning } from '../TokenWarning.js' +import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator = feature('VOICE_MODE') ? require('./VoiceIndicator.js').VoiceIndicator : () => null; +const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator = + feature('VOICE_MODE') + ? require('./VoiceIndicator.js').VoiceIndicator + : () => null /* eslint-enable @typescript-eslint/no-require-imports */ -export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000; +export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000 + type Props = { - apiKeyStatus: VerificationStatus; - autoUpdaterResult: AutoUpdaterResult | null; - isAutoUpdating: boolean; - debug: boolean; - verbose: boolean; - messages: Message[]; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - onChangeIsUpdating: (isUpdating: boolean) => void; - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; - isInputWrapped?: boolean; - isNarrow?: boolean; -}; -export function Notifications(t0) { - const $ = _c(34); - const { - apiKeyStatus, - autoUpdaterResult, - debug, - isAutoUpdating, - verbose, - messages, - onAutoUpdaterResult, - onChangeIsUpdating, - ideSelection, - mcpClients, - isInputWrapped: t1, - isNarrow: t2 - } = t0; - const isInputWrapped = t1 === undefined ? false : t1; - const isNarrow = t2 === undefined ? false : t2; - let t3; - if ($[0] !== messages) { - const messagesForTokenCount = getMessagesAfterCompactBoundary(messages); - t3 = tokenCountFromLastAPIResponse(messagesForTokenCount); - $[0] = messages; - $[1] = t3; - } else { - t3 = $[1]; - } - const tokenUsage = t3; - const mainLoopModel = useMainLoopModel(); - let t4; - if ($[2] !== mainLoopModel || $[3] !== tokenUsage) { - t4 = calculateTokenWarningState(tokenUsage, mainLoopModel); - $[2] = mainLoopModel; - $[3] = tokenUsage; - $[4] = t4; - } else { - t4 = $[4]; - } - const isShowingCompactMessage = t4.isAboveWarningThreshold; - const { - status: ideStatus - } = useIdeConnectionStatus(mcpClients); - const notifications = useAppState(_temp); - const { - addNotification, - removeNotification - } = useNotifications(); - const claudeAiLimits = useClaudeAiLimits(); - let t5; - let t6; - if ($[5] !== addNotification) { - t5 = () => { - setEnvHookNotifier((text, isError) => { - addNotification({ - key: "env-hook", - text, - color: isError ? "error" : undefined, - priority: isError ? "medium" : "low", - timeoutMs: isError ? 8000 : 5000 - }); - }); - return _temp2; - }; - t6 = [addNotification]; - $[5] = addNotification; - $[6] = t5; - $[7] = t6; - } else { - t5 = $[6]; - t6 = $[7]; - } - useEffect(t5, t6); - const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0); - const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== "success"; - const isInOverageMode = claudeAiLimits.isUsingOverage; - let t7; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t7 = getSubscriptionType(); - $[8] = t7; - } else { - t7 = $[8]; - } - const subscriptionType = t7; - const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise"; - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = getExternalEditor(); - $[9] = t8; - } else { - t8 = $[9]; - } - const editor = t8; - const shouldShowExternalEditorHint = isInputWrapped && !isShowingCompactMessage && apiKeyStatus !== "invalid" && apiKeyStatus !== "missing" && editor !== undefined; - let t10; - let t9; - if ($[10] !== addNotification || $[11] !== removeNotification || $[12] !== shouldShowExternalEditorHint) { - t9 = () => { - if (shouldShowExternalEditorHint && editor) { - logEvent("tengu_external_editor_hint_shown", {}); - addNotification({ - key: "external-editor-hint", - jsx: , - priority: "immediate", - timeoutMs: 5000 - }); - } else { - removeNotification("external-editor-hint"); - } - }; - t10 = [shouldShowExternalEditorHint, editor, addNotification, removeNotification]; - $[10] = addNotification; - $[11] = removeNotification; - $[12] = shouldShowExternalEditorHint; - $[13] = t10; - $[14] = t9; - } else { - t10 = $[13]; - t9 = $[14]; - } - useEffect(t9, t10); - const t11 = isNarrow ? "flex-start" : "flex-end"; - const t12 = isInOverageMode ?? false; - let t13; - if ($[15] !== apiKeyStatus || $[16] !== autoUpdaterResult || $[17] !== debug || $[18] !== ideSelection || $[19] !== isAutoUpdating || $[20] !== isShowingCompactMessage || $[21] !== mainLoopModel || $[22] !== mcpClients || $[23] !== notifications || $[24] !== onAutoUpdaterResult || $[25] !== onChangeIsUpdating || $[26] !== shouldShowAutoUpdater || $[27] !== t12 || $[28] !== tokenUsage || $[29] !== verbose) { - t13 = ; - $[15] = apiKeyStatus; - $[16] = autoUpdaterResult; - $[17] = debug; - $[18] = ideSelection; - $[19] = isAutoUpdating; - $[20] = isShowingCompactMessage; - $[21] = mainLoopModel; - $[22] = mcpClients; - $[23] = notifications; - $[24] = onAutoUpdaterResult; - $[25] = onChangeIsUpdating; - $[26] = shouldShowAutoUpdater; - $[27] = t12; - $[28] = tokenUsage; - $[29] = verbose; - $[30] = t13; - } else { - t13 = $[30]; - } - let t14; - if ($[31] !== t11 || $[32] !== t13) { - t14 = {t13}; - $[31] = t11; - $[32] = t13; - $[33] = t14; - } else { - t14 = $[33]; - } - return t14; + apiKeyStatus: VerificationStatus + autoUpdaterResult: AutoUpdaterResult | null + isAutoUpdating: boolean + debug: boolean + verbose: boolean + messages: Message[] + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + onChangeIsUpdating: (isUpdating: boolean) => void + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] + isInputWrapped?: boolean + isNarrow?: boolean } -function _temp2() { - return setEnvHookNotifier(null); -} -function _temp(s) { - return s.notifications; + +export function Notifications({ + apiKeyStatus, + autoUpdaterResult, + debug, + isAutoUpdating, + verbose, + messages, + onAutoUpdaterResult, + onChangeIsUpdating, + ideSelection, + mcpClients, + isInputWrapped = false, + isNarrow = false, +}: Props): ReactNode { + const tokenUsage = useMemo(() => { + const messagesForTokenCount = getMessagesAfterCompactBoundary(messages) + return tokenCountFromLastAPIResponse(messagesForTokenCount) + }, [messages]) + + // AppState-sourced model — same source as API requests. getMainLoopModel() + // re-reads settings.json on every call, so another session's /model write + // would leak into this session's display (anthropics/claude-code#37596). + const mainLoopModel = useMainLoopModel() + const isShowingCompactMessage = calculateTokenWarningState( + tokenUsage, + mainLoopModel, + ).isAboveWarningThreshold + const { status: ideStatus } = useIdeConnectionStatus(mcpClients) + const notifications = useAppState(s => s.notifications) + const { addNotification, removeNotification } = useNotifications() + const claudeAiLimits = useClaudeAiLimits() + + // Register env hook notifier for CwdChanged/FileChanged feedback + useEffect(() => { + setEnvHookNotifier((text, isError) => { + addNotification({ + key: 'env-hook', + text, + color: isError ? 'error' : undefined, + priority: isError ? 'medium' : 'low', + timeoutMs: isError ? 8000 : 5000, + }) + }) + return () => setEnvHookNotifier(null) + }, [addNotification]) + + // Check if we should show the IDE selection indicator + const shouldShowIdeSelection = + ideStatus === 'connected' && + (ideSelection?.filePath || + (ideSelection?.text && ideSelection.lineCount > 0)) + + // Hide update installed message when showing IDE selection + const shouldShowAutoUpdater = + !shouldShowIdeSelection || + isAutoUpdating || + autoUpdaterResult?.status !== 'success' + + // Check if we're in overage mode for UI indicators + const isInOverageMode = claudeAiLimits.isUsingOverage + const subscriptionType = getSubscriptionType() + const isTeamOrEnterprise = + subscriptionType === 'team' || subscriptionType === 'enterprise' + + // Check if the external editor hint should be shown + const editor = getExternalEditor() + const shouldShowExternalEditorHint = + isInputWrapped && + !isShowingCompactMessage && + apiKeyStatus !== 'invalid' && + apiKeyStatus !== 'missing' && + editor !== undefined + + // Show external editor hint as notification when input is wrapped + useEffect(() => { + if (shouldShowExternalEditorHint && editor) { + logEvent('tengu_external_editor_hint_shown', {}) + addNotification({ + key: 'external-editor-hint', + jsx: ( + + + + ), + priority: 'immediate', + timeoutMs: 5000, + }) + } else { + removeNotification('external-editor-hint') + } + }, [ + shouldShowExternalEditorHint, + editor, + addNotification, + removeNotification, + ]) + + return ( + + + + + + ) } + function NotificationContent({ ideSelection, mcpClients, @@ -229,103 +212,155 @@ function NotificationContent({ isAutoUpdating, isShowingCompactMessage, onAutoUpdaterResult, - onChangeIsUpdating + onChangeIsUpdating, }: { - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] notifications: { - current: Notification | null; - queue: Notification[]; - }; - isInOverageMode: boolean; - isTeamOrEnterprise: boolean; - apiKeyStatus: VerificationStatus; - debug: boolean; - verbose: boolean; - tokenUsage: number; - mainLoopModel: string; - shouldShowAutoUpdater: boolean; - autoUpdaterResult: AutoUpdaterResult | null; - isAutoUpdating: boolean; - isShowingCompactMessage: boolean; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - onChangeIsUpdating: (isUpdating: boolean) => void; + current: Notification | null + queue: Notification[] + } + isInOverageMode: boolean + isTeamOrEnterprise: boolean + apiKeyStatus: VerificationStatus + debug: boolean + verbose: boolean + tokenUsage: number + mainLoopModel: string + shouldShowAutoUpdater: boolean + autoUpdaterResult: AutoUpdaterResult | null + isAutoUpdating: boolean + isShowingCompactMessage: boolean + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + onChangeIsUpdating: (isUpdating: boolean) => void }): ReactNode { // Poll apiKeyHelper inflight state to show slow-helper notice. // Gated on configuration — most users never set apiKeyHelper, so the // effect is a no-op for them (no interval allocated). - const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState(null); + const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState(null) useEffect(() => { - if (!getConfiguredApiKeyHelper()) return; - const interval = setInterval((setSlow: React.Dispatch>) => { - const ms = getApiKeyHelperElapsedMs(); - const next = ms >= 10_000 ? formatDuration(ms) : null; - setSlow(prev => next === prev ? prev : next); - }, 1000, setApiKeyHelperSlow); - return () => clearInterval(interval); - }, []); + if (!getConfiguredApiKeyHelper()) return + const interval = setInterval( + (setSlow: React.Dispatch>) => { + const ms = getApiKeyHelperElapsedMs() + const next = ms >= 10_000 ? formatDuration(ms) : null + setSlow(prev => (next === prev ? prev : next)) + }, + 1000, + setApiKeyHelperSlow, + ) + return () => clearInterval(interval) + }, []) // Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook) - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle' as const; + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceError = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_0 => s_0.voiceError) : null; - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_1 => s_1.isBriefOnly) : false; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceError = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceError) + : null + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false // When voice is actively recording or processing, replace all // notifications with just the voice indicator. - if (feature('VOICE_MODE') && voiceEnabled && (voiceState === 'recording' || voiceState === 'processing')) { - return ; + if ( + feature('VOICE_MODE') && + voiceEnabled && + (voiceState === 'recording' || voiceState === 'processing') + ) { + return } - return <> + + return ( + <> - {notifications.current && ('jsx' in notifications.current ? + {notifications.current && + ('jsx' in notifications.current ? ( + {notifications.current.jsx} - : + + ) : ( + {notifications.current.text} - )} - {isInOverageMode && !isTeamOrEnterprise && + + ))} + {isInOverageMode && !isTeamOrEnterprise && ( + Now using extra usage - } - {apiKeyHelperSlow && + + )} + {apiKeyHelperSlow && ( + apiKeyHelper is taking a while{' '} ({apiKeyHelperSlow}) - } - {(apiKeyStatus === 'invalid' || apiKeyStatus === 'missing') && + + )} + {(apiKeyStatus === 'invalid' || apiKeyStatus === 'missing') && ( + - {isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ? 'Authentication error · Try again' : 'Not logged in · Run /login'} + {isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) + ? 'Authentication error · Try again' + : 'Not logged in · Run /login'} - } - {debug && + + )} + {debug && ( + Debug mode - } - {apiKeyStatus !== 'invalid' && apiKeyStatus !== 'missing' && verbose && + + )} + {apiKeyStatus !== 'invalid' && apiKeyStatus !== 'missing' && verbose && ( + {tokenUsage} tokens - } - {!isBriefOnly && } - {shouldShowAutoUpdater && } - {feature('VOICE_MODE') ? voiceEnabled && voiceError && + + )} + {!isBriefOnly && ( + + )} + {shouldShowAutoUpdater && ( + + )} + {feature('VOICE_MODE') + ? voiceEnabled && + voiceError && ( + {voiceError} - : null} + + ) + : null} - ; + + ) } diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index bc80851fb..a678d41f6 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -1,196 +1,317 @@ -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import * as path from 'path'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { useCommandQueue } from 'src/hooks/useCommandQueue.js'; -import { type IDEAtMentioned, useIdeAtMentioned } from 'src/hooks/useIdeAtMentioned.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { type AppState, useAppState, useAppStateStore, useSetAppState } from 'src/state/AppState.js'; -import type { FooterItem } from 'src/state/AppStateStore.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { isQueuedCommandEditable, popAllEditable } from 'src/utils/messageQueueManager.js'; -import stripAnsi from 'strip-ansi'; -import { companionReservedColumns } from '../../buddy/CompanionSprite.js'; -import { findBuddyTriggerPositions, useBuddyNotification } from '../../buddy/useBuddyNotification.js'; -import { FastModePicker } from '../../commands/fast/fast.js'; -import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js'; -import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js'; -import { type Command, hasCommand } from '../../commands.js'; -import { useIsModalOverlayActive } from '../../context/overlayContext.js'; -import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js'; -import { formatImageRef, formatPastedTextRef, getPastedTextRefNumLines, parseReferences } from '../../history.js'; -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; -import { type HistoryMode, useArrowKeyHistory } from '../../hooks/useArrowKeyHistory.js'; -import { useDoublePress } from '../../hooks/useDoublePress.js'; -import { useHistorySearch } from '../../hooks/useHistorySearch.js'; -import type { IDESelection } from '../../hooks/useIdeSelection.js'; -import { useInputBuffer } from '../../hooks/useInputBuffer.js'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useTypeahead } from '../../hooks/useTypeahead.js'; -import type { BorderTextOptions } from '../../ink/render-border.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, type ClickEvent, type Key, Text, useInput } from '../../ink.js'; -import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js'; -import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'; -import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { MCPServerConnection } from '../../services/mcp/types.js'; -import { abortPromptSuggestion, logSuggestionSuppressed } from '../../services/PromptSuggestion/promptSuggestion.js'; -import { type ActiveSpeculationState, abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; -import { getActiveAgentForInput, getViewedTeammateTask } from '../../state/selectors.js'; -import { enterTeammateView, exitTeammateView, stopOrDismissAgent } from '../../state/teammateViewHelpers.js'; -import type { ToolPermissionContext } from '../../Tool.js'; -import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { isPanelAgentTask, type LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { isBackgroundTask } from '../../tasks/types.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; -import type { Message } from '../../types/message.js'; -import type { PermissionMode } from '../../types/permissions.js'; -import type { BaseTextInputProps, PromptInputMode, VimMode } from '../../types/textInputTypes.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { count } from '../../utils/array.js'; -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; -import { Cursor } from '../../utils/Cursor.js'; -import { getGlobalConfig, type PastedContent, saveGlobalConfig } from '../../utils/config.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { parseDirectMemberMessage, sendDirectMemberMessage } from '../../utils/directMemberMessage.js'; -import type { EffortLevel } from '../../utils/effort.js'; -import { env } from '../../utils/env.js'; -import { errorMessage } from '../../utils/errors.js'; -import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; -import { getFastModeUnavailableReason, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js'; -import { getImageFromClipboard, PASTE_THRESHOLD } from '../../utils/imagePaste.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import { cacheImagePath, storeImage } from '../../utils/imageStore.js'; -import { isMacosOptionChar, MACOS_OPTION_SPECIAL_CHARS } from '../../utils/keyboardShortcuts.js'; -import { logError } from '../../utils/log.js'; -import { isOpus1mMergeEnabled, modelDisplayString } from '../../utils/model/model.js'; -import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'; -import { cyclePermissionMode, getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js'; -import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'; -import { getPlatform } from '../../utils/platform.js'; -import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; -import { editPromptInEditor } from '../../utils/promptEditor.js'; -import { hasAutoModeOptIn } from '../../utils/settings/settings.js'; -import { findBtwTriggerPositions } from '../../utils/sideQuestion.js'; -import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'; -import { findSlackChannelPositions, getKnownChannelsVersion, hasSlackMcpServer, subscribeKnownChannels } from '../../utils/suggestions/slackChannelSuggestions.js'; -import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'; -import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js'; -import type { TeamSummary } from '../../utils/teamDiscovery.js'; -import { getTeammateColor } from '../../utils/teammate.js'; -import { isInProcessTeammate } from '../../utils/teammateContext.js'; -import { writeToMailbox } from '../../utils/teammateMailbox.js'; -import type { TextHighlight } from '../../utils/textHighlighting.js'; -import type { Theme } from '../../utils/theme.js'; -import { findThinkingTriggerPositions, getRainbowColor, isUltrathinkEnabled } from '../../utils/thinking.js'; -import { findTokenBudgetPositions } from '../../utils/tokenBudget.js'; -import { findUltraplanTriggerPositions, findUltrareviewTriggerPositions } from '../../utils/ultraplan/keyword.js'; -import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js'; -import { BridgeDialog } from '../BridgeDialog.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { getVisibleAgentTasks, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js'; -import { getEffortNotificationText } from '../EffortIndicator.js'; -import { getFastIconString } from '../FastIcon.js'; -import { GlobalSearchDialog } from '../GlobalSearchDialog.js'; -import { HistorySearchDialog } from '../HistorySearchDialog.js'; -import { ModelPicker } from '../ModelPicker.js'; -import { QuickOpenDialog } from '../QuickOpenDialog.js'; -import TextInput from '../TextInput.js'; -import { ThinkingToggle } from '../ThinkingToggle.js'; -import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js'; -import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; -import { TeamsDialog } from '../teams/TeamsDialog.js'; -import VimTextInput from '../VimTextInput.js'; -import { getModeFromInput, getValueFromInput } from './inputModes.js'; -import { FOOTER_TEMPORARY_STATUS_TIMEOUT, Notifications } from './Notifications.js'; -import PromptInputFooter from './PromptInputFooter.js'; -import type { SuggestionItem } from './PromptInputFooterSuggestions.js'; -import { PromptInputModeIndicator } from './PromptInputModeIndicator.js'; -import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js'; -import { PromptInputStashNotice } from './PromptInputStashNotice.js'; -import { useMaybeTruncateInput } from './useMaybeTruncateInput.js'; -import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js'; -import { useShowFastIconHint } from './useShowFastIconHint.js'; -import { useSwarmBanner } from './useSwarmBanner.js'; -import { isNonSpacePrintable, isVimModeEnabled } from './utils.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import * as path from 'path' +import * as React from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { useCommandQueue } from 'src/hooks/useCommandQueue.js' +import { + type IDEAtMentioned, + useIdeAtMentioned, +} from 'src/hooks/useIdeAtMentioned.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + type AppState, + useAppState, + useAppStateStore, + useSetAppState, +} from 'src/state/AppState.js' +import type { FooterItem } from 'src/state/AppStateStore.js' +import { getCwd } from 'src/utils/cwd.js' +import { + isQueuedCommandEditable, + popAllEditable, +} from 'src/utils/messageQueueManager.js' +import stripAnsi from 'strip-ansi' +import { companionReservedColumns } from '../../buddy/CompanionSprite.js' +import { + findBuddyTriggerPositions, + useBuddyNotification, +} from '../../buddy/useBuddyNotification.js' +import { FastModePicker } from '../../commands/fast/fast.js' +import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js' +import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js' +import { type Command, hasCommand } from '../../commands.js' +import { useIsModalOverlayActive } from '../../context/overlayContext.js' +import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js' +import { + formatImageRef, + formatPastedTextRef, + getPastedTextRefNumLines, + parseReferences, +} from '../../history.js' +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' +import { + type HistoryMode, + useArrowKeyHistory, +} from '../../hooks/useArrowKeyHistory.js' +import { useDoublePress } from '../../hooks/useDoublePress.js' +import { useHistorySearch } from '../../hooks/useHistorySearch.js' +import type { IDESelection } from '../../hooks/useIdeSelection.js' +import { useInputBuffer } from '../../hooks/useInputBuffer.js' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { useTypeahead } from '../../hooks/useTypeahead.js' +import type { BorderTextOptions } from '../../ink/render-border.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, type ClickEvent, type Key, Text, useInput } from '../../ink.js' +import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js' +import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' +import { + useKeybinding, + useKeybindings, +} from '../../keybindings/useKeybinding.js' +import type { MCPServerConnection } from '../../services/mcp/types.js' +import { + abortPromptSuggestion, + logSuggestionSuppressed, +} from '../../services/PromptSuggestion/promptSuggestion.js' +import { + type ActiveSpeculationState, + abortSpeculation, +} from '../../services/PromptSuggestion/speculation.js' +import { + getActiveAgentForInput, + getViewedTeammateTask, +} from '../../state/selectors.js' +import { + enterTeammateView, + exitTeammateView, + stopOrDismissAgent, +} from '../../state/teammateViewHelpers.js' +import type { ToolPermissionContext } from '../../Tool.js' +import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' +import { + isPanelAgentTask, + type LocalAgentTaskState, +} from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { isBackgroundTask } from '../../tasks/types.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import type { Message } from '../../types/message.js' +import type { PermissionMode } from '../../types/permissions.js' +import type { + BaseTextInputProps, + PromptInputMode, + VimMode, +} from '../../types/textInputTypes.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { count } from '../../utils/array.js' +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' +import { Cursor } from '../../utils/Cursor.js' +import { + getGlobalConfig, + type PastedContent, + saveGlobalConfig, +} from '../../utils/config.js' +import { logForDebugging } from '../../utils/debug.js' +import { + parseDirectMemberMessage, + sendDirectMemberMessage, +} from '../../utils/directMemberMessage.js' +import type { EffortLevel } from '../../utils/effort.js' +import { env } from '../../utils/env.js' +import { errorMessage } from '../../utils/errors.js' +import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' +import { + getFastModeUnavailableReason, + isFastModeAvailable, + isFastModeCooldown, + isFastModeEnabled, + isFastModeSupportedByModel, +} from '../../utils/fastMode.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js' +import { + getImageFromClipboard, + PASTE_THRESHOLD, +} from '../../utils/imagePaste.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import { cacheImagePath, storeImage } from '../../utils/imageStore.js' +import { + isMacosOptionChar, + MACOS_OPTION_SPECIAL_CHARS, +} from '../../utils/keyboardShortcuts.js' +import { logError } from '../../utils/log.js' +import { + isOpus1mMergeEnabled, + modelDisplayString, +} from '../../utils/model/model.js' +import { setAutoModeActive } from '../../utils/permissions/autoModeState.js' +import { + cyclePermissionMode, + getNextPermissionMode, +} from '../../utils/permissions/getNextPermissionMode.js' +import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js' +import { getPlatform } from '../../utils/platform.js' +import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' +import { editPromptInEditor } from '../../utils/promptEditor.js' +import { hasAutoModeOptIn } from '../../utils/settings/settings.js' +import { findBtwTriggerPositions } from '../../utils/sideQuestion.js' +import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js' +import { + findSlackChannelPositions, + getKnownChannelsVersion, + hasSlackMcpServer, + subscribeKnownChannels, +} from '../../utils/suggestions/slackChannelSuggestions.js' +import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js' +import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js' +import type { TeamSummary } from '../../utils/teamDiscovery.js' +import { getTeammateColor } from '../../utils/teammate.js' +import { isInProcessTeammate } from '../../utils/teammateContext.js' +import { writeToMailbox } from '../../utils/teammateMailbox.js' +import type { TextHighlight } from '../../utils/textHighlighting.js' +import type { Theme } from '../../utils/theme.js' +import { + findThinkingTriggerPositions, + getRainbowColor, + isUltrathinkEnabled, +} from '../../utils/thinking.js' +import { findTokenBudgetPositions } from '../../utils/tokenBudget.js' +import { + findUltraplanTriggerPositions, + findUltrareviewTriggerPositions, +} from '../../utils/ultraplan/keyword.js' +import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js' +import { BridgeDialog } from '../BridgeDialog.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { + getVisibleAgentTasks, + useCoordinatorTaskCount, +} from '../CoordinatorAgentStatus.js' +import { getEffortNotificationText } from '../EffortIndicator.js' +import { getFastIconString } from '../FastIcon.js' +import { GlobalSearchDialog } from '../GlobalSearchDialog.js' +import { HistorySearchDialog } from '../HistorySearchDialog.js' +import { ModelPicker } from '../ModelPicker.js' +import { QuickOpenDialog } from '../QuickOpenDialog.js' +import TextInput from '../TextInput.js' +import { ThinkingToggle } from '../ThinkingToggle.js' +import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js' +import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js' +import { TeamsDialog } from '../teams/TeamsDialog.js' +import VimTextInput from '../VimTextInput.js' +import { getModeFromInput, getValueFromInput } from './inputModes.js' +import { + FOOTER_TEMPORARY_STATUS_TIMEOUT, + Notifications, +} from './Notifications.js' +import PromptInputFooter from './PromptInputFooter.js' +import type { SuggestionItem } from './PromptInputFooterSuggestions.js' +import { PromptInputModeIndicator } from './PromptInputModeIndicator.js' +import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js' +import { PromptInputStashNotice } from './PromptInputStashNotice.js' +import { useMaybeTruncateInput } from './useMaybeTruncateInput.js' +import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js' +import { useShowFastIconHint } from './useShowFastIconHint.js' +import { useSwarmBanner } from './useSwarmBanner.js' +import { isNonSpacePrintable, isVimModeEnabled } from './utils.js' + type Props = { - debug: boolean; - ideSelection: IDESelection | undefined; - toolPermissionContext: ToolPermissionContext; - setToolPermissionContext: (ctx: ToolPermissionContext) => void; - apiKeyStatus: VerificationStatus; - commands: Command[]; - agents: AgentDefinition[]; - isLoading: boolean; - verbose: boolean; - messages: Message[]; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - input: string; - onInputChange: (value: string) => void; - mode: PromptInputMode; - onModeChange: (mode: PromptInputMode) => void; - stashedPrompt: { - text: string; - cursorOffset: number; - pastedContents: Record; - } | undefined; - setStashedPrompt: (value: { - text: string; - cursorOffset: number; - pastedContents: Record; - } | undefined) => void; - submitCount: number; - onShowMessageSelector: () => void; + debug: boolean + ideSelection: IDESelection | undefined + toolPermissionContext: ToolPermissionContext + setToolPermissionContext: (ctx: ToolPermissionContext) => void + apiKeyStatus: VerificationStatus + commands: Command[] + agents: AgentDefinition[] + isLoading: boolean + verbose: boolean + messages: Message[] + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + input: string + onInputChange: (value: string) => void + mode: PromptInputMode + onModeChange: (mode: PromptInputMode) => void + stashedPrompt: + | { + text: string + cursorOffset: number + pastedContents: Record + } + | undefined + setStashedPrompt: ( + value: + | { + text: string + cursorOffset: number + pastedContents: Record + } + | undefined, + ) => void + submitCount: number + onShowMessageSelector: () => void /** Fullscreen message actions: shift+↑ enters cursor. */ - onMessageActionsEnter?: () => void; - mcpClients: MCPServerConnection[]; - pastedContents: Record; - setPastedContents: React.Dispatch>>; - vimMode: VimMode; - setVimMode: (mode: VimMode) => void; - showBashesDialog: string | boolean; - setShowBashesDialog: (show: string | boolean) => void; - onExit: () => void; - getToolUseContext: (messages: Message[], newMessages: Message[], abortController: AbortController, mainLoopModel: string) => ProcessUserInputContext; - onSubmit: (input: string, helpers: PromptInputHelpers, speculationAccept?: { - state: ActiveSpeculationState; - speculationSessionTimeSavedMs: number; - setAppState: (f: (prev: AppState) => AppState) => void; - }, options?: { - fromKeybinding?: boolean; - }) => Promise; - onAgentSubmit?: (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => Promise; - isSearchingHistory: boolean; - setIsSearchingHistory: (isSearching: boolean) => void; - onDismissSideQuestion?: () => void; - isSideQuestionVisible?: boolean; - helpOpen: boolean; - setHelpOpen: React.Dispatch>; - hasSuppressedDialogs?: boolean; - isLocalJSXCommandActive?: boolean; + onMessageActionsEnter?: () => void + mcpClients: MCPServerConnection[] + pastedContents: Record + setPastedContents: React.Dispatch< + React.SetStateAction> + > + vimMode: VimMode + setVimMode: (mode: VimMode) => void + showBashesDialog: string | boolean + setShowBashesDialog: (show: string | boolean) => void + onExit: () => void + getToolUseContext: ( + messages: Message[], + newMessages: Message[], + abortController: AbortController, + mainLoopModel: string, + ) => ProcessUserInputContext + onSubmit: ( + input: string, + helpers: PromptInputHelpers, + speculationAccept?: { + state: ActiveSpeculationState + speculationSessionTimeSavedMs: number + setAppState: (f: (prev: AppState) => AppState) => void + }, + options?: { fromKeybinding?: boolean }, + ) => Promise + onAgentSubmit?: ( + input: string, + task: InProcessTeammateTaskState | LocalAgentTaskState, + helpers: PromptInputHelpers, + ) => Promise + isSearchingHistory: boolean + setIsSearchingHistory: (isSearching: boolean) => void + onDismissSideQuestion?: () => void + isSideQuestionVisible?: boolean + helpOpen: boolean + setHelpOpen: React.Dispatch> + hasSuppressedDialogs?: boolean + isLocalJSXCommandActive?: boolean insertTextRef?: React.MutableRefObject<{ - insert: (text: string) => void; - setInputWithCursor: (value: string, cursor: number) => void; - cursorOffset: number; - } | null>; - voiceInterimRange?: { - start: number; - end: number; - } | null; -}; + insert: (text: string) => void + setInputWithCursor: (value: string, cursor: number) => void + cursorOffset: number + } | null> + voiceInterimRange?: { start: number; end: number } | null +} // Bottom slot has maxHeight="50%"; reserve lines for footer, border, status. -const PROMPT_FOOTER_LINES = 5; -const MIN_INPUT_VIEWPORT_LINES = 3; +const PROMPT_FOOTER_LINES = 5 +const MIN_INPUT_VIEWPORT_LINES = 3 + function PromptInput({ debug, ideSelection, @@ -233,276 +354,359 @@ function PromptInput({ hasSuppressedDialogs, isLocalJSXCommandActive = false, insertTextRef, - voiceInterimRange + voiceInterimRange, }: Props): React.ReactNode { - const mainLoopModel = useMainLoopModel(); + const mainLoopModel = useMainLoopModel() // A local-jsx command (e.g., /mcp while agent is running) renders a full- // screen dialog on top of PromptInput via the immediate-command path with // shouldHidePromptInput: false. Those dialogs don't register in the overlay // system, so treat them as a modal overlay here to stop navigation keys from // leaking into TextInput/footer handlers and stacking a second dialog. - const isModalOverlayActive = useIsModalOverlayActive() || isLocalJSXCommandActive; - const [isAutoUpdating, setIsAutoUpdating] = useState(false); + const isModalOverlayActive = + useIsModalOverlayActive() || isLocalJSXCommandActive + const [isAutoUpdating, setIsAutoUpdating] = useState(false) const [exitMessage, setExitMessage] = useState<{ - show: boolean; - key?: string; - }>({ - show: false - }); - const [cursorOffset, setCursorOffset] = useState(input.length); + show: boolean + key?: string + }>({ show: false }) + const [cursorOffset, setCursorOffset] = useState(input.length) // Track the last input value set via internal handlers so we can detect // external input changes (e.g. speech-to-text injection) and move cursor to end. - const lastInternalInputRef = React.useRef(input); + const lastInternalInputRef = React.useRef(input) if (input !== lastInternalInputRef.current) { // Input changed externally (not through any internal handler) — move cursor to end - setCursorOffset(input.length); - lastInternalInputRef.current = input; + setCursorOffset(input.length) + lastInternalInputRef.current = input } // Wrap onInputChange to track internal changes before they trigger re-render - const trackAndSetInput = React.useCallback((value: string) => { - lastInternalInputRef.current = value; - onInputChange(value); - }, [onInputChange]); + const trackAndSetInput = React.useCallback( + (value: string) => { + lastInternalInputRef.current = value + onInputChange(value) + }, + [onInputChange], + ) // Expose an insertText function so callers (e.g. STT) can splice text at the // current cursor position instead of replacing the entire input. if (insertTextRef) { insertTextRef.current = { cursorOffset, insert: (text: string) => { - const needsSpace = cursorOffset === input.length && input.length > 0 && !/\s$/.test(input); - const insertText = needsSpace ? ' ' + text : text; - const newValue = input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset); - lastInternalInputRef.current = newValue; - onInputChange(newValue); - setCursorOffset(cursorOffset + insertText.length); + const needsSpace = + cursorOffset === input.length && + input.length > 0 && + !/\s$/.test(input) + const insertText = needsSpace ? ' ' + text : text + const newValue = + input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset) + lastInternalInputRef.current = newValue + onInputChange(newValue) + setCursorOffset(cursorOffset + insertText.length) }, setInputWithCursor: (value: string, cursor: number) => { - lastInternalInputRef.current = value; - onInputChange(value); - setCursorOffset(cursor); - } - }; + lastInternalInputRef.current = value + onInputChange(value) + setCursorOffset(cursor) + }, + } } - const store = useAppStateStore(); - const setAppState = useSetAppState(); - const tasks = useAppState(s => s.tasks); - const replBridgeConnected = useAppState(s => s.replBridgeConnected); - const replBridgeExplicit = useAppState(s => s.replBridgeExplicit); - const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting); + const store = useAppStateStore() + const setAppState = useSetAppState() + const tasks = useAppState(s => s.tasks) + const replBridgeConnected = useAppState(s => s.replBridgeConnected) + const replBridgeExplicit = useAppState(s => s.replBridgeExplicit) + const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting) // Must match BridgeStatusIndicator's render condition (PromptInputFooter.tsx) — // the pill returns null for implicit-and-not-reconnecting, so nav must too, // otherwise bridge becomes an invisible selection stop. - const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting); + const bridgeFooterVisible = + replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting) // Tmux pill (ant-only) — visible when there's an active tungsten session - const hasTungstenSession = useAppState(s => (process.env.USER_TYPE) === 'ant' && s.tungstenActiveSession !== undefined); - const tmuxFooterVisible = (process.env.USER_TYPE) === 'ant' && hasTungstenSession; + const hasTungstenSession = useAppState( + s => + process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined, + ) + const tmuxFooterVisible = + process.env.USER_TYPE === 'ant' && hasTungstenSession // WebBrowser pill — visible when a browser is open - const bagelFooterVisible = useAppState(s => false); - const teamContext = useAppState(s => s.teamContext); - const queuedCommands = useCommandQueue(); - const promptSuggestionState = useAppState(s => s.promptSuggestion); - const speculation = useAppState(s => s.speculation); - const speculationSessionTimeSavedMs = useAppState(s => s.speculationSessionTimeSavedMs); - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); - const viewSelectionMode = useAppState(s => s.viewSelectionMode); - const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates'; - const { - companion: _companion, - companionMuted - } = feature('BUDDY') ? getGlobalConfig() : { - companion: undefined, - companionMuted: undefined - }; - const companionFooterVisible = !!_companion && !companionMuted; + const bagelFooterVisible = useAppState(s => + false, + ) + const teamContext = useAppState(s => s.teamContext) + const queuedCommands = useCommandQueue() + const promptSuggestionState = useAppState(s => s.promptSuggestion) + const speculation = useAppState(s => s.speculation) + const speculationSessionTimeSavedMs = useAppState( + s => s.speculationSessionTimeSavedMs, + ) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const viewSelectionMode = useAppState(s => s.viewSelectionMode) + const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' + const { companion: _companion, companionMuted } = feature('BUDDY') + ? getGlobalConfig() + : { companion: undefined, companionMuted: undefined } + const companionFooterVisible = !!_companion && !companionMuted // Brief mode: BriefSpinner/BriefIdleStatus own the 2-row footprint above // the input. Dropping marginTop here lets the spinner sit flush against // the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx, // REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has // its own marginTop, so the gap stays even without ours. - const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false; - const mainLoopModel_ = useAppState(s => s.mainLoopModel); - const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession); - const thinkingEnabled = useAppState(s => s.thinkingEnabled); - const isFastMode = useAppState(s => isFastModeEnabled() ? s.fastMode : false); - const effortValue = useAppState(s => s.effortValue); - const viewedTeammate = getViewedTeammateTask(store.getState()); - const viewingAgentName = viewedTeammate?.identity.agentName; + const briefOwnsGap = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) && !viewingAgentTaskId + : false + const mainLoopModel_ = useAppState(s => s.mainLoopModel) + const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession) + const thinkingEnabled = useAppState(s => s.thinkingEnabled) + const isFastMode = useAppState(s => + isFastModeEnabled() ? s.fastMode : false, + ) + const effortValue = useAppState(s => s.effortValue) + const viewedTeammate = getViewedTeammateTask(store.getState()) + const viewingAgentName = viewedTeammate?.identity.agentName // identity.color is typed as `string | undefined` (not AgentColorName) because // teammate identity comes from file-based config. Validate before casting to // ensure we only use valid color names (falls back to cyan if invalid). - const viewingAgentColor = viewedTeammate?.identity.color && AGENT_COLORS.includes(viewedTeammate.identity.color as AgentColorName) ? viewedTeammate.identity.color as AgentColorName : undefined; + const viewingAgentColor = + viewedTeammate?.identity.color && + AGENT_COLORS.includes(viewedTeammate.identity.color as AgentColorName) + ? (viewedTeammate.identity.color as AgentColorName) + : undefined // In-process teammates sorted alphabetically for footer team selector - const inProcessTeammates = useMemo(() => getRunningTeammatesSorted(tasks), [tasks]); + const inProcessTeammates = useMemo( + () => getRunningTeammatesSorted(tasks), + [tasks], + ) // Team mode: all background tasks are in-process teammates - const isTeammateMode = inProcessTeammates.length > 0 || viewedTeammate !== undefined; + const isTeammateMode = + inProcessTeammates.length > 0 || viewedTeammate !== undefined // When viewing a teammate, show their permission mode in the footer instead of the leader's const effectiveToolPermissionContext = useMemo((): ToolPermissionContext => { if (viewedTeammate) { return { ...toolPermissionContext, - mode: viewedTeammate.permissionMode - }; + mode: viewedTeammate.permissionMode, + } } - return toolPermissionContext; - }, [viewedTeammate, toolPermissionContext]); - const { - historyQuery, - setHistoryQuery, - historyMatch, - historyFailedMatch - } = useHistorySearch(entry => { - setPastedContents(entry.pastedContents); - void onSubmit(entry.display); - }, input, trackAndSetInput, setCursorOffset, cursorOffset, onModeChange, mode, isSearchingHistory, setIsSearchingHistory, setPastedContents, pastedContents); + return toolPermissionContext + }, [viewedTeammate, toolPermissionContext]) + const { historyQuery, setHistoryQuery, historyMatch, historyFailedMatch } = + useHistorySearch( + entry => { + setPastedContents(entry.pastedContents) + void onSubmit(entry.display) + }, + input, + trackAndSetInput, + setCursorOffset, + cursorOffset, + onModeChange, + mode, + isSearchingHistory, + setIsSearchingHistory, + setPastedContents, + pastedContents, + ) // Counter for paste IDs (shared between images and text). // Compute initial value once from existing messages (for --continue/--resume). // useRef(fn()) evaluates fn() on every render and discards the result after // mount — getInitialPasteId walks all messages + regex-scans text blocks, // so guard with a lazy-init pattern to run it exactly once. - const nextPasteIdRef = useRef(-1); + const nextPasteIdRef = useRef(-1) if (nextPasteIdRef.current === -1) { - nextPasteIdRef.current = getInitialPasteId(messages); + nextPasteIdRef.current = getInitialPasteId(messages) } // Armed by onImagePaste; if the very next keystroke is a non-space // printable, inputFilter prepends a space before it. Any other input // (arrow, escape, backspace, paste, space) disarms without inserting. - const pendingSpaceAfterPillRef = useRef(false); - const [showTeamsDialog, setShowTeamsDialog] = useState(false); - const [showBridgeDialog, setShowBridgeDialog] = useState(false); - const [teammateFooterIndex, setTeammateFooterIndex] = useState(0); + const pendingSpaceAfterPillRef = useRef(false) + + const [showTeamsDialog, setShowTeamsDialog] = useState(false) + const [showBridgeDialog, setShowBridgeDialog] = useState(false) + const [teammateFooterIndex, setTeammateFooterIndex] = useState(0) // -1 sentinel: tasks pill is selected but no specific agent row is selected yet. // First ↓ selects the pill, second ↓ moves to row 0. Prevents double-select // of pill + row when both bg tasks (pill) and forked agents (rows) are visible. - const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); - const setCoordinatorTaskIndex = useCallback((v: number | ((prev: number) => number)) => setAppState(prev => { - const next = typeof v === 'function' ? v(prev.coordinatorTaskIndex) : v; - if (next === prev.coordinatorTaskIndex) return prev; - return { - ...prev, - coordinatorTaskIndex: next - }; - }), [setAppState]); - const coordinatorTaskCount = useCoordinatorTaskCount(); + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) + const setCoordinatorTaskIndex = useCallback( + (v: number | ((prev: number) => number)) => + setAppState(prev => { + const next = typeof v === 'function' ? v(prev.coordinatorTaskIndex) : v + if (next === prev.coordinatorTaskIndex) return prev + return { ...prev, coordinatorTaskIndex: next } + }), + [setAppState], + ) + const coordinatorTaskCount = useCoordinatorTaskCount() // The pill (BackgroundTaskStatus) only renders when non-local_agent bg tasks // exist. When only local_agent tasks are running (coordinator/fork mode), the // pill is absent, so the -1 sentinel would leave nothing visually selected. // In that case, skip -1 and treat 0 as the minimum selectable index. - const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]); - const minCoordinatorIndex = hasBgTaskPill ? -1 : 0; + const hasBgTaskPill = useMemo( + () => + Object.values(tasks).some( + t => + isBackgroundTask(t) && + !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + ), + [tasks], + ) + const minCoordinatorIndex = hasBgTaskPill ? -1 : 0 // Clamp index when tasks complete and the list shrinks beneath the cursor useEffect(() => { if (coordinatorTaskIndex >= coordinatorTaskCount) { - setCoordinatorTaskIndex(Math.max(minCoordinatorIndex, coordinatorTaskCount - 1)); + setCoordinatorTaskIndex( + Math.max(minCoordinatorIndex, coordinatorTaskCount - 1), + ) } else if (coordinatorTaskIndex < minCoordinatorIndex) { - setCoordinatorTaskIndex(minCoordinatorIndex); - } - }, [coordinatorTaskCount, coordinatorTaskIndex, minCoordinatorIndex]); - const [isPasting, setIsPasting] = useState(false); - const [isExternalEditorActive, setIsExternalEditorActive] = useState(false); - const [showModelPicker, setShowModelPicker] = useState(false); - const [showQuickOpen, setShowQuickOpen] = useState(false); - const [showGlobalSearch, setShowGlobalSearch] = useState(false); - const [showHistoryPicker, setShowHistoryPicker] = useState(false); - const [showFastModePicker, setShowFastModePicker] = useState(false); - const [showThinkingToggle, setShowThinkingToggle] = useState(false); - const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false); - const [previousModeBeforeAuto, setPreviousModeBeforeAuto] = useState(null); - const autoModeOptInTimeoutRef = useRef(null); + setCoordinatorTaskIndex(minCoordinatorIndex) + } + }, [coordinatorTaskCount, coordinatorTaskIndex, minCoordinatorIndex]) + const [isPasting, setIsPasting] = useState(false) + const [isExternalEditorActive, setIsExternalEditorActive] = useState(false) + const [showModelPicker, setShowModelPicker] = useState(false) + const [showQuickOpen, setShowQuickOpen] = useState(false) + const [showGlobalSearch, setShowGlobalSearch] = useState(false) + const [showHistoryPicker, setShowHistoryPicker] = useState(false) + const [showFastModePicker, setShowFastModePicker] = useState(false) + const [showThinkingToggle, setShowThinkingToggle] = useState(false) + const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false) + const [previousModeBeforeAuto, setPreviousModeBeforeAuto] = + useState(null) + const autoModeOptInTimeoutRef = useRef(null) // Check if cursor is on the first line of input const isCursorOnFirstLine = useMemo(() => { - const firstNewlineIndex = input.indexOf('\n'); + const firstNewlineIndex = input.indexOf('\n') if (firstNewlineIndex === -1) { - return true; // No newlines, cursor is always on first line + return true // No newlines, cursor is always on first line } - return cursorOffset <= firstNewlineIndex; - }, [input, cursorOffset]); + return cursorOffset <= firstNewlineIndex + }, [input, cursorOffset]) + const isCursorOnLastLine = useMemo(() => { - const lastNewlineIndex = input.lastIndexOf('\n'); + const lastNewlineIndex = input.lastIndexOf('\n') if (lastNewlineIndex === -1) { - return true; // No newlines, cursor is always on last line + return true // No newlines, cursor is always on last line } - return cursorOffset > lastNewlineIndex; - }, [input, cursorOffset]); + return cursorOffset > lastNewlineIndex + }, [input, cursorOffset]) // Derive team info from teamContext (no filesystem I/O needed) // A session can only lead one team at a time const cachedTeams: TeamSummary[] = useMemo(() => { - if (!isAgentSwarmsEnabled()) return []; + if (!isAgentSwarmsEnabled()) return [] // In-process mode uses Shift+Down/Up navigation instead of footer menu - if (isInProcessEnabled()) return []; + if (isInProcessEnabled()) return [] if (!teamContext) { - return []; - } - const teammateCount = count(Object.values(teamContext.teammates), t => t.name !== 'team-lead'); - return [{ - name: teamContext.teamName, - memberCount: teammateCount, - runningCount: 0, - idleCount: 0 - }]; - }, [teamContext]); + return [] + } + const teammateCount = count( + Object.values(teamContext.teammates), + t => t.name !== 'team-lead', + ) + return [ + { + name: teamContext.teamName, + memberCount: teammateCount, + runningCount: 0, + idleCount: 0, + }, + ] + }, [teamContext]) // ─── Footer pill navigation ───────────────────────────────────────────── // Which pills render below the input box. Order here IS the nav order // (down/right = forward, up/left = back). Selection lives in AppState so // pills rendered outside PromptInput (CompanionSprite) can read focus. - const runningTaskCount = useMemo(() => count(Object.values(tasks), t => t.status === 'running'), [tasks]); + const runningTaskCount = useMemo( + () => count(Object.values(tasks), t => t.status === 'running'), + [tasks], + ) // Panel shows retained-completed agents too (getVisibleAgentTasks), so the // pill must stay navigable whenever the panel has rows — not just when // something is running. - const tasksFooterVisible = (runningTaskCount > 0 || (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree); - const teamsFooterVisible = cachedTeams.length > 0; - const footerItems = useMemo(() => [tasksFooterVisible && 'tasks', tmuxFooterVisible && 'tmux', bagelFooterVisible && 'bagel', teamsFooterVisible && 'teams', bridgeFooterVisible && 'bridge', companionFooterVisible && 'companion'].filter(Boolean) as FooterItem[], [tasksFooterVisible, tmuxFooterVisible, bagelFooterVisible, teamsFooterVisible, bridgeFooterVisible, companionFooterVisible]); + const tasksFooterVisible = + (runningTaskCount > 0 || + (process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) && + !shouldHideTasksFooter(tasks, showSpinnerTree) + const teamsFooterVisible = cachedTeams.length > 0 + + const footerItems = useMemo( + () => + [ + tasksFooterVisible && 'tasks', + tmuxFooterVisible && 'tmux', + bagelFooterVisible && 'bagel', + teamsFooterVisible && 'teams', + bridgeFooterVisible && 'bridge', + companionFooterVisible && 'companion', + ].filter(Boolean) as FooterItem[], + [ + tasksFooterVisible, + tmuxFooterVisible, + bagelFooterVisible, + teamsFooterVisible, + bridgeFooterVisible, + companionFooterVisible, + ], + ) // Effective selection: null if the selected pill stopped rendering (bridge // disconnected, task finished). The derivation makes the UI correct // immediately; the useEffect below clears the raw state so it doesn't // resurrect when the same pill reappears (new task starts → focus stolen). - const rawFooterSelection = useAppState(s => s.footerSelection); - const footerItemSelected = rawFooterSelection && footerItems.includes(rawFooterSelection) ? rawFooterSelection : null; + const rawFooterSelection = useAppState(s => s.footerSelection) + const footerItemSelected = + rawFooterSelection && footerItems.includes(rawFooterSelection) + ? rawFooterSelection + : null + useEffect(() => { if (rawFooterSelection && !footerItemSelected) { - setAppState(prev => prev.footerSelection === null ? prev : { - ...prev, - footerSelection: null - }); - } - }, [rawFooterSelection, footerItemSelected, setAppState]); - const tasksSelected = footerItemSelected === 'tasks'; - const tmuxSelected = footerItemSelected === 'tmux'; - const bagelSelected = footerItemSelected === 'bagel'; - const teamsSelected = footerItemSelected === 'teams'; - const bridgeSelected = footerItemSelected === 'bridge'; + setAppState(prev => + prev.footerSelection === null + ? prev + : { ...prev, footerSelection: null }, + ) + } + }, [rawFooterSelection, footerItemSelected, setAppState]) + + const tasksSelected = footerItemSelected === 'tasks' + const tmuxSelected = footerItemSelected === 'tmux' + const bagelSelected = footerItemSelected === 'bagel' + const teamsSelected = footerItemSelected === 'teams' + const bridgeSelected = footerItemSelected === 'bridge' + function selectFooterItem(item: FooterItem | null): void { - setAppState(prev => prev.footerSelection === item ? prev : { - ...prev, - footerSelection: item - }); + setAppState(prev => + prev.footerSelection === item ? prev : { ...prev, footerSelection: item }, + ) if (item === 'tasks') { - setTeammateFooterIndex(0); - setCoordinatorTaskIndex(minCoordinatorIndex); + setTeammateFooterIndex(0) + setCoordinatorTaskIndex(minCoordinatorIndex) } } // delta: +1 = down/right, -1 = up/left. Returns true if nav happened // (including deselecting at the start), false if at a boundary. function navigateFooter(delta: 1 | -1, exitAtStart = false): boolean { - const idx = footerItemSelected ? footerItems.indexOf(footerItemSelected) : -1; - const next = footerItems[idx + delta]; + const idx = footerItemSelected + ? footerItems.indexOf(footerItemSelected) + : -1 + const next = footerItems[idx + delta] if (next) { - selectFooterItem(next); - return true; + selectFooterItem(next) + return true } if (delta < 0 && exitAtStart) { - selectFooterItem(null); - return true; + selectFooterItem(null) + return true } - return false; + return false } // Prompt suggestion hook - reads suggestions generated by forked agent in query loop @@ -510,96 +714,159 @@ function PromptInput({ suggestion: promptSuggestion, markAccepted, logOutcomeAtSubmission, - markShown + markShown, } = usePromptSuggestion({ inputValue: input, - isAssistantResponding: isLoading - }); - const displayedValue = useMemo(() => isSearchingHistory && historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input, [isSearchingHistory, historyMatch, input]); - const thinkTriggers = useMemo(() => findThinkingTriggerPositions(displayedValue), [displayedValue]); - const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl); - const ultraplanLaunching = useAppState(s => s.ultraplanLaunching); - const ultraplanTriggers = useMemo(() => feature('ULTRAPLAN') && !ultraplanSessionUrl && !ultraplanLaunching ? findUltraplanTriggerPositions(displayedValue) : [], [displayedValue, ultraplanSessionUrl, ultraplanLaunching]); - const ultrareviewTriggers = useMemo(() => isUltrareviewEnabled() ? findUltrareviewTriggerPositions(displayedValue) : [], [displayedValue]); - const btwTriggers = useMemo(() => findBtwTriggerPositions(displayedValue), [displayedValue]); - const buddyTriggers = useMemo(() => findBuddyTriggerPositions(displayedValue), [displayedValue]); + isAssistantResponding: isLoading, + }) + + const displayedValue = useMemo( + () => + isSearchingHistory && historyMatch + ? getValueFromInput( + typeof historyMatch === 'string' + ? historyMatch + : historyMatch.display, + ) + : input, + [isSearchingHistory, historyMatch, input], + ) + + const thinkTriggers = useMemo( + () => findThinkingTriggerPositions(displayedValue), + [displayedValue], + ) + + const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl) + const ultraplanLaunching = useAppState(s => s.ultraplanLaunching) + const ultraplanTriggers = useMemo( + () => + feature('ULTRAPLAN') && !ultraplanSessionUrl && !ultraplanLaunching + ? findUltraplanTriggerPositions(displayedValue) + : [], + [displayedValue, ultraplanSessionUrl, ultraplanLaunching], + ) + + const ultrareviewTriggers = useMemo( + () => + isUltrareviewEnabled() + ? findUltrareviewTriggerPositions(displayedValue) + : [], + [displayedValue], + ) + + const btwTriggers = useMemo( + () => findBtwTriggerPositions(displayedValue), + [displayedValue], + ) + + const buddyTriggers = useMemo( + () => findBuddyTriggerPositions(displayedValue), + [displayedValue], + ) + const slashCommandTriggers = useMemo(() => { - const positions = findSlashCommandPositions(displayedValue); + const positions = findSlashCommandPositions(displayedValue) // Only highlight valid commands return positions.filter(pos => { - const commandName = displayedValue.slice(pos.start + 1, pos.end); // +1 to skip "/" - return hasCommand(commandName, commands); - }); - }, [displayedValue, commands]); - const tokenBudgetTriggers = useMemo(() => feature('TOKEN_BUDGET') ? findTokenBudgetPositions(displayedValue) : [], [displayedValue]); - const knownChannelsVersion = useSyncExternalStore(subscribeKnownChannels, getKnownChannelsVersion); - const slackChannelTriggers = useMemo(() => hasSlackMcpServer(store.getState().mcp.clients) ? findSlackChannelPositions(displayedValue) : [], - // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref - [displayedValue, knownChannelsVersion]); + const commandName = displayedValue.slice(pos.start + 1, pos.end) // +1 to skip "/" + return hasCommand(commandName, commands) + }) + }, [displayedValue, commands]) + + const tokenBudgetTriggers = useMemo( + () => + feature('TOKEN_BUDGET') ? findTokenBudgetPositions(displayedValue) : [], + [displayedValue], + ) + + const knownChannelsVersion = useSyncExternalStore( + subscribeKnownChannels, + getKnownChannelsVersion, + ) + const slackChannelTriggers = useMemo( + () => + hasSlackMcpServer(store.getState().mcp.clients) + ? findSlackChannelPositions(displayedValue) + : [], + // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref + [displayedValue, knownChannelsVersion], + ) // Find @name mentions and highlight with team member's color const memberMentionHighlights = useMemo((): Array<{ - start: number; - end: number; - themeColor: keyof Theme; + start: number + end: number + themeColor: keyof Theme }> => { - if (!isAgentSwarmsEnabled()) return []; - if (!teamContext?.teammates) return []; + if (!isAgentSwarmsEnabled()) return [] + if (!teamContext?.teammates) return [] + const highlights: Array<{ - start: number; - end: number; - themeColor: keyof Theme; - }> = []; - const members = teamContext.teammates; - if (!members) return highlights; + start: number + end: number + themeColor: keyof Theme + }> = [] + const members = teamContext.teammates + if (!members) return highlights // Find all @name patterns in the input - const regex = /(^|\s)@([\w-]+)/g; - const memberValues = Object.values(members); - let match; + const regex = /(^|\s)@([\w-]+)/g + const memberValues = Object.values(members) + let match while ((match = regex.exec(displayedValue)) !== null) { - const leadingSpace = match[1] ?? ''; - const nameStart = match.index + leadingSpace.length; - const fullMatch = match[0].trimStart(); - const name = match[2]; + const leadingSpace = match[1] ?? '' + const nameStart = match.index + leadingSpace.length + const fullMatch = match[0].trimStart() + const name = match[2] // Check if this name matches a team member - const member = memberValues.find(t => t.name === name); + const member = memberValues.find(t => t.name === name) if (member?.color) { - const themeColor = AGENT_COLOR_TO_THEME_COLOR[member.color as AgentColorName]; + const themeColor = + AGENT_COLOR_TO_THEME_COLOR[member.color as AgentColorName] if (themeColor) { highlights.push({ start: nameStart, end: nameStart + fullMatch.length, - themeColor - }); + themeColor, + }) } } } - return highlights; - }, [displayedValue, teamContext]); - const imageRefPositions = useMemo(() => parseReferences(displayedValue).filter(r => r.match.startsWith('[Image')).map(r => ({ - start: r.index, - end: r.index + r.match.length - })), [displayedValue]); + return highlights + }, [displayedValue, teamContext]) + + const imageRefPositions = useMemo( + () => + parseReferences(displayedValue) + .filter(r => r.match.startsWith('[Image')) + .map(r => ({ start: r.index, end: r.index + r.match.length })), + [displayedValue], + ) // chip.start is the "selected" state: the inverted chip IS the cursor. // chip.end stays a normal position so you can park the cursor right after // `]` like any other character. - const cursorAtImageChip = imageRefPositions.some(r => r.start === cursorOffset); + const cursorAtImageChip = imageRefPositions.some( + r => r.start === cursorOffset, + ) // up/down movement or a fullscreen click can land the cursor strictly // inside a chip; snap to the nearer boundary so it's never editable // char-by-char. useEffect(() => { - const inside = imageRefPositions.find(r => cursorOffset > r.start && cursorOffset < r.end); + const inside = imageRefPositions.find( + r => cursorOffset > r.start && cursorOffset < r.end, + ) if (inside) { - const mid = (inside.start + inside.end) / 2; - setCursorOffset(cursorOffset < mid ? inside.start : inside.end); + const mid = (inside.start + inside.end) / 2 + setCursorOffset(cursorOffset < mid ? inside.start : inside.end) } - }, [cursorOffset, imageRefPositions, setCursorOffset]); + }, [cursorOffset, imageRefPositions, setCursorOffset]) + const combinedHighlights = useMemo((): TextHighlight[] => { - const highlights: TextHighlight[] = []; + const highlights: TextHighlight[] = [] // Invert the [Image #N] chip when the cursor is at chip.start (the // "selected" state) so backspace-to-delete is visually obvious. @@ -610,17 +877,18 @@ function PromptInput({ end: ref.end, color: undefined, inverse: true, - priority: 8 - }); + priority: 8, + }) } } + if (isSearchingHistory && historyMatch && !historyFailedMatch) { highlights.push({ start: cursorOffset, end: cursorOffset + historyQuery.length, color: 'warning', - priority: 20 - }); + priority: 20, + }) } // Add "btw" highlighting (solid yellow) @@ -629,8 +897,8 @@ function PromptInput({ start: trigger.start, end: trigger.end, color: 'warning', - priority: 15 - }); + priority: 15, + }) } // Add /command highlighting (blue) @@ -639,8 +907,8 @@ function PromptInput({ start: trigger.start, end: trigger.end, color: 'suggestion', - priority: 5 - }); + priority: 5, + }) } // Add token budget highlighting (blue) @@ -649,16 +917,17 @@ function PromptInput({ start: trigger.start, end: trigger.end, color: 'suggestion', - priority: 5 - }); + priority: 5, + }) } + for (const trigger of slackChannelTriggers) { highlights.push({ start: trigger.start, end: trigger.end, color: 'suggestion', - priority: 5 - }); + priority: 5, + }) } // Add @name highlighting with team member's color @@ -667,8 +936,8 @@ function PromptInput({ start: mention.start, end: mention.end, color: mention.themeColor, - priority: 5 - }); + priority: 5, + }) } // Dim interim voice dictation text @@ -678,8 +947,8 @@ function PromptInput({ end: voiceInterimRange.end, color: undefined, dimColor: true, - priority: 1 - }); + priority: 1, + }) } // Rainbow highlighting for ultrathink keyword (per-character cycling colors) @@ -691,8 +960,8 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } } @@ -706,8 +975,8 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } } @@ -720,8 +989,8 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } @@ -733,16 +1002,33 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } - return highlights; - }, [isSearchingHistory, historyQuery, historyMatch, historyFailedMatch, cursorOffset, btwTriggers, imageRefPositions, memberMentionHighlights, slashCommandTriggers, tokenBudgetTriggers, slackChannelTriggers, displayedValue, voiceInterimRange, thinkTriggers, ultraplanTriggers, ultrareviewTriggers, buddyTriggers]); - const { - addNotification, - removeNotification - } = useNotifications(); + + return highlights + }, [ + isSearchingHistory, + historyQuery, + historyMatch, + historyFailedMatch, + cursorOffset, + btwTriggers, + imageRefPositions, + memberMentionHighlights, + slashCommandTriggers, + tokenBudgetTriggers, + slackChannelTriggers, + displayedValue, + voiceInterimRange, + thinkTriggers, + ultraplanTriggers, + ultrareviewTriggers, + buddyTriggers, + ]) + + const { addNotification, removeNotification } = useNotifications() // Show ultrathink notification useEffect(() => { @@ -751,364 +1037,463 @@ function PromptInput({ key: 'ultrathink-active', text: 'Effort set to high for this turn', priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } else { - removeNotification('ultrathink-active'); + removeNotification('ultrathink-active') } - }, [addNotification, removeNotification, thinkTriggers.length]); + }, [addNotification, removeNotification, thinkTriggers.length]) + useEffect(() => { if (feature('ULTRAPLAN') && ultraplanTriggers.length) { addNotification({ key: 'ultraplan-active', text: 'This prompt will launch an ultraplan session in Claude Code on the web', priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } else { - removeNotification('ultraplan-active'); + removeNotification('ultraplan-active') } - }, [addNotification, removeNotification, ultraplanTriggers.length]); + }, [addNotification, removeNotification, ultraplanTriggers.length]) + useEffect(() => { if (isUltrareviewEnabled() && ultrareviewTriggers.length) { addNotification({ key: 'ultrareview-active', text: 'Run /ultrareview after Claude finishes to review these changes in the cloud', priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } - }, [addNotification, ultrareviewTriggers.length]); + }, [addNotification, ultrareviewTriggers.length]) // Track input length for stash hint - const prevInputLengthRef = useRef(input.length); - const peakInputLengthRef = useRef(input.length); + const prevInputLengthRef = useRef(input.length) + const peakInputLengthRef = useRef(input.length) // Dismiss stash hint when user makes any input change const dismissStashHint = useCallback(() => { - removeNotification('stash-hint'); - }, [removeNotification]); + removeNotification('stash-hint') + }, [removeNotification]) // Show stash hint when user gradually clears substantial input useEffect(() => { - const prevLength = prevInputLengthRef.current; - const peakLength = peakInputLengthRef.current; - const currentLength = input.length; - prevInputLengthRef.current = currentLength; + const prevLength = prevInputLengthRef.current + const peakLength = peakInputLengthRef.current + const currentLength = input.length + prevInputLengthRef.current = currentLength // Update peak when input grows if (currentLength > peakLength) { - peakInputLengthRef.current = currentLength; - return; + peakInputLengthRef.current = currentLength + return } // Reset state when input is empty if (currentLength === 0) { - peakInputLengthRef.current = 0; - return; + peakInputLengthRef.current = 0 + return } // Detect gradual clear: peak was high, current is low, but this wasn't a single big jump // (rapid clears like esc-esc go from 20+ to 0 in one step) - const clearedSubstantialInput = peakLength >= 20 && currentLength <= 5; - const wasRapidClear = prevLength >= 20 && currentLength <= 5; + const clearedSubstantialInput = peakLength >= 20 && currentLength <= 5 + const wasRapidClear = prevLength >= 20 && currentLength <= 5 + if (clearedSubstantialInput && !wasRapidClear) { - const config = getGlobalConfig(); + const config = getGlobalConfig() if (!config.hasUsedStash) { addNotification({ key: 'stash-hint', - jsx: + jsx: ( + Tip:{' '} - - , + + + ), priority: 'immediate', - timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT - }); + timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT, + }) } - peakInputLengthRef.current = currentLength; + peakInputLengthRef.current = currentLength } - }, [input.length, addNotification]); + }, [input.length, addNotification]) // Initialize input buffer for undo functionality - const { - pushToBuffer, - undo, - canUndo, - clearBuffer - } = useInputBuffer({ + const { pushToBuffer, undo, canUndo, clearBuffer } = useInputBuffer({ maxBufferSize: 50, - debounceMs: 1000 - }); + debounceMs: 1000, + }) + useMaybeTruncateInput({ input, pastedContents, onInputChange: trackAndSetInput, setCursorOffset, - setPastedContents - }); + setPastedContents, + }) + const defaultPlaceholder = usePromptInputPlaceholder({ input, submitCount, - viewingAgentName - }); - const onChange = useCallback((value: string) => { - if (value === '?') { - logEvent('tengu_help_toggled', {}); - setHelpOpen(v => !v); - return; - } - setHelpOpen(false); - - // Dismiss stash hint when user makes any input change - dismissStashHint(); - - // Cancel any pending prompt suggestion and speculation when user types - abortPromptSuggestion(); - abortSpeculation(setAppState); - - // Check if this is a single character insertion at the start - const isSingleCharInsertion = value.length === input.length + 1; - const insertedAtStart = cursorOffset === 0; - const mode = getModeFromInput(value); - if (insertedAtStart && mode !== 'prompt') { - if (isSingleCharInsertion) { - onModeChange(mode); - return; + viewingAgentName, + }) + + const onChange = useCallback( + (value: string) => { + if (value === '?') { + logEvent('tengu_help_toggled', {}) + setHelpOpen(v => !v) + return } - // Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login") - if (input.length === 0) { - onModeChange(mode); - const valueWithoutMode = getValueFromInput(value).replaceAll('\t', ' '); - pushToBuffer(input, cursorOffset, pastedContents); - trackAndSetInput(valueWithoutMode); - setCursorOffset(valueWithoutMode.length); - return; + setHelpOpen(false) + + // Dismiss stash hint when user makes any input change + dismissStashHint() + + // Cancel any pending prompt suggestion and speculation when user types + abortPromptSuggestion() + abortSpeculation(setAppState) + + // Check if this is a single character insertion at the start + const isSingleCharInsertion = value.length === input.length + 1 + const insertedAtStart = cursorOffset === 0 + const mode = getModeFromInput(value) + + if (insertedAtStart && mode !== 'prompt') { + if (isSingleCharInsertion) { + onModeChange(mode) + return + } + // Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login") + if (input.length === 0) { + onModeChange(mode) + const valueWithoutMode = getValueFromInput(value).replaceAll( + '\t', + ' ', + ) + pushToBuffer(input, cursorOffset, pastedContents) + trackAndSetInput(valueWithoutMode) + setCursorOffset(valueWithoutMode.length) + return + } } - } - const processedValue = value.replaceAll('\t', ' '); - // Push current state to buffer before making changes - if (input !== processedValue) { - pushToBuffer(input, cursorOffset, pastedContents); - } + const processedValue = value.replaceAll('\t', ' ') + + // Push current state to buffer before making changes + if (input !== processedValue) { + pushToBuffer(input, cursorOffset, pastedContents) + } + + // Deselect footer items when user types + setAppState(prev => + prev.footerSelection === null + ? prev + : { ...prev, footerSelection: null }, + ) + + trackAndSetInput(processedValue) + }, + [ + trackAndSetInput, + onModeChange, + input, + cursorOffset, + pushToBuffer, + pastedContents, + dismissStashHint, + setAppState, + ], + ) - // Deselect footer items when user types - setAppState(prev => prev.footerSelection === null ? prev : { - ...prev, - footerSelection: null - }); - trackAndSetInput(processedValue); - }, [trackAndSetInput, onModeChange, input, cursorOffset, pushToBuffer, pastedContents, dismissStashHint, setAppState]); const { resetHistory, onHistoryUp, onHistoryDown, dismissSearchHint, - historyIndex - } = useArrowKeyHistory((value: string, historyMode: HistoryMode, pastedContents: Record) => { - onChange(value); - onModeChange(historyMode); - setPastedContents(pastedContents); - }, input, pastedContents, setCursorOffset, mode); + historyIndex, + } = useArrowKeyHistory( + ( + value: string, + historyMode: HistoryMode, + pastedContents: Record, + ) => { + onChange(value) + onModeChange(historyMode) + setPastedContents(pastedContents) + }, + input, + pastedContents, + setCursorOffset, + mode, + ) // Dismiss search hint when user starts searching useEffect(() => { if (isSearchingHistory) { - dismissSearchHint(); + dismissSearchHint() } - }, [isSearchingHistory, dismissSearchHint]); + }, [isSearchingHistory, dismissSearchHint]) // Only use history navigation when there are 0 or 1 slash command suggestions. // Footer nav is NOT here — when a pill is selected, TextInput focus=false so // these never fire. The Footer keybinding context handles ↑/↓ instead. function handleHistoryUp() { if (suggestions.length > 1) { - return; + return } // Only navigate history when cursor is on the first line. // In multiline inputs, up arrow should move the cursor (handled by TextInput) // and only trigger history when at the top of the input. if (!isCursorOnFirstLine) { - return; + return } // If there's an editable queued command, move it to the input for editing when UP is pressed - const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable); + const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable) if (hasEditableCommand) { - void popAllCommandsFromQueue(); - return; + void popAllCommandsFromQueue() + return } - onHistoryUp(); + + onHistoryUp() } + function handleHistoryDown() { if (suggestions.length > 1) { - return; + return } // Only navigate history/footer when cursor is on the last line. // In multiline inputs, down arrow should move the cursor (handled by TextInput) // and only trigger navigation when at the bottom of the input. if (!isCursorOnLastLine) { - return; + return } // At bottom of history → enter footer at first visible pill if (onHistoryDown() && footerItems.length > 0) { - const first = footerItems[0]!; - selectFooterItem(first); + const first = footerItems[0]! + selectFooterItem(first) if (first === 'tasks' && !getGlobalConfig().hasSeenTasksHint) { - saveGlobalConfig(c => c.hasSeenTasksHint ? c : { - ...c, - hasSeenTasksHint: true - }); + saveGlobalConfig(c => + c.hasSeenTasksHint ? c : { ...c, hasSeenTasksHint: true }, + ) } } } // Create a suggestions state directly - we'll sync it with useTypeahead later const [suggestionsState, setSuggestionsStateRaw] = useState<{ - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string }>({ suggestions: [], selectedSuggestion: -1, - commandArgumentHint: undefined - }); + commandArgumentHint: undefined, + }) // Setter for suggestions state - const setSuggestionsState = useCallback((updater: typeof suggestionsState | ((prev: typeof suggestionsState) => typeof suggestionsState)) => { - setSuggestionsStateRaw(prev => typeof updater === 'function' ? updater(prev) : updater); - }, []); - const onSubmit = useCallback(async (inputParam: string, isSubmittingSlashCommand = false) => { - inputParam = inputParam.trimEnd(); - - // Don't submit if a footer indicator is being opened. Read fresh from - // store — footer:openSelected calls selectFooterItem(null) then onSubmit - // in the same tick, and the closure value hasn't updated yet. Apply the - // same "still visible?" derivation as footerItemSelected so a stale - // selection (pill disappeared) doesn't swallow Enter. - const state = store.getState(); - if (state.footerSelection && footerItems.includes(state.footerSelection)) { - return; - } - - // Enter in selection modes confirms selection (useBackgroundTaskNavigation). - // BaseTextInput's useInput registers before that hook (child effects fire first), - // so without this guard Enter would double-fire and auto-submit the suggestion. - if (state.viewSelectionMode === 'selecting-agent') { - return; - } - - // Check for images early - we need this for suggestion logic below - const hasImages = Object.values(pastedContents).some(c => c.type === 'image'); - - // If input is empty OR matches the suggestion, submit it - // But if there are images attached, don't auto-accept the suggestion - - // the user wants to submit just the image(s). - // Only in leader view — promptSuggestion is leader-context, not teammate. - const suggestionText = promptSuggestionState.text; - const inputMatchesSuggestion = inputParam.trim() === '' || inputParam === suggestionText; - if (inputMatchesSuggestion && suggestionText && !hasImages && !state.viewingAgentTaskId) { - // If speculation is active, inject messages immediately as they stream - if (speculation.status === 'active') { - markAccepted(); - // skipReset: resetSuggestion would abort the speculation before we accept it - logOutcomeAtSubmission(suggestionText, { - skipReset: true - }); - void onSubmitProp(suggestionText, { - setCursorOffset, - clearBuffer, - resetHistory - }, { - state: speculation, - speculationSessionTimeSavedMs: speculationSessionTimeSavedMs, - setAppState - }); - return; // Skip normal query - speculation handled it + const setSuggestionsState = useCallback( + ( + updater: + | typeof suggestionsState + | ((prev: typeof suggestionsState) => typeof suggestionsState), + ) => { + setSuggestionsStateRaw(prev => + typeof updater === 'function' ? updater(prev) : updater, + ) + }, + [], + ) + + const onSubmit = useCallback( + async (inputParam: string, isSubmittingSlashCommand = false) => { + inputParam = inputParam.trimEnd() + + // Don't submit if a footer indicator is being opened. Read fresh from + // store — footer:openSelected calls selectFooterItem(null) then onSubmit + // in the same tick, and the closure value hasn't updated yet. Apply the + // same "still visible?" derivation as footerItemSelected so a stale + // selection (pill disappeared) doesn't swallow Enter. + const state = store.getState() + if ( + state.footerSelection && + footerItems.includes(state.footerSelection) + ) { + return } - // Regular suggestion acceptance (requires shownAt > 0) - if (promptSuggestionState.shownAt > 0) { - markAccepted(); - inputParam = suggestionText; + // Enter in selection modes confirms selection (useBackgroundTaskNavigation). + // BaseTextInput's useInput registers before that hook (child effects fire first), + // so without this guard Enter would double-fire and auto-submit the suggestion. + if (state.viewSelectionMode === 'selecting-agent') { + return } - } - // Handle @name direct message - if (isAgentSwarmsEnabled()) { - const directMessage = parseDirectMemberMessage(inputParam); - if (directMessage) { - const result = await sendDirectMemberMessage(directMessage.recipientName, directMessage.message, teamContext, writeToMailbox); - if (result.success) { - addNotification({ - key: 'direct-message-sent', - text: `Sent to @${result.recipientName}`, - priority: 'immediate', - timeoutMs: 3000 - }); - trackAndSetInput(''); - setCursorOffset(0); - clearBuffer(); - resetHistory(); - return; - } else if ('error' in result && result.error === 'no_team_context') { - // No team context - fall through to normal prompt submission - } else { - // Unknown recipient - fall through to normal prompt submission - // This allows e.g. "@utils explain this code" to be sent as a prompt + // Check for images early - we need this for suggestion logic below + const hasImages = Object.values(pastedContents).some( + c => c.type === 'image', + ) + + // If input is empty OR matches the suggestion, submit it + // But if there are images attached, don't auto-accept the suggestion - + // the user wants to submit just the image(s). + // Only in leader view — promptSuggestion is leader-context, not teammate. + const suggestionText = promptSuggestionState.text + const inputMatchesSuggestion = + inputParam.trim() === '' || inputParam === suggestionText + if ( + inputMatchesSuggestion && + suggestionText && + !hasImages && + !state.viewingAgentTaskId + ) { + // If speculation is active, inject messages immediately as they stream + if (speculation.status === 'active') { + markAccepted() + // skipReset: resetSuggestion would abort the speculation before we accept it + logOutcomeAtSubmission(suggestionText, { skipReset: true }) + + void onSubmitProp( + suggestionText, + { + setCursorOffset, + clearBuffer, + resetHistory, + }, + { + state: speculation, + speculationSessionTimeSavedMs: speculationSessionTimeSavedMs, + setAppState, + }, + ) + return // Skip normal query - speculation handled it + } + + // Regular suggestion acceptance (requires shownAt > 0) + if (promptSuggestionState.shownAt > 0) { + markAccepted() + inputParam = suggestionText } } - } - // Allow submission if there are images attached, even without text - if (inputParam.trim() === '' && !hasImages) { - return; - } + // Handle @name direct message + if (isAgentSwarmsEnabled()) { + const directMessage = parseDirectMemberMessage(inputParam) + if (directMessage) { + const result = await sendDirectMemberMessage( + directMessage.recipientName, + directMessage.message, + teamContext, + writeToMailbox, + ) + + if (result.success) { + addNotification({ + key: 'direct-message-sent', + text: `Sent to @${result.recipientName}`, + priority: 'immediate', + timeoutMs: 3000, + }) + trackAndSetInput('') + setCursorOffset(0) + clearBuffer() + resetHistory() + return + } else if (result.error === 'no_team_context') { + // No team context - fall through to normal prompt submission + } else { + // Unknown recipient - fall through to normal prompt submission + // This allows e.g. "@utils explain this code" to be sent as a prompt + } + } + } - // PromptInput UX: Check if suggestions dropdown is showing - // For directory suggestions, allow submission (Tab is used for completion) - const hasDirectorySuggestions = suggestionsState.suggestions.length > 0 && suggestionsState.suggestions.every(s => s.description === 'directory'); - if (suggestionsState.suggestions.length > 0 && !isSubmittingSlashCommand && !hasDirectorySuggestions) { - logForDebugging(`[onSubmit] early return: suggestions showing (count=${suggestionsState.suggestions.length})`); - return; // Don't submit, user needs to clear suggestions first - } + // Allow submission if there are images attached, even without text + if (inputParam.trim() === '' && !hasImages) { + return + } - // Log suggestion outcome if one exists - if (promptSuggestionState.text && promptSuggestionState.shownAt > 0) { - logOutcomeAtSubmission(inputParam); - } + // PromptInput UX: Check if suggestions dropdown is showing + // For directory suggestions, allow submission (Tab is used for completion) + const hasDirectorySuggestions = + suggestionsState.suggestions.length > 0 && + suggestionsState.suggestions.every(s => s.description === 'directory') + + if ( + suggestionsState.suggestions.length > 0 && + !isSubmittingSlashCommand && + !hasDirectorySuggestions + ) { + logForDebugging( + `[onSubmit] early return: suggestions showing (count=${suggestionsState.suggestions.length})`, + ) + return // Don't submit, user needs to clear suggestions first + } + + // Log suggestion outcome if one exists + if (promptSuggestionState.text && promptSuggestionState.shownAt > 0) { + logOutcomeAtSubmission(inputParam) + } - // Clear stash hint notification on submit - removeNotification('stash-hint'); + // Clear stash hint notification on submit + removeNotification('stash-hint') + + // Route input to viewed agent (in-process teammate or named local_agent). + const activeAgent = getActiveAgentForInput(store.getState()) + if (activeAgent.type !== 'leader' && onAgentSubmit) { + logEvent('tengu_transcript_input_to_teammate', {}) + await onAgentSubmit(inputParam, activeAgent.task, { + setCursorOffset, + clearBuffer, + resetHistory, + }) + return + } - // Route input to viewed agent (in-process teammate or named local_agent). - const activeAgent = getActiveAgentForInput(store.getState()); - if (activeAgent.type !== 'leader' && onAgentSubmit) { - logEvent('tengu_transcript_input_to_teammate', {}); - await onAgentSubmit(inputParam, activeAgent.task, { + // Normal leader submission + await onSubmitProp(inputParam, { setCursorOffset, clearBuffer, - resetHistory - }); - return; - } - - // Normal leader submission - await onSubmitProp(inputParam, { - setCursorOffset, + resetHistory, + }) + }, + [ + promptSuggestionState, + speculation, + speculationSessionTimeSavedMs, + teamContext, + store, + footerItems, + suggestionsState.suggestions, + onSubmitProp, + onAgentSubmit, clearBuffer, - resetHistory - }); - }, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification]); + resetHistory, + logOutcomeAtSubmission, + setAppState, + markAccepted, + pastedContents, + removeNotification, + ], + ) + const { suggestions, selectedSuggestion, commandArgumentHint, inlineGhostText, - maxColumnWidth + maxColumnWidth, } = useTypeahead({ commands, onInputChange: trackAndSetInput, @@ -1122,21 +1507,30 @@ function PromptInput({ suggestionsState, suppressSuggestions: isSearchingHistory || historyIndex > 0, markAccepted, - onModeChange - }); + onModeChange, + }) // Track if prompt suggestion should be shown (computed later with terminal width). // Hidden in teammate view — suggestion is leader-context only. - const showPromptSuggestion = mode === 'prompt' && suggestions.length === 0 && promptSuggestion && !viewingAgentTaskId; + const showPromptSuggestion = + mode === 'prompt' && + suggestions.length === 0 && + promptSuggestion && + !viewingAgentTaskId if (showPromptSuggestion) { - markShown(); + markShown() } // If suggestion was generated but can't be shown due to timing, log suppression. // Exclude teammate view: markShown() is gated above, so shownAt stays 0 there — // but that's not a timing failure, the suggestion is valid when returning to leader. - if (promptSuggestionState.text && !promptSuggestion && promptSuggestionState.shownAt === 0 && !viewingAgentTaskId) { - logSuggestionSuppressed('timing', promptSuggestionState.text); + if ( + promptSuggestionState.text && + !promptSuggestion && + promptSuggestionState.shownAt === 0 && + !viewingAgentTaskId + ) { + logSuggestionSuppressed('timing', promptSuggestionState.text) setAppState(prev => ({ ...prev, promptSuggestion: { @@ -1144,42 +1538,47 @@ function PromptInput({ promptId: null, shownAt: 0, acceptedAt: 0, - generationRequestId: null - } - })); + generationRequestId: null, + }, + })) } - function onImagePaste(image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) { - logEvent('tengu_paste_image', {}); - onModeChange('prompt'); - const pasteId = nextPasteIdRef.current++; + + function onImagePaste( + image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) { + logEvent('tengu_paste_image', {}) + onModeChange('prompt') + + const pasteId = nextPasteIdRef.current++ + const newContent: PastedContent = { id: pasteId, type: 'image', content: image, - mediaType: mediaType || 'image/png', - // default to PNG if not provided + mediaType: mediaType || 'image/png', // default to PNG if not provided filename: filename || 'Pasted image', dimensions, - sourcePath - }; + sourcePath, + } // Cache path immediately (fast) so links work on render - cacheImagePath(newContent); + cacheImagePath(newContent) // Store image to disk in background - void storeImage(newContent); + void storeImage(newContent) // Update UI - setPastedContents(prev => ({ - ...prev, - [pasteId]: newContent - })); + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) // Multi-image paste calls onImagePaste in a loop. If the ref is already // armed, the previous pill's lazy space fires now (before this pill) // rather than being lost. - const prefix = pendingSpaceAfterPillRef.current ? ' ' : ''; - insertTextAtCursor(prefix + formatImageRef(pasteId)); - pendingSpaceAfterPillRef.current = true; + const prefix = pendingSpaceAfterPillRef.current ? ' ' : '' + insertTextAtCursor(prefix + formatImageRef(pasteId)) + pendingSpaceAfterPillRef.current = true } // Prune images whose [Image #N] placeholder is no longer in the input text. @@ -1187,224 +1586,260 @@ function PromptInput({ // the ref. onImagePaste batches setPastedContents + insertTextAtCursor in the // same event, so this effect sees the placeholder already present. useEffect(() => { - const referencedIds = new Set(parseReferences(input).map(r => r.id)); + const referencedIds = new Set(parseReferences(input).map(r => r.id)) setPastedContents(prev => { - const orphaned = Object.values(prev).filter(c => c.type === 'image' && !referencedIds.has(c.id)); - if (orphaned.length === 0) return prev; - const next = { - ...prev - }; - for (const img of orphaned) delete next[img.id]; - return next; - }); - }, [input, setPastedContents]); + const orphaned = Object.values(prev).filter( + c => c.type === 'image' && !referencedIds.has(c.id), + ) + if (orphaned.length === 0) return prev + const next = { ...prev } + for (const img of orphaned) delete next[img.id] + return next + }) + }, [input, setPastedContents]) + function onTextPaste(rawText: string) { - pendingSpaceAfterPillRef.current = false; + pendingSpaceAfterPillRef.current = false // Clean up pasted text - strip ANSI escape codes and normalize line endings and tabs - let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' '); + let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' ') // Match typed/auto-suggest: `!cmd` pasted into empty input enters bash mode. if (input.length === 0) { - const pastedMode = getModeFromInput(text); + const pastedMode = getModeFromInput(text) if (pastedMode !== 'prompt') { - onModeChange(pastedMode); - text = getValueFromInput(text); + onModeChange(pastedMode) + text = getValueFromInput(text) } } - const numLines = getPastedTextRefNumLines(text); + + const numLines = getPastedTextRefNumLines(text) // Limit the number of lines to show in the input // If the overall layout is too high then Ink will repaint // the entire terminal. // The actual required height is dependent on the content, this // is just an estimate. - const maxLines = Math.min(rows - 10, 2); + const maxLines = Math.min(rows - 10, 2) // Use special handling for long pasted text (>PASTE_THRESHOLD chars) // or if it exceeds the number of lines we want to show if (text.length > PASTE_THRESHOLD || numLines > maxLines) { - const pasteId = nextPasteIdRef.current++; + const pasteId = nextPasteIdRef.current++ + const newContent: PastedContent = { id: pasteId, type: 'text', - content: text - }; - setPastedContents(prev => ({ - ...prev, - [pasteId]: newContent - })); - insertTextAtCursor(formatPastedTextRef(pasteId, numLines)); + content: text, + } + + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) + + insertTextAtCursor(formatPastedTextRef(pasteId, numLines)) } else { // For shorter pastes, just insert the text normally - insertTextAtCursor(text); + insertTextAtCursor(text) } } - const lazySpaceInputFilter = useCallback((input: string, key: Key): string => { - if (!pendingSpaceAfterPillRef.current) return input; - pendingSpaceAfterPillRef.current = false; - if (isNonSpacePrintable(input, key)) return ' ' + input; - return input; - }, []); + + const lazySpaceInputFilter = useCallback( + (input: string, key: Key): string => { + if (!pendingSpaceAfterPillRef.current) return input + pendingSpaceAfterPillRef.current = false + if (isNonSpacePrintable(input, key)) return ' ' + input + return input + }, + [], + ) + function insertTextAtCursor(text: string) { // Push current state to buffer before inserting - pushToBuffer(input, cursorOffset, pastedContents); - const newInput = input.slice(0, cursorOffset) + text + input.slice(cursorOffset); - trackAndSetInput(newInput); - setCursorOffset(cursorOffset + text.length); + pushToBuffer(input, cursorOffset, pastedContents) + + const newInput = + input.slice(0, cursorOffset) + text + input.slice(cursorOffset) + trackAndSetInput(newInput) + setCursorOffset(cursorOffset + text.length) } - const doublePressEscFromEmpty = useDoublePress(() => {}, () => onShowMessageSelector()); + + const doublePressEscFromEmpty = useDoublePress( + () => {}, + () => onShowMessageSelector(), + ) // Function to get the queued command for editing. Returns true if commands were popped. const popAllCommandsFromQueue = useCallback((): boolean => { - const result = popAllEditable(input, cursorOffset); + const result = popAllEditable(input, cursorOffset) if (!result) { - return false; + return false } - trackAndSetInput(result.text); - onModeChange('prompt'); // Always prompt mode for queued commands - setCursorOffset(result.cursorOffset); + + trackAndSetInput(result.text) + onModeChange('prompt') // Always prompt mode for queued commands + setCursorOffset(result.cursorOffset) // 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 + }) } - return true; - }, [trackAndSetInput, onModeChange, input, cursorOffset, setPastedContents]); + + return true + }, [trackAndSetInput, onModeChange, input, cursorOffset, setPastedContents]) // Insert the at-mentioned reference (the file and, optionally, a line range) when // we receive an at-mentioned notification the IDE. const onIdeAtMentioned = function (atMentioned: IDEAtMentioned) { - logEvent('tengu_ext_at_mentioned', {}); - let atMentionedText: string; - const relativePath = path.relative(getCwd(), atMentioned.filePath); + logEvent('tengu_ext_at_mentioned', {}) + let atMentionedText: string + const relativePath = path.relative(getCwd(), atMentioned.filePath) if (atMentioned.lineStart && atMentioned.lineEnd) { - atMentionedText = atMentioned.lineStart === atMentioned.lineEnd ? `@${relativePath}#L${atMentioned.lineStart} ` : `@${relativePath}#L${atMentioned.lineStart}-${atMentioned.lineEnd} `; + atMentionedText = + atMentioned.lineStart === atMentioned.lineEnd + ? `@${relativePath}#L${atMentioned.lineStart} ` + : `@${relativePath}#L${atMentioned.lineStart}-${atMentioned.lineEnd} ` } else { - atMentionedText = `@${relativePath} `; + atMentionedText = `@${relativePath} ` } - const cursorChar = input[cursorOffset - 1] ?? ' '; + const cursorChar = input[cursorOffset - 1] ?? ' ' if (!/\s/.test(cursorChar)) { - atMentionedText = ` ${atMentionedText}`; + atMentionedText = ` ${atMentionedText}` } - insertTextAtCursor(atMentionedText); - }; - useIdeAtMentioned(mcpClients, onIdeAtMentioned); + insertTextAtCursor(atMentionedText) + } + useIdeAtMentioned(mcpClients, onIdeAtMentioned) // Handler for chat:undo - undo last edit const handleUndo = useCallback(() => { if (canUndo) { - const previousState = undo(); + const previousState = undo() if (previousState) { - trackAndSetInput(previousState.text); - setCursorOffset(previousState.cursorOffset); - setPastedContents(previousState.pastedContents); + trackAndSetInput(previousState.text) + setCursorOffset(previousState.cursorOffset) + setPastedContents(previousState.pastedContents) } } - }, [canUndo, undo, trackAndSetInput, setPastedContents]); + }, [canUndo, undo, trackAndSetInput, setPastedContents]) // Handler for chat:newline - insert a newline at the cursor position const handleNewline = useCallback(() => { - pushToBuffer(input, cursorOffset, pastedContents); - const newInput = input.slice(0, cursorOffset) + '\n' + input.slice(cursorOffset); - trackAndSetInput(newInput); - setCursorOffset(cursorOffset + 1); - }, [input, cursorOffset, trackAndSetInput, setCursorOffset, pushToBuffer, pastedContents]); + pushToBuffer(input, cursorOffset, pastedContents) + const newInput = + input.slice(0, cursorOffset) + '\n' + input.slice(cursorOffset) + trackAndSetInput(newInput) + setCursorOffset(cursorOffset + 1) + }, [ + input, + cursorOffset, + trackAndSetInput, + setCursorOffset, + pushToBuffer, + pastedContents, + ]) // Handler for chat:externalEditor - edit in $EDITOR const handleExternalEditor = useCallback(async () => { - logEvent('tengu_external_editor_used', {}); - setIsExternalEditorActive(true); + logEvent('tengu_external_editor_used', {}) + setIsExternalEditorActive(true) + try { // Pass pastedContents to expand collapsed text references - const result = await editPromptInEditor(input, pastedContents); + const result = await editPromptInEditor(input, pastedContents) + if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', - priority: 'high' - }); + priority: 'high', + }) } + if (result.content !== null && result.content !== input) { // Push current state to buffer before making changes - pushToBuffer(input, cursorOffset, pastedContents); - trackAndSetInput(result.content); - setCursorOffset(result.content.length); + pushToBuffer(input, cursorOffset, pastedContents) + + trackAndSetInput(result.content) + setCursorOffset(result.content.length) } } catch (err) { if (err instanceof Error) { - logError(err); + logError(err) } addNotification({ key: 'external-editor-error', text: `External editor failed: ${errorMessage(err)}`, color: 'warning', - priority: 'high' - }); + priority: 'high', + }) } finally { - setIsExternalEditorActive(false); + setIsExternalEditorActive(false) } - }, [input, cursorOffset, pastedContents, pushToBuffer, trackAndSetInput, addNotification]); + }, [ + input, + cursorOffset, + pastedContents, + pushToBuffer, + trackAndSetInput, + addNotification, + ]) // Handler for chat:stash - stash/unstash prompt const handleStash = useCallback(() => { if (input.trim() === '' && stashedPrompt !== undefined) { // Pop stash when input is empty - trackAndSetInput(stashedPrompt.text); - setCursorOffset(stashedPrompt.cursorOffset); - setPastedContents(stashedPrompt.pastedContents); - setStashedPrompt(undefined); + trackAndSetInput(stashedPrompt.text) + setCursorOffset(stashedPrompt.cursorOffset) + setPastedContents(stashedPrompt.pastedContents) + setStashedPrompt(undefined) } else if (input.trim() !== '') { // Push to stash (save text, cursor position, and pasted contents) - setStashedPrompt({ - text: input, - cursorOffset, - pastedContents - }); - trackAndSetInput(''); - setCursorOffset(0); - setPastedContents({}); + setStashedPrompt({ text: input, cursorOffset, pastedContents }) + trackAndSetInput('') + setCursorOffset(0) + setPastedContents({}) // Track usage for /discover and stop showing hint saveGlobalConfig(c => { - if (c.hasUsedStash) return c; - return { - ...c, - hasUsedStash: true - }; - }); + if (c.hasUsedStash) return c + return { ...c, hasUsedStash: true } + }) } - }, [input, cursorOffset, stashedPrompt, trackAndSetInput, setStashedPrompt, pastedContents, setPastedContents]); + }, [ + input, + cursorOffset, + stashedPrompt, + trackAndSetInput, + setStashedPrompt, + pastedContents, + setPastedContents, + ]) // Handler for chat:modelPicker - toggle model picker const handleModelPicker = useCallback(() => { - setShowModelPicker(prev => !prev); + setShowModelPicker(prev => !prev) if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }, [helpOpen]); + }, [helpOpen]) // Handler for chat:fastMode - toggle fast mode picker const handleFastModePicker = useCallback(() => { - setShowFastModePicker(prev => !prev); + setShowFastModePicker(prev => !prev) if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }, [helpOpen]); + }, [helpOpen]) // Handler for chat:thinkingToggle - toggle thinking mode const handleThinkingToggle = useCallback(() => { - setShowThinkingToggle(prev => !prev); + setShowThinkingToggle(prev => !prev) if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }, [helpOpen]); + }, [helpOpen]) // Handler for chat:cycleMode - cycle through permission modes const handleCycleMode = useCallback(() => { @@ -1412,21 +1847,23 @@ function PromptInput({ if (isAgentSwarmsEnabled() && viewedTeammate && viewingAgentTaskId) { const teammateContext: ToolPermissionContext = { ...toolPermissionContext, - mode: viewedTeammate.permissionMode - }; + mode: viewedTeammate.permissionMode, + } // Pass undefined for teamContext (unused but kept for API compatibility) - const nextMode = getNextPermissionMode(teammateContext, undefined); + const nextMode = getNextPermissionMode(teammateContext, undefined) + logEvent('tengu_mode_cycle', { - to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const teammateTaskId = viewingAgentTaskId; + to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + const teammateTaskId = viewingAgentTaskId setAppState(prev => { - const task = prev.tasks[teammateTaskId]; + const task = prev.tasks[teammateTaskId] if (!task || task.type !== 'in_process_teammate') { - return prev; + return prev } if (task.permissionMode === nextMode) { - return prev; + return prev } return { ...prev, @@ -1434,34 +1871,42 @@ function PromptInput({ ...prev.tasks, [teammateTaskId]: { ...task, - permissionMode: nextMode - } - } - }; - }); + permissionMode: nextMode, + }, + }, + } + }) + if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - return; + return } // Compute the next mode without triggering side effects first - logForDebugging(`[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`); - const nextMode = getNextPermissionMode(toolPermissionContext, teamContext); + logForDebugging( + `[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`, + ) + const nextMode = getNextPermissionMode(toolPermissionContext, teamContext) // Check if user is entering auto mode for the first time. Gated on the // persistent settings flag (hasAutoModeOptIn) rather than the broader // hasAutoModeOptInAnySource so that --enable-auto-mode users still see // the warning dialog once — the CLI flag should grant carousel access, // not bypass the safety text. - let isEnteringAutoModeFirstTime = false; + let isEnteringAutoModeFirstTime = false if (feature('TRANSCRIPT_CLASSIFIER')) { - isEnteringAutoModeFirstTime = nextMode === 'auto' && toolPermissionContext.mode !== 'auto' && !hasAutoModeOptIn() && !viewingAgentTaskId; // Only show for primary agent, not subagents + isEnteringAutoModeFirstTime = + nextMode === 'auto' && + toolPermissionContext.mode !== 'auto' && + !hasAutoModeOptIn() && + !viewingAgentTaskId // Only show for primary agent, not subagents } + if (feature('TRANSCRIPT_CLASSIFIER')) { if (isEnteringAutoModeFirstTime) { // Store previous mode so we can revert if user declines - setPreviousModeBeforeAuto(toolPermissionContext.mode); + setPreviousModeBeforeAuto(toolPermissionContext.mode) // Only update the UI mode label — do NOT call transitionPermissionMode // or cyclePermissionMode yet; we haven't confirmed with the user. @@ -1469,26 +1914,32 @@ function PromptInput({ ...prev, toolPermissionContext: { ...prev.toolPermissionContext, - mode: 'auto' - } - })); + mode: 'auto', + }, + })) setToolPermissionContext({ ...toolPermissionContext, - mode: 'auto' - }); + mode: 'auto', + }) // Show opt-in dialog after 400ms debounce if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current); + clearTimeout(autoModeOptInTimeoutRef.current) } - autoModeOptInTimeoutRef.current = setTimeout((setShowAutoModeOptIn, autoModeOptInTimeoutRef) => { - setShowAutoModeOptIn(true); - autoModeOptInTimeoutRef.current = null; - }, 400, setShowAutoModeOptIn, autoModeOptInTimeoutRef); + autoModeOptInTimeoutRef.current = setTimeout( + (setShowAutoModeOptIn, autoModeOptInTimeoutRef) => { + setShowAutoModeOptIn(true) + autoModeOptInTimeoutRef.current = null + }, + 400, + setShowAutoModeOptIn, + autoModeOptInTimeoutRef, + ) + if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - return; + return } } @@ -1500,14 +1951,14 @@ function PromptInput({ if (feature('TRANSCRIPT_CLASSIFIER')) { if (showAutoModeOptIn || autoModeOptInTimeoutRef.current) { if (showAutoModeOptIn) { - logEvent('tengu_auto_mode_opt_in_dialog_decline', {}); + logEvent('tengu_auto_mode_opt_in_dialog_decline', {}) } - setShowAutoModeOptIn(false); + setShowAutoModeOptIn(false) if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current); - autoModeOptInTimeoutRef.current = null; + clearTimeout(autoModeOptInTimeoutRef.current) + autoModeOptInTimeoutRef.current = null } - setPreviousModeBeforeAuto(null); + setPreviousModeBeforeAuto(null) // Fall through — mode is 'auto', cyclePermissionMode below goes to 'default'. } } @@ -1515,19 +1966,21 @@ function PromptInput({ // Now that we know this is NOT the first-time auto mode path, // call cyclePermissionMode to apply side effects (e.g. strip // dangerous permissions, activate classifier) - const { - context: preparedContext - } = cyclePermissionMode(toolPermissionContext, teamContext); + const { context: preparedContext } = cyclePermissionMode( + toolPermissionContext, + teamContext, + ) + logEvent('tengu_mode_cycle', { - to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) // Track when user enters plan mode if (nextMode === 'plan') { saveGlobalConfig(current => ({ ...current, - lastPlanModeUse: Date.now() - })); + lastPlanModeUse: Date.now(), + })) } // Set the mode via setAppState directly because setToolPermissionContext @@ -1538,101 +1991,134 @@ function PromptInput({ ...prev, toolPermissionContext: { ...preparedContext, - mode: nextMode - } - })); + mode: nextMode, + }, + })) setToolPermissionContext({ ...preparedContext, - mode: nextMode - }); + mode: nextMode, + }) // If this is a teammate, update config.json so team lead sees the change - syncTeammateMode(nextMode, teamContext?.teamName); + syncTeammateMode(nextMode, teamContext?.teamName) // Close help tips if they're open when mode is cycled if (helpOpen) { - setHelpOpen(false); - } - }, [toolPermissionContext, teamContext, viewingAgentTaskId, viewedTeammate, setAppState, setToolPermissionContext, helpOpen, showAutoModeOptIn]); + setHelpOpen(false) + } + }, [ + toolPermissionContext, + teamContext, + viewingAgentTaskId, + viewedTeammate, + setAppState, + setToolPermissionContext, + helpOpen, + showAutoModeOptIn, + ]) // Handler for auto mode opt-in dialog acceptance const handleAutoModeOptInAccept = useCallback(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { - setShowAutoModeOptIn(false); - setPreviousModeBeforeAuto(null); + setShowAutoModeOptIn(false) + setPreviousModeBeforeAuto(null) // Now that the user accepted, apply the full transition: activate the // auto mode backend (classifier, beta headers) and strip dangerous // permissions (e.g. Bash(*) always-allow rules). - const strippedContext = transitionPermissionMode(previousModeBeforeAuto ?? toolPermissionContext.mode, 'auto', toolPermissionContext); + const strippedContext = transitionPermissionMode( + previousModeBeforeAuto ?? toolPermissionContext.mode, + 'auto', + toolPermissionContext, + ) setAppState(prev => ({ ...prev, toolPermissionContext: { ...strippedContext, - mode: 'auto' - } - })); + mode: 'auto', + }, + })) setToolPermissionContext({ ...strippedContext, - mode: 'auto' - }); + mode: 'auto', + }) // Close help tips if they're open when auto mode is enabled if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } } - }, [helpOpen, setHelpOpen, previousModeBeforeAuto, toolPermissionContext, setAppState, setToolPermissionContext]); + }, [ + helpOpen, + setHelpOpen, + previousModeBeforeAuto, + toolPermissionContext, + setAppState, + setToolPermissionContext, + ]) // Handler for auto mode opt-in dialog decline const handleAutoModeOptInDecline = useCallback(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { - logForDebugging(`[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`); - setShowAutoModeOptIn(false); + logForDebugging( + `[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`, + ) + setShowAutoModeOptIn(false) if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current); - autoModeOptInTimeoutRef.current = null; + clearTimeout(autoModeOptInTimeoutRef.current) + autoModeOptInTimeoutRef.current = null } // Revert to previous mode and remove auto from the carousel // for the rest of this session if (previousModeBeforeAuto) { - setAutoModeActive(false); + setAutoModeActive(false) setAppState(prev => ({ ...prev, toolPermissionContext: { ...prev.toolPermissionContext, mode: previousModeBeforeAuto, - isAutoModeAvailable: false - } - })); + isAutoModeAvailable: false, + }, + })) setToolPermissionContext({ ...toolPermissionContext, mode: previousModeBeforeAuto, - isAutoModeAvailable: false - }); - setPreviousModeBeforeAuto(null); + isAutoModeAvailable: false, + }) + setPreviousModeBeforeAuto(null) } } - }, [previousModeBeforeAuto, toolPermissionContext, setAppState, setToolPermissionContext]); + }, [ + previousModeBeforeAuto, + toolPermissionContext, + setAppState, + setToolPermissionContext, + ]) // Handler for chat:imagePaste - paste image from clipboard const handleImagePaste = useCallback(() => { void getImageFromClipboard().then(imageData => { if (imageData) { - onImagePaste(imageData.base64, imageData.mediaType); + onImagePaste(imageData.base64, imageData.mediaType) } else { - const shortcutDisplay = getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'); - const message = env.isSSH() ? "No image found in clipboard. You're SSH'd; try scp?" : `No image found in clipboard. Use ${shortcutDisplay} to paste images.`; + const shortcutDisplay = getShortcutDisplay( + 'chat:imagePaste', + 'Chat', + 'ctrl+v', + ) + const message = env.isSSH() + ? "No image found in clipboard. You're SSH'd; try scp?" + : `No image found in clipboard. Use ${shortcutDisplay} to paste images.` addNotification({ key: 'no-image-in-clipboard', text: message, priority: 'immediate', - timeoutMs: 1000 - }); + timeoutMs: 1000, + }) } - }); - }, [addNotification, onImagePaste]); + }) + }, [addNotification, onImagePaste]) // Register chat:submit handler directly in the handler registry (not via // useKeybindings) so that only the ChordInterceptor can invoke it for chord @@ -1640,250 +2126,309 @@ function PromptInput({ // handled by TextInput directly (via onSubmit prop) and useTypeahead (for // autocomplete acceptance). Using useKeybindings would cause // stopImmediatePropagation on Enter, blocking autocomplete from seeing the key. - const keybindingContext = useOptionalKeybindingContext(); + const keybindingContext = useOptionalKeybindingContext() useEffect(() => { - if (!keybindingContext || isModalOverlayActive) return; + if (!keybindingContext || isModalOverlayActive) return return keybindingContext.registerHandler({ action: 'chat:submit', context: 'Chat', handler: () => { - void onSubmit(input); - } - }); - }, [keybindingContext, isModalOverlayActive, onSubmit, input]); + void onSubmit(input) + }, + }) + }, [keybindingContext, isModalOverlayActive, onSubmit, input]) // Chat context keybindings for editing shortcuts // Note: history:previous/history:next are NOT handled here. They are passed as // onHistoryUp/onHistoryDown props to TextInput, so that useTextInput's // upOrHistoryUp/downOrHistoryDown can try cursor movement first and only // fall through to history when the cursor can't move further. - const chatHandlers = useMemo(() => ({ - 'chat:undo': handleUndo, - 'chat:newline': handleNewline, - 'chat:externalEditor': handleExternalEditor, - 'chat:stash': handleStash, - 'chat:modelPicker': handleModelPicker, - 'chat:thinkingToggle': handleThinkingToggle, - 'chat:cycleMode': handleCycleMode, - 'chat:imagePaste': handleImagePaste - }), [handleUndo, handleNewline, handleExternalEditor, handleStash, handleModelPicker, handleThinkingToggle, handleCycleMode, handleImagePaste]); + const chatHandlers = useMemo( + () => ({ + 'chat:undo': handleUndo, + 'chat:newline': handleNewline, + 'chat:externalEditor': handleExternalEditor, + 'chat:stash': handleStash, + 'chat:modelPicker': handleModelPicker, + 'chat:thinkingToggle': handleThinkingToggle, + 'chat:cycleMode': handleCycleMode, + 'chat:imagePaste': handleImagePaste, + }), + [ + handleUndo, + handleNewline, + handleExternalEditor, + handleStash, + handleModelPicker, + handleThinkingToggle, + handleCycleMode, + handleImagePaste, + ], + ) + useKeybindings(chatHandlers, { context: 'Chat', - isActive: !isModalOverlayActive - }); + isActive: !isModalOverlayActive, + }) // Shift+↑ enters message-actions cursor. Separate isActive so ctrl+r search // doesn't leave stale isSearchingHistory on cursor-exit remount. useKeybinding('chat:messageActions', () => onMessageActionsEnter?.(), { context: 'Chat', - isActive: !isModalOverlayActive && !isSearchingHistory - }); + isActive: !isModalOverlayActive && !isSearchingHistory, + }) // Fast mode keybinding is only active when fast mode is enabled and available useKeybinding('chat:fastMode', handleFastModePicker, { context: 'Chat', - isActive: !isModalOverlayActive && isFastModeEnabled() && isFastModeAvailable() - }); + isActive: + !isModalOverlayActive && isFastModeEnabled() && isFastModeAvailable(), + }) // Handle help:dismiss keybinding (ESC closes help menu) // This is registered separately from Chat context so it has priority over // CancelRequestHandler when help menu is open - useKeybinding('help:dismiss', () => { - setHelpOpen(false); - }, { - context: 'Help', - isActive: helpOpen - }); + useKeybinding( + 'help:dismiss', + () => { + setHelpOpen(false) + }, + { context: 'Help', isActive: helpOpen }, + ) // Quick Open / Global Search. Hook calls are unconditional (Rules of Hooks); // the handler body is feature()-gated so the setState calls and component // references get tree-shaken in external builds. - const quickSearchActive = feature('QUICK_SEARCH') ? !isModalOverlayActive : false; - useKeybinding('app:quickOpen', () => { - if (feature('QUICK_SEARCH')) { - setShowQuickOpen(true); - setHelpOpen(false); - } - }, { - context: 'Global', - isActive: quickSearchActive - }); - useKeybinding('app:globalSearch', () => { - if (feature('QUICK_SEARCH')) { - setShowGlobalSearch(true); - setHelpOpen(false); - } - }, { - context: 'Global', - isActive: quickSearchActive - }); - useKeybinding('history:search', () => { - if (feature('HISTORY_PICKER')) { - setShowHistoryPicker(true); - setHelpOpen(false); - } - }, { - context: 'Global', - isActive: feature('HISTORY_PICKER') ? !isModalOverlayActive : false - }); + const quickSearchActive = feature('QUICK_SEARCH') + ? !isModalOverlayActive + : false + useKeybinding( + 'app:quickOpen', + () => { + if (feature('QUICK_SEARCH')) { + setShowQuickOpen(true) + setHelpOpen(false) + } + }, + { context: 'Global', isActive: quickSearchActive }, + ) + useKeybinding( + 'app:globalSearch', + () => { + if (feature('QUICK_SEARCH')) { + setShowGlobalSearch(true) + setHelpOpen(false) + } + }, + { context: 'Global', isActive: quickSearchActive }, + ) + + useKeybinding( + 'history:search', + () => { + if (feature('HISTORY_PICKER')) { + setShowHistoryPicker(true) + setHelpOpen(false) + } + }, + { + context: 'Global', + isActive: feature('HISTORY_PICKER') ? !isModalOverlayActive : false, + }, + ) // Handle Ctrl+C to abort speculation when idle (not loading) // CancelRequestHandler only handles Ctrl+C during active tasks - useKeybinding('app:interrupt', () => { - abortSpeculation(setAppState); - }, { - context: 'Global', - isActive: !isLoading && speculation.status === 'active' - }); + useKeybinding( + 'app:interrupt', + () => { + abortSpeculation(setAppState) + }, + { + context: 'Global', + isActive: !isLoading && speculation.status === 'active', + }, + ) // Footer indicator navigation keybindings. ↑/↓ live here (not in // handleHistoryUp/Down) because TextInput focus=false when a pill is // selected — its useInput is inactive, so this is the only path. - useKeybindings({ - 'footer:up': () => { - // ↑ scrolls within the coordinator task list before leaving the pill - if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) { - setCoordinatorTaskIndex(prev => prev - 1); - return; - } - navigateFooter(-1, true); - }, - 'footer:down': () => { - // ↓ scrolls within the coordinator task list, never leaves the pill - if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) { - if (coordinatorTaskIndex < coordinatorTaskCount - 1) { - setCoordinatorTaskIndex(prev => prev + 1); + useKeybindings( + { + 'footer:up': () => { + // ↑ scrolls within the coordinator task list before leaving the pill + if ( + tasksSelected && + process.env.USER_TYPE === 'ant' && + coordinatorTaskCount > 0 && + coordinatorTaskIndex > minCoordinatorIndex + ) { + setCoordinatorTaskIndex(prev => prev - 1) + return } - return; - } - if (tasksSelected && !isTeammateMode) { - setShowBashesDialog(true); - selectFooterItem(null); - return; - } - navigateFooter(1); - }, - 'footer:next': () => { - // Teammate mode: ←/→ cycles within the team member list - if (tasksSelected && isTeammateMode) { - const totalAgents = 1 + inProcessTeammates.length; - setTeammateFooterIndex(prev => (prev + 1) % totalAgents); - return; - } - navigateFooter(1); - }, - 'footer:previous': () => { - if (tasksSelected && isTeammateMode) { - const totalAgents = 1 + inProcessTeammates.length; - setTeammateFooterIndex(prev => (prev - 1 + totalAgents) % totalAgents); - return; - } - navigateFooter(-1); - }, - 'footer:openSelected': () => { - if (viewSelectionMode === 'selecting-agent') { - return; - } - switch (footerItemSelected) { - case 'companion': - if (feature('BUDDY')) { - selectFooterItem(null); - void onSubmit('/buddy'); + navigateFooter(-1, true) + }, + 'footer:down': () => { + // ↓ scrolls within the coordinator task list, never leaves the pill + if ( + tasksSelected && + process.env.USER_TYPE === 'ant' && + coordinatorTaskCount > 0 + ) { + if (coordinatorTaskIndex < coordinatorTaskCount - 1) { + setCoordinatorTaskIndex(prev => prev + 1) } - break; - case 'tasks': - if (isTeammateMode) { - // Enter switches to the selected agent's view - if (teammateFooterIndex === 0) { - exitTeammateView(setAppState); - } else { - const teammate = inProcessTeammates[teammateFooterIndex - 1]; - if (teammate) enterTeammateView(teammate.id, setAppState); + return + } + if (tasksSelected && !isTeammateMode) { + setShowBashesDialog(true) + selectFooterItem(null) + return + } + navigateFooter(1) + }, + 'footer:next': () => { + // Teammate mode: ←/→ cycles within the team member list + if (tasksSelected && isTeammateMode) { + const totalAgents = 1 + inProcessTeammates.length + setTeammateFooterIndex(prev => (prev + 1) % totalAgents) + return + } + navigateFooter(1) + }, + 'footer:previous': () => { + if (tasksSelected && isTeammateMode) { + const totalAgents = 1 + inProcessTeammates.length + setTeammateFooterIndex(prev => (prev - 1 + totalAgents) % totalAgents) + return + } + navigateFooter(-1) + }, + 'footer:openSelected': () => { + if (viewSelectionMode === 'selecting-agent') { + return + } + switch (footerItemSelected) { + case 'companion': + if (feature('BUDDY')) { + selectFooterItem(null) + void onSubmit('/buddy') } - } else if (coordinatorTaskIndex === 0 && coordinatorTaskCount > 0) { - exitTeammateView(setAppState); - } else { - const selectedTaskId = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]?.id; - if (selectedTaskId) { - enterTeammateView(selectedTaskId, setAppState); + break + case 'tasks': + if (isTeammateMode) { + // Enter switches to the selected agent's view + if (teammateFooterIndex === 0) { + exitTeammateView(setAppState) + } else { + const teammate = inProcessTeammates[teammateFooterIndex - 1] + if (teammate) enterTeammateView(teammate.id, setAppState) + } + } else if (coordinatorTaskIndex === 0 && coordinatorTaskCount > 0) { + exitTeammateView(setAppState) } else { - setShowBashesDialog(true); - selectFooterItem(null); + const selectedTaskId = + getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]?.id + if (selectedTaskId) { + enterTeammateView(selectedTaskId, setAppState) + } else { + setShowBashesDialog(true) + selectFooterItem(null) + } } + break + case 'tmux': + if (process.env.USER_TYPE === 'ant') { + setAppState(prev => + prev.tungstenPanelAutoHidden + ? { ...prev, tungstenPanelAutoHidden: false } + : { + ...prev, + tungstenPanelVisible: !( + prev.tungstenPanelVisible ?? true + ), + }, + ) + } + break + case 'bagel': + break + case 'teams': + setShowTeamsDialog(true) + selectFooterItem(null) + break + case 'bridge': + setShowBridgeDialog(true) + selectFooterItem(null) + break + } + }, + 'footer:clearSelection': () => { + selectFooterItem(null) + }, + 'footer:close': () => { + if (tasksSelected && coordinatorTaskIndex >= 1) { + const task = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1] + if (!task) return false + // When the selected row IS the viewed agent, 'x' types into the + // steering input. Any other row — dismiss it. + if ( + viewSelectionMode === 'viewing-agent' && + task.id === viewingAgentTaskId + ) { + onChange( + input.slice(0, cursorOffset) + 'x' + input.slice(cursorOffset), + ) + setCursorOffset(cursorOffset + 1) + return } - break; - case 'tmux': - if ((process.env.USER_TYPE) === 'ant') { - setAppState(prev => prev.tungstenPanelAutoHidden ? { - ...prev, - tungstenPanelAutoHidden: false - } : { - ...prev, - tungstenPanelVisible: !(prev.tungstenPanelVisible ?? true) - }); + stopOrDismissAgent(task.id, setAppState) + if (task.status !== 'running') { + setCoordinatorTaskIndex(i => Math.max(minCoordinatorIndex, i - 1)) } - break; - case 'bagel': - break; - case 'teams': - setShowTeamsDialog(true); - selectFooterItem(null); - break; - case 'bridge': - setShowBridgeDialog(true); - selectFooterItem(null); - break; - } + return + } + // Not handled — let 'x' fall through to type-to-exit + return false + }, }, - 'footer:clearSelection': () => { - selectFooterItem(null); + { + context: 'Footer', + isActive: !!footerItemSelected && !isModalOverlayActive, }, - 'footer:close': () => { - if (tasksSelected && coordinatorTaskIndex >= 1) { - const task = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]; - if (!task) return false; - // When the selected row IS the viewed agent, 'x' types into the - // steering input. Any other row — dismiss it. - if (viewSelectionMode === 'viewing-agent' && task.id === viewingAgentTaskId) { - onChange(input.slice(0, cursorOffset) + 'x' + input.slice(cursorOffset)); - setCursorOffset(cursorOffset + 1); - return; - } - stopOrDismissAgent(task.id, setAppState); - if (task.status !== 'running') { - setCoordinatorTaskIndex(i => Math.max(minCoordinatorIndex, i - 1)); - } - return; - } - // Not handled — let 'x' fall through to type-to-exit - return false; - } - }, { - context: 'Footer', - isActive: !!footerItemSelected && !isModalOverlayActive - }); + ) + useInput((char, key) => { // Skip all input handling when a full-screen dialog is open. These dialogs // render via early return, but hooks run unconditionally — so without this // guard, Escape inside a dialog leaks to the double-press message-selector. - if (showTeamsDialog || showQuickOpen || showGlobalSearch || showHistoryPicker) { - return; + if ( + showTeamsDialog || + showQuickOpen || + showGlobalSearch || + showHistoryPicker + ) { + return } // Detect failed Alt shortcuts on macOS (Option key produces special characters) if (getPlatform() === 'macos' && isMacosOptionChar(char)) { - const shortcut = MACOS_OPTION_SPECIAL_CHARS[char]; - const terminalName = getNativeCSIuTerminalDisplayName(); - const jsx = terminalName ? + const shortcut = MACOS_OPTION_SPECIAL_CHARS[char] + const terminalName = getNativeCSIuTerminalDisplayName() + const jsx = terminalName ? ( + To enable {shortcut}, set Option as Meta in{' '} {terminalName} preferences (⌘,) - : To enable {shortcut}, run /terminal-setup; + + ) : ( + To enable {shortcut}, run /terminal-setup + ) addNotification({ key: 'option-meta-hint', jsx, priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) // Don't return - let the character be typed so user sees the issue } @@ -1895,21 +2440,31 @@ function PromptInput({ // the input and type the char. Nav keys are captured by useKeybindings // above, so anything reaching here is genuinely not a footer action. // onChange clears footerSelection, so no explicit deselect. - if (footerItemSelected && char && !key.ctrl && !key.meta && !key.escape && !key.return) { - onChange(input.slice(0, cursorOffset) + char + input.slice(cursorOffset)); - setCursorOffset(cursorOffset + char.length); - return; + if ( + footerItemSelected && + char && + !key.ctrl && + !key.meta && + !key.escape && + !key.return + ) { + onChange(input.slice(0, cursorOffset) + char + input.slice(cursorOffset)) + setCursorOffset(cursorOffset + char.length) + return } // Exit special modes when backspace/escape/delete/ctrl+u is pressed at cursor position 0 - if (cursorOffset === 0 && (key.escape || key.backspace || key.delete || key.ctrl && char === 'u')) { - onModeChange('prompt'); - setHelpOpen(false); + if ( + cursorOffset === 0 && + (key.escape || key.backspace || key.delete || (key.ctrl && char === 'u')) + ) { + onModeChange('prompt') + setHelpOpen(false) } // Exit help mode when backspace is pressed and input is empty if (helpOpen && input === '' && (key.backspace || key.delete)) { - setHelpOpen(false); + setHelpOpen(false) } // esc is a little overloaded: @@ -1922,73 +2477,83 @@ function PromptInput({ if (key.escape) { // Abort active speculation if (speculation.status === 'active') { - abortSpeculation(setAppState); - return; + abortSpeculation(setAppState) + return } // Dismiss side question response if visible if (isSideQuestionVisible && onDismissSideQuestion) { - onDismissSideQuestion(); - return; + onDismissSideQuestion() + return } // Close help menu if open if (helpOpen) { - setHelpOpen(false); - return; + setHelpOpen(false) + return } // Footer selection clearing is now handled via Footer context keybindings // (footer:clearSelection action bound to escape) // If a footer item is selected, let the Footer keybinding handle it if (footerItemSelected) { - return; + return } // If there's an editable queued command, move it to the input for editing when ESC is pressed - const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable); + const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable) if (hasEditableCommand) { - void popAllCommandsFromQueue(); - return; + void popAllCommandsFromQueue() + return } + if (messages.length > 0 && !input && !isLoading) { - doublePressEscFromEmpty(); + doublePressEscFromEmpty() } } + if (key.return && helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }); - const swarmBanner = useSwarmBanner(); - const fastModeCooldown = isFastModeEnabled() ? isFastModeCooldown() : false; - const showFastIcon = isFastModeEnabled() ? isFastMode && (isFastModeAvailable() || fastModeCooldown) : false; - const showFastIconHint = useShowFastIconHint(showFastIcon ?? false); + }) + + const swarmBanner = useSwarmBanner() + + const fastModeCooldown = isFastModeEnabled() ? isFastModeCooldown() : false + const showFastIcon = isFastModeEnabled() + ? isFastMode && (isFastModeAvailable() || fastModeCooldown) + : false + + const showFastIconHint = useShowFastIconHint(showFastIcon ?? false) // Show effort notification on startup and when effort changes. // Suppressed in brief/assistant mode — the value reflects the local // client's effort, not the connected agent's. - const effortNotificationText = briefOwnsGap ? undefined : getEffortNotificationText(effortValue, mainLoopModel); + const effortNotificationText = briefOwnsGap + ? undefined + : getEffortNotificationText(effortValue, mainLoopModel) useEffect(() => { if (!effortNotificationText) { - removeNotification('effort-level'); - return; + removeNotification('effort-level') + return } addNotification({ key: 'effort-level', text: effortNotificationText, priority: 'high', - timeoutMs: 12_000 - }); - }, [effortNotificationText, addNotification, removeNotification]); - useBuddyNotification(); - const companionSpeaking = feature('BUDDY') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.companionReaction !== undefined) : false; - const { - columns, - rows - } = useTerminalSize(); - const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking); + timeoutMs: 12_000, + }) + }, [effortNotificationText, addNotification, removeNotification]) + + useBuddyNotification() + + const companionSpeaking = feature('BUDDY') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.companionReaction !== undefined) + : false + const { columns, rows } = useTerminalSize() + const textInputColumns = + columns - 3 - companionReservedColumns(columns, companionSpeaking) // POC: click-to-position-cursor. Mouse tracking is only enabled inside // , so this is dormant in the normal main-screen REPL. @@ -1996,184 +2561,324 @@ function PromptInput({ // tightly wraps the text input so they map directly to (column, line) // in the Cursor wrap model. MeasuredText.getOffsetFromPosition handles // wide chars, wrapped lines, and clamps past-end clicks to line end. - const maxVisibleLines = isFullscreenEnvEnabled() ? Math.max(MIN_INPUT_VIEWPORT_LINES, Math.floor(rows / 2) - PROMPT_FOOTER_LINES) : undefined; - const handleInputClick = useCallback((e: ClickEvent) => { - // During history search the displayed text is historyMatch, not - // input, and showCursor is false anyway — skip rather than - // compute an offset against the wrong string. - if (!input || isSearchingHistory) return; - const c = Cursor.fromText(input, textInputColumns, cursorOffset); - const viewportStart = c.getViewportStartLine(maxVisibleLines); - const offset = c.measuredText.getOffsetFromPosition({ - line: e.localRow + viewportStart, - column: e.localCol - }); - setCursorOffset(offset); - }, [input, textInputColumns, isSearchingHistory, cursorOffset, maxVisibleLines]); - const handleOpenTasksDialog = useCallback((taskId?: string) => setShowBashesDialog(taskId ?? true), [setShowBashesDialog]); - const placeholder = showPromptSuggestion && promptSuggestion ? promptSuggestion : defaultPlaceholder; + const maxVisibleLines = isFullscreenEnvEnabled() + ? Math.max( + MIN_INPUT_VIEWPORT_LINES, + Math.floor(rows / 2) - PROMPT_FOOTER_LINES, + ) + : undefined + + const handleInputClick = useCallback( + (e: ClickEvent) => { + // During history search the displayed text is historyMatch, not + // input, and showCursor is false anyway — skip rather than + // compute an offset against the wrong string. + if (!input || isSearchingHistory) return + const c = Cursor.fromText(input, textInputColumns, cursorOffset) + const viewportStart = c.getViewportStartLine(maxVisibleLines) + const offset = c.measuredText.getOffsetFromPosition({ + line: e.localRow + viewportStart, + column: e.localCol, + }) + setCursorOffset(offset) + }, + [ + input, + textInputColumns, + isSearchingHistory, + cursorOffset, + maxVisibleLines, + ], + ) + + const handleOpenTasksDialog = useCallback( + (taskId?: string) => setShowBashesDialog(taskId ?? true), + [setShowBashesDialog], + ) + + const placeholder = + showPromptSuggestion && promptSuggestion + ? promptSuggestion + : defaultPlaceholder // Calculate if input has multiple lines - const isInputWrapped = useMemo(() => input.includes('\n'), [input]); + const isInputWrapped = useMemo(() => input.includes('\n'), [input]) // Memoized callbacks for model picker to prevent re-renders when unrelated // state (like notifications) changes. This prevents the inline model picker // from visually "jumping" when notifications arrive. - const handleModelSelect = useCallback((model: string | null, _effort: EffortLevel | undefined) => { - let wasFastModeDisabled = false; - setAppState(prev => { - wasFastModeDisabled = isFastModeEnabled() && !isFastModeSupportedByModel(model) && !!prev.fastMode; - return { - ...prev, - mainLoopModel: model, - mainLoopModelForSession: null, - // Turn off fast mode if switching to a model that doesn't support it - ...(wasFastModeDisabled && { - fastMode: false - }) - }; - }); - setShowModelPicker(false); - const effectiveFastMode = (isFastMode ?? false) && !wasFastModeDisabled; - let message = `Model set to ${modelDisplayString(model)}`; - if (isBilledAsExtraUsage(model, effectiveFastMode, isOpus1mMergeEnabled())) { - message += ' · Billed as extra usage'; - } - if (wasFastModeDisabled) { - message += ' · Fast mode OFF'; - } - addNotification({ - key: 'model-switched', - jsx: {message}, - priority: 'immediate', - timeoutMs: 3000 - }); - logEvent('tengu_model_picker_hotkey', { - model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }, [setAppState, addNotification, isFastMode]); + const handleModelSelect = useCallback( + (model: string | null, _effort: EffortLevel | undefined) => { + let wasFastModeDisabled = false + setAppState(prev => { + wasFastModeDisabled = + isFastModeEnabled() && + !isFastModeSupportedByModel(model) && + !!prev.fastMode + return { + ...prev, + mainLoopModel: model, + mainLoopModelForSession: null, + // Turn off fast mode if switching to a model that doesn't support it + ...(wasFastModeDisabled && { fastMode: false }), + } + }) + setShowModelPicker(false) + const effectiveFastMode = (isFastMode ?? false) && !wasFastModeDisabled + let message = `Model set to ${modelDisplayString(model)}` + if ( + isBilledAsExtraUsage(model, effectiveFastMode, isOpus1mMergeEnabled()) + ) { + message += ' · Billed as extra usage' + } + if (wasFastModeDisabled) { + message += ' · Fast mode OFF' + } + addNotification({ + key: 'model-switched', + jsx: {message}, + priority: 'immediate', + timeoutMs: 3000, + }) + logEvent('tengu_model_picker_hotkey', { + model: + model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + [setAppState, addNotification, isFastMode], + ) + const handleModelCancel = useCallback(() => { - setShowModelPicker(false); - }, []); + setShowModelPicker(false) + }, []) // Memoize the model picker element to prevent unnecessary re-renders // when AppState changes for unrelated reasons (e.g., notifications arriving) const modelPickerElement = useMemo(() => { - if (!showModelPicker) return null; - return - - ; - }, [showModelPicker, mainLoopModel_, mainLoopModelForSession, handleModelSelect, handleModelCancel]); - const handleFastModeSelect = useCallback((result?: string) => { - setShowFastModePicker(false); - if (result) { - addNotification({ - key: 'fast-mode-toggled', - jsx: {result}, - priority: 'immediate', - timeoutMs: 3000 - }); - } - }, [addNotification]); + if (!showModelPicker) return null + return ( + + + + ) + }, [ + showModelPicker, + mainLoopModel_, + mainLoopModelForSession, + handleModelSelect, + handleModelCancel, + ]) + + const handleFastModeSelect = useCallback( + (result?: string) => { + setShowFastModePicker(false) + if (result) { + addNotification({ + key: 'fast-mode-toggled', + jsx: {result}, + priority: 'immediate', + timeoutMs: 3000, + }) + } + }, + [addNotification], + ) // Memoize the fast mode picker element const fastModePickerElement = useMemo(() => { - if (!showFastModePicker) return null; - return - - ; - }, [showFastModePicker, handleFastModeSelect]); + if (!showFastModePicker) return null + return ( + + + + ) + }, [showFastModePicker, handleFastModeSelect]) // Memoized callbacks for thinking toggle - const handleThinkingSelect = useCallback((enabled: boolean) => { - setAppState(prev => ({ - ...prev, - thinkingEnabled: enabled - })); - setShowThinkingToggle(false); - logEvent('tengu_thinking_toggled_hotkey', { - enabled - }); - addNotification({ - key: 'thinking-toggled-hotkey', - jsx: + const handleThinkingSelect = useCallback( + (enabled: boolean) => { + setAppState(prev => ({ + ...prev, + thinkingEnabled: enabled, + })) + setShowThinkingToggle(false) + logEvent('tengu_thinking_toggled_hotkey', { enabled }) + addNotification({ + key: 'thinking-toggled-hotkey', + jsx: ( + Thinking {enabled ? 'on' : 'off'} - , - priority: 'immediate', - timeoutMs: 3000 - }); - }, [setAppState, addNotification]); + + ), + priority: 'immediate', + timeoutMs: 3000, + }) + }, + [setAppState, addNotification], + ) + const handleThinkingCancel = useCallback(() => { - setShowThinkingToggle(false); - }, []); + setShowThinkingToggle(false) + }, []) // Memoize the thinking toggle element const thinkingToggleElement = useMemo(() => { - if (!showThinkingToggle) return null; - return - m.type === 'assistant')} /> - ; - }, [showThinkingToggle, thinkingEnabled, handleThinkingSelect, handleThinkingCancel, messages.length]); + if (!showThinkingToggle) return null + return ( + + m.type === 'assistant')} + /> + + ) + }, [ + showThinkingToggle, + thinkingEnabled, + handleThinkingSelect, + handleThinkingCancel, + messages.length, + ]) // Portal dialog to DialogOverlay in fullscreen so it escapes the bottom // slot's overflowY:hidden clip (same pattern as SuggestionsOverlay). // Must be called before early returns below to satisfy rules-of-hooks. // Memoized so the portal useEffect doesn't churn on every PromptInput render. - const autoModeOptInDialog = useMemo(() => feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? : null, [showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline]); - useSetPromptOverlayDialog(isFullscreenEnvEnabled() ? autoModeOptInDialog : null); + const autoModeOptInDialog = useMemo( + () => + feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? ( + + ) : null, + [showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline], + ) + useSetPromptOverlayDialog( + isFullscreenEnvEnabled() ? autoModeOptInDialog : null, + ) + if (showBashesDialog) { - return setShowBashesDialog(false)} toolUseContext={getToolUseContext(messages, [], new AbortController(), mainLoopModel)} initialDetailTaskId={typeof showBashesDialog === 'string' ? showBashesDialog : undefined} />; + return ( + setShowBashesDialog(false)} + toolUseContext={getToolUseContext( + messages, + [], + new AbortController(), + mainLoopModel, + )} + initialDetailTaskId={ + typeof showBashesDialog === 'string' ? showBashesDialog : undefined + } + /> + ) } + if (isAgentSwarmsEnabled() && showTeamsDialog) { - return { - setShowTeamsDialog(false); - }} />; + return ( + { + setShowTeamsDialog(false) + }} + /> + ) } + if (feature('QUICK_SEARCH')) { const insertWithSpacing = (text: string) => { - const cursorChar = input[cursorOffset - 1] ?? ' '; - insertTextAtCursor(/\s/.test(cursorChar) ? text : ` ${text}`); - }; + const cursorChar = input[cursorOffset - 1] ?? ' ' + insertTextAtCursor(/\s/.test(cursorChar) ? text : ` ${text}`) + } if (showQuickOpen) { - return setShowQuickOpen(false)} onInsert={insertWithSpacing} />; + return ( + setShowQuickOpen(false)} + onInsert={insertWithSpacing} + /> + ) } if (showGlobalSearch) { - return setShowGlobalSearch(false)} onInsert={insertWithSpacing} />; + return ( + setShowGlobalSearch(false)} + onInsert={insertWithSpacing} + /> + ) } } + if (feature('HISTORY_PICKER') && showHistoryPicker) { - return { - const entryMode = getModeFromInput(entry.display); - const value = getValueFromInput(entry.display); - onModeChange(entryMode); - trackAndSetInput(value); - setPastedContents(entry.pastedContents); - setCursorOffset(value.length); - setShowHistoryPicker(false); - }} onCancel={() => setShowHistoryPicker(false)} />; + return ( + { + const entryMode = getModeFromInput(entry.display) + const value = getValueFromInput(entry.display) + onModeChange(entryMode) + trackAndSetInput(value) + setPastedContents(entry.pastedContents) + setCursorOffset(value.length) + setShowHistoryPicker(false) + }} + onCancel={() => setShowHistoryPicker(false)} + /> + ) } // Show loop mode menu when requested (ant-only, eliminated from external builds) if (modelPickerElement) { - return modelPickerElement; + return modelPickerElement } + if (fastModePickerElement) { - return fastModePickerElement; + return fastModePickerElement } + if (thinkingToggleElement) { - return thinkingToggleElement; + return thinkingToggleElement } + if (showBridgeDialog) { - return { - setShowBridgeDialog(false); - selectFooterItem(null); - }} />; + return ( + { + setShowBridgeDialog(false) + selectFooterItem(null) + }} + /> + ) } + const baseProps: BaseTextInputProps = { multiline: true, onSubmit, onChange, - value: historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input, + value: historyMatch + ? getValueFromInput( + typeof historyMatch === 'string' + ? historyMatch + : historyMatch.display, + ) + : input, // History navigation is handled via TextInput props (onHistoryUp/onHistoryDown), // NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown // to try cursor movement first and only fall through to history navigation when the @@ -2183,117 +2888,243 @@ function PromptInput({ onHistoryReset: resetHistory, placeholder, onExit, - onExitMessage: (show, key) => setExitMessage({ - show, - key - }), + onExitMessage: (show, key) => setExitMessage({ show, key }), onImagePaste, columns: textInputColumns, maxVisibleLines, - disableCursorMovementForUpDownKeys: suggestions.length > 0 || !!footerItemSelected, + disableCursorMovementForUpDownKeys: + suggestions.length > 0 || !!footerItemSelected, disableEscapeDoublePress: suggestions.length > 0, cursorOffset, onChangeCursorOffset: setCursorOffset, onPaste: onTextPaste, onIsPastingChange: setIsPasting, focus: !isSearchingHistory && !isModalOverlayActive && !footerItemSelected, - showCursor: !footerItemSelected && !isSearchingHistory && !cursorAtImageChip, + showCursor: + !footerItemSelected && !isSearchingHistory && !cursorAtImageChip, argumentHint: commandArgumentHint, - onUndo: canUndo ? () => { - const previousState = undo(); - if (previousState) { - trackAndSetInput(previousState.text); - setCursorOffset(previousState.cursorOffset); - setPastedContents(previousState.pastedContents); - } - } : undefined, + onUndo: canUndo + ? () => { + const previousState = undo() + if (previousState) { + trackAndSetInput(previousState.text) + setCursorOffset(previousState.cursorOffset) + setPastedContents(previousState.pastedContents) + } + } + : undefined, highlights: combinedHighlights, inlineGhostText, - inputFilter: lazySpaceInputFilter - }; + inputFilter: lazySpaceInputFilter, + } + const getBorderColor = (): keyof Theme => { const modeColors: Record = { - bash: 'bashBorder' - }; + bash: 'bashBorder', + } // Mode colors take priority, then teammate color, then default if (modeColors[mode]) { - return modeColors[mode]; + return modeColors[mode] } // In-process teammates run headless - don't apply teammate colors to leader UI if (isInProcessTeammate()) { - return 'promptBorder'; + return 'promptBorder' } // Check for teammate color from environment - const teammateColorName = getTeammateColor(); - if (teammateColorName && AGENT_COLORS.includes(teammateColorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName]; + const teammateColorName = getTeammateColor() + if ( + teammateColorName && + AGENT_COLORS.includes(teammateColorName as AgentColorName) + ) { + return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName] } - return 'promptBorder'; - }; + + return 'promptBorder' + } + if (isExternalEditorActive) { - return + return ( + Save and close editor to continue... - ; + + ) } - const textInputElement = isVimModeEnabled() ? : ; - return + + const textInputElement = isVimModeEnabled() ? ( + + ) : ( + + ) + + return ( + {!isFullscreenEnvEnabled() && } - {hasSuppressedDialogs && + {hasSuppressedDialogs && ( + Waiting for permission… - } + + )} - {swarmBanner ? <> + {swarmBanner ? ( + <> - {swarmBanner.text ? <> - {'─'.repeat(Math.max(0, columns - stringWidth(swarmBanner.text) - 4))} + {swarmBanner.text ? ( + <> + {'─'.repeat( + Math.max(0, columns - stringWidth(swarmBanner.text) - 4), + )} {' '} {swarmBanner.text}{' '} {'──'} - : '─'.repeat(columns)} + + ) : ( + '─'.repeat(columns) + )} - + {textInputElement} {'─'.repeat(columns)} - : - + + ) : ( + + {textInputElement} - } - 0} isLoading={isLoading} tasksSelected={tasksSelected} teamsSelected={teamsSelected} bridgeSelected={bridgeSelected} tmuxSelected={tmuxSelected} teammateFooterIndex={teammateFooterIndex} ideSelection={ideSelection} mcpClients={mcpClients} isPasting={isPasting} isInputWrapped={isInputWrapped} messages={messages} isSearching={isSearchingHistory} historyQuery={historyQuery} setHistoryQuery={setHistoryQuery} historyFailedMatch={historyFailedMatch} onOpenTasksDialog={isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined} /> + + )} + 0} + isLoading={isLoading} + tasksSelected={tasksSelected} + teamsSelected={teamsSelected} + bridgeSelected={bridgeSelected} + tmuxSelected={tmuxSelected} + teammateFooterIndex={teammateFooterIndex} + ideSelection={ideSelection} + mcpClients={mcpClients} + isPasting={isPasting} + isInputWrapped={isInputWrapped} + messages={messages} + isSearching={isSearchingHistory} + historyQuery={historyQuery} + setHistoryQuery={setHistoryQuery} + historyFailedMatch={historyFailedMatch} + onOpenTasksDialog={ + isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined + } + /> {isFullscreenEnvEnabled() ? null : autoModeOptInDialog} - {isFullscreenEnvEnabled() ? - // position=absolute takes zero layout height so the spinner - // doesn't shift when a notification appears/disappears. Yoga - // anchors absolute children at the parent's content-box origin; - // marginTop=-1 pulls it into the marginTop=1 gap row above the - // prompt border. In brief mode there is no such gap (briefOwnsGap - // strips our marginTop) and BriefSpinner sits flush against the - // border — marginTop=-2 skips over the spinner content into - // BriefSpinner's own marginTop=1 blank row. height=1 + - // overflow=hidden clips multi-line notifications to a single row. - // flex-end anchors the bottom line so the visible row is always - // the most recent. Suppressed while the slash overlay or - // auto-mode opt-in dialog is up by height=0 (NOT unmount) — this - // Box renders later in tree order so it would paint over their - // bottom row. Keeping Notifications mounted prevents AutoUpdater's - // initial-check effect from re-firing on every slash-completion - // toggle (PR#22413). - - - : null} - ; + {isFullscreenEnvEnabled() ? ( + // position=absolute takes zero layout height so the spinner + // doesn't shift when a notification appears/disappears. Yoga + // anchors absolute children at the parent's content-box origin; + // marginTop=-1 pulls it into the marginTop=1 gap row above the + // prompt border. In brief mode there is no such gap (briefOwnsGap + // strips our marginTop) and BriefSpinner sits flush against the + // border — marginTop=-2 skips over the spinner content into + // BriefSpinner's own marginTop=1 blank row. height=1 + + // overflow=hidden clips multi-line notifications to a single row. + // flex-end anchors the bottom line so the visible row is always + // the most recent. Suppressed while the slash overlay or + // auto-mode opt-in dialog is up by height=0 (NOT unmount) — this + // Box renders later in tree order so it would paint over their + // bottom row. Keeping Notifications mounted prevents AutoUpdater's + // initial-check effect from re-firing on every slash-completion + // toggle (PR#22413). + + + + ) : null} + + ) } /** @@ -2301,38 +3132,46 @@ function PromptInput({ * This handles --continue/--resume scenarios where we need to avoid ID collisions. */ function getInitialPasteId(messages: Message[]): number { - let maxId = 0; + let maxId = 0 for (const message of messages) { if (message.type === 'user') { // Check image paste IDs if (message.imagePasteIds) { - for (const id of message.imagePasteIds as number[]) { - if (id > maxId) maxId = id; + for (const id of message.imagePasteIds) { + if (id > maxId) maxId = id } } // Check text paste references in message content if (Array.isArray(message.message.content)) { for (const block of message.message.content) { if (block.type === 'text') { - const refs = parseReferences(block.text); + const refs = parseReferences(block.text) for (const ref of refs) { - if (ref.id > maxId) maxId = ref.id; + if (ref.id > maxId) maxId = ref.id } } } } } } - return maxId + 1; + return maxId + 1 } -function buildBorderText(showFastIcon: boolean, showFastIconHint: boolean, fastModeCooldown: boolean): BorderTextOptions | undefined { - if (!showFastIcon) return undefined; - const fastSeg = showFastIconHint ? `${getFastIconString(true, fastModeCooldown)} ${chalk.dim('/fast')}` : getFastIconString(true, fastModeCooldown); + +function buildBorderText( + showFastIcon: boolean, + showFastIconHint: boolean, + fastModeCooldown: boolean, +): BorderTextOptions | undefined { + if (!showFastIcon) return undefined + const fastSeg = showFastIconHint + ? `${getFastIconString(true, fastModeCooldown)} ${chalk.dim('/fast')}` + : getFastIconString(true, fastModeCooldown) return { content: ` ${fastSeg} `, position: 'top', align: 'end', - offset: 0 - }; + offset: 0, + } } -export default React.memo(PromptInput); + +export default React.memo(PromptInput) diff --git a/src/components/PromptInput/PromptInputFooter.tsx b/src/components/PromptInput/PromptInputFooter.tsx index 8f23dfdb9..652bdf3f0 100644 --- a/src/components/PromptInput/PromptInputFooter.tsx +++ b/src/components/PromptInput/PromptInputFooter.tsx @@ -1,65 +1,77 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { memo, type ReactNode, useMemo, useRef } from 'react'; -import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'; -import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'; -import { useSetPromptOverlay } from '../../context/promptOverlayContext.js'; -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; -import type { IDESelection } from '../../hooks/useIdeSelection.js'; -import { useSettings } from '../../hooks/useSettings.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Box, Text } from '../../ink.js'; -import type { MCPServerConnection } from '../../services/mcp/types.js'; -import { useAppState } from '../../state/AppState.js'; -import type { ToolPermissionContext } from '../../Tool.js'; -import type { Message } from '../../types/message.js'; -import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'; -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import { isUndercover } from '../../utils/undercover.js'; -import { CoordinatorTaskPanel, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js'; -import { getLastAssistantMessageId, StatusLine, statusLineShouldDisplay } from '../StatusLine.js'; -import { Notifications } from './Notifications.js'; -import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'; -import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'; -import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { memo, type ReactNode, useMemo, useRef } from 'react' +import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' +import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js' +import { useSetPromptOverlay } from '../../context/promptOverlayContext.js' +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' +import type { IDESelection } from '../../hooks/useIdeSelection.js' +import { useSettings } from '../../hooks/useSettings.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Text } from '../../ink.js' +import type { MCPServerConnection } from '../../services/mcp/types.js' +import { useAppState } from '../../state/AppState.js' +import type { ToolPermissionContext } from '../../Tool.js' +import type { Message } from '../../types/message.js' +import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js' +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import { isUndercover } from '../../utils/undercover.js' +import { + CoordinatorTaskPanel, + useCoordinatorTaskCount, +} from '../CoordinatorAgentStatus.js' +import { + getLastAssistantMessageId, + StatusLine, + statusLineShouldDisplay, +} from '../StatusLine.js' +import { Notifications } from './Notifications.js' +import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js' +import { + PromptInputFooterSuggestions, + type SuggestionItem, +} from './PromptInputFooterSuggestions.js' +import { PromptInputHelpMenu } from './PromptInputHelpMenu.js' + type Props = { - apiKeyStatus: VerificationStatus; - debug: boolean; + apiKeyStatus: VerificationStatus + debug: boolean exitMessage: { - show: boolean; - key?: string; - }; - vimMode: VimMode | undefined; - mode: PromptInputMode; - autoUpdaterResult: AutoUpdaterResult | null; - isAutoUpdating: boolean; - verbose: boolean; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - onChangeIsUpdating: (isUpdating: boolean) => void; - suggestions: SuggestionItem[]; - selectedSuggestion: number; - maxColumnWidth?: number; - toolPermissionContext: ToolPermissionContext; - helpOpen: boolean; - suppressHint: boolean; - isLoading: boolean; - tasksSelected: boolean; - teamsSelected: boolean; - bridgeSelected: boolean; - tmuxSelected: boolean; - teammateFooterIndex?: number; - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; - isPasting?: boolean; - isInputWrapped?: boolean; - messages: Message[]; - isSearching: boolean; - historyQuery: string; - setHistoryQuery: (query: string) => void; - historyFailedMatch: boolean; - onOpenTasksDialog?: (taskId?: string) => void; -}; + show: boolean + key?: string + } + vimMode: VimMode | undefined + mode: PromptInputMode + autoUpdaterResult: AutoUpdaterResult | null + isAutoUpdating: boolean + verbose: boolean + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + onChangeIsUpdating: (isUpdating: boolean) => void + suggestions: SuggestionItem[] + selectedSuggestion: number + maxColumnWidth?: number + toolPermissionContext: ToolPermissionContext + helpOpen: boolean + suppressHint: boolean + isLoading: boolean + tasksSelected: boolean + teamsSelected: boolean + bridgeSelected: boolean + tmuxSelected: boolean + teammateFooterIndex?: number + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] + isPasting?: boolean + isInputWrapped?: boolean + messages: Message[] + isSearching: boolean + historyQuery: string + setHistoryQuery: (query: string) => void + historyFailedMatch: boolean + onOpenTasksDialog?: (taskId?: string) => void +} + function PromptInputFooter({ apiKeyStatus, debug, @@ -92,99 +104,176 @@ function PromptInputFooter({ historyQuery, setHistoryQuery, historyFailedMatch, - onOpenTasksDialog + onOpenTasksDialog, }: Props): ReactNode { - const settings = useSettings(); - const { - columns, - rows - } = useTerminalSize(); - const messagesRef = useRef(messages); - messagesRef.current = messages; - const lastAssistantMessageId = useMemo(() => getLastAssistantMessageId(messages), [messages]); - const isNarrow = columns < 80; + const settings = useSettings() + const { columns, rows } = useTerminalSize() + const messagesRef = useRef(messages) + messagesRef.current = messages + const lastAssistantMessageId = useMemo( + () => getLastAssistantMessageId(messages), + [messages], + ) + const isNarrow = columns < 80 // In fullscreen the bottom slot is flexShrink:0, so every row here is a row // stolen from the ScrollBox. Drop the optional StatusLine first. Non-fullscreen // has terminal scrollback to absorb overflow, so we never hide StatusLine there. - const isFullscreen = isFullscreenEnvEnabled(); - const isShort = isFullscreen && rows < 24; + const isFullscreen = isFullscreenEnvEnabled() + const isShort = isFullscreen && rows < 24 // Pill highlights when tasks is the active footer item AND no specific // agent row is selected. When coordinatorTaskIndex >= 0 the pointer has // moved into CoordinatorTaskPanel, so the pill should un-highlight. // coordinatorTaskCount === 0 covers the bash-only case (no agent rows // exist, pill is the only selectable item). - const coordinatorTaskCount = useCoordinatorTaskCount(); - const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); - const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0); + const coordinatorTaskCount = useCoordinatorTaskCount() + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) + const pillSelected = + tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0) // Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r - const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching; + const suppressHint = + suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching // Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx - const overlayData = useMemo(() => isFullscreen && suggestions.length ? { - suggestions, - selectedSuggestion, - maxColumnWidth - } : null, [isFullscreen, suggestions, selectedSuggestion, maxColumnWidth]); - useSetPromptOverlay(overlayData); + const overlayData = useMemo( + () => + isFullscreen && suggestions.length + ? { suggestions, selectedSuggestion, maxColumnWidth } + : null, + [isFullscreen, suggestions, selectedSuggestion, maxColumnWidth], + ) + useSetPromptOverlay(overlayData) + if (suggestions.length && !isFullscreen) { - return - - ; + return ( + + + + ) } + if (helpOpen) { - return ; + return ( + + ) } - return <> - + + return ( + <> + - {mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && } - + {mode === 'prompt' && + !isShort && + !exitMessage.show && + !isPasting && + statusLineShouldDisplay(settings) && ( + + )} + - {isFullscreen ? null : } - {(process.env.USER_TYPE) === 'ant' && isUndercover() && undercover} + {isFullscreen ? null : ( + + )} + {process.env.USER_TYPE === 'ant' && isUndercover() && ( + undercover + )} - {(process.env.USER_TYPE) === 'ant' && } - ; + {process.env.USER_TYPE === 'ant' && } + + ) } -export default memo(PromptInputFooter); + +export default memo(PromptInputFooter) + type BridgeStatusProps = { - bridgeSelected: boolean; -}; + bridgeSelected: boolean +} + function BridgeStatusIndicator({ - bridgeSelected + bridgeSelected, }: BridgeStatusProps): React.ReactNode { - if (!feature('BRIDGE_MODE')) return null; + if (!feature('BRIDGE_MODE')) return null // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const enabled = useAppState(s => s.replBridgeEnabled); + const enabled = useAppState(s => s.replBridgeEnabled) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const connected = useAppState(s_0 => s_0.replBridgeConnected); + const connected = useAppState(s => s.replBridgeConnected) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const sessionActive = useAppState(s_1 => s_1.replBridgeSessionActive); + const sessionActive = useAppState(s => s.replBridgeSessionActive) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const reconnecting = useAppState(s_2 => s_2.replBridgeReconnecting); + const reconnecting = useAppState(s => s.replBridgeReconnecting) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const explicit = useAppState(s_3 => s_3.replBridgeExplicit); + const explicit = useAppState(s => s.replBridgeExplicit) // Failed state is surfaced via notification (useReplBridge), not a footer pill. - if (!isBridgeEnabled() || !enabled) return null; + if (!isBridgeEnabled() || !enabled) return null + const status = getBridgeStatus({ error: undefined, connected, sessionActive, - reconnecting - }); + reconnecting, + }) // For implicit (config-driven) remote, only show the reconnecting state if (!explicit && status.label !== 'Remote Control reconnecting') { - return null; + return null } - return + + return ( + {status.label} {bridgeSelected && · Enter to view} - ; + + ) } diff --git a/src/components/PromptInput/PromptInputFooterLeftSide.tsx b/src/components/PromptInput/PromptInputFooterLeftSide.tsx index 9480af9eb..fc1be8124 100644 --- a/src/components/PromptInput/PromptInputFooterLeftSide.tsx +++ b/src/components/PromptInput/PromptInputFooterLeftSide.tsx @@ -1,239 +1,207 @@ -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 { feature } from 'bun:bundle' // Dead code elimination: conditional import for COORDINATOR_MODE /* eslint-disable @typescript-eslint/no-require-imports */ -const coordinatorModule = feature('COORDINATOR_MODE') ? require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js') : undefined; +const coordinatorModule = feature('COORDINATOR_MODE') + ? (require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js')) + : undefined /* eslint-enable @typescript-eslint/no-require-imports */ -import { Box, Text, Link } from '../../ink.js'; -import * as React from 'react'; -import figures from 'figures'; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js'; -import type { ToolPermissionContext } from '../../Tool.js'; -import { isVimModeEnabled } from './utils.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { isDefaultMode, permissionModeSymbol, permissionModeTitle, getModeColor } from '../../utils/permissions/PermissionMode.js'; -import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js'; -import { isBackgroundTask } from '../../tasks/types.js'; -import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js'; -import { count } from '../../utils/array.js'; -import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { TeamStatus } from '../teams/TeamStatus.js'; -import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'; -import { useAppState, useAppStateStore } from 'src/state/AppState.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -import HistorySearchInput from './HistorySearchInput.js'; -import { usePrStatus } from '../../hooks/usePrStatus.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useTasksV2 } from '../../hooks/useTasksV2.js'; -import { formatDuration } from '../../utils/format.js'; -import { VoiceWarmupHint } from './VoiceIndicator.js'; -import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'; -import { useVoiceState } from '../../context/voice.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import { isXtermJs } from '../../ink/terminal.js'; -import { useHasSelection, useSelection } from '../../ink/hooks/use-selection.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { getPlatform } from '../../utils/platform.js'; -import { PrBadge } from '../PrBadge.js'; +import { Box, Text, Link } from '../../ink.js' +import * as React from 'react' +import figures from 'figures' +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js' +import type { ToolPermissionContext } from '../../Tool.js' +import { isVimModeEnabled } from './utils.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { + isDefaultMode, + permissionModeSymbol, + permissionModeTitle, + getModeColor, +} from '../../utils/permissions/PermissionMode.js' +import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js' +import { isBackgroundTask } from '../../tasks/types.js' +import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js' +import { count } from '../../utils/array.js' +import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { TeamStatus } from '../teams/TeamStatus.js' +import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js' +import { useAppState, useAppStateStore } from 'src/state/AppState.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' +import HistorySearchInput from './HistorySearchInput.js' +import { usePrStatus } from '../../hooks/usePrStatus.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { useTasksV2 } from '../../hooks/useTasksV2.js' +import { formatDuration } from '../../utils/format.js' +import { VoiceWarmupHint } from './VoiceIndicator.js' +import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js' +import { useVoiceState } from '../../context/voice.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import { isXtermJs } from '../../ink/terminal.js' +import { useHasSelection, useSelection } from '../../ink/hooks/use-selection.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { getPlatform } from '../../utils/platform.js' +import { PrBadge } from '../PrBadge.js' // Dead code elimination: conditional import for proactive mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') : null; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? require('../../proactive/index.js') + : null /* eslint-enable @typescript-eslint/no-require-imports */ -const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; -const NULL = () => null; -const MAX_VOICE_HINT_SHOWS = 3; +const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} +const NULL = () => null +const MAX_VOICE_HINT_SHOWS = 3 + type Props = { exitMessage: { - show: boolean; - key?: string; - }; - vimMode: VimMode | undefined; - mode: PromptInputMode; - toolPermissionContext: ToolPermissionContext; - suppressHint: boolean; - isLoading: boolean; - showMemoryTypeSelector?: boolean; - tasksSelected: boolean; - teamsSelected: boolean; - tmuxSelected: boolean; - teammateFooterIndex?: number; - isPasting?: boolean; - isSearching: boolean; - historyQuery: string; - setHistoryQuery: (query: string) => void; - historyFailedMatch: boolean; - onOpenTasksDialog?: (taskId?: string) => void; -}; -function ProactiveCountdown() { - const $ = _c(7); - const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL); - const [remainingSeconds, setRemainingSeconds] = useState(null); - let t0; - let t1; - if ($[0] !== nextTickAt) { - t0 = () => { - if (nextTickAt === null) { - setRemainingSeconds(null); - return; - } - const update = function update() { - const remaining = Math.max(0, Math.ceil((nextTickAt - Date.now()) / 1000)); - setRemainingSeconds(remaining); - }; - update(); - const interval = setInterval(update, 1000); - return () => clearInterval(interval); - }; - t1 = [nextTickAt]; - $[0] = nextTickAt; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; - } - useEffect(t0, t1); - if (remainingSeconds === null) { - return null; - } - const t2 = remainingSeconds * 1000; - let t3; - if ($[3] !== t2) { - t3 = formatDuration(t2, { - mostSignificantOnly: true - }); - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t3) { - t4 = waiting{" "}{t3}; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; + show: boolean + key?: string } - return t4; + vimMode: VimMode | undefined + mode: PromptInputMode + toolPermissionContext: ToolPermissionContext + suppressHint: boolean + isLoading: boolean + showMemoryTypeSelector?: boolean + tasksSelected: boolean + teamsSelected: boolean + tmuxSelected: boolean + teammateFooterIndex?: number + isPasting?: boolean + isSearching: boolean + historyQuery: string + setHistoryQuery: (query: string) => void + historyFailedMatch: boolean + onOpenTasksDialog?: (taskId?: string) => void } -export function PromptInputFooterLeftSide(t0) { - const $ = _c(27); - const { - exitMessage, - vimMode, - mode, - toolPermissionContext, - suppressHint, - isLoading, - tasksSelected, - teamsSelected, - tmuxSelected, - teammateFooterIndex, - isPasting, - isSearching, - historyQuery, - setHistoryQuery, - historyFailedMatch, - onOpenTasksDialog - } = t0; - if (exitMessage.show) { - let t1; - if ($[0] !== exitMessage.key) { - t1 = Press {exitMessage.key} again to exit; - $[0] = exitMessage.key; - $[1] = t1; - } else { - t1 = $[1]; + +function ProactiveCountdown(): React.ReactNode { + const nextTickAt = useSyncExternalStore( + proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, + proactiveModule?.getNextTickAt ?? NULL, + NULL, + ) + + const [remainingSeconds, setRemainingSeconds] = useState(null) + + useEffect(() => { + if (nextTickAt === null) { + setRemainingSeconds(null) + return } - return t1; - } - if (isPasting) { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Pasting text…; - $[2] = t1; - } else { - t1 = $[2]; + + function update(): void { + const remaining = Math.max( + 0, + Math.ceil((nextTickAt! - Date.now()) / 1000), + ) + setRemainingSeconds(remaining) } - return t1; - } - let t1; - if ($[3] !== isSearching || $[4] !== vimMode) { - t1 = isVimModeEnabled() && vimMode === "INSERT" && !isSearching; - $[3] = isSearching; - $[4] = vimMode; - $[5] = t1; - } else { - t1 = $[5]; - } - const showVim = t1; - let t2; - if ($[6] !== historyFailedMatch || $[7] !== historyQuery || $[8] !== isSearching || $[9] !== setHistoryQuery) { - t2 = isSearching && ; - $[6] = historyFailedMatch; - $[7] = historyQuery; - $[8] = isSearching; - $[9] = setHistoryQuery; - $[10] = t2; - } else { - t2 = $[10]; - } - let t3; - if ($[11] !== showVim) { - t3 = showVim ? -- INSERT -- : null; - $[11] = showVim; - $[12] = t3; - } else { - t3 = $[12]; - } - const t4 = !suppressHint && !showVim; - let t5; - if ($[13] !== isLoading || $[14] !== mode || $[15] !== onOpenTasksDialog || $[16] !== t4 || $[17] !== tasksSelected || $[18] !== teammateFooterIndex || $[19] !== teamsSelected || $[20] !== tmuxSelected || $[21] !== toolPermissionContext) { - t5 = ; - $[13] = isLoading; - $[14] = mode; - $[15] = onOpenTasksDialog; - $[16] = t4; - $[17] = tasksSelected; - $[18] = teammateFooterIndex; - $[19] = teamsSelected; - $[20] = tmuxSelected; - $[21] = toolPermissionContext; - $[22] = t5; - } else { - t5 = $[22]; + + update() + const interval = setInterval(update, 1000) + return () => clearInterval(interval) + }, [nextTickAt]) + + if (remainingSeconds === null) return null + + return ( + + waiting{' '} + {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })} + + ) +} + +export function PromptInputFooterLeftSide({ + exitMessage, + vimMode, + mode, + toolPermissionContext, + suppressHint, + isLoading, + tasksSelected, + teamsSelected, + tmuxSelected, + teammateFooterIndex, + isPasting, + isSearching, + historyQuery, + setHistoryQuery, + historyFailedMatch, + onOpenTasksDialog, +}: Props): React.ReactNode { + if (exitMessage.show) { + return ( + + Press {exitMessage.key} again to exit + + ) } - let t6; - if ($[23] !== t2 || $[24] !== t3 || $[25] !== t5) { - t6 = {t2}{t3}{t5}; - $[23] = t2; - $[24] = t3; - $[25] = t5; - $[26] = t6; - } else { - t6 = $[26]; + if (isPasting) { + return ( + + Pasting text… + + ) } - return t6; + + const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching + + return ( + + {isSearching && ( + + )} + {showVim ? ( + + -- INSERT -- + + ) : null} + + + ) } + type ModeIndicatorProps = { - mode: PromptInputMode; - toolPermissionContext: ToolPermissionContext; - showHint: boolean; - isLoading: boolean; - tasksSelected: boolean; - teamsSelected: boolean; - tmuxSelected: boolean; - teammateFooterIndex?: number; - onOpenTasksDialog?: (taskId?: string) => void; -}; + mode: PromptInputMode + toolPermissionContext: ToolPermissionContext + showHint: boolean + isLoading: boolean + tasksSelected: boolean + teamsSelected: boolean + tmuxSelected: boolean + teammateFooterIndex?: number + onOpenTasksDialog?: (taskId?: string) => void +} + function ModeIndicator({ mode, toolPermissionContext, @@ -243,186 +211,334 @@ function ModeIndicator({ teamsSelected, tmuxSelected, teammateFooterIndex, - onOpenTasksDialog + onOpenTasksDialog, }: ModeIndicatorProps): React.ReactNode { - const { - columns - } = useTerminalSize(); - const modeCycleShortcut = useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'); - const tasks = useAppState(s => s.tasks); - const teamContext = useAppState(s_0 => s_0.teamContext); + const { columns } = useTerminalSize() + const modeCycleShortcut = useShortcutDisplay( + 'chat:cycleMode', + 'Chat', + 'shift+tab', + ) + const tasks = useAppState(s => s.tasks) + const teamContext = useAppState(s => s.teamContext) // Set once in initialState (main.tsx --remote mode) and never mutated — lazy // init captures the immutable value without a subscription. - const store = useAppStateStore(); - const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl); - const viewSelectionMode = useAppState(s_1 => s_1.viewSelectionMode); - const viewingAgentTaskId = useAppState(s_2 => s_2.viewingAgentTaskId); - const expandedView = useAppState(s_3 => s_3.expandedView); - const showSpinnerTree = expandedView === 'teammates'; - const prStatus = usePrStatus(isLoading, isPrStatusEnabled()); - const hasTmuxSession = useAppState(s_4 => (process.env.USER_TYPE) === 'ant' && s_4.tungstenActiveSession !== undefined); - const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL); - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_5 => s_5.voiceState) : 'idle' as const; - const voiceWarmingUp = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_6 => s_6.voiceWarmingUp) : false; - const hasSelection = useHasSelection(); - const selGetState = useSelection().getState; - const hasNextTick = nextTickAt !== null; - const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false; - const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]); - const tasksV2 = useTasksV2(); - const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0; - const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase(); - const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'); - const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k'); - const voiceKeyShortcut = feature('VOICE_MODE') ? + const store = useAppStateStore() + const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl) + const viewSelectionMode = useAppState(s => s.viewSelectionMode) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const expandedView = useAppState(s => s.expandedView) + const showSpinnerTree = expandedView === 'teammates' + const prStatus = usePrStatus(isLoading, isPrStatusEnabled()) + const hasTmuxSession = useAppState( + s => + process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined, + ) + + const nextTickAt = useSyncExternalStore( + proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, + proactiveModule?.getNextTickAt ?? NULL, + NULL, + ) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : ''; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) + const voiceWarmingUp = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceWarmingUp) + : false + const hasSelection = useHasSelection() + const selGetState = useSelection().getState + const hasNextTick = nextTickAt !== null + const isCoordinator = feature('COORDINATOR_MODE') + ? coordinatorModule?.isCoordinatorMode() === true + : false + const runningTaskCount = useMemo( + () => + count( + Object.values(tasks), + t => + isBackgroundTask(t) && + !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + ), + [tasks], + ) + const tasksV2 = useTasksV2() + const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0 + const escShortcut = useShortcutDisplay( + 'chat:cancel', + 'Chat', + 'esc', + ).toLowerCase() + const todosShortcut = useShortcutDisplay( + 'app:toggleTodos', + 'Global', + 'ctrl+t', + ) + const killAgentsShortcut = useShortcutDisplay( + 'chat:killAgents', + 'Chat', + 'ctrl+x ctrl+k', + ) + const voiceKeyShortcut = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') + : '' // Captured at mount so the hint doesn't flicker mid-session if another // CC instance increments the counter. Incremented once via useEffect the // first time voice is enabled in this session — approximates "hint was // shown" without tracking the exact render-time condition (which depends // on parts/hintParts computed after the early-return hooks boundary). - const [voiceHintUnderCap] = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS) : [false]; + const [voiceHintUnderCap] = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useState( + () => + (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < + MAX_VOICE_HINT_SHOWS, + ) + : [false] // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null; + const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null useEffect(() => { if (feature('VOICE_MODE')) { - if (!voiceEnabled || !voiceHintUnderCap) return; - if (voiceHintIncrementedRef?.current) return; - if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true; - const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1; + if (!voiceEnabled || !voiceHintUnderCap) return + if (voiceHintIncrementedRef?.current) return + if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true + const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1 saveGlobalConfig(prev => { - if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev; - return { - ...prev, - voiceFooterHintSeenCount: newCount - }; - }); + if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev + return { ...prev, voiceFooterHintSeenCount: newCount } + }) } - }, [voiceEnabled, voiceHintUnderCap]); - const isKillAgentsConfirmShowing = useAppState(s_7 => s_7.notifications.current?.key === 'kill-agents-confirm'); + }, [voiceEnabled, voiceHintUnderCap]) + const isKillAgentsConfirmShowing = useAppState( + s => s.notifications.current?.key === 'kill-agents-confirm', + ) // Derive team info from teamContext (no filesystem I/O needed) // Match the same logic as TeamStatus to avoid trailing separator // In-process mode uses Shift+Down/Up navigation, not footer teams menu - const hasTeams = isAgentSwarmsEnabled() && !isInProcessEnabled() && teamContext !== undefined && count(Object.values(teamContext.teammates), t_0 => t_0.name !== 'team-lead') > 0; + const hasTeams = + isAgentSwarmsEnabled() && + !isInProcessEnabled() && + teamContext !== undefined && + count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0 + if (mode === 'bash') { - return ! for bash mode; + return ! for bash mode } - const currentMode = toolPermissionContext?.mode; - const hasActiveMode = !isDefaultMode(currentMode); - const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; - const isViewingTeammate = viewSelectionMode === 'viewing-agent' && viewedTask?.type === 'in_process_teammate'; - const isViewingCompletedTeammate = isViewingTeammate && viewedTask != null && viewedTask.status !== 'running'; - const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate; + + const currentMode = toolPermissionContext?.mode + const hasActiveMode = !isDefaultMode(currentMode) + const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined + const isViewingTeammate = + viewSelectionMode === 'viewing-agent' && + viewedTask?.type === 'in_process_teammate' + const isViewingCompletedTeammate = + isViewingTeammate && viewedTask != null && viewedTask.status !== 'running' + const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate // Count primary items (permission mode or coordinator mode, background tasks, and teams) - const primaryItemCount = (isCoordinator || hasActiveMode ? 1 : 0) + (hasBackgroundTasks ? 1 : 0) + (hasTeams ? 1 : 0); + const primaryItemCount = + (isCoordinator || hasActiveMode ? 1 : 0) + + (hasBackgroundTasks ? 1 : 0) + + (hasTeams ? 1 : 0) // PR indicator is short (~10 chars) — unlike the old diff indicator the // >=100 threshold was tuned for. Now that auto mode is effectively the // baseline, primaryItemCount is ≥1 for most sessions; keep the threshold // low enough to show PR status on standard 80-col terminals. - const shouldShowPrStatus = isPrStatusEnabled() && prStatus.number !== null && prStatus.reviewState !== null && prStatus.url !== null && primaryItemCount < 2 && (primaryItemCount === 0 || columns >= 80); + const shouldShowPrStatus = + isPrStatusEnabled() && + prStatus.number !== null && + prStatus.reviewState !== null && + prStatus.url !== null && + primaryItemCount < 2 && + (primaryItemCount === 0 || columns >= 80) // Hide the shift+tab hint when there are 2 primary items - const shouldShowModeHint = primaryItemCount < 2; + const shouldShowModeHint = primaryItemCount < 2 // Check if we have in-process teammates (showing pills) // In spinner-tree mode, pills are disabled - teammates appear in the spinner tree instead - const hasInProcessTeammates = !showSpinnerTree && hasBackgroundTasks && Object.values(tasks).some(t_1 => t_1.type === 'in_process_teammate'); - const hasTeammatePills = hasInProcessTeammates || !showSpinnerTree && isViewingTeammate; + const hasInProcessTeammates = + !showSpinnerTree && + hasBackgroundTasks && + Object.values(tasks).some(t => t.type === 'in_process_teammate') + const hasTeammatePills = + hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate) // In remote mode (`claude assistant`, --teleport) the agent runs elsewhere; // the local permission mode shown here doesn't reflect the agent's state. // Rendered before the tasks pill so a long pill label (e.g. ultraplan URL) // doesn't push the mode indicator off-screen. - const modePart = currentMode && hasActiveMode && !getIsRemoteMode() ? + const modePart = + currentMode && hasActiveMode && !getIsRemoteMode() ? ( + {permissionModeSymbol(currentMode)}{' '} {permissionModeTitle(currentMode).toLowerCase()} on - {shouldShowModeHint && + {shouldShowModeHint && ( + {' '} - - } - : null; + + + )} + + ) : null // Build parts array - exclude BackgroundTaskStatus when we have teammate pills // (teammate pills get their own row) const parts = [ - // Remote session indicator - ...(remoteSessionUrl ? [ + // Remote session indicator + ...(remoteSessionUrl + ? [ + {figures.circleDouble} remote - ] : []), - // BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so - // its click-target Box isn't nested inside the - // wrapper (reconciler throws on Box-in-Text). - // Tmux pill (ant-only) — appears right after tasks in nav order - ...(process.env.USER_TYPE === 'ant' && hasTmuxSession && typeof TungstenPill === 'function' ? [] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [] : []), ...(shouldShowPrStatus ? [] : [])]; + , + ] + : []), + // BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so + // its click-target Box isn't nested inside the + // wrapper (reconciler throws on Box-in-Text). + // Tmux pill (ant-only) — appears right after tasks in nav order + ...(process.env.USER_TYPE === 'ant' && hasTmuxSession + ? [] + : []), + ...(isAgentSwarmsEnabled() && hasTeams + ? [ + , + ] + : []), + ...(shouldShowPrStatus + ? [ + , + ] + : []), + ] // Check if any in-process teammates exist (for hint text cycling) - const hasAnyInProcessTeammates = Object.values(tasks).some(t_2 => t_2.type === 'in_process_teammate' && t_2.status === 'running'); - const hasRunningAgentTasks = Object.values(tasks).some(t_3 => t_3.type === 'local_agent' && t_3.status === 'running'); + const hasAnyInProcessTeammates = Object.values(tasks).some( + t => t.type === 'in_process_teammate' && t.status === 'running', + ) + const hasRunningAgentTasks = Object.values(tasks).some( + t => t.type === 'local_agent' && t.status === 'running', + ) // Get hint parts separately for potential second-line rendering - const hintParts = showHint ? getSpinnerHintParts(isLoading, escShortcut, todosShortcut, killAgentsShortcut, hasTaskItems, expandedView, hasAnyInProcessTeammates, hasRunningAgentTasks, isKillAgentsConfirmShowing) : []; + const hintParts = showHint + ? getSpinnerHintParts( + isLoading, + escShortcut, + todosShortcut, + killAgentsShortcut, + hasTaskItems, + expandedView, + hasAnyInProcessTeammates, + hasRunningAgentTasks, + isKillAgentsConfirmShowing, + ) + : [] + if (isViewingCompletedTeammate) { - parts.push( - - ); + parts.push( + + + , + ) } else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) { - parts.push(); + parts.push() } else if (!hasTeammatePills && showHint) { - parts.push(...hintParts); + parts.push(...hintParts) } // When we have teammate pills, always render them on their own line above other parts if (hasTeammatePills) { // Don't append spinner hints when viewing a completed teammate — // the "esc to return to team lead" hint already replaces "esc to interrupt" - const otherParts = [...(modePart ? [modePart] : []), ...parts, ...(isViewingCompletedTeammate ? [] : hintParts)]; - return + const otherParts = [ + ...(modePart ? [modePart] : []), + ...parts, + ...(isViewingCompletedTeammate ? [] : hintParts), + ] + return ( + - + - {otherParts.length > 0 && + {otherParts.length > 0 && ( + {otherParts} - } - ; + + )} + + ) } // Add "↓ to manage tasks" hint when panel has visible rows - const hasCoordinatorTasks = (process.env.USER_TYPE) === 'ant' && getVisibleAgentTasks(tasks).length > 0; + const hasCoordinatorTasks = + process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0 // Tasks pill renders as a Box sibling (not a parts entry) so its // click-target Box isn't nested inside — the // reconciler throws on Box-in-Text. Computed here so the empty-checks // below still treat "pill present" as non-empty. - const tasksPart = hasBackgroundTasks && !hasTeammatePills && !shouldHideTasksFooter(tasks, showSpinnerTree) ? : null; + const tasksPart = + hasBackgroundTasks && + !hasTeammatePills && + !shouldHideTasksFooter(tasks, showSpinnerTree) ? ( + + ) : null + if (parts.length === 0 && !tasksPart && !modePart && showHint) { - parts.push( + parts.push( + ? for shortcuts - ); + , + ) } // Only replace the idle voice hint when there's something to say — otherwise // fall through instead of showing an empty Byline. "esc to clear" was removed // (looked like "esc to interrupt" when idle; esc-clears-selection is standard // UX) leaving only ctrl+c (copyOnSelect off) and the xterm.js native-select hint. - const copyOnSelect = getGlobalConfig().copyOnSelect ?? true; - const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs()); + const copyOnSelect = getGlobalConfig().copyOnSelect ?? true + const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs()) // Warmup hint takes priority — when the user is actively holding // the activation key, show feedback regardless of other hints. if (feature('VOICE_MODE') && voiceEnabled && voiceWarmingUp) { - parts.push(); + parts.push() } else if (isFullscreenEnvEnabled() && selectionHintHasContent) { // xterm.js (VS Code/Cursor/Windsurf) force-selection modifier is // platform-specific and gated on macOS (SelectionService.shouldForceSelection): @@ -434,23 +550,52 @@ function ModeIndicator({ // option+click hint they just tried. // Non-reactive getState() read is safe: lastPressHadAlt is immutable // while hasSelection is true (set pre-drag, cleared with selection). - const isMac = getPlatform() === 'macos'; - const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false); - parts.push( + const isMac = getPlatform() === 'macos' + const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false) + parts.push( + - {!copyOnSelect && } - {isXtermJs() && (altClickFailed ? set macOptionClickForcesSelection in VS Code settings : )} + {!copyOnSelect && ( + + )} + {isXtermJs() && + (altClickFailed ? ( + set macOptionClickForcesSelection in VS Code settings + ) : ( + + ))} - ); - } else if (feature('VOICE_MODE') && parts.length > 0 && showHint && voiceEnabled && voiceState === 'idle' && hintParts.length === 0 && voiceHintUnderCap) { - parts.push( + , + ) + } else if ( + feature('VOICE_MODE') && + parts.length > 0 && + showHint && + voiceEnabled && + voiceState === 'idle' && + hintParts.length === 0 && + voiceHintUnderCap + ) { + parts.push( + hold {voiceKeyShortcut} to speak - ); + , + ) } + if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) { - parts.push( - {tasksSelected ? : } - ); + parts.push( + + {tasksSelected ? ( + + ) : ( + + )} + , + ) } // In fullscreen the bottom section is flexShrink:0 — every row here @@ -462,55 +607,98 @@ function ModeIndicator({ // from 0→1 row. Always render 1 row in fullscreen; return a space when // empty so Yoga reserves the row without painting anything visible. if (parts.length === 0 && !tasksPart && !modePart) { - return isFullscreenEnvEnabled() ? : null; + return isFullscreenEnvEnabled() ? : null } // flexShrink=0 keeps mode + pill at natural width; the remaining parts // truncate at the tail as one string inside the Text wrapper. - return - {modePart && + return ( + + {modePart && ( + {modePart} {(tasksPart || parts.length > 0) && · } - } - {tasksPart && + + )} + {tasksPart && ( + {tasksPart} {parts.length > 0 && · } - } - {parts.length > 0 && + + )} + {parts.length > 0 && ( + {parts} - } - ; + + )} + + ) } -function getSpinnerHintParts(isLoading: boolean, escShortcut: string, todosShortcut: string, killAgentsShortcut: string, hasTaskItems: boolean, expandedView: 'none' | 'tasks' | 'teammates', hasTeammates: boolean, hasRunningAgentTasks: boolean, isKillAgentsConfirmShowing: boolean): React.ReactElement[] { - let toggleAction: string; + +function getSpinnerHintParts( + isLoading: boolean, + escShortcut: string, + todosShortcut: string, + killAgentsShortcut: string, + hasTaskItems: boolean, + expandedView: 'none' | 'tasks' | 'teammates', + hasTeammates: boolean, + hasRunningAgentTasks: boolean, + isKillAgentsConfirmShowing: boolean, +): React.ReactElement[] { + let toggleAction: string if (hasTeammates) { // Cycling: none → tasks → teammates → none switch (expandedView) { case 'none': - toggleAction = 'show tasks'; - break; + toggleAction = 'show tasks' + break case 'tasks': - toggleAction = 'show teammates'; - break; + toggleAction = 'show teammates' + break case 'teammates': - toggleAction = 'hide'; - break; + toggleAction = 'hide' + break } } else { - toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks'; + toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks' } // Show the toggle hint only when there are task items to display or // teammates to cycle to - const showToggleHint = hasTaskItems || hasTeammates; - return [...(isLoading ? [ + const showToggleHint = hasTaskItems || hasTeammates + + return [ + ...(isLoading + ? [ + - ] : []), ...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing ? [ - - ] : []), ...(showToggleHint ? [ - - ] : [])]; + , + ] + : []), + ...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing + ? [ + + + , + ] + : []), + ...(showToggleHint + ? [ + + + , + ] + : []), + ] } + function isPrStatusEnabled(): boolean { - return getGlobalConfig().prStatusFooterEnabled ?? true; + return getGlobalConfig().prStatusFooterEnabled ?? true } diff --git a/src/components/PromptInput/PromptInputFooterSuggestions.tsx b/src/components/PromptInput/PromptInputFooterSuggestions.tsx index 5f1fa74a9..0728e5255 100644 --- a/src/components/PromptInput/PromptInputFooterSuggestions.tsx +++ b/src/components/PromptInput/PromptInputFooterSuggestions.tsx @@ -1,292 +1,248 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { memo, type ReactNode } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js'; -import type { Theme } from '../../utils/theme.js'; +import * as React from 'react' +import { memo, type ReactNode } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js' +import type { Theme } from '../../utils/theme.js' + export type SuggestionItem = { - id: string; - displayText: string; - tag?: string; - description?: string; - metadata?: unknown; - color?: keyof Theme; -}; -export type SuggestionType = 'command' | 'file' | 'directory' | 'agent' | 'shell' | 'custom-title' | 'slack-channel' | 'none'; -export const OVERLAY_MAX_ITEMS = 5; + id: string + displayText: string + tag?: string + description?: string + metadata?: unknown + color?: keyof Theme +} + +export type SuggestionType = + | 'command' + | 'file' + | 'directory' + | 'agent' + | 'shell' + | 'custom-title' + | 'slack-channel' + | 'none' + +export const OVERLAY_MAX_ITEMS = 5 /** * Get the icon for a suggestion based on its type * Icons: + for files, ◇ for MCP resources, * for agents */ function getIcon(itemId: string): string { - if (itemId.startsWith('file-')) return '+'; - if (itemId.startsWith('mcp-resource-')) return '◇'; - if (itemId.startsWith('agent-')) return '*'; - return '+'; + if (itemId.startsWith('file-')) return '+' + if (itemId.startsWith('mcp-resource-')) return '◇' + if (itemId.startsWith('agent-')) return '*' + return '+' } /** * Check if an item is a unified suggestion type (file, mcp-resource, or agent) */ function isUnifiedSuggestion(itemId: string): boolean { - return itemId.startsWith('file-') || itemId.startsWith('mcp-resource-') || itemId.startsWith('agent-'); + return ( + itemId.startsWith('file-') || + itemId.startsWith('mcp-resource-') || + itemId.startsWith('agent-') + ) } -const SuggestionItemRow = memo(function SuggestionItemRow(t0: { item: SuggestionItem; maxColumnWidth: number; isSelected: boolean }) { - const $ = _c(36); - const { - item, - maxColumnWidth, - isSelected - } = t0; - const columns = useTerminalSize().columns; - const isUnified = isUnifiedSuggestion(item.id); + +const SuggestionItemRow = memo(function SuggestionItemRow({ + item, + maxColumnWidth, + isSelected, +}: { + item: SuggestionItem + maxColumnWidth?: number + isSelected: boolean +}): ReactNode { + const columns = useTerminalSize().columns + const isUnified = isUnifiedSuggestion(item.id) + + // For unified suggestions (file, mcp-resource, agent), use single-line layout with icon if (isUnified) { - let t1; - if ($[0] !== item.id) { - t1 = getIcon(item.id); - $[0] = item.id; - $[1] = t1; - } else { - t1 = $[1]; - } - const icon = t1; - const textColor = isSelected ? "suggestion" : undefined; - const dimColor = !isSelected; - const isFile = item.id.startsWith("file-"); - const isMcpResource = item.id.startsWith("mcp-resource-"); - const separatorWidth = item.description ? 3 : 0; - let displayText; + const icon = getIcon(item.id) + const textColor: keyof Theme | undefined = isSelected + ? 'suggestion' + : undefined + const dimColor = !isSelected + + const isFile = item.id.startsWith('file-') + const isMcpResource = item.id.startsWith('mcp-resource-') + + // Calculate layout widths + // Layout: "X " (2) + displayText + " – " (3) + description + padding (4) + const iconWidth = 2 // icon + space (fixed) + const paddingWidth = 4 + const separatorWidth = item.description ? 3 : 0 // ' – ' separator + + // For files, truncate middle of path to show both directory context and filename + // For MCP resources, limit displayText to 30 chars (truncate from end) + // For agents, no truncation + let displayText: string if (isFile) { - let t2; - if ($[2] !== item.description) { - t2 = item.description ? Math.min(20, stringWidth(item.description)) : 0; - $[2] = item.description; - $[3] = t2; - } else { - t2 = $[3]; - } - const descReserve = t2; - const maxPathLength = columns - 2 - 4 - separatorWidth - descReserve; - let t3; - if ($[4] !== item.displayText || $[5] !== maxPathLength) { - t3 = truncatePathMiddle(item.displayText, maxPathLength); - $[4] = item.displayText; - $[5] = maxPathLength; - $[6] = t3; - } else { - t3 = $[6]; - } - displayText = t3; + // Reserve space for description if present, otherwise use all available space + const descReserve = item.description + ? Math.min(20, stringWidth(item.description)) + : 0 + const maxPathLength = + columns - iconWidth - paddingWidth - separatorWidth - descReserve + displayText = truncatePathMiddle(item.displayText, maxPathLength) + } else if (isMcpResource) { + const maxDisplayTextLength = 30 + displayText = truncateToWidth(item.displayText, maxDisplayTextLength) } else { - if (isMcpResource) { - let t2; - if ($[7] !== item.displayText) { - t2 = truncateToWidth(item.displayText, 30); - $[7] = item.displayText; - $[8] = t2; - } else { - t2 = $[8]; - } - displayText = t2; - } else { - displayText = item.displayText; - } + displayText = item.displayText } - const availableWidth = columns - 2 - stringWidth(displayText) - separatorWidth - 4; - let lineContent; + + const availableWidth = + columns - + iconWidth - + stringWidth(displayText) - + separatorWidth - + paddingWidth + + // Build the full line as a single string to prevent wrapping + let lineContent: string if (item.description) { - const maxDescLength = Math.max(0, availableWidth); - let t2; - if ($[9] !== item.description || $[10] !== maxDescLength) { - t2 = truncateToWidth(item.description.replace(/\s+/g, " "), maxDescLength); - $[9] = item.description; - $[10] = maxDescLength; - $[11] = t2; - } else { - t2 = $[11]; - } - const truncatedDesc = t2; - lineContent = `${icon} ${displayText} – ${truncatedDesc}`; - } else { - lineContent = `${icon} ${displayText}`; - } - let t2; - if ($[12] !== dimColor || $[13] !== lineContent || $[14] !== textColor) { - t2 = {lineContent}; - $[12] = dimColor; - $[13] = lineContent; - $[14] = textColor; - $[15] = t2; - } else { - t2 = $[15]; - } - return t2; - } - const maxNameWidth = Math.floor(columns * 0.4); - const displayTextWidth = Math.min(maxColumnWidth ?? stringWidth(item.displayText) + 5, maxNameWidth); - const textColor_0 = item.color || (isSelected ? "suggestion" : undefined); - const shouldDim = !isSelected; - let displayText_0 = item.displayText; - if (stringWidth(displayText_0) > displayTextWidth - 2) { - const t1 = displayTextWidth - 2; - let t2; - if ($[16] !== displayText_0 || $[17] !== t1) { - t2 = truncateToWidth(displayText_0, t1); - $[16] = displayText_0; - $[17] = t1; - $[18] = t2; + const maxDescLength = Math.max(0, availableWidth) + const truncatedDesc = truncateToWidth( + item.description.replace(/\s+/g, ' '), + maxDescLength, + ) + lineContent = `${icon} ${displayText} – ${truncatedDesc}` } else { - t2 = $[18]; + lineContent = `${icon} ${displayText}` } - displayText_0 = t2; - } - const paddedDisplayText = displayText_0 + " ".repeat(Math.max(0, displayTextWidth - stringWidth(displayText_0))); - const tagText = item.tag ? `[${item.tag}] ` : ""; - const tagWidth = stringWidth(tagText); - const descriptionWidth = Math.max(0, columns - displayTextWidth - tagWidth - 4); - let t1; - if ($[19] !== descriptionWidth || $[20] !== item.description) { - t1 = item.description ? truncateToWidth(item.description.replace(/\s+/g, " "), descriptionWidth) : ""; - $[19] = descriptionWidth; - $[20] = item.description; - $[21] = t1; - } else { - t1 = $[21]; - } - const truncatedDescription = t1; - let t2; - if ($[22] !== paddedDisplayText || $[23] !== shouldDim || $[24] !== textColor_0) { - t2 = {paddedDisplayText}; - $[22] = paddedDisplayText; - $[23] = shouldDim; - $[24] = textColor_0; - $[25] = t2; - } else { - t2 = $[25]; - } - let t3; - if ($[26] !== tagText) { - t3 = tagText ? {tagText} : null; - $[26] = tagText; - $[27] = t3; - } else { - t3 = $[27]; - } - const t4 = isSelected ? "suggestion" : undefined; - const t5 = !isSelected; - let t6; - if ($[28] !== t4 || $[29] !== t5 || $[30] !== truncatedDescription) { - t6 = {truncatedDescription}; - $[28] = t4; - $[29] = t5; - $[30] = truncatedDescription; - $[31] = t6; - } else { - t6 = $[31]; + + return ( + + {lineContent} + + ) } - let t7; - if ($[32] !== t2 || $[33] !== t3 || $[34] !== t6) { - t7 = {t2}{t3}{t6}; - $[32] = t2; - $[33] = t3; - $[34] = t6; - $[35] = t7; - } else { - t7 = $[35]; + + // For non-unified suggestions (commands, shell, etc.), use improved layout from main + // Cap the command name column at 40% of terminal width to ensure description has space + const maxNameWidth = Math.floor(columns * 0.4) + const displayTextWidth = Math.min( + maxColumnWidth ?? stringWidth(item.displayText) + 5, + maxNameWidth, + ) + + const textColor = item.color || (isSelected ? 'suggestion' : undefined) + const shouldDim = !isSelected + + // Truncate and pad the display text to fixed width + let displayText = item.displayText + if (stringWidth(displayText) > displayTextWidth - 2) { + displayText = truncateToWidth(displayText, displayTextWidth - 2) } - return t7; -}); + const paddedDisplayText = + displayText + + ' '.repeat(Math.max(0, displayTextWidth - stringWidth(displayText))) + + const tagText = item.tag ? `[${item.tag}] ` : '' + const tagWidth = stringWidth(tagText) + const descriptionWidth = Math.max( + 0, + columns - displayTextWidth - tagWidth - 4, + ) + // Skill descriptions can contain newlines (e.g. /claude-api's "TRIGGER + // when:" block). A multi-line row grows the overlay past minHeight; when + // the filter narrows past that skill, the overlay shrinks and leaves + // ghost rows. Flatten to one line before truncating. + const truncatedDescription = item.description + ? truncateToWidth(item.description.replace(/\s+/g, ' '), descriptionWidth) + : '' + + return ( + + + {paddedDisplayText} + + {tagText ? {tagText} : null} + + {truncatedDescription} + + + ) +}) + type Props = { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - maxColumnWidth?: number; + suggestions: SuggestionItem[] + selectedSuggestion: number + maxColumnWidth?: number /** * When true, the suggestions are rendered inside a position=absolute * overlay. We omit minHeight and flex-end so the y-clamp in the * renderer doesn't push fewer items down into the prompt area. */ - overlay?: boolean; -}; -export function PromptInputFooterSuggestions(t0) { - const $ = _c(22); - const { - suggestions, - selectedSuggestion, - maxColumnWidth: maxColumnWidthProp, - overlay - } = t0; - const { - rows - } = useTerminalSize(); - const maxVisibleItems = overlay ? OVERLAY_MAX_ITEMS : Math.min(6, Math.max(1, rows - 3)); + overlay?: boolean +} + +export function PromptInputFooterSuggestions({ + suggestions, + selectedSuggestion, + maxColumnWidth: maxColumnWidthProp, + overlay, +}: Props): ReactNode { + const { rows } = useTerminalSize() + // Maximum number of suggestions to show at once (leaving space for prompt). + // Overlay mode (fullscreen) uses a fixed 5 — the floating box sits over + // the ScrollBox, so terminal height isn't the constraint. + const maxVisibleItems = overlay + ? OVERLAY_MAX_ITEMS + : Math.min(6, Math.max(1, rows - 3)) + + // No suggestions to display if (suggestions.length === 0) { - return null; + return null } - let t1; - if ($[0] !== maxColumnWidthProp || $[1] !== suggestions) { - t1 = maxColumnWidthProp ?? Math.max(...suggestions.map(_temp)) + 5; - $[0] = maxColumnWidthProp; - $[1] = suggestions; - $[2] = t1; - } else { - t1 = $[2]; - } - const maxColumnWidth = t1; - const startIndex = Math.max(0, Math.min(selectedSuggestion - Math.floor(maxVisibleItems / 2), suggestions.length - maxVisibleItems)); - const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length); - let T0; - let t2; - let t3; - let t4; - if ($[3] !== endIndex || $[4] !== maxColumnWidth || $[5] !== overlay || $[6] !== selectedSuggestion || $[7] !== startIndex || $[8] !== suggestions) { - const visibleItems = suggestions.slice(startIndex, endIndex); - T0 = Box; - t2 = "column"; - t3 = overlay ? undefined : "flex-end"; - let t5; - if ($[13] !== maxColumnWidth || $[14] !== selectedSuggestion || $[15] !== suggestions) { - t5 = item_0 => ; - $[13] = maxColumnWidth; - $[14] = selectedSuggestion; - $[15] = suggestions; - $[16] = t5; - } else { - t5 = $[16]; - } - t4 = visibleItems.map(t5); - $[3] = endIndex; - $[4] = maxColumnWidth; - $[5] = overlay; - $[6] = selectedSuggestion; - $[7] = startIndex; - $[8] = suggestions; - $[9] = T0; - $[10] = t2; - $[11] = t3; - $[12] = t4; - } else { - T0 = $[9]; - t2 = $[10]; - t3 = $[11]; - t4 = $[12]; - } - let t5; - if ($[17] !== T0 || $[18] !== t2 || $[19] !== t3 || $[20] !== t4) { - t5 = {t4}; - $[17] = T0; - $[18] = t2; - $[19] = t3; - $[20] = t4; - $[21] = t5; - } else { - t5 = $[21]; - } - return t5; -} -function _temp(item) { - return stringWidth(item.displayText); + + // Use prop if provided (stable width from all commands), otherwise calculate from visible + const maxColumnWidth = + maxColumnWidthProp ?? + Math.max(...suggestions.map(item => stringWidth(item.displayText))) + 5 + + // Calculate visible items range based on selected index + const startIndex = Math.max( + 0, + Math.min( + selectedSuggestion - Math.floor(maxVisibleItems / 2), + suggestions.length - maxVisibleItems, + ), + ) + const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length) + const visibleItems = suggestions.slice(startIndex, endIndex) + + // In non-overlay (inline) mode, justifyContent keeps suggestions + // anchored to the bottom (near the prompt). In overlay mode we omit + // both minHeight and flex-end: the parent is position=absolute with + // bottom='100%', so its y is clamped to 0 by the renderer when it + // would go negative. Adding minHeight + flex-end would create empty + // padding rows that shift the visible items down into the prompt area + // when the list has fewer items than maxVisibleItems. + return ( + + {visibleItems.map(item => ( + + ))} + + ) } -export default memo(PromptInputFooterSuggestions); + +export default memo(PromptInputFooterSuggestions) diff --git a/src/components/PromptInput/PromptInputHelpMenu.tsx b/src/components/PromptInput/PromptInputHelpMenu.tsx index f8e275765..5f15327d2 100644 --- a/src/components/PromptInput/PromptInputHelpMenu.tsx +++ b/src/components/PromptInput/PromptInputHelpMenu.tsx @@ -1,357 +1,149 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; -import { getPlatform } from 'src/utils/platform.js'; -import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js'; -import { getNewlineInstructions } from './utils.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { Box, Text } from 'src/ink.js' +import { getPlatform } from 'src/utils/platform.js' +import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js' +import { getNewlineInstructions } from './utils.js' /** Format a shortcut for display in the help menu (e.g., "ctrl+o" → "ctrl + o") */ function formatShortcut(shortcut: string): string { - return shortcut.replace(/\+/g, ' + '); + return shortcut.replace(/\+/g, ' + ') } + type Props = { - dimColor?: boolean; - fixedWidth?: boolean; - gap?: number; - paddingX?: number; -}; -export function PromptInputHelpMenu(props) { - const $ = _c(99); - const { - dimColor, - fixedWidth, - gap, - paddingX - } = props; - const t0 = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let t1; - if ($[0] !== t0) { - t1 = formatShortcut(t0); - $[0] = t0; - $[1] = t1; - } else { - t1 = $[1]; - } - const transcriptShortcut = t1; - const t2 = useShortcutDisplay("app:toggleTodos", "Global", "ctrl+t"); - let t3; - if ($[2] !== t2) { - t3 = formatShortcut(t2); - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const todosShortcut = t3; - const t4 = useShortcutDisplay("chat:undo", "Chat", "ctrl+_"); - let t5; - if ($[4] !== t4) { - t5 = formatShortcut(t4); - $[4] = t4; - $[5] = t5; - } else { - t5 = $[5]; - } - const undoShortcut = t5; - const t6 = useShortcutDisplay("chat:stash", "Chat", "ctrl+s"); - let t7; - if ($[6] !== t6) { - t7 = formatShortcut(t6); - $[6] = t6; - $[7] = t7; - } else { - t7 = $[7]; - } - const stashShortcut = t7; - const t8 = useShortcutDisplay("chat:cycleMode", "Chat", "shift+tab"); - let t9; - if ($[8] !== t8) { - t9 = formatShortcut(t8); - $[8] = t8; - $[9] = t9; - } else { - t9 = $[9]; - } - const cycleModeShortcut = t9; - const t10 = useShortcutDisplay("chat:modelPicker", "Chat", "alt+p"); - let t11; - if ($[10] !== t10) { - t11 = formatShortcut(t10); - $[10] = t10; - $[11] = t11; - } else { - t11 = $[11]; - } - const modelPickerShortcut = t11; - const t12 = useShortcutDisplay("chat:fastMode", "Chat", "alt+o"); - let t13; - if ($[12] !== t12) { - t13 = formatShortcut(t12); - $[12] = t12; - $[13] = t13; - } else { - t13 = $[13]; - } - const fastModeShortcut = t13; - const t14 = useShortcutDisplay("chat:externalEditor", "Chat", "ctrl+g"); - let t15; - if ($[14] !== t14) { - t15 = formatShortcut(t14); - $[14] = t14; - $[15] = t15; - } else { - t15 = $[15]; - } - const externalEditorShortcut = t15; - const t16 = useShortcutDisplay("app:toggleTerminal", "Global", "meta+j"); - let t17; - if ($[16] !== t16) { - t17 = formatShortcut(t16); - $[16] = t16; - $[17] = t17; - } else { - t17 = $[17]; - } - const terminalShortcut = t17; - const t18 = useShortcutDisplay("chat:imagePaste", "Chat", "ctrl+v"); - let t19; - if ($[18] !== t18) { - t19 = formatShortcut(t18); - $[18] = t18; - $[19] = t19; - } else { - t19 = $[19]; - } - const imagePasteShortcut = t19; - let t20; - if ($[20] !== dimColor || $[21] !== terminalShortcut) { - t20 = feature("TERMINAL_PANEL") ? getFeatureValue_CACHED_MAY_BE_STALE("tengu_terminal_panel", false) ? {terminalShortcut} for terminal : null : null; - $[20] = dimColor; - $[21] = terminalShortcut; - $[22] = t20; - } else { - t20 = $[22]; - } - const terminalShortcutElement = t20; - const t21 = fixedWidth ? 24 : undefined; - let t22; - if ($[23] !== dimColor) { - t22 = ! for bash mode; - $[23] = dimColor; - $[24] = t22; - } else { - t22 = $[24]; - } - let t23; - if ($[25] !== dimColor) { - t23 = / for commands; - $[25] = dimColor; - $[26] = t23; - } else { - t23 = $[26]; - } - let t24; - if ($[27] !== dimColor) { - t24 = @ for file paths; - $[27] = dimColor; - $[28] = t24; - } else { - t24 = $[28]; - } - let t25; - if ($[29] !== dimColor) { - t25 = {"& for background"}; - $[29] = dimColor; - $[30] = t25; - } else { - t25 = $[30]; - } - let t26; - if ($[31] !== dimColor) { - t26 = /btw for side question; - $[31] = dimColor; - $[32] = t26; - } else { - t26 = $[32]; - } - let t27; - if ($[33] !== t21 || $[34] !== t22 || $[35] !== t23 || $[36] !== t24 || $[37] !== t25 || $[38] !== t26) { - t27 = {t22}{t23}{t24}{t25}{t26}; - $[33] = t21; - $[34] = t22; - $[35] = t23; - $[36] = t24; - $[37] = t25; - $[38] = t26; - $[39] = t27; - } else { - t27 = $[39]; - } - const t28 = fixedWidth ? 35 : undefined; - let t29; - if ($[40] !== dimColor) { - t29 = double tap esc to clear input; - $[40] = dimColor; - $[41] = t29; - } else { - t29 = $[41]; - } - let t30; - if ($[42] !== cycleModeShortcut || $[43] !== dimColor) { - t30 = {cycleModeShortcut}{" "}{false ? "to cycle modes" : "to auto-accept edits"}; - $[42] = cycleModeShortcut; - $[43] = dimColor; - $[44] = t30; - } else { - t30 = $[44]; - } - let t31; - if ($[45] !== dimColor || $[46] !== transcriptShortcut) { - t31 = {transcriptShortcut} for verbose output; - $[45] = dimColor; - $[46] = transcriptShortcut; - $[47] = t31; - } else { - t31 = $[47]; - } - let t32; - if ($[48] !== dimColor || $[49] !== todosShortcut) { - t32 = {todosShortcut} to toggle tasks; - $[48] = dimColor; - $[49] = todosShortcut; - $[50] = t32; - } else { - t32 = $[50]; - } - let t33; - if ($[51] === Symbol.for("react.memo_cache_sentinel")) { - t33 = getNewlineInstructions(); - $[51] = t33; - } else { - t33 = $[51]; - } - let t34; - if ($[52] !== dimColor) { - t34 = {t33}; - $[52] = dimColor; - $[53] = t34; - } else { - t34 = $[53]; - } - let t35; - if ($[54] !== t28 || $[55] !== t29 || $[56] !== t30 || $[57] !== t31 || $[58] !== t32 || $[59] !== t34 || $[60] !== terminalShortcutElement) { - t35 = {t29}{t30}{t31}{t32}{terminalShortcutElement}{t34}; - $[54] = t28; - $[55] = t29; - $[56] = t30; - $[57] = t31; - $[58] = t32; - $[59] = t34; - $[60] = terminalShortcutElement; - $[61] = t35; - } else { - t35 = $[61]; - } - let t36; - if ($[62] !== dimColor || $[63] !== undoShortcut) { - t36 = {undoShortcut} to undo; - $[62] = dimColor; - $[63] = undoShortcut; - $[64] = t36; - } else { - t36 = $[64]; - } - let t37; - if ($[65] !== dimColor) { - t37 = getPlatform() !== "windows" && ctrl + z to suspend; - $[65] = dimColor; - $[66] = t37; - } else { - t37 = $[66]; - } - let t38; - if ($[67] !== dimColor || $[68] !== imagePasteShortcut) { - t38 = {imagePasteShortcut} to paste images; - $[67] = dimColor; - $[68] = imagePasteShortcut; - $[69] = t38; - } else { - t38 = $[69]; - } - let t39; - if ($[70] !== dimColor || $[71] !== modelPickerShortcut) { - t39 = {modelPickerShortcut} to switch model; - $[70] = dimColor; - $[71] = modelPickerShortcut; - $[72] = t39; - } else { - t39 = $[72]; - } - let t40; - if ($[73] !== dimColor || $[74] !== fastModeShortcut) { - t40 = isFastModeEnabled() && isFastModeAvailable() && {fastModeShortcut} to toggle fast mode; - $[73] = dimColor; - $[74] = fastModeShortcut; - $[75] = t40; - } else { - t40 = $[75]; - } - let t41; - if ($[76] !== dimColor || $[77] !== stashShortcut) { - t41 = {stashShortcut} to stash prompt; - $[76] = dimColor; - $[77] = stashShortcut; - $[78] = t41; - } else { - t41 = $[78]; - } - let t42; - if ($[79] !== dimColor || $[80] !== externalEditorShortcut) { - t42 = {externalEditorShortcut} to edit in $EDITOR; - $[79] = dimColor; - $[80] = externalEditorShortcut; - $[81] = t42; - } else { - t42 = $[81]; - } - let t43; - if ($[82] !== dimColor) { - t43 = isKeybindingCustomizationEnabled() && /keybindings to customize; - $[82] = dimColor; - $[83] = t43; - } else { - t43 = $[83]; - } - let t44; - if ($[84] !== t36 || $[85] !== t37 || $[86] !== t38 || $[87] !== t39 || $[88] !== t40 || $[89] !== t41 || $[90] !== t42 || $[91] !== t43) { - t44 = {t36}{t37}{t38}{t39}{t40}{t41}{t42}{t43}; - $[84] = t36; - $[85] = t37; - $[86] = t38; - $[87] = t39; - $[88] = t40; - $[89] = t41; - $[90] = t42; - $[91] = t43; - $[92] = t44; - } else { - t44 = $[92]; - } - let t45; - if ($[93] !== gap || $[94] !== paddingX || $[95] !== t27 || $[96] !== t35 || $[97] !== t44) { - t45 = {t27}{t35}{t44}; - $[93] = gap; - $[94] = paddingX; - $[95] = t27; - $[96] = t35; - $[97] = t44; - $[98] = t45; - } else { - t45 = $[98]; - } - return t45; + dimColor?: boolean + fixedWidth?: boolean + gap?: number + paddingX?: number +} + +export function PromptInputHelpMenu(props: Props): React.ReactNode { + const { dimColor, fixedWidth, gap, paddingX } = props + + // Get configured shortcuts from keybinding system + const transcriptShortcut = formatShortcut( + useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'), + ) + const todosShortcut = formatShortcut( + useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'), + ) + const undoShortcut = formatShortcut( + useShortcutDisplay('chat:undo', 'Chat', 'ctrl+_'), + ) + const stashShortcut = formatShortcut( + useShortcutDisplay('chat:stash', 'Chat', 'ctrl+s'), + ) + const cycleModeShortcut = formatShortcut( + useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'), + ) + const modelPickerShortcut = formatShortcut( + useShortcutDisplay('chat:modelPicker', 'Chat', 'alt+p'), + ) + const fastModeShortcut = formatShortcut( + useShortcutDisplay('chat:fastMode', 'Chat', 'alt+o'), + ) + const externalEditorShortcut = formatShortcut( + useShortcutDisplay('chat:externalEditor', 'Chat', 'ctrl+g'), + ) + const terminalShortcut = formatShortcut( + useShortcutDisplay('app:toggleTerminal', 'Global', 'meta+j'), + ) + const imagePasteShortcut = formatShortcut( + useShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'), + ) + + // Compute terminal shortcut element outside JSX to satisfy feature() constraint + const terminalShortcutElement = feature('TERMINAL_PANEL') ? ( + getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false) ? ( + + {terminalShortcut} for terminal + + ) : null + ) : null + + return ( + + + + ! for bash mode + + + / for commands + + + @ for file paths + + + & for background + + + /btw for side question + + + + + double tap esc to clear input + + + + {cycleModeShortcut}{' '} + {process.env.USER_TYPE === 'ant' + ? 'to cycle modes' + : 'to auto-accept edits'} + + + + + {transcriptShortcut} for verbose output + + + + {todosShortcut} to toggle tasks + + {terminalShortcutElement} + + {getNewlineInstructions()} + + + + + {undoShortcut} to undo + + {getPlatform() !== 'windows' && ( + + ctrl + z to suspend + + )} + + {imagePasteShortcut} to paste images + + + {modelPickerShortcut} to switch model + + {isFastModeEnabled() && isFastModeAvailable() && ( + + + {fastModeShortcut} to toggle fast mode + + + )} + + {stashShortcut} to stash prompt + + + + {externalEditorShortcut} to edit in $EDITOR + + + {isKeybindingCustomizationEnabled() && ( + + /keybindings to customize + + )} + + + ) } diff --git a/src/components/PromptInput/PromptInputModeIndicator.tsx b/src/components/PromptInput/PromptInputModeIndicator.tsx index 3ca879ddb..4aa66bf7b 100644 --- a/src/components/PromptInput/PromptInputModeIndicator.tsx +++ b/src/components/PromptInput/PromptInputModeIndicator.tsx @@ -1,18 +1,22 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'; -import type { PromptInputMode } from 'src/types/textInputTypes.js'; -import { getTeammateColor } from 'src/utils/teammate.js'; -import type { Theme } from 'src/utils/theme.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; +import figures from 'figures' +import * as React from 'react' +import { Box, Text } from 'src/ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from 'src/tools/AgentTool/agentColorManager.js' +import type { PromptInputMode } from 'src/types/textInputTypes.js' +import { getTeammateColor } from 'src/utils/teammate.js' +import type { Theme } from 'src/utils/theme.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' + type Props = { - mode: PromptInputMode; - isLoading: boolean; - viewingAgentName?: string; - viewingAgentColor?: AgentColorName; -}; + mode: PromptInputMode + isLoading: boolean + viewingAgentName?: string + viewingAgentColor?: AgentColorName +} /** * Gets the theme color key for the teammate's assigned color. @@ -20,73 +24,81 @@ type Props = { */ function getTeammateThemeColor(): keyof Theme | undefined { if (!isAgentSwarmsEnabled()) { - return undefined; + return undefined } - const colorName = getTeammateColor(); + const colorName = getTeammateColor() if (!colorName) { - return undefined; + return undefined } if (AGENT_COLORS.includes(colorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]; + return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] } - return undefined; + return undefined } + type PromptCharProps = { - isLoading: boolean; + isLoading: boolean // Dead code elimination: parameter named themeColor to avoid "teammate" string in external builds - themeColor?: keyof Theme; -}; + themeColor?: keyof Theme +} /** * Renders the prompt character (❯). * Teammate color overrides the default color when set. */ -function PromptChar(t0) { - const $ = _c(3); - const { - isLoading, - themeColor - } = t0; - const teammateColor = themeColor; - const color = teammateColor ?? (false ? "subtle" : undefined); - let t1; - if ($[0] !== color || $[1] !== isLoading) { - t1 = {figures.pointer} ; - $[0] = color; - $[1] = isLoading; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; +function PromptChar({ + isLoading, + themeColor, +}: PromptCharProps): React.ReactNode { + // Assign to original name for clarity within the function + const teammateColor = themeColor + const isAnt = process.env.USER_TYPE === 'ant' + const color = teammateColor ?? (isAnt ? 'subtle' : undefined) + + return ( + + {figures.pointer}  + + ) } -export function PromptInputModeIndicator(t0) { - const $ = _c(6); - const { - mode, - isLoading, - viewingAgentName, - viewingAgentColor - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTeammateThemeColor(); - $[0] = t1; - } else { - t1 = $[0]; - } - const teammateColor = t1; - const viewedTeammateThemeColor = viewingAgentColor ? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor] : undefined; - let t2; - if ($[1] !== isLoading || $[2] !== mode || $[3] !== viewedTeammateThemeColor || $[4] !== viewingAgentName) { - t2 = {viewingAgentName ? : mode === "bash" ? : }; - $[1] = isLoading; - $[2] = mode; - $[3] = viewedTeammateThemeColor; - $[4] = viewingAgentName; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; + +export function PromptInputModeIndicator({ + mode, + isLoading, + viewingAgentName, + viewingAgentColor, +}: Props): React.ReactNode { + const teammateColor = getTeammateThemeColor() + + // Convert viewed teammate's color to theme color + // Falls back to PromptChar's default (subtle for ants, undefined for external) + const viewedTeammateThemeColor = viewingAgentColor + ? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor] + : undefined + + return ( + + {viewingAgentName ? ( + // Use teammate's color on the standard prompt character, matching established style + + ) : mode === 'bash' ? ( + + !  + + ) : ( + + )} + + ) } diff --git a/src/components/PromptInput/PromptInputQueuedCommands.tsx b/src/components/PromptInput/PromptInputQueuedCommands.tsx index 9fe1ad571..c4637b803 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.tsx @@ -1,17 +1,26 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useMemo } from 'react'; -import { Box } from 'src/ink.js'; -import { useAppState } from 'src/state/AppState.js'; -import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js'; -import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js'; -import { useCommandQueue } from '../../hooks/useCommandQueue.js'; -import type { QueuedCommand } from '../../types/textInputTypes.js'; -import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js'; -import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; -import { jsonParse } from '../../utils/slowOperations.js'; -import { Message } from '../Message.js'; -const EMPTY_SET = new Set(); +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useMemo } from 'react' +import { Box } from 'src/ink.js' +import { useAppState } from 'src/state/AppState.js' +import { + STATUS_TAG, + SUMMARY_TAG, + TASK_NOTIFICATION_TAG, +} from '../../constants/xml.js' +import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js' +import { useCommandQueue } from '../../hooks/useCommandQueue.js' +import type { QueuedCommand } from '../../types/textInputTypes.js' +import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js' +import { + createUserMessage, + EMPTY_LOOKUPS, + normalizeMessages, +} from '../../utils/messages.js' +import { jsonParse } from '../../utils/slowOperations.js' +import { Message } from '../Message.js' + +const EMPTY_SET = new Set() /** * Check if a command value is an idle notification that should be hidden. @@ -19,15 +28,15 @@ const EMPTY_SET = new Set(); */ function isIdleNotification(value: string): boolean { try { - const parsed = jsonParse(value); - return parsed?.type === 'idle_notification'; + const parsed = jsonParse(value) + return parsed?.type === 'idle_notification' } catch { - return false; + return false } } // Maximum number of task notification lines to show -const MAX_VISIBLE_NOTIFICATIONS = 3; +const MAX_VISIBLE_NOTIFICATIONS = 3 /** * Create a synthetic overflow notification message for capped task notifications. @@ -36,7 +45,7 @@ function createOverflowNotificationMessage(count: number): string { return `<${TASK_NOTIFICATION_TAG}> <${SUMMARY_TAG}>+${count} more tasks completed <${STATUS_TAG}>completed -`; +` } /** @@ -44,73 +53,114 @@ function createOverflowNotificationMessage(count: number): string { * Other command types are always shown in full. * Idle notifications are filtered out entirely. */ -function processQueuedCommands(queuedCommands: QueuedCommand[]): QueuedCommand[] { +function processQueuedCommands( + queuedCommands: QueuedCommand[], +): QueuedCommand[] { // Filter out idle notifications - they are processed silently - const filteredCommands = queuedCommands.filter(cmd => typeof cmd.value !== 'string' || !isIdleNotification(cmd.value)); + const filteredCommands = queuedCommands.filter( + cmd => typeof cmd.value !== 'string' || !isIdleNotification(cmd.value), + ) // Separate task notifications from other commands - const taskNotifications = filteredCommands.filter(cmd => cmd.mode === 'task-notification'); - const otherCommands = filteredCommands.filter(cmd => cmd.mode !== 'task-notification'); + const taskNotifications = filteredCommands.filter( + cmd => cmd.mode === 'task-notification', + ) + const otherCommands = filteredCommands.filter( + cmd => cmd.mode !== 'task-notification', + ) // If notifications fit within limit, return all commands as-is if (taskNotifications.length <= MAX_VISIBLE_NOTIFICATIONS) { - return [...otherCommands, ...taskNotifications]; + return [...otherCommands, ...taskNotifications] } // Show first (MAX_VISIBLE_NOTIFICATIONS - 1) notifications, then a summary - const visibleNotifications = taskNotifications.slice(0, MAX_VISIBLE_NOTIFICATIONS - 1); - const overflowCount = taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1); + const visibleNotifications = taskNotifications.slice( + 0, + MAX_VISIBLE_NOTIFICATIONS - 1, + ) + const overflowCount = + taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1) // Create synthetic overflow message const overflowCommand: QueuedCommand = { value: createOverflowNotificationMessage(overflowCount), - mode: 'task-notification' - }; - return [...otherCommands, ...visibleNotifications, overflowCommand]; + mode: 'task-notification', + } + + return [...otherCommands, ...visibleNotifications, overflowCommand] } + function PromptInputQueuedCommandsImpl(): React.ReactNode { - const queuedCommands = useCommandQueue(); - const viewingAgent = useAppState(s => !!s.viewingAgentTaskId); + const queuedCommands = useCommandQueue() + const viewingAgent = useAppState(s => !!s.viewingAgentTaskId) // Brief layout: dim queue items + skip the paddingX (brief messages // already indent themselves). Gate mirrors the brief-spinner/message // check elsewhere — no teammate-view override needed since this // component early-returns when viewing a teammate. - const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.isBriefOnly) : false; + const useBriefLayout = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false // createUserMessage mints a fresh UUID per call; without memoization, streaming // re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker. const messages = useMemo(() => { - if (queuedCommands.length === 0) return null; + if (queuedCommands.length === 0) return null // task-notification is shown via useInboxNotification; most isMeta commands // (scheduled tasks, proactive ticks) are system-generated and hidden. // Channel messages are the exception — isMeta but shown so the keyboard // user sees what arrived. - const visibleCommands = queuedCommands.filter(isQueuedCommandVisible); - if (visibleCommands.length === 0) return null; - const processedCommands = processQueuedCommands(visibleCommands); - return normalizeMessages(processedCommands.map(cmd => { - let content = cmd.value; - if (cmd.mode === 'bash' && typeof content === 'string') { - content = `${content}`; - } - // [Image #N] placeholders are inline in the text value (inserted at - // paste time), so the queue preview shows them without stub blocks. - return createUserMessage({ - content - }); - })); - }, [queuedCommands]); + const visibleCommands = queuedCommands.filter(isQueuedCommandVisible) + if (visibleCommands.length === 0) return null + const processedCommands = processQueuedCommands(visibleCommands) + return normalizeMessages( + processedCommands.map(cmd => { + let content = cmd.value + if (cmd.mode === 'bash' && typeof content === 'string') { + content = `${content}` + } + // [Image #N] placeholders are inline in the text value (inserted at + // paste time), so the queue preview shows them without stub blocks. + return createUserMessage({ content }) + }), + ) + }, [queuedCommands]) // Don't show leader's queued commands when viewing any agent's transcript if (viewingAgent || messages === null) { - return null; + return null } - return - {messages.map((message, i) => - - )} - ; + + return ( + + {messages.map((message, i) => ( + + + + ))} + + ) } -export const PromptInputQueuedCommands = React.memo(PromptInputQueuedCommandsImpl); + +export const PromptInputQueuedCommands = React.memo( + PromptInputQueuedCommandsImpl, +) diff --git a/src/components/PromptInput/PromptInputStashNotice.tsx b/src/components/PromptInput/PromptInputStashNotice.tsx index cf01db045..8a44e8607 100644 --- a/src/components/PromptInput/PromptInputStashNotice.tsx +++ b/src/components/PromptInput/PromptInputStashNotice.tsx @@ -1,24 +1,21 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; +import figures from 'figures' +import * as React from 'react' +import { Box, Text } from 'src/ink.js' + type Props = { - hasStash: boolean; -}; -export function PromptInputStashNotice(t0) { - const $ = _c(1); - const { - hasStash - } = t0; + hasStash: boolean +} + +export function PromptInputStashNotice({ hasStash }: Props): React.ReactNode { if (!hasStash) { - return null; - } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {figures.pointerSmall} Stashed (auto-restores after submit); - $[0] = t1; - } else { - t1 = $[0]; + return null } - return t1; + + return ( + + + {figures.pointerSmall} Stashed (auto-restores after submit) + + + ) } diff --git a/src/components/PromptInput/SandboxPromptFooterHint.tsx b/src/components/PromptInput/SandboxPromptFooterHint.tsx index 43b81fcfa..1324a9832 100644 --- a/src/components/PromptInput/SandboxPromptFooterHint.tsx +++ b/src/components/PromptInput/SandboxPromptFooterHint.tsx @@ -1,63 +1,61 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { type ReactNode, useEffect, useRef, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxPromptFooterHint() { - const $ = _c(6); - const [recentViolationCount, setRecentViolationCount] = useState(0); - const timerRef = useRef(null); - const detailsShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - if (!SandboxManager.isSandboxingEnabled()) { - return; - } - const store = SandboxManager.getSandboxViolationStore(); - let lastCount = store.getTotalCount(); - const unsubscribe = store.subscribe(() => { - const currentCount = store.getTotalCount(); - const newViolations = currentCount - lastCount; - if (newViolations > 0) { - setRecentViolationCount(newViolations); - lastCount = currentCount; - if (timerRef.current) { - clearTimeout(timerRef.current); - } - timerRef.current = setTimeout(setRecentViolationCount, 5000, 0); - } - }); - return () => { - unsubscribe(); +import * as React from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' + +export function SandboxPromptFooterHint(): ReactNode { + const [recentViolationCount, setRecentViolationCount] = useState(0) + const timerRef = useRef(null) + const detailsShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + + useEffect(() => { + if (!SandboxManager.isSandboxingEnabled()) { + return + } + + const store = SandboxManager.getSandboxViolationStore() + let lastCount = store.getTotalCount() + + const unsubscribe = store.subscribe(() => { + const currentCount = store.getTotalCount() + const newViolations = currentCount - lastCount + + if (newViolations > 0) { + setRecentViolationCount(newViolations) + lastCount = currentCount + if (timerRef.current) { - clearTimeout(timerRef.current); + clearTimeout(timerRef.current) } - }; - }; - t1 = []; - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; - } - useEffect(t0, t1); + + timerRef.current = setTimeout(setRecentViolationCount, 5000, 0) + } + }) + + return () => { + unsubscribe() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + if (!SandboxManager.isSandboxingEnabled() || recentViolationCount === 0) { - return null; - } - const t2 = recentViolationCount === 1 ? "operation" : "operations"; - let t3; - if ($[2] !== detailsShortcut || $[3] !== recentViolationCount || $[4] !== t2) { - t3 = ⧈ Sandbox blocked {recentViolationCount}{" "}{t2} ·{" "}{detailsShortcut} for details · /sandbox to disable; - $[2] = detailsShortcut; - $[3] = recentViolationCount; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; + return null } - return t3; + + return ( + + + ⧈ Sandbox blocked {recentViolationCount}{' '} + {recentViolationCount === 1 ? 'operation' : 'operations'} ·{' '} + {detailsShortcut} for details · /sandbox to disable + + + ) } diff --git a/src/components/PromptInput/ShimmeredInput.tsx b/src/components/PromptInput/ShimmeredInput.tsx index 5fd163e04..11da7ad76 100644 --- a/src/components/PromptInput/ShimmeredInput.tsx +++ b/src/components/PromptInput/ShimmeredInput.tsx @@ -1,142 +1,121 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Ansi, Box, Text, useAnimationFrame } from '../../ink.js'; -import { segmentTextByHighlights, type TextHighlight } from '../../utils/textHighlighting.js'; -import { ShimmerChar } from '../Spinner/ShimmerChar.js'; +import * as React from 'react' +import { Ansi, Box, Text, useAnimationFrame } from '../../ink.js' +import { + segmentTextByHighlights, + type TextHighlight, +} from '../../utils/textHighlighting.js' +import { ShimmerChar } from '../Spinner/ShimmerChar.js' + type Props = { - text: string; - highlights: TextHighlight[]; -}; + text: string + highlights: TextHighlight[] +} + type LinePart = { - text: string; - highlight: TextHighlight | undefined; - start: number; -}; -export function HighlightedInput(t0) { - const $ = _c(23); - const { - text, - highlights - } = t0; - let lines; - if ($[0] !== highlights || $[1] !== text) { - const segments = segmentTextByHighlights(text, highlights); - lines = [[]]; - let pos = 0; + text: string + highlight: TextHighlight | undefined + start: number +} + +export function HighlightedInput({ text, highlights }: Props): React.ReactNode { + // The shimmer animation (below) re-renders this component at 20fps while the + // ultrathink keyword is present. text/highlights are referentially stable + // across animation ticks (parent doesn't re-render), so memoize everything + // that derives from them: segmentTextByHighlights alone is ~85µs/call + // (tokenize + sort + O(n²) overlap), which adds up fast at 20fps. + const { lines, hasShimmer, sweepStart, cycleLength } = React.useMemo(() => { + const segments = segmentTextByHighlights(text, highlights) + + // Split segments by newlines into per-line groups. Ink's row-direction Box + // indents continuation lines of a multi-line child to that child's X offset. + // By splitting at newlines, each line renders as its own row, avoiding the + // incorrect indentation when highlighted text is followed by wrapped content. + const lines: LinePart[][] = [[]] + let pos = 0 for (const segment of segments) { - const parts = segment.text.split("\n"); + const parts = segment.text.split('\n') for (let i = 0; i < parts.length; i++) { if (i > 0) { - lines.push([]); - pos = pos + 1; + lines.push([]) + pos += 1 } - const part = parts[i]; + const part = parts[i]! if (part.length > 0) { - lines[lines.length - 1].push({ + lines[lines.length - 1]!.push({ text: part, highlight: segment.highlight, - start: pos - }); + start: pos, + }) } - pos = pos + part.length; + pos += part.length } } - $[0] = highlights; - $[1] = text; - $[2] = lines; - } else { - lines = $[2]; - } - let t1; - if ($[3] !== highlights) { - t1 = highlights.some(_temp); - $[3] = highlights; - $[4] = t1; - } else { - t1 = $[4]; - } - const hasShimmer = t1; - let sweepStart = 0; - let cycleLength = 1; - if (hasShimmer) { - let lo = Infinity; - let hi = -Infinity; - if ($[5] !== hi || $[6] !== highlights || $[7] !== lo) { - for (const h_0 of highlights) { - if (h_0.shimmerColor) { - lo = Math.min(lo, h_0.start); - hi = Math.max(hi, h_0.end); + + // Scope the sweep to shimmer-highlighted ranges so cycle time doesn't grow + // with input length. Padding creates an offscreen pause between sweeps. + const hasShimmer = highlights.some(h => h.shimmerColor) + let sweepStart = 0 + let cycleLength = 1 + if (hasShimmer) { + const padding = 10 + let lo = Infinity + let hi = -Infinity + for (const h of highlights) { + if (h.shimmerColor) { + lo = Math.min(lo, h.start) + hi = Math.max(hi, h.end) } } - $[5] = hi; - $[6] = highlights; - $[7] = lo; - $[8] = lo; - $[9] = hi; - } else { - lo = $[8] as number; - hi = $[9] as number; + sweepStart = lo - padding + cycleLength = hi - lo + padding * 2 } - sweepStart = lo - 10; - cycleLength = hi - lo + 20; - } - let t2; - if ($[10] !== cycleLength || $[11] !== hasShimmer || $[12] !== lines || $[13] !== sweepStart) { - t2 = { - lines, - hasShimmer, - sweepStart, - cycleLength - }; - $[10] = cycleLength; - $[11] = hasShimmer; - $[12] = lines; - $[13] = sweepStart; - $[14] = t2; - } else { - t2 = $[14]; - } - const { - lines: lines_0, - hasShimmer: hasShimmer_0, - sweepStart: sweepStart_0, - cycleLength: cycleLength_0 - } = t2; - const [ref, time] = useAnimationFrame(hasShimmer_0 ? 50 : null); - const glimmerIndex = hasShimmer_0 ? sweepStart_0 + Math.floor(time / 50) % cycleLength_0 : -100; - let t3; - if ($[15] !== glimmerIndex || $[16] !== lines_0) { - let t4; - if ($[18] !== glimmerIndex) { - t4 = (lineParts, lineIndex) => {lineParts.length === 0 ? : lineParts.map((part_0, partIndex) => { - if (part_0.highlight?.shimmerColor && part_0.highlight.color) { - return {part_0.text.split("").map((char, charIndex) => )}; - } - return {part_0.text}; - })}; - $[18] = glimmerIndex; - $[19] = t4; - } else { - t4 = $[19]; - } - t3 = lines_0.map(t4); - $[15] = glimmerIndex; - $[16] = lines_0; - $[17] = t3; - } else { - t3 = $[17]; - } - let t4; - if ($[20] !== ref || $[21] !== t3) { - t4 = {t3}; - $[20] = ref; - $[21] = t3; - $[22] = t4; - } else { - t4 = $[22]; - } - return t4; -} -function _temp(h) { - return h.shimmerColor; + + return { lines, hasShimmer, sweepStart, cycleLength } + }, [text, highlights]) + + const [ref, time] = useAnimationFrame(hasShimmer ? 50 : null) + const glimmerIndex = hasShimmer + ? sweepStart + (Math.floor(time / 50) % cycleLength) + : -100 + + return ( + + {lines.map((lineParts, lineIndex) => ( + + {lineParts.length === 0 ? ( + + ) : ( + lineParts.map((part, partIndex) => { + if (part.highlight?.shimmerColor && part.highlight.color) { + return ( + + {part.text.split('').map((char, charIndex) => ( + + ))} + + ) + } + return ( + + {part.text} + + ) + }) + )} + + ))} + + ) } diff --git a/src/components/PromptInput/VoiceIndicator.tsx b/src/components/PromptInput/VoiceIndicator.tsx index 9bf0a6d8b..6dc73baf2 100644 --- a/src/components/PromptInput/VoiceIndicator.tsx +++ b/src/components/PromptInput/VoiceIndicator.tsx @@ -1,73 +1,32 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useSettings } from '../../hooks/useSettings.js'; -import { Box, Text, useAnimationFrame } from '../../ink.js'; -import { interpolateColor, toRGBColor } from '../Spinner/utils.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useSettings } from '../../hooks/useSettings.js' +import { Box, Text, useAnimationFrame } from '../../ink.js' +import { interpolateColor, toRGBColor } from '../Spinner/utils.js' + type Props = { - voiceState: 'idle' | 'recording' | 'processing'; -}; + voiceState: 'idle' | 'recording' | 'processing' +} // Processing shimmer colors: dim gray to lighter gray (matches ThinkingShimmerText) -const PROCESSING_DIM = { - r: 153, - g: 153, - b: 153 -}; -const PROCESSING_BRIGHT = { - r: 185, - g: 185, - b: 185 -}; -const PULSE_PERIOD_S = 2; // 2 second period for all pulsing animations +const PROCESSING_DIM = { r: 153, g: 153, b: 153 } +const PROCESSING_BRIGHT = { r: 185, g: 185, b: 185 } -export function VoiceIndicator(props) { - const $ = _c(2); - if (!feature("VOICE_MODE")) { - return null; - } - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +const PULSE_PERIOD_S = 2 // 2 second period for all pulsing animations + +export function VoiceIndicator(props: Props): React.ReactNode { + if (!feature('VOICE_MODE')) return null + return } -function VoiceIndicatorImpl(t0) { - const $ = _c(2); - const { - voiceState - } = t0; + +function VoiceIndicatorImpl({ voiceState }: Props): React.ReactNode { switch (voiceState) { - case "recording": - { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = listening…; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - case "processing": - { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - case "idle": - { - return null; - } + case 'recording': + return listening… + case 'processing': + return + case 'idle': + return null } } @@ -75,62 +34,30 @@ function VoiceIndicatorImpl(t0) { // is too brief for a 1s-period shimmer to register, and a 50ms animation // timer here runs concurrently with auto-repeat spaces arriving every // 30-80ms, compounding re-renders during an already-busy window. -export function VoiceWarmupHint() { - const $ = _c(1); - if (!feature("VOICE_MODE")) { - return null; - } - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = keep holding…; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +export function VoiceWarmupHint(): React.ReactNode { + if (!feature('VOICE_MODE')) return null + return keep holding… } -function ProcessingShimmer() { - const $ = _c(8); - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const [ref, time] = useAnimationFrame(reducedMotion ? null : 50); + +function ProcessingShimmer(): React.ReactNode { + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + const [ref, time] = useAnimationFrame(reducedMotion ? null : 50) + if (reducedMotion) { - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Voice: processing…; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; - } - const elapsedSec = time / 1000; - const opacity = (Math.sin(elapsedSec * Math.PI * 2 / PULSE_PERIOD_S) + 1) / 2; - let t0; - if ($[1] !== opacity) { - t0 = toRGBColor(interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity)); - $[1] = opacity; - $[2] = t0; - } else { - t0 = $[2]; - } - const color = t0; - let t1; - if ($[3] !== color) { - t1 = Voice: processing…; - $[3] = color; - $[4] = t1; - } else { - t1 = $[4]; + return Voice: processing… } - let t2; - if ($[5] !== ref || $[6] !== t1) { - t2 = {t1}; - $[5] = ref; - $[6] = t1; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; + + const elapsedSec = time / 1000 + const opacity = + (Math.sin((elapsedSec * Math.PI * 2) / PULSE_PERIOD_S) + 1) / 2 + const color = toRGBColor( + interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity), + ) + + return ( + + Voice: processing… + + ) } diff --git a/src/components/agents/AgentDetail.tsx b/src/components/agents/AgentDetail.tsx index 4c0f50e56..4c817b134 100644 --- a/src/components/agents/AgentDetail.tsx +++ b/src/components/agents/AgentDetail.tsx @@ -1,219 +1,148 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Tools } from '../../Tool.js'; -import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js'; -import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js'; -import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js'; -import { type AgentDefinition, isBuiltInAgent } from '../../tools/AgentTool/loadAgentsDir.js'; -import { getAgentModelDisplay } from '../../utils/model/agent.js'; -import { Markdown } from '../Markdown.js'; -import { getActualRelativeAgentFilePath } from './agentFileUtils.js'; +import figures from 'figures' +import * as React from 'react' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Tools } from '../../Tool.js' +import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js' +import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js' +import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js' +import { + type AgentDefinition, + isBuiltInAgent, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { getAgentModelDisplay } from '../../utils/model/agent.js' +import { Markdown } from '../Markdown.js' +import { getActualRelativeAgentFilePath } from './agentFileUtils.js' + type Props = { - agent: AgentDefinition; - tools: Tools; - allAgents?: AgentDefinition[]; - onBack: () => void; -}; -export function AgentDetail(t0) { - const $ = _c(48); - const { - agent, - tools, - onBack - } = t0; - const resolvedTools = resolveAgentTools(agent, tools, false); - let t1; - if ($[0] !== agent) { - t1 = getActualRelativeAgentFilePath(agent); - $[0] = agent; - $[1] = t1; - } else { - t1 = $[1]; - } - const filePath = t1; - let t2; - if ($[2] !== agent.agentType) { - t2 = getAgentColor(agent.agentType); - $[2] = agent.agentType; - $[3] = t2; - } else { - t2 = $[3]; - } - const backgroundColor = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[4] = t3; - } else { - t3 = $[4]; - } - useKeybinding("confirm:no", onBack, t3); - let t4; - if ($[5] !== onBack) { - t4 = e => { - if (e.key === "return") { - e.preventDefault(); - onBack(); - } - }; - $[5] = onBack; - $[6] = t4; - } else { - t4 = $[6]; + agent: AgentDefinition + tools: Tools + allAgents?: AgentDefinition[] + onBack: () => void +} + +export function AgentDetail({ agent, tools, onBack }: Props): React.ReactNode { + const resolvedTools = resolveAgentTools(agent, tools, false) + const filePath = getActualRelativeAgentFilePath(agent) + const backgroundColor = getAgentColor(agent.agentType) + + // Handle Esc to go back + useKeybinding('confirm:no', onBack, { context: 'Confirmation' }) + + // Handle Enter to go back + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + onBack() + } } - const handleKeyDown = t4; - const renderToolsList = function renderToolsList() { + + function renderToolsList(): React.ReactNode { if (resolvedTools.hasWildcard) { - return All tools; + return All tools } + if (!agent.tools || agent.tools.length === 0) { - return None; + return None } - return <>{resolvedTools.validTools.length > 0 && {resolvedTools.validTools.join(", ")}}{resolvedTools.invalidTools.length > 0 && {figures.warning} Unrecognized:{" "}{resolvedTools.invalidTools.join(", ")}}; - }; - const T0 = Box; - const t5 = "column"; - const t6 = 1; - const t7 = 0; - const t8 = true; - let t9; - if ($[7] !== filePath) { - t9 = {filePath}; - $[7] = filePath; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Description (tells Claude when to use this agent):; - $[9] = t10; - } else { - t10 = $[9]; - } - let t11; - if ($[10] !== agent.whenToUse) { - t11 = {t10}{agent.whenToUse}; - $[10] = agent.whenToUse; - $[11] = t11; - } else { - t11 = $[11]; - } - const T1 = Box; - let t12; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Tools:{" "}; - $[12] = t12; - } else { - t12 = $[12]; - } - const t13 = renderToolsList(); - let t14; - if ($[13] !== T1 || $[14] !== t12 || $[15] !== t13) { - t14 = {t12}{t13}; - $[13] = T1; - $[14] = t12; - $[15] = t13; - $[16] = t14; - } else { - t14 = $[16]; - } - let t15; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t15 = Model; - $[17] = t15; - } else { - t15 = $[17]; - } - let t16; - if ($[18] !== agent.model) { - t16 = getAgentModelDisplay(agent.model); - $[18] = agent.model; - $[19] = t16; - } else { - t16 = $[19]; - } - let t17; - if ($[20] !== t16) { - t17 = {t15}: {t16}; - $[20] = t16; - $[21] = t17; - } else { - t17 = $[21]; - } - let t18; - if ($[22] !== agent.permissionMode) { - t18 = agent.permissionMode && Permission mode: {agent.permissionMode}; - $[22] = agent.permissionMode; - $[23] = t18; - } else { - t18 = $[23]; - } - let t19; - if ($[24] !== agent.memory) { - t19 = agent.memory && Memory: {getMemoryScopeDisplay(agent.memory)}; - $[24] = agent.memory; - $[25] = t19; - } else { - t19 = $[25]; - } - let t20; - if ($[26] !== agent.hooks) { - t20 = agent.hooks && Object.keys(agent.hooks).length > 0 && Hooks: {Object.keys(agent.hooks).join(", ")}; - $[26] = agent.hooks; - $[27] = t20; - } else { - t20 = $[27]; - } - let t21; - if ($[28] !== agent.skills) { - t21 = agent.skills && agent.skills.length > 0 && Skills:{" "}{agent.skills.length > 10 ? `${agent.skills.length} skills` : agent.skills.join(", ")}; - $[28] = agent.skills; - $[29] = t21; - } else { - t21 = $[29]; - } - let t22; - if ($[30] !== agent.agentType || $[31] !== backgroundColor) { - t22 = backgroundColor && Color:{" "}{" "}{agent.agentType}{" "}; - $[30] = agent.agentType; - $[31] = backgroundColor; - $[32] = t22; - } else { - t22 = $[32]; - } - let t23; - if ($[33] !== agent) { - t23 = !isBuiltInAgent(agent) && <>System prompt:{agent.getSystemPrompt()}; - $[33] = agent; - $[34] = t23; - } else { - t23 = $[34]; - } - let t24; - if ($[35] !== T0 || $[36] !== handleKeyDown || $[37] !== t11 || $[38] !== t14 || $[39] !== t17 || $[40] !== t18 || $[41] !== t19 || $[42] !== t20 || $[43] !== t21 || $[44] !== t22 || $[45] !== t23 || $[46] !== t9) { - t24 = {t9}{t11}{t14}{t17}{t18}{t19}{t20}{t21}{t22}{t23}; - $[35] = T0; - $[36] = handleKeyDown; - $[37] = t11; - $[38] = t14; - $[39] = t17; - $[40] = t18; - $[41] = t19; - $[42] = t20; - $[43] = t21; - $[44] = t22; - $[45] = t23; - $[46] = t9; - $[47] = t24; - } else { - t24 = $[47]; - } - return t24; + + return ( + <> + {resolvedTools.validTools.length > 0 && ( + {resolvedTools.validTools.join(', ')} + )} + {resolvedTools.invalidTools.length > 0 && ( + + {figures.warning} Unrecognized:{' '} + {resolvedTools.invalidTools.join(', ')} + + )} + + ) + } + + return ( + + {filePath} + + + + Description (tells Claude when to use this agent): + + + {agent.whenToUse} + + + + + + Tools:{' '} + + {renderToolsList()} + + + + Model: {getAgentModelDisplay(agent.model)} + + + {agent.permissionMode && ( + + Permission mode: {agent.permissionMode} + + )} + + {agent.memory && ( + + Memory: {getMemoryScopeDisplay(agent.memory)} + + )} + + {agent.hooks && Object.keys(agent.hooks).length > 0 && ( + + Hooks: {Object.keys(agent.hooks).join(', ')} + + )} + + {agent.skills && agent.skills.length > 0 && ( + + Skills:{' '} + {agent.skills.length > 10 + ? `${agent.skills.length} skills` + : agent.skills.join(', ')} + + )} + + {backgroundColor && ( + + + Color:{' '} + + {' '} + {agent.agentType}{' '} + + + + )} + + {!isBuiltInAgent(agent) && ( + <> + + + System prompt: + + + + {agent.getSystemPrompt()} + + + )} + + ) } diff --git a/src/components/agents/AgentEditor.tsx b/src/components/agents/AgentEditor.tsx index e406cf5b2..e5c7b1847 100644 --- a/src/components/agents/AgentEditor.tsx +++ b/src/components/agents/AgentEditor.tsx @@ -1,177 +1,246 @@ -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import { useSetAppState } from 'src/state/AppState.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Tools } from '../../Tool.js'; -import { type AgentColorName, setAgentColor } from '../../tools/AgentTool/agentColorManager.js'; -import { type AgentDefinition, getActiveAgentsFromList, isCustomAgent, isPluginAgent } from '../../tools/AgentTool/loadAgentsDir.js'; -import { editFileInEditor } from '../../utils/promptEditor.js'; -import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js'; -import { ColorPicker } from './ColorPicker.js'; -import { ModelSelector } from './ModelSelector.js'; -import { ToolSelector } from './ToolSelector.js'; -import { getAgentSourceDisplayName } from './utils.js'; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useSetAppState } from 'src/state/AppState.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Tools } from '../../Tool.js' +import { + type AgentColorName, + setAgentColor, +} from '../../tools/AgentTool/agentColorManager.js' +import { + type AgentDefinition, + getActiveAgentsFromList, + isCustomAgent, + isPluginAgent, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { editFileInEditor } from '../../utils/promptEditor.js' +import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js' +import { ColorPicker } from './ColorPicker.js' +import { ModelSelector } from './ModelSelector.js' +import { ToolSelector } from './ToolSelector.js' +import { getAgentSourceDisplayName } from './utils.js' + type Props = { - agent: AgentDefinition; - tools: Tools; - onSaved: (message: string) => void; - onBack: () => void; -}; -type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model'; + agent: AgentDefinition + tools: Tools + onSaved: (message: string) => void + onBack: () => void +} + +type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model' + type SaveChanges = { - tools?: string[]; - color?: AgentColorName; - model?: string; -}; + tools?: string[] + color?: AgentColorName + model?: string +} + export function AgentEditor({ agent, tools, onSaved, - onBack + onBack, }: Props): React.ReactNode { - const setAppState = useSetAppState(); - const [editMode, setEditMode] = useState('menu'); - const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); - const [error, setError] = useState(null); - const [selectedColor, setSelectedColor] = useState(agent.color as AgentColorName | undefined); + const setAppState = useSetAppState() + const [editMode, setEditMode] = useState('menu') + const [selectedMenuIndex, setSelectedMenuIndex] = useState(0) + const [error, setError] = useState(null) + const [selectedColor, setSelectedColor] = useState< + AgentColorName | undefined + >(agent.color as AgentColorName | undefined) + const handleOpenInEditor = useCallback(async () => { - const filePath = getActualAgentFilePath(agent); - const result = await editFileInEditor(filePath); + const filePath = getActualAgentFilePath(agent) + const result = await editFileInEditor(filePath) + if (result.error) { - setError(result.error); + setError(result.error) } else { - onSaved(`Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`); + onSaved( + `Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`, + ) } - }, [agent, onSaved]); - const handleSave = useCallback(async (changes: SaveChanges = {}) => { - const { - tools: newTools, - color: newColor, - model: newModel - } = changes; - const finalColor = newColor ?? selectedColor; - const hasToolsChanged = newTools !== undefined; - const hasModelChanged = newModel !== undefined; - const hasColorChanged = finalColor !== agent.color; - if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { - return false; - } - try { - // Only custom/plugin agents can be edited - // this is for type safety; the UI shouldn't allow editing otherwise - if (!isCustomAgent(agent) && !isPluginAgent(agent)) { - return false; - } - await updateAgentFile(agent, agent.whenToUse, newTools ?? agent.tools, agent.getSystemPrompt(), finalColor, newModel ?? agent.model); - if (hasColorChanged && finalColor) { - setAgentColor(agent.agentType, finalColor); + }, [agent, onSaved]) + + const handleSave = useCallback( + async (changes: SaveChanges = {}) => { + const { tools: newTools, color: newColor, model: newModel } = changes + const finalColor = newColor ?? selectedColor + const hasToolsChanged = newTools !== undefined + const hasModelChanged = newModel !== undefined + const hasColorChanged = finalColor !== agent.color + + if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { + return false } - setAppState(state => { - const allAgents = state.agentDefinitions.allAgents.map(a => a.agentType === agent.agentType ? { - ...a, - tools: newTools ?? a.tools, - color: finalColor, - model: newModel ?? a.model - } : a); - return { - ...state, - agentDefinitions: { - ...state.agentDefinitions, - activeAgents: getActiveAgentsFromList(allAgents), - allAgents + + try { + // Only custom/plugin agents can be edited + // this is for type safety; the UI shouldn't allow editing otherwise + if (!isCustomAgent(agent) && !isPluginAgent(agent)) { + return false + } + + await updateAgentFile( + agent, + agent.whenToUse, + newTools ?? agent.tools, + agent.getSystemPrompt(), + finalColor, + newModel ?? agent.model, + ) + + if (hasColorChanged && finalColor) { + setAgentColor(agent.agentType, finalColor) + } + + setAppState(state => { + const allAgents = state.agentDefinitions.allAgents.map(a => + a.agentType === agent.agentType + ? { + ...a, + tools: newTools ?? a.tools, + color: finalColor, + model: newModel ?? a.model, + } + : a, + ) + return { + ...state, + agentDefinitions: { + ...state.agentDefinitions, + activeAgents: getActiveAgentsFromList(allAgents), + allAgents, + }, } - }; - }); - onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`); - return true; - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save agent'); - return false; - } - }, [agent, selectedColor, onSaved, setAppState]); - const menuItems = useMemo(() => [{ - label: 'Open in editor', - action: handleOpenInEditor - }, { - label: 'Edit tools', - action: () => setEditMode('edit-tools') - }, { - label: 'Edit model', - action: () => setEditMode('edit-model') - }, { - label: 'Edit color', - action: () => setEditMode('edit-color') - }], [handleOpenInEditor]); + }) + + onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`) + return true + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save agent') + return false + } + }, + [agent, selectedColor, onSaved, setAppState], + ) + + const menuItems = useMemo( + () => [ + { label: 'Open in editor', action: handleOpenInEditor }, + { label: 'Edit tools', action: () => setEditMode('edit-tools') }, + { label: 'Edit model', action: () => setEditMode('edit-model') }, + { label: 'Edit color', action: () => setEditMode('edit-color') }, + ], + [handleOpenInEditor], + ) + const handleEscape = useCallback(() => { - setError(null); + setError(null) if (editMode === 'menu') { - onBack(); + onBack() } else { - setEditMode('menu'); + setEditMode('menu') } - }, [editMode, onBack]); - const handleMenuKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'up') { - e.preventDefault(); - setSelectedMenuIndex(index => Math.max(0, index - 1)); - } else if (e.key === 'down') { - e.preventDefault(); - setSelectedMenuIndex(index_0 => Math.min(menuItems.length - 1, index_0 + 1)); - } else if (e.key === 'return') { - e.preventDefault(); - const selectedItem = menuItems[selectedMenuIndex]; - if (selectedItem) { - void selectedItem.action(); + }, [editMode, onBack]) + + const handleMenuKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'up') { + e.preventDefault() + setSelectedMenuIndex(index => Math.max(0, index - 1)) + } else if (e.key === 'down') { + e.preventDefault() + setSelectedMenuIndex(index => Math.min(menuItems.length - 1, index + 1)) + } else if (e.key === 'return') { + e.preventDefault() + const selectedItem = menuItems[selectedMenuIndex] + if (selectedItem) { + void selectedItem.action() + } } - } - }, [menuItems, selectedMenuIndex]); - useKeybinding('confirm:no', handleEscape, { - context: 'Confirmation' - }); - const renderMenu = (): React.ReactNode => + }, + [menuItems, selectedMenuIndex], + ) + + useKeybinding('confirm:no', handleEscape, { context: 'Confirmation' }) + + const renderMenu = (): React.ReactNode => ( + Source: {getAgentSourceDisplayName(agent.source)} - {menuItems.map((item, index_1) => - {index_1 === selectedMenuIndex ? `${figures.pointer} ` : ' '} + {menuItems.map((item, index) => ( + + {index === selectedMenuIndex ? `${figures.pointer} ` : ' '} {item.label} - )} + + ))} - {error && + {error && ( + {error} - } - ; + + )} + + ) + switch (editMode) { case 'menu': - return renderMenu(); + return renderMenu() + case 'edit-tools': - return { - setEditMode('menu'); - await handleSave({ - tools: finalTools - }); - }} />; + return ( + { + setEditMode('menu') + await handleSave({ tools: finalTools }) + }} + /> + ) + case 'edit-color': - return { - setSelectedColor(color); - setEditMode('menu'); - await handleSave({ - color - }); - }} />; + return ( + { + setSelectedColor(color) + setEditMode('menu') + await handleSave({ color }) + }} + /> + ) + case 'edit-model': - return { - setEditMode('menu'); - await handleSave({ - model - }); - }} />; + return ( + { + setEditMode('menu') + await handleSave({ model }) + }} + /> + ) + default: - return null; + return null } } diff --git a/src/components/agents/AgentNavigationFooter.tsx b/src/components/agents/AgentNavigationFooter.tsx index e20f7301d..9c4fa9f76 100644 --- a/src/components/agents/AgentNavigationFooter.tsx +++ b/src/components/agents/AgentNavigationFooter.tsx @@ -1,25 +1,23 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; +import * as React from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' + type Props = { - instructions?: string; -}; -export function AgentNavigationFooter(t0) { - const $ = _c(2); - const { - instructions: t1 - } = t0; - const instructions = t1 === undefined ? "Press \u2191\u2193 to navigate \xB7 Enter to select \xB7 Esc to go back" : t1; - const exitState = useExitOnCtrlCDWithKeybindings(); - const t2 = exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions; - let t3; - if ($[0] !== t2) { - t3 = {t2}; - $[0] = t2; - $[1] = t3; - } else { - t3 = $[1]; - } - return t3; + instructions?: string +} + +export function AgentNavigationFooter({ + instructions = 'Press ↑↓ to navigate · Enter to select · Esc to go back', +}: Props): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings() + + return ( + + + {exitState.pending + ? `Press ${exitState.keyName} again to exit` + : instructions} + + + ) } diff --git a/src/components/agents/AgentsList.tsx b/src/components/agents/AgentsList.tsx index 2e394fa1d..6eadf1ef7 100644 --- a/src/components/agents/AgentsList.tsx +++ b/src/components/agents/AgentsList.tsx @@ -1,439 +1,342 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js'; -import { AGENT_SOURCE_GROUPS, compareAgentsByName, getOverrideSourceLabel, resolveAgentModelDisplay } from '../../tools/AgentTool/agentDisplay.js'; -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; -import { count } from '../../utils/array.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { Divider } from '../design-system/Divider.js'; -import { getAgentSourceDisplayName } from './utils.js'; +import figures from 'figures' +import * as React from 'react' +import type { SettingSource } from 'src/utils/settings/constants.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js' +import { + AGENT_SOURCE_GROUPS, + compareAgentsByName, + getOverrideSourceLabel, + resolveAgentModelDisplay, +} from '../../tools/AgentTool/agentDisplay.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { count } from '../../utils/array.js' +import { Dialog } from '../design-system/Dialog.js' +import { Divider } from '../design-system/Divider.js' +import { getAgentSourceDisplayName } from './utils.js' + type Props = { - source: SettingSource | 'all' | 'built-in' | 'plugin'; - agents: ResolvedAgent[]; - onBack: () => void; - onSelect: (agent: AgentDefinition) => void; - onCreateNew?: () => void; - changes?: string[]; -}; -export function AgentsList(t0) { - const $ = _c(96); - const { - source, - agents, - onBack, - onSelect, - onCreateNew, - changes - } = t0; - const [selectedAgent, setSelectedAgent] = React.useState(null); - const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true); - let t1; - if ($[0] !== agents) { - t1 = [...agents].sort(compareAgentsByName); - $[0] = agents; - $[1] = t1; - } else { - t1 = $[1]; + source: SettingSource | 'all' | 'built-in' | 'plugin' + agents: ResolvedAgent[] + onBack: () => void + onSelect: (agent: AgentDefinition) => void + onCreateNew?: () => void + changes?: string[] +} + +export function AgentsList({ + source, + agents, + onBack, + onSelect, + onCreateNew, + changes, +}: Props): React.ReactNode { + const [selectedAgent, setSelectedAgent] = + React.useState(null) + const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true) + + // Sort agents alphabetically by name within each source group + const sortedAgents = React.useMemo( + () => [...agents].sort(compareAgentsByName), + [agents], + ) + + const getOverrideInfo = (agent: ResolvedAgent) => { + return { + isOverridden: !!agent.overriddenBy, + overriddenBy: agent.overriddenBy || null, + } } - const sortedAgents = t1; - const getOverrideInfo = _temp; - let t2; - if ($[2] !== isCreateNewSelected) { - t2 = () => {isCreateNewSelected ? `${figures.pointer} ` : " "}Create new agent; - $[2] = isCreateNewSelected; - $[3] = t2; - } else { - t2 = $[3]; + + const renderCreateNewOption = () => { + return ( + + + {isCreateNewSelected ? `${figures.pointer} ` : ' '} + + + Create new agent + + + ) } - const renderCreateNewOption = t2; - let t3; - if ($[4] !== isCreateNewSelected || $[5] !== selectedAgent?.agentType || $[6] !== selectedAgent?.source) { - t3 = agent_0 => { - const isBuiltIn = agent_0.source === "built-in"; - const isSelected = !isBuiltIn && !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source; - const { - isOverridden, - overriddenBy - } = getOverrideInfo(agent_0); - const dimmed = isBuiltIn || isOverridden; - const textColor = !isBuiltIn && isSelected ? "suggestion" : undefined; - const resolvedModel = resolveAgentModelDisplay(agent_0); - return {isBuiltIn ? "" : isSelected ? `${figures.pointer} ` : " "}{agent_0.agentType}{resolvedModel && {" \xB7 "}{resolvedModel}}{agent_0.memory && {" \xB7 "}{agent_0.memory} memory}{overriddenBy && {" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}}; - }; - $[4] = isCreateNewSelected; - $[5] = selectedAgent?.agentType; - $[6] = selectedAgent?.source; - $[7] = t3; - } else { - t3 = $[7]; + + const renderAgent = (agent: ResolvedAgent) => { + const isBuiltIn = agent.source === 'built-in' + const isSelected = + !isBuiltIn && + !isCreateNewSelected && + selectedAgent?.agentType === agent.agentType && + selectedAgent?.source === agent.source + + const { isOverridden, overriddenBy } = getOverrideInfo(agent) + const dimmed = isBuiltIn || isOverridden + const textColor = !isBuiltIn && isSelected ? 'suggestion' : undefined + + const resolvedModel = resolveAgentModelDisplay(agent) + + return ( + + + {isBuiltIn ? '' : isSelected ? `${figures.pointer} ` : ' '} + + + {agent.agentType} + + {resolvedModel && ( + + {' · '} + {resolvedModel} + + )} + {agent.memory && ( + + {' · '} + {agent.memory} memory + + )} + {overriddenBy && ( + + {' '} + {figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)} + + )} + + ) } - const renderAgent = t3; - let t4; - if ($[8] !== sortedAgents || $[9] !== source) { - bb0: { - const nonBuiltIn = sortedAgents.filter(_temp2); - if (source === "all") { - t4 = AGENT_SOURCE_GROUPS.filter(_temp3).flatMap(t5 => { - const { - source: groupSource - } = t5; - return nonBuiltIn.filter(a_0 => a_0.source === groupSource); - }); - break bb0; - } - t4 = nonBuiltIn; + + const selectableAgentsInOrder = React.useMemo(() => { + const nonBuiltIn = sortedAgents.filter(a => a.source !== 'built-in') + if (source === 'all') { + return AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').flatMap( + ({ source: groupSource }) => + nonBuiltIn.filter(a => a.source === groupSource), + ) } - $[8] = sortedAgents; - $[9] = source; - $[10] = t4; - } else { - t4 = $[10]; - } - const selectableAgentsInOrder = t4; - let t5; - let t6; - if ($[11] !== isCreateNewSelected || $[12] !== onCreateNew || $[13] !== selectableAgentsInOrder || $[14] !== selectedAgent) { - t5 = () => { - if (!selectedAgent && !isCreateNewSelected && selectableAgentsInOrder.length > 0) { - if (onCreateNew) { - setIsCreateNewSelected(true); - } else { - setSelectedAgent(selectableAgentsInOrder[0] || null); - } - } - }; - t6 = [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]; - $[11] = isCreateNewSelected; - $[12] = onCreateNew; - $[13] = selectableAgentsInOrder; - $[14] = selectedAgent; - $[15] = t5; - $[16] = t6; - } else { - t5 = $[15]; - t6 = $[16]; - } - React.useEffect(t5, t6); - let t7; - if ($[17] !== isCreateNewSelected || $[18] !== onCreateNew || $[19] !== onSelect || $[20] !== selectableAgentsInOrder || $[21] !== selectedAgent) { - t7 = e => { - if (e.key === "return") { - e.preventDefault(); - if (isCreateNewSelected && onCreateNew) { - onCreateNew(); - } else { - if (selectedAgent) { - onSelect(selectedAgent); - } - } - return; - } - if (e.key !== "up" && e.key !== "down") { - return; - } - e.preventDefault(); - const hasCreateOption = !!onCreateNew; - const totalItems = selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0); - if (totalItems === 0) { - return; - } - let currentPosition = 0; - if (!isCreateNewSelected && selectedAgent) { - const agentIndex = selectableAgentsInOrder.findIndex(a_1 => a_1.agentType === selectedAgent.agentType && a_1.source === selectedAgent.source); - if (agentIndex >= 0) { - currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex; - } - } - const newPosition = e.key === "up" ? currentPosition === 0 ? totalItems - 1 : currentPosition - 1 : currentPosition === totalItems - 1 ? 0 : currentPosition + 1; - if (hasCreateOption && newPosition === 0) { - setIsCreateNewSelected(true); - setSelectedAgent(null); + return nonBuiltIn + }, [sortedAgents, source]) + + // Set initial selection + React.useEffect(() => { + if ( + !selectedAgent && + !isCreateNewSelected && + selectableAgentsInOrder.length > 0 + ) { + if (onCreateNew) { + setIsCreateNewSelected(true) } else { - const agentIndex_0 = hasCreateOption ? newPosition - 1 : newPosition; - const newAgent = selectableAgentsInOrder[agentIndex_0]; - if (newAgent) { - setIsCreateNewSelected(false); - setSelectedAgent(newAgent); - } - } - }; - $[17] = isCreateNewSelected; - $[18] = onCreateNew; - $[19] = onSelect; - $[20] = selectableAgentsInOrder; - $[21] = selectedAgent; - $[22] = t7; - } else { - t7 = $[22]; - } - const handleKeyDown = t7; - let t8; - if ($[23] !== renderAgent || $[24] !== sortedAgents) { - t8 = t9 => { - const title = t9 === undefined ? "Built-in (always available):" : t9; - const builtInAgents = sortedAgents.filter(_temp4); - return {title}{builtInAgents.map(renderAgent)}; - }; - $[23] = renderAgent; - $[24] = sortedAgents; - $[25] = t8; - } else { - t8 = $[25]; - } - const renderBuiltInAgentsSection = t8; - let t9; - if ($[26] !== renderAgent) { - t9 = (title_0, groupAgents) => { - if (!groupAgents.length) { - return null; + setSelectedAgent(selectableAgentsInOrder[0] || null) } - const folderPath = groupAgents[0]?.baseDir; - return {title_0}{folderPath && ({folderPath})}{groupAgents.map(agent_1 => renderAgent(agent_1))}; - }; - $[26] = renderAgent; - $[27] = t9; - } else { - t9 = $[27]; - } - const renderAgentGroup = t9; - let t10; - if ($[28] !== source) { - t10 = getAgentSourceDisplayName(source); - $[28] = source; - $[29] = t10; - } else { - t10 = $[29]; - } - const sourceTitle = t10; - let T0; - let T1; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - let t18; - let t19; - let t20; - let t21; - let t22; - if ($[30] !== changes || $[31] !== handleKeyDown || $[32] !== onBack || $[33] !== onCreateNew || $[34] !== renderAgent || $[35] !== renderAgentGroup || $[36] !== renderBuiltInAgentsSection || $[37] !== renderCreateNewOption || $[38] !== sortedAgents || $[39] !== source || $[40] !== sourceTitle) { - t22 = Symbol.for("react.early_return_sentinel"); - bb1: { - const builtInAgents_0 = sortedAgents.filter(_temp5); - const hasNoAgents = !sortedAgents.length || source !== "built-in" && !sortedAgents.some(_temp6); - if (hasNoAgents) { - let t23; - if ($[55] !== onCreateNew || $[56] !== renderCreateNewOption) { - t23 = onCreateNew && {renderCreateNewOption()}; - $[55] = onCreateNew; - $[56] = renderCreateNewOption; - $[57] = t23; - } else { - t23 = $[57]; - } - let t24; - let t25; - let t26; - if ($[58] === Symbol.for("react.memo_cache_sentinel")) { - t24 = No agents found. Create specialized subagents that Claude can delegate to.; - t25 = Each subagent has its own context window, custom system prompt, and specific tools.; - t26 = Try creating: Code Reviewer, Code Simplifier, Security Reviewer, Tech Lead, or UX Reviewer.; - $[58] = t24; - $[59] = t25; - $[60] = t26; - } else { - t24 = $[58]; - t25 = $[59]; - t26 = $[60]; - } - let t27; - if ($[61] !== renderBuiltInAgentsSection || $[62] !== sortedAgents || $[63] !== source) { - t27 = source !== "built-in" && sortedAgents.some(_temp7) && <>{renderBuiltInAgentsSection()}; - $[61] = renderBuiltInAgentsSection; - $[62] = sortedAgents; - $[63] = source; - $[64] = t27; - } else { - t27 = $[64]; - } - let t28; - if ($[65] !== handleKeyDown || $[66] !== t23 || $[67] !== t27) { - t28 = {t23}{t24}{t25}{t26}{t27}; - $[65] = handleKeyDown; - $[66] = t23; - $[67] = t27; - $[68] = t28; - } else { - t28 = $[68]; - } - let t29; - if ($[69] !== onBack || $[70] !== sourceTitle || $[71] !== t28) { - t29 = {t28}; - $[69] = onBack; - $[70] = sourceTitle; - $[71] = t28; - $[72] = t29; - } else { - t29 = $[72]; - } - t22 = t29; - break bb1; - } - T1 = Dialog; - t17 = sourceTitle; - let t23; - if ($[73] !== sortedAgents) { - t23 = count(sortedAgents, _temp8); - $[73] = sortedAgents; - $[74] = t23; - } else { - t23 = $[74]; + } + }, [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + if (isCreateNewSelected && onCreateNew) { + onCreateNew() + } else if (selectedAgent) { + onSelect(selectedAgent) } - t18 = `${t23} agents`; - t19 = onBack; - t20 = true; - if ($[75] !== changes) { - t21 = changes && changes.length > 0 && {changes[changes.length - 1]}; - $[75] = changes; - $[76] = t21; - } else { - t21 = $[76]; + return + } + + if (e.key !== 'up' && e.key !== 'down') return + e.preventDefault() + + // Handle navigation with "Create New Agent" option + const hasCreateOption = !!onCreateNew + const totalItems = + selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0) + + if (totalItems === 0) return + + // Calculate current position in list (0 = create new, 1+ = agents) + let currentPosition = 0 + if (!isCreateNewSelected && selectedAgent) { + const agentIndex = selectableAgentsInOrder.findIndex( + a => + a.agentType === selectedAgent.agentType && + a.source === selectedAgent.source, + ) + if (agentIndex >= 0) { + currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex } - T0 = Box; - t11 = "column"; - t12 = 0; - t13 = true; - t14 = handleKeyDown; - if ($[77] !== onCreateNew || $[78] !== renderCreateNewOption) { - t15 = onCreateNew && {renderCreateNewOption()}; - $[77] = onCreateNew; - $[78] = renderCreateNewOption; - $[79] = t15; - } else { - t15 = $[79]; + } + + // Calculate new position with wrap-around + const newPosition = + e.key === 'up' + ? currentPosition === 0 + ? totalItems - 1 + : currentPosition - 1 + : currentPosition === totalItems - 1 + ? 0 + : currentPosition + 1 + + // Update selection based on new position + if (hasCreateOption && newPosition === 0) { + setIsCreateNewSelected(true) + setSelectedAgent(null) + } else { + const agentIndex = hasCreateOption ? newPosition - 1 : newPosition + const newAgent = selectableAgentsInOrder[agentIndex] + if (newAgent) { + setIsCreateNewSelected(false) + setSelectedAgent(newAgent) } - t16 = source === "all" ? <>{AGENT_SOURCE_GROUPS.filter(_temp9).map(t24 => { - const { - label, - source: groupSource_0 - } = t24; - return {renderAgentGroup(label, sortedAgents.filter(a_7 => a_7.source === groupSource_0))}; - })}{builtInAgents_0.length > 0 && Built-in agents (always available){builtInAgents_0.map(renderAgent)}} : source === "built-in" ? <>Built-in agents are provided by default and cannot be modified.{sortedAgents.map(agent_2 => renderAgent(agent_2))} : <>{sortedAgents.filter(_temp0).map(agent_3 => renderAgent(agent_3))}{sortedAgents.some(_temp1) && <>{renderBuiltInAgentsSection()}}; } - $[30] = changes; - $[31] = handleKeyDown; - $[32] = onBack; - $[33] = onCreateNew; - $[34] = renderAgent; - $[35] = renderAgentGroup; - $[36] = renderBuiltInAgentsSection; - $[37] = renderCreateNewOption; - $[38] = sortedAgents; - $[39] = source; - $[40] = sourceTitle; - $[41] = T0; - $[42] = T1; - $[43] = t11; - $[44] = t12; - $[45] = t13; - $[46] = t14; - $[47] = t15; - $[48] = t16; - $[49] = t17; - $[50] = t18; - $[51] = t19; - $[52] = t20; - $[53] = t21; - $[54] = t22; - } else { - T0 = $[41]; - T1 = $[42]; - t11 = $[43]; - t12 = $[44]; - t13 = $[45]; - t14 = $[46]; - t15 = $[47]; - t16 = $[48]; - t17 = $[49]; - t18 = $[50]; - t19 = $[51]; - t20 = $[52]; - t21 = $[53]; - t22 = $[54]; } - if (t22 !== Symbol.for("react.early_return_sentinel")) { - return t22; + + const renderBuiltInAgentsSection = ( + title = 'Built-in (always available):', + ) => { + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + return ( + + + {title} + + {builtInAgents.map(renderAgent)} + + ) } - let t23; - if ($[80] !== T0 || $[81] !== t11 || $[82] !== t12 || $[83] !== t13 || $[84] !== t14 || $[85] !== t15 || $[86] !== t16) { - t23 = {t15}{t16}; - $[80] = T0; - $[81] = t11; - $[82] = t12; - $[83] = t13; - $[84] = t14; - $[85] = t15; - $[86] = t16; - $[87] = t23; - } else { - t23 = $[87]; + + const renderAgentGroup = (title: string, groupAgents: ResolvedAgent[]) => { + if (!groupAgents.length) return null + + const folderPath = groupAgents[0]?.baseDir + + return ( + + + + {title} + + {folderPath && ({folderPath})} + + {groupAgents.map(agent => renderAgent(agent))} + + ) } - let t24; - if ($[88] !== T1 || $[89] !== t17 || $[90] !== t18 || $[91] !== t19 || $[92] !== t20 || $[93] !== t21 || $[94] !== t23) { - t24 = {t21}{t23}; - $[88] = T1; - $[89] = t17; - $[90] = t18; - $[91] = t19; - $[92] = t20; - $[93] = t21; - $[94] = t23; - $[95] = t24; - } else { - t24 = $[95]; + + const sourceTitle = getAgentSourceDisplayName(source) + + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + + const hasNoAgents = + !sortedAgents.length || + (source !== 'built-in' && !sortedAgents.some(a => a.source !== 'built-in')) + + if (hasNoAgents) { + return ( + + + {onCreateNew && {renderCreateNewOption()}} + + No agents found. Create specialized subagents that Claude can + delegate to. + + + Each subagent has its own context window, custom system prompt, and + specific tools. + + + Try creating: Code Reviewer, Code Simplifier, Security Reviewer, + Tech Lead, or UX Reviewer. + + {source !== 'built-in' && + sortedAgents.some(a => a.source === 'built-in') && ( + <> + + {renderBuiltInAgentsSection()} + + )} + + + ) } - return t24; -} -function _temp1(a_9) { - return a_9.source === "built-in"; -} -function _temp0(a_8) { - return a_8.source !== "built-in"; -} -function _temp9(g_0) { - return g_0.source !== "built-in"; -} -function _temp8(a_6) { - return !a_6.overriddenBy; -} -function _temp7(a_5) { - return a_5.source === "built-in"; -} -function _temp6(a_4) { - return a_4.source !== "built-in"; -} -function _temp5(a_3) { - return a_3.source === "built-in"; -} -function _temp4(a_2) { - return a_2.source === "built-in"; -} -function _temp3(g) { - return g.source !== "built-in"; -} -function _temp2(a) { - return a.source !== "built-in"; -} -function _temp(agent) { - return { - isOverridden: !!agent.overriddenBy, - overriddenBy: agent.overriddenBy || null - }; + + return ( + !a.overriddenBy)} agents`} + onCancel={onBack} + hideInputGuide + > + {changes && changes.length > 0 && ( + + {changes[changes.length - 1]} + + )} + + {onCreateNew && {renderCreateNewOption()}} + {source === 'all' ? ( + <> + {AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').map( + ({ label, source: groupSource }) => ( + + {renderAgentGroup( + label, + sortedAgents.filter(a => a.source === groupSource), + )} + + ), + )} + {builtInAgents.length > 0 && ( + + + Built-in agents (always available) + + {builtInAgents.map(renderAgent)} + + )} + + ) : source === 'built-in' ? ( + <> + + Built-in agents are provided by default and cannot be modified. + + + {sortedAgents.map(agent => renderAgent(agent))} + + + ) : ( + <> + {sortedAgents + .filter(a => a.source !== 'built-in') + .map(agent => renderAgent(agent))} + {sortedAgents.some(a => a.source === 'built-in') && ( + <> + + {renderBuiltInAgentsSection()} + + )} + + )} + + + ) } diff --git a/src/components/agents/AgentsMenu.tsx b/src/components/agents/AgentsMenu.tsx index 5a3f56eed..91de932b4 100644 --- a/src/components/agents/AgentsMenu.tsx +++ b/src/components/agents/AgentsMenu.tsx @@ -1,799 +1,369 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useMergedTools } from '../../hooks/useMergedTools.js'; -import { Box, Text } from '../../ink.js'; -import { useAppState, useSetAppState } from '../../state/AppState.js'; -import type { Tools } from '../../Tool.js'; -import { type ResolvedAgent, resolveAgentOverrides } from '../../tools/AgentTool/agentDisplay.js'; -import { type AgentDefinition, getActiveAgentsFromList } from '../../tools/AgentTool/loadAgentsDir.js'; -import { toError } from '../../utils/errors.js'; -import { logError } from '../../utils/log.js'; -import { Select } from '../CustomSelect/select.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { AgentDetail } from './AgentDetail.js'; -import { AgentEditor } from './AgentEditor.js'; -import { AgentNavigationFooter } from './AgentNavigationFooter.js'; -import { AgentsList } from './AgentsList.js'; -import { deleteAgentFromFile } from './agentFileUtils.js'; -import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js'; -import type { ModeState } from './types.js'; +import chalk from 'chalk' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import type { SettingSource } from 'src/utils/settings/constants.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useMergedTools } from '../../hooks/useMergedTools.js' +import { Box, Text } from '../../ink.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' +import type { Tools } from '../../Tool.js' +import { + type ResolvedAgent, + resolveAgentOverrides, +} from '../../tools/AgentTool/agentDisplay.js' +import { + type AgentDefinition, + getActiveAgentsFromList, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { toError } from '../../utils/errors.js' +import { logError } from '../../utils/log.js' +import { Select } from '../CustomSelect/select.js' +import { Dialog } from '../design-system/Dialog.js' +import { AgentDetail } from './AgentDetail.js' +import { AgentEditor } from './AgentEditor.js' +import { AgentNavigationFooter } from './AgentNavigationFooter.js' +import { AgentsList } from './AgentsList.js' +import { deleteAgentFromFile } from './agentFileUtils.js' +import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js' +import type { ModeState } from './types.js' + type Props = { - tools: Tools; - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function AgentsMenu(t0) { - const $ = _c(157); - const { - tools, - onExit - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - mode: "list-agents", - source: "all" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const [modeState, setModeState] = useState(t1); - const agentDefinitions = useAppState(_temp); - const mcpTools = useAppState(_temp2); - const toolPermissionContext = useAppState(_temp3); - const setAppState = useSetAppState(); - const { - allAgents, - activeAgents: agents - } = agentDefinitions; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [changes, setChanges] = useState(t2); - const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext); - useExitOnCtrlCDWithKeybindings(); - let t3; - if ($[2] !== allAgents) { - t3 = allAgents.filter(_temp4); - $[2] = allAgents; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== allAgents) { - t4 = allAgents.filter(_temp5); - $[4] = allAgents; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== allAgents) { - t5 = allAgents.filter(_temp6); - $[6] = allAgents; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== allAgents) { - t6 = allAgents.filter(_temp7); - $[8] = allAgents; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== allAgents) { - t7 = allAgents.filter(_temp8); - $[10] = allAgents; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== allAgents) { - t8 = allAgents.filter(_temp9); - $[12] = allAgents; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== allAgents) { - t9 = allAgents.filter(_temp0); - $[14] = allAgents; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== allAgents || $[17] !== t3 || $[18] !== t4 || $[19] !== t5 || $[20] !== t6 || $[21] !== t7 || $[22] !== t8 || $[23] !== t9) { - t10 = { - "built-in": t3, - userSettings: t4, - projectSettings: t5, - policySettings: t6, - localSettings: t7, - flagSettings: t8, - plugin: t9, - all: allAgents - }; - $[16] = allAgents; - $[17] = t3; - $[18] = t4; - $[19] = t5; - $[20] = t6; - $[21] = t7; - $[22] = t8; - $[23] = t9; - $[24] = t10; - } else { - t10 = $[24]; - } - const agentsBySource = t10; - let t11; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t11 = message => { - setChanges(prev => [...prev, message]); - setModeState({ - mode: "list-agents", - source: "all" - }); - }; - $[25] = t11; - } else { - t11 = $[25]; - } - const handleAgentCreated = t11; - let t12; - if ($[26] !== setAppState) { - t12 = async agent => { - ; + tools: Tools + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { + const [modeState, setModeState] = useState({ + mode: 'list-agents', + source: 'all', + }) + const agentDefinitions = useAppState(s => s.agentDefinitions) + const mcpTools = useAppState(s => s.mcp.tools) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const setAppState = useSetAppState() + const { allAgents, activeAgents: agents } = agentDefinitions + const [changes, setChanges] = useState([]) + + // Get MCP tools from app state and merge with local tools + const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext) + + useExitOnCtrlCDWithKeybindings() + + const agentsBySource: Record< + SettingSource | 'all' | 'built-in' | 'plugin', + AgentDefinition[] + > = useMemo( + () => ({ + 'built-in': allAgents.filter(a => a.source === 'built-in'), + userSettings: allAgents.filter(a => a.source === 'userSettings'), + projectSettings: allAgents.filter(a => a.source === 'projectSettings'), + policySettings: allAgents.filter(a => a.source === 'policySettings'), + localSettings: allAgents.filter(a => a.source === 'localSettings'), + flagSettings: allAgents.filter(a => a.source === 'flagSettings'), + plugin: allAgents.filter(a => a.source === 'plugin'), + all: allAgents, + }), + [allAgents], + ) + + const handleAgentCreated = useCallback((message: string) => { + setChanges(prev => [...prev, message]) + setModeState({ mode: 'list-agents', source: 'all' }) + }, []) + + const handleAgentDeleted = useCallback( + async (agent: AgentDefinition) => { try { - await deleteAgentFromFile(agent); + await deleteAgentFromFile(agent) setAppState(state => { - const allAgents_0 = state.agentDefinitions.allAgents.filter(a_6 => !(a_6.agentType === agent.agentType && a_6.source === agent.source)); + const allAgents = state.agentDefinitions.allAgents.filter( + a => + !(a.agentType === agent.agentType && a.source === agent.source), + ) return { ...state, agentDefinitions: { ...state.agentDefinitions, - allAgents: allAgents_0, - activeAgents: getActiveAgentsFromList(allAgents_0) - } - }; - }); - setChanges(prev_0 => [...prev_0, `Deleted agent: ${chalk.bold(agent.agentType)}`]); - setModeState({ - mode: "list-agents", - source: "all" - }); - } catch (t13) { - const error = t13; - logError(toError(error)); + allAgents, + activeAgents: getActiveAgentsFromList(allAgents), + }, + } + }) + + setChanges(prev => [ + ...prev, + `Deleted agent: ${chalk.bold(agent.agentType)}`, + ]) + // Go back to the agents list after deletion + setModeState({ mode: 'list-agents', source: 'all' }) + } catch (error) { + logError(toError(error)) } - }; - $[26] = setAppState; - $[27] = t12; - } else { - t12 = $[27]; - } - const handleAgentDeleted = t12; + }, + [setAppState], + ) + + // Render based on mode switch (modeState.mode) { - case "list-agents": - { - let t13; - if ($[28] !== agentsBySource || $[29] !== modeState.source) { - t13 = modeState.source === "all" ? [...agentsBySource["built-in"], ...agentsBySource.userSettings, ...agentsBySource.projectSettings, ...agentsBySource.localSettings, ...agentsBySource.policySettings, ...agentsBySource.flagSettings, ...agentsBySource.plugin] : agentsBySource[modeState.source]; - $[28] = agentsBySource; - $[29] = modeState.source; - $[30] = t13; - } else { - t13 = $[30]; - } - const agentsToShow = t13; - let t14; - if ($[31] !== agents || $[32] !== agentsToShow) { - t14 = resolveAgentOverrides(agentsToShow, agents); - $[31] = agents; - $[32] = agentsToShow; - $[33] = t14; - } else { - t14 = $[33]; - } - const allResolved = t14; - const resolvedAgents = allResolved; - let t15; - if ($[34] !== changes || $[35] !== onExit) { - t15 = () => { - const exitMessage = changes.length > 0 ? `Agent changes:\n${changes.join("\n")}` : undefined; - onExit(exitMessage ?? "Agents dialog dismissed", { - display: changes.length === 0 ? "system" : undefined - }); - }; - $[34] = changes; - $[35] = onExit; - $[36] = t15; - } else { - t15 = $[36]; - } - let t16; - if ($[37] !== modeState) { - t16 = agent_0 => setModeState({ - mode: "agent-menu", - agent: agent_0, - previousMode: modeState - }); - $[37] = modeState; - $[38] = t16; - } else { - t16 = $[38]; - } - let t17; - if ($[39] === Symbol.for("react.memo_cache_sentinel")) { - t17 = () => setModeState({ - mode: "create-agent" - }); - $[39] = t17; - } else { - t17 = $[39]; - } - let t18; - if ($[40] !== changes || $[41] !== modeState.source || $[42] !== resolvedAgents || $[43] !== t15 || $[44] !== t16) { - t18 = ; - $[40] = changes; - $[41] = modeState.source; - $[42] = resolvedAgents; - $[43] = t15; - $[44] = t16; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[46] = t19; - } else { - t19 = $[46]; - } - let t20; - if ($[47] !== t18) { - t20 = <>{t18}{t19}; - $[47] = t18; - $[48] = t20; - } else { - t20 = $[48]; - } - return t20; - } - case "create-agent": - { - let t13; - if ($[49] === Symbol.for("react.memo_cache_sentinel")) { - t13 = () => setModeState({ - mode: "list-agents", - source: "all" - }); - $[49] = t13; - } else { - t13 = $[49]; - } - let t14; - if ($[50] !== agents || $[51] !== mergedTools) { - t14 = ; - $[50] = agents; - $[51] = mergedTools; - $[52] = t14; - } else { - t14 = $[52]; - } - return t14; - } - case "agent-menu": - { - let t13; - if ($[53] !== allAgents || $[54] !== modeState.agent.agentType || $[55] !== modeState.agent.source) { - let t14; - if ($[57] !== modeState.agent.agentType || $[58] !== modeState.agent.source) { - t14 = a_9 => a_9.agentType === modeState.agent.agentType && a_9.source === modeState.agent.source; - $[57] = modeState.agent.agentType; - $[58] = modeState.agent.source; - $[59] = t14; - } else { - t14 = $[59]; - } - t13 = allAgents.find(t14); - $[53] = allAgents; - $[54] = modeState.agent.agentType; - $[55] = modeState.agent.source; - $[56] = t13; - } else { - t13 = $[56]; - } - const freshAgent_1 = t13; - const agentToUse = freshAgent_1 || modeState.agent; - const isEditable = agentToUse.source !== "built-in" && agentToUse.source !== "plugin" && agentToUse.source !== "flagSettings"; - let t14; - if ($[60] === Symbol.for("react.memo_cache_sentinel")) { - t14 = { - label: "View agent", - value: "view" - }; - $[60] = t14; - } else { - t14 = $[60]; - } - let t15; - if ($[61] !== isEditable) { - t15 = isEditable ? [{ - label: "Edit agent", - value: "edit" - }, { - label: "Delete agent", - value: "delete" - }] : []; - $[61] = isEditable; - $[62] = t15; - } else { - t15 = $[62]; - } - let t16; - if ($[63] === Symbol.for("react.memo_cache_sentinel")) { - t16 = { - label: "Back", - value: "back" - }; - $[63] = t16; - } else { - t16 = $[63]; - } - let t17; - if ($[64] !== t15) { - t17 = [t14, ...t15, t16]; - $[64] = t15; - $[65] = t17; - } else { - t17 = $[65]; - } - const menuItems = t17; - let t18; - if ($[66] !== agentToUse || $[67] !== modeState) { - t18 = value_0 => { - bb129: switch (value_0) { - case "view": - { - setModeState({ - mode: "view-agent", - agent: agentToUse, - previousMode: modeState.previousMode - }); - break bb129; - } - case "edit": - { - setModeState({ - mode: "edit-agent", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "delete": - { - setModeState({ - mode: "delete-confirm", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "back": - { - setModeState(modeState.previousMode); - } + case 'list-agents': { + const agentsToShow = + modeState.source === 'all' + ? [ + ...agentsBySource['built-in'], + ...agentsBySource['userSettings'], + ...agentsBySource['projectSettings'], + ...agentsBySource['localSettings'], + ...agentsBySource['policySettings'], + ...agentsBySource['flagSettings'], + ...agentsBySource['plugin'], + ] + : agentsBySource[modeState.source] + + // Resolve overrides and filter to the agents we want to show + const allResolved = resolveAgentOverrides(agentsToShow, agents) + const resolvedAgents: ResolvedAgent[] = allResolved + + return ( + <> + { + const exitMessage = + changes.length > 0 + ? `Agent changes:\n${changes.join('\n')}` + : undefined + onExit(exitMessage ?? 'Agents dialog dismissed', { + display: changes.length === 0 ? 'system' : undefined, + }) + }} + onSelect={agent => + setModeState({ + mode: 'agent-menu', + agent, + previousMode: modeState, + }) } - }; - $[66] = agentToUse; - $[67] = modeState; - $[68] = t18; - } else { - t18 = $[68]; - } - const handleMenuSelect = t18; - let t19; - if ($[69] !== modeState.previousMode) { - t19 = () => setModeState(modeState.previousMode); - $[69] = modeState.previousMode; - $[70] = t19; - } else { - t19 = $[70]; - } - let t20; - if ($[71] !== modeState.previousMode) { - t20 = () => setModeState(modeState.previousMode); - $[71] = modeState.previousMode; - $[72] = t20; - } else { - t20 = $[72]; - } - let t21; - if ($[73] !== handleMenuSelect || $[74] !== menuItems || $[75] !== t20) { - t21 = setModeState(modeState.previousMode)} + /> + {changes.length > 0 && ( + + {changes[changes.length - 1]} + + )} + + + + + ) + } + + case 'view-agent': { + // Always use fresh agent data from allAgents + const freshAgent = allAgents.find( + a => + a.agentType === modeState.agent.agentType && + a.source === modeState.agent.source, + ) + const agentToDisplay = freshAgent || modeState.agent + + return ( + <> + + setModeState({ + mode: 'agent-menu', + agent: agentToDisplay, + previousMode: modeState.previousMode, + }) } - }; - $[113] = modeState; - $[114] = t14; - } else { - t14 = $[114]; - } - let t15; - if ($[115] !== modeState.agent.agentType) { - t15 = Are you sure you want to delete the agent{" "}{modeState.agent.agentType}?; - $[115] = modeState.agent.agentType; - $[116] = t15; - } else { - t15 = $[116]; - } - let t16; - if ($[117] !== modeState.agent.source) { - t16 = Source: {modeState.agent.source}; - $[117] = modeState.agent.source; - $[118] = t16; - } else { - t16 = $[118]; - } - let t17; - if ($[119] !== handleAgentDeleted || $[120] !== modeState) { - t17 = value => { - if (value === "yes") { - handleAgentDeleted(modeState.agent); - } else { - if ("previousMode" in modeState) { - setModeState(modeState.previousMode); + hideInputGuide + > + + setModeState({ + mode: 'agent-menu', + agent: agentToDisplay, + previousMode: modeState.previousMode, + }) } - } - }; - $[119] = handleAgentDeleted; - $[120] = modeState; - $[121] = t17; - } else { - t17 = $[121]; - } - let t18; - if ($[122] !== modeState) { - t18 = () => { - if ("previousMode" in modeState) { - setModeState(modeState.previousMode); - } - }; - $[122] = modeState; - $[123] = t18; - } else { - t18 = $[123]; - } - let t19; - if ($[124] !== t17 || $[125] !== t18) { - t19 = { + if (value === 'yes') { + void handleAgentDeleted(modeState.agent) + } else { + if ('previousMode' in modeState) { + setModeState(modeState.previousMode) + } + } + }} + onCancel={() => { + if ('previousMode' in modeState) { + setModeState(modeState.previousMode) + } + }} + /> + + + + + ) + } + + case 'edit-agent': { + // Always use fresh agent data + const freshAgent = allAgents.find( + a => + a.agentType === modeState.agent.agentType && + a.source === modeState.agent.source, + ) + const agentToEdit = freshAgent || modeState.agent + + return ( + <> + setModeState(modeState.previousMode)} + hideInputGuide + > + { + handleAgentCreated(message) + setModeState(modeState.previousMode) + }} + onBack={() => setModeState(modeState.previousMode)} + /> + + + + ) + } + default: - { - return null; - } + return null } } -function _temp0(a_5) { - return a_5.source === "plugin"; -} -function _temp9(a_4) { - return a_4.source === "flagSettings"; -} -function _temp8(a_3) { - return a_3.source === "localSettings"; -} -function _temp7(a_2) { - return a_2.source === "policySettings"; -} -function _temp6(a_1) { - return a_1.source === "projectSettings"; -} -function _temp5(a_0) { - return a_0.source === "userSettings"; -} -function _temp4(a) { - return a.source === "built-in"; -} -function _temp3(s_1) { - return s_1.toolPermissionContext; -} -function _temp2(s_0) { - return s_0.mcp.tools; -} -function _temp(s) { - return s.agentDefinitions; -} diff --git a/src/components/agents/ColorPicker.tsx b/src/components/agents/ColorPicker.tsx index 2f372e3c0..8549424cd 100644 --- a/src/components/agents/ColorPicker.tsx +++ b/src/components/agents/ColorPicker.tsx @@ -1,111 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useState } from 'react'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import { capitalize } from '../../utils/stringUtils.js'; -type ColorOption = AgentColorName | 'automatic'; -const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS]; +import figures from 'figures' +import React, { useState } from 'react' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import { capitalize } from '../../utils/stringUtils.js' + +type ColorOption = AgentColorName | 'automatic' + +const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS] + type Props = { - agentName: string; - currentColor?: AgentColorName | 'automatic'; - onConfirm: (color: AgentColorName | undefined) => void; -}; -export function ColorPicker(t0) { - const $ = _c(17); - const { - agentName, - currentColor: t1, - onConfirm - } = t0; - const currentColor = t1 === undefined ? "automatic" : t1; - let t2; - if ($[0] !== currentColor) { - t2 = COLOR_OPTIONS.findIndex(opt => opt === currentColor); - $[0] = currentColor; - $[1] = t2; - } else { - t2 = $[1]; - } - const [selectedIndex, setSelectedIndex] = useState(Math.max(0, t2)); - let t3; - if ($[2] !== onConfirm || $[3] !== selectedIndex) { - t3 = e => { - if (e.key === "up") { - e.preventDefault(); - setSelectedIndex(_temp); - } else { - if (e.key === "down") { - e.preventDefault(); - setSelectedIndex(_temp2); - } else { - if (e.key === "return") { - e.preventDefault(); - const selected = COLOR_OPTIONS[selectedIndex]; - onConfirm(selected === "automatic" ? undefined : selected); - } - } - } - }; - $[2] = onConfirm; - $[3] = selectedIndex; - $[4] = t3; - } else { - t3 = $[4]; - } - const handleKeyDown = t3; - const selectedValue = COLOR_OPTIONS[selectedIndex]; - let t4; - if ($[5] !== selectedIndex) { - t4 = COLOR_OPTIONS.map((option, index) => { - const isSelected = index === selectedIndex; - return {isSelected ? figures.pointer : " "}{option === "automatic" ? Automatic color : {" "}{capitalize(option)}}; - }); - $[5] = selectedIndex; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t4) { - t5 = {t4}; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Preview: ; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== agentName || $[11] !== selectedValue) { - t7 = {t6}{selectedValue === undefined || selectedValue === "automatic" ? {" "}@{agentName}{" "} : {" "}@{agentName}{" "}}; - $[10] = agentName; - $[11] = selectedValue; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== handleKeyDown || $[14] !== t5 || $[15] !== t7) { - t8 = {t5}{t7}; - $[13] = handleKeyDown; - $[14] = t5; - $[15] = t7; - $[16] = t8; - } else { - t8 = $[16]; - } - return t8; + agentName: string + currentColor?: AgentColorName | 'automatic' + onConfirm: (color: AgentColorName | undefined) => void } -function _temp2(prev_0) { - return prev_0 < COLOR_OPTIONS.length - 1 ? prev_0 + 1 : 0; -} -function _temp(prev) { - return prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1; + +export function ColorPicker({ + agentName, + currentColor = 'automatic', + onConfirm, +}: Props): React.ReactNode { + const [selectedIndex, setSelectedIndex] = useState( + Math.max( + 0, + COLOR_OPTIONS.findIndex(opt => opt === currentColor), + ), + ) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'up') { + e.preventDefault() + setSelectedIndex(prev => (prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1)) + } else if (e.key === 'down') { + e.preventDefault() + setSelectedIndex(prev => (prev < COLOR_OPTIONS.length - 1 ? prev + 1 : 0)) + } else if (e.key === 'return') { + e.preventDefault() + const selected = COLOR_OPTIONS[selectedIndex] + onConfirm(selected === 'automatic' ? undefined : selected) + } + } + + const selectedValue = COLOR_OPTIONS[selectedIndex] + + return ( + + + {COLOR_OPTIONS.map((option, index) => { + const isSelected = index === selectedIndex + + return ( + + + {isSelected ? figures.pointer : ' '} + + + {option === 'automatic' ? ( + Automatic color + ) : ( + + + {' '} + + {capitalize(option)} + + )} + + ) + })} + + + + Preview: + {selectedValue === undefined || selectedValue === 'automatic' ? ( + + {' '} + @{agentName}{' '} + + ) : ( + + {' '} + @{agentName}{' '} + + )} + + + ) } diff --git a/src/components/agents/ModelSelector.tsx b/src/components/agents/ModelSelector.tsx index 9e186c7d2..4f1b2e8af 100644 --- a/src/components/agents/ModelSelector.tsx +++ b/src/components/agents/ModelSelector.tsx @@ -1,67 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getAgentModelOptions } from '../../utils/model/agent.js'; -import { Select } from '../CustomSelect/select.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { getAgentModelOptions } from '../../utils/model/agent.js' +import { Select } from '../CustomSelect/select.js' + interface ModelSelectorProps { - initialModel?: string; - onComplete: (model?: string) => void; - onCancel?: () => void; + initialModel?: string + onComplete: (model?: string) => void + onCancel?: () => void } -export function ModelSelector(t0) { - const $ = _c(11); - const { - initialModel, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== initialModel) { - bb0: { - const base = getAgentModelOptions(); - if (initialModel && !base.some(o => o.value === initialModel)) { - t1 = [{ + +export function ModelSelector({ + initialModel, + onComplete, + onCancel, +}: ModelSelectorProps): React.ReactNode { + const modelOptions = React.useMemo(() => { + const base = getAgentModelOptions() + // If the agent's current model is a full ID (e.g. 'claude-opus-4-5') not + // in the alias list, inject it as an option so it can round-trip through + // confirm without being overwritten. + if (initialModel && !base.some(o => o.value === initialModel)) { + return [ + { value: initialModel, label: initialModel, - description: "Current model (custom ID)" - }, ...base]; - break bb0; - } - t1 = base; + description: 'Current model (custom ID)', + }, + ...base, + ] } - $[0] = initialModel; - $[1] = t1; - } else { - t1 = $[1]; - } - const modelOptions = t1; - const defaultModel = initialModel ?? "sonnet"; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Model determines the agent's reasoning capabilities and speed.; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onCancel || $[4] !== onComplete) { - t3 = () => onCancel ? onCancel() : onComplete(undefined); - $[3] = onCancel; - $[4] = onComplete; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== defaultModel || $[7] !== modelOptions || $[8] !== onComplete || $[9] !== t3) { - t4 = {t2} (onCancel ? onCancel() : onComplete(undefined))} + /> + + ) } diff --git a/src/components/agents/ToolSelector.tsx b/src/components/agents/ToolSelector.tsx index 27766abae..9bc20b7d8 100644 --- a/src/components/agents/ToolSelector.tsx +++ b/src/components/agents/ToolSelector.tsx @@ -1,561 +1,478 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useCallback, useMemo, useState } from 'react'; -import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js'; -import { isMcpTool } from 'src/services/mcp/utils.js'; -import type { Tool, Tools } from 'src/Tool.js'; -import { filterToolsForAgent } from 'src/tools/AgentTool/agentToolUtils.js'; -import { AGENT_TOOL_NAME } from 'src/tools/AgentTool/constants.js'; -import { BashTool } from 'src/tools/BashTool/BashTool.js'; -import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; -import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js'; -import { FileReadTool } from 'src/tools/FileReadTool/FileReadTool.js'; -import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js'; -import { GlobTool } from 'src/tools/GlobTool/GlobTool.js'; -import { GrepTool } from 'src/tools/GrepTool/GrepTool.js'; -import { ListMcpResourcesTool } from 'src/tools/ListMcpResourcesTool/ListMcpResourcesTool.js'; -import { NotebookEditTool } from 'src/tools/NotebookEditTool/NotebookEditTool.js'; -import { ReadMcpResourceTool } from 'src/tools/ReadMcpResourceTool/ReadMcpResourceTool.js'; -import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js'; -import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js'; -import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js'; -import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.js'; -import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js'; -import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { count } from '../../utils/array.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Divider } from '../design-system/Divider.js'; +import figures from 'figures' +import React, { useCallback, useMemo, useState } from 'react' +import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js' +import { isMcpTool } from 'src/services/mcp/utils.js' +import type { Tool, Tools } from 'src/Tool.js' +import { filterToolsForAgent } from 'src/tools/AgentTool/agentToolUtils.js' +import { AGENT_TOOL_NAME } from 'src/tools/AgentTool/constants.js' +import { BashTool } from 'src/tools/BashTool/BashTool.js' +import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' +import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js' +import { FileReadTool } from 'src/tools/FileReadTool/FileReadTool.js' +import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js' +import { GlobTool } from 'src/tools/GlobTool/GlobTool.js' +import { GrepTool } from 'src/tools/GrepTool/GrepTool.js' +import { ListMcpResourcesTool } from 'src/tools/ListMcpResourcesTool/ListMcpResourcesTool.js' +import { NotebookEditTool } from 'src/tools/NotebookEditTool/NotebookEditTool.js' +import { ReadMcpResourceTool } from 'src/tools/ReadMcpResourceTool/ReadMcpResourceTool.js' +import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js' +import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js' +import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js' +import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.js' +import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js' +import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { count } from '../../utils/array.js' +import { plural } from '../../utils/stringUtils.js' +import { Divider } from '../design-system/Divider.js' + type Props = { - tools: Tools; - initialTools: string[] | undefined; - onComplete: (selectedTools: string[] | undefined) => void; - onCancel?: () => void; -}; + tools: Tools + initialTools: string[] | undefined + onComplete: (selectedTools: string[] | undefined) => void + onCancel?: () => void +} + type ToolBucket = { - name: string; - toolNames: Set; - isMcp?: boolean; -}; + name: string + toolNames: Set + isMcp?: boolean +} + type ToolBuckets = { - READ_ONLY: ToolBucket; - EDIT: ToolBucket; - EXECUTION: ToolBucket; - MCP: ToolBucket; - OTHER: ToolBucket; -}; + READ_ONLY: ToolBucket + EDIT: ToolBucket + EXECUTION: ToolBucket + MCP: ToolBucket + OTHER: ToolBucket +} + function getToolBuckets(): ToolBuckets { return { READ_ONLY: { name: 'Read-only tools', - toolNames: new Set([GlobTool.name, GrepTool.name, ExitPlanModeV2Tool.name, FileReadTool.name, WebFetchTool.name, TodoWriteTool.name, WebSearchTool.name, TaskStopTool.name, TaskOutputTool.name, ListMcpResourcesTool.name, ReadMcpResourceTool.name]) + toolNames: new Set([ + GlobTool.name, + GrepTool.name, + ExitPlanModeV2Tool.name, + FileReadTool.name, + WebFetchTool.name, + TodoWriteTool.name, + WebSearchTool.name, + TaskStopTool.name, + TaskOutputTool.name, + ListMcpResourcesTool.name, + ReadMcpResourceTool.name, + ]), }, EDIT: { name: 'Edit tools', - toolNames: new Set([FileEditTool.name, FileWriteTool.name, NotebookEditTool.name]) + toolNames: new Set([ + FileEditTool.name, + FileWriteTool.name, + NotebookEditTool.name, + ]), }, EXECUTION: { name: 'Execution tools', - toolNames: new Set([BashTool.name, (process.env.USER_TYPE) === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined)) + toolNames: new Set( + [ + BashTool.name, + process.env.USER_TYPE === 'ant' ? TungstenTool.name : undefined, + ].filter(n => n !== undefined), + ), }, MCP: { name: 'MCP tools', - toolNames: new Set(), - // Dynamic - no static list - isMcp: true + toolNames: new Set(), // Dynamic - no static list + isMcp: true, }, OTHER: { name: 'Other tools', - toolNames: new Set() // Dynamic - catch-all for uncategorized tools - } - }; + toolNames: new Set(), // Dynamic - catch-all for uncategorized tools + }, + } } // Helper to get MCP server buckets dynamically function getMcpServerBuckets(tools: Tools): Array<{ - serverName: string; - tools: Tools; + serverName: string + tools: Tools }> { - const serverMap = new Map(); + const serverMap = new Map() + tools.forEach(tool => { if (isMcpTool(tool)) { - const mcpInfo = mcpInfoFromString(tool.name); + const mcpInfo = mcpInfoFromString(tool.name) if (mcpInfo?.serverName) { - const existing = serverMap.get(mcpInfo.serverName) || []; - existing.push(tool); - serverMap.set(mcpInfo.serverName, existing); + const existing = serverMap.get(mcpInfo.serverName) || [] + existing.push(tool) + serverMap.set(mcpInfo.serverName, existing) } } - }); - return Array.from(serverMap.entries()).map(([serverName, tools]) => ({ - serverName, - tools - })).sort((a, b) => a.serverName.localeCompare(b.serverName)); + }) + + return Array.from(serverMap.entries()) + .map(([serverName, tools]) => ({ serverName, tools })) + .sort((a, b) => a.serverName.localeCompare(b.serverName)) } -export function ToolSelector(t0) { - const $ = _c(69); - const { - tools, - initialTools, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== tools) { - t1 = filterToolsForAgent({ - tools, - isBuiltIn: false, - isAsync: false - }); - $[0] = tools; - $[1] = t1; - } else { - t1 = $[1]; - } - const customAgentTools = t1; - let t2; - if ($[2] !== customAgentTools || $[3] !== initialTools) { - t2 = !initialTools || initialTools.includes("*") ? customAgentTools.map(_temp) : initialTools; - $[2] = customAgentTools; - $[3] = initialTools; - $[4] = t2; - } else { - t2 = $[4]; - } - const expandedInitialTools = t2; - const [selectedTools, setSelectedTools] = useState(expandedInitialTools); - const [focusIndex, setFocusIndex] = useState(0); - const [showIndividualTools, setShowIndividualTools] = useState(false); - let t3; - if ($[5] !== customAgentTools) { - t3 = new Set(customAgentTools.map(_temp2)); - $[5] = customAgentTools; - $[6] = t3; - } else { - t3 = $[6]; - } - const toolNames = t3; - let t4; - if ($[7] !== selectedTools || $[8] !== toolNames) { - let t5; - if ($[10] !== toolNames) { - t5 = name => toolNames.has(name); - $[10] = toolNames; - $[11] = t5; - } else { - t5 = $[11]; - } - t4 = selectedTools.filter(t5); - $[7] = selectedTools; - $[8] = toolNames; - $[9] = t4; - } else { - t4 = $[9]; - } - const validSelectedTools = t4; - let t5; - if ($[12] !== validSelectedTools) { - t5 = new Set(validSelectedTools); - $[12] = validSelectedTools; - $[13] = t5; - } else { - t5 = $[13]; + +export function ToolSelector({ + tools, + initialTools, + onComplete, + onCancel, +}: Props): React.ReactNode { + // Filter tools for custom agents + const customAgentTools = useMemo( + () => filterToolsForAgent({ tools, isBuiltIn: false, isAsync: false }), + [tools], + ) + + // Expand wildcard or undefined to explicit tool list for internal state + const expandedInitialTools = + !initialTools || initialTools.includes('*') + ? customAgentTools.map(t => t.name) + : initialTools + + const [selectedTools, setSelectedTools] = + useState(expandedInitialTools) + const [focusIndex, setFocusIndex] = useState(0) + const [showIndividualTools, setShowIndividualTools] = useState(false) + + // Filter selectedTools to only include tools that currently exist + // This handles MCP tools that disconnect while selected + const validSelectedTools = useMemo(() => { + const toolNames = new Set(customAgentTools.map(t => t.name)) + return selectedTools.filter(name => toolNames.has(name)) + }, [selectedTools, customAgentTools]) + + const selectedSet = new Set(validSelectedTools) + const isAllSelected = + validSelectedTools.length === customAgentTools.length && + customAgentTools.length > 0 + + const handleToggleTool = (toolName: string) => { + if (!toolName) return + + setSelectedTools(current => + current.includes(toolName) + ? current.filter(t => t !== toolName) + : [...current, toolName], + ) } - const selectedSet = t5; - const isAllSelected = validSelectedTools.length === customAgentTools.length && customAgentTools.length > 0; - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = toolName => { - if (!toolName) { - return; + + const handleToggleTools = (toolNames: string[], select: boolean) => { + setSelectedTools(current => { + if (select) { + const toolsToAdd = toolNames.filter(t => !current.includes(t)) + return [...current, ...toolsToAdd] + } else { + return current.filter(t => !toolNames.includes(t)) } - setSelectedTools(current => current.includes(toolName) ? current.filter(t_1 => t_1 !== toolName) : [...current, toolName]); - }; - $[14] = t6; - } else { - t6 = $[14]; - } - const handleToggleTool = t6; - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = (toolNames_0, select) => { - setSelectedTools(current_0 => { - if (select) { - const toolsToAdd = toolNames_0.filter(t_2 => !current_0.includes(t_2)); - return [...current_0, ...toolsToAdd]; - } else { - return current_0.filter(t_3 => !toolNames_0.includes(t_3)); - } - }); - }; - $[15] = t7; - } else { - t7 = $[15]; + }) } - const handleToggleTools = t7; - let t8; - if ($[16] !== customAgentTools || $[17] !== onComplete || $[18] !== validSelectedTools) { - t8 = () => { - const allToolNames = customAgentTools.map(_temp3); - const areAllToolsSelected = validSelectedTools.length === allToolNames.length && allToolNames.every(name_0 => validSelectedTools.includes(name_0)); - const finalTools = areAllToolsSelected ? undefined : validSelectedTools; - onComplete(finalTools); - }; - $[16] = customAgentTools; - $[17] = onComplete; - $[18] = validSelectedTools; - $[19] = t8; - } else { - t8 = $[19]; + + const handleConfirm = () => { + // Convert to undefined if all tools are selected (for cleaner file format) + const allToolNames = customAgentTools.map(t => t.name) + const areAllToolsSelected = + validSelectedTools.length === allToolNames.length && + allToolNames.every(name => validSelectedTools.includes(name)) + const finalTools = areAllToolsSelected ? undefined : validSelectedTools + + onComplete(finalTools) } - const handleConfirm = t8; - let buckets; - if ($[20] !== customAgentTools) { - const toolBuckets = getToolBuckets(); - buckets = { + + // Group tools by bucket + const toolsByBucket = useMemo(() => { + const toolBuckets = getToolBuckets() + const buckets = { readOnly: [] as Tool[], edit: [] as Tool[], execution: [] as Tool[], mcp: [] as Tool[], - other: [] as Tool[] - }; + other: [] as Tool[], + } + customAgentTools.forEach(tool => { + // Check if it's an MCP tool first if (isMcpTool(tool)) { - buckets.mcp.push(tool); - } else { - if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { - buckets.readOnly.push(tool); - } else { - if (toolBuckets.EDIT.toolNames.has(tool.name)) { - buckets.edit.push(tool); - } else { - if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { - buckets.execution.push(tool); - } else { - if (tool.name !== AGENT_TOOL_NAME) { - buckets.other.push(tool); - } - } - } - } + buckets.mcp.push(tool) + } else if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { + buckets.readOnly.push(tool) + } else if (toolBuckets.EDIT.toolNames.has(tool.name)) { + buckets.edit.push(tool) + } else if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { + buckets.execution.push(tool) + } else if (tool.name !== AGENT_TOOL_NAME) { + // Catch-all for uncategorized tools (except Task) + buckets.other.push(tool) } - }); - $[20] = customAgentTools; - $[21] = buckets; - } else { - buckets = $[21]; - } - const toolsByBucket = buckets; - let t9; - if ($[22] !== selectedSet) { - t9 = bucketTools => { - const selected = count(bucketTools, (t_5: Tool) => selectedSet.has(t_5.name)); - const needsSelection = selected < bucketTools.length; - return () => { - const toolNames_1 = bucketTools.map(_temp4); - handleToggleTools(toolNames_1, needsSelection); - }; - }; - $[22] = selectedSet; - $[23] = t9; - } else { - t9 = $[23]; + }) + + return buckets + }, [customAgentTools]) + + const createBucketToggleAction = (bucketTools: Tool[]) => { + const selected = count(bucketTools, t => selectedSet.has(t.name)) + const needsSelection = selected < bucketTools.length + + return () => { + const toolNames = bucketTools.map(t => t.name) + handleToggleTools(toolNames, needsSelection) + } } - const createBucketToggleAction = t9; - let navigableItems; - if ($[24] !== createBucketToggleAction || $[25] !== customAgentTools || $[26] !== focusIndex || $[27] !== handleConfirm || $[28] !== isAllSelected || $[29] !== selectedSet || $[30] !== showIndividualTools || $[31] !== toolsByBucket.edit || $[32] !== toolsByBucket.execution || $[33] !== toolsByBucket.mcp || $[34] !== toolsByBucket.other || $[35] !== toolsByBucket.readOnly) { - navigableItems = []; + + // Build navigable items (no separators) + const navigableItems: Array<{ + id: string + label: string + action: () => void + isContinue?: boolean + isToggle?: boolean + isHeader?: boolean + }> = [] + + // Continue button + navigableItems.push({ + id: 'continue', + label: 'Continue', + action: handleConfirm, + isContinue: true, + }) + + // All tools + navigableItems.push({ + id: 'bucket-all', + label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, + action: () => { + const allToolNames = customAgentTools.map(t => t.name) + handleToggleTools(allToolNames, !isAllSelected) + }, + }) + + // Create bucket menu items + const toolBuckets = getToolBuckets() + const bucketConfigs = [ + { + id: 'bucket-readonly', + name: toolBuckets.READ_ONLY.name, + tools: toolsByBucket.readOnly, + }, + { + id: 'bucket-edit', + name: toolBuckets.EDIT.name, + tools: toolsByBucket.edit, + }, + { + id: 'bucket-execution', + name: toolBuckets.EXECUTION.name, + tools: toolsByBucket.execution, + }, + { + id: 'bucket-mcp', + name: toolBuckets.MCP.name, + tools: toolsByBucket.mcp, + }, + { + id: 'bucket-other', + name: toolBuckets.OTHER.name, + tools: toolsByBucket.other, + }, + ] + + bucketConfigs.forEach(({ id, name, tools: bucketTools }) => { + if (bucketTools.length === 0) return + + const selected = count(bucketTools, t => selectedSet.has(t.name)) + const isFullySelected = selected === bucketTools.length + navigableItems.push({ - id: "continue", - label: "Continue", - action: handleConfirm, - isContinue: true - }); - let t10; - if ($[37] !== customAgentTools || $[38] !== isAllSelected) { - t10 = () => { - const allToolNames_0 = customAgentTools.map(_temp5); - handleToggleTools(allToolNames_0, !isAllSelected); - }; - $[37] = customAgentTools; - $[38] = isAllSelected; - $[39] = t10; - } else { - t10 = $[39]; + id, + label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name}`, + action: createBucketToggleAction(bucketTools), + }) + }) + + // Toggle button for individual tools + const toggleButtonIndex = navigableItems.length + navigableItems.push({ + id: 'toggle-individual', + label: showIndividualTools + ? 'Hide advanced options' + : 'Show advanced options', + action: () => { + setShowIndividualTools(!showIndividualTools) + // If hiding tools and focus is on an individual tool, move focus to toggle button + if (showIndividualTools && focusIndex > toggleButtonIndex) { + setFocusIndex(toggleButtonIndex) + } + }, + isToggle: true, + }) + + // Memoize MCP server buckets (must be outside conditional for hooks rules) + const mcpServerBuckets = useMemo( + () => getMcpServerBuckets(customAgentTools), + [customAgentTools], + ) + + // Individual tools (only if expanded) + if (showIndividualTools) { + // Add MCP server buckets if any exist + if (mcpServerBuckets.length > 0) { + navigableItems.push({ + id: 'mcp-servers-header', + label: 'MCP Servers:', + action: () => {}, // No action - just a header + isHeader: true, + }) + + mcpServerBuckets.forEach(({ serverName, tools: serverTools }) => { + const selected = count(serverTools, t => selectedSet.has(t.name)) + const isFullySelected = selected === serverTools.length + + navigableItems.push({ + id: `mcp-server-${serverName}`, + label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${serverName} (${serverTools.length} ${plural(serverTools.length, 'tool')})`, + action: () => { + const toolNames = serverTools.map(t => t.name) + handleToggleTools(toolNames, !isFullySelected) + }, + }) + }) + + // Add separator header before individual tools + navigableItems.push({ + id: 'tools-header', + label: 'Individual Tools:', + action: () => {}, + isHeader: true, + }) } - navigableItems.push({ - id: "bucket-all", - label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, - action: t10 - }); - const toolBuckets_0 = getToolBuckets(); - const bucketConfigs = [{ - id: "bucket-readonly", - name: toolBuckets_0.READ_ONLY.name, - tools: toolsByBucket.readOnly - }, { - id: "bucket-edit", - name: toolBuckets_0.EDIT.name, - tools: toolsByBucket.edit - }, { - id: "bucket-execution", - name: toolBuckets_0.EXECUTION.name, - tools: toolsByBucket.execution - }, { - id: "bucket-mcp", - name: toolBuckets_0.MCP.name, - tools: toolsByBucket.mcp - }, { - id: "bucket-other", - name: toolBuckets_0.OTHER.name, - tools: toolsByBucket.other - }]; - bucketConfigs.forEach(t11 => { - const { - id, - name: name_1, - tools: bucketTools_0 - } = t11; - if (bucketTools_0.length === 0) { - return; + + // Add individual tools + customAgentTools.forEach(tool => { + let displayName = tool.name + if (tool.name.startsWith('mcp__')) { + const mcpInfo = mcpInfoFromString(tool.name) + displayName = mcpInfo + ? `${mcpInfo.toolName} (${mcpInfo.serverName})` + : tool.name } - const selected_0 = count(bucketTools_0, (t_8: Tool) => selectedSet.has(t_8.name)); - const isFullySelected = selected_0 === bucketTools_0.length; + navigableItems.push({ - id, - label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name_1}`, - action: createBucketToggleAction(bucketTools_0) - }); - }); - const toggleButtonIndex = navigableItems.length; - let t12; - if ($[40] !== focusIndex || $[41] !== showIndividualTools || $[42] !== toggleButtonIndex) { - t12 = () => { - setShowIndividualTools(!showIndividualTools); - if (showIndividualTools && focusIndex > toggleButtonIndex) { - setFocusIndex(toggleButtonIndex); - } - }; - $[40] = focusIndex; - $[41] = showIndividualTools; - $[42] = toggleButtonIndex; - $[43] = t12; + id: `tool-${tool.name}`, + label: `${selectedSet.has(tool.name) ? figures.checkboxOn : figures.checkboxOff} ${displayName}`, + action: () => handleToggleTool(tool.name), + }) + }) + } + + const handleCancel = useCallback(() => { + if (onCancel) { + onCancel() } else { - t12 = $[43]; + onComplete(initialTools) } - navigableItems.push({ - id: "toggle-individual", - label: showIndividualTools ? "Hide advanced options" : "Show advanced options", - action: t12, - isToggle: true - }); - const mcpServerBuckets = getMcpServerBuckets(customAgentTools); - if (showIndividualTools) { - if (mcpServerBuckets.length > 0) { - navigableItems.push({ - id: "mcp-servers-header", - label: "MCP Servers:", - action: _temp6, - isHeader: true - }); - mcpServerBuckets.forEach(t13 => { - const { - serverName, - tools: serverTools - } = t13; - const selected_1 = count(serverTools, t_9 => selectedSet.has(t_9.name)); - const isFullySelected_0 = selected_1 === serverTools.length; - navigableItems.push({ - id: `mcp-server-${serverName}`, - label: `${isFullySelected_0 ? figures.checkboxOn : figures.checkboxOff} ${serverName} (${serverTools.length} ${plural(serverTools.length, "tool")})`, - action: () => { - const toolNames_2 = serverTools.map(_temp7); - handleToggleTools(toolNames_2, !isFullySelected_0); - } - }); - }); - navigableItems.push({ - id: "tools-header", - label: "Individual Tools:", - action: _temp8, - isHeader: true - }); + }, [onCancel, onComplete, initialTools]) + + useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + const item = navigableItems[focusIndex] + if (item && !item.isHeader) { + item.action() } - customAgentTools.forEach(tool_0 => { - let displayName = tool_0.name; - if (tool_0.name.startsWith("mcp__")) { - const mcpInfo = mcpInfoFromString(tool_0.name); - displayName = mcpInfo ? `${mcpInfo.toolName} (${mcpInfo.serverName})` : tool_0.name; - } - navigableItems.push({ - id: `tool-${tool_0.name}`, - label: `${selectedSet.has(tool_0.name) ? figures.checkboxOn : figures.checkboxOff} ${displayName}`, - action: () => handleToggleTool(tool_0.name) - }); - }); - } - $[24] = createBucketToggleAction; - $[25] = customAgentTools; - $[26] = focusIndex; - $[27] = handleConfirm; - $[28] = isAllSelected; - $[29] = selectedSet; - $[30] = showIndividualTools; - $[31] = toolsByBucket.edit; - $[32] = toolsByBucket.execution; - $[33] = toolsByBucket.mcp; - $[34] = toolsByBucket.other; - $[35] = toolsByBucket.readOnly; - $[36] = navigableItems; - } else { - navigableItems = $[36]; - } - let t10; - if ($[44] !== initialTools || $[45] !== onCancel || $[46] !== onComplete) { - t10 = () => { - if (onCancel) { - onCancel(); - } else { - onComplete(initialTools); + } else if (e.key === 'up') { + e.preventDefault() + let newIndex = focusIndex - 1 + // Skip headers when navigating up + while (newIndex > 0 && navigableItems[newIndex]?.isHeader) { + newIndex-- } - }; - $[44] = initialTools; - $[45] = onCancel; - $[46] = onComplete; - $[47] = t10; - } else { - t10 = $[47]; - } - const handleCancel = t10; - let t11; - if ($[48] === Symbol.for("react.memo_cache_sentinel")) { - t11 = { - context: "Confirmation" - }; - $[48] = t11; - } else { - t11 = $[48]; - } - useKeybinding("confirm:no", handleCancel, t11); - let t12; - if ($[49] !== focusIndex || $[50] !== navigableItems) { - t12 = e => { - if (e.key === "return") { - e.preventDefault(); - const item = navigableItems[focusIndex]; - if (item && !item.isHeader) { - item.action(); - } - } else { - if (e.key === "up") { - e.preventDefault(); - let newIndex = focusIndex - 1; - while (newIndex > 0 && navigableItems[newIndex]?.isHeader) { - newIndex--; - } - setFocusIndex(Math.max(0, newIndex)); - } else { - if (e.key === "down") { - e.preventDefault(); - let newIndex_0 = focusIndex + 1; - while (newIndex_0 < navigableItems.length - 1 && navigableItems[newIndex_0]?.isHeader) { - newIndex_0++; - } - setFocusIndex(Math.min(navigableItems.length - 1, newIndex_0)); - } - } + setFocusIndex(Math.max(0, newIndex)) + } else if (e.key === 'down') { + e.preventDefault() + let newIndex = focusIndex + 1 + // Skip headers when navigating down + while ( + newIndex < navigableItems.length - 1 && + navigableItems[newIndex]?.isHeader + ) { + newIndex++ } - }; - $[49] = focusIndex; - $[50] = navigableItems; - $[51] = t12; - } else { - t12 = $[51]; - } - const handleKeyDown = t12; - const t13 = focusIndex === 0 ? "suggestion" : undefined; - const t14 = focusIndex === 0; - const t15 = focusIndex === 0 ? `${figures.pointer} ` : " "; - let t16; - if ($[52] !== t13 || $[53] !== t14 || $[54] !== t15) { - t16 = {t15}[ Continue ]; - $[52] = t13; - $[53] = t14; - $[54] = t15; - $[55] = t16; - } else { - t16 = $[55]; - } - let t17; - if ($[56] === Symbol.for("react.memo_cache_sentinel")) { - t17 = ; - $[56] = t17; - } else { - t17 = $[56]; - } - let t18; - if ($[57] !== navigableItems) { - t18 = navigableItems.slice(1); - $[57] = navigableItems; - $[58] = t18; - } else { - t18 = $[58]; - } - let t19; - if ($[59] !== focusIndex || $[60] !== t18) { - t19 = t18.map((item_0, index) => { - const isCurrentlyFocused = index + 1 === focusIndex; - const isToggleButton = item_0.isToggle; - const isHeader = item_0.isHeader; - return {isToggleButton && }{isHeader && index > 0 && }{isHeader ? "" : isCurrentlyFocused ? `${figures.pointer} ` : " "}{isToggleButton ? `[ ${item_0.label} ]` : item_0.label}; - }); - $[59] = focusIndex; - $[60] = t18; - $[61] = t19; - } else { - t19 = $[61]; - } - const t20 = isAllSelected ? "All tools selected" : `${selectedSet.size} of ${customAgentTools.length} tools selected`; - let t21; - if ($[62] !== t20) { - t21 = {t20}; - $[62] = t20; - $[63] = t21; - } else { - t21 = $[63]; - } - let t22; - if ($[64] !== handleKeyDown || $[65] !== t16 || $[66] !== t19 || $[67] !== t21) { - t22 = {t16}{t17}{t19}{t21}; - $[64] = handleKeyDown; - $[65] = t16; - $[66] = t19; - $[67] = t21; - $[68] = t22; - } else { - t22 = $[68]; + setFocusIndex(Math.min(navigableItems.length - 1, newIndex)) + } } - return t22; -} -function _temp8() {} -function _temp7(t_10) { - return t_10.name; -} -function _temp6() {} -function _temp5(t_7) { - return t_7.name; -} -function _temp4(t_6) { - return t_6.name; -} -function _temp3(t_4) { - return t_4.name; -} -function _temp2(t_0) { - return t_0.name; -} -function _temp(t) { - return t.name; + + return ( + + {/* Render Continue button */} + + {focusIndex === 0 ? `${figures.pointer} ` : ' '}[ Continue ] + + + {/* Separator */} + + + {/* Render all navigable items except Continue (which is at index 0) */} + {navigableItems.slice(1).map((item, index) => { + const isCurrentlyFocused = index + 1 === focusIndex + const isToggleButton = item.isToggle + const isHeader = item.isHeader + + return ( + + {/* Add separator before toggle button */} + {isToggleButton && } + + {/* Add margin before headers */} + {isHeader && index > 0 && } + + + {isHeader + ? '' + : isCurrentlyFocused + ? `${figures.pointer} ` + : ' '} + {isToggleButton ? `[ ${item.label} ]` : item.label} + + + ) + })} + + + + {isAllSelected + ? 'All tools selected' + : `${selectedSet.size} of ${customAgentTools.length} tools selected`} + + + + ) } diff --git a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx index bad4005a4..b9959d91d 100644 --- a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx +++ b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx @@ -1,96 +1,68 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { isAutoMemoryEnabled } from '../../../memdir/paths.js'; -import type { Tools } from '../../../Tool.js'; -import type { AgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js'; -import { WizardProvider } from '../../wizard/index.js'; -import type { WizardStepComponent } from '../../wizard/types.js'; -import type { AgentWizardData } from './types.js'; -import { ColorStep } from './wizard-steps/ColorStep.js'; -import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js'; -import { DescriptionStep } from './wizard-steps/DescriptionStep.js'; -import { GenerateStep } from './wizard-steps/GenerateStep.js'; -import { LocationStep } from './wizard-steps/LocationStep.js'; -import { MemoryStep } from './wizard-steps/MemoryStep.js'; -import { MethodStep } from './wizard-steps/MethodStep.js'; -import { ModelStep } from './wizard-steps/ModelStep.js'; -import { PromptStep } from './wizard-steps/PromptStep.js'; -import { ToolsStep } from './wizard-steps/ToolsStep.js'; -import { TypeStep } from './wizard-steps/TypeStep.js'; +import React, { type ReactNode } from 'react' +import { isAutoMemoryEnabled } from '../../../memdir/paths.js' +import type { Tools } from '../../../Tool.js' +import type { AgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js' +import { WizardProvider } from '../../wizard/index.js' +import type { WizardStepComponent } from '../../wizard/types.js' +import type { AgentWizardData } from './types.js' +import { ColorStep } from './wizard-steps/ColorStep.js' +import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js' +import { DescriptionStep } from './wizard-steps/DescriptionStep.js' +import { GenerateStep } from './wizard-steps/GenerateStep.js' +import { LocationStep } from './wizard-steps/LocationStep.js' +import { MemoryStep } from './wizard-steps/MemoryStep.js' +import { MethodStep } from './wizard-steps/MethodStep.js' +import { ModelStep } from './wizard-steps/ModelStep.js' +import { PromptStep } from './wizard-steps/PromptStep.js' +import { ToolsStep } from './wizard-steps/ToolsStep.js' +import { TypeStep } from './wizard-steps/TypeStep.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onComplete: (message: string) => void; - onCancel: () => void; -}; -export function CreateAgentWizard(t0) { - const $ = _c(17); - const { - tools, - existingAgents, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== existingAgents) { - t1 = () => ; - $[0] = existingAgents; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== tools) { - t2 = () => ; - $[2] = tools; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = isAutoMemoryEnabled() ? [MemoryStep] : []; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== existingAgents || $[6] !== onComplete || $[7] !== tools) { - t4 = () => ; - $[5] = existingAgents; - $[6] = onComplete; - $[7] = tools; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t1 || $[10] !== t2 || $[11] !== t4) { - t5 = [LocationStep, MethodStep, GenerateStep, t1, PromptStep, DescriptionStep, t2, ModelStep, ColorStep, ...t3, t4]; - $[9] = t1; - $[10] = t2; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - const steps = t5; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {}; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== onCancel || $[15] !== steps) { - t7 = ; - $[14] = onCancel; - $[15] = steps; - $[16] = t7; - } else { - t7 = $[16]; - } - return t7; + tools: Tools + existingAgents: AgentDefinition[] + onComplete: (message: string) => void + onCancel: () => void +} + +export function CreateAgentWizard({ + tools, + existingAgents, + onComplete, + onCancel, +}: Props): ReactNode { + // Create step components with props + const steps: WizardStepComponent[] = [ + LocationStep, // 0 + MethodStep, // 1 + GenerateStep, // 2 + () => , // 3 + PromptStep, // 4 + DescriptionStep, // 5 + () => , // 6 + ModelStep, // 7 + ColorStep, // 8 + // MemoryStep is conditionally included based on GrowthBook gate + ...(isAutoMemoryEnabled() ? [MemoryStep] : []), + () => ( + + ), + ] + + return ( + + steps={steps} + initialData={{}} + onComplete={() => { + // Wizard completion is handled by ConfirmStepWrapper + // which calls onComplete with the appropriate message + }} + onCancel={onCancel} + title="Create new agent" + showStepCounter={false} + /> + ) } -function _temp() {} diff --git a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx index 9ec059371..adc35e27c 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx @@ -1,83 +1,64 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import type { AgentColorName } from '../../../../tools/AgentTool/agentColorManager.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ColorPicker } from '../../ColorPicker.js'; -import type { AgentWizardData } from '../types.js'; -export function ColorStep() { - const $ = _c(14); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Confirmation" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import type { AgentColorName } from '../../../../tools/AgentTool/agentColorManager.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ColorPicker } from '../../ColorPicker.js' +import type { AgentWizardData } from '../types.js' + +export function ColorStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + // Handle escape key - ColorPicker handles its own escape internally + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + + const handleConfirm = (color?: string): void => { + updateWizardData({ + selectedColor: color, + // Prepare final agent for confirmation + finalAgent: { + agentType: wizardData.agentType!, + whenToUse: wizardData.whenToUse!, + getSystemPrompt: () => wizardData.systemPrompt!, + tools: wizardData.selectedTools, + ...(wizardData.selectedModel + ? { model: wizardData.selectedModel } + : {}), + ...(color ? { color: color as AgentColorName } : {}), + source: wizardData.location!, + }, + }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== goNext || $[2] !== updateWizardData || $[3] !== wizardData.agentType || $[4] !== wizardData.location || $[5] !== wizardData.selectedModel || $[6] !== wizardData.selectedTools || $[7] !== wizardData.systemPrompt || $[8] !== wizardData.whenToUse) { - t1 = color => { - updateWizardData({ - selectedColor: color, - finalAgent: { - agentType: wizardData.agentType, - whenToUse: wizardData.whenToUse, - getSystemPrompt: () => wizardData.systemPrompt, - tools: wizardData.selectedTools, - ...(wizardData.selectedModel ? { - model: wizardData.selectedModel - } : {}), - ...(color ? { - color: color as AgentColorName - } : {}), - source: wizardData.location - } - }); - goNext(); - }; - $[1] = goNext; - $[2] = updateWizardData; - $[3] = wizardData.agentType; - $[4] = wizardData.location; - $[5] = wizardData.selectedModel; - $[6] = wizardData.selectedTools; - $[7] = wizardData.systemPrompt; - $[8] = wizardData.whenToUse; - $[9] = t1; - } else { - t1 = $[9]; - } - const handleConfirm = t1; - let t2; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[10] = t2; - } else { - t2 = $[10]; - } - const t3 = wizardData.agentType || "agent"; - let t4; - if ($[11] !== handleConfirm || $[12] !== t3) { - t4 = ; - $[11] = handleConfirm; - $[12] = t3; - $[13] = t4; - } else { - t4 = $[13]; - } - return t4; + + return ( + + + + + + } + > + + + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx index b696d861b..bfa035eb5 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx @@ -1,377 +1,168 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import type { KeyboardEvent } from '../../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; -import type { Tools } from '../../../../Tool.js'; -import { getMemoryScopeDisplay } from '../../../../tools/AgentTool/agentMemory.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { truncateToWidth } from '../../../../utils/format.js'; -import { getAgentModelDisplay } from '../../../../utils/model/agent.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js'; -import { validateAgent } from '../../validateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import type { KeyboardEvent } from '../../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' +import type { Tools } from '../../../../Tool.js' +import { getMemoryScopeDisplay } from '../../../../tools/AgentTool/agentMemory.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { truncateToWidth } from '../../../../utils/format.js' +import { getAgentModelDisplay } from '../../../../utils/model/agent.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js' +import { validateAgent } from '../../validateAgent.js' +import type { AgentWizardData } from '../types.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onSave: () => void; - onSaveAndEdit: () => void; - error?: string | null; -}; -export function ConfirmStep(t0) { - const $ = _c(88); - const { - tools, - existingAgents, - onSave, - onSaveAndEdit, - error - } = t0; - const { - goBack, - wizardData - } = useWizard(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", goBack, t1); - let t2; - if ($[1] !== onSave || $[2] !== onSaveAndEdit) { - t2 = e => { - if (e.key === "s" || e.key === "return") { - e.preventDefault(); - onSave(); - } else { - if (e.key === "e") { - e.preventDefault(); - onSaveAndEdit(); - } - } - }; - $[1] = onSave; - $[2] = onSaveAndEdit; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleKeyDown = t2; - const agent = wizardData.finalAgent; - let T0; - let T1; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - let t18; - let t19; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[4] !== agent || $[5] !== existingAgents || $[6] !== handleKeyDown || $[7] !== tools || $[8] !== wizardData.location) { - const validation = validateAgent(agent, tools, existingAgents); - let t20; - if ($[28] !== agent) { - t20 = truncateToWidth(agent.getSystemPrompt(), 240); - $[28] = agent; - $[29] = t20; - } else { - t20 = $[29]; - } - const systemPromptPreview = t20; - let t21; - if ($[30] !== agent.whenToUse) { - t21 = truncateToWidth(agent.whenToUse, 240); - $[30] = agent.whenToUse; - $[31] = t21; - } else { - t21 = $[31]; - } - const whenToUsePreview = t21; - const getToolsDisplay = _temp; - let t22; - if ($[32] !== agent.memory) { - t22 = isAutoMemoryEnabled() ? Memory: {getMemoryScopeDisplay(agent.memory)} : null; - $[32] = agent.memory; - $[33] = t22; - } else { - t22 = $[33]; - } - const memoryDisplayElement = t22; - T1 = WizardDialogLayout; - t18 = "Confirm and save"; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[34] = t19; - } else { - t19 = $[34]; - } - T0 = Box; - t3 = "column"; - t4 = 0; - t5 = true; - t6 = handleKeyDown; - let t23; - if ($[35] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Name; - $[35] = t23; - } else { - t23 = $[35]; - } - if ($[36] !== agent.agentType) { - t7 = {t23}: {agent.agentType}; - $[36] = agent.agentType; - $[37] = t7; - } else { - t7 = $[37]; - } - let t24; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t24 = Location; - $[38] = t24; - } else { - t24 = $[38]; - } - let t25; - if ($[39] !== agent.agentType || $[40] !== wizardData.location) { - t25 = getNewRelativeAgentFilePath({ - source: wizardData.location, - agentType: agent.agentType - }); - $[39] = agent.agentType; - $[40] = wizardData.location; - $[41] = t25; - } else { - t25 = $[41]; - } - if ($[42] !== t25) { - t8 = {t24}:{" "}{t25}; - $[42] = t25; - $[43] = t8; - } else { - t8 = $[43]; - } - let t26; - if ($[44] === Symbol.for("react.memo_cache_sentinel")) { - t26 = Tools; - $[44] = t26; - } else { - t26 = $[44]; - } - let t27; - if ($[45] !== agent.tools) { - t27 = getToolsDisplay(agent.tools); - $[45] = agent.tools; - $[46] = t27; - } else { - t27 = $[46]; - } - if ($[47] !== t27) { - t9 = {t26}: {t27}; - $[47] = t27; - $[48] = t9; - } else { - t9 = $[48]; - } - let t28; - if ($[49] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Model; - $[49] = t28; - } else { - t28 = $[49]; - } - let t29; - if ($[50] !== agent.model) { - t29 = getAgentModelDisplay(agent.model); - $[50] = agent.model; - $[51] = t29; - } else { - t29 = $[51]; - } - if ($[52] !== t29) { - t10 = {t28}: {t29}; - $[52] = t29; - $[53] = t10; - } else { - t10 = $[53]; - } - t11 = memoryDisplayElement; - if ($[54] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Description (tells Claude when to use this agent):; - $[54] = t12; - } else { - t12 = $[54]; - } - if ($[55] !== whenToUsePreview) { - t13 = {whenToUsePreview}; - $[55] = whenToUsePreview; - $[56] = t13; - } else { - t13 = $[56]; - } - if ($[57] === Symbol.for("react.memo_cache_sentinel")) { - t14 = System prompt:; - $[57] = t14; - } else { - t14 = $[57]; - } - if ($[58] !== systemPromptPreview) { - t15 = {systemPromptPreview}; - $[58] = systemPromptPreview; - $[59] = t15; - } else { - t15 = $[59]; - } - t16 = validation.warnings.length > 0 && Warnings:{validation.warnings.map(_temp2)}; - t17 = validation.errors.length > 0 && Errors:{validation.errors.map(_temp3)}; - $[4] = agent; - $[5] = existingAgents; - $[6] = handleKeyDown; - $[7] = tools; - $[8] = wizardData.location; - $[9] = T0; - $[10] = T1; - $[11] = t10; - $[12] = t11; - $[13] = t12; - $[14] = t13; - $[15] = t14; - $[16] = t15; - $[17] = t16; - $[18] = t17; - $[19] = t18; - $[20] = t19; - $[21] = t3; - $[22] = t4; - $[23] = t5; - $[24] = t6; - $[25] = t7; - $[26] = t8; - $[27] = t9; - } else { - T0 = $[9]; - T1 = $[10]; - t10 = $[11]; - t11 = $[12]; - t12 = $[13]; - t13 = $[14]; - t14 = $[15]; - t15 = $[16]; - t16 = $[17]; - t17 = $[18]; - t18 = $[19]; - t19 = $[20]; - t3 = $[21]; - t4 = $[22]; - t5 = $[23]; - t6 = $[24]; - t7 = $[25]; - t8 = $[26]; - t9 = $[27]; - } - let t20; - if ($[60] !== error) { - t20 = error && {error}; - $[60] = error; - $[61] = t20; - } else { - t20 = $[61]; - } - let t21; - if ($[62] === Symbol.for("react.memo_cache_sentinel")) { - t21 = s; - $[62] = t21; - } else { - t21 = $[62]; - } - let t22; - if ($[63] === Symbol.for("react.memo_cache_sentinel")) { - t22 = Enter; - $[63] = t22; - } else { - t22 = $[63]; - } - let t23; - if ($[64] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Press {t21} or {t22} to save,{" "}e to save and edit; - $[64] = t23; - } else { - t23 = $[64]; - } - let t24; - if ($[65] !== T0 || $[66] !== t10 || $[67] !== t11 || $[68] !== t12 || $[69] !== t13 || $[70] !== t14 || $[71] !== t15 || $[72] !== t16 || $[73] !== t17 || $[74] !== t20 || $[75] !== t3 || $[76] !== t4 || $[77] !== t5 || $[78] !== t6 || $[79] !== t7 || $[80] !== t8 || $[81] !== t9) { - t24 = {t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t20}{t23}; - $[65] = T0; - $[66] = t10; - $[67] = t11; - $[68] = t12; - $[69] = t13; - $[70] = t14; - $[71] = t15; - $[72] = t16; - $[73] = t17; - $[74] = t20; - $[75] = t3; - $[76] = t4; - $[77] = t5; - $[78] = t6; - $[79] = t7; - $[80] = t8; - $[81] = t9; - $[82] = t24; - } else { - t24 = $[82]; - } - let t25; - if ($[83] !== T1 || $[84] !== t18 || $[85] !== t19 || $[86] !== t24) { - t25 = {t24}; - $[83] = T1; - $[84] = t18; - $[85] = t19; - $[86] = t24; - $[87] = t25; - } else { - t25 = $[87]; - } - return t25; -} -function _temp3(err, i_0) { - return {" "}• {err}; -} -function _temp2(warning, i) { - return {" "}• {warning}; + tools: Tools + existingAgents: AgentDefinition[] + onSave: () => void + onSaveAndEdit: () => void + error?: string | null } -function _temp(toolNames) { - if (toolNames === undefined) { - return "All tools"; - } - if (toolNames.length === 0) { - return "None"; - } - if (toolNames.length === 1) { - return toolNames[0] || "None"; + +export function ConfirmStep({ + tools, + existingAgents, + onSave, + onSaveAndEdit, + error, +}: Props): ReactNode { + const { goBack, wizardData } = useWizard() + + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 's' || e.key === 'return') { + e.preventDefault() + onSave() + } else if (e.key === 'e') { + e.preventDefault() + onSaveAndEdit() + } } - if (toolNames.length === 2) { - return toolNames.join(" and "); + + const agent = wizardData.finalAgent! + const validation = validateAgent(agent, tools, existingAgents) + + const systemPromptPreview = truncateToWidth(agent.getSystemPrompt(), 240) + const whenToUsePreview = truncateToWidth(agent.whenToUse, 240) + + const getToolsDisplay = (toolNames: string[] | undefined): string => { + // undefined means "all tools" per PR semantic + if (toolNames === undefined) return 'All tools' + if (toolNames.length === 0) return 'None' + if (toolNames.length === 1) return toolNames[0] || 'None' + if (toolNames.length === 2) return toolNames.join(' and ') + return `${toolNames.slice(0, -1).join(', ')}, and ${toolNames[toolNames.length - 1]}` } - return `${toolNames.slice(0, -1).join(", ")}, and ${toolNames[toolNames.length - 1]}`; + + // Compute memory display outside JSX + const memoryDisplayElement = isAutoMemoryEnabled() ? ( + + Memory: {getMemoryScopeDisplay(agent.memory)} + + ) : null + + return ( + + + + + + } + > + + + Name: {agent.agentType} + + + Location:{' '} + {getNewRelativeAgentFilePath({ + source: wizardData.location!, + agentType: agent.agentType, + })} + + + Tools: {getToolsDisplay(agent.tools)} + + + Model: {getAgentModelDisplay(agent.model)} + + {memoryDisplayElement} + + + + Description (tells Claude when to use this agent): + + + + {whenToUsePreview} + + + + + System prompt: + + + + {systemPromptPreview} + + + {validation.warnings.length > 0 && ( + + Warnings: + {validation.warnings.map((warning, i) => ( + + {' '} + • {warning} + + ))} + + )} + + {validation.errors.length > 0 && ( + + Errors: + {validation.errors.map((err, i) => ( + + {' '} + • {err} + + ))} + + )} + + {error && ( + + {error} + + )} + + + + Press s or Enter to save,{' '} + e to save and edit + + + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx index 0def7267b..013de633a 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx @@ -1,73 +1,112 @@ -import chalk from 'chalk'; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useSetAppState } from 'src/state/AppState.js'; -import type { Tools } from '../../../../Tool.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { getActiveAgentsFromList } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { editFileInEditor } from '../../../../utils/promptEditor.js'; -import { useWizard } from '../../../wizard/index.js'; -import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js'; -import type { AgentWizardData } from '../types.js'; -import { ConfirmStep } from './ConfirmStep.js'; +import chalk from 'chalk' +import React, { type ReactNode, useCallback, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { useSetAppState } from 'src/state/AppState.js' +import type { Tools } from '../../../../Tool.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { getActiveAgentsFromList } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { editFileInEditor } from '../../../../utils/promptEditor.js' +import { useWizard } from '../../../wizard/index.js' +import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js' +import type { AgentWizardData } from '../types.js' +import { ConfirmStep } from './ConfirmStep.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onComplete: (message: string) => void; -}; + tools: Tools + existingAgents: AgentDefinition[] + onComplete: (message: string) => void +} + export function ConfirmStepWrapper({ tools, existingAgents, - onComplete + onComplete, }: Props): ReactNode { - const { - wizardData - } = useWizard(); - const [saveError, setSaveError] = useState(null); - const setAppState = useSetAppState(); - const saveAgent = useCallback(async (openInEditor: boolean): Promise => { - if (!wizardData?.finalAgent) return; - try { - await saveAgentToFile(wizardData.location!, wizardData.finalAgent.agentType, wizardData.finalAgent.whenToUse, wizardData.finalAgent.tools, wizardData.finalAgent.getSystemPrompt(), true, wizardData.finalAgent.color, wizardData.finalAgent.model, wizardData.finalAgent.memory); - setAppState(state => { - if (!wizardData.finalAgent) return state; - const allAgents = state.agentDefinitions.allAgents.concat(wizardData.finalAgent); - return { - ...state, - agentDefinitions: { - ...state.agentDefinitions, - activeAgents: getActiveAgentsFromList(allAgents), - allAgents + const { wizardData } = useWizard() + const [saveError, setSaveError] = useState(null) + const setAppState = useSetAppState() + + const saveAgent = useCallback( + async (openInEditor: boolean): Promise => { + if (!wizardData?.finalAgent) return + + try { + await saveAgentToFile( + wizardData.location!, + wizardData.finalAgent.agentType, + wizardData.finalAgent.whenToUse, + wizardData.finalAgent.tools, + wizardData.finalAgent.getSystemPrompt(), + true, + wizardData.finalAgent.color, + wizardData.finalAgent.model, + wizardData.finalAgent.memory, + ) + + setAppState(state => { + if (!wizardData.finalAgent) return state + + const allAgents = state.agentDefinitions.allAgents.concat( + wizardData.finalAgent, + ) + return { + ...state, + agentDefinitions: { + ...state.agentDefinitions, + activeAgents: getActiveAgentsFromList(allAgents), + allAgents, + }, } - }; - }); - if (openInEditor) { - const filePath = getNewAgentFilePath({ + }) + + if (openInEditor) { + const filePath = getNewAgentFilePath({ + source: wizardData.location!, + agentType: wizardData.finalAgent.agentType, + }) + await editFileInEditor(filePath) + } + + logEvent('tengu_agent_created', { + agent_type: wizardData.finalAgent.agentType, + generation_method: wizardData.wasGenerated ? 'generated' : 'manual', source: wizardData.location!, - agentType: wizardData.finalAgent.agentType - }); - await editFileInEditor(filePath); + tool_count: wizardData.finalAgent.tools?.length ?? 'all', + has_custom_model: !!wizardData.finalAgent.model, + has_custom_color: !!wizardData.finalAgent.color, + has_memory: !!wizardData.finalAgent.memory, + memory_scope: wizardData.finalAgent.memory ?? 'none', + ...(openInEditor ? { opened_in_editor: true } : {}), + } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + + const message = openInEditor + ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + + `If you made edits, restart to load the latest version.` + : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}` + onComplete(message) + } catch (err) { + setSaveError( + err instanceof Error ? err.message : 'Failed to save agent', + ) } - logEvent('tengu_agent_created', { - agent_type: wizardData.finalAgent.agentType, - generation_method: wizardData.wasGenerated ? 'generated' : 'manual', - source: wizardData.location!, - tool_count: wizardData.finalAgent.tools?.length ?? 'all', - has_custom_model: !!wizardData.finalAgent.model, - has_custom_color: !!wizardData.finalAgent.color, - has_memory: !!wizardData.finalAgent.memory, - memory_scope: wizardData.finalAgent.memory ?? 'none', - ...(openInEditor ? { - opened_in_editor: true - } : {}) - } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS); - const message = openInEditor ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + `If you made edits, restart to load the latest version.` : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}`; - onComplete(message); - } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save agent'); - } - }, [wizardData, onComplete, setAppState]); - const handleSave = useCallback(() => saveAgent(false), [saveAgent]); - const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]); - return ; + }, + [wizardData, onComplete, setAppState], + ) + + const handleSave = useCallback(() => saveAgent(false), [saveAgent]) + + const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]) + + return ( + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx index 504ff0fd1..1138cc3d3 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx @@ -1,122 +1,94 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function DescriptionStep() { - const $ = _c(18); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || ""); - const [cursorOffset, setCursorOffset] = useState(whenToUse.length); - const [error, setError] = useState(null); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode, useCallback, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function DescriptionStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || '') + const [cursorOffset, setCursorOffset] = useState(whenToUse.length) + const [error, setError] = useState(null) + + // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleExternalEditor = useCallback(async () => { + const result = await editPromptInEditor(whenToUse) + if (result.content !== null) { + setWhenToUse(result.content) + setCursorOffset(result.content.length) + } + }, [whenToUse]) + + useKeybinding('chat:externalEditor', handleExternalEditor, { + context: 'Chat', + }) + + const handleSubmit = (value: string): void => { + const trimmedValue = value.trim() + if (!trimmedValue) { + setError('Description is required') + return + } + + setError(null) + updateWizardData({ whenToUse: trimmedValue }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== whenToUse) { - t1 = async () => { - const result = await editPromptInEditor(whenToUse); - if (result.content !== null) { - setWhenToUse(result.content); - setCursorOffset(result.content.length); + + return ( + + + + + + } - }; - $[1] = whenToUse; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleExternalEditor = t1; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Chat" - }; - $[3] = t2; - } else { - t2 = $[3]; - } - useKeybinding("chat:externalEditor", handleExternalEditor, t2); - let t3; - if ($[4] !== goNext || $[5] !== updateWizardData) { - t3 = value => { - const trimmedValue = value.trim(); - if (!trimmedValue) { - setError("Description is required"); - return; - } - setError(null); - updateWizardData({ - whenToUse: trimmedValue - }); - goNext(); - }; - $[4] = goNext; - $[5] = updateWizardData; - $[6] = t3; - } else { - t3 = $[6]; - } - const handleSubmit = t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = When should Claude use this agent?; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== cursorOffset || $[10] !== handleSubmit || $[11] !== whenToUse) { - t6 = ; - $[9] = cursorOffset; - $[10] = handleSubmit; - $[11] = whenToUse; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== error) { - t7 = error && {error}; - $[13] = error; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== t6 || $[16] !== t7) { - t8 = {t5}{t6}{t7}; - $[15] = t6; - $[16] = t7; - $[17] = t8; - } else { - t8 = $[17]; - } - return t8; + > + + When should Claude use this agent? + + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx index 892833bc3..1cb7ae69d 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx @@ -1,58 +1,57 @@ -import { APIUserAbortError } from '@anthropic-ai/sdk'; -import React, { type ReactNode, useCallback, useRef, useState } from 'react'; -import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { createAbortController } from '../../../../utils/abortController.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { Spinner } from '../../../Spinner.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { generateAgent } from '../../generateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import { APIUserAbortError } from '@anthropic-ai/sdk' +import React, { type ReactNode, useCallback, useRef, useState } from 'react' +import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { createAbortController } from '../../../../utils/abortController.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { Spinner } from '../../../Spinner.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { generateAgent } from '../../generateAgent.js' +import type { AgentWizardData } from '../types.js' + export function GenerateStep(): ReactNode { - const { - updateWizardData, - goBack, - goToStep, - wizardData - } = useWizard(); - const [prompt, setPrompt] = useState(wizardData.generationPrompt || ''); - const [isGenerating, setIsGenerating] = useState(false); - const [error, setError] = useState(null); - const [cursorOffset, setCursorOffset] = useState(prompt.length); - const model = useMainLoopModel(); - const abortControllerRef = useRef(null); + const { updateWizardData, goBack, goToStep, wizardData } = + useWizard() + const [prompt, setPrompt] = useState(wizardData.generationPrompt || '') + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState(null) + const [cursorOffset, setCursorOffset] = useState(prompt.length) + const model = useMainLoopModel() + const abortControllerRef = useRef(null) // Cancel generation when escape pressed during generation const handleCancelGeneration = useCallback(() => { if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - setIsGenerating(false); - setError('Generation cancelled'); + abortControllerRef.current.abort() + abortControllerRef.current = null + setIsGenerating(false) + setError('Generation cancelled') } - }, []); + }, []) // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleCancelGeneration, { context: 'Settings', - isActive: isGenerating - }); + isActive: isGenerating, + }) + const handleExternalEditor = useCallback(async () => { - const result = await editPromptInEditor(prompt); + const result = await editPromptInEditor(prompt) if (result.content !== null) { - setPrompt(result.content); - setCursorOffset(result.content.length); + setPrompt(result.content) + setCursorOffset(result.content.length) } - }, [prompt]); + }, [prompt]) + useKeybinding('chat:externalEditor', handleExternalEditor, { context: 'Chat', - isActive: !isGenerating - }); + isActive: !isGenerating, + }) // Go back when escape pressed while not generating const handleGoBack = useCallback(() => { @@ -62,81 +61,141 @@ export function GenerateStep(): ReactNode { systemPrompt: '', whenToUse: '', generatedAgent: undefined, - wasGenerated: false - }); - setPrompt(''); - setError(null); - goBack(); - }, [updateWizardData, goBack]); + wasGenerated: false, + }) + setPrompt('') + setError(null) + goBack() + }, [updateWizardData, goBack]) // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleGoBack, { context: 'Settings', - isActive: !isGenerating - }); + isActive: !isGenerating, + }) + const handleGenerate = async (): Promise => { - const trimmedPrompt = prompt.trim(); + const trimmedPrompt = prompt.trim() if (!trimmedPrompt) { - setError('Please describe what the agent should do'); - return; + setError('Please describe what the agent should do') + return } - setError(null); - setIsGenerating(true); + + setError(null) + setIsGenerating(true) updateWizardData({ generationPrompt: trimmedPrompt, - isGenerating: true - }); + isGenerating: true, + }) // Create abort controller for this generation - const controller = createAbortController(); - abortControllerRef.current = controller; + const controller = createAbortController() + abortControllerRef.current = controller + try { - const generated = await generateAgent(trimmedPrompt, model, [], controller.signal); + const generated = await generateAgent( + trimmedPrompt, + model, + [], + controller.signal, + ) + updateWizardData({ agentType: generated.identifier, whenToUse: generated.whenToUse, systemPrompt: generated.systemPrompt, generatedAgent: generated, isGenerating: false, - wasGenerated: true - }); + wasGenerated: true, + }) // Skip directly to ToolsStep (index 6) - matching original flow - goToStep(6); + goToStep(6) } catch (err) { // Don't show error if it was cancelled (already set in escape handler) if (err instanceof APIUserAbortError) { // User cancelled - no error to show - } else if (err instanceof Error && !err.message.includes('No assistant message found')) { - setError(err.message || 'Failed to generate agent'); + } else if ( + err instanceof Error && + !err.message.includes('No assistant message found') + ) { + setError(err.message || 'Failed to generate agent') } - updateWizardData({ - isGenerating: false - }); + updateWizardData({ isGenerating: false }) } finally { - setIsGenerating(false); - abortControllerRef.current = null; + setIsGenerating(false) + abortControllerRef.current = null } - }; - const subtitle = 'Describe what this agent should do and when it should be used (be comprehensive for best results)'; + } + + const subtitle = + 'Describe what this agent should do and when it should be used (be comprehensive for best results)' + if (isGenerating) { - return }> + return ( + + } + > Generating agent from description... - ; + + ) } - return - - - - }> + + return ( + + + + + + } + > - {error && + {error && ( + {error} - } - + + )} + - ; + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx index cf0a544d5..a7fd0a2bc 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx @@ -1,79 +1,55 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import type { SettingSource } from '../../../../utils/settings/constants.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function LocationStep() { - const $ = _c(11); - const { - goNext, - updateWizardData, - cancel - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - label: "Project (.claude/agents/)", - value: "projectSettings" as SettingSource - }; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = [t0, { - label: "Personal (~/.claude/agents/)", - value: "userSettings" as SettingSource - }]; - $[1] = t1; - } else { - t1 = $[1]; - } - const locationOptions = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== goNext || $[4] !== updateWizardData) { - t3 = value => { - updateWizardData({ - location: value as SettingSource - }); - goNext(); - }; - $[3] = goNext; - $[4] = updateWizardData; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== cancel) { - t4 = () => cancel(); - $[6] = cancel; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = { + updateWizardData({ location: value as SettingSource }) + goNext() + }} + onCancel={() => cancel()} + /> + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx index fc5cad0f3..3c987cf77 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx @@ -1,112 +1,102 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; -import { type AgentMemoryScope, loadAgentMemoryPrompt } from '../../../../tools/AgentTool/agentMemory.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' +import { + type AgentMemoryScope, + loadAgentMemoryPrompt, +} from '../../../../tools/AgentTool/agentMemory.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Select } from '../../../CustomSelect/select.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + type MemoryOption = { - label: string; - value: AgentMemoryScope | 'none'; -}; -export function MemoryStep() { - const $ = _c(13); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Confirmation" - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useKeybinding("confirm:no", goBack, t0); - const isUserScope = wizardData.location === "userSettings"; - let t1; - if ($[1] !== isUserScope) { - t1 = isUserScope ? [{ - label: "User scope (~/.claude/agent-memory/) (Recommended)", - value: "user" - }, { - label: "None (no persistent memory)", - value: "none" - }, { - label: "Project scope (.claude/agent-memory/)", - value: "project" - }, { - label: "Local scope (.claude/agent-memory-local/)", - value: "local" - }] : [{ - label: "Project scope (.claude/agent-memory/) (Recommended)", - value: "project" - }, { - label: "None (no persistent memory)", - value: "none" - }, { - label: "User scope (~/.claude/agent-memory/)", - value: "user" - }, { - label: "Local scope (.claude/agent-memory-local/)", - value: "local" - }]; - $[1] = isUserScope; - $[2] = t1; - } else { - t1 = $[2]; - } - const memoryOptions = t1; - let t2; - if ($[3] !== goNext || $[4] !== updateWizardData || $[5] !== wizardData.finalAgent || $[6] !== wizardData.systemPrompt) { - t2 = value => { - const memory = value === "none" ? undefined : value as AgentMemoryScope; - const agentType = wizardData.finalAgent?.agentType; - updateWizardData({ - selectedMemory: memory, - finalAgent: wizardData.finalAgent ? { - ...wizardData.finalAgent, - memory, - getSystemPrompt: isAutoMemoryEnabled() && memory && agentType ? () => wizardData.systemPrompt + "\n\n" + loadAgentMemoryPrompt(agentType, memory) : () => wizardData.systemPrompt - } : undefined - }); - goNext(); - }; - $[3] = goNext; - $[4] = updateWizardData; - $[5] = wizardData.finalAgent; - $[6] = wizardData.systemPrompt; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleSelect = t2; - let t3; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[8] = t3; - } else { - t3 = $[8]; - } - let t4; - if ($[9] !== goBack || $[10] !== handleSelect || $[11] !== memoryOptions) { - t4 = + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx index 5e9f40418..8f8252e12 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx @@ -1,79 +1,65 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function MethodStep() { - const $ = _c(11); - const { - goNext, - goBack, - updateWizardData, - goToStep - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = [{ - label: "Generate with Claude (recommended)", - value: "generate" - }, { - label: "Manual configuration", - value: "manual" - }]; - $[0] = t0; - } else { - t0 = $[0]; - } - const methodOptions = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== goNext || $[3] !== goToStep || $[4] !== updateWizardData) { - t2 = value => { - const method = value as 'generate' | 'manual'; - updateWizardData({ - method, - wasGenerated: method === "generate" - }); - if (method === "generate") { - goNext(); - } else { - goToStep(3); +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Select } from '../../../CustomSelect/select.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function MethodStep(): ReactNode { + const { goNext, goBack, updateWizardData, goToStep } = + useWizard() + + const methodOptions = [ + { + label: 'Generate with Claude (recommended)', + value: 'generate', + }, + { + label: 'Manual configuration', + value: 'manual', + }, + ] + + return ( + + + + + } - }; - $[2] = goNext; - $[3] = goToStep; - $[4] = updateWizardData; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== goBack) { - t3 = () => goBack(); - $[6] = goBack; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== t2 || $[9] !== t3) { - t4 = { + const method = value as 'generate' | 'manual' + updateWizardData({ + method, + wasGenerated: method === 'generate', + }) + + // Dynamic navigation based on method + if (method === 'generate') { + goNext() // Go to GenerateStep (index 2) + } else { + goToStep(3) // Skip to TypeStep (index 3) + } + }} + onCancel={() => goBack()} + /> + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx index b53ffd683..586cc6cc8 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx @@ -1,51 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ModelSelector } from '../../ModelSelector.js'; -import type { AgentWizardData } from '../types.js'; -export function ModelStep() { - const $ = _c(8); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] !== goNext || $[1] !== updateWizardData) { - t0 = model => { - updateWizardData({ - selectedModel: model - }); - goNext(); - }; - $[0] = goNext; - $[1] = updateWizardData; - $[2] = t0; - } else { - t0 = $[2]; +import React, { type ReactNode } from 'react' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ModelSelector } from '../../ModelSelector.js' +import type { AgentWizardData } from '../types.js' + +export function ModelStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + const handleComplete = (model?: string): void => { + updateWizardData({ selectedModel: model }) + goNext() } - const handleComplete = t0; - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== wizardData.selectedModel) { - t2 = ; - $[4] = goBack; - $[5] = handleComplete; - $[6] = wizardData.selectedModel; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; + + return ( + + + + + + } + > + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx index 1b8224c28..4d6747520 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx @@ -1,127 +1,97 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function PromptStep() { - const $ = _c(20); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [systemPrompt, setSystemPrompt] = useState(wizardData.systemPrompt || ""); - const [cursorOffset, setCursorOffset] = useState(systemPrompt.length); - const [error, setError] = useState(null); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode, useCallback, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function PromptStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [systemPrompt, setSystemPrompt] = useState( + wizardData.systemPrompt || '', + ) + const [cursorOffset, setCursorOffset] = useState(systemPrompt.length) + const [error, setError] = useState(null) + + // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleExternalEditor = useCallback(async () => { + const result = await editPromptInEditor(systemPrompt) + if (result.content !== null) { + setSystemPrompt(result.content) + setCursorOffset(result.content.length) + } + }, [systemPrompt]) + + useKeybinding('chat:externalEditor', handleExternalEditor, { + context: 'Chat', + }) + + const handleSubmit = (): void => { + const trimmedPrompt = systemPrompt.trim() + if (!trimmedPrompt) { + setError('System prompt is required') + return + } + + setError(null) + updateWizardData({ systemPrompt: trimmedPrompt }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== systemPrompt) { - t1 = async () => { - const result = await editPromptInEditor(systemPrompt); - if (result.content !== null) { - setSystemPrompt(result.content); - setCursorOffset(result.content.length); + + return ( + + + + + + } - }; - $[1] = systemPrompt; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleExternalEditor = t1; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Chat" - }; - $[3] = t2; - } else { - t2 = $[3]; - } - useKeybinding("chat:externalEditor", handleExternalEditor, t2); - let t3; - if ($[4] !== goNext || $[5] !== systemPrompt || $[6] !== updateWizardData) { - t3 = () => { - const trimmedPrompt = systemPrompt.trim(); - if (!trimmedPrompt) { - setError("System prompt is required"); - return; - } - setError(null); - updateWizardData({ - systemPrompt: trimmedPrompt - }); - goNext(); - }; - $[4] = goNext; - $[5] = systemPrompt; - $[6] = updateWizardData; - $[7] = t3; - } else { - t3 = $[7]; - } - const handleSubmit = t3; - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Enter the system prompt for your agent:; - t6 = Be comprehensive for best results; - $[9] = t5; - $[10] = t6; - } else { - t5 = $[9]; - t6 = $[10]; - } - let t7; - if ($[11] !== cursorOffset || $[12] !== handleSubmit || $[13] !== systemPrompt) { - t7 = ; - $[11] = cursorOffset; - $[12] = handleSubmit; - $[13] = systemPrompt; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== error) { - t8 = error && {error}; - $[15] = error; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== t7 || $[18] !== t8) { - t9 = {t5}{t6}{t7}{t8}; - $[17] = t7; - $[18] = t8; - $[19] = t9; - } else { - t9 = $[19]; - } - return t9; + > + + Enter the system prompt for your agent: + Be comprehensive for best results + + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx index 0c982da6a..501509ff5 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx @@ -1,60 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import type { Tools } from '../../../../Tool.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ToolSelector } from '../../ToolSelector.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import type { Tools } from '../../../../Tool.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ToolSelector } from '../../ToolSelector.js' +import type { AgentWizardData } from '../types.js' + type Props = { - tools: Tools; -}; -export function ToolsStep(t0) { - const $ = _c(9); - const { - tools - } = t0; - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t1; - if ($[0] !== goNext || $[1] !== updateWizardData) { - t1 = selectedTools => { - updateWizardData({ - selectedTools - }); - goNext(); - }; - $[0] = goNext; - $[1] = updateWizardData; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleComplete = t1; - const initialTools = wizardData.selectedTools; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== initialTools || $[7] !== tools) { - t3 = ; - $[4] = goBack; - $[5] = handleComplete; - $[6] = initialTools; - $[7] = tools; - $[8] = t3; - } else { - t3 = $[8]; + tools: Tools +} + +export function ToolsStep({ tools }: Props): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + const handleComplete = (selectedTools: string[] | undefined): void => { + updateWizardData({ selectedTools }) + goNext() } - return t3; + + // Pass through undefined to preserve "all tools" semantic + // ToolSelector will expand it internally for display purposes + const initialTools = wizardData.selectedTools + + return ( + + + + + + } + > + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx index 70c085cc5..6ff025492 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx @@ -1,102 +1,83 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { validateAgentType } from '../../validateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { validateAgentType } from '../../validateAgent.js' +import type { AgentWizardData } from '../types.js' + type Props = { - existingAgents: AgentDefinition[]; -}; -export function TypeStep(_props) { - const $ = _c(15); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [agentType, setAgentType] = useState(wizardData.agentType || ""); - const [error, setError] = useState(null); - const [cursorOffset, setCursorOffset] = useState(agentType.length); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; + existingAgents: AgentDefinition[] +} + +export function TypeStep(_props: Props): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [agentType, setAgentType] = useState(wizardData.agentType || '') + const [error, setError] = useState(null) + const [cursorOffset, setCursorOffset] = useState(agentType.length) + + // Handle escape key - Go back to MethodStep + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleSubmit = (value: string): void => { + const trimmedValue = value.trim() + const validationError = validateAgentType(trimmedValue) + + if (validationError) { + setError(validationError) + return + } + + setError(null) + updateWizardData({ agentType: trimmedValue }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== goNext || $[2] !== updateWizardData) { - t1 = value => { - const trimmedValue = value.trim(); - const validationError = validateAgentType(trimmedValue); - if (validationError) { - setError(validationError); - return; + + return ( + + + + + } - setError(null); - updateWizardData({ - agentType: trimmedValue - }); - goNext(); - }; - $[1] = goNext; - $[2] = updateWizardData; - $[3] = t1; - } else { - t1 = $[3]; - } - const handleSubmit = t1; - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Enter a unique identifier for your agent:; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== agentType || $[7] !== cursorOffset || $[8] !== handleSubmit) { - t4 = ; - $[6] = agentType; - $[7] = cursorOffset; - $[8] = handleSubmit; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== error) { - t5 = error && {error}; - $[10] = error; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t4 || $[13] !== t5) { - t6 = {t3}{t4}{t5}; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + > + + Enter a unique identifier for your agent: + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/design-system/Byline.tsx b/src/components/design-system/Byline.tsx index be41b584c..b0ddc97f3 100644 --- a/src/components/design-system/Byline.tsx +++ b/src/components/design-system/Byline.tsx @@ -1,10 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Children, isValidElement } from 'react'; -import { Text } from '../../ink.js'; +import React, { Children, isValidElement } from 'react' +import { Text } from '../../ink.js' + type Props = { /** The items to join with a middot separator */ - children: React.ReactNode; -}; + children: React.ReactNode +} /** * Joins children with a middot separator (" · ") for inline metadata display. @@ -34,43 +34,24 @@ type Props = { * * */ -export function Byline(t0) { - const $ = _c(5); - const { - children - } = t0; - let t1; - let t2; - if ($[0] !== children) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const validChildren = Children.toArray(children); - if (validChildren.length === 0) { - t2 = null; - break bb0; - } - t1 = validChildren.map(_temp); - } - $[0] = children; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - let t3; - if ($[3] !== t1) { - t3 = <>{t1}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; +export function Byline({ children }: Props): React.ReactNode { + // Children.toArray already filters out null, undefined, and booleans + const validChildren = Children.toArray(children) + + if (validChildren.length === 0) { + return null } - return t3; -} -function _temp(child, index) { - return {index > 0 && · }{child}; + + return ( + <> + {validChildren.map((child, index) => ( + + {index > 0 && · } + {child} + + ))} + + ) } diff --git a/src/components/design-system/Dialog.tsx b/src/components/design-system/Dialog.tsx index 5461c6c74..4472bd0d0 100644 --- a/src/components/design-system/Dialog.tsx +++ b/src/components/design-system/Dialog.tsx @@ -1,23 +1,26 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Theme } from '../../utils/theme.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from './Byline.js'; -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'; -import { Pane } from './Pane.js'; +import React from 'react' +import { + type ExitState, + useExitOnCtrlCDWithKeybindings, +} from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Theme } from '../../utils/theme.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from './Byline.js' +import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' +import { Pane } from './Pane.js' + type DialogProps = { - title: React.ReactNode; - subtitle?: React.ReactNode; - children: React.ReactNode; - onCancel: () => void; - color?: keyof Theme; - hideInputGuide?: boolean; - hideBorder?: boolean; + title: React.ReactNode + subtitle?: React.ReactNode + children: React.ReactNode + onCancel: () => void + color?: keyof Theme + hideInputGuide?: boolean + hideBorder?: boolean /** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */ - inputGuide?: (exitState: ExitState) => React.ReactNode; + inputGuide?: (exitState: ExitState) => React.ReactNode /** * Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt * (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text @@ -25,113 +28,73 @@ type DialogProps = { * consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on * press, delete-forward on ctrl+d with text). Defaults to `true`. */ - isCancelActive?: boolean; -}; -export function Dialog(t0) { - const $ = _c(27); - const { - title, - subtitle, - children, - onCancel, - color: t1, - hideInputGuide, - hideBorder, - inputGuide, - isCancelActive: t2 - } = t0; - const color = t1 === undefined ? "permission" : t1; - const isCancelActive = t2 === undefined ? true : t2; - const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive); - let t3; - if ($[0] !== isCancelActive) { - t3 = { - context: "Confirmation", - isActive: isCancelActive - }; - $[0] = isCancelActive; - $[1] = t3; - } else { - t3 = $[1]; - } - useKeybinding("confirm:no", onCancel, t3); - let t4; - if ($[2] !== exitState.keyName || $[3] !== exitState.pending) { - t4 = exitState.pending ? Press {exitState.keyName} again to exit : ; - $[2] = exitState.keyName; - $[3] = exitState.pending; - $[4] = t4; - } else { - t4 = $[4]; - } - const defaultInputGuide = t4; - let t5; - if ($[5] !== color || $[6] !== title) { - t5 = {title}; - $[5] = color; - $[6] = title; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== subtitle) { - t6 = subtitle && {subtitle}; - $[8] = subtitle; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== t5 || $[11] !== t6) { - t7 = {t5}{t6}; - $[10] = t5; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== children || $[14] !== t7) { - t8 = {t7}{children}; - $[13] = children; - $[14] = t7; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] !== defaultInputGuide || $[17] !== exitState || $[18] !== hideInputGuide || $[19] !== inputGuide) { - t9 = !hideInputGuide && {inputGuide ? inputGuide(exitState) : defaultInputGuide}; - $[16] = defaultInputGuide; - $[17] = exitState; - $[18] = hideInputGuide; - $[19] = inputGuide; - $[20] = t9; - } else { - t9 = $[20]; - } - let t10; - if ($[21] !== t8 || $[22] !== t9) { - t10 = <>{t8}{t9}; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const content = t10; + isCancelActive?: boolean +} + +export function Dialog({ + title, + subtitle, + children, + onCancel, + color = 'permission', + hideInputGuide, + hideBorder, + inputGuide, + isCancelActive = true, +}: DialogProps): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings( + undefined, + undefined, + isCancelActive, + ) + + // Use configurable keybinding for ESC to cancel. + // isCancelActive lets consumers (e.g. ElicitationDialog) disable this while + // an embedded TextInput is focused, so that keys like 'n' reach the field + // instead of being consumed here. + useKeybinding('confirm:no', onCancel, { + context: 'Confirmation', + isActive: isCancelActive, + }) + + const defaultInputGuide = exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + + + + ) + + const content = ( + <> + + + + {title} + + {subtitle && {subtitle}} + + {children} + + {!hideInputGuide && ( + + + {inputGuide ? inputGuide(exitState) : defaultInputGuide} + + + )} + + ) + if (hideBorder) { - return content; - } - let t11; - if ($[24] !== color || $[25] !== content) { - t11 = {content}; - $[24] = color; - $[25] = content; - $[26] = t11; - } else { - t11 = $[26]; + return content } - return t11; + + return {content} } diff --git a/src/components/design-system/Divider.tsx b/src/components/design-system/Divider.tsx index 362f4c283..a88982be5 100644 --- a/src/components/design-system/Divider.tsx +++ b/src/components/design-system/Divider.tsx @@ -1,33 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Ansi, Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; +import React from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Ansi, Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' + type DividerProps = { /** * Width of the divider in characters. * Defaults to terminal width. */ - width?: number; + width?: number /** * Theme color for the divider. * If not provided, dimColor is used. */ - color?: keyof Theme; + color?: keyof Theme /** * Character to use for the divider line. * @default '─' */ - char?: string; + char?: string /** * Padding to subtract from the width (e.g., for indentation). * @default 0 */ - padding?: number; + padding?: number /** * Title shown in the middle of the divider. @@ -37,8 +37,8 @@ type DividerProps = { * // ─────────── Title ─────────── * */ - title?: string; -}; + title?: string +} /** * A horizontal divider line. @@ -63,86 +63,35 @@ type DividerProps = { * // With centered title * */ -export function Divider(t0) { - const $ = _c(21); - const { - width, - color, - char: t1, - padding: t2, - title - } = t0; - const char = t1 === undefined ? "\u2500" : t1; - const padding = t2 === undefined ? 0 : t2; - const { - columns: terminalWidth - } = useTerminalSize(); - const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding); +export function Divider({ + width, + color, + char = '─', + padding = 0, + title, +}: DividerProps): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding) + if (title) { - const titleWidth = stringWidth(title) + 2; - const sideWidth = Math.max(0, effectiveWidth - titleWidth); - const leftWidth = Math.floor(sideWidth / 2); - const rightWidth = sideWidth - leftWidth; - const t3 = !color; - let t4; - if ($[0] !== char || $[1] !== leftWidth) { - t4 = char.repeat(leftWidth); - $[0] = char; - $[1] = leftWidth; - $[2] = t4; - } else { - t4 = $[2]; - } - let t5; - if ($[3] !== title) { - t5 = {title}; - $[3] = title; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] !== char || $[6] !== rightWidth) { - t6 = char.repeat(rightWidth); - $[5] = char; - $[6] = rightWidth; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== color || $[9] !== t3 || $[10] !== t4 || $[11] !== t5 || $[12] !== t6) { - t7 = {t4}{" "}{t5}{" "}{t6}; - $[8] = color; - $[9] = t3; - $[10] = t4; - $[11] = t5; - $[12] = t6; - $[13] = t7; - } else { - t7 = $[13]; - } - return t7; + const titleWidth = stringWidth(title) + 2 // +2 for spaces around title + const sideWidth = Math.max(0, effectiveWidth - titleWidth) + const leftWidth = Math.floor(sideWidth / 2) + const rightWidth = sideWidth - leftWidth + return ( + + {char.repeat(leftWidth)}{' '} + + {title} + {' '} + {char.repeat(rightWidth)} + + ) } - const t3 = !color; - let t4; - if ($[14] !== char || $[15] !== effectiveWidth) { - t4 = char.repeat(effectiveWidth); - $[14] = char; - $[15] = effectiveWidth; - $[16] = t4; - } else { - t4 = $[16]; - } - let t5; - if ($[17] !== color || $[18] !== t3 || $[19] !== t4) { - t5 = {t4}; - $[17] = color; - $[18] = t3; - $[19] = t4; - $[20] = t5; - } else { - t5 = $[20]; - } - return t5; + + return ( + + {char.repeat(effectiveWidth)} + + ) } diff --git a/src/components/design-system/FuzzyPicker.tsx b/src/components/design-system/FuzzyPicker.tsx index e84f1aafd..fc1b9fe9e 100644 --- a/src/components/design-system/FuzzyPicker.tsx +++ b/src/components/design-system/FuzzyPicker.tsx @@ -1,70 +1,73 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { useSearchInput } from '../../hooks/useSearchInput.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { clamp } from '../../ink/layout/geometry.js'; -import { Box, Text, useTerminalFocus } from '../../ink.js'; -import { SearchBox } from '../SearchBox.js'; -import { Byline } from './Byline.js'; -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'; -import { ListItem } from './ListItem.js'; -import { Pane } from './Pane.js'; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { useSearchInput } from '../../hooks/useSearchInput.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { clamp } from '../../ink/layout/geometry.js' +import { Box, Text, useTerminalFocus } from '../../ink.js' +import { SearchBox } from '../SearchBox.js' +import { Byline } from './Byline.js' +import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' +import { ListItem } from './ListItem.js' +import { Pane } from './Pane.js' + type PickerAction = { /** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */ - action: string; - handler: (item: T) => void; -}; + action: string + handler: (item: T) => void +} + type Props = { - title: string; - placeholder?: string; - initialQuery?: string; - items: readonly T[]; - getKey: (item: T) => string; + title: string + placeholder?: string + initialQuery?: string + items: readonly T[] + getKey: (item: T) => string /** Keep to one line — preview handles overflow. */ - renderItem: (item: T, isFocused: boolean) => React.ReactNode; - renderPreview?: (item: T) => React.ReactNode; + renderItem: (item: T, isFocused: boolean) => React.ReactNode + renderPreview?: (item: T) => React.ReactNode /** 'right' keeps hints stable (no bounce), but needs width. */ - previewPosition?: 'bottom' | 'right'; - visibleCount?: number; + previewPosition?: 'bottom' | 'right' + visibleCount?: number /** * 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows * always match screen direction — ↑ walks visually up regardless. */ - direction?: 'down' | 'up'; + direction?: 'down' | 'up' /** Caller owns filtering: re-filter on each call and pass new items. */ - onQueryChange: (query: string) => void; + onQueryChange: (query: string) => void /** Enter key. Primary action. */ - onSelect: (item: T) => void; + onSelect: (item: T) => void /** * Tab key. If provided, Tab no longer aliases Enter — it gets its own * handler and hint. Shift+Tab falls through to this if onShiftTab is unset. */ - onTab?: PickerAction; + onTab?: PickerAction /** Shift+Tab key. Gets its own hint. */ - onShiftTab?: PickerAction; + onShiftTab?: PickerAction /** * Fires when the focused item changes (via arrows or when items reset). * Useful for async preview loading — keeps I/O out of renderPreview. */ - onFocus?: (item: T | undefined) => void; - onCancel: () => void; + onFocus?: (item: T | undefined) => void + onCancel: () => void /** Shown when items is empty. Caller bakes loading/searching state into this. */ - emptyMessage?: string | ((query: string) => string); + emptyMessage?: string | ((query: string) => string) /** * Status line below the list, e.g. "500+ matches" or "42 matches…". * Caller decides when to show it — pass undefined to hide. */ - matchLabel?: string; - selectAction?: string; - extraHints?: React.ReactNode; -}; -const DEFAULT_VISIBLE = 8; + matchLabel?: string + selectAction?: string + extraHints?: React.ReactNode +} + +const DEFAULT_VISIBLE = 8 // Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3 // rows) + hints. matchLabel adds +1 when present, accounted for separately. -const CHROME_ROWS = 10; -const MIN_VISIBLE = 2; +const CHROME_ROWS = 10 +const MIN_VISIBLE = 2 + export function FuzzyPicker({ title, placeholder = 'Type to search…', @@ -85,117 +88,168 @@ export function FuzzyPicker({ emptyMessage = 'No results', matchLabel, selectAction = 'select', - extraHints + extraHints, }: Props): React.ReactNode { - const isTerminalFocused = useTerminalFocus(); - const { - rows, - columns - } = useTerminalSize(); - const [focusedIndex, setFocusedIndex] = useState(0); + const isTerminalFocused = useTerminalFocus() + const { rows, columns } = useTerminalSize() + const [focusedIndex, setFocusedIndex] = useState(0) // Cap visibleCount so the picker never exceeds the terminal height. When it // overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up // by the overflow amount and a previously-drawn line flashes blank. - const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0))); + const visibleCount = Math.max( + MIN_VISIBLE, + Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)), + ) // Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently // below that. Compact mode drops shift+tab and shortens labels. - const compact = columns < 120; + const compact = columns < 120 + const step = (delta: 1 | -1) => { - setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)); - }; + setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)) + } // onKeyDown fires after useSearchInput's useInput, so onExit must be a // no-op — return/downArrow are handled by handleKeyDown below. onCancel // still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so // a held backspace doesn't eject the user from the dialog. - const { - query, - cursorOffset - } = useSearchInput({ + const { query, cursorOffset } = useSearchInput({ isActive: true, onExit: () => {}, onCancel, initialQuery, - backspaceExitsOnEmpty: false - }); + backspaceExitsOnEmpty: false, + }) + const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'up' || e.ctrl && e.key === 'p') { - e.preventDefault(); - e.stopImmediatePropagation(); - step(direction === 'up' ? 1 : -1); - return; + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + e.stopImmediatePropagation() + step(direction === 'up' ? 1 : -1) + return } - if (e.key === 'down' || e.ctrl && e.key === 'n') { - e.preventDefault(); - e.stopImmediatePropagation(); - step(direction === 'up' ? -1 : 1); - return; + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + e.stopImmediatePropagation() + step(direction === 'up' ? -1 : 1) + return } if (e.key === 'return') { - e.preventDefault(); - e.stopImmediatePropagation(); - const selected = items[focusedIndex]; - if (selected) onSelect(selected); - return; + e.preventDefault() + e.stopImmediatePropagation() + const selected = items[focusedIndex] + if (selected) onSelect(selected) + return } if (e.key === 'tab') { - e.preventDefault(); - e.stopImmediatePropagation(); - const selected = items[focusedIndex]; - if (!selected) return; - const tabAction = e.shift ? onShiftTab ?? onTab : onTab; + e.preventDefault() + e.stopImmediatePropagation() + const selected = items[focusedIndex] + if (!selected) return + const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab if (tabAction) { - tabAction.handler(selected); + tabAction.handler(selected) } else { - onSelect(selected); + onSelect(selected) } } - }; + } + useEffect(() => { - onQueryChange(query); - setFocusedIndex(0); + onQueryChange(query) + setFocusedIndex(0) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]); + }, [query]) + useEffect(() => { - setFocusedIndex(i => clamp(i, 0, items.length - 1)); - }, [items.length]); - const focused = items[focusedIndex]; + setFocusedIndex(i => clamp(i, 0, items.length - 1)) + }, [items.length]) + + const focused = items[focusedIndex] useEffect(() => { - onFocus?.(focused); + onFocus?.(focused) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focused]); - const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount); - const visible = items.slice(windowStart, windowStart + visibleCount); - const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage; - const searchBox = ; - const listBlock = ; - const preview = renderPreview && focused ? + }, [focused]) + + const windowStart = clamp( + focusedIndex - visibleCount + 1, + 0, + items.length - visibleCount, + ) + const visible = items.slice(windowStart, windowStart + visibleCount) + + const emptyText = + typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage + + const searchBox = ( + + ) + + const listBlock = ( + + ) + + const preview = + renderPreview && focused ? ( + {renderPreview(focused)} - : null; + + ) : null // Structure must not depend on preview truthiness — when focused goes // undefined (e.g. delete clears matches), switching row→fragment would // change both layout AND gap count, bouncing the searchBox below. - const listGroup = renderPreview && previewPosition === 'right' ? + const listGroup = + renderPreview && previewPosition === 'right' ? ( + {listBlock} {matchLabel && {matchLabel}} {preview ?? } - : - // Box (not fragment) so the outer gap={1} doesn't insert a blank line - // between list/matchLabel/preview — that read as extra space above the - // prompt in direction='up'. - + + ) : ( + // Box (not fragment) so the outer gap={1} doesn't insert a blank line + // between list/matchLabel/preview — that read as extra space above the + // prompt in direction='up'. + {listBlock} {matchLabel && {matchLabel}} {preview} - ; - const inputAbove = direction !== 'up'; - return - + + ) + + const inputAbove = direction !== 'up' + return ( + + {title} @@ -204,108 +258,93 @@ export function FuzzyPicker({ {!inputAbove && searchBox} - - - {onTab && } - {onShiftTab && !compact && } + + + {onTab && ( + + )} + {onShiftTab && !compact && ( + + )} {extraHints} - ; + + ) } -type ListProps = Pick, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & { - visible: readonly T[]; - windowStart: number; - total: number; - focusedIndex: number; - emptyText: string; -}; -function List(t0) { - const $ = _c(27); - const { - visible, - windowStart, - visibleCount, - total, - focusedIndex, - direction, - getKey, - renderItem, - emptyText - } = t0; + +type ListProps = Pick< + Props, + 'visibleCount' | 'direction' | 'getKey' | 'renderItem' +> & { + visible: readonly T[] + windowStart: number + total: number + focusedIndex: number + emptyText: string +} + +function List({ + visible, + windowStart, + visibleCount, + total, + focusedIndex, + direction, + getKey, + renderItem, + emptyText, +}: ListProps): React.ReactNode { if (visible.length === 0) { - let t1; - if ($[0] !== emptyText) { - t1 = {emptyText}; - $[0] = emptyText; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1 || $[3] !== visibleCount) { - t2 = {t1}; - $[2] = t1; - $[3] = visibleCount; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; - } - let t1; - if ($[5] !== direction || $[6] !== focusedIndex || $[7] !== getKey || $[8] !== renderItem || $[9] !== total || $[10] !== visible || $[11] !== visibleCount || $[12] !== windowStart) { - let t2; - if ($[14] !== direction || $[15] !== focusedIndex || $[16] !== getKey || $[17] !== renderItem || $[18] !== total || $[19] !== visible.length || $[20] !== visibleCount || $[21] !== windowStart) { - t2 = (item, i) => { - const actualIndex = windowStart + i; - const isFocused = actualIndex === focusedIndex; - const atLowEdge = i === 0 && windowStart > 0; - const atHighEdge = i === visible.length - 1 && windowStart + visibleCount < total; - return {renderItem(item, isFocused)}; - }; - $[14] = direction; - $[15] = focusedIndex; - $[16] = getKey; - $[17] = renderItem; - $[18] = total; - $[19] = visible.length; - $[20] = visibleCount; - $[21] = windowStart; - $[22] = t2; - } else { - t2 = $[22]; - } - t1 = visible.map(t2); - $[5] = direction; - $[6] = focusedIndex; - $[7] = getKey; - $[8] = renderItem; - $[9] = total; - $[10] = visible; - $[11] = visibleCount; - $[12] = windowStart; - $[13] = t1; - } else { - t1 = $[13]; - } - const rows = t1; - const t2 = direction === "up" ? "column-reverse" : "column"; - let t3; - if ($[23] !== rows || $[24] !== t2 || $[25] !== visibleCount) { - t3 = {rows}; - $[23] = rows; - $[24] = t2; - $[25] = visibleCount; - $[26] = t3; - } else { - t3 = $[26]; + return ( + + {emptyText} + + ) } - return t3; + + const rows = visible.map((item, i) => { + const actualIndex = windowStart + i + const isFocused = actualIndex === focusedIndex + const atLowEdge = i === 0 && windowStart > 0 + const atHighEdge = + i === visible.length - 1 && windowStart + visibleCount! < total + return ( + + {renderItem(item, isFocused)} + + ) + }) + + return ( + + {rows} + + ) } + function firstWord(s: string): string { - const i = s.indexOf(' '); - return i === -1 ? s : s.slice(0, i); + const i = s.indexOf(' ') + return i === -1 ? s : s.slice(0, i) } diff --git a/src/components/design-system/KeyboardShortcutHint.tsx b/src/components/design-system/KeyboardShortcutHint.tsx index 19b51d05b..7d3c136d1 100644 --- a/src/components/design-system/KeyboardShortcutHint.tsx +++ b/src/components/design-system/KeyboardShortcutHint.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import Text from '../../ink/components/Text.js'; +import React from 'react' +import Text from '../../ink/components/Text.js' + type Props = { /** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */ - shortcut: string; + shortcut: string /** The action the key performs (e.g., "expand", "select", "navigate") */ - action: string; + action: string /** Whether to wrap the hint in parentheses. Default: false */ - parens?: boolean; + parens?: boolean /** Whether to render the shortcut in bold. Default: false */ - bold?: boolean; -}; + bold?: boolean +} /** * Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)" @@ -35,46 +35,24 @@ type Props = { * * */ -export function KeyboardShortcutHint(t0) { - const $ = _c(9); - const { - shortcut, - action, - parens: t1, - bold: t2 - } = t0; - const parens = t1 === undefined ? false : t1; - const bold = t2 === undefined ? false : t2; - let t3; - if ($[0] !== bold || $[1] !== shortcut) { - t3 = bold ? {shortcut} : shortcut; - $[0] = bold; - $[1] = shortcut; - $[2] = t3; - } else { - t3 = $[2]; - } - const shortcutText = t3; +export function KeyboardShortcutHint({ + shortcut, + action, + parens = false, + bold = false, +}: Props): React.ReactNode { + const shortcutText = bold ? {shortcut} : shortcut + if (parens) { - let t4; - if ($[3] !== action || $[4] !== shortcutText) { - t4 = ({shortcutText} to {action}); - $[3] = action; - $[4] = shortcutText; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; - } - let t4; - if ($[6] !== action || $[7] !== shortcutText) { - t4 = {shortcutText} to {action}; - $[6] = action; - $[7] = shortcutText; - $[8] = t4; - } else { - t4 = $[8]; + return ( + + ({shortcutText} to {action}) + + ) } - return t4; + return ( + + {shortcutText} to {action} + + ) } diff --git a/src/components/design-system/ListItem.tsx b/src/components/design-system/ListItem.tsx index 0ee8068cc..2d142be03 100644 --- a/src/components/design-system/ListItem.tsx +++ b/src/components/design-system/ListItem.tsx @@ -1,44 +1,44 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import type { ReactNode } from 'react'; -import React from 'react'; -import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js'; -import { Box, Text } from '../../ink.js'; +import figures from 'figures' +import type { ReactNode } from 'react' +import React from 'react' +import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js' +import { Box, Text } from '../../ink.js' + type ListItemProps = { /** * Whether this item is currently focused (keyboard selection). * Shows the pointer indicator (❯) when true. */ - isFocused: boolean; + isFocused: boolean /** * Whether this item is selected (chosen/checked). * Shows the checkmark indicator (✓) when true. * @default false */ - isSelected?: boolean; + isSelected?: boolean /** * The content to display for this item. */ - children: ReactNode; + children: ReactNode /** * Optional description text displayed below the main content. */ - description?: string; + description?: string /** * Show a down arrow indicator instead of pointer (for scroll hints). * Only applies when not focused. */ - showScrollDown?: boolean; + showScrollDown?: boolean /** * Show an up arrow indicator instead of pointer (for scroll hints). * Only applies when not focused. */ - showScrollUp?: boolean; + showScrollUp?: boolean /** * Whether to apply automatic styling to the children based on focus/selection state. @@ -46,21 +46,21 @@ type ListItemProps = { * - When false: children are rendered as-is, allowing custom styling * @default true */ - styled?: boolean; + styled?: boolean /** * Whether this item is disabled. Disabled items show dimmed text and no indicators. * @default false */ - disabled?: boolean; + disabled?: boolean /** * Whether this ListItem should declare the terminal cursor position. * Set false when a child (e.g. BaseTextInput) declares its own cursor. * @default true */ - declareCursor?: boolean; -}; + declareCursor?: boolean +} /** * A list item component for selection UIs (dropdowns, multi-selects, menus). @@ -101,143 +101,88 @@ type ListItemProps = { * Custom styled content * */ -export function ListItem(t0) { - const $ = _c(32); - const { - isFocused, - isSelected: t1, - children, - description, - showScrollDown, - showScrollUp, - styled: t2, - disabled: t3, - declareCursor - } = t0; - const isSelected = t1 === undefined ? false : t1; - const styled = t2 === undefined ? true : t2; - const disabled = t3 === undefined ? false : t3; - let t4; - if ($[0] !== disabled || $[1] !== isFocused || $[2] !== showScrollDown || $[3] !== showScrollUp) { - t4 = function renderIndicator() { - if (disabled) { - return ; - } - if (isFocused) { - return {figures.pointer}; - } - if (showScrollDown) { - return {figures.arrowDown}; - } - if (showScrollUp) { - return {figures.arrowUp}; - } - return ; - }; - $[0] = disabled; - $[1] = isFocused; - $[2] = showScrollDown; - $[3] = showScrollUp; - $[4] = t4; - } else { - t4 = $[4]; - } - const renderIndicator = t4; - let t5; - if ($[5] !== disabled || $[6] !== isFocused || $[7] !== isSelected || $[8] !== styled) { - const getTextColor = function getTextColor() { - if (disabled) { - return "inactive"; - } - if (!styled) { - return; - } - if (isSelected) { - return "success"; - } - if (isFocused) { - return "suggestion"; - } - }; - t5 = getTextColor(); - $[5] = disabled; - $[6] = isFocused; - $[7] = isSelected; - $[8] = styled; - $[9] = t5; - } else { - t5 = $[9]; - } - const textColor = t5; - const t6 = isFocused && !disabled && declareCursor !== false; - let t7; - if ($[10] !== t6) { - t7 = { - line: 0, - column: 0, - active: t6 - }; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - const cursorRef = useDeclaredCursor(t7); - let t8; - if ($[12] !== renderIndicator) { - t8 = renderIndicator(); - $[12] = renderIndicator; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== children || $[15] !== disabled || $[16] !== styled || $[17] !== textColor) { - t9 = styled ? {children} : children; - $[14] = children; - $[15] = disabled; - $[16] = styled; - $[17] = textColor; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== disabled || $[20] !== isSelected) { - t10 = isSelected && !disabled && {figures.tick}; - $[19] = disabled; - $[20] = isSelected; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== t10 || $[23] !== t8 || $[24] !== t9) { - t11 = {t8}{t9}{t10}; - $[22] = t10; - $[23] = t8; - $[24] = t9; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== description) { - t12 = description && {description}; - $[26] = description; - $[27] = t12; - } else { - t12 = $[27]; +export function ListItem({ + isFocused, + isSelected = false, + children, + description, + showScrollDown, + showScrollUp, + styled = true, + disabled = false, + declareCursor, +}: ListItemProps): React.ReactNode { + // Determine which indicator to show + function renderIndicator(): ReactNode { + if (disabled) { + return + } + + if (isFocused) { + return {figures.pointer} + } + + if (showScrollDown) { + return {figures.arrowDown} + } + + if (showScrollUp) { + return {figures.arrowUp} + } + + return } - let t13; - if ($[28] !== cursorRef || $[29] !== t11 || $[30] !== t12) { - t13 = {t11}{t12}; - $[28] = cursorRef; - $[29] = t11; - $[30] = t12; - $[31] = t13; - } else { - t13 = $[31]; + + // Determine text color based on state + function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined { + if (disabled) { + return 'inactive' + } + + if (!styled) { + return undefined + } + + if (isSelected) { + return 'success' + } + + if (isFocused) { + return 'suggestion' + } + + return undefined } - return t13; + + const textColor = getTextColor() + + // Park the native terminal cursor on the pointer indicator so screen + // readers / magnifiers track the focused item. (0,0) is the top-left of + // this Box, where the pointer renders. + const cursorRef = useDeclaredCursor({ + line: 0, + column: 0, + active: isFocused && !disabled && declareCursor !== false, + }) + + return ( + + + {renderIndicator()} + {styled ? ( + + {children} + + ) : ( + children + )} + {isSelected && !disabled && {figures.tick}} + + {description && ( + + {description} + + )} + + ) } diff --git a/src/components/design-system/LoadingState.tsx b/src/components/design-system/LoadingState.tsx index aa05dd941..046f726fa 100644 --- a/src/components/design-system/LoadingState.tsx +++ b/src/components/design-system/LoadingState.tsx @@ -1,30 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Spinner } from '../Spinner.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { Spinner } from '../Spinner.js' + type LoadingStateProps = { /** * The loading message to display next to the spinner. */ - message: string; + message: string /** * Display the message in bold. * @default false */ - bold?: boolean; + bold?: boolean /** * Display the message in dimmed color. * @default false */ - dimColor?: boolean; + dimColor?: boolean /** * Optional subtitle displayed below the main message. */ - subtitle?: string; -}; + subtitle?: string +} /** * A spinner with loading message for async operations. @@ -45,49 +45,22 @@ type LoadingStateProps = { * subtitle="Fetching your Claude Code sessions..." * /> */ -export function LoadingState(t0) { - const $ = _c(10); - const { - message, - bold: t1, - dimColor: t2, - subtitle - } = t0; - const bold = t1 === undefined ? false : t1; - const dimColor = t2 === undefined ? false : t2; - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[0] = t3; - } else { - t3 = $[0]; - } - let t4; - if ($[1] !== bold || $[2] !== dimColor || $[3] !== message) { - t4 = {t3}{" "}{message}; - $[1] = bold; - $[2] = dimColor; - $[3] = message; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== subtitle) { - t5 = subtitle && {subtitle}; - $[5] = subtitle; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== t4 || $[8] !== t5) { - t6 = {t4}{t5}; - $[7] = t4; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - return t6; +export function LoadingState({ + message, + bold = false, + dimColor = false, + subtitle, +}: LoadingStateProps): React.ReactNode { + return ( + + + + + {' '} + {message} + + + {subtitle && {subtitle}} + + ) } diff --git a/src/components/design-system/Pane.tsx b/src/components/design-system/Pane.tsx index 4f1264bea..9c10907d3 100644 --- a/src/components/design-system/Pane.tsx +++ b/src/components/design-system/Pane.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { Box } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; -import { Divider } from './Divider.js'; +import React from 'react' +import { useIsInsideModal } from '../../context/modalContext.js' +import { Box } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' +import { Divider } from './Divider.js' + type PaneProps = { - children: React.ReactNode; + children: React.ReactNode /** * Theme color for the top border line. */ - color?: keyof Theme; -}; + color?: keyof Theme +} /** * A pane — a region of the terminal that appears below the REPL prompt, @@ -30,47 +30,28 @@ type PaneProps = { * ... * */ -export function Pane(t0) { - const $ = _c(9); - const { - children, - color - } = t0; +export function Pane({ children, color }: PaneProps): React.ReactNode { + // When rendered inside FullscreenLayout's modal slot, its ▔ divider IS + // the frame. Skip our own Divider (would double-frame) and the extra top + // padding. This lets slash-command screens that wrap in Pane (e.g. + // /model → ModelPicker) route through the modal slot unchanged. if (useIsInsideModal()) { - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - let t1; - if ($[2] !== color) { - t1 = ; - $[2] = color; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== children) { - t2 = {children}; - $[4] = children; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== t1 || $[7] !== t2) { - t3 = {t1}{t2}; - $[6] = t1; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; + // flexShrink=0: the modal slot's absolute Box has no explicit height + // (grows to fit, maxHeight cap). With flexGrow=1, re-renders cause + // yoga to resolve this Box's height to 0 against the undetermined + // parent — /permissions body blanks on Down arrow. See #23592. + return ( + + {children} + + ) } - return t3; + return ( + + + + {children} + + + ) } diff --git a/src/components/design-system/ProgressBar.tsx b/src/components/design-system/ProgressBar.tsx index 0d27c514b..590fcd265 100644 --- a/src/components/design-system/ProgressBar.tsx +++ b/src/components/design-system/ProgressBar.tsx @@ -1,85 +1,54 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; +import React from 'react' +import { Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' + type Props = { /** * How much progress to display, between 0 and 1 inclusive */ - ratio: number; // [0, 1] + ratio: number // [0, 1] /** * How many characters wide to draw the progress bar */ - width: number; // how many characters wide + width: number // how many characters wide /** * Optional color for the filled portion of the bar */ - fillColor?: keyof Theme; + fillColor?: keyof Theme /** * Optional color for the empty portion of the bar */ - emptyColor?: keyof Theme; -}; -const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; -export function ProgressBar(t0) { - const $ = _c(13); - const { - ratio: inputRatio, - width, - fillColor, - emptyColor - } = t0; - const ratio = Math.min(1, Math.max(0, inputRatio)); - const whole = Math.floor(ratio * width); - let t1; - if ($[0] !== whole) { - t1 = BLOCKS[BLOCKS.length - 1].repeat(whole); - $[0] = whole; - $[1] = t1; - } else { - t1 = $[1]; - } - let segments; - if ($[2] !== ratio || $[3] !== t1 || $[4] !== whole || $[5] !== width) { - segments = [t1]; - if (whole < width) { - const remainder = ratio * width - whole; - const middle = Math.floor(remainder * BLOCKS.length); - segments.push(BLOCKS[middle]); - const empty = width - whole - 1; - if (empty > 0) { - let t2; - if ($[7] !== empty) { - t2 = BLOCKS[0].repeat(empty); - $[7] = empty; - $[8] = t2; - } else { - t2 = $[8]; - } - segments.push(t2); - } + emptyColor?: keyof Theme +} + +const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] + +export function ProgressBar({ + ratio: inputRatio, + width, + fillColor, + emptyColor, +}: Props): React.ReactNode { + const ratio = Math.min(1, Math.max(0, inputRatio)) + const whole = Math.floor(ratio * width) + const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)] + if (whole < width) { + const remainder = ratio * width - whole + const middle = Math.floor(remainder * BLOCKS.length) + segments.push(BLOCKS[middle]!) + + const empty = width - whole - 1 + if (empty > 0) { + segments.push(BLOCKS[0]!.repeat(empty)) } - $[2] = ratio; - $[3] = t1; - $[4] = whole; - $[5] = width; - $[6] = segments; - } else { - segments = $[6]; } - const t2 = segments.join(""); - let t3; - if ($[9] !== emptyColor || $[10] !== fillColor || $[11] !== t2) { - t3 = {t2}; - $[9] = emptyColor; - $[10] = fillColor; - $[11] = t2; - $[12] = t3; - } else { - t3 = $[12]; - } - return t3; + + return ( + + {segments.join('')} + + ) } diff --git a/src/components/design-system/Ratchet.tsx b/src/components/design-system/Ratchet.tsx index a63cffb33..91580ff05 100644 --- a/src/components/design-system/Ratchet.tsx +++ b/src/components/design-system/Ratchet.tsx @@ -1,79 +1,45 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js'; -import { Box, type DOMElement, measureElement } from '../../ink.js'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js' +import { Box, type DOMElement, measureElement } from '../../ink.js' + type Props = { - children: React.ReactNode; - lock?: 'always' | 'offscreen'; -}; -export function Ratchet(t0) { - const $ = _c(10); - const { - children, - lock: t1 - } = t0; - const lock = t1 === undefined ? "always" : t1; - const [viewportRef, t2] = useTerminalViewport(); - const { - isVisible - } = t2; - const { - rows - } = useTerminalSize(); - const innerRef = useRef(null); - const maxHeight = useRef(0); - const [minHeight, setMinHeight] = useState(0); - let t3; - if ($[0] !== viewportRef) { - t3 = el => { - viewportRef(el); - }; - $[0] = viewportRef; - $[1] = t3; - } else { - t3 = $[1]; - } - const outerRef = t3; - const engaged = lock === "always" || !isVisible; - let t4; - if ($[2] !== rows) { - t4 = () => { - if (!innerRef.current) { - return; - } - const { - height - } = measureElement(innerRef.current); - if (height > maxHeight.current) { - maxHeight.current = Math.min(height, rows); - setMinHeight(maxHeight.current); - } - }; - $[2] = rows; - $[3] = t4; - } else { - t4 = $[3]; - } - useLayoutEffect(t4); - const t5 = engaged ? minHeight : undefined; - let t6; - if ($[4] !== children) { - t6 = {children}; - $[4] = children; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== outerRef || $[7] !== t5 || $[8] !== t6) { - t7 = {t6}; - $[6] = outerRef; - $[7] = t5; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - return t7; + children: React.ReactNode + lock?: 'always' | 'offscreen' +} + +export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode { + const [viewportRef, { isVisible }] = useTerminalViewport() + const { rows } = useTerminalSize() + const innerRef = useRef(null) + const maxHeight = useRef(0) + const [minHeight, setMinHeight] = useState(0) + + const outerRef = useCallback( + (el: DOMElement | null) => { + viewportRef(el) + }, + [viewportRef], + ) + + const engaged = lock === 'always' || !isVisible + + useLayoutEffect(() => { + if (!innerRef.current) { + return + } + const { height } = measureElement(innerRef.current) + if (height > maxHeight.current) { + maxHeight.current = Math.min(height, rows) + setMinHeight(maxHeight.current) + } + }) + + return ( + + + {children} + + + ) } diff --git a/src/components/design-system/StatusIcon.tsx b/src/components/design-system/StatusIcon.tsx index d50693edd..832c83a9e 100644 --- a/src/components/design-system/StatusIcon.tsx +++ b/src/components/design-system/StatusIcon.tsx @@ -1,8 +1,9 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Text } from '../../ink.js'; -type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading'; +import figures from 'figures' +import React from 'react' +import { Text } from '../../ink.js' + +type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading' + type Props = { /** * The status to display. Determines both the icon and color. @@ -14,42 +15,28 @@ type Props = { * - `pending`: Dimmed circle (○) * - `loading`: Dimmed ellipsis (…) */ - status: Status; + status: Status /** * Include a trailing space after the icon. Useful when followed by text. * @default false */ - withSpace?: boolean; -}; -const STATUS_CONFIG: Record = { - success: { - icon: figures.tick, - color: 'success' - }, - error: { - icon: figures.cross, - color: 'error' - }, - warning: { - icon: figures.warning, - color: 'warning' - }, - info: { - icon: figures.info, - color: 'suggestion' - }, - pending: { - icon: figures.circle, - color: undefined - }, - loading: { - icon: '…', - color: undefined + withSpace?: boolean +} + +const STATUS_CONFIG: Record< + Status, + { + icon: string + color: 'success' | 'error' | 'warning' | 'suggestion' | undefined } -}; +> = { + success: { icon: figures.tick, color: 'success' }, + error: { icon: figures.cross, color: 'error' }, + warning: { icon: figures.warning, color: 'warning' }, + info: { icon: figures.info, color: 'suggestion' }, + pending: { icon: figures.circle, color: undefined }, + loading: { icon: '…', color: undefined }, +} /** * Renders a status indicator icon with appropriate color. @@ -69,26 +56,16 @@ const STATUS_CONFIG: Record */ -export function StatusIcon(t0) { - const $ = _c(5); - const { - status, - withSpace: t1 - } = t0; - const withSpace = t1 === undefined ? false : t1; - const config = STATUS_CONFIG[status]; - const t2 = !config.color; - const t3 = withSpace && " "; - let t4; - if ($[0] !== config.color || $[1] !== config.icon || $[2] !== t2 || $[3] !== t3) { - t4 = {config.icon}{t3}; - $[0] = config.color; - $[1] = config.icon; - $[2] = t2; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - return t4; +export function StatusIcon({ + status, + withSpace = false, +}: Props): React.ReactNode { + const config = STATUS_CONFIG[status] + + return ( + + {config.icon} + {withSpace && ' '} + + ) } diff --git a/src/components/design-system/Tabs.tsx b/src/components/design-system/Tabs.tsx index db8d0d59e..40bae7baa 100644 --- a/src/components/design-system/Tabs.tsx +++ b/src/components/design-system/Tabs.tsx @@ -1,28 +1,37 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { useIsInsideModal, useModalScrollRef } from '../../context/modalContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import ScrollBox from '../../ink/components/ScrollBox.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { Theme } from '../../utils/theme.js'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { + useIsInsideModal, + useModalScrollRef, +} from '../../context/modalContext.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import ScrollBox from '../../ink/components/ScrollBox.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { Theme } from '../../utils/theme.js' + type TabsProps = { - children: Array>; - title?: string; - color?: keyof Theme; - defaultTab?: string; - hidden?: boolean; - useFullWidth?: boolean; + children: Array> + title?: string + color?: keyof Theme + defaultTab?: string + hidden?: boolean + useFullWidth?: boolean /** Controlled mode: current selected tab id/title */ - selectedTab?: string; + selectedTab?: string /** Controlled mode: callback when tab changes */ - onTabChange?: (tabId: string) => void; + onTabChange?: (tabId: string) => void /** Optional banner to display below tabs header */ - banner?: React.ReactNode; + banner?: React.ReactNode /** Disable keyboard navigation (e.g. when a child component handles arrow keys) */ - disableNavigation?: boolean; + disableNavigation?: boolean /** * Initial focus state for the tab header row. Defaults to true (header * focused, nav always works). Keep the default for Select/list content — @@ -31,28 +40,30 @@ type TabsProps = { * content actually binds left/right/tab (e.g. enum cycling), and show a * "↑ tabs" footer hint — without it tabs look broken. */ - initialHeaderFocused?: boolean; + initialHeaderFocused?: boolean /** * Fixed height for the content area. When set, all tabs render within the * same height (overflow hidden) so switching tabs doesn't cause layout * shifts. Shorter tabs get whitespace; taller tabs are clipped. */ - contentHeight?: number; + contentHeight?: number /** * Let Tab/←/→ switch tabs from focused content. Opt-in since some * content uses those keys; pass a reactive boolean to cede them when * needed. Switching from content focuses the header. */ - navFromContent?: boolean; -}; + navFromContent?: boolean +} + type TabsContextValue = { - selectedTab: string | undefined; - width: number | undefined; - headerFocused: boolean; - focusHeader: () => void; - blurHeader: () => void; - registerOptIn: () => () => void; -}; + selectedTab: string | undefined + width: number | undefined + headerFocused: boolean + focusHeader: () => void + blurHeader: () => void + registerOptIn: () => () => void +} + const TabsContext = createContext({ selectedTab: undefined, width: undefined, @@ -61,236 +72,248 @@ const TabsContext = createContext({ headerFocused: false, focusHeader: () => {}, blurHeader: () => {}, - registerOptIn: () => () => {} -}); -export function Tabs(t0) { - const $ = _c(25); - const { - title, - color, - defaultTab, - children, - hidden, - useFullWidth, - selectedTab: controlledSelectedTab, - onTabChange, - banner, - disableNavigation, - initialHeaderFocused: t1, - contentHeight, - navFromContent: t2 - } = t0; - const initialHeaderFocused = t1 === undefined ? true : t1; - const navFromContent = t2 === undefined ? false : t2; - const { - columns: terminalWidth - } = useTerminalSize(); - const tabs = children.map(_temp); - const defaultTabIndex = defaultTab ? tabs.findIndex(tab => defaultTab === tab[0]) : 0; - const isControlled = controlledSelectedTab !== undefined; - const [internalSelectedTab, setInternalSelectedTab] = useState(defaultTabIndex !== -1 ? defaultTabIndex : 0); - const controlledTabIndex = isControlled ? tabs.findIndex(tab_0 => tab_0[0] === controlledSelectedTab) : -1; - const selectedTabIndex = isControlled ? controlledTabIndex !== -1 ? controlledTabIndex : 0 : internalSelectedTab; - const modalScrollRef = useModalScrollRef(); - const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused); - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setHeaderFocused(true); - $[0] = t3; - } else { - t3 = $[0]; - } - const focusHeader = t3; - let t4; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t4 = () => setHeaderFocused(false); - $[1] = t4; - } else { - t4 = $[1]; - } - const blurHeader = t4; - const [optInCount, setOptInCount] = useState(0); - let t5; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t5 = () => { - setOptInCount(_temp2); - return () => setOptInCount(_temp3); - }; - $[2] = t5; - } else { - t5 = $[2]; - } - const registerOptIn = t5; - const optedIn = optInCount > 0; - const handleTabChange = offset => { - const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length; - const newTabId = tabs[newIndex]?.[0]; + registerOptIn: () => () => {}, +}) + +export function Tabs({ + title, + color, + defaultTab, + children, + hidden, + useFullWidth, + selectedTab: controlledSelectedTab, + onTabChange, + banner, + disableNavigation, + initialHeaderFocused = true, + contentHeight, + navFromContent = false, +}: TabsProps): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const tabs = children.map(child => [ + child.props.id ?? child.props.title, + child.props.title, + ]) + const defaultTabIndex = defaultTab + ? tabs.findIndex(tab => defaultTab === tab[0]) + : 0 + + // Support both controlled and uncontrolled modes + const isControlled = controlledSelectedTab !== undefined + const [internalSelectedTab, setInternalSelectedTab] = useState( + defaultTabIndex !== -1 ? defaultTabIndex : 0, + ) + + // In controlled mode, find the index of the controlled tab + const controlledTabIndex = isControlled + ? tabs.findIndex(tab => tab[0] === controlledSelectedTab) + : -1 + const selectedTabIndex = isControlled + ? controlledTabIndex !== -1 + ? controlledTabIndex + : 0 + : internalSelectedTab + + const modalScrollRef = useModalScrollRef() + + // Header focus: left/right/tab only switch tabs when the header row is + // focused. Children with interactive content call focusHeader() (via + // useTabHeaderFocus) on up-arrow to hand focus back here; down-arrow + // returns it. Tabs that never call the hook see no behavior change — + // initialHeaderFocused defaults to true so nav always works. + const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused) + const focusHeader = useCallback(() => setHeaderFocused(true), []) + const blurHeader = useCallback(() => setHeaderFocused(false), []) + // Count of mounted children using useTabHeaderFocus(). Down-arrow blur and + // the ↓ hint only engage when at least one child has opted in — otherwise + // pressing down on a legacy tab would strand the user with nav disabled. + const [optInCount, setOptInCount] = useState(0) + const registerOptIn = useCallback(() => { + setOptInCount(n => n + 1) + return () => setOptInCount(n => n - 1) + }, []) + const optedIn = optInCount > 0 + + const handleTabChange = (offset: number) => { + const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length + const newTabId = tabs[newIndex]?.[0] + if (isControlled && onTabChange && newTabId) { - onTabChange(newTabId); + onTabChange(newTabId) } else { - setInternalSelectedTab(newIndex); + setInternalSelectedTab(newIndex) } - setHeaderFocused(true); - }; - const t6 = !hidden && !disableNavigation && headerFocused; - let t7; - if ($[3] !== t6) { - t7 = { - context: "Tabs", - isActive: t6 - }; - $[3] = t6; - $[4] = t7; - } else { - t7 = $[4]; + // Tab switching is a header action — stay focused so the user can keep + // cycling. The newly mounted tab can blur via its own interaction. + setHeaderFocused(true) } - useKeybindings({ - "tabs:next": () => handleTabChange(1), - "tabs:previous": () => handleTabChange(-1) - }, t7); - let t8; - if ($[5] !== headerFocused || $[6] !== hidden || $[7] !== optedIn) { - t8 = e => { - if (!headerFocused || !optedIn || hidden) { - return; - } - if (e.key === "down") { - e.preventDefault(); - setHeaderFocused(false); - } - }; - $[5] = headerFocused; - $[6] = hidden; - $[7] = optedIn; - $[8] = t8; - } else { - t8 = $[8]; - } - const handleKeyDown = t8; - const t9 = navFromContent && !headerFocused && optedIn && !hidden && !disableNavigation; - let t10; - if ($[9] !== t9) { - t10 = { - context: "Tabs", - isActive: t9 - }; - $[9] = t9; - $[10] = t10; - } else { - t10 = $[10]; - } - useKeybindings({ - "tabs:next": () => { - handleTabChange(1); - setHeaderFocused(true); + + useKeybindings( + { + 'tabs:next': () => handleTabChange(1), + 'tabs:previous': () => handleTabChange(-1), }, - "tabs:previous": () => { - handleTabChange(-1); - setHeaderFocused(true); + { + context: 'Tabs', + isActive: !hidden && !disableNavigation && headerFocused, + }, + ) + + // When the header is focused, down-arrow returns focus to content. Only + // active when the selected tab has opted in via useTabHeaderFocus() — + // legacy tabs have nowhere to return focus to. + const handleKeyDown = (e: KeyboardEvent) => { + if (!headerFocused || !optedIn || hidden) return + if (e.key === 'down') { + e.preventDefault() + setHeaderFocused(false) } - }, t10); - const titleWidth = title ? stringWidth(title) + 1 : 0; - const tabsWidth = tabs.reduce(_temp4, 0); - const usedWidth = titleWidth + tabsWidth; - const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0; - const contentWidth = useFullWidth ? terminalWidth : undefined; - const T0 = Box; - const t11 = "column"; - const t12 = 0; - const t13 = true; - const t14 = modalScrollRef ? 0 : undefined; - const t15 = !hidden && {title !== undefined && {title}}{tabs.map((t16, i) => { - const [id, title_0] = t16; - const isCurrent = selectedTabIndex === i; - const hasColorCursor = color && isCurrent && headerFocused; - return {" "}{title_0}{" "}; - })}{spacerWidth > 0 && {" ".repeat(spacerWidth)}}; - let t17; - if ($[11] !== children || $[12] !== contentHeight || $[13] !== contentWidth || $[14] !== hidden || $[15] !== modalScrollRef || $[16] !== selectedTabIndex) { - t17 = modalScrollRef ? {children} : {children}; - $[11] = children; - $[12] = contentHeight; - $[13] = contentWidth; - $[14] = hidden; - $[15] = modalScrollRef; - $[16] = selectedTabIndex; - $[17] = t17; - } else { - t17 = $[17]; } - let t18; - if ($[18] !== T0 || $[19] !== banner || $[20] !== handleKeyDown || $[21] !== t14 || $[22] !== t15 || $[23] !== t17) { - t18 = {t15}{banner}{t17}; - $[18] = T0; - $[19] = banner; - $[20] = handleKeyDown; - $[21] = t14; - $[22] = t15; - $[23] = t17; - $[24] = t18; - } else { - t18 = $[24]; - } - return {t18}; -} -function _temp4(sum, t0) { - const [, tabTitle] = t0; - return sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1; -} -function _temp3(n_0) { - return n_0 - 1; -} -function _temp2(n) { - return n + 1; -} -function _temp(child) { - return [child.props.id ?? child.props.title, child.props.title]; + + // Opt-in: same tabs:next/previous actions, active from content. Focuses + // the header so subsequent presses cycle via the handler above. + useKeybindings( + { + 'tabs:next': () => { + handleTabChange(1) + setHeaderFocused(true) + }, + 'tabs:previous': () => { + handleTabChange(-1) + setHeaderFocused(true) + }, + }, + { + context: 'Tabs', + isActive: + navFromContent && + !headerFocused && + optedIn && + !hidden && + !disableNavigation, + }, + ) + + // Calculate spacing to fill the available width. No keyboard hint in the + // header row — content footers own hints (see useTabHeaderFocus docs). + const titleWidth = title ? stringWidth(title) + 1 : 0 // +1 for gap + const tabsWidth = tabs.reduce( + (sum, [, tabTitle]) => sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1, // +2 for padding, +1 for gap + 0, + ) + const usedWidth = titleWidth + tabsWidth + const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0 + + const contentWidth = useFullWidth ? terminalWidth : undefined + + return ( + + + {!hidden && ( + + {title !== undefined && ( + + {title} + + )} + {tabs.map(([id, title], i) => { + const isCurrent = selectedTabIndex === i + const hasColorCursor = color && isCurrent && headerFocused + return ( + + {' '} + {title}{' '} + + ) + })} + {spacerWidth > 0 && {' '.repeat(spacerWidth)}} + + )} + {banner} + {modalScrollRef ? ( + // Inside the modal slot: own the ScrollBox here so the tabs + // header row above sits OUTSIDE the scroll area — it can never + // scroll off. The ref reaches REPL's ScrollKeybindingHandler via + // ModalContext. Keyed by selectedTabIndex → remounts on tab + // switch, resetting scrollTop to 0 without scrollTo() timing games. + + + {children} + + + ) : ( + + {children} + + )} + + + ) } + type TabProps = { - title: string; - id?: string; - children: React.ReactNode; -}; -export function Tab(t0) { - const $ = _c(4); - const { - title, - id, - children - } = t0; - const { - selectedTab, - width - } = useContext(TabsContext); - const insideModal = useIsInsideModal(); + title: string + id?: string + children: React.ReactNode +} + +export function Tab({ title, id, children }: TabProps): React.ReactNode { + const { selectedTab, width } = useContext(TabsContext) + const insideModal = useIsInsideModal() if (selectedTab !== (id ?? title)) { - return null; - } - const t1 = insideModal ? 0 : undefined; - let t2; - if ($[0] !== children || $[1] !== t1 || $[2] !== width) { - t2 = {children}; - $[0] = children; - $[1] = t1; - $[2] = width; - $[3] = t2; - } else { - t2 = $[3]; + return null } - return t2; + + return ( + + {children} + + ) } -export function useTabsWidth() { - const { - width - } = useContext(TabsContext); - return width; + +export function useTabsWidth(): number | undefined { + const { width } = useContext(TabsContext) + return width } /** @@ -304,36 +327,13 @@ export function useTabsWidth() { * no onUpFromFirstItem to recover. Split the component so the hook only runs * when the Select renders. */ -export function useTabHeaderFocus() { - const $ = _c(6); - const { - headerFocused, - focusHeader, - blurHeader, - registerOptIn - } = useContext(TabsContext); - let t0; - if ($[0] !== registerOptIn) { - t0 = [registerOptIn]; - $[0] = registerOptIn; - $[1] = t0; - } else { - t0 = $[1]; - } - useEffect(registerOptIn, t0); - let t1; - if ($[2] !== blurHeader || $[3] !== focusHeader || $[4] !== headerFocused) { - t1 = { - headerFocused, - focusHeader, - blurHeader - }; - $[2] = blurHeader; - $[3] = focusHeader; - $[4] = headerFocused; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; +export function useTabHeaderFocus(): { + headerFocused: boolean + focusHeader: () => void + blurHeader: () => void +} { + const { headerFocused, focusHeader, blurHeader, registerOptIn } = + useContext(TabsContext) + useEffect(registerOptIn, [registerOptIn]) + return { headerFocused, focusHeader, blurHeader } } diff --git a/src/components/design-system/ThemeProvider.tsx b/src/components/design-system/ThemeProvider.tsx index 373f73072..ef60d23a1 100644 --- a/src/components/design-system/ThemeProvider.tsx +++ b/src/components/design-system/ThemeProvider.tsx @@ -1,169 +1,160 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import useStdin from '../../ink/hooks/use-stdin.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { getSystemThemeName, type SystemTheme } from '../../utils/systemTheme.js'; -import type { ThemeName, ThemeSetting } from '../../utils/theme.js'; +import { feature } from 'bun:bundle' +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import useStdin from '../../ink/hooks/use-stdin.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { + getSystemThemeName, + type SystemTheme, +} from '../../utils/systemTheme.js' +import type { ThemeName, ThemeSetting } from '../../utils/theme.js' + type ThemeContextValue = { /** The saved user preference. May be 'auto'. */ - themeSetting: ThemeSetting; - setThemeSetting: (setting: ThemeSetting) => void; - setPreviewTheme: (setting: ThemeSetting) => void; - savePreview: () => void; - cancelPreview: () => void; + themeSetting: ThemeSetting + setThemeSetting: (setting: ThemeSetting) => void + setPreviewTheme: (setting: ThemeSetting) => void + savePreview: () => void + cancelPreview: () => void /** The resolved theme to render with. Never 'auto'. */ - currentTheme: ThemeName; -}; + currentTheme: ThemeName +} // Non-'auto' default so useTheme() works without a provider (tests, tooling). -const DEFAULT_THEME: ThemeName = 'dark'; +const DEFAULT_THEME: ThemeName = 'dark' + const ThemeContext = createContext({ themeSetting: DEFAULT_THEME, setThemeSetting: () => {}, setPreviewTheme: () => {}, savePreview: () => {}, cancelPreview: () => {}, - currentTheme: DEFAULT_THEME -}); + currentTheme: DEFAULT_THEME, +}) + type Props = { - children: React.ReactNode; - initialState?: ThemeSetting; - onThemeSave?: (setting: ThemeSetting) => void; -}; + children: React.ReactNode + initialState?: ThemeSetting + onThemeSave?: (setting: ThemeSetting) => void +} + function defaultInitialTheme(): ThemeSetting { - return getGlobalConfig().theme; + return getGlobalConfig().theme } + function defaultSaveTheme(setting: ThemeSetting): void { - saveGlobalConfig(current => ({ - ...current, - theme: setting - })); + saveGlobalConfig(current => ({ ...current, theme: setting })) } + export function ThemeProvider({ children, initialState, - onThemeSave = defaultSaveTheme + onThemeSave = defaultSaveTheme, }: Props) { - const [themeSetting, setThemeSetting] = useState(initialState ?? defaultInitialTheme); - const [previewTheme, setPreviewTheme] = useState(null); + const [themeSetting, setThemeSetting] = useState( + initialState ?? defaultInitialTheme, + ) + const [previewTheme, setPreviewTheme] = useState(null) // Track terminal theme for 'auto' resolution. Seeds from $COLORFGBG (or // 'dark' if unset); the OSC 11 watcher corrects it on first poll. - const [systemTheme, setSystemTheme] = useState(() => (initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark'); + const [systemTheme, setSystemTheme] = useState(() => + (initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark', + ) // The setting currently in effect (preview wins while picker is open) - const activeSetting = previewTheme ?? themeSetting; - const { - internal_querier - } = useStdin(); + const activeSetting = previewTheme ?? themeSetting + + const { internal_querier } = useStdin() // Watch for live terminal theme changes while 'auto' is active. // Positive feature() pattern so the watcher import is dead-code-eliminated // in external builds. useEffect(() => { if (feature('AUTO_THEME')) { - if (activeSetting !== 'auto' || !internal_querier) return; - let cleanup: (() => void) | undefined; - let cancelled = false; - void import('../../utils/systemThemeWatcher.js').then(({ - watchSystemTheme - }) => { - if (cancelled) return; - cleanup = watchSystemTheme(internal_querier, setSystemTheme); - }); + if (activeSetting !== 'auto' || !internal_querier) return + let cleanup: (() => void) | undefined + let cancelled = false + void import('../../utils/systemThemeWatcher.js').then( + ({ watchSystemTheme }) => { + if (cancelled) return + cleanup = watchSystemTheme(internal_querier, setSystemTheme) + }, + ) return () => { - cancelled = true; - cleanup?.(); - }; - } - }, [activeSetting, internal_querier]); - const currentTheme: ThemeName = activeSetting === 'auto' ? systemTheme : activeSetting; - const value = useMemo(() => ({ - themeSetting, - setThemeSetting: (newSetting: ThemeSetting) => { - setThemeSetting(newSetting); - setPreviewTheme(null); - // Switching to 'auto' restarts the watcher (activeSetting dep), whose - // first poll fires immediately. Seed from the cache so the OSC - // round-trip doesn't flash the wrong palette. - if (newSetting === 'auto') { - setSystemTheme(getSystemThemeName()); - } - onThemeSave?.(newSetting); - }, - setPreviewTheme: (newSetting_0: ThemeSetting) => { - setPreviewTheme(newSetting_0); - if (newSetting_0 === 'auto') { - setSystemTheme(getSystemThemeName()); - } - }, - savePreview: () => { - if (previewTheme !== null) { - setThemeSetting(previewTheme); - setPreviewTheme(null); - onThemeSave?.(previewTheme); + cancelled = true + cleanup?.() } - }, - cancelPreview: () => { - if (previewTheme !== null) { - setPreviewTheme(null); - } - }, - currentTheme - }), [themeSetting, previewTheme, currentTheme, onThemeSave]); - return {children}; + } + }, [activeSetting, internal_querier]) + + const currentTheme: ThemeName = + activeSetting === 'auto' ? systemTheme : activeSetting + + const value = useMemo( + () => ({ + themeSetting, + setThemeSetting: (newSetting: ThemeSetting) => { + setThemeSetting(newSetting) + setPreviewTheme(null) + // Switching to 'auto' restarts the watcher (activeSetting dep), whose + // first poll fires immediately. Seed from the cache so the OSC + // round-trip doesn't flash the wrong palette. + if (newSetting === 'auto') { + setSystemTheme(getSystemThemeName()) + } + onThemeSave?.(newSetting) + }, + setPreviewTheme: (newSetting: ThemeSetting) => { + setPreviewTheme(newSetting) + if (newSetting === 'auto') { + setSystemTheme(getSystemThemeName()) + } + }, + savePreview: () => { + if (previewTheme !== null) { + setThemeSetting(previewTheme) + setPreviewTheme(null) + onThemeSave?.(previewTheme) + } + }, + cancelPreview: () => { + if (previewTheme !== null) { + setPreviewTheme(null) + } + }, + currentTheme, + }), + [themeSetting, previewTheme, currentTheme, onThemeSave], + ) + + return {children} } /** * Returns the resolved theme for rendering (never 'auto') and a setter that * accepts any ThemeSetting (including 'auto'). */ -export function useTheme() { - const $ = _c(3); - const { - currentTheme, - setThemeSetting - } = useContext(ThemeContext); - let t0; - if ($[0] !== currentTheme || $[1] !== setThemeSetting) { - t0 = [currentTheme, setThemeSetting]; - $[0] = currentTheme; - $[1] = setThemeSetting; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; +export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] { + const { currentTheme, setThemeSetting } = useContext(ThemeContext) + return [currentTheme, setThemeSetting] } /** * Returns the raw theme setting as stored in config. Use this in UI that * needs to show 'auto' as a distinct choice (e.g., ThemePicker). */ -export function useThemeSetting() { - return useContext(ThemeContext).themeSetting; +export function useThemeSetting(): ThemeSetting { + return useContext(ThemeContext).themeSetting } + export function usePreviewTheme() { - const $ = _c(4); - const { - setPreviewTheme, - savePreview, - cancelPreview - } = useContext(ThemeContext); - let t0; - if ($[0] !== cancelPreview || $[1] !== savePreview || $[2] !== setPreviewTheme) { - t0 = { - setPreviewTheme, - savePreview, - cancelPreview - }; - $[0] = cancelPreview; - $[1] = savePreview; - $[2] = setPreviewTheme; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + const { setPreviewTheme, savePreview, cancelPreview } = + useContext(ThemeContext) + return { setPreviewTheme, savePreview, cancelPreview } } diff --git a/src/components/design-system/ThemedBox.tsx b/src/components/design-system/ThemedBox.tsx index 0b56f18a6..10fbe9137 100644 --- a/src/components/design-system/ThemedBox.tsx +++ b/src/components/design-system/ThemedBox.tsx @@ -1,155 +1,112 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren, type Ref } from 'react'; -import Box from '../../ink/components/Box.js'; -import type { DOMElement } from '../../ink/dom.js'; -import type { ClickEvent } from '../../ink/events/click-event.js'; -import type { FocusEvent } from '../../ink/events/focus-event.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import type { Color, Styles } from '../../ink/styles.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { useTheme } from './ThemeProvider.js'; +import React, { type PropsWithChildren, type Ref } from 'react' +import Box from '../../ink/components/Box.js' +import type { DOMElement } from '../../ink/dom.js' +import type { ClickEvent } from '../../ink/events/click-event.js' +import type { FocusEvent } from '../../ink/events/focus-event.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import type { Color, Styles } from '../../ink/styles.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { useTheme } from './ThemeProvider.js' // Color props that accept theme keys type ThemedColorProps = { - readonly borderColor?: keyof Theme | Color; - readonly borderTopColor?: keyof Theme | Color; - readonly borderBottomColor?: keyof Theme | Color; - readonly borderLeftColor?: keyof Theme | Color; - readonly borderRightColor?: keyof Theme | Color; - readonly backgroundColor?: keyof Theme | Color; -}; + readonly borderColor?: keyof Theme | Color + readonly borderTopColor?: keyof Theme | Color + readonly borderBottomColor?: keyof Theme | Color + readonly borderLeftColor?: keyof Theme | Color + readonly borderRightColor?: keyof Theme | Color + readonly backgroundColor?: keyof Theme | Color +} // Base Styles without color props (they'll be overridden) -type BaseStylesWithoutColors = Omit; -export type Props = BaseStylesWithoutColors & ThemedColorProps & { - ref?: Ref; - tabIndex?: number; - autoFocus?: boolean; - 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; - onMouseEnter?: () => void; - onMouseLeave?: () => void; -}; +type BaseStylesWithoutColors = Omit< + Styles, + | 'textWrap' + | 'borderColor' + | 'borderTopColor' + | 'borderBottomColor' + | 'borderLeftColor' + | 'borderRightColor' + | 'backgroundColor' +> + +export type Props = BaseStylesWithoutColors & + ThemedColorProps & { + ref?: Ref + tabIndex?: number + autoFocus?: boolean + 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 + onMouseEnter?: () => void + onMouseLeave?: () => void + } /** * Resolves a color value that may be a theme key to a raw Color. */ -function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined { - if (!color) return undefined; +function resolveColor( + color: keyof Theme | Color | undefined, + theme: Theme, +): Color | undefined { + if (!color) return undefined // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) { - return color as Color; + if ( + color.startsWith('rgb(') || + color.startsWith('#') || + color.startsWith('ansi256(') || + color.startsWith('ansi:') + ) { + return color as Color } // It's a theme key - resolve it - return theme[color as keyof Theme] as Color; + return theme[color as keyof Theme] as Color } /** * Theme-aware Box component that resolves theme color keys to raw colors. * This wraps the base Box component with theme resolution for border colors. */ -function ThemedBox(t0) { - const $ = _c(33); - let backgroundColor; - let borderBottomColor; - let borderColor; - let borderLeftColor; - let borderRightColor; - let borderTopColor; - let children; - let ref; - let rest; - if ($[0] !== t0) { - ({ - borderColor, - borderTopColor, - borderBottomColor, - borderLeftColor, - borderRightColor, - backgroundColor, - children, - ref, - ...rest - } = t0); - $[0] = t0; - $[1] = backgroundColor; - $[2] = borderBottomColor; - $[3] = borderColor; - $[4] = borderLeftColor; - $[5] = borderRightColor; - $[6] = borderTopColor; - $[7] = children; - $[8] = ref; - $[9] = rest; - } else { - backgroundColor = $[1]; - borderBottomColor = $[2]; - borderColor = $[3]; - borderLeftColor = $[4]; - borderRightColor = $[5]; - borderTopColor = $[6]; - children = $[7]; - ref = $[8]; - rest = $[9]; - } - const [themeName] = useTheme(); - let resolvedBorderBottomColor; - let resolvedBorderColor; - let resolvedBorderLeftColor; - let resolvedBorderRightColor; - let resolvedBorderTopColor; - let t1; - if ($[10] !== backgroundColor || $[11] !== borderBottomColor || $[12] !== borderColor || $[13] !== borderLeftColor || $[14] !== borderRightColor || $[15] !== borderTopColor || $[16] !== themeName) { - const theme = getTheme(themeName); - resolvedBorderColor = resolveColor(borderColor, theme); - resolvedBorderTopColor = resolveColor(borderTopColor, theme); - resolvedBorderBottomColor = resolveColor(borderBottomColor, theme); - resolvedBorderLeftColor = resolveColor(borderLeftColor, theme); - resolvedBorderRightColor = resolveColor(borderRightColor, theme); - t1 = resolveColor(backgroundColor, theme); - $[10] = backgroundColor; - $[11] = borderBottomColor; - $[12] = borderColor; - $[13] = borderLeftColor; - $[14] = borderRightColor; - $[15] = borderTopColor; - $[16] = themeName; - $[17] = resolvedBorderBottomColor; - $[18] = resolvedBorderColor; - $[19] = resolvedBorderLeftColor; - $[20] = resolvedBorderRightColor; - $[21] = resolvedBorderTopColor; - $[22] = t1; - } else { - resolvedBorderBottomColor = $[17]; - resolvedBorderColor = $[18]; - resolvedBorderLeftColor = $[19]; - resolvedBorderRightColor = $[20]; - resolvedBorderTopColor = $[21]; - t1 = $[22]; - } - const resolvedBackgroundColor = t1; - let t2; - if ($[23] !== children || $[24] !== ref || $[25] !== resolvedBackgroundColor || $[26] !== resolvedBorderBottomColor || $[27] !== resolvedBorderColor || $[28] !== resolvedBorderLeftColor || $[29] !== resolvedBorderRightColor || $[30] !== resolvedBorderTopColor || $[31] !== rest) { - t2 = {children}; - $[23] = children; - $[24] = ref; - $[25] = resolvedBackgroundColor; - $[26] = resolvedBorderBottomColor; - $[27] = resolvedBorderColor; - $[28] = resolvedBorderLeftColor; - $[29] = resolvedBorderRightColor; - $[30] = resolvedBorderTopColor; - $[31] = rest; - $[32] = t2; - } else { - t2 = $[32]; - } - return t2; +function ThemedBox({ + borderColor, + borderTopColor, + borderBottomColor, + borderLeftColor, + borderRightColor, + backgroundColor, + children, + ref, + ...rest +}: PropsWithChildren): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + + // Resolve theme keys to raw colors + const resolvedBorderColor = resolveColor(borderColor, theme) + const resolvedBorderTopColor = resolveColor(borderTopColor, theme) + const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme) + const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme) + const resolvedBorderRightColor = resolveColor(borderRightColor, theme) + const resolvedBackgroundColor = resolveColor(backgroundColor, theme) + + return ( + + {children} + + ) } -export default ThemedBox; + +export default ThemedBox diff --git a/src/components/design-system/ThemedText.tsx b/src/components/design-system/ThemedText.tsx index abaa68f23..3c32b8bc3 100644 --- a/src/components/design-system/ThemedText.tsx +++ b/src/components/design-system/ThemedText.tsx @@ -1,123 +1,132 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React, { useContext } from 'react'; -import Text from '../../ink/components/Text.js'; -import type { Color, Styles } from '../../ink/styles.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { useTheme } from './ThemeProvider.js'; +import type { ReactNode } from 'react' +import React, { useContext } from 'react' +import Text from '../../ink/components/Text.js' +import type { Color, Styles } from '../../ink/styles.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { useTheme } from './ThemeProvider.js' /** Colors uncolored ThemedText in the subtree. Precedence: explicit `color` > * this > dimColor. Crosses Box boundaries (Ink's style cascade doesn't). */ -export const TextHoverColorContext = React.createContext(undefined); +export const TextHoverColorContext = React.createContext< + keyof Theme | undefined +>(undefined) + export type Props = { /** * Change text color. Accepts a theme key or raw color value. */ - readonly color?: keyof Theme | Color; + readonly color?: keyof Theme | Color /** * Same as `color`, but for background. Must be a theme key. */ - readonly backgroundColor?: keyof Theme; + readonly backgroundColor?: keyof Theme /** * Dim the color using the theme's inactive color. * This is compatible with bold (unlike ANSI dim). */ - readonly dimColor?: boolean; + readonly dimColor?: boolean /** * Make the text bold. */ - readonly bold?: boolean; + readonly bold?: boolean /** * 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 +} /** * Resolves a color value that may be a theme key to a raw Color. */ -function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined { - if (!color) return undefined; +function resolveColor( + color: keyof Theme | Color | undefined, + theme: Theme, +): Color | undefined { + if (!color) return undefined // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) { - return color as Color; + if ( + color.startsWith('rgb(') || + color.startsWith('#') || + color.startsWith('ansi256(') || + color.startsWith('ansi:') + ) { + return color as Color } // It's a theme key - resolve it - return theme[color as keyof Theme] as Color; + return theme[color as keyof Theme] as Color } /** * Theme-aware Text component that resolves theme color keys to raw colors. * This wraps the base Text component with theme resolution. */ -export default function ThemedText(t0) { - const $ = _c(10); - const { - color, - backgroundColor, - dimColor: t1, - bold: t2, - italic: t3, - underline: t4, - strikethrough: t5, - inverse: t6, - wrap: t7, - children - } = t0; - const dimColor = t1 === undefined ? false : t1; - const bold = t2 === undefined ? false : t2; - const italic = t3 === undefined ? false : t3; - const underline = t4 === undefined ? false : t4; - const strikethrough = t5 === undefined ? false : t5; - const inverse = t6 === undefined ? false : t6; - const wrap = t7 === undefined ? "wrap" : t7; - const [themeName] = useTheme(); - const theme = getTheme(themeName); - const hoverColor = useContext(TextHoverColorContext); - const resolvedColor = !color && hoverColor ? resolveColor(hoverColor, theme) : dimColor ? theme.inactive as Color : resolveColor(color, theme); - const resolvedBackgroundColor = backgroundColor ? theme[backgroundColor] as Color : undefined; - let t8; - if ($[0] !== bold || $[1] !== children || $[2] !== inverse || $[3] !== italic || $[4] !== resolvedBackgroundColor || $[5] !== resolvedColor || $[6] !== strikethrough || $[7] !== underline || $[8] !== wrap) { - t8 = {children}; - $[0] = bold; - $[1] = children; - $[2] = inverse; - $[3] = italic; - $[4] = resolvedBackgroundColor; - $[5] = resolvedColor; - $[6] = strikethrough; - $[7] = underline; - $[8] = wrap; - $[9] = t8; - } else { - t8 = $[9]; - } - return t8; +export default function ThemedText({ + color, + backgroundColor, + dimColor = false, + bold = false, + italic = false, + underline = false, + strikethrough = false, + inverse = false, + wrap = 'wrap', + children, +}: Props): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + const hoverColor = useContext(TextHoverColorContext) + + // Resolve theme keys to raw colors + const resolvedColor = + !color && hoverColor + ? resolveColor(hoverColor, theme) + : dimColor + ? (theme.inactive as Color) + : resolveColor(color, theme) + const resolvedBackgroundColor = backgroundColor + ? (theme[backgroundColor] as Color) + : undefined + + return ( + + {children} + + ) } diff --git a/src/components/skills/SkillsMenu.tsx b/src/components/skills/SkillsMenu.tsx index df78c1af4..f8f2896a6 100644 --- a/src/components/skills/SkillsMenu.tsx +++ b/src/components/skills/SkillsMenu.tsx @@ -1,236 +1,205 @@ -import { c as _c } from "react/compiler-runtime"; -import capitalize from 'lodash-es/capitalize.js'; -import * as React from 'react'; -import { useMemo } from 'react'; -import { type Command, type CommandBase, type CommandResultDisplay, getCommandName, type PromptCommand } from '../../commands.js'; -import { Box, Text } from '../../ink.js'; -import { estimateSkillFrontmatterTokens, getSkillsPath } from '../../skills/loadSkillsDir.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatTokens } from '../../utils/format.js'; -import { getSettingSourceName, type SettingSource } from '../../utils/settings/constants.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Dialog } from '../design-system/Dialog.js'; +import capitalize from 'lodash-es/capitalize.js' +import * as React from 'react' +import { useMemo } from 'react' +import { + type Command, + type CommandBase, + type CommandResultDisplay, + getCommandName, + type PromptCommand, +} from '../../commands.js' +import { Box, Text } from '../../ink.js' +import { + estimateSkillFrontmatterTokens, + getSkillsPath, +} from '../../skills/loadSkillsDir.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatTokens } from '../../utils/format.js' +import { + getSettingSourceName, + type SettingSource, +} from '../../utils/settings/constants.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Dialog } from '../design-system/Dialog.js' // Skills are always PromptCommands with CommandBase properties -type SkillCommand = CommandBase & PromptCommand; -type SkillSource = SettingSource | 'plugin' | 'mcp'; +type SkillCommand = CommandBase & PromptCommand + +type SkillSource = SettingSource | 'plugin' | 'mcp' + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - commands: Command[]; -}; + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + commands: Command[] +} + function getSourceTitle(source: SkillSource): string { if (source === 'plugin') { - return 'Plugin skills'; + return 'Plugin skills' } if (source === 'mcp') { - return 'MCP skills'; + return 'MCP skills' } - return `${capitalize(getSettingSourceName(source))} skills`; + return `${capitalize(getSettingSourceName(source))} skills` } -function getSourceSubtitle(source: SkillSource, skills: SkillCommand[]): string | undefined { + +function getSourceSubtitle( + source: SkillSource, + skills: SkillCommand[], +): string | undefined { // MCP skills show server names; file-based skills show filesystem paths. // Skill names are `:`, not `mcp____…`. if (source === 'mcp') { - const servers = [...new Set(skills.map(s => { - const idx = s.name.indexOf(':'); - return idx > 0 ? s.name.slice(0, idx) : null; - }).filter((n): n is string => n != null))]; - return servers.length > 0 ? servers.join(', ') : undefined; - } - const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')); - const hasCommandsSkills = skills.some(s => s.loadedFrom === 'commands_DEPRECATED'); - return hasCommandsSkills ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` : skillsPath; + const servers = [ + ...new Set( + skills + .map(s => { + const idx = s.name.indexOf(':') + return idx > 0 ? s.name.slice(0, idx) : null + }) + .filter((n): n is string => n != null), + ), + ] + return servers.length > 0 ? servers.join(', ') : undefined + } + const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')) + const hasCommandsSkills = skills.some( + s => s.loadedFrom === 'commands_DEPRECATED', + ) + return hasCommandsSkills + ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` + : skillsPath } -export function SkillsMenu(t0) { - const $ = _c(35); - const { - onExit, - commands - } = t0; - let t1; - if ($[0] !== commands) { - t1 = commands.filter(_temp); - $[0] = commands; - $[1] = t1; - } else { - t1 = $[1]; - } - const skills = t1; - let groups; - if ($[2] !== skills) { - groups = { + +export function SkillsMenu({ onExit, commands }: Props): React.ReactNode { + // Filter commands for skills and cast to SkillCommand + const skills = useMemo(() => { + return commands.filter( + (cmd): cmd is SkillCommand => + cmd.type === 'prompt' && + (cmd.loadedFrom === 'skills' || + cmd.loadedFrom === 'commands_DEPRECATED' || + cmd.loadedFrom === 'plugin' || + cmd.loadedFrom === 'mcp'), + ) + }, [commands]) + + const skillsBySource = useMemo((): Record => { + const groups: Record = { policySettings: [], userSettings: [], projectSettings: [], localSettings: [], flagSettings: [], plugin: [], - mcp: [] - }; + mcp: [], + } + for (const skill of skills) { - const source = skill.source as SkillSource; + const source = skill.source as SkillSource if (source in groups) { - groups[source].push(skill); + groups[source].push(skill) } } + for (const group of Object.values(groups)) { - (group as Array<{ name: string }>).sort(_temp2); + group.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b))) } - $[2] = skills; - $[3] = groups; - } else { - groups = $[3]; - } - const skillsBySource = groups; - let t2; - if ($[4] !== onExit) { - t2 = () => { - onExit("Skills dialog dismissed", { - display: "system" - }); - }; - $[4] = onExit; - $[5] = t2; - } else { - t2 = $[5]; + + return groups + }, [skills]) + + const handleCancel = (): void => { + onExit('Skills dialog dismissed', { display: 'system' }) } - const handleCancel = t2; + if (skills.length === 0) { - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Create skills in .claude/skills/ or ~/.claude/skills/; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== handleCancel) { - t5 = {t3}{t4}; - $[8] = handleCancel; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + return ( + + + Create skills in .claude/skills/ or ~/.claude/skills/ + + + + + + ) } - const renderSkill = _temp3; - let t3; - if ($[10] !== skillsBySource) { - t3 = source_0 => { - const groupSkills = skillsBySource[source_0]; - if (groupSkills.length === 0) { - return null; - } - const title = getSourceTitle(source_0); - const subtitle = getSourceSubtitle(source_0, groupSkills); - return {title}{subtitle && ({subtitle})}{groupSkills.map(skill_1 => renderSkill(skill_1))}; - }; - $[10] = skillsBySource; - $[11] = t3; - } else { - t3 = $[11]; - } - const renderSkillGroup = t3; - const t4 = skills.length; - let t5; - if ($[12] !== skills.length) { - t5 = plural(skills.length, "skill"); - $[12] = skills.length; - $[13] = t5; - } else { - t5 = $[13]; - } - const t6 = `${t4} ${t5}`; - let t7; - if ($[14] !== renderSkillGroup) { - t7 = renderSkillGroup("projectSettings"); - $[14] = renderSkillGroup; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== renderSkillGroup) { - t8 = renderSkillGroup("userSettings"); - $[16] = renderSkillGroup; - $[17] = t8; - } else { - t8 = $[17]; - } - let t9; - if ($[18] !== renderSkillGroup) { - t9 = renderSkillGroup("policySettings"); - $[18] = renderSkillGroup; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== renderSkillGroup) { - t10 = renderSkillGroup("plugin"); - $[20] = renderSkillGroup; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== renderSkillGroup) { - t11 = renderSkillGroup("mcp"); - $[22] = renderSkillGroup; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== t10 || $[25] !== t11 || $[26] !== t7 || $[27] !== t8 || $[28] !== t9) { - t12 = {t7}{t8}{t9}{t10}{t11}; - $[24] = t10; - $[25] = t11; - $[26] = t7; - $[27] = t8; - $[28] = t9; - $[29] = t12; - } else { - t12 = $[29]; - } - let t13; - if ($[30] === Symbol.for("react.memo_cache_sentinel")) { - t13 = ; - $[30] = t13; - } else { - t13 = $[30]; + + const renderSkill = (skill: SkillCommand) => { + const estimatedTokens = estimateSkillFrontmatterTokens(skill) + const tokenDisplay = `~${formatTokens(estimatedTokens)}` + const pluginName = + skill.source === 'plugin' + ? skill.pluginInfo?.pluginManifest.name + : undefined + + return ( + + {getCommandName(skill)} + + {pluginName ? ` · ${pluginName}` : ''} · {tokenDisplay} description + tokens + + + ) } - let t14; - if ($[31] !== handleCancel || $[32] !== t12 || $[33] !== t6) { - t14 = {t12}{t13}; - $[31] = handleCancel; - $[32] = t12; - $[33] = t6; - $[34] = t14; - } else { - t14 = $[34]; + + const renderSkillGroup = (source: SkillSource) => { + const groupSkills = skillsBySource[source] + if (groupSkills.length === 0) return null + + const title = getSourceTitle(source) + const subtitle = getSourceSubtitle(source, groupSkills) + + return ( + + + + {title} + + {subtitle && ({subtitle})} + + {groupSkills.map(skill => renderSkill(skill))} + + ) } - return t14; -} -function _temp3(skill_0) { - const estimatedTokens = estimateSkillFrontmatterTokens(skill_0); - const tokenDisplay = `~${formatTokens(estimatedTokens)}`; - const pluginName = skill_0.source === "plugin" ? skill_0.pluginInfo?.pluginManifest.name : undefined; - return {getCommandName(skill_0)}{pluginName ? ` · ${pluginName}` : ""} · {tokenDisplay} description tokens; -} -function _temp2(a, b) { - return getCommandName(a).localeCompare(getCommandName(b)); -} -function _temp(cmd) { - return cmd.type === "prompt" && (cmd.loadedFrom === "skills" || cmd.loadedFrom === "commands_DEPRECATED" || cmd.loadedFrom === "plugin" || cmd.loadedFrom === "mcp"); + + return ( + + + {renderSkillGroup('projectSettings')} + {renderSkillGroup('userSettings')} + {renderSkillGroup('policySettings')} + {renderSkillGroup('plugin')} + {renderSkillGroup('mcp')} + + + + + + ) } diff --git a/src/components/tasks/AsyncAgentDetailDialog.tsx b/src/components/tasks/AsyncAgentDetailDialog.tsx index a942c105e..4174d4fa5 100644 --- a/src/components/tasks/AsyncAgentDetailDialog.tsx +++ b/src/components/tasks/AsyncAgentDetailDialog.tsx @@ -1,228 +1,200 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { getEmptyToolPermissionContext } from '../../Tool.js'; -import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { getTools } from '../../tools.js'; -import { formatNumber } from '../../utils/format.js'; -import { extractTag } from '../../utils/messages.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { UserPlanMessage } from '../messages/UserPlanMessage.js'; -import { renderToolActivity } from './renderToolActivity.js'; -import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js'; +import React, { useMemo } from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { getTools } from '../../tools.js' +import { formatNumber } from '../../utils/format.js' +import { extractTag } from '../../utils/messages.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { UserPlanMessage } from '../messages/UserPlanMessage.js' +import { renderToolActivity } from './renderToolActivity.js' +import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js' + type Props = { - agent: DeepImmutable; - onDone: () => void; - onKillAgent?: () => void; - onBack?: () => void; -}; -export function AsyncAgentDetailDialog(t0) { - const $ = _c(54); - const { - agent, - onDone, - onKillAgent, - onBack - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTools(getEmptyToolPermissionContext()); - $[0] = t1; - } else { - t1 = $[0]; - } - const tools = t1; - const elapsedTime = useElapsedTime(agent.startTime, agent.status === "running", 1000, agent.totalPausedMs ?? 0); - let t2; - if ($[1] !== onDone) { - t2 = { - "confirm:yes": onDone - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybindings(t2, t3); - let t4; - if ($[4] !== agent.status || $[5] !== onBack || $[6] !== onDone || $[7] !== onKillAgent) { - t4 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && agent.status === "running" && onKillAgent) { - e.preventDefault(); - onKillAgent(); - } + agent: DeepImmutable + onDone: () => void + onKillAgent?: () => void + onBack?: () => void +} + +export function AsyncAgentDetailDialog({ + agent, + onDone, + onKillAgent, + onBack, +}: Props): React.ReactNode { + const [theme] = useTheme() + + // Get tools for rendering activity messages + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + + const elapsedTime = useElapsedTime( + agent.startTime, + agent.status === 'running', + 1000, + agent.totalPausedMs ?? 0, + ) + + // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) + // internally but does NOT auto-wire confirm:yes. + useKeybindings( + { + 'confirm:yes': onDone, + }, + { context: 'Confirmation' }, + ) + + // Component-specific shortcuts shown in UI hints (x=stop) and + // navigation keys (space=dismiss, left=back). These are context-dependent + // actions tied to agent state, not standard dialog keybindings. + // Note: Dialog component already handles ESC via confirm:no keybinding; + // confirm:yes (Enter/y) is handled by useKeybindings above. + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && agent.status === 'running' && onKillAgent) { + e.preventDefault() + onKillAgent() + } + } + + // Extract plan from prompt - if present, we show the plan instead of the prompt + const planContent = extractTag(agent.prompt, 'plan') + + const displayPrompt = + agent.prompt.length > 300 + ? agent.prompt.substring(0, 297) + '…' + : agent.prompt + + // Get tokens and tool uses (from result if completed, otherwise from progress) + const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount + const toolUseCount = + agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount + + const title = ( + + {agent.selectedAgent?.agentType ?? 'agent'} ›{' '} + {agent.description || 'Async agent'} + + ) + + // Build subtitle with status and stats + const subtitle = ( + + {agent.status !== 'running' && ( + + {getTaskStatusIcon(agent.status)}{' '} + {agent.status === 'completed' + ? 'Completed' + : agent.status === 'failed' + ? 'Failed' + : 'Stopped'} + {' · '} + + )} + + {elapsedTime} + {tokenCount !== undefined && tokenCount > 0 && ( + <> · {formatNumber(tokenCount)} tokens + )} + {toolUseCount !== undefined && toolUseCount > 0 && ( + <> + {' '} + · {toolUseCount} {toolUseCount === 1 ? 'tool' : 'tools'} + + )} + + + ) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {agent.status === 'running' && onKillAgent && ( + + )} + + ) } - } - }; - $[4] = agent.status; - $[5] = onBack; - $[6] = onDone; - $[7] = onKillAgent; - $[8] = t4; - } else { - t4 = $[8]; - } - const handleKeyDown = t4; - let t5; - if ($[9] !== agent.prompt) { - t5 = extractTag(agent.prompt, "plan"); - $[9] = agent.prompt; - $[10] = t5; - } else { - t5 = $[10]; - } - const planContent = t5; - const displayPrompt = agent.prompt.length > 300 ? agent.prompt.substring(0, 297) + "\u2026" : agent.prompt; - const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount; - const toolUseCount = agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount; - const t6 = agent.selectedAgent?.agentType ?? "agent"; - const t7 = agent.description || "Async agent"; - let t8; - if ($[11] !== t6 || $[12] !== t7) { - t8 = {t6} ›{" "}{t7}; - $[11] = t6; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - const title = t8; - let t9; - if ($[14] !== agent.status) { - t9 = agent.status !== "running" && {getTaskStatusIcon(agent.status)}{" "}{agent.status === "completed" ? "Completed" : agent.status === "failed" ? "Failed" : "Stopped"}{" \xB7 "}; - $[14] = agent.status; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== tokenCount) { - t10 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[16] = tokenCount; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== toolUseCount) { - t11 = toolUseCount !== undefined && toolUseCount > 0 && <>{" "}· {toolUseCount} {toolUseCount === 1 ? "tool" : "tools"}; - $[18] = toolUseCount; - $[19] = t11; - } else { - t11 = $[19]; - } - let t12; - if ($[20] !== elapsedTime || $[21] !== t10 || $[22] !== t11) { - t12 = {elapsedTime}{t10}{t11}; - $[20] = elapsedTime; - $[21] = t10; - $[22] = t11; - $[23] = t12; - } else { - t12 = $[23]; - } - let t13; - if ($[24] !== t12 || $[25] !== t9) { - t13 = {t9}{t12}; - $[24] = t12; - $[25] = t9; - $[26] = t13; - } else { - t13 = $[26]; - } - const subtitle = t13; - let t14; - if ($[27] !== agent.status || $[28] !== onBack || $[29] !== onKillAgent) { - t14 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{agent.status === "running" && onKillAgent && }; - $[27] = agent.status; - $[28] = onBack; - $[29] = onKillAgent; - $[30] = t14; - } else { - t14 = $[30]; - } - let t15; - if ($[31] !== agent.progress || $[32] !== agent.status || $[33] !== theme) { - t15 = agent.status === "running" && agent.progress?.recentActivities && agent.progress.recentActivities.length > 0 && Progress{agent.progress.recentActivities.map((activity, i) => {i === agent.progress.recentActivities.length - 1 ? "\u203A " : " "}{renderToolActivity(activity, tools, theme)})}; - $[31] = agent.progress; - $[32] = agent.status; - $[33] = theme; - $[34] = t15; - } else { - t15 = $[34]; - } - let t16; - if ($[35] !== displayPrompt || $[36] !== planContent) { - t16 = planContent ? : Prompt{displayPrompt}; - $[35] = displayPrompt; - $[36] = planContent; - $[37] = t16; - } else { - t16 = $[37]; - } - let t17; - if ($[38] !== agent.error || $[39] !== agent.status) { - t17 = agent.status === "failed" && agent.error && Error{agent.error}; - $[38] = agent.error; - $[39] = agent.status; - $[40] = t17; - } else { - t17 = $[40]; - } - let t18; - if ($[41] !== t15 || $[42] !== t16 || $[43] !== t17) { - t18 = {t15}{t16}{t17}; - $[41] = t15; - $[42] = t16; - $[43] = t17; - $[44] = t18; - } else { - t18 = $[44]; - } - let t19; - if ($[45] !== onDone || $[46] !== subtitle || $[47] !== t14 || $[48] !== t18 || $[49] !== title) { - t19 = {t18}; - $[45] = onDone; - $[46] = subtitle; - $[47] = t14; - $[48] = t18; - $[49] = title; - $[50] = t19; - } else { - t19 = $[50]; - } - let t20; - if ($[51] !== handleKeyDown || $[52] !== t19) { - t20 = {t19}; - $[51] = handleKeyDown; - $[52] = t19; - $[53] = t20; - } else { - t20 = $[53]; - } - return t20; + > + + {/* Recent activities for running agents */} + {agent.status === 'running' && + agent.progress?.recentActivities && + agent.progress.recentActivities.length > 0 && ( + + + Progress + + {agent.progress.recentActivities.map((activity, i) => ( + + {i === agent.progress!.recentActivities!.length - 1 + ? '› ' + : ' '} + {renderToolActivity(activity, tools, theme)} + + ))} + + )} + + {/* Plan section (if present) - shown instead of prompt */} + {planContent ? ( + + + + ) : ( + /* Prompt section - only shown when no plan */ + + + Prompt + + {displayPrompt} + + )} + + {/* Error details if failed */} + {agent.status === 'failed' && agent.error && ( + + + Error + + + {agent.error} + + + )} + + + + ) } diff --git a/src/components/tasks/BackgroundTask.tsx b/src/components/tasks/BackgroundTask.tsx index a6923b1da..fd48d09e7 100644 --- a/src/components/tasks/BackgroundTask.tsx +++ b/src/components/tasks/BackgroundTask.tsx @@ -1,344 +1,146 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from 'src/ink.js'; -import type { BackgroundTaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { truncate } from 'src/utils/format.js'; -import { toInkColor } from 'src/utils/ink.js'; -import { plural } from 'src/utils/stringUtils.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { RemoteSessionProgress } from './RemoteSessionProgress.js'; -import { ShellProgress, TaskStatusText } from './ShellProgress.js'; -import { describeTeammateActivity } from './taskStatusUtils.js'; +import * as React from 'react' +import { Text } from 'src/ink.js' +import type { BackgroundTaskState } from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { truncate } from 'src/utils/format.js' +import { toInkColor } from 'src/utils/ink.js' +import { plural } from 'src/utils/stringUtils.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { RemoteSessionProgress } from './RemoteSessionProgress.js' +import { ShellProgress, TaskStatusText } from './ShellProgress.js' +import { describeTeammateActivity } from './taskStatusUtils.js' + type Props = { - task: DeepImmutable; - maxActivityWidth?: number; -}; -export function BackgroundTask(t0) { - const $ = _c(92); - const { - task, - maxActivityWidth - } = t0; - const activityLimit = maxActivityWidth ?? 40; + task: DeepImmutable + maxActivityWidth?: number +} + +export function BackgroundTask({ + task, + maxActivityWidth, +}: Props): React.ReactNode { + const activityLimit = maxActivityWidth ?? 40 switch (task.type) { - case "local_bash": - { - const t1 = task.kind === "monitor" ? task.description : task.command; - let t2; - if ($[0] !== activityLimit || $[1] !== t1) { - t2 = truncate(t1, activityLimit, true); - $[0] = activityLimit; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== task) { - t3 = ; - $[3] = task; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{" "}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; - } - case "remote_agent": - { - if (task.isRemoteReview) { - let t1; - if ($[8] !== task) { - t1 = ; - $[8] = task; - $[9] = t1; - } else { - t1 = $[9]; - } - return t1; - } - const running = task.status === "running" || task.status === "pending"; - const t1 = running ? DIAMOND_OPEN : DIAMOND_FILLED; - let t2; - if ($[10] !== t1) { - t2 = {t1} ; - $[10] = t1; - $[11] = t2; - } else { - t2 = $[11]; - } - let t3; - if ($[12] !== activityLimit || $[13] !== task.title) { - t3 = truncate(task.title, activityLimit, true); - $[12] = activityLimit; - $[13] = task.title; - $[14] = t3; - } else { - t3 = $[14]; - } - let t4; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t4 = · ; - $[15] = t4; - } else { - t4 = $[15]; - } - let t5; - if ($[16] !== task) { - t5 = ; - $[16] = task; - $[17] = t5; - } else { - t5 = $[17]; - } - let t6; - if ($[18] !== t2 || $[19] !== t3 || $[20] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[18] = t2; - $[19] = t3; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - return t6; - } - case "local_agent": - { - let t1; - if ($[22] !== activityLimit || $[23] !== task.description) { - t1 = truncate(task.description, activityLimit, true); - $[22] = activityLimit; - $[23] = task.description; - $[24] = t1; - } else { - t1 = $[24]; - } - const t2 = task.status === "completed" ? "done" : undefined; - const t3 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t4; - if ($[25] !== t2 || $[26] !== t3 || $[27] !== task.status) { - t4 = ; - $[25] = t2; - $[26] = t3; - $[27] = task.status; - $[28] = t4; - } else { - t4 = $[28]; - } - let t5; - if ($[29] !== t1 || $[30] !== t4) { - t5 = {t1}{" "}{t4}; - $[29] = t1; - $[30] = t4; - $[31] = t5; - } else { - t5 = $[31]; - } - return t5; - } - case "in_process_teammate": - { - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - if ($[32] !== activityLimit || $[33] !== task) { - const activity = describeTeammateActivity(task); - T1 = Text; - let t5; - if ($[40] !== task.identity.color) { - t5 = toInkColor(task.identity.color); - $[40] = task.identity.color; - $[41] = t5; - } else { - t5 = $[41]; - } - if ($[42] !== t5 || $[43] !== task.identity.agentName) { - t4 = @{task.identity.agentName}; - $[42] = t5; - $[43] = task.identity.agentName; - $[44] = t4; - } else { - t4 = $[44]; - } - T0 = Text; - t1 = true; - t2 = ": "; - t3 = truncate(activity, activityLimit, true); - $[32] = activityLimit; - $[33] = task; - $[34] = T0; - $[35] = T1; - $[36] = t1; - $[37] = t2; - $[38] = t3; - $[39] = t4; - } else { - T0 = $[34]; - T1 = $[35]; - t1 = $[36]; - t2 = $[37]; - t3 = $[38]; - t4 = $[39]; - } - let t5; - if ($[45] !== T0 || $[46] !== t1 || $[47] !== t2 || $[48] !== t3) { - t5 = {t2}{t3}; - $[45] = T0; - $[46] = t1; - $[47] = t2; - $[48] = t3; - $[49] = t5; - } else { - t5 = $[49]; - } - let t6; - if ($[50] !== T1 || $[51] !== t4 || $[52] !== t5) { - t6 = {t4}{t5}; - $[50] = T1; - $[51] = t4; - $[52] = t5; - $[53] = t6; - } else { - t6 = $[53]; - } - return t6; - } - case "local_workflow": - { - const t1 = task.workflowName ?? task.summary ?? task.description; - let t2; - if ($[54] !== activityLimit || $[55] !== t1) { - t2 = truncate(t1, activityLimit, true); - $[54] = activityLimit; - $[55] = t1; - $[56] = t2; - } else { - t2 = $[56]; - } - let t3; - if ($[57] !== task.agentCount || $[58] !== task.status) { - t3 = task.status === "running" ? `${task.agentCount} ${plural(task.agentCount, "agent")}` : task.status === "completed" ? "done" : undefined; - $[57] = task.agentCount; - $[58] = task.status; - $[59] = t3; - } else { - t3 = $[59]; - } - const t4 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t5; - if ($[60] !== t3 || $[61] !== t4 || $[62] !== task.status) { - t5 = ; - $[60] = t3; - $[61] = t4; - $[62] = task.status; - $[63] = t5; - } else { - t5 = $[63]; - } - let t6; - if ($[64] !== t2 || $[65] !== t5) { - t6 = {t2}{" "}{t5}; - $[64] = t2; - $[65] = t5; - $[66] = t6; - } else { - t6 = $[66]; - } - return t6; - } - case "monitor_mcp": - { - let t1; - if ($[67] !== activityLimit || $[68] !== task.description) { - t1 = truncate(task.description, activityLimit, true); - $[67] = activityLimit; - $[68] = task.description; - $[69] = t1; - } else { - t1 = $[69]; - } - const t2 = task.status === "completed" ? "done" : undefined; - const t3 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t4; - if ($[70] !== t2 || $[71] !== t3 || $[72] !== task.status) { - t4 = ; - $[70] = t2; - $[71] = t3; - $[72] = task.status; - $[73] = t4; - } else { - t4 = $[73]; - } - let t5; - if ($[74] !== t1 || $[75] !== t4) { - t5 = {t1}{" "}{t4}; - $[74] = t1; - $[75] = t4; - $[76] = t5; - } else { - t5 = $[76]; - } - return t5; - } - case "dream": - { - const n = task.filesTouched.length; - let t1; - if ($[77] !== n || $[78] !== task.phase || $[79] !== task.sessionsReviewing) { - t1 = task.phase === "updating" && n > 0 ? `${n} ${plural(n, "file")}` : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, "session")}`; - $[77] = n; - $[78] = task.phase; - $[79] = task.sessionsReviewing; - $[80] = t1; - } else { - t1 = $[80]; - } - const detail = t1; - let t2; - if ($[81] !== detail || $[82] !== task.phase) { - t2 = · {task.phase} · {detail}; - $[81] = detail; - $[82] = task.phase; - $[83] = t2; - } else { - t2 = $[83]; - } - const t3 = task.status === "completed" ? "done" : undefined; - const t4 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t5; - if ($[84] !== t3 || $[85] !== t4 || $[86] !== task.status) { - t5 = ; - $[84] = t3; - $[85] = t4; - $[86] = task.status; - $[87] = t5; - } else { - t5 = $[87]; - } - let t6; - if ($[88] !== t2 || $[89] !== t5 || $[90] !== task.description) { - t6 = {task.description}{" "}{t2}{" "}{t5}; - $[88] = t2; - $[89] = t5; - $[90] = task.description; - $[91] = t6; - } else { - t6 = $[91]; - } - return t6; + case 'local_bash': + return ( + + {truncate( + task.kind === 'monitor' ? task.description : task.command, + activityLimit, + true, + )}{' '} + + + ) + case 'remote_agent': { + // Lite-review renders its own rainbow line (title + live counts), + // so we don't prefix the title — the rainbow already includes it. + if (task.isRemoteReview) { + return ( + + + + ) } + const running = task.status === 'running' || task.status === 'pending' + return ( + + {running ? DIAMOND_OPEN : DIAMOND_FILLED} + {truncate(task.title, activityLimit, true)} + · + + + ) + } + case 'local_agent': + return ( + + {truncate(task.description, activityLimit, true)}{' '} + + + ) + case 'in_process_teammate': { + const activity = describeTeammateActivity(task) + return ( + + + @{task.identity.agentName} + + : {truncate(activity, activityLimit, true)} + + ) + } + case 'local_workflow': + return ( + + {truncate( + task.workflowName ?? task.summary ?? task.description, + activityLimit, + true, + )}{' '} + + + ) + case 'monitor_mcp': + return ( + + {truncate(task.description, activityLimit, true)}{' '} + + + ) + case 'dream': { + const n = task.filesTouched.length + const detail = + task.phase === 'updating' && n > 0 + ? `${n} ${plural(n, 'file')}` + : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, 'session')}` + return ( + + {task.description}{' '} + + · {task.phase} · {detail} + {' '} + + + ) + } } } diff --git a/src/components/tasks/BackgroundTaskStatus.tsx b/src/components/tasks/BackgroundTaskStatus.tsx index 37bfd8009..26d46cf98 100644 --- a/src/components/tasks/BackgroundTaskStatus.tsx +++ b/src/components/tasks/BackgroundTaskStatus.tsx @@ -1,428 +1,310 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useMemo, useState } from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { stringWidth } from 'src/ink/stringWidth.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; -import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js'; -import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js'; -import { Box, Text } from '../../ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import type { Theme } from '../../utils/theme.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { shouldHideTasksFooter } from './taskStatusUtils.js'; +import figures from 'figures' +import * as React from 'react' +import { useMemo, useState } from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { stringWidth } from 'src/ink/stringWidth.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from 'src/state/teammateViewHelpers.js' +import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js' +import { + type BackgroundTaskState, + isBackgroundTask, + type TaskState, +} from 'src/tasks/types.js' +import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js' +import { Box, Text } from '../../ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import type { Theme } from '../../utils/theme.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { shouldHideTasksFooter } from './taskStatusUtils.js' + type Props = { - tasksSelected: boolean; - isViewingTeammate?: boolean; - teammateFooterIndex?: number; - isLeaderIdle?: boolean; - onOpenDialog?: (taskId?: string) => void; -}; -export function BackgroundTaskStatus(t0) { - const $ = _c(48); - const { - tasksSelected, - isViewingTeammate, - teammateFooterIndex: t1, - isLeaderIdle: t2, - onOpenDialog - } = t0; - const teammateFooterIndex = t1 === undefined ? 0 : t1; - const isLeaderIdle = t2 === undefined ? false : t2; - const setAppState = useSetAppState(); - const { - columns - } = useTerminalSize(); - const tasks = useAppState(_temp); - const viewingAgentTaskId = useAppState(_temp2); - let t3; - if ($[0] !== tasks) { - t3 = (Object.values(tasks ?? {}) as TaskState[]).filter(_temp3); - $[0] = tasks; - $[1] = t3; - } else { - t3 = $[1]; - } - const runningTasks = t3; - const expandedView = useAppState(_temp4); - const showSpinnerTree = expandedView === "teammates"; - const allTeammates = !showSpinnerTree && runningTasks.length > 0 && runningTasks.every(_temp5); - let t4; - if ($[2] !== runningTasks) { - t4 = runningTasks.filter(_temp6).sort(_temp7); - $[2] = runningTasks; - $[3] = t4; - } else { - t4 = $[3]; - } - const teammateEntries = t4; - let t5; - if ($[4] !== isLeaderIdle) { - t5 = { - name: "main", + tasksSelected: boolean + isViewingTeammate?: boolean + teammateFooterIndex?: number + isLeaderIdle?: boolean + onOpenDialog?: (taskId?: string) => void +} + +export function BackgroundTaskStatus({ + tasksSelected, + isViewingTeammate, + teammateFooterIndex = 0, + isLeaderIdle = false, + onOpenDialog, +}: Props): React.ReactNode { + const setAppState = useSetAppState() + const { columns } = useTerminalSize() + const tasks = useAppState(s => s.tasks) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + + const runningTasks = useMemo( + () => + (Object.values(tasks ?? {}) as TaskState[]).filter( + t => + isBackgroundTask(t) && + !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + ), + [tasks], + ) + + // Check if all tasks are in-process teammates (team mode) + // In spinner-tree mode, don't show teammate pills (teammates appear in the spinner tree) + const expandedView = useAppState(s => s.expandedView) + const showSpinnerTree = expandedView === 'teammates' + const allTeammates = + !showSpinnerTree && + runningTasks.length > 0 && + runningTasks.every(t => t.type === 'in_process_teammate') + + // Memoize teammate-related computations at the top level (rules of hooks) + const teammateEntries = useMemo( + () => + runningTasks + .filter( + (t): t is BackgroundTaskState & { type: 'in_process_teammate' } => + t.type === 'in_process_teammate', + ) + .sort((a, b) => + a.identity.agentName.localeCompare(b.identity.agentName), + ), + [runningTasks], + ) + + // Build array of all pills with their activity state + // Each pill is "@{name}" and separator is " " (1 char) + // Sort idle agents to the end, but only when not in selection mode + // to avoid reordering while user is arrowing through the list + // "main" always stays first regardless of idle state + const allPills = useMemo(() => { + const mainPill = { + name: 'main', color: undefined as keyof Theme | undefined, isIdle: isLeaderIdle, - taskId: undefined as string | undefined - }; - $[4] = isLeaderIdle; - $[5] = t5; - } else { - t5 = $[5]; - } - const mainPill = t5; - let t6; - if ($[6] !== mainPill || $[7] !== tasksSelected || $[8] !== teammateEntries) { - const teammatePills = teammateEntries.map(_temp8); - if (!tasksSelected) { - teammatePills.sort(_temp9); - } - const pills = [mainPill, ...teammatePills]; - t6 = pills.map(_temp0); - $[6] = mainPill; - $[7] = tasksSelected; - $[8] = teammateEntries; - $[9] = t6; - } else { - t6 = $[9]; - } - const allPills = t6; - let t7; - if ($[10] !== allPills) { - t7 = allPills.map(_temp1); - $[10] = allPills; - $[11] = t7; - } else { - t7 = $[11]; - } - const pillWidths = t7; - if (allTeammates || !showSpinnerTree && isViewingTeammate) { - const selectedIdx = tasksSelected ? teammateFooterIndex : -1; - let t8; - if ($[12] !== teammateEntries || $[13] !== viewingAgentTaskId) { - t8 = viewingAgentTaskId ? teammateEntries.findIndex(t_3 => t_3.id === viewingAgentTaskId) + 1 : 0; - $[12] = teammateEntries; - $[13] = viewingAgentTaskId; - $[14] = t8; - } else { - t8 = $[14]; - } - const viewedIdx = t8; - const availableWidth = Math.max(20, columns - 20 - 4); - const t9 = selectedIdx >= 0 ? selectedIdx : 0; - let t10; - if ($[15] !== availableWidth || $[16] !== pillWidths || $[17] !== t9) { - t10 = calculateHorizontalScrollWindow(pillWidths, availableWidth, 2, t9); - $[15] = availableWidth; - $[16] = pillWidths; - $[17] = t9; - $[18] = t10; - } else { - t10 = $[18]; + taskId: undefined as string | undefined, } - const { - startIndex, - endIndex, - showLeftArrow, - showRightArrow - } = t10; - let t11; - if ($[19] !== allPills || $[20] !== endIndex || $[21] !== startIndex) { - t11 = allPills.slice(startIndex, endIndex); - $[19] = allPills; - $[20] = endIndex; - $[21] = startIndex; - $[22] = t11; - } else { - t11 = $[22]; - } - const visiblePills = t11; - let t12; - if ($[23] !== showLeftArrow) { - t12 = showLeftArrow && {figures.arrowLeft} ; - $[23] = showLeftArrow; - $[24] = t12; - } else { - t12 = $[24]; - } - let t13; - if ($[25] !== selectedIdx || $[26] !== setAppState || $[27] !== viewedIdx || $[28] !== visiblePills) { - t13 = visiblePills.map((pill_1, i_1) => { - const needsSeparator = i_1 > 0; - return {needsSeparator && } pill_1.taskId ? enterTeammateView(pill_1.taskId, setAppState) : exitTeammateView(setAppState)} />; - }); - $[25] = selectedIdx; - $[26] = setAppState; - $[27] = viewedIdx; - $[28] = visiblePills; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== showRightArrow) { - t14 = showRightArrow && {figures.arrowRight}; - $[30] = showRightArrow; - $[31] = t14; - } else { - t14 = $[31]; - } - let t15; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {" \xB7 "}; - $[32] = t15; - } else { - t15 = $[32]; - } - let t16; - if ($[33] !== t12 || $[34] !== t13 || $[35] !== t14) { - t16 = <>{t12}{t13}{t14}{t15}; - $[33] = t12; - $[34] = t13; - $[35] = t14; - $[36] = t16; - } else { - t16 = $[36]; + + const teammatePills = teammateEntries.map(t => ({ + name: t.identity.agentName, + color: getAgentThemeColor(t.identity.color), + isIdle: t.isIdle, + taskId: t.id, + })) + + // Only sort teammates when not selecting to avoid reordering during navigation + if (!tasksSelected) { + teammatePills.sort((a, b) => { + // Active agents first, idle agents last + if (a.isIdle !== b.isIdle) return a.isIdle ? 1 : -1 + return 0 // Keep original order within each group + }) } - return t16; + + // main always first, then sorted teammates + const pills = [mainPill, ...teammatePills] + + // Add idx after sorting + return pills.map((pill, i) => ({ ...pill, idx: i })) + }, [teammateEntries, isLeaderIdle, tasksSelected]) + + // Calculate pill widths (including separator space, except first) + const pillWidths = useMemo( + () => + allPills.map((pill, i) => { + const pillText = `@${pill.name}` + // First pill has no leading space, others have 1 space separator + return stringWidth(pillText) + (i > 0 ? 1 : 0) + }), + [allPills], + ) + + if (allTeammates || (!showSpinnerTree && isViewingTeammate)) { + const selectedIdx = tasksSelected ? teammateFooterIndex : -1 + // Which agent is currently foregrounded (bold) + const viewedIdx = viewingAgentTaskId + ? teammateEntries.findIndex(t => t.id === viewingAgentTaskId) + 1 + : 0 // 0 = main/leader + + // Calculate available width for pills + // Reserve space for: arrows, hint, and minimal padding + // Pills are rendered on their own line when in team mode + const ARROW_WIDTH = 2 // arrow char + space + const HINT_WIDTH = 20 // shift+↓ to expand + const PADDING = 4 // minimal safety margin + const availableWidth = Math.max(20, columns - HINT_WIDTH - PADDING) + + // Calculate visible window of pills + const { startIndex, endIndex, showLeftArrow, showRightArrow } = + calculateHorizontalScrollWindow( + pillWidths, + availableWidth, + ARROW_WIDTH, + selectedIdx >= 0 ? selectedIdx : 0, + ) + + const visiblePills = allPills.slice(startIndex, endIndex) + + return ( + <> + {showLeftArrow && {figures.arrowLeft} } + {visiblePills.map((pill, i) => { + // First visible pill has no leading separator + // (left arrow already provides spacing if present) + const needsSeparator = i > 0 + return ( + + {needsSeparator && } + + pill.taskId + ? enterTeammateView(pill.taskId, setAppState) + : exitTeammateView(setAppState) + } + /> + + ) + })} + {showRightArrow && {figures.arrowRight}} + + {' · '} + + + + ) } + + // In spinner-tree mode, don't show any footer status for teammates + // (they appear in the spinner tree above) if (shouldHideTasksFooter(tasks ?? {}, showSpinnerTree)) { - return null; + return null } + if (runningTasks.length === 0) { - return null; - } - let t8; - if ($[37] !== runningTasks) { - t8 = getPillLabel(runningTasks); - $[37] = runningTasks; - $[38] = t8; - } else { - t8 = $[38]; - } - let t9; - if ($[39] !== onOpenDialog || $[40] !== t8 || $[41] !== tasksSelected) { - t9 = {t8}; - $[39] = onOpenDialog; - $[40] = t8; - $[41] = tasksSelected; - $[42] = t9; - } else { - t9 = $[42]; - } - let t10; - if ($[43] !== runningTasks) { - t10 = pillNeedsCta(runningTasks) && · {figures.arrowDown} to view; - $[43] = runningTasks; - $[44] = t10; - } else { - t10 = $[44]; - } - let t11; - if ($[45] !== t10 || $[46] !== t9) { - t11 = <>{t9}{t10}; - $[45] = t10; - $[46] = t9; - $[47] = t11; - } else { - t11 = $[47]; + return null } - return t11; -} -function _temp1(pill_0, i_0) { - const pillText = `@${pill_0.name}`; - return stringWidth(pillText) + (i_0 > 0 ? 1 : 0); -} -function _temp0(pill, i) { - return { - ...pill, - idx: i - }; -} -function _temp9(a_0, b_0) { - if (a_0.isIdle !== b_0.isIdle) { - return a_0.isIdle ? 1 : -1; - } - return 0; -} -function _temp8(t_2) { - return { - name: t_2.identity.agentName, - color: getAgentThemeColor(t_2.identity.color), - isIdle: t_2.isIdle, - taskId: t_2.id - }; -} -function _temp7(a, b) { - return a.identity.agentName.localeCompare(b.identity.agentName); -} -function _temp6(t_1) { - return t_1.type === "in_process_teammate"; -} -function _temp5(t_0) { - return t_0.type === "in_process_teammate"; -} -function _temp4(s_1) { - return s_1.expandedView; -} -function _temp3(t) { - return isBackgroundTask(t) && !(false && isPanelAgentTask(t)); -} -function _temp2(s_0) { - return s_0.viewingAgentTaskId; -} -function _temp(s) { - return s.tasks; + + return ( + <> + + {getPillLabel(runningTasks)} + + {pillNeedsCta(runningTasks) && ( + · {figures.arrowDown} to view + )} + + ) } + type AgentPillProps = { - name: string; - color?: keyof Theme; - isSelected: boolean; - isViewed: boolean; - isIdle: boolean; - onClick?: () => void; -}; -function AgentPill(t0) { - const $ = _c(19); - const { - name, - color, - isSelected, - isViewed, - isIdle, - onClick - } = t0; - const [hover, setHover] = useState(false); - const highlighted = isSelected || hover; - let label; + name: string + color?: keyof Theme + isSelected: boolean + isViewed: boolean + isIdle: boolean + onClick?: () => void +} + +function AgentPill({ + name, + color, + isSelected, + isViewed, + isIdle, + onClick, +}: AgentPillProps): React.ReactNode { + const [hover, setHover] = useState(false) + // Hover mirrors the keyboard-selected look so the affordance is familiar. + const highlighted = isSelected || hover + + let label: React.ReactNode if (highlighted) { - let t1; - if ($[0] !== color || $[1] !== isViewed || $[2] !== name) { - t1 = color ? @{name} : @{name}; - $[0] = color; - $[1] = isViewed; - $[2] = name; - $[3] = t1; - } else { - t1 = $[3]; - } - label = t1; + label = color ? ( + + @{name} + + ) : ( + + @{name} + + ) + } else if (isIdle) { + label = ( + + @{name} + + ) + } else if (isViewed) { + label = ( + + @{name} + + ) } else { - if (isIdle) { - let t1; - if ($[4] !== isViewed || $[5] !== name) { - t1 = @{name}; - $[4] = isViewed; - $[5] = name; - $[6] = t1; - } else { - t1 = $[6]; - } - label = t1; - } else { - if (isViewed) { - let t1; - if ($[7] !== color || $[8] !== name) { - t1 = @{name}; - $[7] = color; - $[8] = name; - $[9] = t1; - } else { - t1 = $[9]; - } - label = t1; - } else { - const t1 = !color; - let t2; - if ($[10] !== color || $[11] !== name || $[12] !== t1) { - t2 = @{name}; - $[10] = color; - $[11] = name; - $[12] = t1; - $[13] = t2; - } else { - t2 = $[13]; - } - label = t2; - } - } - } - if (!onClick) { - return label; - } - let t1; - let t2; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setHover(true); - t2 = () => setHover(false); - $[14] = t1; - $[15] = t2; - } else { - t1 = $[14]; - t2 = $[15]; + label = ( + + @{name} + + ) } - let t3; - if ($[16] !== label || $[17] !== onClick) { - t3 = {label}; - $[16] = label; - $[17] = onClick; - $[18] = t3; - } else { - t3 = $[18]; - } - return t3; + + if (!onClick) return label + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {label} + + ) } -function SummaryPill(t0) { - const $ = _c(8); - const { - selected, - onClick, - children - } = t0; - const [hover, setHover] = useState(false); - const t1 = selected || hover; - let t2; - if ($[0] !== children || $[1] !== t1) { - t2 = {children}; - $[0] = children; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - const label = t2; - if (!onClick) { - return label; - } - let t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setHover(true); - t4 = () => setHover(false); - $[3] = t3; - $[4] = t4; - } else { - t3 = $[3]; - t4 = $[4]; - } - let t5; - if ($[5] !== label || $[6] !== onClick) { - t5 = {label}; - $[5] = label; - $[6] = onClick; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; + +function SummaryPill({ + selected, + onClick, + children, +}: { + selected: boolean + onClick?: () => void + children: React.ReactNode +}): React.ReactNode { + const [hover, setHover] = useState(false) + const label = ( + + {children} + + ) + if (!onClick) return label + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {label} + + ) } -function getAgentThemeColor(colorName: string | undefined): keyof Theme | undefined { - if (!colorName) return undefined; + +function getAgentThemeColor( + colorName: string | undefined, +): keyof Theme | undefined { + if (!colorName) return undefined if (AGENT_COLORS.includes(colorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]; + return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] } - return undefined; + return undefined } diff --git a/src/components/tasks/BackgroundTasksDialog.tsx b/src/components/tasks/BackgroundTasksDialog.tsx index c7bbd9b60..d9f119cf1 100644 --- a/src/components/tasks/BackgroundTasksDialog.tsx +++ b/src/components/tasks/BackgroundTasksDialog.tsx @@ -1,171 +1,214 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import React, { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; -import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; -import type { ToolUseContext } from 'src/Tool.js'; -import { DreamTask, type DreamTaskState } from 'src/tasks/DreamTask/DreamTask.js'; -import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js'; -import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js'; -import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js'; +import { feature } from 'bun:bundle' +import figures from 'figures' +import React, { + type ReactNode, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from 'react' +import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from 'src/state/teammateViewHelpers.js' +import type { ToolUseContext } from 'src/Tool.js' +import { + DreamTask, + type DreamTaskState, +} from 'src/tasks/DreamTask/DreamTask.js' +import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' +import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' +import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js' // Type import is erased at build time — safe even though module is ant-gated. -import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js'; -import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js'; -import { RemoteAgentTask, type RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { intersperse } from 'src/utils/array.js'; -import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js'; -import { stopUltraplan } from '../../commands/ultraplan.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { count } from '../../utils/array.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js'; -import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js'; -import { DreamDetailDialog } from './DreamDetailDialog.js'; -import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js'; -import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js'; -import { ShellDetailDialog } from './ShellDetailDialog.js'; -type ViewState = { - mode: 'list'; -} | { - mode: 'detail'; - itemId: string; -}; +import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js' +import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js' +import { + RemoteAgentTask, + type RemoteAgentTaskState, +} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' +import { + type BackgroundTaskState, + isBackgroundTask, + type TaskState, +} from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { intersperse } from 'src/utils/array.js' +import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js' +import { stopUltraplan } from '../../commands/ultraplan.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { count } from '../../utils/array.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js' +import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js' +import { DreamDetailDialog } from './DreamDetailDialog.js' +import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js' +import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js' +import { ShellDetailDialog } from './ShellDetailDialog.js' + +type ViewState = { mode: 'list' } | { mode: 'detail'; itemId: string } + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - toolUseContext: ToolUseContext; - initialDetailTaskId?: string; -}; -type ListItem = { - id: string; - type: 'local_bash'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'remote_agent'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'local_agent'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'in_process_teammate'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'local_workflow'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'monitor_mcp'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'dream'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'leader'; - label: string; - status: 'running'; -}; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + toolUseContext: ToolUseContext + initialDetailTaskId?: string +} + +type ListItem = + | { + id: string + type: 'local_bash' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'remote_agent' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'local_agent' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'in_process_teammate' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'local_workflow' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'monitor_mcp' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'dream' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'leader' + label: string + status: 'running' + } // WORKFLOW_SCRIPTS is ant-only (build_flags.yaml). Static imports would leak // ~1.3K lines into external builds. Gate with feature() + require so the // bundler can dead-code-eliminate the branch. /* eslint-disable @typescript-eslint/no-require-imports */ -const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') ? (require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js')).WorkflowDetailDialog : null; -const workflowTaskModule = feature('WORKFLOW_SCRIPTS') ? require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') : null; -const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null; -const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null; -const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null; +const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') + ? ( + require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js') + ).WorkflowDetailDialog + : null +const workflowTaskModule = feature('WORKFLOW_SCRIPTS') + ? (require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js')) + : null +const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null +const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null +const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null // Relative path, not `src/...` path-mapping — Bun's DCE can statically // resolve + eliminate `./` requires, but path-mapped strings stay opaque // and survive as dead literals in the bundle. Matches tasks.ts pattern. -const monitorMcpModule = feature('MONITOR_TOOL') ? require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js') : null; -const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null; -const MonitorMcpDetailDialog = feature('MONITOR_TOOL') ? (require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js')).MonitorMcpDetailDialog : null; +const monitorMcpModule = feature('MONITOR_TOOL') + ? (require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js')) + : null +const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null +const MonitorMcpDetailDialog = feature('MONITOR_TOOL') + ? ( + require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js') + ).MonitorMcpDetailDialog + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Helper to get filtered background tasks (excludes foregrounded local_agent) -function getSelectableBackgroundTasks(tasks: Record | undefined, foregroundedTaskId: string | undefined): TaskState[] { - const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask); - return backgroundTasks.filter(task => !(task.type === 'local_agent' && task.id === foregroundedTaskId)); +function getSelectableBackgroundTasks( + tasks: Record | undefined, + foregroundedTaskId: string | undefined, +): TaskState[] { + const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask) + return backgroundTasks.filter( + task => !(task.type === 'local_agent' && task.id === foregroundedTaskId), + ) } + export function BackgroundTasksDialog({ onDone, toolUseContext, - initialDetailTaskId + initialDetailTaskId, }: Props): React.ReactNode { - const tasks = useAppState(s => s.tasks); - const foregroundedTaskId = useAppState(s_0 => s_0.foregroundedTaskId); - const showSpinnerTree = useAppState(s_1 => s_1.expandedView) === 'teammates'; - const setAppState = useSetAppState(); - const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k'); - const typedTasks = tasks as Record | undefined; + const tasks = useAppState(s => s.tasks) + const foregroundedTaskId = useAppState(s => s.foregroundedTaskId) + const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' + const setAppState = useSetAppState() + const killAgentsShortcut = useShortcutDisplay( + 'chat:killAgents', + 'Chat', + 'ctrl+x ctrl+k', + ) + const typedTasks = tasks as Record | undefined // Track if we skipped list view on mount (for back button behavior) - const skippedListOnMount = useRef(false); + const skippedListOnMount = useRef(false) // Compute initial view state - skip list if caller provided a specific task, // or if there's exactly one task const [viewState, setViewState] = useState(() => { if (initialDetailTaskId) { - skippedListOnMount.current = true; - return { - mode: 'detail', - itemId: initialDetailTaskId - }; + skippedListOnMount.current = true + return { mode: 'detail', itemId: initialDetailTaskId } } - const allItems = getSelectableBackgroundTasks(typedTasks, foregroundedTaskId); + const allItems = getSelectableBackgroundTasks( + typedTasks, + foregroundedTaskId, + ) if (allItems.length === 1) { - skippedListOnMount.current = true; - return { - mode: 'detail', - itemId: allItems[0]!.id - }; + skippedListOnMount.current = true + return { mode: 'detail', itemId: allItems[0]!.id } } - return { - mode: 'list' - }; - }); - const [selectedIndex, setSelectedIndex] = useState(0); + return { mode: 'list' } + }) + const [selectedIndex, setSelectedIndex] = useState(0) // Register as modal overlay so parent Chat keybindings (up/down for history) // are deactivated while this dialog is open - useRegisterOverlay('background-tasks-dialog', undefined); + useRegisterOverlay('background-tasks-dialog') // Memoize the sorted and categorized items together to ensure stable references const { @@ -175,37 +218,48 @@ export function BackgroundTasksDialog({ teammateTasks, workflowTasks, mcpMonitors, - dreamTasks: dreamTasks_0, - allSelectableItems + dreamTasks, + allSelectableItems, } = useMemo(() => { // Filter to only show running/pending background tasks, matching the status bar count - const backgroundTasks = Object.values(typedTasks ?? {}).filter(isBackgroundTask); - const allItems_0 = backgroundTasks.map(toListItem); - const sorted = allItems_0.sort((a, b) => { - const aStatus = a.status; - const bStatus = b.status; - if (aStatus === 'running' && bStatus !== 'running') return -1; - if (aStatus !== 'running' && bStatus === 'running') return 1; - const aTime = 'task' in a ? a.task.startTime : 0; - const bTime = 'task' in b ? b.task.startTime : 0; - return bTime - aTime; - }); - const bash = sorted.filter(item => item.type === 'local_bash'); - const remote = sorted.filter(item_0 => item_0.type === 'remote_agent'); + const backgroundTasks = Object.values(typedTasks ?? {}).filter( + isBackgroundTask, + ) + const allItems = backgroundTasks.map(toListItem) + const sorted = allItems.sort((a, b) => { + const aStatus = a.status + const bStatus = b.status + if (aStatus === 'running' && bStatus !== 'running') return -1 + if (aStatus !== 'running' && bStatus === 'running') return 1 + const aTime = 'task' in a ? a.task.startTime : 0 + const bTime = 'task' in b ? b.task.startTime : 0 + return bTime - aTime + }) + const bash = sorted.filter(item => item.type === 'local_bash') + const remote = sorted.filter(item => item.type === 'remote_agent') // Exclude foregrounded task - it's being viewed in the main UI, not a background task - const agent = sorted.filter(item_1 => item_1.type === 'local_agent' && item_1.id !== foregroundedTaskId); - const workflows = sorted.filter(item_2 => item_2.type === 'local_workflow'); - const monitorMcp = sorted.filter(item_3 => item_3.type === 'monitor_mcp'); - const dreamTasks = sorted.filter(item_4 => item_4.type === 'dream'); + const agent = sorted.filter( + item => item.type === 'local_agent' && item.id !== foregroundedTaskId, + ) + const workflows = sorted.filter(item => item.type === 'local_workflow') + const monitorMcp = sorted.filter(item => item.type === 'monitor_mcp') + const dreamTasks = sorted.filter(item => item.type === 'dream') // In spinner-tree mode, exclude teammates from the dialog (they appear in the tree) - const teammates = showSpinnerTree ? [] : sorted.filter(item_5 => item_5.type === 'in_process_teammate'); + const teammates = showSpinnerTree + ? [] + : sorted.filter(item => item.type === 'in_process_teammate') // Add leader entry when there are teammates, so users can foreground back to leader - const leaderItem: ListItem[] = teammates.length > 0 ? [{ - id: '__leader__', - type: 'leader', - label: `@${TEAM_LEAD_NAME}`, - status: 'running' - }] : []; + const leaderItem: ListItem[] = + teammates.length > 0 + ? [ + { + id: '__leader__', + type: 'leader', + label: `@${TEAM_LEAD_NAME}`, + status: 'running', + }, + ] + : [] return { bashTasks: bash, remoteSessions: remote, @@ -217,135 +271,177 @@ export function BackgroundTasksDialog({ // Order MUST match JSX render order (teammates \u2192 bash \u2192 monitorMcp \u2192 // remote \u2192 agent \u2192 workflows \u2192 dream) so \u2193/\u2191 navigation moves the cursor // visually downward. - allSelectableItems: [...leaderItem, ...teammates, ...bash, ...monitorMcp, ...remote, ...agent, ...workflows, ...dreamTasks] - }; - }, [typedTasks, foregroundedTaskId, showSpinnerTree]); - const currentSelection = allSelectableItems[selectedIndex] ?? null; + allSelectableItems: [ + ...leaderItem, + ...teammates, + ...bash, + ...monitorMcp, + ...remote, + ...agent, + ...workflows, + ...dreamTasks, + ], + } + }, [typedTasks, foregroundedTaskId, showSpinnerTree]) + + const currentSelection = allSelectableItems[selectedIndex] ?? null // Use configurable keybindings for standard navigation and confirm/cancel. // confirm:no is handled by Dialog's onCancel prop. - useKeybindings({ - 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), - 'confirm:next': () => setSelectedIndex(prev_0 => Math.min(allSelectableItems.length - 1, prev_0 + 1)), - 'confirm:yes': () => { - const current = allSelectableItems[selectedIndex]; - if (current) { - if (current.type === 'leader') { - exitTeammateView(setAppState); - onDone('Viewing leader', { - display: 'system' - }); - } else { - setViewState({ - mode: 'detail', - itemId: current.id - }); + useKeybindings( + { + 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), + 'confirm:next': () => + setSelectedIndex(prev => + Math.min(allSelectableItems.length - 1, prev + 1), + ), + 'confirm:yes': () => { + const current = allSelectableItems[selectedIndex] + if (current) { + if (current.type === 'leader') { + exitTeammateView(setAppState) + onDone('Viewing leader', { display: 'system' }) + } else { + setViewState({ mode: 'detail', itemId: current.id }) + } } - } - } - }, { - context: 'Confirmation', - isActive: viewState.mode === 'list' - }); + }, + }, + { context: 'Confirmation', isActive: viewState.mode === 'list' }, + ) // Component-specific shortcuts (x=stop, f=foreground, right=zoom) shown in UI. // These are task-type and status dependent, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { // Only handle input when in list mode - if (viewState.mode !== 'list') return; + if (viewState.mode !== 'list') return + if (e.key === 'left') { - e.preventDefault(); - onDone('Background tasks dialog dismissed', { - display: 'system' - }); - return; + e.preventDefault() + onDone('Background tasks dialog dismissed', { display: 'system' }) + return } // Compute current selection at the time of the key press - const currentSelection_0 = allSelectableItems[selectedIndex]; - if (!currentSelection_0) return; // everything below requires a selection + const currentSelection = allSelectableItems[selectedIndex] + if (!currentSelection) return // everything below requires a selection if (e.key === 'x') { - e.preventDefault(); - if (currentSelection_0.type === 'local_bash' && currentSelection_0.status === 'running') { - void killShellTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'local_agent' && currentSelection_0.status === 'running') { - void killAgentTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'in_process_teammate' && currentSelection_0.status === 'running') { - void killTeammateTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'local_workflow' && currentSelection_0.status === 'running' && killWorkflowTask) { - killWorkflowTask(currentSelection_0.id, setAppState); - } else if (currentSelection_0.type === 'monitor_mcp' && currentSelection_0.status === 'running' && killMonitorMcp) { - killMonitorMcp(currentSelection_0.id, setAppState); - } else if (currentSelection_0.type === 'dream' && currentSelection_0.status === 'running') { - void killDreamTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'remote_agent' && currentSelection_0.status === 'running') { - if (currentSelection_0.task.isUltraplan) { - void stopUltraplan(currentSelection_0.id, currentSelection_0.task.sessionId, setAppState); + e.preventDefault() + if ( + currentSelection.type === 'local_bash' && + currentSelection.status === 'running' + ) { + void killShellTask(currentSelection.id) + } else if ( + currentSelection.type === 'local_agent' && + currentSelection.status === 'running' + ) { + void killAgentTask(currentSelection.id) + } else if ( + currentSelection.type === 'in_process_teammate' && + currentSelection.status === 'running' + ) { + void killTeammateTask(currentSelection.id) + } else if ( + currentSelection.type === 'local_workflow' && + currentSelection.status === 'running' && + killWorkflowTask + ) { + killWorkflowTask(currentSelection.id, setAppState) + } else if ( + currentSelection.type === 'monitor_mcp' && + currentSelection.status === 'running' && + killMonitorMcp + ) { + killMonitorMcp(currentSelection.id, setAppState) + } else if ( + currentSelection.type === 'dream' && + currentSelection.status === 'running' + ) { + void killDreamTask(currentSelection.id) + } else if ( + currentSelection.type === 'remote_agent' && + currentSelection.status === 'running' + ) { + if (currentSelection.task.isUltraplan) { + void stopUltraplan( + currentSelection.id, + currentSelection.task.sessionId, + setAppState, + ) } else { - void killRemoteAgentTask(currentSelection_0.id); + void killRemoteAgentTask(currentSelection.id) } } } + if (e.key === 'f') { - if (currentSelection_0.type === 'in_process_teammate' && currentSelection_0.status === 'running') { - e.preventDefault(); - enterTeammateView(currentSelection_0.id, setAppState); - onDone('Viewing teammate', { - display: 'system' - }); - } else if (currentSelection_0.type === 'leader') { - e.preventDefault(); - exitTeammateView(setAppState); - onDone('Viewing leader', { - display: 'system' - }); + if ( + currentSelection.type === 'in_process_teammate' && + currentSelection.status === 'running' + ) { + e.preventDefault() + enterTeammateView(currentSelection.id, setAppState) + onDone('Viewing teammate', { display: 'system' }) + } else if (currentSelection.type === 'leader') { + e.preventDefault() + exitTeammateView(setAppState) + onDone('Viewing leader', { display: 'system' }) } } - }; + } + async function killShellTask(taskId: string): Promise { - await LocalShellTask.kill(taskId, setAppState); + await LocalShellTask.kill(taskId, setAppState) } - async function killAgentTask(taskId_0: string): Promise { - await LocalAgentTask.kill(taskId_0, setAppState); + + async function killAgentTask(taskId: string): Promise { + await LocalAgentTask.kill(taskId, setAppState) } - async function killTeammateTask(taskId_1: string): Promise { - await InProcessTeammateTask.kill(taskId_1, setAppState); + + async function killTeammateTask(taskId: string): Promise { + await InProcessTeammateTask.kill(taskId, setAppState) } - async function killDreamTask(taskId_2: string): Promise { - await DreamTask.kill(taskId_2, setAppState); + + async function killDreamTask(taskId: string): Promise { + await DreamTask.kill(taskId, setAppState) } - async function killRemoteAgentTask(taskId_3: string): Promise { - await RemoteAgentTask.kill(taskId_3, setAppState); + + async function killRemoteAgentTask(taskId: string): Promise { + await RemoteAgentTask.kill(taskId, setAppState) } // Wrap onDone in useEffectEvent to get a stable reference that always calls // the current onDone callback without causing the effect to re-fire. - const onDoneEvent = useEffectEvent(onDone); + const onDoneEvent = useEffectEvent(onDone) + useEffect(() => { if (viewState.mode !== 'list') { - const task = (typedTasks ?? {})[viewState.itemId]; + const task = (typedTasks ?? {})[viewState.itemId] // Workflow tasks get a grace: their detail view stays open through // completion so the user sees the final state before eviction. - if (!task || task.type !== 'local_workflow' && !isBackgroundTask(task)) { + if ( + !task || + (task.type !== 'local_workflow' && !isBackgroundTask(task)) + ) { // Task was removed or is no longer a background task (e.g. killed). // If we skipped the list on mount, close the dialog entirely. if (skippedListOnMount.current) { onDoneEvent('Background tasks dialog dismissed', { - display: 'system' - }); + display: 'system', + }) } else { - setViewState({ - mode: 'list' - }); + setViewState({ mode: 'list' }) } } } - const totalItems = allSelectableItems.length; + + const totalItems = allSelectableItems.length if (selectedIndex >= totalItems && totalItems > 0) { - setSelectedIndex(totalItems - 1); + setSelectedIndex(totalItems - 1) } - }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]); + }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]) // Helper to go back to list view (or close dialog if we skipped list on // mount AND there's still only ≤1 item). Checking current count prevents @@ -353,142 +449,421 @@ export function BackgroundTasksDialog({ // then a second task started, 'back' should show the list — not close. const goBackToList = () => { if (skippedListOnMount.current && allSelectableItems.length <= 1) { - onDone('Background tasks dialog dismissed', { - display: 'system' - }); + onDone('Background tasks dialog dismissed', { display: 'system' }) } else { - skippedListOnMount.current = false; - setViewState({ - mode: 'list' - }); + skippedListOnMount.current = false + setViewState({ mode: 'list' }) } - }; + } // If an item is selected, show the appropriate view if (viewState.mode !== 'list' && typedTasks) { - const task_0 = typedTasks[viewState.itemId]; - if (!task_0) { - return null; + const task = typedTasks[viewState.itemId] + if (!task) { + return null } // Detail mode - show appropriate detail dialog - switch (task_0.type) { + switch (task.type) { case 'local_bash': - return void killShellTask(task_0.id)} onBack={goBackToList} key={`shell-${task_0.id}`} />; + return ( + void killShellTask(task.id)} + onBack={goBackToList} + key={`shell-${task.id}`} + /> + ) case 'local_agent': - return void killAgentTask(task_0.id)} onBack={goBackToList} key={`agent-${task_0.id}`} />; + return ( + void killAgentTask(task.id)} + onBack={goBackToList} + key={`agent-${task.id}`} + /> + ) case 'remote_agent': - return void stopUltraplan(task_0.id, task_0.sessionId, setAppState) : () => void killRemoteAgentTask(task_0.id)} key={`session-${task_0.id}`} />; + return ( + + void stopUltraplan(task.id, task.sessionId, setAppState) + : () => void killRemoteAgentTask(task.id) + } + key={`session-${task.id}`} + /> + ) case 'in_process_teammate': - return void killTeammateTask(task_0.id) : undefined} onBack={goBackToList} onForeground={task_0.status === 'running' ? () => { - enterTeammateView(task_0.id, setAppState); - onDone('Viewing teammate', { - display: 'system' - }); - } : undefined} key={`teammate-${task_0.id}`} />; + return ( + void killTeammateTask(task.id) + : undefined + } + onBack={goBackToList} + onForeground={ + task.status === 'running' + ? () => { + enterTeammateView(task.id, setAppState) + onDone('Viewing teammate', { display: 'system' }) + } + : undefined + } + key={`teammate-${task.id}`} + /> + ) case 'local_workflow': - if (!WorkflowDetailDialog) return null; - return killWorkflowTask(task_0.id, setAppState) : undefined} onSkipAgent={task_0.status === 'running' && skipWorkflowAgent ? agentId => skipWorkflowAgent(task_0.id, agentId, setAppState) : undefined} onRetryAgent={task_0.status === 'running' && retryWorkflowAgent ? agentId_0 => retryWorkflowAgent(task_0.id, agentId_0, setAppState) : undefined} onBack={goBackToList} key={`workflow-${task_0.id}`} />; + if (!WorkflowDetailDialog) return null + return ( + killWorkflowTask(task.id, setAppState) + : undefined + } + onSkipAgent={ + task.status === 'running' && skipWorkflowAgent + ? agentId => skipWorkflowAgent(task.id, agentId, setAppState) + : undefined + } + onRetryAgent={ + task.status === 'running' && retryWorkflowAgent + ? agentId => retryWorkflowAgent(task.id, agentId, setAppState) + : undefined + } + onBack={goBackToList} + key={`workflow-${task.id}`} + /> + ) case 'monitor_mcp': - if (!MonitorMcpDetailDialog) return null; - return killMonitorMcp(task_0.id, setAppState) : undefined} onBack={goBackToList} key={`monitor-mcp-${task_0.id}`} />; + if (!MonitorMcpDetailDialog) return null + return ( + killMonitorMcp(task.id, setAppState) + : undefined + } + onBack={goBackToList} + key={`monitor-mcp-${task.id}`} + /> + ) case 'dream': - return onDone('Background tasks dialog dismissed', { - display: 'system' - })} onBack={goBackToList} onKill={task_0.status === 'running' ? () => void killDreamTask(task_0.id) : undefined} key={`dream-${task_0.id}`} />; + return ( + + onDone('Background tasks dialog dismissed', { + display: 'system', + }) + } + onBack={goBackToList} + onKill={ + task.status === 'running' + ? () => void killDreamTask(task.id) + : undefined + } + key={`dream-${task.id}`} + /> + ) } } - const runningBashCount = count(bashTasks, _ => _.status === 'running'); - const runningAgentCount = count(remoteSessions, __0 => __0.status === 'running' || __0.status === 'pending') + count(agentTasks, __1 => __1.status === 'running'); - const runningTeammateCount = count(teammateTasks, __2 => __2.status === 'running'); - const subtitle = intersperse([...(runningTeammateCount > 0 ? [ + + const runningBashCount = count(bashTasks, _ => _.status === 'running') + const runningAgentCount = + count( + remoteSessions, + _ => _.status === 'running' || _.status === 'pending', + ) + count(agentTasks, _ => _.status === 'running') + const runningTeammateCount = count(teammateTasks, _ => _.status === 'running') + const subtitle = intersperse( + [ + ...(runningTeammateCount > 0 + ? [ + {runningTeammateCount}{' '} {runningTeammateCount !== 1 ? 'agents' : 'agent'} - ] : []), ...(runningBashCount > 0 ? [ + , + ] + : []), + ...(runningBashCount > 0 + ? [ + {runningBashCount}{' '} {runningBashCount !== 1 ? 'active shells' : 'active shell'} - ] : []), ...(runningAgentCount > 0 ? [ + , + ] + : []), + ...(runningAgentCount > 0 + ? [ + {runningAgentCount}{' '} {runningAgentCount !== 1 ? 'active agents' : 'active agent'} - ] : [])], index => · ); - const actions = [, , ...(currentSelection?.type === 'in_process_teammate' && currentSelection.status === 'running' ? [] : []), ...((currentSelection?.type === 'local_bash' || currentSelection?.type === 'local_agent' || currentSelection?.type === 'in_process_teammate' || currentSelection?.type === 'local_workflow' || currentSelection?.type === 'monitor_mcp' || currentSelection?.type === 'dream' || currentSelection?.type === 'remote_agent') && currentSelection.status === 'running' ? [] : []), ...(agentTasks.some(t => t.status === 'running') ? [] : []), ]; - const handleCancel = () => onDone('Background tasks dialog dismissed', { - display: 'system' - }); + , + ] + : []), + ], + index => · , + ) + + const actions = [ + , + , + ...(currentSelection?.type === 'in_process_teammate' && + currentSelection.status === 'running' + ? [ + , + ] + : []), + ...((currentSelection?.type === 'local_bash' || + currentSelection?.type === 'local_agent' || + currentSelection?.type === 'in_process_teammate' || + currentSelection?.type === 'local_workflow' || + currentSelection?.type === 'monitor_mcp' || + currentSelection?.type === 'dream' || + currentSelection?.type === 'remote_agent') && + currentSelection.status === 'running' + ? [] + : []), + ...(agentTasks.some(t => t.status === 'running') + ? [ + , + ] + : []), + , + ] + + const handleCancel = () => + onDone('Background tasks dialog dismissed', { display: 'system' }) + function renderInputGuide(exitState: ExitState): React.ReactNode { if (exitState.pending) { - return Press {exitState.keyName} again to exit; + return Press {exitState.keyName} again to exit } - return {actions}; + return {actions} } - return - {subtitle}} onCancel={handleCancel} color="background" inputGuide={renderInputGuide}> - {allSelectableItems.length === 0 ? No tasks currently running : - {teammateTasks.length > 0 && - {(bashTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && + + return ( + + {subtitle}} + onCancel={handleCancel} + color="background" + inputGuide={renderInputGuide} + > + {allSelectableItems.length === 0 ? ( + No tasks currently running + ) : ( + + {teammateTasks.length > 0 && ( + + {(bashTasks.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0) && ( + {' '}Agents ( {count(teammateTasks, i => i.type !== 'leader')}) - } + + )} - + - } + + )} - {bashTasks.length > 0 && 0 ? 1 : 0}> - {(teammateTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && + {bashTasks.length > 0 && ( + 0 ? 1 : 0} + > + {(teammateTasks.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0) && ( + {' '}Shells ({bashTasks.length}) - } + + )} - {bashTasks.map(item_6 => )} + {bashTasks.map(item => ( + + ))} - } + + )} - {mcpMonitors.length > 0 && 0 || bashTasks.length > 0 ? 1 : 0}> + {mcpMonitors.length > 0 && ( + 0 || bashTasks.length > 0 ? 1 : 0 + } + > {' '}Monitors ({mcpMonitors.length}) - {mcpMonitors.map(item_7 => )} + {mcpMonitors.map(item => ( + + ))} - } + + )} - {remoteSessions.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 ? 1 : 0}> + {remoteSessions.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 + ? 1 + : 0 + } + > {' '}Remote agents ({remoteSessions.length} ) - {remoteSessions.map(item_8 => )} + {remoteSessions.map(item => ( + + ))} - } + + )} - {agentTasks.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 ? 1 : 0}> + {agentTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 + ? 1 + : 0 + } + > {' '}Local agents ({agentTasks.length}) - {agentTasks.map(item_9 => )} + {agentTasks.map(item => ( + + ))} - } + + )} - {workflowTasks.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 ? 1 : 0}> + {workflowTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0 + ? 1 + : 0 + } + > {' '}Workflows ({workflowTasks.length}) - {workflowTasks.map(item_10 => )} + {workflowTasks.map(item => ( + + ))} - } + + )} - {dreamTasks_0.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 || workflowTasks.length > 0 ? 1 : 0}> + {dreamTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0 || + workflowTasks.length > 0 + ? 1 + : 0 + } + > - {dreamTasks_0.map(item_11 => )} + {dreamTasks.map(item => ( + + ))} - } - } + + )} + + )} - ; + + ) } + function toListItem(task: BackgroundTaskState): ListItem { switch (task.type) { case 'local_bash': @@ -497,155 +872,141 @@ function toListItem(task: BackgroundTaskState): ListItem { type: 'local_bash', label: task.kind === 'monitor' ? task.description : task.command, status: task.status, - task - }; + task, + } case 'remote_agent': return { id: task.id, type: 'remote_agent', label: task.title, status: task.status, - task - }; + task, + } case 'local_agent': return { id: task.id, type: 'local_agent', label: task.description, status: task.status, - task - }; + task, + } case 'in_process_teammate': return { id: task.id, type: 'in_process_teammate', label: `@${task.identity.agentName}`, status: task.status, - task - }; + task, + } case 'local_workflow': return { id: task.id, type: 'local_workflow', label: task.summary ?? task.description, status: task.status, - task - }; + task, + } case 'monitor_mcp': return { id: task.id, type: 'monitor_mcp', label: task.description, status: task.status, - task - }; + task, + } case 'dream': return { id: task.id, type: 'dream', label: task.description, status: task.status, - task - }; + task, + } } } -function Item(t0) { - const $ = _c(14); - const { - item, - isSelected - } = t0; - const { - columns - } = useTerminalSize(); - const maxActivityWidth = Math.max(30, columns - 26); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = isCoordinatorMode(); - $[0] = t1; - } else { - t1 = $[0]; - } - const useGreyPointer = t1; - const t2 = useGreyPointer && isSelected; - const t3 = isSelected ? figures.pointer + " " : " "; - let t4; - if ($[1] !== t2 || $[2] !== t3) { - t4 = {t3}; - $[1] = t2; - $[2] = t3; - $[3] = t4; - } else { - t4 = $[3]; - } - const t5 = isSelected && !useGreyPointer ? "suggestion" : undefined; - let t6; - if ($[4] !== item.task || $[5] !== item.type || $[6] !== maxActivityWidth) { - t6 = item.type === "leader" ? @{TEAM_LEAD_NAME} : ; - $[4] = item.task; - $[5] = item.type; - $[6] = maxActivityWidth; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== t5 || $[9] !== t6) { - t7 = {t6}; - $[8] = t5; - $[9] = t6; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t4 || $[12] !== t7) { - t8 = {t4}{t7}; - $[11] = t4; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; + +function Item({ + item, + isSelected, +}: { + item: ListItem + isSelected: boolean +}): ReactNode { + const { columns } = useTerminalSize() + // Dialog border (2) + padding (2) + pointer prefix (2) + name/status overhead (~20) + const maxActivityWidth = Math.max(30, columns - 26) + // In coordinator mode, use grey pointer instead of blue + const useGreyPointer = isCoordinatorMode() + + return ( + + + {isSelected ? figures.pointer + ' ' : ' '} + + + {item.type === 'leader' ? ( + @{TEAM_LEAD_NAME} + ) : ( + + )} + + + ) } -function TeammateTaskGroups(t0) { - const $ = _c(3); - const { - teammateTasks, - currentSelectionId - } = t0; - let t1; - if ($[0] !== currentSelectionId || $[1] !== teammateTasks) { - const leaderItems = teammateTasks.filter(_temp); - const teammateItems = teammateTasks.filter(_temp2); - const teams = new Map(); - for (const item of teammateItems) { - const teamName = item.task.identity.teamName; - const group = teams.get(teamName); - if (group) { - group.push(item); - } else { - teams.set(teamName, [item]); - } + +function TeammateTaskGroups({ + teammateTasks, + currentSelectionId, +}: { + teammateTasks: ListItem[] + currentSelectionId: string | undefined +}): ReactNode { + // Separate leader from teammates, group teammates by team + const leaderItems = teammateTasks.filter(i => i.type === 'leader') + const teammateItems = teammateTasks.filter( + i => i.type === 'in_process_teammate', + ) + const teams = new Map() + for (const item of teammateItems) { + const teamName = item.task.identity.teamName + const group = teams.get(teamName) + if (group) { + group.push(item) + } else { + teams.set(teamName, [item]) } - const teamEntries = [...teams.entries()]; - t1 = <>{teamEntries.map(t2 => { - const [teamName_0, items] = t2; - const memberCount = items.length + leaderItems.length; - return {" "}Team: {teamName_0} ({memberCount}){leaderItems.map(item_0 => )}{items.map(item_1 => )}; - })}; - $[0] = currentSelectionId; - $[1] = teammateTasks; - $[2] = t1; - } else { - t1 = $[2]; } - return t1; -} -function _temp2(i_0) { - return i_0.type === "in_process_teammate"; -} -function _temp(i) { - return i.type === "leader"; + const teamEntries = [...teams.entries()] + return ( + <> + {teamEntries.map(([teamName, items]) => { + const memberCount = items.length + leaderItems.length + return ( + + + {' '}Team: {teamName} ({memberCount}) + + {/* Render leader first within each team */} + {leaderItems.map(item => ( + + ))} + {items.map(item => ( + + ))} + + ) + })} + + ) } diff --git a/src/components/tasks/DreamDetailDialog.tsx b/src/components/tasks/DreamDetailDialog.tsx index 46470c000..bea310946 100644 --- a/src/components/tasks/DreamDetailDialog.tsx +++ b/src/components/tasks/DreamDetailDialog.tsx @@ -1,250 +1,136 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js' +import { plural } from '../../utils/stringUtils.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - task: DeepImmutable; - onDone: () => void; - onBack?: () => void; - onKill?: () => void; -}; + task: DeepImmutable + onDone: () => void + onBack?: () => void + onKill?: () => void +} // How many recent turns to render. Earlier turns collapse to a count. -const VISIBLE_TURNS = 6; -export function DreamDetailDialog(t0) { - const $ = _c(70); - const { - task, - onDone, - onBack, - onKill - } = t0; - const elapsedTime = useElapsedTime(task.startTime, task.status === "running", 1000, 0); - let t1; - if ($[0] !== onDone) { - t1 = { - "confirm:yes": onDone - }; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useKeybindings(t1, t2); - let t3; - if ($[3] !== onBack || $[4] !== onDone || $[5] !== onKill || $[6] !== task.status) { - t3 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && task.status === "running" && onKill) { - e.preventDefault(); - onKill(); - } - } - } - }; - $[3] = onBack; - $[4] = onDone; - $[5] = onKill; - $[6] = task.status; - $[7] = t3; - } else { - t3 = $[7]; - } - const handleKeyDown = t3; - let T0; - let T1; - let T2; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[8] !== elapsedTime || $[9] !== handleKeyDown || $[10] !== onBack || $[11] !== onDone || $[12] !== onKill || $[13] !== task.filesTouched.length || $[14] !== task.sessionsReviewing || $[15] !== task.status || $[16] !== task.turns) { - const visibleTurns = task.turns.filter(_temp); - const shown = visibleTurns.slice(-VISIBLE_TURNS); - const hidden = visibleTurns.length - shown.length; - T2 = Box; - t13 = "column"; - t14 = 0; - t15 = true; - t16 = handleKeyDown; - T1 = Dialog; - t8 = "Memory consolidation"; - const t17 = task.sessionsReviewing; - let t18; - if ($[33] !== task.sessionsReviewing) { - t18 = plural(task.sessionsReviewing, "session"); - $[33] = task.sessionsReviewing; - $[34] = t18; - } else { - t18 = $[34]; - } - let t19; - if ($[35] !== task.filesTouched.length) { - t19 = task.filesTouched.length > 0 && <>{" "}· {task.filesTouched.length}{" "}{plural(task.filesTouched.length, "file")} touched; - $[35] = task.filesTouched.length; - $[36] = t19; - } else { - t19 = $[36]; - } - if ($[37] !== elapsedTime || $[38] !== t18 || $[39] !== t19 || $[40] !== task.sessionsReviewing) { - t9 = {elapsedTime} · reviewing {t17}{" "}{t18}{t19}; - $[37] = elapsedTime; - $[38] = t18; - $[39] = t19; - $[40] = task.sessionsReviewing; - $[41] = t9; - } else { - t9 = $[41]; - } - t10 = onDone; - t11 = "background"; - if ($[42] !== onBack || $[43] !== onKill || $[44] !== task.status) { - t12 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{task.status === "running" && onKill && }; - $[42] = onBack; - $[43] = onKill; - $[44] = task.status; - $[45] = t12; - } else { - t12 = $[45]; - } - T0 = Box; - t4 = "column"; - t5 = 1; - let t20; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t20 = Status:; - $[46] = t20; - } else { - t20 = $[46]; - } - if ($[47] !== task.status) { - t6 = {t20}{" "}{task.status === "running" ? running : task.status === "completed" ? {task.status} : {task.status}}; - $[47] = task.status; - $[48] = t6; - } else { - t6 = $[48]; +const VISIBLE_TURNS = 6 + +export function DreamDetailDialog({ + task, + onDone, + onBack, + onKill, +}: Props): React.ReactNode { + const elapsedTime = useElapsedTime( + task.startTime, + task.status === 'running', + 1000, + 0, + ) + + // Dialog handles confirm:no (Esc) → onCancel. Wire confirm:yes (Enter/y) too. + useKeybindings({ 'confirm:yes': onDone }, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && task.status === 'running' && onKill) { + e.preventDefault() + onKill() } - t7 = shown.length === 0 ? {task.status === "running" ? "Starting\u2026" : "(no text output)"} : <>{hidden > 0 && ({hidden} earlier {plural(hidden, "turn")})}{shown.map(_temp2)}; - $[8] = elapsedTime; - $[9] = handleKeyDown; - $[10] = onBack; - $[11] = onDone; - $[12] = onKill; - $[13] = task.filesTouched.length; - $[14] = task.sessionsReviewing; - $[15] = task.status; - $[16] = task.turns; - $[17] = T0; - $[18] = T1; - $[19] = T2; - $[20] = t10; - $[21] = t11; - $[22] = t12; - $[23] = t13; - $[24] = t14; - $[25] = t15; - $[26] = t16; - $[27] = t4; - $[28] = t5; - $[29] = t6; - $[30] = t7; - $[31] = t8; - $[32] = t9; - } else { - T0 = $[17]; - T1 = $[18]; - T2 = $[19]; - t10 = $[20]; - t11 = $[21]; - t12 = $[22]; - t13 = $[23]; - t14 = $[24]; - t15 = $[25]; - t16 = $[26]; - t4 = $[27]; - t5 = $[28]; - t6 = $[29]; - t7 = $[30]; - t8 = $[31]; - t9 = $[32]; - } - let t17; - if ($[49] !== T0 || $[50] !== t4 || $[51] !== t5 || $[52] !== t6 || $[53] !== t7) { - t17 = {t6}{t7}; - $[49] = T0; - $[50] = t4; - $[51] = t5; - $[52] = t6; - $[53] = t7; - $[54] = t17; - } else { - t17 = $[54]; } - let t18; - if ($[55] !== T1 || $[56] !== t10 || $[57] !== t11 || $[58] !== t12 || $[59] !== t17 || $[60] !== t8 || $[61] !== t9) { - t18 = {t17}; - $[55] = T1; - $[56] = t10; - $[57] = t11; - $[58] = t12; - $[59] = t17; - $[60] = t8; - $[61] = t9; - $[62] = t18; - } else { - t18 = $[62]; - } - let t19; - if ($[63] !== T2 || $[64] !== t13 || $[65] !== t14 || $[66] !== t15 || $[67] !== t16 || $[68] !== t18) { - t19 = {t18}; - $[63] = T2; - $[64] = t13; - $[65] = t14; - $[66] = t15; - $[67] = t16; - $[68] = t18; - $[69] = t19; - } else { - t19 = $[69]; - } - return t19; -} -function _temp2(turn, i) { - return {turn.text}{turn.toolUseCount > 0 && {" "}({turn.toolUseCount}{" "}{plural(turn.toolUseCount, "tool")})}; -} -function _temp(t) { - return t.text !== ""; + + // Turns with text to show. Tool-only turns (text='') are dropped entirely — + // the per-turn toolUseCount already captures that work. + const visibleTurns = task.turns.filter(t => t.text !== '') + const shown = visibleTurns.slice(-VISIBLE_TURNS) + const hidden = visibleTurns.length - shown.length + + return ( + + + {elapsedTime} · reviewing {task.sessionsReviewing}{' '} + {plural(task.sessionsReviewing, 'session')} + {task.filesTouched.length > 0 && ( + <> + {' '} + · {task.filesTouched.length}{' '} + {plural(task.filesTouched.length, 'file')} touched + + )} + + } + onCancel={onDone} + color="background" + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {task.status === 'running' && onKill && ( + + )} + + ) + } + > + + + Status:{' '} + {task.status === 'running' ? ( + running + ) : task.status === 'completed' ? ( + {task.status} + ) : ( + {task.status} + )} + + + {shown.length === 0 ? ( + + {task.status === 'running' ? 'Starting…' : '(no text output)'} + + ) : ( + <> + {hidden > 0 && ( + + ({hidden} earlier {plural(hidden, 'turn')}) + + )} + {shown.map((turn, i) => ( + + {turn.text} + {turn.toolUseCount > 0 && ( + + {' '}({turn.toolUseCount}{' '} + {plural(turn.toolUseCount, 'tool')}) + + )} + + ))} + + )} + + + + ) } diff --git a/src/components/tasks/InProcessTeammateDetailDialog.tsx b/src/components/tasks/InProcessTeammateDetailDialog.tsx index c8b25f80f..b59bbbd5e 100644 --- a/src/components/tasks/InProcessTeammateDetailDialog.tsx +++ b/src/components/tasks/InProcessTeammateDetailDialog.tsx @@ -1,265 +1,193 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { getEmptyToolPermissionContext } from '../../Tool.js'; -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { getTools } from '../../tools.js'; -import { formatNumber, truncateToWidth } from '../../utils/format.js'; -import { toInkColor } from '../../utils/ink.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { renderToolActivity } from './renderToolActivity.js'; -import { describeTeammateActivity } from './taskStatusUtils.js'; +import React, { useMemo } from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' +import { getTools } from '../../tools.js' +import { formatNumber, truncateToWidth } from '../../utils/format.js' +import { toInkColor } from '../../utils/ink.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { renderToolActivity } from './renderToolActivity.js' +import { describeTeammateActivity } from './taskStatusUtils.js' + type Props = { - teammate: DeepImmutable; - onDone: () => void; - onKill?: () => void; - onBack?: () => void; - onForeground?: () => void; -}; -export function InProcessTeammateDetailDialog(t0) { - const $ = _c(63); - const { - teammate, - onDone, - onKill, - onBack, - onForeground - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTools(getEmptyToolPermissionContext()); - $[0] = t1; - } else { - t1 = $[0]; - } - const tools = t1; - const elapsedTime = useElapsedTime(teammate.startTime, teammate.status === "running", 1000, teammate.totalPausedMs ?? 0); - let t2; - if ($[1] !== onDone) { - t2 = { - "confirm:yes": onDone - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybindings(t2, t3); - let t4; - if ($[4] !== onBack || $[5] !== onDone || $[6] !== onForeground || $[7] !== onKill || $[8] !== teammate.status) { - t4 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && teammate.status === "running" && onKill) { - e.preventDefault(); - onKill(); - } else { - if (e.key === "f" && teammate.status === "running" && onForeground) { - e.preventDefault(); - onForeground(); - } + teammate: DeepImmutable + onDone: () => void + onKill?: () => void + onBack?: () => void + onForeground?: () => void +} +export function InProcessTeammateDetailDialog({ + teammate, + onDone, + onKill, + onBack, + onForeground, +}: Props): React.ReactNode { + const [theme] = useTheme() + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + + const elapsedTime = useElapsedTime( + teammate.startTime, + teammate.status === 'running', + 1000, + teammate.totalPausedMs ?? 0, + ) + + // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) + useKeybindings( + { + 'confirm:yes': onDone, + }, + { context: 'Confirmation' }, + ) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && teammate.status === 'running' && onKill) { + e.preventDefault() + onKill() + } else if (e.key === 'f' && teammate.status === 'running' && onForeground) { + e.preventDefault() + onForeground() + } + } + + const activity = describeTeammateActivity(teammate) + + const tokenCount = + teammate.result?.totalTokens ?? teammate.progress?.tokenCount + const toolUseCount = + teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount + + const displayPrompt = truncateToWidth(teammate.prompt, 300) + + const title = ( + + + @{teammate.identity.agentName} + + {activity && ({activity})} + + ) + + const subtitle = ( + + {teammate.status !== 'running' && ( + + {teammate.status === 'completed' + ? 'Completed' + : teammate.status === 'failed' + ? 'Failed' + : 'Stopped'} + {' · '} + + )} + + {elapsedTime} + {tokenCount !== undefined && tokenCount > 0 && ( + <> · {formatNumber(tokenCount)} tokens + )} + {toolUseCount !== undefined && toolUseCount > 0 && ( + <> + {' '} + · {toolUseCount} {toolUseCount === 1 ? 'tool' : 'tools'} + + )} + + + ) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {teammate.status === 'running' && onKill && ( + + )} + {teammate.status === 'running' && onForeground && ( + + )} + + ) } - } - }; - $[4] = onBack; - $[5] = onDone; - $[6] = onForeground; - $[7] = onKill; - $[8] = teammate.status; - $[9] = t4; - } else { - t4 = $[9]; - } - const handleKeyDown = t4; - let t5; - if ($[10] !== teammate) { - t5 = describeTeammateActivity(teammate); - $[10] = teammate; - $[11] = t5; - } else { - t5 = $[11]; - } - const activity = t5; - const tokenCount = teammate.result?.totalTokens ?? teammate.progress?.tokenCount; - const toolUseCount = teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount; - let t6; - if ($[12] !== teammate.prompt) { - t6 = truncateToWidth(teammate.prompt, 300); - $[12] = teammate.prompt; - $[13] = t6; - } else { - t6 = $[13]; - } - const displayPrompt = t6; - let t7; - if ($[14] !== teammate.identity.color) { - t7 = toInkColor(teammate.identity.color); - $[14] = teammate.identity.color; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t7 || $[17] !== teammate.identity.agentName) { - t8 = @{teammate.identity.agentName}; - $[16] = t7; - $[17] = teammate.identity.agentName; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== activity) { - t9 = activity && ({activity}); - $[19] = activity; - $[20] = t9; - } else { - t9 = $[20]; - } - let t10; - if ($[21] !== t8 || $[22] !== t9) { - t10 = {t8}{t9}; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const title = t10; - let t11; - if ($[24] !== teammate.status) { - t11 = teammate.status !== "running" && {teammate.status === "completed" ? "Completed" : teammate.status === "failed" ? "Failed" : "Stopped"}{" \xB7 "}; - $[24] = teammate.status; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== tokenCount) { - t12 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[26] = tokenCount; - $[27] = t12; - } else { - t12 = $[27]; - } - let t13; - if ($[28] !== toolUseCount) { - t13 = toolUseCount !== undefined && toolUseCount > 0 && <>{" "}· {toolUseCount} {toolUseCount === 1 ? "tool" : "tools"}; - $[28] = toolUseCount; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== elapsedTime || $[31] !== t12 || $[32] !== t13) { - t14 = {elapsedTime}{t12}{t13}; - $[30] = elapsedTime; - $[31] = t12; - $[32] = t13; - $[33] = t14; - } else { - t14 = $[33]; - } - let t15; - if ($[34] !== t11 || $[35] !== t14) { - t15 = {t11}{t14}; - $[34] = t11; - $[35] = t14; - $[36] = t15; - } else { - t15 = $[36]; - } - const subtitle = t15; - let t16; - if ($[37] !== onBack || $[38] !== onForeground || $[39] !== onKill || $[40] !== teammate.status) { - t16 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{teammate.status === "running" && onKill && }{teammate.status === "running" && onForeground && }; - $[37] = onBack; - $[38] = onForeground; - $[39] = onKill; - $[40] = teammate.status; - $[41] = t16; - } else { - t16 = $[41]; - } - let t17; - if ($[42] !== teammate.progress || $[43] !== teammate.status || $[44] !== theme) { - t17 = teammate.status === "running" && teammate.progress?.recentActivities && teammate.progress.recentActivities.length > 0 && Progress{teammate.progress.recentActivities.map((activity_0, i) => {i === teammate.progress.recentActivities.length - 1 ? "\u203A " : " "}{renderToolActivity(activity_0, tools, theme)})}; - $[42] = teammate.progress; - $[43] = teammate.status; - $[44] = theme; - $[45] = t17; - } else { - t17 = $[45]; - } - let t18; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t18 = Prompt; - $[46] = t18; - } else { - t18 = $[46]; - } - let t19; - if ($[47] !== displayPrompt) { - t19 = {t18}{displayPrompt}; - $[47] = displayPrompt; - $[48] = t19; - } else { - t19 = $[48]; - } - let t20; - if ($[49] !== teammate.error || $[50] !== teammate.status) { - t20 = teammate.status === "failed" && teammate.error && Error{teammate.error}; - $[49] = teammate.error; - $[50] = teammate.status; - $[51] = t20; - } else { - t20 = $[51]; - } - let t21; - if ($[52] !== onDone || $[53] !== subtitle || $[54] !== t16 || $[55] !== t17 || $[56] !== t19 || $[57] !== t20 || $[58] !== title) { - t21 = {t17}{t19}{t20}; - $[52] = onDone; - $[53] = subtitle; - $[54] = t16; - $[55] = t17; - $[56] = t19; - $[57] = t20; - $[58] = title; - $[59] = t21; - } else { - t21 = $[59]; - } - let t22; - if ($[60] !== handleKeyDown || $[61] !== t21) { - t22 = {t21}; - $[60] = handleKeyDown; - $[61] = t21; - $[62] = t22; - } else { - t22 = $[62]; - } - return t22; + > + {/* Recent activities for running teammates */} + {teammate.status === 'running' && + teammate.progress?.recentActivities && + teammate.progress.recentActivities.length > 0 && ( + + + Progress + + {teammate.progress.recentActivities.map((activity, i) => ( + + {i === teammate.progress!.recentActivities!.length - 1 + ? '› ' + : ' '} + {renderToolActivity(activity, tools, theme)} + + ))} + + )} + + {/* Prompt section */} + + + Prompt + + {displayPrompt} + + + {/* Error details if failed */} + {teammate.status === 'failed' && teammate.error && ( + + + Error + + + {teammate.error} + + + )} + + + ) } diff --git a/src/components/tasks/RemoteSessionDetailDialog.tsx b/src/components/tasks/RemoteSessionDetailDialog.tsx index f5435ead7..55c897fd9 100644 --- a/src/components/tasks/RemoteSessionDetailDialog.tsx +++ b/src/components/tasks/RemoteSessionDetailDialog.tsx @@ -1,41 +1,48 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useMemo, useState } from 'react'; -import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'; -import type { ToolUseContext } from 'src/Tool.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Link, Text } from '../../ink.js'; -import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'; -import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js'; -import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js'; -import { openBrowser } from '../../utils/browser.js'; -import { errorMessage } from '../../utils/errors.js'; -import { formatDuration, truncateToWidth } from '../../utils/format.js'; -import { toInternalMessages } from '../../utils/messages/mappers.js'; -import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; -import { plural } from '../../utils/stringUtils.js'; -import { teleportResumeCodeSession } from '../../utils/teleport.js'; -import { Select } from '../CustomSelect/select.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Message } from '../Message.js'; -import { formatReviewStageCounts, RemoteSessionProgress } from './RemoteSessionProgress.js'; +import figures from 'figures' +import React, { useMemo, useState } from 'react' +import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js' +import type { ToolUseContext } from 'src/Tool.js' +import type { DeepImmutable } from 'src/types/utils.js' +import type { CommandResultDisplay } from '../../commands.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Link, Text } from '../../ink.js' +import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { + AGENT_TOOL_NAME, + LEGACY_AGENT_TOOL_NAME, +} from '../../tools/AgentTool/constants.js' +import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js' +import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js' +import { openBrowser } from '../../utils/browser.js' +import { errorMessage } from '../../utils/errors.js' +import { formatDuration, truncateToWidth } from '../../utils/format.js' +import { toInternalMessages } from '../../utils/messages/mappers.js' +import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js' +import { plural } from '../../utils/stringUtils.js' +import { teleportResumeCodeSession } from '../../utils/teleport.js' +import { Select } from '../CustomSelect/select.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Message } from '../Message.js' +import { + formatReviewStageCounts, + RemoteSessionProgress, +} from './RemoteSessionProgress.js' + type Props = { - session: DeepImmutable; - toolUseContext: ToolUseContext; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onBack?: () => void; - onKill?: () => void; -}; + session: DeepImmutable + toolUseContext: ToolUseContext + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onBack?: () => void + onKill?: () => void +} // Compact one-line summary: tool name + first meaningful string arg. // Lighter than tool.renderToolUseMessage (no registry lookup / schema parse). @@ -44,746 +51,423 @@ type Props = { export function formatToolUseSummary(name: string, input: unknown): string { // plan_ready phase is only reached via ExitPlanMode tool if (name === EXIT_PLAN_MODE_V2_TOOL_NAME) { - return 'Review the plan in Claude Code on the web'; + return 'Review the plan in Claude Code on the web' } - if (!input || typeof input !== 'object') return name; + if (!input || typeof input !== 'object') return name // AskUserQuestion: show the question text as a CTA, not the tool name. // Input shape is {questions: [{question, header, options}]}. if (name === ASK_USER_QUESTION_TOOL_NAME && 'questions' in input) { - const qs = input.questions; + const qs = input.questions if (Array.isArray(qs) && qs[0] && typeof qs[0] === 'object') { // Prefer question (full text) over header (max-12-char tag). header // is a required schema field so checking it first would make the // question fallback dead code. - const q = 'question' in qs[0] && typeof qs[0].question === 'string' && qs[0].question ? qs[0].question : 'header' in qs[0] && typeof qs[0].header === 'string' ? qs[0].header : null; + const q = + 'question' in qs[0] && + typeof qs[0].question === 'string' && + qs[0].question + ? qs[0].question + : 'header' in qs[0] && typeof qs[0].header === 'string' + ? qs[0].header + : null if (q) { - const oneLine = q.replace(/\s+/g, ' ').trim(); - return `Answer in browser: ${truncateToWidth(oneLine, 50)}`; + const oneLine = q.replace(/\s+/g, ' ').trim() + return `Answer in browser: ${truncateToWidth(oneLine, 50)}` } } } for (const v of Object.values(input)) { if (typeof v === 'string' && v.trim()) { - const oneLine = v.replace(/\s+/g, ' ').trim(); - return `${name} ${truncateToWidth(oneLine, 60)}`; + const oneLine = v.replace(/\s+/g, ' ').trim() + return `${name} ${truncateToWidth(oneLine, 60)}` } } - return name; + return name } + const PHASE_LABEL = { needs_input: 'input required', - plan_ready: 'ready' -} as const; + plan_ready: 'ready', +} as const + const AGENT_VERB = { needs_input: 'waiting', - plan_ready: 'done' -} as const; -function UltraplanSessionDetail(t0) { - const $ = _c(70); - const { - session, - onDone, - onBack, - onKill - } = t0; - const running = session.status === "running" || session.status === "pending"; - const phase = session.ultraplanPhase; - const statusText = running ? phase ? PHASE_LABEL[phase] : "running" : session.status; - const elapsedTime = useElapsedTime(session.startTime, running, 1000, 0, session.endTime); - let spawns = 0; - let calls = 0; - let lastBlock = null; - for (const msg of session.log) { - if (msg.type !== "assistant") { - continue; - } - for (const block of msg.message.content) { - if (block.type !== "tool_use") { - continue; - } - calls++; - lastBlock = block; - if (block.name === AGENT_TOOL_NAME || block.name === LEGACY_AGENT_TOOL_NAME) { - spawns++; + plan_ready: 'done', +} as const + +function UltraplanSessionDetail({ + session, + onDone, + onBack, + onKill, +}: Omit): React.ReactNode { + const running = session.status === 'running' || session.status === 'pending' + const phase = session.ultraplanPhase + const statusText = running + ? phase + ? PHASE_LABEL[phase] + : 'running' + : session.status + const elapsedTime = useElapsedTime( + session.startTime, + running, + 1000, + 0, + session.endTime, + ) + + // Counts are eventually correct (lag ≤ poll interval). agentsWorking starts + // at 1 (the main session agent) and increments per subagent spawn. toolCalls + // is main-session only — subagent calls may not surface in this stream. + const { agentsWorking, toolCalls, lastToolCall } = useMemo(() => { + let spawns = 0 + let calls = 0 + let lastBlock: { name: string; input: unknown } | null = null + for (const msg of session.log) { + if (msg.type !== 'assistant') continue + for (const block of msg.message.content) { + if (block.type !== 'tool_use') continue + calls++ + lastBlock = block + if ( + block.name === AGENT_TOOL_NAME || + block.name === LEGACY_AGENT_TOOL_NAME + ) { + spawns++ + } } } - } - const t1 = 1 + spawns; - let t2; - if ($[0] !== lastBlock) { - t2 = lastBlock ? formatToolUseSummary(lastBlock.name, lastBlock.input) : null; - $[0] = lastBlock; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== calls || $[3] !== t1 || $[4] !== t2) { - t3 = { - agentsWorking: t1, + return { + agentsWorking: 1 + spawns, toolCalls: calls, - lastToolCall: t2 - }; - $[2] = calls; - $[3] = t1; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const { - agentsWorking, - toolCalls, - lastToolCall - } = t3; - let t4; - if ($[6] !== session.sessionId) { - t4 = getRemoteTaskSessionUrl(session.sessionId); - $[6] = session.sessionId; - $[7] = t4; - } else { - t4 = $[7]; - } - const sessionUrl = t4; - let t5; - if ($[8] !== onBack || $[9] !== onDone) { - t5 = onBack ?? (() => onDone("Remote session details dismissed", { - display: "system" - })); - $[8] = onBack; - $[9] = onDone; - $[10] = t5; - } else { - t5 = $[10]; - } - const goBackOrClose = t5; - const [confirmingStop, setConfirmingStop] = useState(false); - if (confirmingStop) { - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => setConfirmingStop(false); - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t7 = This will terminate the Claude Code on the web session.; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - label: "Terminate session", - value: "stop" as const - }; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [t8, { - label: "Back", - value: "back" as const - }]; - $[14] = t9; - } else { - t9 = $[14]; - } - let t10; - if ($[15] !== goBackOrClose || $[16] !== onKill) { - t10 = {t7} { + if (v === 'stop') { + onKill?.() + goBackOrClose() + } else { + setConfirmingStop(false) + } + }} + /> + + + ) } - let t23; - if ($[54] !== goBackOrClose || $[55] !== onDone || $[56] !== sessionUrl) { - t23 = v_0 => { - switch (v_0) { - case "open": - { - openBrowser(sessionUrl); - onDone(); - return; - } - case "stop": - { - setConfirmingStop(true); - return; - } - case "back": - { - goBackOrClose(); - return; - } + + return ( + + + {phase === 'plan_ready' ? DIAMOND_FILLED : DIAMOND_OPEN}{' '} + + ultraplan + + {' · '} + {elapsedTime} + {' · '} + {statusText} + + } - }; - $[54] = goBackOrClose; - $[55] = onDone; - $[56] = sessionUrl; - $[57] = t23; - } else { - t23 = $[57]; - } - let t24; - if ($[58] !== t22 || $[59] !== t23) { - t24 = { + switch (v) { + case 'open': + void openBrowser(sessionUrl) + // Close the dialog so the user lands back at the prompt with + // any half-written input intact (inputValue persists across + // the showBashesDialog toggle). + onDone() + return + case 'stop': + setConfirmingStop(true) + return + case 'back': + goBackOrClose() + return + } + }} + /> + + + ) } -const STAGES = ['finding', 'verifying', 'synthesizing'] as const; + +const STAGES = ['finding', 'verifying', 'synthesizing'] as const const STAGE_LABELS: Record<(typeof STAGES)[number], string> = { finding: 'Find', verifying: 'Verify', - synthesizing: 'Dedupe' -}; + synthesizing: 'Dedupe', +} // Setup → Find → Verify → Dedupe pipeline. Current stage in cloud teal, // rest dim. When completed, all stages dim with a trailing green ✓. The // "Setup" label shows before the orchestrator writes its first progress // snapshot (container boot + repo clone), so the 0-found display doesn't // look like a hung finder. -function StagePipeline(t0) { - const $ = _c(15); - const { - stage, - completed, - hasProgress - } = t0; - let t1; - if ($[0] !== stage) { - t1 = stage ? STAGES.indexOf(stage) : -1; - $[0] = stage; - $[1] = t1; - } else { - t1 = $[1]; - } - const currentIdx = t1; - const inSetup = !completed && !hasProgress; - let t2; - if ($[2] !== inSetup) { - t2 = inSetup ? Setup : Setup; - $[2] = inSetup; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== completed || $[6] !== currentIdx || $[7] !== inSetup) { - t4 = STAGES.map((s, i) => { - const isCurrent = !completed && !inSetup && i === currentIdx; - return {i > 0 && }{isCurrent ? {STAGE_LABELS[s]} : {STAGE_LABELS[s]}}; - }); - $[5] = completed; - $[6] = currentIdx; - $[7] = inSetup; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== completed) { - t5 = completed && ; - $[9] = completed; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t2 || $[12] !== t4 || $[13] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[11] = t2; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; +function StagePipeline({ + stage, + completed, + hasProgress, +}: { + stage: 'finding' | 'verifying' | 'synthesizing' | undefined + completed: boolean + hasProgress: boolean +}): React.ReactNode { + const currentIdx = stage ? STAGES.indexOf(stage) : -1 + const inSetup = !completed && !hasProgress + return ( + + {inSetup ? ( + Setup + ) : ( + Setup + )} + + {STAGES.map((s, i) => { + const isCurrent = !completed && !inSetup && i === currentIdx + return ( + + {i > 0 && } + {isCurrent ? ( + {STAGE_LABELS[s]} + ) : ( + {STAGE_LABELS[s]} + )} + + ) + })} + {completed && } + + ) } // Stage-appropriate counts line. Running-state formatting delegates to // formatReviewStageCounts (shared with the pill) so the two views can't // drift; completed state is dialog-specific (findings summary). -function reviewCountsLine(session: DeepImmutable): string { - const p = session.reviewProgress; +function reviewCountsLine( + session: DeepImmutable, +): string { + const p = session.reviewProgress // No progress data — the orchestrator never wrote a snapshot. Don't // claim "0 findings" when completed; we just don't know. - if (!p) return session.status === 'completed' ? 'done' : 'setting up'; - const verified = p.bugsVerified; - const refuted = p.bugsRefuted ?? 0; + if (!p) return session.status === 'completed' ? 'done' : 'setting up' + const verified = p.bugsVerified + const refuted = p.bugsRefuted ?? 0 if (session.status === 'completed') { - const parts = [`${verified} ${plural(verified, 'finding')}`]; - if (refuted > 0) parts.push(`${refuted} refuted`); - return parts.join(' · '); + const parts = [`${verified} ${plural(verified, 'finding')}`] + if (refuted > 0) parts.push(`${refuted} refuted`) + return parts.join(' · ') } - return formatReviewStageCounts(p.stage, p.bugsFound, verified, refuted); + return formatReviewStageCounts(p.stage, p.bugsFound, verified, refuted) } -type MenuAction = 'open' | 'stop' | 'back' | 'dismiss'; -function ReviewSessionDetail(t0) { - const $ = _c(56); - const { - session, - onDone, - onBack, - onKill - } = t0; - const completed = session.status === "completed"; - const running = session.status === "running" || session.status === "pending"; - const [confirmingStop, setConfirmingStop] = useState(false); - const elapsedTime = useElapsedTime(session.startTime, running, 1000, 0, session.endTime); - let t1; - if ($[0] !== onDone) { - t1 = () => onDone("Remote session details dismissed", { - display: "system" - }); - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleClose = t1; - const goBackOrClose = onBack ?? handleClose; - let t2; - if ($[2] !== session.sessionId) { - t2 = getRemoteTaskSessionUrl(session.sessionId); - $[2] = session.sessionId; - $[3] = t2; - } else { - t2 = $[3]; - } - const sessionUrl = t2; - const statusLabel = completed ? "ready" : running ? "running" : session.status; + +type MenuAction = 'open' | 'stop' | 'back' | 'dismiss' + +function ReviewSessionDetail({ + session, + onDone, + onBack, + onKill, +}: Omit): React.ReactNode { + const completed = session.status === 'completed' + const running = session.status === 'running' || session.status === 'pending' + const [confirmingStop, setConfirmingStop] = useState(false) + + // useElapsedTime drives the 1Hz tick so the timer advances while the + // dialog is open — the previous inline elapsed-time calculation only + // re-rendered on session state changes (poll interval), which looked + // like the clock was stuck. + const elapsedTime = useElapsedTime( + session.startTime, + running, + 1000, + 0, + session.endTime, + ) + + const handleClose = () => + onDone('Remote session details dismissed', { display: 'system' }) + const goBackOrClose = onBack ?? handleClose + + const sessionUrl = getRemoteTaskSessionUrl(session.sessionId) + const statusLabel = completed ? 'ready' : running ? 'running' : session.status + if (confirmingStop) { - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setConfirmingStop(false); - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = This archives the remote session and stops local tracking. The review will not complete and any findings so far are discarded.; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Stop ultrareview", - value: "stop" as const - }; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [t5, { - label: "Back", - value: "back" as const - }]; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== goBackOrClose || $[9] !== onKill) { - t7 = {t4} { + if (v === 'stop') { + onKill?.() + goBackOrClose() + } else { + setConfirmingStop(false) + } + }} + /> + + + ) } - let t3; - if ($[11] !== completed || $[12] !== onKill || $[13] !== running) { - t3 = completed ? [{ - label: "Open in Claude Code on the web", - value: "open" - }, { - label: "Dismiss", - value: "dismiss" - }] : [{ - label: "Open in Claude Code on the web", - value: "open" - }, ...(onKill && running ? [{ - label: "Stop ultrareview", - value: "stop" as const - }] : []), { - label: "Back", - value: "back" - }]; - $[11] = completed; - $[12] = onKill; - $[13] = running; - $[14] = t3; - } else { - t3 = $[14]; + + const options: { label: string; value: MenuAction }[] = completed + ? [ + { label: 'Open in Claude Code on the web', value: 'open' }, + { label: 'Dismiss', value: 'dismiss' }, + ] + : [ + { label: 'Open in Claude Code on the web', value: 'open' }, + ...(onKill && running + ? [{ label: 'Stop ultrareview', value: 'stop' as const }] + : []), + { label: 'Back', value: 'back' }, + ] + + const handleSelect = (action: MenuAction) => { + switch (action) { + case 'open': + void openBrowser(sessionUrl) + onDone() + break + case 'stop': + setConfirmingStop(true) + break + case 'back': + goBackOrClose() + break + case 'dismiss': + handleClose() + break + } } - const options = t3; - let t4; - if ($[15] !== goBackOrClose || $[16] !== handleClose || $[17] !== onDone || $[18] !== sessionUrl) { - t4 = action => { - bb45: switch (action) { - case "open": - { - openBrowser(sessionUrl); - onDone(); - break bb45; - } - case "stop": - { - setConfirmingStop(true); - break bb45; - } - case "back": - { - goBackOrClose(); - break bb45; - } - case "dismiss": - { - handleClose(); - } + + return ( + + + {completed ? DIAMOND_FILLED : DIAMOND_OPEN}{' '} + + ultrareview + + {' · '} + {elapsedTime} + {' · '} + {statusLabel} + + } - }; - $[15] = goBackOrClose; - $[16] = handleClose; - $[17] = onDone; - $[18] = sessionUrl; - $[19] = t4; - } else { - t4 = $[19]; - } - const handleSelect = t4; - const t5 = completed ? DIAMOND_FILLED : DIAMOND_OPEN; - let t6; - if ($[20] !== t5) { - t6 = {t5}{" "}; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - let t7; - if ($[22] === Symbol.for("react.memo_cache_sentinel")) { - t7 = ultrareview; - $[22] = t7; - } else { - t7 = $[22]; - } - let t8; - if ($[23] !== elapsedTime || $[24] !== statusLabel) { - t8 = {" \xB7 "}{elapsedTime}{" \xB7 "}{statusLabel}; - $[23] = elapsedTime; - $[24] = statusLabel; - $[25] = t8; - } else { - t8 = $[25]; - } - let t9; - if ($[26] !== t6 || $[27] !== t8) { - t9 = {t6}{t7}{t8}; - $[26] = t6; - $[27] = t8; - $[28] = t9; - } else { - t9 = $[28]; - } - const t10 = session.reviewProgress?.stage; - const t11 = !!session.reviewProgress; - let t12; - if ($[29] !== completed || $[30] !== t10 || $[31] !== t11) { - t12 = ; - $[29] = completed; - $[30] = t10; - $[31] = t11; - $[32] = t12; - } else { - t12 = $[32]; - } - let t13; - if ($[33] !== session) { - t13 = reviewCountsLine(session); - $[33] = session; - $[34] = t13; - } else { - t13 = $[34]; - } - let t14; - if ($[35] !== t13) { - t14 = {t13}; - $[35] = t13; - $[36] = t14; - } else { - t14 = $[36]; - } - let t15; - if ($[37] !== sessionUrl) { - t15 = {sessionUrl}; - $[37] = sessionUrl; - $[38] = t15; - } else { - t15 = $[38]; - } - let t16; - if ($[39] !== sessionUrl || $[40] !== t15) { - t16 = {t15}; - $[39] = sessionUrl; - $[40] = t15; - $[41] = t16; - } else { - t16 = $[41]; - } - let t17; - if ($[42] !== t14 || $[43] !== t16) { - t17 = {t14}{t16}; - $[42] = t14; - $[43] = t16; - $[44] = t17; - } else { - t17 = $[44]; - } - let t18; - if ($[45] !== handleSelect || $[46] !== options) { - t18 = + + + ) } + export function RemoteSessionDetailDialog({ session, toolUseContext, onDone, onBack, - onKill + onKill, }: Props): React.ReactNode { - const [isTeleporting, setIsTeleporting] = useState(false); - const [teleportError, setTeleportError] = useState(null); + const [isTeleporting, setIsTeleporting] = useState(false) + const [teleportError, setTeleportError] = useState(null) // Get last few messages from remote session for display. // Scan all messages (not just the last 3 raw entries) because the tail of @@ -791,74 +475,119 @@ export function RemoteSessionDetailDialog({ // Placed before the early returns so hook call order is stable (Rules of Hooks). // Ultraplan/review sessions never read this — skip the normalize work for them. const lastMessages = useMemo(() => { - if (session.isUltraplan || session.isRemoteReview) return []; - return normalizeMessages(toInternalMessages(session.log as SDKMessage[])).filter(_ => _.type !== 'progress').slice(-3); - }, [session]); + if (session.isUltraplan || session.isRemoteReview) return [] + return normalizeMessages(toInternalMessages(session.log as SDKMessage[])) + .filter(_ => _.type !== 'progress') + .slice(-3) + }, [session]) + if (session.isUltraplan) { - return ; + return ( + + ) } // Review sessions get the stage-pipeline view; everything else keeps the // generic label/value + recent-messages dialog below. if (session.isRemoteReview) { - return ; + return ( + + ) } - const handleClose = () => onDone('Remote session details dismissed', { - display: 'system' - }); + + const handleClose = () => + onDone('Remote session details dismissed', { display: 'system' }) // Component-specific shortcuts shown in UI hints (t=teleport, space=dismiss, // left=back). These are state-dependent actions, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { if (e.key === ' ') { - e.preventDefault(); - onDone('Remote session details dismissed', { - display: 'system' - }); + e.preventDefault() + onDone('Remote session details dismissed', { display: 'system' }) } else if (e.key === 'left' && onBack) { - e.preventDefault(); - onBack(); + e.preventDefault() + onBack() } else if (e.key === 't' && !isTeleporting) { - e.preventDefault(); - void handleTeleport(); + e.preventDefault() + void handleTeleport() } else if (e.key === 'return') { - e.preventDefault(); - handleClose(); + e.preventDefault() + handleClose() } - }; + } // Handle teleporting to remote session async function handleTeleport(): Promise { - setIsTeleporting(true); - setTeleportError(null); + setIsTeleporting(true) + setTeleportError(null) + try { - await teleportResumeCodeSession(session.sessionId); + await teleportResumeCodeSession(session.sessionId) } catch (err) { - setTeleportError(errorMessage(err)); + setTeleportError(errorMessage(err)) } finally { - setIsTeleporting(false); + setIsTeleporting(false) } } // Truncate title if too long (for display purposes) - const displayTitle = truncateToWidth(session.title, 50); + const displayTitle = truncateToWidth(session.title, 50) // Map TaskStatus to display status (handle 'pending') - const displayStatus = session.status === 'pending' ? 'starting' : session.status; - return - exitState.pending ? Press {exitState.keyName} again to exit : + const displayStatus = + session.status === 'pending' ? 'starting' : session.status + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + {onBack && } - {!isTeleporting && } - }> + {!isTeleporting && ( + + )} + + ) + } + > Status:{' '} - {displayStatus === 'running' || displayStatus === 'starting' ? {displayStatus} : displayStatus === 'completed' ? {displayStatus} : {displayStatus}} + {displayStatus === 'running' || displayStatus === 'starting' ? ( + {displayStatus} + ) : displayStatus === 'completed' ? ( + {displayStatus} + ) : ( + {displayStatus} + )} Runtime:{' '} - {formatDuration((session.endTime ?? Date.now()) - session.startTime)} + {formatDuration( + (session.endTime ?? Date.now()) - session.startTime, + )} Title: {displayTitle} @@ -876,12 +605,30 @@ export function RemoteSessionDetailDialog({ {/* Remote session messages section */} - {session.log.length > 0 && + {session.log.length > 0 && ( + Recent messages: - {lastMessages.map((msg, i) => 0} tools={toolUseContext.options.tools} commands={toolUseContext.options.commands} verbose={toolUseContext.options.verbose} inProgressToolUseIDs={new Set()} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} />)} + {lastMessages.map((msg, i) => ( + 0} + tools={toolUseContext.options.tools} + commands={toolUseContext.options.commands} + verbose={toolUseContext.options.verbose} + inProgressToolUseIDs={new Set()} + progressMessagesForMessage={[]} + shouldAnimate={false} + shouldShowDot={false} + style="condensed" + isTranscriptMode={false} + isStatic={true} + /> + ))} @@ -889,15 +636,21 @@ export function RemoteSessionDetailDialog({ messages - } + + )} {/* Teleport error message */} - {teleportError && + {teleportError && ( + Teleport failed: {teleportError} - } + + )} {/* Teleporting status */} - {isTeleporting && Teleporting to session…} + {isTeleporting && ( + Teleporting to session… + )} - ; + + ) } diff --git a/src/components/tasks/RemoteSessionProgress.tsx b/src/components/tasks/RemoteSessionProgress.tsx index 2da0140a5..c1711cd8a 100644 --- a/src/components/tasks/RemoteSessionProgress.tsx +++ b/src/components/tasks/RemoteSessionProgress.tsx @@ -1,14 +1,17 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useRef } from 'react'; -import type { RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { useSettings } from '../../hooks/useSettings.js'; -import { Text, useAnimationFrame } from '../../ink.js'; -import { count } from '../../utils/array.js'; -import { getRainbowColor } from '../../utils/thinking.js'; -const TICK_MS = 80; -type ReviewStage = NonNullable['stage']>; +import React, { useRef } from 'react' +import type { RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { useSettings } from '../../hooks/useSettings.js' +import { Text, useAnimationFrame } from '../../ink.js' +import { count } from '../../utils/array.js' +import { getRainbowColor } from '../../utils/thinking.js' + +const TICK_MS = 80 + +type ReviewStage = NonNullable< + NonNullable['stage'] +> /** * Stage-appropriate counts line for a running review. Shared between the @@ -19,52 +22,48 @@ type ReviewStage = NonNullable 0) parts.push(`${refuted} refuted`); - parts.push('deduping'); - return parts.join(' · '); + const parts = [`${verified} verified`] + if (refuted > 0) parts.push(`${refuted} refuted`) + parts.push('deduping') + return parts.join(' · ') } if (stage === 'verifying') { - const parts = [`${found} found`, `${verified} verified`]; - if (refuted > 0) parts.push(`${refuted} refuted`); - return parts.join(' · '); + const parts = [`${found} found`, `${verified} verified`] + if (refuted > 0) parts.push(`${refuted} refuted`) + return parts.join(' · ') } // stage === 'finding' - return found > 0 ? `${found} found` : 'finding'; + return found > 0 ? `${found} found` : 'finding' } // Per-character rainbow gradient, same treatment as the ultraplan keyword. // The phase offset lets the gradient cycle — so the colors sweep along the // text on each animation frame instead of being static. -function RainbowText(t0) { - const $ = _c(5); - const { - text, - phase: t1 - } = t0; - const phase = t1 === undefined ? 0 : t1; - let t2; - if ($[0] !== text) { - t2 = [...text]; - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== phase || $[3] !== t2) { - t3 = <>{t2.map((ch, i) => {ch})}; - $[2] = phase; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; +function RainbowText({ + text, + phase = 0, +}: { + text: string + phase?: number +}): React.ReactNode { + return ( + <> + {[...text].map((ch, i) => ( + + {ch} + + ))} + + ) } // Smooth-tick a count toward target, +1 per frame. Same pattern as the @@ -74,169 +73,129 @@ function RainbowText(t0) { // the clock is frozen), bypass the tick and jump straight to target — // otherwise a frozen `time` would leave the ref stuck at its init value. function useSmoothCount(target: number, time: number, snap: boolean): number { - const displayed = useRef(target); - const lastTick = useRef(time); + const displayed = useRef(target) + const lastTick = useRef(time) if (snap || target < displayed.current) { - displayed.current = target; + displayed.current = target } else if (target > displayed.current && time !== lastTick.current) { - displayed.current += 1; - lastTick.current = time; + displayed.current += 1 + lastTick.current = time } - return displayed.current; + return displayed.current } -function ReviewRainbowLine(t0) { - const $ = _c(15); - const { - session - } = t0; - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const p = session.reviewProgress; - const running = session.status === "running"; - const [, time] = useAnimationFrame(running && !reducedMotion ? TICK_MS : null); - const targetFound = p?.bugsFound ?? 0; - const targetVerified = p?.bugsVerified ?? 0; - const targetRefuted = p?.bugsRefuted ?? 0; - const snap = reducedMotion || !running; - const found = useSmoothCount(targetFound, time, snap); - const verified = useSmoothCount(targetVerified, time, snap); - const refuted = useSmoothCount(targetRefuted, time, snap); - const phase = Math.floor(time / (TICK_MS * 3)) % 7; - if (session.status === "completed") { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = <>{DIAMOND_FILLED} ready · shift+↓ to view; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - if (session.status === "failed") { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = <>{DIAMOND_FILLED} {" \xB7 "}error; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - let t1; - if ($[2] !== found || $[3] !== p || $[4] !== refuted || $[5] !== verified) { - t1 = !p ? "setting up" : formatReviewStageCounts(p.stage, found, verified, refuted); - $[2] = found; - $[3] = p; - $[4] = refuted; - $[5] = verified; - $[6] = t1; - } else { - t1 = $[6]; - } - const tail = t1; - let t2; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {DIAMOND_OPEN} ; - $[7] = t2; - } else { - t2 = $[7]; - } - const t3 = running ? phase : 0; - let t4; - if ($[8] !== t3) { - t4 = ; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== tail) { - t5 = · {tail}; - $[10] = tail; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t4 || $[13] !== t5) { - t6 = <>{t2}{t4}{t5}; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; + +function ReviewRainbowLine({ + session, +}: { + session: DeepImmutable +}): React.ReactNode { + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + const p = session.reviewProgress + const running = session.status === 'running' + // Animation clock runs only while running — completed/failed are static. + // Disabled entirely when the user prefers reduced motion. + // + // The ref is intentionally discarded: this component is rendered inside + // wrappers (BackgroundTasksDialog, RemoteSessionDetailDialog), and + // Ink can't nest inside . Dropping the ref means + // useTerminalViewport's isVisible stays true, so the clock ticks even when + // scrolled off-screen — acceptable for a single 30-char line. + const [, time] = useAnimationFrame(running && !reducedMotion ? TICK_MS : null) + + const targetFound = p?.bugsFound ?? 0 + const targetVerified = p?.bugsVerified ?? 0 + const targetRefuted = p?.bugsRefuted ?? 0 + // snap when the clock isn't advancing (reduced motion, or not running) — + // useAnimationFrame(null) freezes `time` at its mount value, which would + // leave the tick-gate permanently false. + const snap = reducedMotion || !running + const found = useSmoothCount(targetFound, time, snap) + const verified = useSmoothCount(targetVerified, time, snap) + const refuted = useSmoothCount(targetRefuted, time, snap) + + // Phase advances every 3 ticks so the gradient sweep is visible but + // not frantic. Modulo keeps it in the 7-color cycle. + const phase = Math.floor(time / (TICK_MS * 3)) % 7 + + // ◇ open diamond while running (teal, matches cloud-session accent), ◆ + // filled when terminal. Rainbow is scoped to the word `ultrareview` only — + // per design feedback, "there is a limit to the glittering rainbow". + // Counts stay dimColor. + if (session.status === 'completed') { + return ( + <> + {DIAMOND_FILLED} + + ready · shift+↓ to view + + ) + } + if (session.status === 'failed') { + return ( + <> + {DIAMOND_FILLED} + + + {' · '} + error + + + ) } - return t6; + + // The !p branch ("setting up") covers the window before the orchestrator + // writes its first progress snapshot — container boot + repo clone can + // take 1-3 min, during which "0 found" looked hung. + const tail = !p + ? 'setting up' + : formatReviewStageCounts(p.stage, found, verified, refuted) + return ( + <> + {DIAMOND_OPEN} + + · {tail} + + ) } -export function RemoteSessionProgress(t0) { - const $ = _c(11); - const { - session - } = t0; + +export function RemoteSessionProgress({ + session, +}: { + session: DeepImmutable +}): React.ReactNode { + // Lite-review: rainbow gradient over the full line, ultraplan-style. + // BackgroundTask.tsx delegates the whole wrapper here so the + // gradient spans the title, not just the trailing status. if (session.isRemoteReview) { - let t1; - if ($[0] !== session) { - t1 = ; - $[0] = session; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + return } - if (session.status === "completed") { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = done; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + + if (session.status === 'completed') { + return ( + + done + + ) } - if (session.status === "failed") { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = error; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + + if (session.status === 'failed') { + return ( + + error + + ) } + if (!session.todoList.length) { - let t1; - if ($[4] !== session.status) { - t1 = {session.status}…; - $[4] = session.status; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } - let t1; - if ($[6] !== session.todoList) { - t1 = count(session.todoList, _temp); - $[6] = session.todoList; - $[7] = t1; - } else { - t1 = $[7]; - } - const completed = t1; - const total = session.todoList.length; - let t2; - if ($[8] !== completed || $[9] !== total) { - t2 = {completed}/{total}; - $[8] = completed; - $[9] = total; - $[10] = t2; - } else { - t2 = $[10]; + return {session.status}… } - return t2; -} -function _temp(_) { - return _.status === "completed"; + + const completed = count(session.todoList, _ => _.status === 'completed') + const total = session.todoList.length + return ( + + {completed}/{total} + + ) } diff --git a/src/components/tasks/ShellDetailDialog.tsx b/src/components/tasks/ShellDetailDialog.tsx index d42472c31..a81bafc8b 100644 --- a/src/components/tasks/ShellDetailDialog.tsx +++ b/src/components/tasks/ShellDetailDialog.tsx @@ -1,403 +1,247 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Suspense, use, useDeferredValue, useEffect, useState } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { LocalShellTaskState } from '../../tasks/LocalShellTask/guards.js'; -import { formatDuration, formatFileSize, truncateToWidth } from '../../utils/format.js'; -import { tailFile } from '../../utils/fsOperations.js'; -import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React, { + Suspense, + use, + useDeferredValue, + useEffect, + useState, +} from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { LocalShellTaskState } from '../../tasks/LocalShellTask/guards.js' +import { + formatDuration, + formatFileSize, + truncateToWidth, +} from '../../utils/format.js' +import { tailFile } from '../../utils/fsOperations.js' +import { getTaskOutputPath } from '../../utils/task/diskOutput.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - shell: DeepImmutable; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onKillShell?: () => void; - onBack?: () => void; -}; -const SHELL_DETAIL_TAIL_BYTES = 8192; + shell: DeepImmutable + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onKillShell?: () => void + onBack?: () => void +} + +const SHELL_DETAIL_TAIL_BYTES = 8192 + type TaskOutputResult = { - content: string; - bytesTotal: number; -}; + content: string + bytesTotal: number +} /** * Read the tail of the task output file. Only reads the last few KB, * not the entire file. */ -async function getTaskOutput(shell: DeepImmutable): Promise { - const path = getTaskOutputPath(shell.id); +async function getTaskOutput( + shell: DeepImmutable, +): Promise { + const path = getTaskOutputPath(shell.id) try { - const result = await tailFile(path, SHELL_DETAIL_TAIL_BYTES); - return { - content: result.content, - bytesTotal: result.bytesTotal - }; + const result = await tailFile(path, SHELL_DETAIL_TAIL_BYTES) + return { content: result.content, bytesTotal: result.bytesTotal } } catch { - return { - content: '', - bytesTotal: 0 - }; + return { content: '', bytesTotal: 0 } } } -export function ShellDetailDialog(t0) { - const $ = _c(57); - const { - shell, - onDone, - onKillShell, - onBack - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== shell) { - t1 = () => getTaskOutput(shell); - $[0] = shell; - $[1] = t1; - } else { - t1 = $[1]; - } - const [outputPromise, setOutputPromise] = useState(t1); - const deferredOutputPromise = useDeferredValue(outputPromise); - let t2; - if ($[2] !== shell) { - t2 = () => { - if (shell.status !== "running") { - return; - } - const timer = setInterval(_temp, 1000, setOutputPromise, shell); - return () => clearInterval(timer); - }; - $[2] = shell; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== shell.id || $[5] !== shell.status) { - t3 = [shell.id, shell.status]; - $[4] = shell.id; - $[5] = shell.status; - $[6] = t3; - } else { - t3 = $[6]; - } - useEffect(t2, t3); - let t4; - if ($[7] !== onDone) { - t4 = () => onDone("Shell details dismissed", { - display: "system" - }); - $[7] = onDone; - $[8] = t4; - } else { - t4 = $[8]; - } - const handleClose = t4; - let t5; - if ($[9] !== handleClose) { - t5 = { - "confirm:yes": handleClose - }; - $[9] = handleClose; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - context: "Confirmation" - }; - $[11] = t6; - } else { - t6 = $[11]; + +export function ShellDetailDialog({ + shell, + onDone, + onKillShell, + onBack, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + + // Promise created in initializer (not during render). For running shells, + // the effect timer replaces it periodically to pick up new output. + // useDeferredValue keeps showing the previous output while the new promise + // resolves, preventing the Suspense fallback from flickering. + const [outputPromise, setOutputPromise] = useState>( + () => getTaskOutput(shell), + ) + const deferredOutputPromise = useDeferredValue(outputPromise) + + useEffect(() => { + if (shell.status !== 'running') { + return + } + const timer = setInterval( + (setOutputPromise, shell) => setOutputPromise(getTaskOutput(shell)), + 1000, + setOutputPromise, + shell, + ) + return () => clearInterval(timer) + }, [shell.id, shell.status]) + + // Handle standard close action + const handleClose = () => + onDone('Shell details dismissed', { display: 'system' }) + + // Handle additional close actions beyond Dialog's built-in Esc handler + useKeybindings( + { + 'confirm:yes': handleClose, + }, + { context: 'Confirmation' }, + ) + + // Handle dialog-specific keys + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone('Shell details dismissed', { display: 'system' }) + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && shell.status === 'running' && onKillShell) { + e.preventDefault() + onKillShell() + } } - useKeybindings(t5, t6); - let t7; - if ($[12] !== onBack || $[13] !== onDone || $[14] !== onKillShell || $[15] !== shell.status) { - t7 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone("Shell details dismissed", { - display: "system" - }); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && shell.status === "running" && onKillShell) { - e.preventDefault(); - onKillShell(); - } + + // Truncate command if too long (for display purposes) + const isMonitor = shell.kind === 'monitor' + const displayCommand = truncateToWidth(shell.command, 280) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {shell.status === 'running' && onKillShell && ( + + )} + + ) } - } - }; - $[12] = onBack; - $[13] = onDone; - $[14] = onKillShell; - $[15] = shell.status; - $[16] = t7; - } else { - t7 = $[16]; - } - const handleKeyDown = t7; - const isMonitor = shell.kind === "monitor"; - let t8; - if ($[17] !== shell.command) { - t8 = truncateToWidth(shell.command, 280); - $[17] = shell.command; - $[18] = t8; - } else { - t8 = $[18]; - } - const displayCommand = t8; - const t9 = isMonitor ? "Monitor details" : "Shell details"; - let t10; - if ($[19] !== onBack || $[20] !== onKillShell || $[21] !== shell.status) { - t10 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{shell.status === "running" && onKillShell && }; - $[19] = onBack; - $[20] = onKillShell; - $[21] = shell.status; - $[22] = t10; - } else { - t10 = $[22]; - } - let t11; - if ($[23] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Status:; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== shell.result || $[25] !== shell.status) { - t12 = {t11}{" "}{shell.status === "running" ? {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`} : shell.status === "completed" ? {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`} : {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`}}; - $[24] = shell.result; - $[25] = shell.status; - $[26] = t12; - } else { - t12 = $[26]; - } - let t13; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Runtime:; - $[27] = t13; - } else { - t13 = $[27]; - } - let t14; - if ($[28] !== shell.endTime) { - t14 = shell.endTime ?? Date.now(); - $[28] = shell.endTime; - $[29] = t14; - } else { - t14 = $[29]; - } - const t15 = t14 - shell.startTime; - let t16; - if ($[30] !== t15) { - t16 = formatDuration(t15); - $[30] = t15; - $[31] = t16; - } else { - t16 = $[31]; - } - let t17; - if ($[32] !== t16) { - t17 = {t13}{" "}{t16}; - $[32] = t16; - $[33] = t17; - } else { - t17 = $[33]; - } - const t18 = isMonitor ? "Script:" : "Command:"; - let t19; - if ($[34] !== t18) { - t19 = {t18}; - $[34] = t18; - $[35] = t19; - } else { - t19 = $[35]; - } - let t20; - if ($[36] !== displayCommand || $[37] !== t19) { - t20 = {t19}{" "}{displayCommand}; - $[36] = displayCommand; - $[37] = t19; - $[38] = t20; - } else { - t20 = $[38]; - } - let t21; - if ($[39] !== t12 || $[40] !== t17 || $[41] !== t20) { - t21 = {t12}{t17}{t20}; - $[39] = t12; - $[40] = t17; - $[41] = t20; - $[42] = t21; - } else { - t21 = $[42]; - } - let t22; - if ($[43] === Symbol.for("react.memo_cache_sentinel")) { - t22 = Output:; - $[43] = t22; - } else { - t22 = $[43]; - } - let t23; - if ($[44] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Loading output…; - $[44] = t23; - } else { - t23 = $[44]; - } - let t24; - if ($[45] !== columns || $[46] !== deferredOutputPromise) { - t24 = {t22}; - $[45] = columns; - $[46] = deferredOutputPromise; - $[47] = t24; - } else { - t24 = $[47]; - } - let t25; - if ($[48] !== handleClose || $[49] !== t10 || $[50] !== t21 || $[51] !== t24 || $[52] !== t9) { - t25 = {t21}{t24}; - $[48] = handleClose; - $[49] = t10; - $[50] = t21; - $[51] = t24; - $[52] = t9; - $[53] = t25; - } else { - t25 = $[53]; - } - let t26; - if ($[54] !== handleKeyDown || $[55] !== t25) { - t26 = {t25}; - $[54] = handleKeyDown; - $[55] = t25; - $[56] = t26; - } else { - t26 = $[56]; - } - return t26; -} -function _temp(setOutputPromise_0, shell_0) { - return setOutputPromise_0(getTaskOutput(shell_0)); + > + + + Status:{' '} + {shell.status === 'running' ? ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + ) : shell.status === 'completed' ? ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + ) : ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + )} + + + Runtime:{' '} + {formatDuration((shell.endTime ?? Date.now()) - shell.startTime)} + + + {isMonitor ? 'Script:' : 'Command:'}{' '} + {displayCommand} + + + + + Output: + Loading output…}> + + + + + + ) } + type ShellOutputContentProps = { - outputPromise: Promise; - columns: number; -}; -function ShellOutputContent(t0) { - const $ = _c(19); - const { - outputPromise, - columns - } = t0; - const { - content, - bytesTotal - } = use(outputPromise) as any; + outputPromise: Promise + columns: number +} + +function ShellOutputContent({ + outputPromise, + columns, +}: ShellOutputContentProps): React.ReactNode { + const { content, bytesTotal } = use(outputPromise) + if (!content) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = No output available; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - let isIncomplete; - let rendered; - if ($[1] !== bytesTotal || $[2] !== content) { - const starts = []; - let pos = content.length; - for (let i = 0; i < 10 && pos > 0; i++) { - const prev = content.lastIndexOf("\n", pos - 1); - starts.push(prev + 1); - pos = prev; - } - starts.reverse(); - isIncomplete = bytesTotal > content.length; - rendered = []; - for (let i_0 = 0; i_0 < starts.length; i_0++) { - const start = starts[i_0]; - const end = i_0 < starts.length - 1 ? starts[i_0 + 1] - 1 : content.length; - const line = content.slice(start, end); - if (line) { - rendered.push(line); - } - } - $[1] = bytesTotal; - $[2] = content; - $[3] = isIncomplete; - $[4] = rendered; - } else { - isIncomplete = $[3]; - rendered = $[4]; - } - const t1 = columns - 6; - let t2; - if ($[5] !== rendered) { - t2 = rendered.map(_temp2); - $[5] = rendered; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== t1 || $[8] !== t2) { - t3 = {t2}; - $[7] = t1; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; + return No output available } - const t4 = `Showing ${rendered.length} lines`; - let t5; - if ($[10] !== bytesTotal || $[11] !== isIncomplete) { - t5 = isIncomplete ? ` of ${formatFileSize(bytesTotal)}` : ""; - $[10] = bytesTotal; - $[11] = isIncomplete; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - let t7; - if ($[16] !== t3 || $[17] !== t6) { - t7 = <>{t3}{t6}; - $[16] = t3; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; + + // Find last 10 line boundaries via lastIndexOf + const starts: number[] = [] + let pos = content.length + for (let i = 0; i < 10 && pos > 0; i++) { + const prev = content.lastIndexOf('\n', pos - 1) + starts.push(prev + 1) + pos = prev + } + starts.reverse() + const isIncomplete = bytesTotal > content.length + + // Build lines, skip empty trailing/leading segments + const rendered: string[] = [] + for (let i = 0; i < starts.length; i++) { + const start = starts[i]! + const end = i < starts.length - 1 ? starts[i + 1]! - 1 : content.length + const line = content.slice(start, end) + if (line) rendered.push(line) } - return t7; -} -function _temp2(line_0, i_1) { - return {line_0}; + + return ( + <> + + {rendered.map((line, i) => ( + + {line} + + ))} + + + {`Showing ${rendered.length} lines`} + {isIncomplete ? ` of ${formatFileSize(bytesTotal)}` : ''} + + + ) } diff --git a/src/components/tasks/ShellProgress.tsx b/src/components/tasks/ShellProgress.tsx index 6e9a671c0..b70494c16 100644 --- a/src/components/tasks/ShellProgress.tsx +++ b/src/components/tasks/ShellProgress.tsx @@ -1,86 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React from 'react'; -import { Text } from 'src/ink.js'; -import type { TaskStatus } from 'src/Task.js'; -import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js'; -import type { DeepImmutable } from 'src/types/utils.js'; +import type { ReactNode } from 'react' +import React from 'react' +import { Text } from 'src/ink.js' +import type { TaskStatus } from 'src/Task.js' +import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' +import type { DeepImmutable } from 'src/types/utils.js' + type TaskStatusTextProps = { - status: TaskStatus; - label?: string; - suffix?: string; -}; -export function TaskStatusText(t0) { - const $ = _c(4); - const { - status, - label, - suffix - } = t0; - const displayLabel = label ?? status; - const color = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : undefined; - let t1; - if ($[0] !== color || $[1] !== displayLabel || $[2] !== suffix) { - t1 = ({displayLabel}{suffix}); - $[0] = color; - $[1] = displayLabel; - $[2] = suffix; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + status: TaskStatus + label?: string + suffix?: string +} + +export function TaskStatusText({ + status, + label, + suffix, +}: TaskStatusTextProps): ReactNode { + const displayLabel = label ?? status + const color = + status === 'completed' + ? 'success' + : status === 'failed' + ? 'error' + : status === 'killed' + ? 'warning' + : undefined + return ( + + ({displayLabel} + {suffix}) + + ) } -export function ShellProgress(t0) { - const $ = _c(4); - const { - shell - } = t0; + +export function ShellProgress({ + shell, +}: { + shell: DeepImmutable +}): ReactNode { switch (shell.status) { - case "completed": - { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - case "failed": - { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - case "killed": - { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - case "running": - case "pending": - { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; - } + case 'completed': + return + case 'failed': + return + case 'killed': + return + case 'running': + case 'pending': + return } } diff --git a/src/components/tasks/renderToolActivity.tsx b/src/components/tasks/renderToolActivity.tsx index e2e4ebae7..a6e1c60a2 100644 --- a/src/components/tasks/renderToolActivity.tsx +++ b/src/components/tasks/renderToolActivity.tsx @@ -1,32 +1,39 @@ -import React from 'react'; -import { Text } from '../../ink.js'; -import type { Tools } from '../../Tool.js'; -import { findToolByName } from '../../Tool.js'; -import type { ToolActivity } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import type { ThemeName } from '../../utils/theme.js'; -export function renderToolActivity(activity: ToolActivity, tools: Tools, theme: ThemeName): React.ReactNode { - const tool = findToolByName(tools, activity.toolName); +import React from 'react' +import { Text } from '../../ink.js' +import type { Tools } from '../../Tool.js' +import { findToolByName } from '../../Tool.js' +import type { ToolActivity } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import type { ThemeName } from '../../utils/theme.js' + +export function renderToolActivity( + activity: ToolActivity, + tools: Tools, + theme: ThemeName, +): React.ReactNode { + const tool = findToolByName(tools, activity.toolName) if (!tool) { - return activity.toolName; + return activity.toolName } try { - const parsed = tool.inputSchema.safeParse(activity.input); - const parsedInput = parsed.success ? parsed.data : {}; - const userFacingName = tool.userFacingName(parsedInput); + const parsed = tool.inputSchema.safeParse(activity.input) + const parsedInput = parsed.success ? parsed.data : {} + const userFacingName = tool.userFacingName(parsedInput) if (!userFacingName) { - return activity.toolName; + return activity.toolName } const toolArgs = tool.renderToolUseMessage(parsedInput, { theme, - verbose: false - }); + verbose: false, + }) if (toolArgs) { - return + return ( + {userFacingName}({toolArgs}) - ; + + ) } - return userFacingName; + return userFacingName } catch { - return activity.toolName; + return activity.toolName } } diff --git a/src/components/tasks/taskStatusUtils.tsx b/src/components/tasks/taskStatusUtils.tsx index a70cbd6ca..91cb14cbf 100644 --- a/src/components/tasks/taskStatusUtils.tsx +++ b/src/components/tasks/taskStatusUtils.tsx @@ -2,71 +2,73 @@ * Shared utilities for displaying task status across different task types. */ -import figures from 'figures'; -import type { TaskStatus } from 'src/Task.js'; -import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js'; -import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { summarizeRecentActivities } from 'src/utils/collapseReadSearch.js'; +import figures from 'figures' +import type { TaskStatus } from 'src/Task.js' +import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' +import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { isBackgroundTask, type TaskState } from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { summarizeRecentActivities } from 'src/utils/collapseReadSearch.js' /** * Returns true if the given task status represents a terminal (finished) state. */ export function isTerminalStatus(status: TaskStatus): boolean { - return status === 'completed' || status === 'failed' || status === 'killed'; + return status === 'completed' || status === 'failed' || status === 'killed' } /** * Returns the appropriate icon for a task based on status and state flags. */ -export function getTaskStatusIcon(status: TaskStatus, options?: { - isIdle?: boolean; - awaitingApproval?: boolean; - hasError?: boolean; - shutdownRequested?: boolean; -}): string { - const { - isIdle, - awaitingApproval, - hasError, - shutdownRequested - } = options ?? {}; - if (hasError) return figures.cross; - if (awaitingApproval) return figures.questionMarkPrefix; - if (shutdownRequested) return figures.warning; +export function getTaskStatusIcon( + status: TaskStatus, + options?: { + isIdle?: boolean + awaitingApproval?: boolean + hasError?: boolean + shutdownRequested?: boolean + }, +): string { + const { isIdle, awaitingApproval, hasError, shutdownRequested } = + options ?? {} + + if (hasError) return figures.cross + if (awaitingApproval) return figures.questionMarkPrefix + if (shutdownRequested) return figures.warning + if (status === 'running') { - if (isIdle) return figures.ellipsis; - return figures.play; + if (isIdle) return figures.ellipsis + return figures.play } - if (status === 'completed') return figures.tick; - if (status === 'failed' || status === 'killed') return figures.cross; - return figures.bullet; + if (status === 'completed') return figures.tick + if (status === 'failed' || status === 'killed') return figures.cross + return figures.bullet } /** * Returns the appropriate semantic color for a task based on status and state flags. */ -export function getTaskStatusColor(status: TaskStatus, options?: { - isIdle?: boolean; - awaitingApproval?: boolean; - hasError?: boolean; - shutdownRequested?: boolean; -}): 'success' | 'error' | 'warning' | 'background' { - const { - isIdle, - awaitingApproval, - hasError, - shutdownRequested - } = options ?? {}; - if (hasError) return 'error'; - if (awaitingApproval) return 'warning'; - if (shutdownRequested) return 'warning'; - if (isIdle) return 'background'; - if (status === 'completed') return 'success'; - if (status === 'failed') return 'error'; - if (status === 'killed') return 'warning'; - return 'background'; +export function getTaskStatusColor( + status: TaskStatus, + options?: { + isIdle?: boolean + awaitingApproval?: boolean + hasError?: boolean + shutdownRequested?: boolean + }, +): 'success' | 'error' | 'warning' | 'background' { + const { isIdle, awaitingApproval, hasError, shutdownRequested } = + options ?? {} + + if (hasError) return 'error' + if (awaitingApproval) return 'warning' + if (shutdownRequested) return 'warning' + if (isIdle) return 'background' + + if (status === 'completed') return 'success' + if (status === 'failed') return 'error' + if (status === 'killed') return 'warning' + return 'background' } /** @@ -74,11 +76,18 @@ export function getTaskStatusColor(status: TaskStatus, options?: { * accounting for shutdown/approval/idle states and falling back through * recent-activity summary → last activity description → 'working'. */ -export function describeTeammateActivity(t: DeepImmutable): string { - if (t.shutdownRequested) return 'stopping'; - if (t.awaitingPlanApproval) return 'awaiting approval'; - if (t.isIdle) return 'idle'; - return (t.progress?.recentActivities && summarizeRecentActivities(t.progress.recentActivities)) ?? t.progress?.lastActivity?.activityDescription ?? 'working'; +export function describeTeammateActivity( + t: DeepImmutable, +): string { + if (t.shutdownRequested) return 'stopping' + if (t.awaitingPlanApproval) return 'awaiting approval' + if (t.isIdle) return 'idle' + return ( + (t.progress?.recentActivities && + summarizeRecentActivities(t.progress.recentActivities)) ?? + t.progress?.lastActivity?.activityDescription ?? + 'working' + ) } /** @@ -90,17 +99,21 @@ export function describeTeammateActivity(t: DeepImmutable{children}; - $[0] = children; - $[1] = color; - $[2] = goBack; - $[3] = subtitle; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== footerText) { - t4 = ; - $[6] = footerText; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = <>{t3}{t4}; - $[8] = t3; - $[9] = t4; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; + goBack, + } = useWizard() + const title = titleOverride || providerTitle || 'Wizard' + const stepSuffix = + showStepCounter !== false ? ` (${currentStepIndex + 1}/${totalSteps})` : '' + + return ( + <> + + {children} + + + + ) } diff --git a/src/components/wizard/WizardNavigationFooter.tsx b/src/components/wizard/WizardNavigationFooter.tsx index 183334a91..35a03ee81 100644 --- a/src/components/wizard/WizardNavigationFooter.tsx +++ b/src/components/wizard/WizardNavigationFooter.tsx @@ -1,23 +1,37 @@ -import React, { type ReactNode } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React, { type ReactNode } from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - instructions?: ReactNode; -}; + instructions?: ReactNode +} + export function WizardNavigationFooter({ - instructions = + instructions = ( + - + + ), }: Props): ReactNode { - const exitState = useExitOnCtrlCDWithKeybindings(); - return + const exitState = useExitOnCtrlCDWithKeybindings() + + return ( + - {exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions} + {exitState.pending + ? `Press ${exitState.keyName} again to exit` + : instructions} - ; + + ) } diff --git a/src/components/wizard/WizardProvider.tsx b/src/components/wizard/WizardProvider.tsx index 3160ea610..6707cb95a 100644 --- a/src/components/wizard/WizardProvider.tsx +++ b/src/components/wizard/WizardProvider.tsx @@ -1,156 +1,96 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import type { WizardContextValue, WizardProviderProps } from './types.js'; +import React, { + createContext, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import type { WizardContextValue, WizardProviderProps } from './types.js' // Use any here for the context since it will be cast properly when used // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const WizardContext = createContext | null>(null); -export function WizardProvider(t0) { - const $ = _c(38); - const { - steps, - initialData: t1, - onComplete, - onCancel, - children, - title, - showStepCounter: t2 - } = t0; - let t3; - if ($[0] !== t1) { - t3 = t1 === undefined ? {} as T : t1; - $[0] = t1; - $[1] = t3; - } else { - t3 = $[1]; - } - const initialData = t3; - const showStepCounter = t2 === undefined ? true : t2; - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [wizardData, setWizardData] = useState(initialData); - const [isCompleted, setIsCompleted] = useState(false); - let t4; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t4 = []; - $[2] = t4; - } else { - t4 = $[2]; - } - const [navigationHistory, setNavigationHistory] = useState(t4); - useExitOnCtrlCDWithKeybindings(); - let t5; - let t6; - if ($[3] !== isCompleted || $[4] !== onComplete || $[5] !== wizardData) { - t5 = () => { - if (isCompleted) { - setNavigationHistory([]); - onComplete(wizardData); - } - }; - t6 = [isCompleted, wizardData, onComplete]; - $[3] = isCompleted; - $[4] = onComplete; - $[5] = wizardData; - $[6] = t5; - $[7] = t6; - } else { - t5 = $[6]; - t6 = $[7]; - } - useEffect(t5, t6); - let t7; - if ($[8] !== currentStepIndex || $[9] !== navigationHistory || $[10] !== steps.length) { - t7 = () => { - if (currentStepIndex < steps.length - 1) { - if (navigationHistory.length > 0) { - setNavigationHistory(prev => [...prev, currentStepIndex]); - } - setCurrentStepIndex(_temp); - } else { - setIsCompleted(true); - } - }; - $[8] = currentStepIndex; - $[9] = navigationHistory; - $[10] = steps.length; - $[11] = t7; - } else { - t7 = $[11]; - } - const goNext = t7; - let t8; - if ($[12] !== currentStepIndex || $[13] !== navigationHistory || $[14] !== onCancel) { - t8 = () => { +export const WizardContext = createContext | null>(null) + +export function WizardProvider>({ + steps, + initialData = {} as T, + onComplete, + onCancel, + children, + title, + showStepCounter = true, +}: WizardProviderProps): ReactNode { + const [currentStepIndex, setCurrentStepIndex] = useState(0) + const [wizardData, setWizardData] = useState(initialData) + const [isCompleted, setIsCompleted] = useState(false) + const [navigationHistory, setNavigationHistory] = useState([]) + + useExitOnCtrlCDWithKeybindings() + + // Handle completion in useEffect to avoid updating parent during render + useEffect(() => { + if (isCompleted) { + setNavigationHistory([]) + void onComplete(wizardData) + } + }, [isCompleted, wizardData, onComplete]) + + const goNext = useCallback(() => { + if (currentStepIndex < steps.length - 1) { + // If we have history (non-linear flow), add current step to it if (navigationHistory.length > 0) { - const previousStep = navigationHistory[navigationHistory.length - 1]; - if (previousStep !== undefined) { - setNavigationHistory(_temp2); - setCurrentStepIndex(previousStep); - } - } else { - if (currentStepIndex > 0) { - setCurrentStepIndex(_temp3); - } else { - if (onCancel) { - onCancel(); - } - } + setNavigationHistory(prev => [...prev, currentStepIndex]) } - }; - $[12] = currentStepIndex; - $[13] = navigationHistory; - $[14] = onCancel; - $[15] = t8; - } else { - t8 = $[15]; - } - const goBack = t8; - let t9; - if ($[16] !== currentStepIndex || $[17] !== steps.length) { - t9 = index => { - if (index >= 0 && index < steps.length) { - setNavigationHistory(prev_3 => [...prev_3, currentStepIndex]); - setCurrentStepIndex(index); + + setCurrentStepIndex(prev => prev + 1) + } else { + // Mark as completed, which will trigger useEffect + setIsCompleted(true) + } + }, [currentStepIndex, steps.length, navigationHistory]) + + const goBack = useCallback(() => { + // Check if we have navigation history to use + if (navigationHistory.length > 0) { + const previousStep = navigationHistory[navigationHistory.length - 1] + if (previousStep !== undefined) { + setNavigationHistory(prev => prev.slice(0, -1)) + setCurrentStepIndex(previousStep) } - }; - $[16] = currentStepIndex; - $[17] = steps.length; - $[18] = t9; - } else { - t9 = $[18]; - } - const goToStep = t9; - let t10; - if ($[19] !== onCancel) { - t10 = () => { - setNavigationHistory([]); - if (onCancel) { - onCancel(); + } else if (currentStepIndex > 0) { + // Fallback to simple decrement if no history + setCurrentStepIndex(prev => prev - 1) + } else if (onCancel) { + onCancel() + } + }, [currentStepIndex, navigationHistory, onCancel]) + + const goToStep = useCallback( + (index: number) => { + if (index >= 0 && index < steps.length) { + // Push current step to history before jumping + setNavigationHistory(prev => [...prev, currentStepIndex]) + setCurrentStepIndex(index) } - }; - $[19] = onCancel; - $[20] = t10; - } else { - t10 = $[20]; - } - const cancel = t10; - let t11; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t11 = updates => { - setWizardData(prev_4 => ({ - ...prev_4, - ...updates - })); - }; - $[21] = t11; - } else { - t11 = $[21]; - } - const updateWizardData = t11; - let t12; - if ($[22] !== cancel || $[23] !== currentStepIndex || $[24] !== goBack || $[25] !== goNext || $[26] !== goToStep || $[27] !== showStepCounter || $[28] !== steps.length || $[29] !== title || $[30] !== wizardData) { - t12 = { + }, + [currentStepIndex, steps.length], + ) + + const cancel = useCallback(() => { + setNavigationHistory([]) + if (onCancel) { + onCancel() + } + }, [onCancel]) + + const updateWizardData = useCallback((updates: Partial) => { + setWizardData(prev => ({ ...prev, ...updates })) + }, []) + + const contextValue = useMemo>( + () => ({ currentStepIndex, totalSteps: steps.length, wizardData, @@ -161,52 +101,31 @@ export function WizardProvider(t0) { goToStep, cancel, title, - showStepCounter - }; - $[22] = cancel; - $[23] = currentStepIndex; - $[24] = goBack; - $[25] = goNext; - $[26] = goToStep; - $[27] = showStepCounter; - $[28] = steps.length; - $[29] = title; - $[30] = wizardData; - $[31] = t12; - } else { - t12 = $[31]; - } - const contextValue = t12; - const CurrentStepComponent = steps[currentStepIndex]; + showStepCounter, + }), + [ + currentStepIndex, + steps.length, + wizardData, + updateWizardData, + goNext, + goBack, + goToStep, + cancel, + title, + showStepCounter, + ], + ) + + const CurrentStepComponent = steps[currentStepIndex] + if (!CurrentStepComponent || isCompleted) { - return null; - } - let t13; - if ($[32] !== CurrentStepComponent || $[33] !== children) { - t13 = children || ; - $[32] = CurrentStepComponent; - $[33] = children; - $[34] = t13; - } else { - t13 = $[34]; - } - let t14; - if ($[35] !== contextValue || $[36] !== t13) { - t14 = {t13}; - $[35] = contextValue; - $[36] = t13; - $[37] = t14; - } else { - t14 = $[37]; + return null } - return t14; -} -function _temp3(prev_2) { - return prev_2 - 1; -} -function _temp2(prev_1) { - return prev_1.slice(0, -1); -} -function _temp(prev_0) { - return prev_0 + 1; + + return ( + + {children || } + + ) } From a574ea205bd97e5ce04372af5c5ca18995bec9e4 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 4 Apr 2026 22:50:19 +0800 Subject: [PATCH 5/9] =?UTF-8?q?style(B1-5):=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=20components=E5=85=B6=E4=BD=99=20+=20hooks=20+=20tools=20(232?= =?UTF-8?q?=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 --- src/components/AgentProgressLine.tsx | 234 +- src/components/App.tsx | 78 +- src/components/ApproveApiKey.tsx | 193 +- src/components/AutoModeOptInDialog.tsx | 213 +- src/components/AutoUpdater.tsx | 279 +- src/components/AutoUpdaterWrapper.tsx | 168 +- src/components/AwsAuthStatusBox.tsx | 147 +- src/components/BaseTextInput.tsx | 253 +- src/components/BashModeProgress.tsx | 93 +- src/components/BridgeDialog.tsx | 550 +-- .../BypassPermissionsModeDialog.tsx | 151 +- src/components/ChannelDowngradeDialog.tsx | 140 +- .../ClaudeCodeHint/PluginHintMenu.tsx | 101 +- src/components/ClaudeInChromeOnboarding.tsx | 190 +- .../ClaudeMdExternalIncludesDialog.tsx | 221 +- src/components/ClickableImageRef.tsx | 107 +- src/components/CompactSummary.tsx | 210 +- src/components/ConfigurableShortcutHint.tsx | 67 +- src/components/ConsoleOAuthFlow.tsx | 1707 ++++---- src/components/ContextSuggestions.tsx | 78 +- src/components/ContextVisualization.tsx | 896 ++--- src/components/CoordinatorAgentStatus.tsx | 451 +-- src/components/CostThresholdDialog.tsx | 76 +- src/components/CtrlOToExpand.tsx | 79 +- src/components/CustomSelect/SelectMulti.tsx | 345 +- .../CustomSelect/select-input-option.tsx | 805 ++-- src/components/CustomSelect/select-option.tsx | 71 +- src/components/CustomSelect/select.tsx | 1362 ++++--- src/components/DesktopHandoff.tsx | 313 +- .../DesktopUpsell/DesktopUpsellStartup.tsx | 250 +- src/components/DevBar.tsx | 80 +- src/components/DevChannelsDialog.tsx | 164 +- src/components/DiagnosticsDisplay.tsx | 175 +- src/components/EffortCallout.tsx | 357 +- src/components/ExitFlow.tsx | 66 +- src/components/ExportDialog.tsx | 204 +- .../FallbackToolUseErrorMessage.tsx | 184 +- .../FallbackToolUseRejectedMessage.tsx | 24 +- src/components/FastIcon.tsx | 58 +- src/components/Feedback.tsx | 800 ++-- src/components/FileEditToolDiff.tsx | 272 +- src/components/FileEditToolUpdatedMessage.tsx | 199 +- .../FileEditToolUseRejectedMessage.tsx | 251 +- src/components/FilePathLink.tsx | 41 +- src/components/FullscreenLayout.tsx | 830 ++-- src/components/GlobalSearchDialog.tsx | 600 ++- src/components/HelpV2/Commands.tsx | 146 +- src/components/HelpV2/General.tsx | 42 +- src/components/HelpV2/HelpV2.tsx | 313 +- src/components/HighlightedCode.tsx | 298 +- src/components/HighlightedCode/Fallback.tsx | 257 +- src/components/HistorySearchDialog.tsx | 219 +- src/components/IdeAutoConnectDialog.tsx | 233 +- src/components/IdeOnboardingDialog.tsx | 248 +- src/components/IdeStatusIndicator.tsx | 86 +- src/components/IdleReturnDialog.tsx | 164 +- src/components/InterruptedByUser.tsx | 27 +- src/components/InvalidConfigDialog.tsx | 218 +- src/components/InvalidSettingsDialog.tsx | 121 +- src/components/KeybindingWarnings.tsx | 102 +- src/components/LanguagePicker.tsx | 131 +- src/components/LogSelector.tsx | 2756 ++++++------- src/components/LogoV2/AnimatedAsterisk.tsx | 70 +- src/components/LogoV2/AnimatedClawd.tsx | 155 +- src/components/LogoV2/ChannelsNotice.tsx | 373 +- src/components/LogoV2/Clawd.tsx | 281 +- src/components/LogoV2/CondensedLogo.tsx | 275 +- src/components/LogoV2/EmergencyTip.tsx | 76 +- src/components/LogoV2/Feed.tsx | 196 +- src/components/LogoV2/FeedColumn.tsx | 84 +- src/components/LogoV2/GuestPassesUpsell.tsx | 98 +- src/components/LogoV2/LogoV2.tsx | 998 +++-- src/components/LogoV2/Opus1mMergeNotice.tsx | 89 +- src/components/LogoV2/OverageCreditUpsell.tsx | 193 +- src/components/LogoV2/VoiceModeNotice.tsx | 114 +- src/components/LogoV2/WelcomeV2.tsx | 742 ++-- src/components/LogoV2/feedConfigs.tsx | 128 +- .../LspRecommendationMenu.tsx | 115 +- src/components/MCPServerApprovalDialog.tsx | 196 +- .../MCPServerDesktopImportDialog.tsx | 312 +- src/components/MCPServerDialogCopy.tsx | 24 +- src/components/MCPServerMultiselectDialog.tsx | 243 +- .../ManagedSettingsSecurityDialog.tsx | 230 +- src/components/Markdown.tsx | 258 +- src/components/MarkdownTable.tsx | 365 +- src/components/MemoryUsageIndicator.tsx | 38 +- src/components/Message.tsx | 1056 +++-- src/components/MessageModel.tsx | 64 +- src/components/MessageResponse.tsx | 104 +- src/components/MessageRow.tsx | 518 ++- src/components/MessageSelector.tsx | 1347 ++++--- src/components/MessageTimestamp.tsx | 93 +- src/components/Messages.tsx | 1267 +++--- src/components/ModelPicker.tsx | 757 ++-- src/components/NativeAutoUpdater.tsx | 227 +- .../NotebookEditToolUseRejectedMessage.tsx | 136 +- src/components/OffscreenFreeze.tsx | 32 +- src/components/Onboarding.tsx | 340 +- src/components/OutputStylePicker.tsx | 190 +- src/components/PackageManagerAutoUpdater.tsx | 212 +- src/components/Passes/Passes.tsx | 247 +- src/components/PrBadge.tsx | 132 +- src/components/PressEnterToContinue.tsx | 22 +- src/components/QuickOpenDialog.tsx | 403 +- src/components/RemoteCallout.tsx | 101 +- src/components/RemoteEnvironmentDialog.tsx | 552 ++- src/components/ResumeTask.tsx | 342 +- .../SandboxViolationExpandedView.tsx | 144 +- src/components/ScrollKeybindingHandler.tsx | 949 +++-- src/components/SearchBox.tsx | 138 +- src/components/SessionBackgroundHint.tsx | 178 +- src/components/SessionPreview.tsx | 311 +- src/components/Settings/Config.tsx | 3514 ++++++++++------- src/components/Settings/Settings.tsx | 264 +- src/components/Settings/Status.tsx | 396 +- src/components/Settings/Usage.tsx | 588 ++- src/components/ShowInIDEPrompt.tsx | 267 +- src/components/SkillImprovementSurvey.tsx | 237 +- src/components/Spinner.tsx | 781 ++-- src/components/Spinner/FlashingChar.tsx | 93 +- src/components/Spinner/GlimmerMessage.tsx | 451 +-- src/components/Spinner/ShimmerChar.tsx | 58 +- .../Spinner/SpinnerAnimationRow.tsx | 411 +- src/components/Spinner/SpinnerGlyph.tsx | 149 +- .../Spinner/TeammateSpinnerLine.tsx | 283 +- .../Spinner/TeammateSpinnerTree.tsx | 387 +- src/components/Stats.tsx | 1707 ++++---- src/components/StatusLine.tsx | 442 ++- src/components/StatusNotices.tsx | 75 +- src/components/StructuredDiff.tsx | 283 +- src/components/StructuredDiff/Fallback.tsx | 562 +-- src/components/StructuredDiffList.tsx | 49 +- src/components/TagTabs.tsx | 181 +- src/components/TaskListV2.tsx | 520 ++- src/components/TeammateViewHeader.tsx | 104 +- src/components/TeleportError.tsx | 299 +- src/components/TeleportProgress.tsx | 233 +- src/components/TeleportRepoMismatchDialog.tsx | 197 +- src/components/TeleportResumeWrapper.tsx | 244 +- src/components/TeleportStash.tsx | 155 +- src/components/TextInput.tsx | 157 +- src/components/ThemePicker.tsx | 543 ++- src/components/ThinkingToggle.tsx | 271 +- src/components/TokenWarning.tsx | 291 +- src/components/ToolUseLoader.tsx | 75 +- src/components/TrustDialog/TrustDialog.tsx | 513 ++- src/components/ValidationErrorsList.tsx | 249 +- src/components/VimTextInput.tsx | 202 +- src/components/VirtualMessageList.tsx | 1213 +++--- src/components/WorkflowMultiselectDialog.tsx | 218 +- src/components/WorktreeExitDialog.tsx | 362 +- src/components/diff/DiffDetailView.tsx | 386 +- src/components/diff/DiffDialog.tsx | 629 ++- src/components/diff/DiffFileList.tsx | 416 +- src/components/grove/Grove.tsx | 791 ++-- src/components/hooks/HooksConfigMenu.tsx | 842 ++-- src/components/hooks/PromptDialog.tsx | 128 +- src/components/hooks/SelectEventMode.tsx | 194 +- src/components/hooks/SelectHookMode.tsx | 177 +- src/components/hooks/SelectMatcherMode.tsx | 224 +- src/components/hooks/ViewHookMode.tsx | 246 +- src/components/memory/MemoryFileSelector.tsx | 701 ++-- .../memory/MemoryUpdateNotification.tsx | 68 +- src/components/messageActions.tsx | 669 ++-- src/components/teams/TeamStatus.tsx | 113 +- src/components/teams/TeamsDialog.tsx | 1110 +++--- src/components/ui/OrderedList.tsx | 106 +- src/components/ui/OrderedListItem.tsx | 61 +- src/components/ui/TreeSelect.tsx | 534 ++- .../useCanSwitchToExistingSubscription.tsx | 84 +- .../useDeprecationWarningNotification.tsx | 71 +- src/hooks/notifs/useFastModeNotification.tsx | 254 +- src/hooks/notifs/useIDEStatusIndicator.tsx | 336 +- src/hooks/notifs/useInstallMessages.tsx | 45 +- .../useLspInitializationNotification.tsx | 235 +- src/hooks/notifs/useMcpConnectivityStatus.tsx | 207 +- .../notifs/useModelMigrationNotifications.tsx | 84 +- .../notifs/useNpmDeprecationNotification.tsx | 50 +- .../usePluginAutoupdateNotification.tsx | 137 +- .../notifs/usePluginInstallationStatus.tsx | 189 +- .../useRateLimitWarningNotification.tsx | 191 +- src/hooks/notifs/useSettingsErrors.tsx | 107 +- src/hooks/useArrowKeyHistory.tsx | 316 +- src/hooks/useCanUseTool.tsx | 527 ++- src/hooks/useChromeExtensionNotification.tsx | 94 +- src/hooks/useClaudeCodeHintRecommendation.tsx | 210 +- src/hooks/useCommandKeybindings.tsx | 129 +- src/hooks/useGlobalKeybindings.tsx | 264 +- src/hooks/useIDEIntegration.tsx | 145 +- src/hooks/useLspPluginRecommendation.tsx | 318 +- .../useOfficialMarketplaceNotification.tsx | 98 +- src/hooks/usePluginRecommendationBase.tsx | 136 +- src/hooks/usePromptsFromClaudeInChrome.tsx | 181 +- src/hooks/useReplBridge.tsx | 966 +++-- src/hooks/useTeleportResume.tsx | 152 +- src/hooks/useTypeahead.tsx | 2169 ++++++---- src/hooks/useVoiceIntegration.tsx | 717 ++-- src/tools/AgentTool/AgentTool.tsx | 2223 ++++++----- src/tools/AgentTool/UI.tsx | 1387 ++++--- .../AskUserQuestionTool.tsx | 405 +- src/tools/BashTool/BashTool.tsx | 1397 ++++--- src/tools/BashTool/BashToolResultMessage.tsx | 249 +- src/tools/BashTool/UI.tsx | 317 +- src/tools/BriefTool/UI.tsx | 110 +- src/tools/ConfigTool/UI.tsx | 43 +- src/tools/EnterPlanModeTool/UI.tsx | 41 +- src/tools/EnterWorktreeTool/UI.tsx | 30 +- src/tools/ExitPlanModeTool/UI.tsx | 97 +- src/tools/ExitWorktreeTool/UI.tsx | 39 +- src/tools/FileEditTool/UI.tsx | 480 +-- src/tools/FileReadTool/UI.tsx | 229 +- src/tools/FileWriteTool/UI.tsx | 615 ++- src/tools/GlobTool/UI.tsx | 91 +- src/tools/GrepTool/UI.tsx | 336 +- src/tools/LSPTool/UI.tsx | 352 +- src/tools/ListMcpResourcesTool/UI.tsx | 49 +- src/tools/MCPTool/UI.tsx | 513 ++- src/tools/NotebookEditTool/UI.tsx | 152 +- src/tools/PowerShellTool/PowerShellTool.tsx | 1169 +++--- src/tools/PowerShellTool/UI.tsx | 237 +- src/tools/ReadMcpResourceTool/UI.tsx | 52 +- src/tools/RemoteTriggerTool/UI.tsx | 22 +- src/tools/ScheduleCronTool/UI.tsx | 74 +- src/tools/SendMessageTool/UI.tsx | 46 +- src/tools/SkillTool/UI.tsx | 230 +- src/tools/TaskOutputTool/TaskOutputTool.tsx | 951 +++-- src/tools/TaskStopTool/UI.tsx | 63 +- src/tools/TeamCreateTool/UI.tsx | 7 +- src/tools/TeamDeleteTool/UI.tsx | 32 +- src/tools/WebFetchTool/UI.tsx | 88 +- src/tools/WebSearchTool/UI.tsx | 143 +- src/tools/testing/TestingPermissionTool.tsx | 59 +- 232 files changed, 40150 insertions(+), 40018 deletions(-) diff --git a/src/components/AgentProgressLine.tsx b/src/components/AgentProgressLine.tsx index 8de2fb060..7580160e7 100644 --- a/src/components/AgentProgressLine.tsx +++ b/src/components/AgentProgressLine.tsx @@ -1,135 +1,105 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../ink.js'; -import { formatNumber } from '../utils/format.js'; -import type { Theme } from '../utils/theme.js'; +import * as React from 'react' +import { Box, Text } from '../ink.js' +import { formatNumber } from '../utils/format.js' +import type { Theme } from '../utils/theme.js' + type Props = { - agentType: string; - description?: string; - name?: string; - descriptionColor?: keyof Theme; - taskDescription?: string; - toolUseCount: number; - tokens: number | null; - color?: keyof Theme; - isLast: boolean; - isResolved: boolean; - isError: boolean; - isAsync?: boolean; - shouldAnimate: boolean; - lastToolInfo?: string | null; - hideType?: boolean; -}; -export function AgentProgressLine(t0) { - const $ = _c(32); - const { - agentType, - description, - name, - descriptionColor, - taskDescription, - toolUseCount, - tokens, - color, - isLast, - isResolved, - isAsync: t1, - lastToolInfo, - hideType: t2 - } = t0; - const isAsync = t1 === undefined ? false : t1; - const hideType = t2 === undefined ? false : t2; - const treeChar = isLast ? "\u2514\u2500" : "\u251C\u2500"; - const isBackgrounded = isAsync && isResolved; - let t3; - if ($[0] !== isBackgrounded || $[1] !== isResolved || $[2] !== lastToolInfo || $[3] !== taskDescription) { - t3 = () => { - if (!isResolved) { - return lastToolInfo || "Initializing\u2026"; - } - if (isBackgrounded) { - return taskDescription ?? "Running in the background"; - } - return "Done"; - }; - $[0] = isBackgrounded; - $[1] = isResolved; - $[2] = lastToolInfo; - $[3] = taskDescription; - $[4] = t3; - } else { - t3 = $[4]; - } - const getStatusText = t3; - let t4; - if ($[5] !== treeChar) { - t4 = {treeChar} ; - $[5] = treeChar; - $[6] = t4; - } else { - t4 = $[6]; - } - const t5 = !isResolved; - let t6; - if ($[7] !== agentType || $[8] !== color || $[9] !== description || $[10] !== descriptionColor || $[11] !== hideType || $[12] !== name) { - t6 = hideType ? <>{name ?? description ?? agentType}{name && description && : {description}} : <>{agentType}{description && <>{" ("}{description}{")"}}; - $[7] = agentType; - $[8] = color; - $[9] = description; - $[10] = descriptionColor; - $[11] = hideType; - $[12] = name; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== isBackgrounded || $[15] !== tokens || $[16] !== toolUseCount) { - t7 = !isBackgrounded && <>{" \xB7 "}{toolUseCount} tool {toolUseCount === 1 ? "use" : "uses"}{tokens !== null && <> · {formatNumber(tokens)} tokens}; - $[14] = isBackgrounded; - $[15] = tokens; - $[16] = toolUseCount; - $[17] = t7; - } else { - t7 = $[17]; - } - let t8; - if ($[18] !== t5 || $[19] !== t6 || $[20] !== t7) { - t8 = {t6}{t7}; - $[18] = t5; - $[19] = t6; - $[20] = t7; - $[21] = t8; - } else { - t8 = $[21]; - } - let t9; - if ($[22] !== t4 || $[23] !== t8) { - t9 = {t4}{t8}; - $[22] = t4; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - let t10; - if ($[25] !== getStatusText || $[26] !== isBackgrounded || $[27] !== isLast) { - t10 = !isBackgrounded && {isLast ? " \u23BF " : "\u2502 \u23BF "}{getStatusText()}; - $[25] = getStatusText; - $[26] = isBackgrounded; - $[27] = isLast; - $[28] = t10; - } else { - t10 = $[28]; - } - let t11; - if ($[29] !== t10 || $[30] !== t9) { - t11 = {t9}{t10}; - $[29] = t10; - $[30] = t9; - $[31] = t11; - } else { - t11 = $[31]; + agentType: string + description?: string + name?: string + descriptionColor?: keyof Theme + taskDescription?: string + toolUseCount: number + tokens: number | null + color?: keyof Theme + isLast: boolean + isResolved: boolean + isError: boolean + isAsync?: boolean + shouldAnimate: boolean + lastToolInfo?: string | null + hideType?: boolean +} + +export function AgentProgressLine({ + agentType, + description, + name, + descriptionColor, + taskDescription, + toolUseCount, + tokens, + color, + isLast, + isResolved, + isError: _isError, + isAsync = false, + shouldAnimate: _shouldAnimate, + lastToolInfo, + hideType = false, +}: Props): React.ReactNode { + const treeChar = isLast ? '└─' : '├─' + const isBackgrounded = isAsync && isResolved + + // Determine the status text + const getStatusText = (): string => { + if (!isResolved) { + return lastToolInfo || 'Initializing…' + } + if (isBackgrounded) { + return taskDescription ?? 'Running in the background' + } + return 'Done' } - return t11; + + return ( + + + {treeChar} + + {hideType ? ( + <> + {name ?? description ?? agentType} + {name && description && : {description}} + + ) : ( + <> + + {agentType} + + {description && ( + <> + {' ('} + + {description} + + {')'} + + )} + + )} + {!isBackgrounded && ( + <> + {' · '} + {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} + {tokens !== null && <> · {formatNumber(tokens)} tokens} + + )} + + + {!isBackgrounded && ( + + {isLast ? ' ⎿ ' : '│ ⎿ '} + {getStatusText()} + + )} + + ) } diff --git a/src/components/App.tsx b/src/components/App.tsx index dc83cdc29..45e97624a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,55 +1,37 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { FpsMetricsProvider } from '../context/fpsMetrics.js'; -import { StatsProvider, type StatsStore } from '../context/stats.js'; -import { type AppState, AppStateProvider } from '../state/AppState.js'; -import { onChangeAppState } from '../state/onChangeAppState.js'; -import type { FpsMetrics } from '../utils/fpsTracker.js'; +import React from 'react' +import { FpsMetricsProvider } from '../context/fpsMetrics.js' +import { StatsProvider, type StatsStore } from '../context/stats.js' +import { type AppState, AppStateProvider } from '../state/AppState.js' +import { onChangeAppState } from '../state/onChangeAppState.js' +import type { FpsMetrics } from '../utils/fpsTracker.js' + type Props = { - getFpsMetrics: () => FpsMetrics | undefined; - stats?: StatsStore; - initialState: AppState; - children: React.ReactNode; -}; + getFpsMetrics: () => FpsMetrics | undefined + stats?: StatsStore + initialState: AppState + children: React.ReactNode +} /** * Top-level wrapper for interactive sessions. * Provides FPS metrics, stats context, and app state to the component tree. */ -export function App(t0) { - const $ = _c(9); - const { - getFpsMetrics, - stats, - initialState, - children - } = t0; - let t1; - if ($[0] !== children || $[1] !== initialState) { - t1 = {children}; - $[0] = children; - $[1] = initialState; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== stats || $[4] !== t1) { - t2 = {t1}; - $[3] = stats; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== getFpsMetrics || $[7] !== t2) { - t3 = {t2}; - $[6] = getFpsMetrics; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; +export function App({ + getFpsMetrics, + stats, + initialState, + children, +}: Props): React.ReactNode { + return ( + + + + {children} + + + + ) } diff --git a/src/components/ApproveApiKey.tsx b/src/components/ApproveApiKey.tsx index 1957075f9..990f0c14e 100644 --- a/src/components/ApproveApiKey.tsx +++ b/src/components/ApproveApiKey.tsx @@ -1,122 +1,79 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../ink.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { Text } from '../ink.js' +import { saveGlobalConfig } from '../utils/config.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - customApiKeyTruncated: string; - onDone(approved: boolean): void; -}; -export function ApproveApiKey(t0) { - const $ = _c(17); - const { - customApiKeyTruncated, - onDone - } = t0; - let t1; - if ($[0] !== customApiKeyTruncated || $[1] !== onDone) { - t1 = function onChange(value) { - bb2: switch (value) { - case "yes": - { - saveGlobalConfig(current_0 => ({ - ...current_0, - customApiKeyResponses: { - ...current_0.customApiKeyResponses, - approved: [...(current_0.customApiKeyResponses?.approved ?? []), customApiKeyTruncated] - } - })); - onDone(true); - break bb2; - } - case "no": - { - saveGlobalConfig(current => ({ - ...current, - customApiKeyResponses: { - ...current.customApiKeyResponses, - rejected: [...(current.customApiKeyResponses?.rejected ?? []), customApiKeyTruncated] - } - })); - onDone(false); - } + customApiKeyTruncated: string + onDone(approved: boolean): void +} + +export function ApproveApiKey({ + customApiKeyTruncated, + onDone, +}: Props): React.ReactNode { + function onChange(value: 'yes' | 'no') { + switch (value) { + case 'yes': { + saveGlobalConfig(current => ({ + ...current, + customApiKeyResponses: { + ...current.customApiKeyResponses, + approved: [ + ...(current.customApiKeyResponses?.approved ?? []), + customApiKeyTruncated, + ], + }, + })) + onDone(true) + break } - }; - $[0] = customApiKeyTruncated; - $[1] = onDone; - $[2] = t1; - } else { - t1 = $[2]; - } - const onChange = t1; - let t2; - if ($[3] !== onChange) { - t2 = () => onChange("no"); - $[3] = onChange; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ANTHROPIC_API_KEY; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== customApiKeyTruncated) { - t4 = {t3}: sk-ant-...{customApiKeyTruncated}; - $[6] = customApiKeyTruncated; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Do you want to use this API key?; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: "Yes", - value: "yes" - }; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = [t6, { - label: No (recommended), - value: "no" - }]; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== onChange) { - t8 = + No (recommended) + + ), + value: 'no', + }, + ]} + onChange={value => onChange(value as 'yes' | 'no')} + onCancel={() => onChange('no')} + /> + + ) } diff --git a/src/components/AutoModeOptInDialog.tsx b/src/components/AutoModeOptInDialog.tsx index e1504a9f2..4aeee28e6 100644 --- a/src/components/AutoModeOptInDialog.tsx +++ b/src/components/AutoModeOptInDialog.tsx @@ -1,141 +1,86 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Link, Text } from '../ink.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Link, Text } from '../ink.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' // NOTE: This copy is legally reviewed — do not modify without Legal team approval. -export const AUTO_MODE_DESCRIPTION = "Auto mode lets Claude handle permission prompts automatically — Claude checks each tool call for risky actions and prompt injection before executing. Actions Claude identifies as safe are executed, while actions Claude identifies as risky are blocked and Claude may try a different approach. Ideal for long-running tasks. Sessions are slightly more expensive. Claude can make mistakes that allow harmful commands to run, it's recommended to only use in isolated environments. Shift+Tab to change mode."; +export const AUTO_MODE_DESCRIPTION = + "Auto mode lets Claude handle permission prompts automatically — Claude checks each tool call for risky actions and prompt injection before executing. Actions Claude identifies as safe are executed, while actions Claude identifies as risky are blocked and Claude may try a different approach. Ideal for long-running tasks. Sessions are slightly more expensive. Claude can make mistakes that allow harmful commands to run, it's recommended to only use in isolated environments. Shift+Tab to change mode." + type Props = { - onAccept(): void; - onDecline(): void; + onAccept(): void + onDecline(): void // Startup gate: decline exits the process, so relabel accordingly. - declineExits?: boolean; -}; -export function AutoModeOptInDialog(t0) { - const $ = _c(18); - const { - onAccept, - onDecline, - declineExits - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp, t1); - let t2; - if ($[1] !== onAccept || $[2] !== onDecline) { - t2 = function onChange(value) { - bb3: switch (value) { - case "accept": - { - logEvent("tengu_auto_mode_opt_in_dialog_accept", {}); - updateSettingsForSource("userSettings", { - skipAutoPermissionPrompt: true - }); - onAccept(); - break bb3; - } - case "accept-default": - { - logEvent("tengu_auto_mode_opt_in_dialog_accept_default", {}); - updateSettingsForSource("userSettings", { - skipAutoPermissionPrompt: true, - permissions: { - defaultMode: "auto" - } - }); - onAccept(); - break bb3; - } - case "decline": - { - logEvent("tengu_auto_mode_opt_in_dialog_decline", {}); - onDecline(); - } + declineExits?: boolean +} + +export function AutoModeOptInDialog({ + onAccept, + onDecline, + declineExits, +}: Props): React.ReactNode { + React.useEffect(() => { + logEvent('tengu_auto_mode_opt_in_dialog_shown', {}) + }, []) + + function onChange(value: 'accept' | 'accept-default' | 'decline') { + switch (value) { + case 'accept': { + logEvent('tengu_auto_mode_opt_in_dialog_accept', {}) + updateSettingsForSource('userSettings', { + skipAutoPermissionPrompt: true, + }) + onAccept() + break } - }; - $[1] = onAccept; - $[2] = onDecline; - $[3] = t2; - } else { - t2 = $[3]; - } - const onChange = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {AUTO_MODE_DESCRIPTION}; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = true ? [{ - label: "Yes, and make it my default mode", - value: "accept-default" as const - }] : []; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Yes, enable auto mode", - value: "accept" as const - }; - $[6] = t5; - } else { - t5 = $[6]; - } - const t6 = declineExits ? "No, exit" : "No, go back"; - let t7; - if ($[7] !== t6) { - t7 = [...t4, t5, { - label: t6, - value: "decline" as const - }]; - $[7] = t6; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] !== onChange) { - t8 = value_0 => onChange(value_0 as 'accept' | 'accept-default' | 'decline'); - $[9] = onChange; - $[10] = t8; - } else { - t8 = $[10]; - } - let t9; - if ($[11] !== onDecline || $[12] !== t7 || $[13] !== t8) { - t9 = + onChange(value as 'accept' | 'accept-default' | 'decline') + } + onCancel={onDecline} + /> + + ) } diff --git a/src/components/AutoUpdater.tsx b/src/components/AutoUpdater.tsx index 3acbbfc89..09b523fc4 100644 --- a/src/components/AutoUpdater.tsx +++ b/src/components/AutoUpdater.tsx @@ -1,197 +1,264 @@ -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useInterval } from 'usehooks-ts'; -import { useUpdateNotification } from '../hooks/useUpdateNotification.js'; -import { Box, Text } from '../ink.js'; -import { type AutoUpdaterResult, getLatestVersion, getMaxVersion, type InstallStatus, installGlobalPackage, shouldSkipVersion } from '../utils/autoUpdater.js'; -import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'; -import { installOrUpdateClaudePackage, localInstallationExists } from '../utils/localInstaller.js'; -import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js'; -import { gt, gte } from '../utils/semver.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { useInterval } from 'usehooks-ts' +import { useUpdateNotification } from '../hooks/useUpdateNotification.js' +import { Box, Text } from '../ink.js' +import { + type AutoUpdaterResult, + getLatestVersion, + getMaxVersion, + type InstallStatus, + installGlobalPackage, + shouldSkipVersion, +} from '../utils/autoUpdater.js' +import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js' +import { + installOrUpdateClaudePackage, + localInstallationExists, +} from '../utils/localInstaller.js' +import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js' +import { gt, gte } from '../utils/semver.js' +import { getInitialSettings } from '../utils/settings/settings.js' + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + export function AutoUpdater({ isUpdating, onChangeIsUpdating, onAutoUpdaterResult, autoUpdaterResult, showSuccessMessage, - verbose + verbose, }: Props): React.ReactNode { const [versions, setVersions] = useState<{ - global?: string | null; - latest?: string | null; - }>({}); - const [hasLocalInstall, setHasLocalInstall] = useState(false); - const updateSemver = useUpdateNotification(autoUpdaterResult?.version); + global?: string | null + latest?: string | null + }>({}) + const [hasLocalInstall, setHasLocalInstall] = useState(false) + const updateSemver = useUpdateNotification(autoUpdaterResult?.version) + useEffect(() => { - void localInstallationExists().then(setHasLocalInstall); - }, []); + void localInstallationExists().then(setHasLocalInstall) + }, []) // Track latest isUpdating value in a ref so the memoized checkForUpdates // callback always sees the current value. Without this, the 30-minute // interval fires with a stale closure where isUpdating is false, allowing // a concurrent installGlobalPackage() to run while one is already in // progress. - const isUpdatingRef = useRef(isUpdating); - isUpdatingRef.current = isUpdating; + const isUpdatingRef = useRef(isUpdating) + isUpdatingRef.current = isUpdating + const checkForUpdates = React.useCallback(async () => { if (isUpdatingRef.current) { - return; + return } - if (("production" as string) === 'test' || ("production" as string) === 'development') { - logForDebugging('AutoUpdater: Skipping update check in test/dev environment'); - return; + + if ( + "production" === 'test' || + "production" === 'development' + ) { + logForDebugging( + 'AutoUpdater: Skipping update check in test/dev environment', + ) + return } - const currentVersion = MACRO.VERSION; - const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'; - let latestVersion = await getLatestVersion(channel); - const isDisabled = isAutoUpdaterDisabled(); + + const currentVersion = MACRO.VERSION + const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' + let latestVersion = await getLatestVersion(channel) + const isDisabled = isAutoUpdaterDisabled() // Check if max version is set (server-side kill switch for auto-updates) - const maxVersion = await getMaxVersion(); + const maxVersion = await getMaxVersion() if (maxVersion && latestVersion && gt(latestVersion, maxVersion)) { - logForDebugging(`AutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latestVersion} to ${maxVersion}`); + logForDebugging( + `AutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latestVersion} to ${maxVersion}`, + ) if (gte(currentVersion, maxVersion)) { - logForDebugging(`AutoUpdater: current version ${currentVersion} is already at or above maxVersion ${maxVersion}, skipping update`); - setVersions({ - global: currentVersion, - latest: latestVersion - }); - return; + logForDebugging( + `AutoUpdater: current version ${currentVersion} is already at or above maxVersion ${maxVersion}, skipping update`, + ) + setVersions({ global: currentVersion, latest: latestVersion }) + return } - latestVersion = maxVersion; + latestVersion = maxVersion } - setVersions({ - global: currentVersion, - latest: latestVersion - }); + + setVersions({ global: currentVersion, latest: latestVersion }) // Check if update needed and perform update - if (!isDisabled && currentVersion && latestVersion && !gte(currentVersion, latestVersion) && !shouldSkipVersion(latestVersion)) { - const startTime = Date.now(); - onChangeIsUpdating(true); + if ( + !isDisabled && + currentVersion && + latestVersion && + !gte(currentVersion, latestVersion) && + !shouldSkipVersion(latestVersion) + ) { + const startTime = Date.now() + onChangeIsUpdating(true) // Remove native installer symlink since we're using JS-based updates // But only if user hasn't migrated to native installation - const config = getGlobalConfig(); + const config = getGlobalConfig() if (config.installMethod !== 'native') { - await removeInstalledSymlink(); + await removeInstalledSymlink() } // Detect actual running installation type - const installationType = await getCurrentInstallationType(); - logForDebugging(`AutoUpdater: Detected installation type: ${installationType}`); + const installationType = await getCurrentInstallationType() + logForDebugging( + `AutoUpdater: Detected installation type: ${installationType}`, + ) // Skip update for development builds if (installationType === 'development') { - logForDebugging('AutoUpdater: Cannot auto-update development build'); - onChangeIsUpdating(false); - return; + logForDebugging('AutoUpdater: Cannot auto-update development build') + onChangeIsUpdating(false) + return } // Choose the appropriate update method based on what's actually running - let installStatus: InstallStatus; - let updateMethod: 'local' | 'global'; + let installStatus: InstallStatus + let updateMethod: 'local' | 'global' + if (installationType === 'npm-local') { // Use local update for local installations - logForDebugging('AutoUpdater: Using local update method'); - updateMethod = 'local'; - installStatus = await installOrUpdateClaudePackage(channel); + logForDebugging('AutoUpdater: Using local update method') + updateMethod = 'local' + installStatus = await installOrUpdateClaudePackage(channel) } else if (installationType === 'npm-global') { // Use global update for global installations - logForDebugging('AutoUpdater: Using global update method'); - updateMethod = 'global'; - installStatus = await installGlobalPackage(); + logForDebugging('AutoUpdater: Using global update method') + updateMethod = 'global' + installStatus = await installGlobalPackage() } else if (installationType === 'native') { // This shouldn't happen - native should use NativeAutoUpdater - logForDebugging('AutoUpdater: Unexpected native installation in non-native updater'); - onChangeIsUpdating(false); - return; + logForDebugging( + 'AutoUpdater: Unexpected native installation in non-native updater', + ) + onChangeIsUpdating(false) + return } else { // Fallback to config-based detection for unknown types - logForDebugging(`AutoUpdater: Unknown installation type, falling back to config`); - const isMigrated = config.installMethod === 'local'; - updateMethod = isMigrated ? 'local' : 'global'; + logForDebugging( + `AutoUpdater: Unknown installation type, falling back to config`, + ) + const isMigrated = config.installMethod === 'local' + updateMethod = isMigrated ? 'local' : 'global' + if (isMigrated) { - installStatus = await installOrUpdateClaudePackage(channel); + installStatus = await installOrUpdateClaudePackage(channel) } else { - installStatus = await installGlobalPackage(); + installStatus = await installGlobalPackage() } } - onChangeIsUpdating(false); + + onChangeIsUpdating(false) + if (installStatus === 'success') { logEvent('tengu_auto_updater_success', { - fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + fromVersion: + currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toVersion: + latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', - installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + installationType: + installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } else { logEvent('tengu_auto_updater_fail', { - fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - attemptedVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - status: installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + fromVersion: + currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + attemptedVersion: + latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + status: + installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', - installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + installationType: + installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } + onAutoUpdaterResult({ version: latestVersion, - status: installStatus - }); + status: installStatus, + }) } // isUpdating intentionally omitted from deps; we read isUpdatingRef // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref - }, [onAutoUpdaterResult]); + }, [onAutoUpdaterResult]) // Initial check useEffect(() => { - void checkForUpdates(); - }, [checkForUpdates]); + void checkForUpdates() + }, [checkForUpdates]) // Check every 30 minutes - useInterval(checkForUpdates, 30 * 60 * 1000); + useInterval(checkForUpdates, 30 * 60 * 1000) + if (!autoUpdaterResult?.version && (!versions.global || !versions.latest)) { - return null; + return null } + if (!autoUpdaterResult?.version && !isUpdating) { - return null; + return null } - return - {verbose && + + return ( + + {verbose && ( + globalVersion: {versions.global} · latestVersion:{' '} {versions.latest} - } - {isUpdating ? <> + + )} + {isUpdating ? ( + <> Auto-updating… - : autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && + + ) : ( + autoUpdaterResult?.status === 'success' && + showSuccessMessage && + updateSemver && ( + ✓ Update installed · Restart to apply - } - {(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && + + ) + )} + {(autoUpdaterResult?.status === 'install_failed' || + autoUpdaterResult?.status === 'no_permissions') && ( + ✗ Auto-update failed · Try claude doctor or{' '} - {hasLocalInstall ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`} + {hasLocalInstall + ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` + : `npm i -g ${MACRO.PACKAGE_URL}`} - } - ; + + )} + + ) } diff --git a/src/components/AutoUpdaterWrapper.tsx b/src/components/AutoUpdaterWrapper.tsx index e229d09d2..709c776d2 100644 --- a/src/components/AutoUpdaterWrapper.tsx +++ b/src/components/AutoUpdaterWrapper.tsx @@ -1,90 +1,90 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; -import { isAutoUpdaterDisabled } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'; -import { AutoUpdater } from './AutoUpdater.js'; -import { NativeAutoUpdater } from './NativeAutoUpdater.js'; -import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import type { AutoUpdaterResult } from '../utils/autoUpdater.js' +import { isAutoUpdaterDisabled } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js' +import { AutoUpdater } from './AutoUpdater.js' +import { NativeAutoUpdater } from './NativeAutoUpdater.js' +import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js' + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; -export function AutoUpdaterWrapper(t0) { - const $ = _c(17); - const { - isUpdating, - onChangeIsUpdating, - onAutoUpdaterResult, - autoUpdaterResult, - showSuccessMessage, - verbose - } = t0; - const [useNativeInstaller, setUseNativeInstaller] = React.useState(null); - const [isPackageManager, setIsPackageManager] = React.useState(null); - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - const checkInstallation = async function checkInstallation() { - if (feature("SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED") && isAutoUpdaterDisabled()) { - logForDebugging("AutoUpdaterWrapper: Skipping detection, auto-updates disabled"); - return; - } - const installationType = await getCurrentInstallationType(); - logForDebugging(`AutoUpdaterWrapper: Installation type: ${installationType}`); - setUseNativeInstaller(installationType === "native"); - setIsPackageManager(installationType === "package-manager"); - }; - checkInstallation(); - }; - t2 = []; - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - React.useEffect(t1, t2); + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + +export function AutoUpdaterWrapper({ + isUpdating, + onChangeIsUpdating, + onAutoUpdaterResult, + autoUpdaterResult, + showSuccessMessage, + verbose, +}: Props): React.ReactNode { + const [useNativeInstaller, setUseNativeInstaller] = React.useState< + boolean | null + >(null) + const [isPackageManager, setIsPackageManager] = React.useState< + boolean | null + >(null) + + React.useEffect(() => { + async function checkInstallation() { + // Skip installation type detection if auto-updates are disabled (ant-only) + // This avoids potentially slow package manager detection (spawnSync calls) + if ( + feature('SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED') && + isAutoUpdaterDisabled() + ) { + logForDebugging( + 'AutoUpdaterWrapper: Skipping detection, auto-updates disabled', + ) + return + } + + const installationType = await getCurrentInstallationType() + logForDebugging( + `AutoUpdaterWrapper: Installation type: ${installationType}`, + ) + setUseNativeInstaller(installationType === 'native') + setIsPackageManager(installationType === 'package-manager') + } + + void checkInstallation() + }, []) + + // Don't render until we know the installation type if (useNativeInstaller === null || isPackageManager === null) { - return null; + return null } + if (isPackageManager) { - let t3; - if ($[2] !== autoUpdaterResult || $[3] !== isUpdating || $[4] !== onAutoUpdaterResult || $[5] !== onChangeIsUpdating || $[6] !== showSuccessMessage || $[7] !== verbose) { - t3 = ; - $[2] = autoUpdaterResult; - $[3] = isUpdating; - $[4] = onAutoUpdaterResult; - $[5] = onChangeIsUpdating; - $[6] = showSuccessMessage; - $[7] = verbose; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; - } - const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater; - let t3; - if ($[9] !== Updater || $[10] !== autoUpdaterResult || $[11] !== isUpdating || $[12] !== onAutoUpdaterResult || $[13] !== onChangeIsUpdating || $[14] !== showSuccessMessage || $[15] !== verbose) { - t3 = ; - $[9] = Updater; - $[10] = autoUpdaterResult; - $[11] = isUpdating; - $[12] = onAutoUpdaterResult; - $[13] = onChangeIsUpdating; - $[14] = showSuccessMessage; - $[15] = verbose; - $[16] = t3; - } else { - t3 = $[16]; + return ( + + ) } - return t3; + + const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater + + return ( + + ) } diff --git a/src/components/AwsAuthStatusBox.tsx b/src/components/AwsAuthStatusBox.tsx index 6f20bd497..ea2d1a5d3 100644 --- a/src/components/AwsAuthStatusBox.tsx +++ b/src/components/AwsAuthStatusBox.tsx @@ -1,81 +1,76 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useState } from 'react'; -import { Box, Link, Text } from '../ink.js'; -import { type AwsAuthStatus, AwsAuthStatusManager } from '../utils/awsAuthStatusManager.js'; -const URL_RE = /https?:\/\/\S+/; -export function AwsAuthStatusBox() { - const $ = _c(11); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = AwsAuthStatusManager.getInstance().getStatus(); - $[0] = t0; - } else { - t0 = $[0]; - } - const [status, setStatus] = useState(t0); - let t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus); - return unsubscribe; - }; - t2 = []; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); +import React, { useEffect, useState } from 'react' +import { Box, Link, Text } from '../ink.js' +import { + type AwsAuthStatus, + AwsAuthStatusManager, +} from '../utils/awsAuthStatusManager.js' + +const URL_RE = /https?:\/\/\S+/ + +export function AwsAuthStatusBox(): React.ReactNode { + const [status, setStatus] = useState( + AwsAuthStatusManager.getInstance().getStatus(), + ) + + useEffect(() => { + // Subscribe to status updates + const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus) + return unsubscribe + }, []) + + // Don't show anything if not authenticating and no error if (!status.isAuthenticating && !status.error && status.output.length === 0) { - return null; + return null } + + // Don't show if authentication succeeded (no error and not authenticating) if (!status.isAuthenticating && !status.error) { - return null; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Cloud Authentication; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== status.output) { - t4 = status.output.length > 0 && {status.output.slice(-5).map(_temp)}; - $[4] = status.output; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== status.error) { - t5 = status.error && {status.error}; - $[6] = status.error; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== t4 || $[9] !== t5) { - t6 = {t3}{t4}{t5}; - $[8] = t4; - $[9] = t5; - $[10] = t6; - } else { - t6 = $[10]; - } - return t6; -} -function _temp(line, index) { - const m = line.match(URL_RE); - if (!m) { - return {line}; + return null } - const url = m[0]; - const start = m.index ?? 0; - const before = line.slice(0, start); - const after = line.slice(start + url.length); - return {before}{url}{after}; + + return ( + + + Cloud Authentication + + + {status.output.length > 0 && ( + + {status.output.slice(-5).map((line, index) => { + const m = line.match(URL_RE) + if (!m) { + return ( + + {line} + + ) + } + const url = m[0] + const start = m.index ?? 0 + const before = line.slice(0, start) + const after = line.slice(start + url.length) + return ( + + {before} + {url} + {after} + + ) + })} + + )} + + {status.error && ( + + {status.error} + + )} + + ) } diff --git a/src/components/BaseTextInput.tsx b/src/components/BaseTextInput.tsx index c4f9e1f0d..07d12974b 100644 --- a/src/components/BaseTextInput.tsx +++ b/src/components/BaseTextInput.tsx @@ -1,135 +1,162 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { renderPlaceholder } from '../hooks/renderPlaceholder.js'; -import { usePasteHandler } from '../hooks/usePasteHandler.js'; -import { useDeclaredCursor } from '../ink/hooks/use-declared-cursor.js'; -import { Ansi, Box, Text, useInput } from '../ink.js'; -import type { BaseInputState, BaseTextInputProps } from '../types/textInputTypes.js'; -import type { TextHighlight } from '../utils/textHighlighting.js'; -import { HighlightedInput } from './PromptInput/ShimmeredInput.js'; +import React from 'react' +import { renderPlaceholder } from '../hooks/renderPlaceholder.js' +import { usePasteHandler } from '../hooks/usePasteHandler.js' +import { useDeclaredCursor } from '../ink/hooks/use-declared-cursor.js' +import { Ansi, Box, Text, useInput } from '../ink.js' +import type { + BaseInputState, + BaseTextInputProps, +} from '../types/textInputTypes.js' +import type { TextHighlight } from '../utils/textHighlighting.js' +import { HighlightedInput } from './PromptInput/ShimmeredInput.js' + type BaseTextInputComponentProps = BaseTextInputProps & { - inputState: BaseInputState; - children?: React.ReactNode; - terminalFocus: boolean; - highlights?: TextHighlight[]; - invert?: (text: string) => string; - hidePlaceholderText?: boolean; -}; + inputState: BaseInputState + children?: React.ReactNode + terminalFocus: boolean + highlights?: TextHighlight[] + invert?: (text: string) => string + hidePlaceholderText?: boolean +} /** * A base component for text inputs that handles rendering and basic input */ -export function BaseTextInput(t0) { - const $ = _c(14); - const { - inputState, - children, - terminalFocus, - invert, - hidePlaceholderText, - ...props - } = t0; - const { - onInput, - renderedValue, - cursorLine, - cursorColumn - } = inputState; - const t1 = Boolean(props.focus && props.showCursor && terminalFocus); - let t2; - if ($[0] !== cursorColumn || $[1] !== cursorLine || $[2] !== t1) { - t2 = { - line: cursorLine, - column: cursorColumn, - active: t1 - }; - $[0] = cursorColumn; - $[1] = cursorLine; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - const cursorRef = useDeclaredCursor(t2); - const { - wrappedOnInput, - isPasting: t3 - } = usePasteHandler({ +export function BaseTextInput({ + inputState, + children, + terminalFocus, + invert, + hidePlaceholderText, + ...props +}: BaseTextInputComponentProps): React.ReactNode { + const { onInput, renderedValue, cursorLine, cursorColumn } = inputState + + // Park the native terminal cursor at the input caret. Terminal emulators + // position IME preedit text at the physical cursor, and screen readers / + // screen magnifiers track it — so parking here makes CJK input appear + // inline and lets accessibility tools follow the input. The Box ref below + // is the yoga layout origin; (cursorLine, cursorColumn) is relative to it. + // Only active when the input is focused, showing its cursor, and the + // terminal itself has focus. + const cursorRef = useDeclaredCursor({ + line: cursorLine, + column: cursorColumn, + active: Boolean(props.focus && props.showCursor && terminalFocus), + }) + + const { wrappedOnInput, isPasting } = usePasteHandler({ onPaste: props.onPaste, onInput: (input, key) => { + // Prevent Enter key from triggering submission during paste if (isPasting && key.return) { - return; + return } - onInput(input, key); + onInput(input, key) }, - onImagePaste: props.onImagePaste - }); - const isPasting = t3; - const { - onIsPastingChange - } = props; + onImagePaste: props.onImagePaste, + }) + + // Notify parent when paste state changes + const { onIsPastingChange } = props React.useEffect(() => { if (onIsPastingChange) { - onIsPastingChange(isPasting); + onIsPastingChange(isPasting) } - }, [isPasting, onIsPastingChange]); - const { - showPlaceholder, - renderedPlaceholder - } = renderPlaceholder({ + }, [isPasting, onIsPastingChange]) + + const { showPlaceholder, renderedPlaceholder } = renderPlaceholder({ placeholder: props.placeholder, value: props.value, showCursor: props.showCursor, focus: props.focus, terminalFocus, invert, - hidePlaceholderText - }); - useInput(wrappedOnInput, { - isActive: props.focus - }); - const commandWithoutArgs = props.value && props.value.trim().indexOf(" ") === -1 || props.value && props.value.endsWith(" "); - const showArgumentHint = Boolean(props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith("/")); - const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || props.cursorOffset < h.start || props.cursorOffset >= h.end) : props.highlights; - const { - viewportCharOffset, - viewportCharEnd - } = inputState; - const filteredHighlights = cursorFiltered && viewportCharOffset > 0 ? cursorFiltered.filter(h_0 => h_0.end > viewportCharOffset && h_0.start < viewportCharEnd).map(h_1 => ({ - ...h_1, - start: Math.max(0, h_1.start - viewportCharOffset), - end: h_1.end - viewportCharOffset - })) : cursorFiltered; - const hasHighlights = filteredHighlights && filteredHighlights.length > 0; + hidePlaceholderText, + }) + + useInput(wrappedOnInput, { isActive: props.focus }) + + // Show argument hint only when we have a value and the hint is provided + // Only show the argument hint when: + // 1. We have a hint to show + // 2. We have a command typed (value is not empty) + // 3. The command doesn't have arguments yet (no text after the space) + // 4. We're actually typing a command (the value starts with /) + const commandWithoutArgs = + (props.value && props.value.trim().indexOf(' ') === -1) || + (props.value && props.value.endsWith(' ')) + + const showArgumentHint = Boolean( + props.argumentHint && + props.value && + commandWithoutArgs && + props.value.startsWith('/'), + ) + + // Filter out highlights that contain the cursor position + const cursorFiltered = + props.showCursor && props.highlights + ? props.highlights.filter( + h => + h.dimColor || + props.cursorOffset < h.start || + props.cursorOffset >= h.end, + ) + : props.highlights + + // Adjust highlights for viewport windowing: highlight positions reference the + // full input text, but renderedValue only contains the windowed subset. + const { viewportCharOffset, viewportCharEnd } = inputState + const filteredHighlights = + cursorFiltered && viewportCharOffset > 0 + ? cursorFiltered + .filter(h => h.end > viewportCharOffset && h.start < viewportCharEnd) + .map(h => ({ + ...h, + start: Math.max(0, h.start - viewportCharOffset), + end: h.end - viewportCharOffset, + })) + : cursorFiltered + + const hasHighlights = filteredHighlights && filteredHighlights.length > 0 + if (hasHighlights) { - return {showArgumentHint && {props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}}{children}; - } - const T0 = Box; - const T1 = Text; - const t4 = "truncate-end"; - const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? {renderedPlaceholder} : {renderedValue}; - const t6 = showArgumentHint && {props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}; - let t7; - if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) { - t7 = {t5}{t6}{children}; - $[4] = T1; - $[5] = children; - $[6] = props; - $[7] = t5; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; + return ( + + + {showArgumentHint && ( + + {props.value?.endsWith(' ') ? '' : ' '} + {props.argumentHint} + + )} + {children} + + ) } - let t8; - if ($[10] !== T0 || $[11] !== cursorRef || $[12] !== t7) { - t8 = {t7}; - $[10] = T0; - $[11] = cursorRef; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; + + return ( + + + {showPlaceholder && props.placeholderElement ? ( + props.placeholderElement + ) : showPlaceholder && renderedPlaceholder ? ( + {renderedPlaceholder} + ) : ( + {renderedValue} + )} + {showArgumentHint && ( + + {props.value?.endsWith(' ') ? '' : ' '} + {props.argumentHint} + + )} + {children} + + + ) } diff --git a/src/components/BashModeProgress.tsx b/src/components/BashModeProgress.tsx index fc254e371..0b6d4b408 100644 --- a/src/components/BashModeProgress.tsx +++ b/src/components/BashModeProgress.tsx @@ -1,55 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box } from '../ink.js'; -import { BashTool } from '../tools/BashTool/BashTool.js'; -import type { ShellProgress } from '../types/tools.js'; -import { UserBashInputMessage } from './messages/UserBashInputMessage.js'; -import { ShellProgressMessage } from './shell/ShellProgressMessage.js'; +import React from 'react' +import { Box } from '../ink.js' +import { BashTool } from '../tools/BashTool/BashTool.js' +import type { ShellProgress } from '../types/tools.js' +import { UserBashInputMessage } from './messages/UserBashInputMessage.js' +import { ShellProgressMessage } from './shell/ShellProgressMessage.js' + type Props = { - input: string; - progress: ShellProgress | null; - verbose: boolean; -}; -export function BashModeProgress(t0) { - const $ = _c(8); - const { - input, - progress, - verbose - } = t0; - const t1 = `${input}`; - let t2; - if ($[0] !== t1) { - t2 = ; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== progress || $[3] !== verbose) { - t3 = progress ? : BashTool.renderToolUseProgressMessage?.([], { - verbose, - tools: [], - terminalSize: undefined - }); - $[2] = progress; - $[3] = verbose; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; + input: string + progress: ShellProgress | null + verbose: boolean +} + +export function BashModeProgress({ + input, + progress, + verbose, +}: Props): React.ReactNode { + return ( + + ${input}`, type: 'text' }} + /> + {progress ? ( + + ) : ( + BashTool.renderToolUseProgressMessage?.([], { + verbose, + tools: [], + terminalSize: undefined, + }) + )} + + ) } diff --git a/src/components/BridgeDialog.tsx b/src/components/BridgeDialog.tsx index 48cadc202..9a23311fb 100644 --- a/src/components/BridgeDialog.tsx +++ b/src/components/BridgeDialog.tsx @@ -1,400 +1,160 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename } from 'path'; -import { toString as qrToString } from 'qrcode'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { getOriginalCwd } from '../bootstrap/state.js'; -import { buildActiveFooterText, buildIdleFooterText, FAILED_FOOTER_TEXT, getBridgeStatus } from '../bridge/bridgeStatusUtil.js'; -import { BRIDGE_FAILED_INDICATOR, BRIDGE_READY_INDICATOR } from '../constants/figures.js'; -import { useRegisterOverlay } from '../context/overlayContext.js'; +import { basename } from 'path' +import { toString as qrToString } from 'qrcode' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { getOriginalCwd } from '../bootstrap/state.js' +import { + buildActiveFooterText, + buildIdleFooterText, + FAILED_FOOTER_TEXT, + getBridgeStatus, +} from '../bridge/bridgeStatusUtil.js' +import { + BRIDGE_FAILED_INDICATOR, + BRIDGE_READY_INDICATOR, +} from '../constants/figures.js' +import { useRegisterOverlay } from '../context/overlayContext.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw 'd' key for disconnect, not a configurable keybinding action -import { Box, Text, useInput } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { getBranch } from '../utils/git.js'; -import { Dialog } from './design-system/Dialog.js'; +import { Box, Text, useInput } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { saveGlobalConfig } from '../utils/config.js' +import { getBranch } from '../utils/git.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onDone: () => void; -}; -export function BridgeDialog(t0) { - const $ = _c(87); - const { - onDone - } = t0; - useRegisterOverlay("bridge-dialog", undefined); - const connected = useAppState(_temp); - const sessionActive = useAppState(_temp2); - const reconnecting = useAppState(_temp3); - const connectUrl = useAppState(_temp4); - const sessionUrl = useAppState(_temp5); - const error = useAppState(_temp6); - const explicit = useAppState(_temp7); - const environmentId = useAppState(_temp8); - const sessionId = useAppState(_temp9); - const verbose = useAppState(_temp0); - const setAppState = useSetAppState(); - const [showQR, setShowQR] = useState(false); - const [qrText, setQrText] = useState(""); - const [branchName, setBranchName] = useState(""); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = basename(getOriginalCwd()); - $[0] = t1; - } else { - t1 = $[0]; - } - const repoName = t1; - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - getBranch().then(setBranchName).catch(_temp1); - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - const displayUrl = sessionActive ? sessionUrl : connectUrl; - let t4; - let t5; - if ($[3] !== displayUrl || $[4] !== showQR) { - t4 = () => { - if (!showQR || !displayUrl) { - setQrText(""); - return; - } - qrToString(displayUrl, { - type: "utf8", - errorCorrectionLevel: "L", - small: true - }).then(setQrText).catch(() => setQrText("")); - }; - t5 = [showQR, displayUrl]; - $[3] = displayUrl; - $[4] = showQR; - $[5] = t4; - $[6] = t5; - } else { - t4 = $[5]; - t5 = $[6]; - } - useEffect(t4, t5); - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => { - setShowQR(_temp10); - }; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== onDone) { - t7 = { - "confirm:yes": onDone, - "confirm:toggle": t6 - }; - $[8] = onDone; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - context: "Confirmation" - }; - $[10] = t8; - } else { - t8 = $[10]; - } - useKeybindings(t7, t8); - let t9; - if ($[11] !== explicit || $[12] !== onDone || $[13] !== setAppState) { - t9 = input => { - if (input === "d") { - if (explicit) { - saveGlobalConfig(_temp11); - } - setAppState(_temp12); - onDone(); - } - }; - $[11] = explicit; - $[12] = onDone; - $[13] = setAppState; - $[14] = t9; - } else { - t9 = $[14]; - } - useInput(t9); - let t10; - if ($[15] !== connected || $[16] !== error || $[17] !== reconnecting || $[18] !== sessionActive) { - t10 = getBridgeStatus({ - error, - connected, - sessionActive, - reconnecting - }); - $[15] = connected; - $[16] = error; - $[17] = reconnecting; - $[18] = sessionActive; - $[19] = t10; - } else { - t10 = $[19]; - } - const { - label: statusLabel, - color: statusColor - } = t10; - const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR; - let T0; - let T1; - let footerText; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - if ($[20] !== branchName || $[21] !== displayUrl || $[22] !== environmentId || $[23] !== error || $[24] !== indicator || $[25] !== onDone || $[26] !== qrText || $[27] !== sessionActive || $[28] !== sessionId || $[29] !== showQR || $[30] !== statusColor || $[31] !== statusLabel || $[32] !== verbose) { - const qrLines = qrText ? qrText.split("\n").filter(_temp13) : []; - let contextParts; - if ($[43] !== branchName) { - contextParts = []; - if (repoName) { - contextParts.push(repoName); - } - if (branchName) { - contextParts.push(branchName); - } - $[43] = branchName; - $[44] = contextParts; - } else { - contextParts = $[44]; - } - const contextSuffix = contextParts.length > 0 ? " \xB7 " + contextParts.join(" \xB7 ") : ""; - let t18; - if ($[45] !== displayUrl || $[46] !== error || $[47] !== sessionActive) { - t18 = error ? FAILED_FOOTER_TEXT : displayUrl ? sessionActive ? buildActiveFooterText(displayUrl) : buildIdleFooterText(displayUrl) : undefined; - $[45] = displayUrl; - $[46] = error; - $[47] = sessionActive; - $[48] = t18; - } else { - t18 = $[48]; - } - footerText = t18; - T1 = Dialog; - t15 = "Remote Control"; - t16 = onDone; - t17 = true; - T0 = Box; - t11 = "column"; - t12 = 1; - let t19; - if ($[49] !== indicator || $[50] !== statusColor || $[51] !== statusLabel) { - t19 = {indicator} {statusLabel}; - $[49] = indicator; - $[50] = statusColor; - $[51] = statusLabel; - $[52] = t19; - } else { - t19 = $[52]; - } - let t20; - if ($[53] !== contextSuffix) { - t20 = {contextSuffix}; - $[53] = contextSuffix; - $[54] = t20; - } else { - t20 = $[54]; + onDone: () => void +} + +export function BridgeDialog({ onDone }: Props): React.ReactNode { + useRegisterOverlay('bridge-dialog') + + const connected = useAppState(s => s.replBridgeConnected) + const sessionActive = useAppState(s => s.replBridgeSessionActive) + const reconnecting = useAppState(s => s.replBridgeReconnecting) + const connectUrl = useAppState(s => s.replBridgeConnectUrl) + const sessionUrl = useAppState(s => s.replBridgeSessionUrl) + const error = useAppState(s => s.replBridgeError) + const explicit = useAppState(s => s.replBridgeExplicit) + const environmentId = useAppState(s => s.replBridgeEnvironmentId) + const sessionId = useAppState(s => s.replBridgeSessionId) + const verbose = useAppState(s => s.verbose) + const setAppState = useSetAppState() + + const [showQR, setShowQR] = useState(false) + const [qrText, setQrText] = useState('') + const [branchName, setBranchName] = useState('') + + const repoName = basename(getOriginalCwd()) + + // Fetch branch name on mount + useEffect(() => { + getBranch() + .then(setBranchName) + .catch(() => {}) + }, []) + + // The URL to display/QR: session URL when connected, connect URL when ready + const displayUrl = sessionActive ? sessionUrl : connectUrl + + // Generate QR code when URL changes or QR is toggled on + useEffect(() => { + if (!showQR || !displayUrl) { + setQrText('') + return } - let t21; - if ($[55] !== t19 || $[56] !== t20) { - t21 = {t19}{t20}; - $[55] = t19; - $[56] = t20; - $[57] = t21; - } else { - t21 = $[57]; - } - let t22; - if ($[58] !== error) { - t22 = error && {error}; - $[58] = error; - $[59] = t22; - } else { - t22 = $[59]; - } - let t23; - if ($[60] !== environmentId || $[61] !== verbose) { - t23 = verbose && environmentId && Environment: {environmentId}; - $[60] = environmentId; - $[61] = verbose; - $[62] = t23; - } else { - t23 = $[62]; - } - let t24; - if ($[63] !== sessionId || $[64] !== verbose) { - t24 = verbose && sessionId && Session: {sessionId}; - $[63] = sessionId; - $[64] = verbose; - $[65] = t24; - } else { - t24 = $[65]; - } - if ($[66] !== t21 || $[67] !== t22 || $[68] !== t23 || $[69] !== t24) { - t13 = {t21}{t22}{t23}{t24}; - $[66] = t21; - $[67] = t22; - $[68] = t23; - $[69] = t24; - $[70] = t13; - } else { - t13 = $[70]; + qrToString(displayUrl, { + type: 'utf8', + errorCorrectionLevel: 'L', + small: true, + }) + .then(setQrText) + .catch(() => setQrText('')) + }, [showQR, displayUrl]) + + useKeybindings( + { + 'confirm:yes': onDone, + 'confirm:toggle': () => { + setShowQR(prev => !prev) + }, + }, + { context: 'Confirmation' }, + ) + + useInput(input => { + if (input === 'd') { + // Persist opt-out only for CLI-flag/command-activated bridge. + // Config-driven and GB-auto-connect users get session-only disconnect + // — writing false would silently undo a Settings choice or opt a + // GB-rollout user out permanently. + if (explicit) { + saveGlobalConfig(current => { + if (current.remoteControlAtStartup === false) return current + return { ...current, remoteControlAtStartup: false } + }) + } + setAppState(prev => { + if (!prev.replBridgeEnabled) return prev + return { ...prev, replBridgeEnabled: false } + }) + onDone() } - t14 = showQR && qrLines.length > 0 && {qrLines.map(_temp14)}; - $[20] = branchName; - $[21] = displayUrl; - $[22] = environmentId; - $[23] = error; - $[24] = indicator; - $[25] = onDone; - $[26] = qrText; - $[27] = sessionActive; - $[28] = sessionId; - $[29] = showQR; - $[30] = statusColor; - $[31] = statusLabel; - $[32] = verbose; - $[33] = T0; - $[34] = T1; - $[35] = footerText; - $[36] = t11; - $[37] = t12; - $[38] = t13; - $[39] = t14; - $[40] = t15; - $[41] = t16; - $[42] = t17; - } else { - T0 = $[33]; - T1 = $[34]; - footerText = $[35]; - t11 = $[36]; - t12 = $[37]; - t13 = $[38]; - t14 = $[39]; - t15 = $[40]; - t16 = $[41]; - t17 = $[42]; - } - let t18; - if ($[71] !== footerText) { - t18 = footerText && {footerText}; - $[71] = footerText; - $[72] = t18; - } else { - t18 = $[72]; - } - let t19; - if ($[73] === Symbol.for("react.memo_cache_sentinel")) { - t19 = d to disconnect · space for QR code · Enter/Esc to close; - $[73] = t19; - } else { - t19 = $[73]; - } - let t20; - if ($[74] !== T0 || $[75] !== t11 || $[76] !== t12 || $[77] !== t13 || $[78] !== t14 || $[79] !== t18) { - t20 = {t13}{t14}{t18}{t19}; - $[74] = T0; - $[75] = t11; - $[76] = t12; - $[77] = t13; - $[78] = t14; - $[79] = t18; - $[80] = t20; - } else { - t20 = $[80]; - } - let t21; - if ($[81] !== T1 || $[82] !== t15 || $[83] !== t16 || $[84] !== t17 || $[85] !== t20) { - t21 = {t20}; - $[81] = T1; - $[82] = t15; - $[83] = t16; - $[84] = t17; - $[85] = t20; - $[86] = t21; - } else { - t21 = $[86]; - } - return t21; -} -function _temp14(line, i) { - return {line}; -} -function _temp13(l) { - return l.length > 0; -} -function _temp12(prev_0) { - if (!prev_0.replBridgeEnabled) { - return prev_0; - } - return { - ...prev_0, - replBridgeEnabled: false - }; -} -function _temp11(current) { - if (current.remoteControlAtStartup === false) { - return current; - } - return { - ...current, - remoteControlAtStartup: false - }; -} -function _temp10(prev) { - return !prev; -} -function _temp1() {} -function _temp0(s_8) { - return s_8.verbose; -} -function _temp9(s_7) { - return s_7.replBridgeSessionId; -} -function _temp8(s_6) { - return s_6.replBridgeEnvironmentId; -} -function _temp7(s_5) { - return s_5.replBridgeExplicit; -} -function _temp6(s_4) { - return s_4.replBridgeError; -} -function _temp5(s_3) { - return s_3.replBridgeSessionUrl; -} -function _temp4(s_2) { - return s_2.replBridgeConnectUrl; -} -function _temp3(s_1) { - return s_1.replBridgeReconnecting; -} -function _temp2(s_0) { - return s_0.replBridgeSessionActive; -} -function _temp(s) { - return s.replBridgeConnected; + }) + + const { label: statusLabel, color: statusColor } = getBridgeStatus({ + error, + connected, + sessionActive, + reconnecting, + }) + const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR + const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [] + + // Build suffix with repo and branch (matches standalone bridge format) + const contextParts: string[] = [] + if (repoName) contextParts.push(repoName) + if (branchName) contextParts.push(branchName) + const contextSuffix = + contextParts.length > 0 ? ' \u00b7 ' + contextParts.join(' \u00b7 ') : '' + + // Footer text matches standalone bridge + const footerText = error + ? FAILED_FOOTER_TEXT + : displayUrl + ? sessionActive + ? buildActiveFooterText(displayUrl) + : buildIdleFooterText(displayUrl) + : undefined + + return ( + + + + + + {indicator} {statusLabel} + + {contextSuffix} + + {error && {error}} + {verbose && environmentId && ( + Environment: {environmentId} + )} + {verbose && sessionId && Session: {sessionId}} + + {showQR && qrLines.length > 0 && ( + + {qrLines.map((line, i) => ( + {line} + ))} + + )} + {footerText && {footerText}} + + d to disconnect · space for QR code · Enter/Esc to close + + + + ) } diff --git a/src/components/BypassPermissionsModeDialog.tsx b/src/components/BypassPermissionsModeDialog.tsx index b3f8b199e..adc708c77 100644 --- a/src/components/BypassPermissionsModeDialog.tsx +++ b/src/components/BypassPermissionsModeDialog.tsx @@ -1,86 +1,73 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Link, Newline, Text } from '../ink.js'; -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Link, Newline, Text } from '../ink.js' +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onAccept(): void; -}; -export function BypassPermissionsModeDialog(t0) { - const $ = _c(7); - const { - onAccept - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp, t1); - let t2; - if ($[1] !== onAccept) { - t2 = function onChange(value) { - bb3: switch (value) { - case "accept": - { - logEvent("tengu_bypass_permissions_mode_dialog_accept", {}); - updateSettingsForSource("userSettings", { - skipDangerousModePermissionPrompt: true - }); - onAccept(); - break bb3; - } - case "decline": - { - gracefulShutdownSync(1); - } + onAccept(): void +} + +export function BypassPermissionsModeDialog({ + onAccept, +}: Props): React.ReactNode { + React.useEffect(() => { + logEvent('tengu_bypass_permissions_mode_dialog_shown', {}) + }, []) + + function onChange(value: 'accept' | 'decline') { + switch (value) { + case 'accept': { + logEvent('tengu_bypass_permissions_mode_dialog_accept', {}) + + updateSettingsForSource('userSettings', { + skipDangerousModePermissionPrompt: true, + }) + onAccept() + break } - }; - $[1] = onAccept; - $[2] = t2; - } else { - t2 = $[2]; - } - const onChange = t2; - const handleEscape = _temp2; - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = In Bypass Permissions mode, Claude Code will not ask for your approval before running potentially dangerous commands.This mode should only be used in a sandboxed container/VM that has restricted internet access and can easily be restored if damaged.By proceeding, you accept all responsibility for actions taken while running in Bypass Permissions mode.; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "No, exit", - value: "decline" - }, { - label: "Yes, I accept", - value: "accept" - }]; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== onChange) { - t5 = {t3} onChange(value as 'accept' | 'decline')} + /> + + ) } diff --git a/src/components/ChannelDowngradeDialog.tsx b/src/components/ChannelDowngradeDialog.tsx index d5e2129d9..54db87690 100644 --- a/src/components/ChannelDowngradeDialog.tsx +++ b/src/components/ChannelDowngradeDialog.tsx @@ -1,101 +1,57 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../ink.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel'; +import React from 'react' +import { Text } from '../ink.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + +export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel' + type Props = { - currentVersion: string; - onChoice: (choice: ChannelDowngradeChoice) => void; -}; + currentVersion: string + onChoice: (choice: ChannelDowngradeChoice) => void +} /** * Dialog shown when switching from latest to stable channel. * Allows user to choose whether to downgrade or stay on current version. */ -export function ChannelDowngradeDialog(t0) { - const $ = _c(17); - const { - currentVersion, - onChoice - } = t0; - let t1; - if ($[0] !== onChoice) { - t1 = function handleSelect(value) { - onChoice(value); - }; - $[0] = onChoice; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSelect = t1; - let t2; - if ($[2] !== onChoice) { - t2 = function handleCancel() { - onChoice("cancel"); - }; - $[2] = onChoice; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleCancel = t2; - let t3; - if ($[4] !== currentVersion) { - t3 = The stable channel may have an older version than what you're currently running ({currentVersion}).; - $[4] = currentVersion; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = How would you like to handle this?; - $[6] = t4; - } else { - t4 = $[6]; +export function ChannelDowngradeDialog({ + currentVersion, + onChoice, +}: Props): React.ReactNode { + function handleSelect(value: ChannelDowngradeChoice): void { + onChoice(value) } - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Allow possible downgrade to stable version", - value: "downgrade" as ChannelDowngradeChoice - }; - $[7] = t5; - } else { - t5 = $[7]; - } - const t6 = `Stay on current version (${currentVersion}) until stable catches up`; - let t7; - if ($[8] !== t6) { - t7 = [t5, { - label: t6, - value: "stay" as ChannelDowngradeChoice - }]; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] !== handleSelect || $[11] !== t7) { - t8 = + + ) } diff --git a/src/components/ClaudeCodeHint/PluginHintMenu.tsx b/src/components/ClaudeCodeHint/PluginHintMenu.tsx index 955a0dc66..8afd7cda8 100644 --- a/src/components/ClaudeCodeHint/PluginHintMenu.tsx +++ b/src/components/ClaudeCodeHint/PluginHintMenu.tsx @@ -1,53 +1,71 @@ -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Select } from '../CustomSelect/select.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { Select } from '../CustomSelect/select.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' + type Props = { - pluginName: string; - pluginDescription?: string; - marketplaceName: string; - sourceCommand: string; - onResponse: (response: 'yes' | 'no' | 'disable') => void; -}; -const AUTO_DISMISS_MS = 30_000; + pluginName: string + pluginDescription?: string + marketplaceName: string + sourceCommand: string + onResponse: (response: 'yes' | 'no' | 'disable') => void +} + +const AUTO_DISMISS_MS = 30_000 + export function PluginHintMenu({ pluginName, pluginDescription, marketplaceName, sourceCommand, - onResponse + onResponse, }: Props): React.ReactNode { - const onResponseRef = React.useRef(onResponse); - onResponseRef.current = onResponse; + const onResponseRef = React.useRef(onResponse) + onResponseRef.current = onResponse + React.useEffect(() => { - const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef); - return () => clearTimeout(timeoutId); - }, []); + const timeoutId = setTimeout( + ref => ref.current('no'), + AUTO_DISMISS_MS, + onResponseRef, + ) + return () => clearTimeout(timeoutId) + }, []) + function onSelect(value: string): void { switch (value) { case 'yes': - onResponse('yes'); - break; + onResponse('yes') + break case 'disable': - onResponse('disable'); - break; + onResponse('disable') + break default: - onResponse('no'); + onResponse('no') } } - const options = [{ - label: + + const options = [ + { + label: ( + Yes, install {pluginName} - , - value: 'yes' - }, { - label: 'No', - value: 'no' - }, { - label: "No, and don't show plugin installation hints again", - value: 'disable' - }]; - return + + ), + value: 'yes', + }, + { + label: 'No', + value: 'no', + }, + { + label: "No, and don't show plugin installation hints again", + value: 'disable', + }, + ] + + return ( + @@ -63,15 +81,22 @@ export function PluginHintMenu({ Marketplace: {marketplaceName} - {pluginDescription && + {pluginDescription && ( + {pluginDescription} - } + + )} Would you like to install it? - onResponse('no')} + /> - ; + + ) } diff --git a/src/components/ClaudeInChromeOnboarding.tsx b/src/components/ClaudeInChromeOnboarding.tsx index a7f0bf99e..7c420ad43 100644 --- a/src/components/ClaudeInChromeOnboarding.tsx +++ b/src/components/ClaudeInChromeOnboarding.tsx @@ -1,120 +1,78 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; +import React from 'react' +import { logEvent } from 'src/services/analytics/index.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to continue -import { Box, Link, Newline, Text, useInput } from '../ink.js'; -import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { Dialog } from './design-system/Dialog.js'; -const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'; -const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'; +import { Box, Link, Newline, Text, useInput } from '../ink.js' +import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js' +import { saveGlobalConfig } from '../utils/config.js' +import { Dialog } from './design-system/Dialog.js' + +const CHROME_EXTENSION_URL = 'https://claude.ai/chrome' +const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions' + type Props = { - onDone(): void; -}; -export function ClaudeInChromeOnboarding(t0) { - const $ = _c(20); - const { - onDone - } = t0; - const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false); - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - logEvent("tengu_claude_in_chrome_onboarding_shown", {}); - isChromeExtensionInstalled().then(setIsExtensionInstalled); - saveGlobalConfig(_temp); - }; - t2 = []; - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - React.useEffect(t1, t2); - let t3; - if ($[2] !== onDone) { - t3 = (_input, key) => { - if (key.return) { - onDone(); - } - }; - $[2] = onDone; - $[3] = t3; - } else { - t3 = $[3]; - } - useInput(t3); - let t4; - if ($[4] !== isExtensionInstalled) { - t4 = !isExtensionInstalled && <>Requires the Chrome extension. Get started at{" "}; - $[4] = isExtensionInstalled; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t4) { - t5 = Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.{t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== isExtensionInstalled) { - t6 = isExtensionInstalled && <>{" "}(); - $[8] = isExtensionInstalled; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== t6) { - t7 = Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension settings to control which sites Claude can browse, click, and type on{t6}.; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t8 = /chrome; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t9 = For more info, use{" "}{t8}{" "}or visit ; - $[13] = t9; - } else { - t9 = $[13]; - } - let t10; - if ($[14] !== t5 || $[15] !== t7) { - t10 = {t5}{t7}{t9}; - $[14] = t5; - $[15] = t7; - $[16] = t10; - } else { - t10 = $[16]; - } - let t11; - if ($[17] !== onDone || $[18] !== t10) { - t11 = {t10}; - $[17] = onDone; - $[18] = t10; - $[19] = t11; - } else { - t11 = $[19]; - } - return t11; + onDone(): void } -function _temp(current) { - return { - ...current, - hasCompletedClaudeInChromeOnboarding: true - }; + +export function ClaudeInChromeOnboarding({ onDone }: Props): React.ReactNode { + const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false) + + React.useEffect(() => { + logEvent('tengu_claude_in_chrome_onboarding_shown', {}) + void isChromeExtensionInstalled().then(setIsExtensionInstalled) + saveGlobalConfig(current => { + return { ...current, hasCompletedClaudeInChromeOnboarding: true } + }) + }, []) + + // Handle Enter to continue + useInput((_input, key) => { + if (key.return) { + onDone() + } + }) + + return ( + + + + Claude in Chrome works with the Chrome extension to let you control + your browser directly from Claude Code. You can navigate websites, + fill forms, capture screenshots, record GIFs, and debug with console + logs and network requests. + {!isExtensionInstalled && ( + <> + + + Requires the Chrome extension. Get started at{' '} + + + )} + + + + Site-level permissions are inherited from the Chrome extension. Manage + permissions in the Chrome extension settings to control which sites + Claude can browse, click, and type on + {isExtensionInstalled && ( + <> + {' '} + () + + )} + . + + + For more info, use{' '} + + /chrome + {' '} + or visit + + + + ) } diff --git a/src/components/ClaudeMdExternalIncludesDialog.tsx b/src/components/ClaudeMdExternalIncludesDialog.tsx index e7b0b93d0..1ca6fcd12 100644 --- a/src/components/ClaudeMdExternalIncludesDialog.tsx +++ b/src/components/ClaudeMdExternalIncludesDialog.tsx @@ -1,136 +1,93 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Link, Text } from '../ink.js'; -import type { ExternalClaudeMdInclude } from '../utils/claudemd.js'; -import { saveCurrentProjectConfig } from '../utils/config.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Link, Text } from '../ink.js' +import type { ExternalClaudeMdInclude } from '../utils/claudemd.js' +import { saveCurrentProjectConfig } from '../utils/config.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onDone(): void; - isStandaloneDialog?: boolean; - externalIncludes?: ExternalClaudeMdInclude[]; -}; -export function ClaudeMdExternalIncludesDialog(t0) { - const $ = _c(18); - const { - onDone, - isStandaloneDialog, - externalIncludes - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp, t1); - let t2; - if ($[1] !== onDone) { - t2 = value => { - if (value === "no") { - logEvent("tengu_claude_md_external_includes_dialog_declined", {}); - saveCurrentProjectConfig(_temp2); + onDone(): void + isStandaloneDialog?: boolean + externalIncludes?: ExternalClaudeMdInclude[] +} + +export function ClaudeMdExternalIncludesDialog({ + onDone, + isStandaloneDialog, + externalIncludes, +}: Props): React.ReactNode { + React.useEffect(() => { + // Log when dialog is shown + logEvent('tengu_claude_md_includes_dialog_shown', {}) + }, []) + + const handleSelection = useCallback( + (value: 'yes' | 'no') => { + if (value === 'no') { + logEvent('tengu_claude_md_external_includes_dialog_declined', {}) + // Mark that we've shown the dialog but it was declined + saveCurrentProjectConfig(current => ({ + ...current, + hasClaudeMdExternalIncludesApproved: false, + hasClaudeMdExternalIncludesWarningShown: true, + })) } else { - logEvent("tengu_claude_md_external_includes_dialog_accepted", {}); - saveCurrentProjectConfig(_temp3); + logEvent('tengu_claude_md_external_includes_dialog_accepted', {}) + saveCurrentProjectConfig(current => ({ + ...current, + hasClaudeMdExternalIncludesApproved: true, + hasClaudeMdExternalIncludesWarningShown: true, + })) } - onDone(); - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - const handleSelection = t2; - let t3; - if ($[3] !== handleSelection) { - t3 = () => { - handleSelection("no"); - }; - $[3] = handleSelection; - $[4] = t3; - } else { - t3 = $[4]; - } - const handleEscape = t3; - const t4 = !isStandaloneDialog; - const t5 = !isStandaloneDialog; - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = This project's CLAUDE.md imports files outside the current working directory. Never allow this for third-party repositories.; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== externalIncludes) { - t7 = externalIncludes && externalIncludes.length > 0 && External imports:{externalIncludes.map(_temp4)}; - $[6] = externalIncludes; - $[7] = t7; - } else { - t7 = $[7]; - } - let t8; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Important: Only use Claude Code with files you trust. Accessing untrusted files may pose security risks{" "}{" "}; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [{ - label: "Yes, allow external imports", - value: "yes" - }, { - label: "No, disable external imports", - value: "no" - }]; - $[9] = t9; - } else { - t9 = $[9]; - } - let t10; - if ($[10] !== handleSelection) { - t10 = handleSelection(value as 'yes' | 'no')} + /> + + ) } diff --git a/src/components/ClickableImageRef.tsx b/src/components/ClickableImageRef.tsx index 6b61fea8a..51144a720 100644 --- a/src/components/ClickableImageRef.tsx +++ b/src/components/ClickableImageRef.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { pathToFileURL } from 'url'; -import Link from '../ink/components/Link.js'; -import { supportsHyperlinks } from '../ink/supports-hyperlinks.js'; -import { Text } from '../ink.js'; -import { getStoredImagePath } from '../utils/imageStore.js'; -import type { Theme } from '../utils/theme.js'; +import * as React from 'react' +import { pathToFileURL } from 'url' +import Link from '../ink/components/Link.js' +import { supportsHyperlinks } from '../ink/supports-hyperlinks.js' +import { Text } from '../ink.js' +import { getStoredImagePath } from '../utils/imageStore.js' +import type { Theme } from '../utils/theme.js' + type Props = { - imageId: number; - backgroundColor?: keyof Theme; - isSelected?: boolean; -}; + imageId: number + backgroundColor?: keyof Theme + isSelected?: boolean +} /** * Renders an image reference like [Image #1] as a clickable link. @@ -20,53 +20,42 @@ type Props = { * - Terminal doesn't support hyperlinks * - Image file is not found in the store */ -export function ClickableImageRef(t0) { - const $ = _c(13); - const { - imageId, - backgroundColor, - isSelected: t1 - } = t0; - const isSelected = t1 === undefined ? false : t1; - const imagePath = getStoredImagePath(imageId); - const displayText = `[Image #${imageId}]`; +export function ClickableImageRef({ + imageId, + backgroundColor, + isSelected = false, +}: Props): React.ReactNode { + const imagePath = getStoredImagePath(imageId) + const displayText = `[Image #${imageId}]` + + // If we have a stored image and terminal supports hyperlinks, make it clickable if (imagePath && supportsHyperlinks()) { - const fileUrl = pathToFileURL(imagePath).href; - let t2; - let t3; - if ($[0] !== backgroundColor || $[1] !== displayText || $[2] !== isSelected) { - t2 = {displayText}; - t3 = {displayText}; - $[0] = backgroundColor; - $[1] = displayText; - $[2] = isSelected; - $[3] = t2; - $[4] = t3; - } else { - t2 = $[3]; - t3 = $[4]; - } - let t4; - if ($[5] !== fileUrl || $[6] !== t2 || $[7] !== t3) { - t4 = {t3}; - $[5] = fileUrl; - $[6] = t2; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; - } - let t2; - if ($[9] !== backgroundColor || $[10] !== displayText || $[11] !== isSelected) { - t2 = {displayText}; - $[9] = backgroundColor; - $[10] = displayText; - $[11] = isSelected; - $[12] = t2; - } else { - t2 = $[12]; + const fileUrl = pathToFileURL(imagePath).href + + return ( + + {displayText} + + } + > + + {displayText} + + + ) } - return t2; + + // Fallback: styled but not clickable + return ( + + {displayText} + + ) } diff --git a/src/components/CompactSummary.tsx b/src/components/CompactSummary.tsx index 082d08d1d..1cd1687fa 100644 --- a/src/components/CompactSummary.tsx +++ b/src/components/CompactSummary.tsx @@ -1,117 +1,101 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { BLACK_CIRCLE } from '../constants/figures.js'; -import { Box, Text } from '../ink.js'; -import type { Screen } from '../screens/REPL.js'; -import type { NormalizedUserMessage } from '../types/message.js'; -import { getUserMessageText } from '../utils/messages.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { MessageResponse } from './MessageResponse.js'; +import * as React from 'react' +import { BLACK_CIRCLE } from '../constants/figures.js' +import { Box, Text } from '../ink.js' +import type { Screen } from '../screens/REPL.js' +import type { NormalizedUserMessage } from '../types/message.js' +import { getUserMessageText } from '../utils/messages.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { MessageResponse } from './MessageResponse.js' + type Props = { - message: NormalizedUserMessage; - screen: Screen; -}; -export function CompactSummary(t0) { - const $ = _c(24); - const { - message, - screen - } = t0; - const isTranscriptMode = screen === "transcript"; - let t1; - if ($[0] !== message) { - t1 = getUserMessageText(message) || ""; - $[0] = message; - $[1] = t1; - } else { - t1 = $[1]; - } - const textContent = t1; - const metadata = message.summarizeMetadata; + message: NormalizedUserMessage + screen: Screen +} + +export function CompactSummary({ message, screen }: Props): React.ReactNode { + const isTranscriptMode = screen === 'transcript' + const textContent = getUserMessageText(message) || '' + const metadata = message.summarizeMetadata + + // "Summarize from here" with metadata if (metadata) { - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {BLACK_CIRCLE}; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Summarized conversation; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== isTranscriptMode || $[5] !== metadata) { - t4 = !isTranscriptMode && Summarized {metadata.messagesSummarized} messages{" "}{metadata.direction === "up_to" ? "up to this point" : "from this point"}{metadata.userContext && Context: {"\u201C"}{metadata.userContext}{"\u201D"}}; - $[4] = isTranscriptMode; - $[5] = metadata; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== isTranscriptMode || $[8] !== textContent) { - t5 = isTranscriptMode && {textContent}; - $[7] = isTranscriptMode; - $[8] = textContent; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t4 || $[11] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; - } - let t2; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {BLACK_CIRCLE}; - $[13] = t2; - } else { - t2 = $[13]; - } - let t3; - if ($[14] !== isTranscriptMode) { - t3 = !isTranscriptMode && {" "}; - $[14] = isTranscriptMode; - $[15] = t3; - } else { - t3 = $[15]; - } - let t4; - if ($[16] !== t3) { - t4 = {t2}Compact summary{t3}; - $[16] = t3; - $[17] = t4; - } else { - t4 = $[17]; - } - let t5; - if ($[18] !== isTranscriptMode || $[19] !== textContent) { - t5 = isTranscriptMode && {textContent}; - $[18] = isTranscriptMode; - $[19] = textContent; - $[20] = t5; - } else { - t5 = $[20]; - } - let t6; - if ($[21] !== t4 || $[22] !== t5) { - t6 = {t4}{t5}; - $[21] = t4; - $[22] = t5; - $[23] = t6; - } else { - t6 = $[23]; + return ( + + + + {BLACK_CIRCLE} + + + Summarized conversation + {!isTranscriptMode && ( + + + + Summarized {metadata.messagesSummarized} messages{' '} + {metadata.direction === 'up_to' + ? 'up to this point' + : 'from this point'} + + {metadata.userContext && ( + + Context: {'\u201c'} + {metadata.userContext} + {'\u201d'} + + )} + + + + + + )} + {isTranscriptMode && ( + + {textContent} + + )} + + + + ) } - return t6; + + // Default compact summary (auto-compact) + return ( + + + + {BLACK_CIRCLE} + + + + Compact summary + {!isTranscriptMode && ( + + {' '} + + + )} + + + + {isTranscriptMode && ( + + {textContent} + + )} + + ) } diff --git a/src/components/ConfigurableShortcutHint.tsx b/src/components/ConfigurableShortcutHint.tsx index e6187349b..82aea15fa 100644 --- a/src/components/ConfigurableShortcutHint.tsx +++ b/src/components/ConfigurableShortcutHint.tsx @@ -1,22 +1,25 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import type { KeybindingAction, KeybindingContextName } from '../keybindings/types.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import * as React from 'react' +import type { + KeybindingAction, + KeybindingContextName, +} from '../keybindings/types.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' + type Props = { /** The keybinding action (e.g., 'app:toggleTranscript') */ - action: KeybindingAction; + action: KeybindingAction /** The keybinding context (e.g., 'Global') */ - context: KeybindingContextName; + context: KeybindingContextName /** Default shortcut if keybinding not configured */ - fallback: string; + fallback: string /** The action description text (e.g., 'expand') */ - description: string; + description: string /** Whether to wrap in parentheses */ - parens?: boolean; + parens?: boolean /** Whether to show in bold */ - bold?: boolean; -}; + bold?: boolean +} /** * KeyboardShortcutHint that displays the user-configured shortcut. @@ -30,27 +33,21 @@ type Props = { * description="expand" * /> */ -export function ConfigurableShortcutHint(t0) { - const $ = _c(5); - const { - action, - context, - fallback, - description, - parens, - bold - } = t0; - const shortcut = useShortcutDisplay(action, context, fallback); - let t1; - if ($[0] !== bold || $[1] !== description || $[2] !== parens || $[3] !== shortcut) { - t1 = ; - $[0] = bold; - $[1] = description; - $[2] = parens; - $[3] = shortcut; - $[4] = t1; - } else { - t1 = $[4]; - } - return t1; +export function ConfigurableShortcutHint({ + action, + context, + fallback, + description, + parens, + bold, +}: Props): React.ReactNode { + const shortcut = useShortcutDisplay(action, context, fallback) + return ( + + ) } diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 03b6e0e7a..bfaf6af20 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -1,333 +1,370 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { installOAuthTokens } from '../cli/handlers/auth.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import { useTerminalNotification } from '../ink/useTerminalNotification.js'; -import { Box, Link, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { getSSLErrorHint } from '../services/api/errorUtils.js'; -import { sendNotification } from '../services/notifier.js'; -import { OAuthService } from '../services/oauth/index.js'; -import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; -import { logError } from '../utils/log.js'; -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/select.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Spinner } from './Spinner.js'; -import TextInput from './TextInput.js'; +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { installOAuthTokens } from '../cli/handlers/auth.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { setClipboard } from '../ink/termio/osc.js' +import { useTerminalNotification } from '../ink/useTerminalNotification.js' +import { Box, Link, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { getSSLErrorHint } from '../services/api/errorUtils.js' +import { sendNotification } from '../services/notifier.js' +import { OAuthService } from '../services/oauth/index.js' +import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js' +import { logError } from '../utils/log.js' +import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js' +import { Select } from './CustomSelect/select.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Spinner } from './Spinner.js' +import TextInput from './TextInput.js' + type Props = { - onDone(): void; - startingMessage?: string; - mode?: 'login' | 'setup-token'; - forceLoginMethod?: 'claudeai' | 'console'; -}; -type OAuthStatus = { - state: 'idle'; -} // Initial state, waiting to select login method -| { - state: 'platform_setup'; -} // Show platform setup info (Bedrock/Vertex/Foundry) -| { - state: 'custom_platform'; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; -} // Custom platform: configure API endpoint and model names -| { - state: 'openai_chat_api'; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; -} // OpenAI Chat Completions API platform -| { - state: 'ready_to_start'; -} // Flow started, waiting for browser to open -| { - state: 'waiting_for_login'; - url: string; -} // Browser opened, waiting for user to login -| { - state: 'creating_api_key'; -} // Got access token, creating API key -| { - state: 'about_to_retry'; - nextState: OAuthStatus; -} | { - state: 'success'; - token?: string; -} | { - state: 'error'; - message: string; - toRetry?: OAuthStatus; -}; -const PASTE_HERE_MSG = 'Paste code here if prompted > '; + onDone(): void + startingMessage?: string + mode?: 'login' | 'setup-token' + forceLoginMethod?: 'claudeai' | 'console' +} + +type OAuthStatus = + | { state: 'idle' } // Initial state, waiting to select login method + | { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry) + | { + state: 'custom_platform' + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + } // Custom platform: configure API endpoint and model names + | { + state: 'openai_chat_api' + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + } // OpenAI Chat Completions API platform + | { state: 'ready_to_start' } // Flow started, waiting for browser to open + | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login + | { state: 'creating_api_key' } // Got access token, creating API key + | { state: 'about_to_retry'; nextState: OAuthStatus } + | { state: 'success'; token?: string } + | { + state: 'error' + message: string + toRetry?: OAuthStatus + } + +const PASTE_HERE_MSG = 'Paste code here if prompted > ' + export function ConsoleOAuthFlow({ onDone, startingMessage, mode = 'login', - forceLoginMethod: forceLoginMethodProp + forceLoginMethod: forceLoginMethodProp, }: Props): React.ReactNode { - const settings = getSettings_DEPRECATED() || {}; - const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod; - const orgUUID = settings.forceLoginOrgUUID; - const forcedMethodMessage = forceLoginMethod === 'claudeai' ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' : forceLoginMethod === 'console' ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' : null; - const terminal = useTerminalNotification(); + const settings = getSettings_DEPRECATED() || {} + const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod + const orgUUID = settings.forceLoginOrgUUID + const forcedMethodMessage = + forceLoginMethod === 'claudeai' + ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' + : forceLoginMethod === 'console' + ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' + : null + + const terminal = useTerminalNotification() + const [oauthStatus, setOAuthStatus] = useState(() => { if (mode === 'setup-token') { - return { - state: 'ready_to_start' - }; + return { state: 'ready_to_start' } } if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { - return { - state: 'ready_to_start' - }; + return { state: 'ready_to_start' } } - return { - state: 'idle' - }; - }); - const [pastedCode, setPastedCode] = useState(''); - const [cursorOffset, setCursorOffset] = useState(0); - const [oauthService] = useState(() => new OAuthService()); + return { state: 'idle' } + }) + + const [pastedCode, setPastedCode] = useState('') + const [cursorOffset, setCursorOffset] = useState(0) + const [oauthService] = useState(() => new OAuthService()) const [loginWithClaudeAi, setLoginWithClaudeAi] = useState(() => { // Use Claude AI auth for setup-token mode to support user:inference scope - return mode === 'setup-token' || forceLoginMethod === 'claudeai'; - }); + return mode === 'setup-token' || forceLoginMethod === 'claudeai' + }) // After a few seconds we suggest the user to copy/paste url if the // browser did not open automatically. In this flow we expect the user to // copy the code from the browser and paste it in the terminal - const [showPastePrompt, setShowPastePrompt] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); - const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1; + const [showPastePrompt, setShowPastePrompt] = useState(false) + const [urlCopied, setUrlCopied] = useState(false) + + const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1 // Log forced login method on mount useEffect(() => { if (forceLoginMethod === 'claudeai') { - logEvent('tengu_oauth_claudeai_forced', {}); + logEvent('tengu_oauth_claudeai_forced', {}) } else if (forceLoginMethod === 'console') { - logEvent('tengu_oauth_console_forced', {}); + logEvent('tengu_oauth_console_forced', {}) } - }, [forceLoginMethod]); + }, [forceLoginMethod]) // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { - const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState); - return () => clearTimeout(timer); + const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState) + return () => clearTimeout(timer) } - }, [oauthStatus]); + }, [oauthStatus]) // Handle Enter to continue on success state - useKeybinding('confirm:yes', () => { - logEvent('tengu_oauth_success', { - loginWithClaudeAi - }); - onDone(); - }, { - context: 'Confirmation', - isActive: oauthStatus.state === 'success' && mode !== 'setup-token' - }); + useKeybinding( + 'confirm:yes', + () => { + logEvent('tengu_oauth_success', { loginWithClaudeAi }) + onDone() + }, + { + context: 'Confirmation', + isActive: oauthStatus.state === 'success' && mode !== 'setup-token', + }, + ) // Handle Enter to continue from platform setup - useKeybinding('confirm:yes', () => { - setOAuthStatus({ - state: 'idle' - }); - }, { - context: 'Confirmation', - isActive: oauthStatus.state === 'platform_setup' - }); + useKeybinding( + 'confirm:yes', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { + context: 'Confirmation', + isActive: oauthStatus.state === 'platform_setup', + }, + ) // Handle Enter to retry on error state - useKeybinding('confirm:yes', () => { - if (oauthStatus.state === 'error' && oauthStatus.toRetry) { - setPastedCode(''); - setOAuthStatus({ - state: 'about_to_retry', - nextState: oauthStatus.toRetry - }); - } - }, { - context: 'Confirmation', - isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry - }); + useKeybinding( + 'confirm:yes', + () => { + if (oauthStatus.state === 'error' && oauthStatus.toRetry) { + setPastedCode('') + setOAuthStatus({ + state: 'about_to_retry', + nextState: oauthStatus.toRetry, + }) + } + }, + { + context: 'Confirmation', + isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry, + }, + ) + useEffect(() => { - if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { + if ( + pastedCode === 'c' && + oauthStatus.state === 'waiting_for_login' && + showPastePrompt && + !urlCopied + ) { void setClipboard(oauthStatus.url).then(raw => { - if (raw) process.stdout.write(raw); - setUrlCopied(true); - setTimeout(setUrlCopied, 2000, false); - }); - setPastedCode(''); + if (raw) process.stdout.write(raw) + setUrlCopied(true) + setTimeout(setUrlCopied, 2000, false) + }) + setPastedCode('') } - }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); + }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]) + async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL - const [authorizationCode, state] = value.split('#'); + const [authorizationCode, state] = value.split('#') + if (!authorizationCode || !state) { setOAuthStatus({ state: 'error', message: 'Invalid code. Please make sure the full code was copied', - toRetry: { - state: 'waiting_for_login', - url - } - }); - return; + toRetry: { state: 'waiting_for_login', url }, + }) + return } // Track which path the user is taking (manual code entry) - logEvent('tengu_oauth_manual_entry', {}); + logEvent('tengu_oauth_manual_entry', {}) oauthService.handleManualAuthCodeInput({ authorizationCode, - state - }); + state, + }) } catch (err: unknown) { - logError(err); + logError(err) setOAuthStatus({ state: 'error', message: (err as Error).message, - toRetry: { - state: 'waiting_for_login', - url - } - }); + toRetry: { state: 'waiting_for_login', url }, + }) } } + const startOAuth = useCallback(async () => { try { - logEvent('tengu_oauth_flow_start', { - loginWithClaudeAi - }); - const result = await oauthService.startOAuthFlow(async url_0 => { - setOAuthStatus({ - state: 'waiting_for_login', - url: url_0 - }); - setTimeout(setShowPastePrompt, 3000, true); - }, { - loginWithClaudeAi, - inferenceOnly: mode === 'setup-token', - expiresIn: mode === 'setup-token' ? 365 * 24 * 60 * 60 : undefined, - // 1 year for setup-token - orgUUID - }).catch(err_1 => { - const isTokenExchangeError = err_1.message.includes('Token exchange failed'); - // Enterprise TLS proxies (Zscaler et al.) intercept the token - // exchange POST and cause cryptic SSL errors. Surface an - // actionable hint so the user isn't stuck in a login loop. - const sslHint_0 = getSSLErrorHint(err_1); - setOAuthStatus({ - state: 'error', - message: sslHint_0 ?? (isTokenExchangeError ? 'Failed to exchange authorization code for access token. Please try again.' : err_1.message), - toRetry: mode === 'setup-token' ? { - state: 'ready_to_start' - } : { - state: 'idle' - } - }); - logEvent('tengu_oauth_token_exchange_error', { - error: err_1.message, - ssl_error: sslHint_0 !== null - }); - throw err_1; - }); + logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }) + + const result = await oauthService + .startOAuthFlow( + async url => { + setOAuthStatus({ state: 'waiting_for_login', url }) + setTimeout(setShowPastePrompt, 3000, true) + }, + { + loginWithClaudeAi, + inferenceOnly: mode === 'setup-token', + expiresIn: mode === 'setup-token' ? 365 * 24 * 60 * 60 : undefined, // 1 year for setup-token + orgUUID, + }, + ) + .catch(err => { + const isTokenExchangeError = err.message.includes( + 'Token exchange failed', + ) + // Enterprise TLS proxies (Zscaler et al.) intercept the token + // exchange POST and cause cryptic SSL errors. Surface an + // actionable hint so the user isn't stuck in a login loop. + const sslHint = getSSLErrorHint(err) + setOAuthStatus({ + state: 'error', + message: + sslHint ?? + (isTokenExchangeError + ? 'Failed to exchange authorization code for access token. Please try again.' + : err.message), + toRetry: + mode === 'setup-token' + ? { state: 'ready_to_start' } + : { state: 'idle' }, + }) + logEvent('tengu_oauth_token_exchange_error', { + error: err.message, + ssl_error: sslHint !== null, + }) + throw err + }) + if (mode === 'setup-token') { // For setup-token mode, return the OAuth access token directly (it can be used as an API key) // Don't save to keychain - the token is displayed for manual use with CLAUDE_CODE_OAUTH_TOKEN - setOAuthStatus({ - state: 'success', - token: result.accessToken - }); + setOAuthStatus({ state: 'success', token: result.accessToken }) } else { - await installOAuthTokens(result); - const orgResult = await validateForceLoginOrg(); + await installOAuthTokens(result) + + const orgResult = await validateForceLoginOrg() if (!orgResult.valid) { - throw new Error((orgResult as { valid: false; message: string }).message); + throw new Error(orgResult.message) } - // Reset modelType to anthropic when using OAuth login - updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any); - setOAuthStatus({ - state: 'success' - }); - void sendNotification({ - message: 'Claude Code login successful', - notificationType: 'auth_success' - }, terminal); + + setOAuthStatus({ state: 'success' }) + void sendNotification( + { + message: 'Claude Code login successful', + notificationType: 'auth_success', + }, + terminal, + ) } - } catch (err_0) { - const errorMessage = (err_0 as Error).message; - const sslHint = getSSLErrorHint(err_0); + } catch (err) { + const errorMessage = (err as Error).message + const sslHint = getSSLErrorHint(err) setOAuthStatus({ state: 'error', message: sslHint ?? errorMessage, toRetry: { - state: mode === 'setup-token' ? 'ready_to_start' : 'idle' - } - }); + state: mode === 'setup-token' ? 'ready_to_start' : 'idle', + }, + }) logEvent('tengu_oauth_error', { - error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ssl_error: sslHint !== null - }); + error: + errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ssl_error: sslHint !== null, + }) } - }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]); - const pendingOAuthStartRef = useRef(false); + }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]) + + const pendingOAuthStartRef = useRef(false) + useEffect(() => { - if (oauthStatus.state === 'ready_to_start' && !pendingOAuthStartRef.current) { - pendingOAuthStartRef.current = true; - process.nextTick((startOAuth_0: () => Promise, pendingOAuthStartRef_0: React.MutableRefObject) => { - void startOAuth_0(); - pendingOAuthStartRef_0.current = false; - }, startOAuth, pendingOAuthStartRef); + if ( + oauthStatus.state === 'ready_to_start' && + !pendingOAuthStartRef.current + ) { + pendingOAuthStartRef.current = true + process.nextTick( + ( + startOAuth: () => Promise, + pendingOAuthStartRef: React.MutableRefObject, + ) => { + void startOAuth() + pendingOAuthStartRef.current = false + }, + startOAuth, + pendingOAuthStartRef, + ) } - }, [oauthStatus.state, startOAuth]); + }, [oauthStatus.state, startOAuth]) // Auto-exit for setup-token mode useEffect(() => { if (mode === 'setup-token' && oauthStatus.state === 'success') { // Delay to ensure static content is fully rendered before exiting - const timer_0 = setTimeout((loginWithClaudeAi_0, onDone_0) => { - logEvent('tengu_oauth_success', { - loginWithClaudeAi: loginWithClaudeAi_0 - }); - // Don't clear terminal so the token remains visible - onDone_0(); - }, 500, loginWithClaudeAi, onDone); - return () => clearTimeout(timer_0); + const timer = setTimeout( + (loginWithClaudeAi, onDone) => { + logEvent('tengu_oauth_success', { loginWithClaudeAi }) + // Don't clear terminal so the token remains visible + onDone() + }, + 500, + loginWithClaudeAi, + onDone, + ) + return () => clearTimeout(timer) } - }, [mode, oauthStatus, loginWithClaudeAi, onDone]); + }, [mode, oauthStatus, loginWithClaudeAi, onDone]) // Cleanup OAuth service when component unmounts useEffect(() => { return () => { - oauthService.cleanup(); - }; - }, [oauthService]); - return - {oauthStatus.state === 'waiting_for_login' && showPastePrompt && + oauthService.cleanup() + } + }, [oauthService]) + + return ( + + {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( + Browser didn't open? Use the url below to sign in{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} {oauthStatus.url} - } - {mode === 'setup-token' && oauthStatus.state === 'success' && oauthStatus.token && + + )} + {mode === 'setup-token' && + oauthStatus.state === 'success' && + oauthStatus.token && ( + ✓ Long-lived authentication token created successfully! @@ -343,548 +380,730 @@ export function ConsoleOAuthFlow({ CLAUDE_CODE_OAUTH_TOKEN=<token> - } + + )} - + - ; + + ) } + type OAuthStatusMessageProps = { - oauthStatus: OAuthStatus; - mode: 'login' | 'setup-token'; - startingMessage: string | undefined; - forcedMethodMessage: string | null; - showPastePrompt: boolean; - pastedCode: string; - setPastedCode: (value: string) => void; - cursorOffset: number; - setCursorOffset: (offset: number) => void; - textInputColumns: number; - handleSubmitCode: (value: string, url: string) => void; - setOAuthStatus: (status: OAuthStatus) => void; - setLoginWithClaudeAi: (value: boolean) => void; - onDone: () => void; -}; -function OAuthStatusMessage(t0) { - const $ = _c(51); - const { - oauthStatus, - mode, - startingMessage, - forcedMethodMessage, - showPastePrompt, - pastedCode, - setPastedCode, - cursorOffset, - setCursorOffset, - textInputColumns, - handleSubmitCode, - setOAuthStatus, - setLoginWithClaudeAi, - onDone - } = t0; + oauthStatus: OAuthStatus + mode: 'login' | 'setup-token' + startingMessage: string | undefined + forcedMethodMessage: string | null + showPastePrompt: boolean + pastedCode: string + setPastedCode: (value: string) => void + cursorOffset: number + setCursorOffset: (offset: number) => void + textInputColumns: number + handleSubmitCode: (value: string, url: string) => void + setOAuthStatus: (status: OAuthStatus) => void + setLoginWithClaudeAi: (value: boolean) => void +} + +function OAuthStatusMessage({ + oauthStatus, + mode, + startingMessage, + forcedMethodMessage, + showPastePrompt, + pastedCode, + setPastedCode, + cursorOffset, + setCursorOffset, + textInputColumns, + handleSubmitCode, + setOAuthStatus, + setLoginWithClaudeAi, +}: OAuthStatusMessageProps): React.ReactNode { switch (oauthStatus.state) { - case "idle": - { - const t1 = startingMessage ? startingMessage : "Claude Code can be used with your Claude subscription or billed based on API usage through your Console account."; - let t2; - if ($[0] !== t1) { - t2 = {t1}; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Select login method:; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: Claude account with subscription ·{" "}Pro, Max, Team, or Enterprise{false && {"\n"}[ANT-ONLY]{" "}Please use this option unless you need to login to a special org for accessing sensitive data (e.g. customer data, HIPI data) with the Console option}{"\n"}, - value: "claudeai" - }; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: Anthropic Console account ·{" "}API usage billing{"\n"}, - value: "console" - }; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [{ - label: Custom Platform ·{" "}Configure your own API endpoint{"\n"}, - value: "custom_platform" - }, { - label: OpenAI Compatible ·{" "}Ollama, DeepSeek, vLLM, One API, etc.{"\n"}, - value: "openai_chat_api" - }, t4, t5, { - label: 3rd-party platform ·{" "}Amazon Bedrock, Microsoft Foundry, or Vertex AI{"\n"}, - value: "platform" - }]; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== setLoginWithClaudeAi || $[7] !== setOAuthStatus) { - t7 = + Claude account with subscription ·{' '} + Pro, Max, Team, or Enterprise + {process.env.USER_TYPE === 'ant' && ( + + {'\n'} + [ANT-ONLY]{' '} + + Please use this option unless you need to login to a + special org for accessing sensitive data (e.g. + customer data, HIPI data) with the Console option + + + )} + {'\n'} + + ), + value: 'claudeai', + }, + { + label: ( + + Anthropic Console account ·{' '} + API usage billing + {'\n'} + + ), + value: 'console', + }, + { + label: ( + + Custom Platform ·{' '} + Configure your own API endpoint + {'\n'} + + ), + value: 'custom_platform', + }, + { + label: ( + + OpenAI Compatible ·{' '} + + Ollama, DeepSeek, vLLM, One API, etc. + + {'\n'} + + ), + value: 'openai_chat_api', + }, + { + label: ( + + 3rd-party platform ·{' '} + + Amazon Bedrock, Microsoft Foundry, or Vertex AI + + {'\n'} + + ), + value: 'platform', + }, + ]} + onChange={value => { + if (value === 'custom_platform') { + logEvent('tengu_custom_platform_selected', {}) + setOAuthStatus({ + state: 'custom_platform', + baseUrl: process.env.ANTHROPIC_BASE_URL ?? '', + apiKey: process.env.ANTHROPIC_AUTH_TOKEN ?? '', + haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '', + sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', + opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', + activeField: 'base_url', + }) + } else if (value === 'openai_chat_api') { + logEvent('tengu_openai_chat_api_selected', {}) + setOAuthStatus({ + state: 'openai_chat_api', + baseUrl: process.env.OPENAI_BASE_URL ?? '', + apiKey: process.env.OPENAI_API_KEY ?? '', + haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '', + sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', + opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', + activeField: 'base_url', + }) + } else if (value === 'platform') { + logEvent('tengu_oauth_platform_selected', {}) + setOAuthStatus({ state: 'platform_setup' }) } else { - logEvent("tengu_oauth_console_selected", {}); - setLoginWithClaudeAi(false); + setOAuthStatus({ state: 'ready_to_start' }) + if (value === 'claudeai') { + logEvent('tengu_oauth_claudeai_selected', {}) + setLoginWithClaudeAi(true) + } else { + logEvent('tengu_oauth_console_selected', {}) + setLoginWithClaudeAi(false) + } } - } - }} />; - $[6] = setLoginWithClaudeAi; - $[7] = setOAuthStatus; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] !== t2 || $[10] !== t7) { - t8 = {t2}{t3}{t7}; - $[9] = t2; - $[10] = t7; - $[11] = t8; - } else { - t8 = $[11]; - } - return t8; - } - case "platform_setup": + }} + /> + + + ) + + case 'custom_platform': { - let t1; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Using 3rd-party platforms; - $[12] = t1; - } else { - t1 = $[12]; - } - let t2; - let t3; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment variables, then restart Claude Code.; - t3 = If you are part of an enterprise organization, contact your administrator for setup instructions.; - $[13] = t2; - $[14] = t3; - } else { - t2 = $[13]; - t3 = $[14]; - } - let t4; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Documentation:; - $[15] = t4; - } else { - t4 = $[15]; + type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'] + const cp = oauthStatus as { + state: 'custom_platform' + activeField: Field + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string } - let t5; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t5 = · Amazon Bedrock:{" "}https://code.claude.com/docs/en/amazon-bedrock; - $[16] = t5; - } else { - t5 = $[16]; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp + const displayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, } - let t6; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t6 = · Microsoft Foundry:{" "}https://code.claude.com/docs/en/microsoft-foundry; - $[17] = t6; - } else { - t6 = $[17]; - } - let t7; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t7 = {t4}{t5}{t6}· Vertex AI:{" "}https://code.claude.com/docs/en/google-vertex-ai; - $[18] = t7; - } else { - t7 = $[18]; - } - let t8; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t8 = {t1}{t2}{t3}{t7}Press Enter to go back to login options.; - $[19] = t8; - } else { - t8 = $[19]; - } - return t8; - } - case "custom_platform": - { - type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; - const cp = oauthStatus as { state: 'custom_platform'; activeField: Field; baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp; - const displayValues: Record = { base_url: baseUrl, api_key: apiKey, haiku_model: haikuModel, sonnet_model: sonnetModel, opus_model: opusModel }; - - const [inputValue, setInputValue] = useState(() => displayValues[activeField]); - const [inputCursorOffset, setInputCursorOffset] = useState(() => displayValues[activeField].length); - - // Build updated state with a given field changed - const buildState = useCallback((field: Field, value: string, newActive?: Field) => { - const s = { state: 'custom_platform' as const, activeField: newActive ?? activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel }; - switch (field) { - case 'base_url': return { ...s, baseUrl: value }; - case 'api_key': return { ...s, apiKey: value }; - case 'haiku_model': return { ...s, haikuModel: value }; - case 'sonnet_model': return { ...s, sonnetModel: value }; - case 'opus_model': return { ...s, opusModel: value }; - } - }, [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel]); - // Tab switching: save current → update state → load target - const switchTo = useCallback((target: Field) => { - setOAuthStatus(buildState(activeField, inputValue, target)); - setInputValue(displayValues[target] ?? ''); - setInputCursorOffset((displayValues[target] ?? '').length); - }, [activeField, inputValue, displayValues, buildState, setOAuthStatus]); + const [inputValue, setInputValue] = useState(() => displayValues[activeField]) + const [inputCursorOffset, setInputCursorOffset] = useState( + () => displayValues[activeField].length, + ) + + const buildState = useCallback( + (field: Field, value: string, newActive?: Field) => { + const s = { + state: 'custom_platform' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + } + switch (field) { + case 'base_url': + return { ...s, baseUrl: value } + case 'api_key': + return { ...s, apiKey: value } + case 'haiku_model': + return { ...s, haikuModel: value } + case 'sonnet_model': + return { ...s, sonnetModel: value } + case 'opus_model': + return { ...s, opusModel: value } + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ) + + const switchTo = useCallback( + (target: Field) => { + setOAuthStatus(buildState(activeField, inputValue, target)) + setInputValue(displayValues[target] ?? '') + setInputCursorOffset((displayValues[target] ?? '').length) + }, + [activeField, inputValue, displayValues, buildState, setOAuthStatus], + ) const doSave = useCallback(() => { - const finalVals = { ...displayValues, [activeField]: inputValue }; - const env: Record = {}; - if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url; - if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key; - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { modelType: 'anthropic' as any, env } as any); + const finalVals = { ...displayValues, [activeField]: inputValue } + const env: Record = {} + if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url + if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model + const { error } = updateSettingsForSource('userSettings', { + modelType: 'anthropic' as any, + env, + } as any) if (error) { - setOAuthStatus({ state: 'error', message: `Failed to save: ${error.message}`, toRetry: { state: 'custom_platform', baseUrl: '', apiKey: '', haikuModel: '', sonnetModel: '', opusModel: '', activeField: 'base_url' } }); + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + state: 'custom_platform', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }) } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; - setOAuthStatus({ state: 'success' }); - void onDone(); + for (const [k, v] of Object.entries(env)) process.env[k] = v + setOAuthStatus({ state: 'success' }) + void onDone() } - }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]); + }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]) const handleEnter = useCallback(() => { - const idx = FIELDS.indexOf(activeField); - // Update current field value in state - setOAuthStatus(buildState(activeField, inputValue)); + const idx = FIELDS.indexOf(activeField) + setOAuthStatus(buildState(activeField, inputValue)) if (idx === FIELDS.length - 1) { - doSave(); + doSave() } else { - const next = FIELDS[idx + 1]!; - setInputValue(displayValues[next] ?? ''); - setInputCursorOffset((displayValues[next] ?? '').length); - } - }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]); - - useKeybinding('tabs:next', () => { - const idx = FIELDS.indexOf(activeField); - if (idx < FIELDS.length - 1) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])); - setInputValue(displayValues[FIELDS[idx + 1]!] ?? ''); - setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length); - } - }, { context: 'Tabs' }); - useKeybinding('tabs:previous', () => { - const idx = FIELDS.indexOf(activeField); - if (idx > 0) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])); - setInputValue(displayValues[FIELDS[idx - 1]!] ?? ''); - setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length); + const next = FIELDS[idx + 1]! + setInputValue(displayValues[next] ?? '') + setInputCursorOffset((displayValues[next] ?? '').length) } - }, { context: 'Tabs' }); - useKeybinding('confirm:no', () => { - setOAuthStatus({ state: 'idle' }); - }, { context: 'Confirmation' }); - - const columns = useTerminalSize().columns - 20; - - const renderRow = (field: Field, label: string, opts?: { mask?: boolean; placeholder?: string }) => { - const active = activeField === field; - const val = displayValues[field]; - return - {` ${label} `} - - {active - ? - : (val - ? {opts?.mask ? val.slice(0, 8) + '·'.repeat(Math.max(0, val.length - 8)) : val} - : null)} - ; - }; - - return - Custom Platform Setup + }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]) + + useKeybinding( + 'tabs:next', + () => { + const idx = FIELDS.indexOf(activeField) + if (idx < FIELDS.length - 1) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])) + setInputValue(displayValues[FIELDS[idx + 1]!] ?? '') + setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'tabs:previous', + () => { + const idx = FIELDS.indexOf(activeField) + if (idx > 0) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])) + setInputValue(displayValues[FIELDS[idx - 1]!] ?? '') + setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { context: 'Confirmation' }, + ) + + const columns = useTerminalSize().columns - 20 + + const renderRow = ( + field: Field, + label: string, + opts?: { mask?: boolean; placeholder?: string }, + ) => { + const active = activeField === field + const val = displayValues[field] + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask + ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) + : val} + + ) : null} + + ) + } + + return ( - {renderRow('base_url', 'Base URL ')} - {renderRow('api_key', 'API Key ', { mask: true })} - {renderRow('haiku_model', 'Haiku ')} - {renderRow('sonnet_model', 'Sonnet ')} - {renderRow('opus_model', 'Opus ')} + Custom Platform Setup + + {renderRow('base_url', 'Base URL ')} + {renderRow('api_key', 'API Key ', { mask: true })} + {renderRow('haiku_model', 'Haiku ')} + {renderRow('sonnet_model', 'Sonnet ')} + {renderRow('opus_model', 'Opus ')} + + + Tab to switch · Enter on last field to save · Esc to go back + - Tab to switch · Enter on last field to save · Esc to go back - ; + ) } - case "openai_chat_api": + + case 'openai_chat_api': { - type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const OPENAI_FIELDS: OpenAIField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; - const op = oauthStatus as { state: 'openai_chat_api'; activeField: OpenAIField; baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op; - const openaiDisplayValues: Record = { base_url: baseUrl, api_key: apiKey, haiku_model: haikuModel, sonnet_model: sonnetModel, opus_model: opusModel }; - - const [openaiInputValue, setOpenaiInputValue] = useState(() => openaiDisplayValues[activeField]); - const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState(() => openaiDisplayValues[activeField].length); - - const buildOpenAIState = useCallback((field: OpenAIField, value: string, newActive?: OpenAIField) => { - const s = { state: 'openai_chat_api' as const, activeField: newActive ?? activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel }; - switch (field) { - case 'base_url': return { ...s, baseUrl: value }; - case 'api_key': return { ...s, apiKey: value }; - case 'haiku_model': return { ...s, haikuModel: value }; - case 'sonnet_model': return { ...s, sonnetModel: value }; - case 'opus_model': return { ...s, opusModel: value }; - } - }, [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel]); + type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + const OPENAI_FIELDS: OpenAIField[] = [ + 'base_url', + 'api_key', + 'haiku_model', + 'sonnet_model', + 'opus_model', + ] + const op = oauthStatus as { + state: 'openai_chat_api' + activeField: OpenAIField + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + } + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op + const openaiDisplayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + } + + const [openaiInputValue, setOpenaiInputValue] = useState( + () => openaiDisplayValues[activeField], + ) + const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( + () => openaiDisplayValues[activeField].length, + ) + + const buildOpenAIState = useCallback( + (field: OpenAIField, value: string, newActive?: OpenAIField) => { + const s = { + state: 'openai_chat_api' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + } + switch (field) { + case 'base_url': + return { ...s, baseUrl: value } + case 'api_key': + return { ...s, apiKey: value } + case 'haiku_model': + return { ...s, haikuModel: value } + case 'sonnet_model': + return { ...s, sonnetModel: value } + case 'opus_model': + return { ...s, opusModel: value } + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ) const doOpenAISave = useCallback(() => { - const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue }; - const env: Record = {}; - if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url; - if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key; - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { modelType: 'openai' as any, env } as any); + const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue } + const env: Record = {} + if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url + if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model + const { error } = updateSettingsForSource('userSettings', { + modelType: 'openai' as any, + env, + } as any) if (error) { - setOAuthStatus({ state: 'error', message: `Failed to save: ${error.message}`, toRetry: { state: 'openai_chat_api', baseUrl: '', apiKey: '', haikuModel: '', sonnetModel: '', opusModel: '', activeField: 'base_url' } }); + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + state: 'openai_chat_api', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }) } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; - setOAuthStatus({ state: 'success' }); - void onDone(); + for (const [k, v] of Object.entries(env)) process.env[k] = v + setOAuthStatus({ state: 'success' }) + void onDone() } - }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]); + }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]) const handleOpenAIEnter = useCallback(() => { - const idx = OPENAI_FIELDS.indexOf(activeField); - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)); + const idx = OPENAI_FIELDS.indexOf(activeField) + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)) if (idx === OPENAI_FIELDS.length - 1) { - doOpenAISave(); + doOpenAISave() } else { - const next = OPENAI_FIELDS[idx + 1]!; - setOpenaiInputValue(openaiDisplayValues[next] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length); - } - }, [activeField, openaiInputValue, buildOpenAIState, doOpenAISave, openaiDisplayValues, setOAuthStatus]); - - useKeybinding('tabs:next', () => { - const idx = OPENAI_FIELDS.indexOf(activeField); - if (idx < OPENAI_FIELDS.length - 1) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length); - } - }, { context: 'Tabs' }); - useKeybinding('tabs:previous', () => { - const idx = OPENAI_FIELDS.indexOf(activeField); - if (idx > 0) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length); + const next = OPENAI_FIELDS[idx + 1]! + setOpenaiInputValue(openaiDisplayValues[next] ?? '') + setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length) } - }, { context: 'Tabs' }); - useKeybinding('confirm:no', () => { - setOAuthStatus({ state: 'idle' }); - }, { context: 'Confirmation' }); - - const openaiColumns = useTerminalSize().columns - 20; - - const renderOpenAIRow = (field: OpenAIField, label: string, opts?: { mask?: boolean }) => { - const active = activeField === field; - const val = openaiDisplayValues[field]; - return - {` ${label} `} - - {active - ? - : (val - ? {opts?.mask ? val.slice(0, 8) + '·'.repeat(Math.max(0, val.length - 8)) : val} - : null)} - ; - }; - - return - OpenAI Compatible API Setup - Configure an OpenAI Chat Completions compatible endpoint (e.g. Ollama, DeepSeek, vLLM). + }, [ + activeField, + openaiInputValue, + buildOpenAIState, + doOpenAISave, + openaiDisplayValues, + setOAuthStatus, + ]) + + useKeybinding( + 'tabs:next', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField) + if (idx < OPENAI_FIELDS.length - 1) { + setOAuthStatus( + buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1]), + ) + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '') + setOpenaiInputCursorOffset( + (openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length, + ) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'tabs:previous', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField) + if (idx > 0) { + setOAuthStatus( + buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1]), + ) + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '') + setOpenaiInputCursorOffset( + (openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length, + ) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { context: 'Confirmation' }, + ) + + const openaiColumns = useTerminalSize().columns - 20 + + const renderOpenAIRow = ( + field: OpenAIField, + label: string, + opts?: { mask?: boolean }, + ) => { + const active = activeField === field + const val = openaiDisplayValues[field] + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask + ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) + : val} + + ) : null} + + ) + } + + return ( - {renderOpenAIRow('base_url', 'Base URL ')} - {renderOpenAIRow('api_key', 'API Key ', { mask: true })} - {renderOpenAIRow('haiku_model', 'Haiku ')} - {renderOpenAIRow('sonnet_model', 'Sonnet ')} - {renderOpenAIRow('opus_model', 'Opus ')} + OpenAI Compatible API Setup + + Configure an OpenAI Chat Completions compatible endpoint (e.g. + Ollama, DeepSeek, vLLM). + + + {renderOpenAIRow('base_url', 'Base URL ')} + {renderOpenAIRow('api_key', 'API Key ', { mask: true })} + {renderOpenAIRow('haiku_model', 'Haiku ')} + {renderOpenAIRow('sonnet_model', 'Sonnet ')} + {renderOpenAIRow('opus_model', 'Opus ')} + + + Tab to switch · Enter on last field to save · Esc to go back + - Tab to switch · Enter on last field to save · Esc to go back - ; - } - case "waiting_for_login": - { - let t1; - if ($[20] !== forcedMethodMessage) { - t1 = forcedMethodMessage && {forcedMethodMessage}; - $[20] = forcedMethodMessage; - $[21] = t1; - } else { - t1 = $[21]; - } - let t2; - if ($[22] !== showPastePrompt) { - t2 = !showPastePrompt && Opening browser to sign in…; - $[22] = showPastePrompt; - $[23] = t2; - } else { - t2 = $[23]; - } - let t3; - if ($[24] !== cursorOffset || $[25] !== handleSubmitCode || $[26] !== oauthStatus.url || $[27] !== pastedCode || $[28] !== setCursorOffset || $[29] !== setPastedCode || $[30] !== showPastePrompt || $[31] !== textInputColumns) { - t3 = showPastePrompt && {PASTE_HERE_MSG} handleSubmitCode(value, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} mask="*" />; - $[24] = cursorOffset; - $[25] = handleSubmitCode; - $[26] = oauthStatus.url; - $[27] = pastedCode; - $[28] = setCursorOffset; - $[29] = setPastedCode; - $[30] = showPastePrompt; - $[31] = textInputColumns; - $[32] = t3; - } else { - t3 = $[32]; - } - let t4; - if ($[33] !== t1 || $[34] !== t2 || $[35] !== t3) { - t4 = {t1}{t2}{t3}; - $[33] = t1; - $[34] = t2; - $[35] = t3; - $[36] = t4; - } else { - t4 = $[36]; - } - return t4; - } - case "creating_api_key": - { - let t1; - if ($[37] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Creating API key for Claude Code…; - $[37] = t1; - } else { - t1 = $[37]; - } - return t1; - } - case "about_to_retry": - { - let t1; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Retrying…; - $[38] = t1; - } else { - t1 = $[38]; - } - return t1; - } - case "success": - { - let t1; - if ($[39] !== mode || $[40] !== oauthStatus.token) { - t1 = mode === "setup-token" && oauthStatus.token ? null : <>{getOauthAccountInfo()?.emailAddress ? Logged in as{" "}{getOauthAccountInfo()?.emailAddress} : null}Login successful. Press Enter to continue…; - $[39] = mode; - $[40] = oauthStatus.token; - $[41] = t1; - } else { - t1 = $[41]; - } - let t2; - if ($[42] !== t1) { - t2 = {t1}; - $[42] = t1; - $[43] = t2; - } else { - t2 = $[43]; - } - return t2; - } - case "error": - { - let t1; - if ($[44] !== oauthStatus.message) { - t1 = OAuth error: {oauthStatus.message}; - $[44] = oauthStatus.message; - $[45] = t1; - } else { - t1 = $[45]; - } - let t2; - if ($[46] !== oauthStatus.toRetry) { - t2 = oauthStatus.toRetry && Press Enter to retry.; - $[46] = oauthStatus.toRetry; - $[47] = t2; - } else { - t2 = $[47]; - } - let t3; - if ($[48] !== t1 || $[49] !== t2) { - t3 = {t1}{t2}; - $[48] = t1; - $[49] = t2; - $[50] = t3; - } else { - t3 = $[50]; - } - return t3; + ) } + + case 'platform_setup': + return ( + + Using 3rd-party platforms + + + + Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex + AI. Set the required environment variables, then restart Claude + Code. + + + + If you are part of an enterprise organization, contact your + administrator for setup instructions. + + + + Documentation: + + · Amazon Bedrock:{' '} + + https://code.claude.com/docs/en/amazon-bedrock + + + + · Microsoft Foundry:{' '} + + https://code.claude.com/docs/en/microsoft-foundry + + + + · Vertex AI:{' '} + + https://code.claude.com/docs/en/google-vertex-ai + + + + + + + Press Enter to go back to login options. + + + + + ) + + case 'waiting_for_login': + return ( + + {forcedMethodMessage && ( + + {forcedMethodMessage} + + )} + + {!showPastePrompt && ( + + + Opening browser to sign in… + + )} + + {showPastePrompt && ( + + {PASTE_HERE_MSG} + + handleSubmitCode(value, oauthStatus.url) + } + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + columns={textInputColumns} + mask="*" + /> + + )} + + ) + + case 'creating_api_key': + return ( + + + + Creating API key for Claude Code… + + + ) + + case 'about_to_retry': + return ( + + Retrying… + + ) + + case 'success': + return ( + + {mode === 'setup-token' && oauthStatus.token ? null : ( + <> + {getOauthAccountInfo()?.emailAddress ? ( + + Logged in as{' '} + {getOauthAccountInfo()?.emailAddress} + + ) : null} + + Login successful. Press Enter to continue… + + + )} + + ) + + case 'error': + return ( + + OAuth error: {oauthStatus.message} + + {oauthStatus.toRetry && ( + + + Press Enter to retry. + + + )} + + ) + default: - { - return null; - } + return null } } diff --git a/src/components/ContextSuggestions.tsx b/src/components/ContextSuggestions.tsx index 1fc9e3d5c..2eeafafe3 100644 --- a/src/components/ContextSuggestions.tsx +++ b/src/components/ContextSuggestions.tsx @@ -1,46 +1,38 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Box, Text } from '../ink.js'; -import type { ContextSuggestion } from '../utils/contextSuggestions.js'; -import { formatTokens } from '../utils/format.js'; -import { StatusIcon } from './design-system/StatusIcon.js'; +import figures from 'figures' +import * as React from 'react' +import { Box, Text } from '../ink.js' +import type { ContextSuggestion } from '../utils/contextSuggestions.js' +import { formatTokens } from '../utils/format.js' +import { StatusIcon } from './design-system/StatusIcon.js' + type Props = { - suggestions: ContextSuggestion[]; -}; -export function ContextSuggestions(t0) { - const $ = _c(5); - const { - suggestions - } = t0; - if (suggestions.length === 0) { - return null; - } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Suggestions; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== suggestions) { - t2 = suggestions.map(_temp); - $[1] = suggestions; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t1}{t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + suggestions: ContextSuggestion[] } -function _temp(suggestion, i) { - return {suggestion.title}{suggestion.savingsTokens ? {" "}{figures.arrowRight} save ~{formatTokens(suggestion.savingsTokens)} : null}{suggestion.detail}; + +export function ContextSuggestions({ suggestions }: Props): React.ReactNode { + if (suggestions.length === 0) return null + + return ( + + Suggestions + {suggestions.map((suggestion, i) => ( + + + + {suggestion.title} + {suggestion.savingsTokens ? ( + + {' '} + {figures.arrowRight} save ~ + {formatTokens(suggestion.savingsTokens)} + + ) : null} + + + {suggestion.detail} + + + ))} + + ) } diff --git a/src/components/ContextVisualization.tsx b/src/components/ContextVisualization.tsx index 04a9c1374..e6fb57493 100644 --- a/src/components/ContextVisualization.tsx +++ b/src/components/ContextVisualization.tsx @@ -1,15 +1,18 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { Box, Text } from '../ink.js'; -import type { ContextData } from '../utils/analyzeContext.js'; -import { generateContextSuggestions } from '../utils/contextSuggestions.js'; -import { getDisplayPath } from '../utils/file.js'; -import { formatTokens } from '../utils/format.js'; -import { getSourceDisplayName, type SettingSource } from '../utils/settings/constants.js'; -import { plural } from '../utils/stringUtils.js'; -import { ContextSuggestions } from './ContextSuggestions.js'; -const RESERVED_CATEGORY_NAME = 'Autocompact buffer'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { Box, Text } from '../ink.js' +import type { ContextData } from '../utils/analyzeContext.js' +import { generateContextSuggestions } from '../utils/contextSuggestions.js' +import { getDisplayPath } from '../utils/file.js' +import { formatTokens } from '../utils/format.js' +import { + getSourceDisplayName, + type SettingSource, +} from '../utils/settings/constants.js' +import { plural } from '../utils/stringUtils.js' +import { ContextSuggestions } from './ContextSuggestions.js' + +const RESERVED_CATEGORY_NAME = 'Autocompact buffer' /** * One-liner for the legend header showing what context-collapse has done. @@ -18,95 +21,100 @@ const RESERVED_CATEGORY_NAME = 'Autocompact buffer'; * their context was rewritten — the placeholders are isMeta * and don't appear in the conversation view. */ -function CollapseStatus() { - const $ = _c(2); - if (feature("CONTEXT_COLLAPSE")) { - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const { - getStats, - isContextCollapseEnabled - } = require("../services/contextCollapse/index.js") as typeof import('../services/contextCollapse/index.js'); - if (!isContextCollapseEnabled()) { - t1 = null; - break bb0; - } - const s = getStats(); - const { - health: h - } = s; - const parts = []; - if (s.collapsedSpans > 0) { - parts.push(`${s.collapsedSpans} ${plural(s.collapsedSpans, "span")} summarized (${s.collapsedMessages} msgs)`); - } - if (s.stagedSpans > 0) { - parts.push(`${s.stagedSpans} staged`); - } - const summary = parts.length > 0 ? parts.join(", ") : h.totalSpawns > 0 ? `${h.totalSpawns} ${plural(h.totalSpawns, "spawn")}, nothing staged yet` : "waiting for first trigger"; - let line2 = null; - if (h.totalErrors > 0) { - line2 = Collapse errors: {h.totalErrors}/{h.totalSpawns} spawns failed{h.lastError ? ` (last: ${h.lastError.slice(0, 60)})` : ""}; - } else { - if (h.emptySpawnWarningEmitted) { - line2 = Collapse idle: {h.totalEmptySpawns} consecutive empty runs; - } - } - t0 = <>Context strategy: collapse ({summary}){line2}; - } - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; +function CollapseStatus(): React.ReactNode { + if (feature('CONTEXT_COLLAPSE')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { getStats, isContextCollapseEnabled } = + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + if (!isContextCollapseEnabled()) return null + + const s = getStats() + const { health: h } = s + + const parts: string[] = [] + if (s.collapsedSpans > 0) { + parts.push( + `${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} msgs)`, + ) } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; + if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`) + const summary = + parts.length > 0 + ? parts.join(', ') + : h.totalSpawns > 0 + ? `${h.totalSpawns} ${plural(h.totalSpawns, 'spawn')}, nothing staged yet` + : 'waiting for first trigger' + + let line2: React.ReactNode = null + if (h.totalErrors > 0) { + line2 = ( + + Collapse errors: {h.totalErrors}/{h.totalSpawns} spawns failed + {h.lastError ? ` (last: ${h.lastError.slice(0, 60)})` : ''} + + ) + } else if (h.emptySpawnWarningEmitted) { + line2 = ( + + Collapse idle: {h.totalEmptySpawns} consecutive empty runs + + ) } - return t0; + + return ( + <> + Context strategy: collapse ({summary}) + {line2} + + ) } - return null; + return null } // Order for displaying source groups: Project > User > Managed > Plugin > Built-in -const SOURCE_DISPLAY_ORDER = ['Project', 'User', 'Managed', 'Plugin', 'Built-in']; +const SOURCE_DISPLAY_ORDER = [ + 'Project', + 'User', + 'Managed', + 'Plugin', + 'Built-in', +] /** Group items by source type for display, sorted by tokens descending within each group */ -function groupBySource(items: T[]): Map { - const groups = new Map(); +function groupBySource< + T extends { source: SettingSource | 'plugin' | 'built-in'; tokens: number }, +>(items: T[]): Map { + const groups = new Map() for (const item of items) { - const key = getSourceDisplayName(item.source); - const existing = groups.get(key) || []; - existing.push(item); - groups.set(key, existing); + const key = getSourceDisplayName(item.source) + const existing = groups.get(key) || [] + existing.push(item) + groups.set(key, existing) } // Sort each group by tokens descending for (const [key, group] of groups.entries()) { - groups.set(key, group.sort((a, b) => b.tokens - a.tokens)); + groups.set( + key, + group.sort((a, b) => b.tokens - a.tokens), + ) } // Return groups in consistent order - const orderedGroups = new Map(); + const orderedGroups = new Map() for (const source of SOURCE_DISPLAY_ORDER) { - const group = groups.get(source); + const group = groups.get(source) if (group) { - orderedGroups.set(source, group); + orderedGroups.set(source, group) } } - return orderedGroups; + return orderedGroups } + interface Props { - data: ContextData; + data: ContextData } -export function ContextVisualization(t0) { - const $ = _c(87); - const { - data - } = t0; + +export function ContextVisualization({ data }: Props): React.ReactNode { const { categories, totalTokens, @@ -116,373 +124,371 @@ export function ContextVisualization(t0) { model, memoryFiles, mcpTools, - deferredBuiltinTools: t1, + deferredBuiltinTools = [], systemTools, systemPromptSections, agents, skills, - messageBreakdown - } = data; - let T0; - let T1; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[0] !== categories || $[1] !== gridRows || $[2] !== mcpTools || $[3] !== model || $[4] !== percentage || $[5] !== rawMaxTokens || $[6] !== systemTools || $[7] !== t1 || $[8] !== totalTokens) { - const deferredBuiltinTools = t1 === undefined ? [] : t1; - const visibleCategories = categories.filter(_temp); - let t10; - if ($[19] !== categories) { - t10 = categories.some(_temp2); - $[19] = categories; - $[20] = t10; - } else { - t10 = $[20]; - } - const hasDeferredMcpTools = t10; - const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0; - const autocompactCategory = categories.find(_temp3); - T1 = Box; - t6 = "column"; - t7 = 1; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Context Usage; - $[21] = t8; - } else { - t8 = $[21]; - } - let t11; - if ($[22] !== gridRows) { - t11 = gridRows.map(_temp5); - $[22] = gridRows; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== t11) { - t12 = {t11}; - $[24] = t11; - $[25] = t12; - } else { - t12 = $[25]; - } - let t13; - if ($[26] !== totalTokens) { - t13 = formatTokens(totalTokens); - $[26] = totalTokens; - $[27] = t13; - } else { - t13 = $[27]; - } - let t14; - if ($[28] !== rawMaxTokens) { - t14 = formatTokens(rawMaxTokens); - $[28] = rawMaxTokens; - $[29] = t14; - } else { - t14 = $[29]; - } - let t15; - if ($[30] !== model || $[31] !== percentage || $[32] !== t13 || $[33] !== t14) { - t15 = {model} · {t13}/{t14}{" "}tokens ({percentage}%); - $[30] = model; - $[31] = percentage; - $[32] = t13; - $[33] = t14; - $[34] = t15; - } else { - t15 = $[34]; - } - let t16; - let t17; - let t18; - if ($[35] === Symbol.for("react.memo_cache_sentinel")) { - t16 = ; - t17 = ; - t18 = Estimated usage by category; - $[35] = t16; - $[36] = t17; - $[37] = t18; - } else { - t16 = $[35]; - t17 = $[36]; - t18 = $[37]; - } - let t19; - if ($[38] !== rawMaxTokens) { - t19 = (cat_2, index) => { - const tokenDisplay = formatTokens(cat_2.tokens); - const percentDisplay = cat_2.isDeferred ? "N/A" : `${(cat_2.tokens / rawMaxTokens * 100).toFixed(1)}%`; - const isReserved = cat_2.name === RESERVED_CATEGORY_NAME; - const displayName = cat_2.name; - const symbol = cat_2.isDeferred ? " " : isReserved ? "\u26DD" : "\u26C1"; - return {symbol} {displayName}: {tokenDisplay} tokens ({percentDisplay}); - }; - $[38] = rawMaxTokens; - $[39] = t19; - } else { - t19 = $[39]; - } - const t20 = visibleCategories.map(t19); - let t21; - if ($[40] !== categories || $[41] !== rawMaxTokens) { - t21 = (categories.find(_temp6)?.tokens ?? 0) > 0 && Free space: {formatTokens(categories.find(_temp7)?.tokens || 0)}{" "}({((categories.find(_temp8)?.tokens || 0) / rawMaxTokens * 100).toFixed(1)}%); - $[40] = categories; - $[41] = rawMaxTokens; - $[42] = t21; - } else { - t21 = $[42]; - } - const t22 = autocompactCategory && autocompactCategory.tokens > 0 && {autocompactCategory.name}: {formatTokens(autocompactCategory.tokens)} tokens ({(autocompactCategory.tokens / rawMaxTokens * 100).toFixed(1)}%); - let t23; - if ($[43] !== t15 || $[44] !== t20 || $[45] !== t21 || $[46] !== t22) { - t23 = {t15}{t16}{t17}{t18}{t20}{t21}{t22}; - $[43] = t15; - $[44] = t20; - $[45] = t21; - $[46] = t22; - $[47] = t23; - } else { - t23 = $[47]; - } - if ($[48] !== t12 || $[49] !== t23) { - t9 = {t12}{t23}; - $[48] = t12; - $[49] = t23; - $[50] = t9; - } else { - t9 = $[50]; - } - T0 = Box; - t2 = "column"; - t3 = -1; - if ($[51] !== hasDeferredMcpTools || $[52] !== mcpTools) { - t4 = mcpTools.length > 0 && MCP tools{" "}· /mcp{hasDeferredMcpTools ? " (loaded on-demand)" : ""}{mcpTools.some(_temp9) && Loaded{mcpTools.filter(_temp0).map(_temp1)}}{hasDeferredMcpTools && mcpTools.some(_temp10) && Available{mcpTools.filter(_temp11).map(_temp12)}}{!hasDeferredMcpTools && mcpTools.map(_temp13)}; - $[51] = hasDeferredMcpTools; - $[52] = mcpTools; - $[53] = t4; - } else { - t4 = $[53]; - } - t5 = (systemTools && systemTools.length > 0 || hasDeferredBuiltinTools) && false && [ANT-ONLY] System tools{hasDeferredBuiltinTools && (some loaded on-demand)}Loaded{systemTools?.map(_temp14)}{deferredBuiltinTools.filter(_temp15).map(_temp16)}{hasDeferredBuiltinTools && deferredBuiltinTools.some(_temp17) && Available{deferredBuiltinTools.filter(_temp18).map(_temp19)}}; - $[0] = categories; - $[1] = gridRows; - $[2] = mcpTools; - $[3] = model; - $[4] = percentage; - $[5] = rawMaxTokens; - $[6] = systemTools; - $[7] = t1; - $[8] = totalTokens; - $[9] = T0; - $[10] = T1; - $[11] = t2; - $[12] = t3; - $[13] = t4; - $[14] = t5; - $[15] = t6; - $[16] = t7; - $[17] = t8; - $[18] = t9; - } else { - T0 = $[9]; - T1 = $[10]; - t2 = $[11]; - t3 = $[12]; - t4 = $[13]; - t5 = $[14]; - t6 = $[15]; - t7 = $[16]; - t8 = $[17]; - t9 = $[18]; - } - let t10; - if ($[54] !== systemPromptSections) { - t10 = systemPromptSections && systemPromptSections.length > 0 && false && [ANT-ONLY] System prompt sections{systemPromptSections.map(_temp20)}; - $[54] = systemPromptSections; - $[55] = t10; - } else { - t10 = $[55]; - } - let t11; - if ($[56] !== agents) { - t11 = agents.length > 0 && Custom agents · /agents{Array.from(groupBySource(agents).entries()).map(_temp22)}; - $[56] = agents; - $[57] = t11; - } else { - t11 = $[57]; - } - let t12; - if ($[58] !== memoryFiles) { - t12 = memoryFiles.length > 0 && Memory files · /memory{memoryFiles.map(_temp23)}; - $[58] = memoryFiles; - $[59] = t12; - } else { - t12 = $[59]; - } - let t13; - if ($[60] !== skills) { - t13 = skills && skills.tokens > 0 && Skills · /skills{Array.from(groupBySource(skills.skillFrontmatter).entries()).map(_temp25)}; - $[60] = skills; - $[61] = t13; - } else { - t13 = $[61]; - } - let t14; - if ($[62] !== messageBreakdown) { - t14 = messageBreakdown && false && [ANT-ONLY] Message breakdownTool calls: {formatTokens(messageBreakdown.toolCallTokens)} tokensTool results: {formatTokens(messageBreakdown.toolResultTokens)} tokensAttachments: {formatTokens(messageBreakdown.attachmentTokens)} tokensAssistant messages (non-tool): {formatTokens(messageBreakdown.assistantMessageTokens)} tokensUser messages (non-tool-result): {formatTokens(messageBreakdown.userMessageTokens)} tokens{messageBreakdown.toolCallsByType.length > 0 && [ANT-ONLY] Top tools{messageBreakdown.toolCallsByType.slice(0, 5).map(_temp26)}}{messageBreakdown.attachmentsByType.length > 0 && [ANT-ONLY] Top attachments{messageBreakdown.attachmentsByType.slice(0, 5).map(_temp27)}}; - $[62] = messageBreakdown; - $[63] = t14; - } else { - t14 = $[63]; - } - let t15; - if ($[64] !== T0 || $[65] !== t10 || $[66] !== t11 || $[67] !== t12 || $[68] !== t13 || $[69] !== t14 || $[70] !== t2 || $[71] !== t3 || $[72] !== t4 || $[73] !== t5) { - t15 = {t4}{t5}{t10}{t11}{t12}{t13}{t14}; - $[64] = T0; - $[65] = t10; - $[66] = t11; - $[67] = t12; - $[68] = t13; - $[69] = t14; - $[70] = t2; - $[71] = t3; - $[72] = t4; - $[73] = t5; - $[74] = t15; - } else { - t15 = $[74]; - } - let t16; - if ($[75] !== data) { - t16 = generateContextSuggestions(data); - $[75] = data; - $[76] = t16; - } else { - t16 = $[76]; - } - let t17; - if ($[77] !== t16) { - t17 = ; - $[77] = t16; - $[78] = t17; - } else { - t17 = $[78]; - } - let t18; - if ($[79] !== T1 || $[80] !== t15 || $[81] !== t17 || $[82] !== t6 || $[83] !== t7 || $[84] !== t8 || $[85] !== t9) { - t18 = {t8}{t9}{t15}{t17}; - $[79] = T1; - $[80] = t15; - $[81] = t17; - $[82] = t6; - $[83] = t7; - $[84] = t8; - $[85] = t9; - $[86] = t18; - } else { - t18 = $[86]; - } - return t18; -} -function _temp27(attachment, i_10) { - return └ {attachment.name}: {formatTokens(attachment.tokens)} tokens; -} -function _temp26(tool_5, i_9) { - return └ {tool_5.name}: calls {formatTokens(tool_5.callTokens)}, results{" "}{formatTokens(tool_5.resultTokens)}; -} -function _temp25(t0) { - const [sourceDisplay_0, sourceSkills] = t0; - return {sourceDisplay_0}{sourceSkills.map(_temp24)}; -} -function _temp24(skill, i_8) { - return └ {skill.name}: {formatTokens(skill.tokens)} tokens; -} -function _temp23(file, i_7) { - return └ {getDisplayPath(file.path)}: {formatTokens(file.tokens)} tokens; -} -function _temp22(t0) { - const [sourceDisplay, sourceAgents] = t0; - return {sourceDisplay}{sourceAgents.map(_temp21)}; -} -function _temp21(agent, i_6) { - return └ {agent.agentType}: {formatTokens(agent.tokens)} tokens; -} -function _temp20(section, i_5) { - return └ {section.name}: {formatTokens(section.tokens)} tokens; -} -function _temp19(tool_4, i_4) { - return └ {tool_4.name}; -} -function _temp18(t_4) { - return !t_4.isLoaded; -} -function _temp17(t_5) { - return !t_5.isLoaded; -} -function _temp16(tool_3, i_3) { - return └ {tool_3.name}: {formatTokens(tool_3.tokens)} tokens; -} -function _temp15(t_3) { - return t_3.isLoaded; -} -function _temp14(tool_2, i_2) { - return └ {tool_2.name}: {formatTokens(tool_2.tokens)} tokens; -} -function _temp13(tool_1, i_1) { - return └ {tool_1.name}: {formatTokens(tool_1.tokens)} tokens; -} -function _temp12(tool_0, i_0) { - return └ {tool_0.name}; -} -function _temp11(t_1) { - return !t_1.isLoaded; -} -function _temp10(t_2) { - return !t_2.isLoaded; -} -function _temp1(tool, i) { - return └ {tool.name}: {formatTokens(tool.tokens)} tokens; -} -function _temp0(t) { - return t.isLoaded; -} -function _temp9(t_0) { - return t_0.isLoaded; -} -function _temp8(c_0) { - return c_0.name === "Free space"; -} -function _temp7(c) { - return c.name === "Free space"; -} -function _temp6(c_1) { - return c_1.name === "Free space"; -} -function _temp5(row, rowIndex) { - return {row.map(_temp4)}; -} -function _temp4(square, colIndex) { - if (square.categoryName === "Free space") { - return {"\u26F6 "}; - } - if (square.categoryName === RESERVED_CATEGORY_NAME) { - return {"\u26DD "}; - } - return {square.squareFullness >= 0.7 ? "\u26C1 " : "\u26C0 "}; -} -function _temp3(cat_1) { - return cat_1.name === RESERVED_CATEGORY_NAME; -} -function _temp2(cat_0) { - return cat_0.isDeferred && cat_0.name.includes("MCP"); -} -function _temp(cat) { - return cat.tokens > 0 && cat.name !== "Free space" && cat.name !== RESERVED_CATEGORY_NAME && !cat.isDeferred; + messageBreakdown, + } = data + + // Filter out categories with 0 tokens for the legend, and exclude Free space, Autocompact buffer, and deferred + const visibleCategories = categories.filter( + cat => + cat.tokens > 0 && + cat.name !== 'Free space' && + cat.name !== RESERVED_CATEGORY_NAME && + !cat.isDeferred, + ) + // Check if MCP tools are deferred (loaded on-demand via tool search) + const hasDeferredMcpTools = categories.some( + cat => cat.isDeferred && cat.name.includes('MCP'), + ) + // Check if builtin tools are deferred + const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0 + const autocompactCategory = categories.find( + cat => cat.name === RESERVED_CATEGORY_NAME, + ) + + return ( + + Context Usage + + {/* Fixed size grid */} + + {gridRows.map((row, rowIndex) => ( + + {row.map((square, colIndex) => { + if (square.categoryName === 'Free space') { + return ( + + {'⛶ '} + + ) + } + if (square.categoryName === RESERVED_CATEGORY_NAME) { + return ( + + {'⛝ '} + + ) + } + return ( + + {square.squareFullness >= 0.7 ? '⛁ ' : '⛀ '} + + ) + })} + + ))} + + + {/* Legend to the right */} + + + {model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)}{' '} + tokens ({percentage}%) + + + + + Estimated usage by category + + {visibleCategories.map((cat, index) => { + const tokenDisplay = formatTokens(cat.tokens) + // Show "N/A" for deferred categories since they don't count toward context + const percentDisplay = cat.isDeferred + ? 'N/A' + : `${((cat.tokens / rawMaxTokens) * 100).toFixed(1)}%` + const isReserved = cat.name === RESERVED_CATEGORY_NAME + const displayName = cat.name + // Deferred categories don't appear in grid, so show blank instead of symbol + const symbol = cat.isDeferred ? ' ' : isReserved ? '⛝' : '⛁' + + return ( + + {symbol} + {displayName}: + + {tokenDisplay} tokens ({percentDisplay}) + + + ) + })} + {(categories.find(c => c.name === 'Free space')?.tokens ?? 0) > 0 && ( + + + Free space: + + {formatTokens( + categories.find(c => c.name === 'Free space')?.tokens || 0, + )}{' '} + ( + {( + ((categories.find(c => c.name === 'Free space')?.tokens || + 0) / + rawMaxTokens) * + 100 + ).toFixed(1)} + %) + + + )} + {autocompactCategory && autocompactCategory.tokens > 0 && ( + + + {autocompactCategory.name}: + + {formatTokens(autocompactCategory.tokens)} tokens ( + {((autocompactCategory.tokens / rawMaxTokens) * 100).toFixed(1)} + %) + + + )} + + + + + {mcpTools.length > 0 && ( + + + MCP tools + + {' '} + · /mcp{hasDeferredMcpTools ? ' (loaded on-demand)' : ''} + + + {/* Show loaded tools first */} + {mcpTools.some(t => t.isLoaded) && ( + + Loaded + {mcpTools + .filter(t => t.isLoaded) + .map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + + )} + {/* Show available (deferred) tools */} + {hasDeferredMcpTools && mcpTools.some(t => !t.isLoaded) && ( + + Available + {mcpTools + .filter(t => !t.isLoaded) + .map((tool, i) => ( + + └ {tool.name} + + ))} + + )} + {/* Show all tools normally when not deferred */} + {!hasDeferredMcpTools && + mcpTools.map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + + )} + + {/* Show builtin tools: always-loaded + deferred (ant-only) */} + {((systemTools && systemTools.length > 0) || hasDeferredBuiltinTools) && + process.env.USER_TYPE === 'ant' && ( + + + [ANT-ONLY] System tools + {hasDeferredBuiltinTools && ( + (some loaded on-demand) + )} + + {/* Always-loaded + deferred-but-loaded tools */} + + Loaded + {systemTools?.map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + {deferredBuiltinTools + .filter(t => t.isLoaded) + .map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + + {/* Deferred (not yet loaded) tools */} + {hasDeferredBuiltinTools && + deferredBuiltinTools.some(t => !t.isLoaded) && ( + + Available + {deferredBuiltinTools + .filter(t => !t.isLoaded) + .map((tool, i) => ( + + └ {tool.name} + + ))} + + )} + + )} + + {systemPromptSections && + systemPromptSections.length > 0 && + process.env.USER_TYPE === 'ant' && ( + + [ANT-ONLY] System prompt sections + {systemPromptSections.map((section, i) => ( + + └ {section.name}: + {formatTokens(section.tokens)} tokens + + ))} + + )} + + {agents.length > 0 && ( + + + Custom agents + · /agents + + {Array.from(groupBySource(agents).entries()).map( + ([sourceDisplay, sourceAgents]) => ( + + {sourceDisplay} + {sourceAgents.map((agent, i) => ( + + └ {agent.agentType}: + {formatTokens(agent.tokens)} tokens + + ))} + + ), + )} + + )} + + {memoryFiles.length > 0 && ( + + + Memory files + · /memory + + {memoryFiles.map((file, i) => ( + + └ {getDisplayPath(file.path)}: + {formatTokens(file.tokens)} tokens + + ))} + + )} + + {skills && skills.tokens > 0 && ( + + + Skills + · /skills + + {Array.from(groupBySource(skills.skillFrontmatter).entries()).map( + ([sourceDisplay, sourceSkills]) => ( + + {sourceDisplay} + {sourceSkills.map((skill, i) => ( + + └ {skill.name}: + {formatTokens(skill.tokens)} tokens + + ))} + + ), + )} + + )} + + {messageBreakdown && process.env.USER_TYPE === 'ant' && ( + + [ANT-ONLY] Message breakdown + + + + Tool calls: + + {formatTokens(messageBreakdown.toolCallTokens)} tokens + + + + + Tool results: + + {formatTokens(messageBreakdown.toolResultTokens)} tokens + + + + + Attachments: + + {formatTokens(messageBreakdown.attachmentTokens)} tokens + + + + + Assistant messages (non-tool): + + {formatTokens(messageBreakdown.assistantMessageTokens)} tokens + + + + + User messages (non-tool-result): + + {formatTokens(messageBreakdown.userMessageTokens)} tokens + + + + + {messageBreakdown.toolCallsByType.length > 0 && ( + + [ANT-ONLY] Top tools + {messageBreakdown.toolCallsByType.slice(0, 5).map((tool, i) => ( + + └ {tool.name}: + + calls {formatTokens(tool.callTokens)}, results{' '} + {formatTokens(tool.resultTokens)} + + + ))} + + )} + + {messageBreakdown.attachmentsByType.length > 0 && ( + + [ANT-ONLY] Top attachments + {messageBreakdown.attachmentsByType + .slice(0, 5) + .map((attachment, i) => ( + + └ {attachment.name}: + + {formatTokens(attachment.tokens)} tokens + + + ))} + + )} + + )} + + + + ) } diff --git a/src/components/CoordinatorAgentStatus.tsx b/src/components/CoordinatorAgentStatus.tsx index 239095b5c..2aacb7d6a 100644 --- a/src/components/CoordinatorAgentStatus.tsx +++ b/src/components/CoordinatorAgentStatus.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * CoordinatorTaskPanel — Steerable list of background agents. * @@ -7,18 +6,28 @@ import { c as _c } from "react/compiler-runtime"; * always; a timestamp shows until passed. Enter to view/steer, x to dismiss. */ -import figures from 'figures'; -import * as React from 'react'; -import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text, wrapText } from '../ink.js'; -import { type AppState, useAppState, useSetAppState } from '../state/AppState.js'; -import { enterTeammateView, exitTeammateView } from '../state/teammateViewHelpers.js'; -import { isPanelAgentTask, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'; -import { formatDuration, formatNumber } from '../utils/format.js'; -import { evictTerminalTask } from '../utils/task/framework.js'; -import { isTerminalStatus } from './tasks/taskStatusUtils.js'; +import figures from 'figures' +import * as React from 'react' +import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text, wrapText } from '../ink.js' +import { + type AppState, + useAppState, + useSetAppState, +} from '../state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from '../state/teammateViewHelpers.js' +import { + isPanelAgentTask, + type LocalAgentTaskState, +} from '../tasks/LocalAgentTask/LocalAgentTask.js' +import { formatDuration, formatNumber } from '../utils/format.js' +import { evictTerminalTask } from '../utils/task/framework.js' +import { isTerminalStatus } from './tasks/taskStatusUtils.js' /** * Which panel-managed tasks currently have a visible row. @@ -28,51 +37,83 @@ import { isTerminalStatus } from './tasks/taskStatusUtils.js'; * the filter time-dependent. Shared by panel render, useCoordinatorTaskCount, * and index resolvers so the math can't drift. */ -export function getVisibleAgentTasks(tasks: AppState['tasks']): LocalAgentTaskState[] { - return Object.values(tasks).filter((t): t is LocalAgentTaskState => isPanelAgentTask(t) && t.evictAfter !== 0).sort((a, b) => a.startTime - b.startTime); +export function getVisibleAgentTasks( + tasks: AppState['tasks'], +): LocalAgentTaskState[] { + return Object.values(tasks) + .filter( + (t): t is LocalAgentTaskState => + isPanelAgentTask(t) && t.evictAfter !== 0, + ) + .sort((a, b) => a.startTime - b.startTime) } + export function CoordinatorTaskPanel(): React.ReactNode { - const tasks = useAppState(s => s.tasks); - const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId); - const agentNameRegistry = useAppState(s_1 => s_1.agentNameRegistry); - const coordinatorTaskIndex = useAppState(s_2 => s_2.coordinatorTaskIndex); - const tasksSelected = useAppState(s_3 => s_3.footerSelection === 'tasks'); - const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined; - const setAppState = useSetAppState(); - const visibleTasks = getVisibleAgentTasks(tasks); - const hasTasks = Object.values(tasks).some(isPanelAgentTask); + const tasks = useAppState(s => s.tasks) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const agentNameRegistry = useAppState(s => s.agentNameRegistry) + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) + const tasksSelected = useAppState(s => s.footerSelection === 'tasks') + const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined + const setAppState = useSetAppState() + + const visibleTasks = getVisibleAgentTasks(tasks) + const hasTasks = Object.values(tasks).some(isPanelAgentTask) // 1s tick: re-render for elapsed time + evict tasks past their deadline. // The eviction deletes from prev.tasks, which makes useCoordinatorTaskCount // (and other consumers) see the updated count without their own tick. - const tasksRef = React.useRef(tasks); - tasksRef.current = tasks; - const [, setTick] = React.useState(0); + const tasksRef = React.useRef(tasks) + tasksRef.current = tasks + const [, setTick] = React.useState(0) React.useEffect(() => { - if (!hasTasks) return; - const interval = setInterval((tasksRef_0, setAppState_0, setTick_0) => { - const now = Date.now(); - for (const t of Object.values(tasksRef_0.current)) { - if (isPanelAgentTask(t) && (t.evictAfter ?? Infinity) <= now) { - evictTerminalTask(t.id, setAppState_0); + if (!hasTasks) return + const interval = setInterval( + (tasksRef, setAppState, setTick) => { + const now = Date.now() + for (const t of Object.values(tasksRef.current)) { + if (isPanelAgentTask(t) && (t.evictAfter ?? Infinity) <= now) { + evictTerminalTask(t.id, setAppState) + } } - } - setTick_0((prev: number) => prev + 1); - }, 1000, tasksRef, setAppState, setTick); - return () => clearInterval(interval); - }, [hasTasks, setAppState]); + setTick((prev: number) => prev + 1) + }, + 1000, + tasksRef, + setAppState, + setTick, + ) + return () => clearInterval(interval) + }, [hasTasks, setAppState]) const nameByAgentId = React.useMemo(() => { - const inv = new Map(); - for (const [n, id] of agentNameRegistry) inv.set(id, n); - return inv; - }, [agentNameRegistry]); + const inv = new Map() + for (const [n, id] of agentNameRegistry) inv.set(id, n) + return inv + }, [agentNameRegistry]) + if (visibleTasks.length === 0) { - return null; + return null } - return - exitTeammateView(setAppState)} /> - {visibleTasks.map((task, i) => enterTeammateView(task.id, setAppState)} />)} - ; + + return ( + + exitTeammateView(setAppState)} + /> + {visibleTasks.map((task, i) => ( + enterTeammateView(task.id, setAppState)} + /> + ))} + + ) } /** @@ -80,193 +121,137 @@ export function CoordinatorTaskPanel(): React.ReactNode { * The panel's 1s tick evicts expired tasks from prev.tasks, so this count * stays accurate without needing its own tick. */ -export function useCoordinatorTaskCount() { - const tasks = useAppState(_temp); - let t0; - t0 = 0; - return t0; +export function useCoordinatorTaskCount(): number { + const tasks = useAppState(s => s.tasks) + return React.useMemo(() => { + if ("external" !== 'ant') return 0 + const count = getVisibleAgentTasks(tasks).length + return count > 0 ? count + 1 : 0 + }, [tasks]) } -function _temp(s) { - return s.tasks; -} -function MainLine(t0) { - const $ = _c(10); - const { - isSelected, - isViewed, - onClick - } = t0; - const [hover, setHover] = React.useState(false); - const prefix = isSelected || hover ? figures.pointer + " " : " "; - const bullet = isViewed ? BLACK_CIRCLE : figures.circle; - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setHover(true); - t2 = () => setHover(false); - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - const t3 = !isSelected && !isViewed && !hover; - let t4; - if ($[2] !== bullet || $[3] !== isViewed || $[4] !== prefix || $[5] !== t3) { - t4 = {prefix}{bullet} main; - $[2] = bullet; - $[3] = isViewed; - $[4] = prefix; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== onClick || $[8] !== t4) { - t5 = {t4}; - $[7] = onClick; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + +function MainLine({ + isSelected, + isViewed, + onClick, +}: { + isSelected?: boolean + isViewed?: boolean + onClick: () => void +}): React.ReactNode { + const [hover, setHover] = React.useState(false) + const prefix = isSelected || hover ? figures.pointer + ' ' : ' ' + const bullet = isViewed ? BLACK_CIRCLE : figures.circle + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {prefix} + {bullet} main + + + ) } + type AgentLineProps = { - task: LocalAgentTaskState; - name?: string; - isSelected?: boolean; - isViewed?: boolean; - onClick?: () => void; -}; -function AgentLine(t0) { - const $ = _c(32); - const { - task, - name, - isSelected, - isViewed, - onClick - } = t0; - const { - columns - } = useTerminalSize(); - const [hover, setHover] = React.useState(false); - const isRunning = !isTerminalStatus(task.status); - const pausedMs = task.totalPausedMs ?? 0; - const elapsedMs = Math.max(0, isRunning ? Date.now() - task.startTime - pausedMs : (task.endTime ?? task.startTime) - task.startTime - pausedMs); - let t1; - if ($[0] !== elapsedMs) { - t1 = formatDuration(elapsedMs); - $[0] = elapsedMs; - $[1] = t1; - } else { - t1 = $[1]; - } - const elapsed = t1; - const tokenCount = task.progress?.tokenCount; - const lastActivity = task.progress?.lastActivity; - const arrow = lastActivity ? figures.arrowDown : figures.arrowUp; - let t2; - if ($[2] !== arrow || $[3] !== tokenCount) { - t2 = tokenCount !== undefined && tokenCount > 0 ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` : ""; - $[2] = arrow; - $[3] = tokenCount; - $[4] = t2; - } else { - t2 = $[4]; - } - const tokenText = t2; - const queuedCount = task.pendingMessages.length; - const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : ""; - const displayDescription = task.progress?.summary || task.description; - const highlighted = isSelected || hover; - const prefix = highlighted ? figures.pointer + " " : " "; - const bullet = isViewed ? BLACK_CIRCLE : figures.circle; - const dim = !highlighted && !isViewed; - const sep = isRunning ? PLAY_ICON : PAUSE_ICON; - const namePart = name ? `${name}: ` : ""; - const hintPart = isSelected && !isViewed ? ` · x to ${isRunning ? "stop" : "clear"}` : ""; - const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}`; - const availableForDesc = columns - stringWidth(prefix) - stringWidth(`${bullet} `) - stringWidth(namePart) - stringWidth(suffixPart); - const t3 = Math.max(0, availableForDesc); - let t4; - if ($[5] !== displayDescription || $[6] !== t3) { - t4 = wrapText(displayDescription, t3, "truncate-end"); - $[5] = displayDescription; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - const truncated = t4; - let t5; - if ($[8] !== name) { - t5 = name && <>{name}{": "}; - $[8] = name; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== queuedCount || $[11] !== queuedText) { - t6 = queuedCount > 0 && {queuedText}; - $[10] = queuedCount; - $[11] = queuedText; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== hintPart) { - t7 = hintPart && {hintPart}; - $[13] = hintPart; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== bullet || $[16] !== dim || $[17] !== elapsed || $[18] !== isViewed || $[19] !== prefix || $[20] !== sep || $[21] !== t5 || $[22] !== t6 || $[23] !== t7 || $[24] !== tokenText || $[25] !== truncated) { - t8 = {prefix}{bullet}{" "}{t5}{truncated} {sep} {elapsed}{tokenText}{t6}{t7}; - $[15] = bullet; - $[16] = dim; - $[17] = elapsed; - $[18] = isViewed; - $[19] = prefix; - $[20] = sep; - $[21] = t5; - $[22] = t6; - $[23] = t7; - $[24] = tokenText; - $[25] = truncated; - $[26] = t8; - } else { - t8 = $[26]; - } - const line = t8; - if (!onClick) { - return line; - } - let t10; - let t9; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t9 = () => setHover(true); - t10 = () => setHover(false); - $[27] = t10; - $[28] = t9; - } else { - t10 = $[27]; - t9 = $[28]; - } - let t11; - if ($[29] !== line || $[30] !== onClick) { - t11 = {line}; - $[29] = line; - $[30] = onClick; - $[31] = t11; - } else { - t11 = $[31]; - } - return t11; + task: LocalAgentTaskState + name?: string + isSelected?: boolean + isViewed?: boolean + onClick?: () => void +} + +function AgentLine({ + task, + name, + isSelected, + isViewed, + onClick, +}: AgentLineProps): React.ReactNode { + const { columns } = useTerminalSize() + const [hover, setHover] = React.useState(false) + const isRunning = !isTerminalStatus(task.status) + const pausedMs = task.totalPausedMs ?? 0 + const elapsedMs = Math.max( + 0, + isRunning + ? Date.now() - task.startTime - pausedMs + : (task.endTime ?? task.startTime) - task.startTime - pausedMs, + ) + + const elapsed = formatDuration(elapsedMs) + const tokenCount = task.progress?.tokenCount + + // Derive direction arrow from activity state, same logic as Spinner + const lastActivity = task.progress?.lastActivity + const arrow = lastActivity ? figures.arrowDown : figures.arrowUp + + const tokenText = + tokenCount !== undefined && tokenCount > 0 + ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` + : '' + + const queuedCount = task.pendingMessages.length + const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : '' + + // Precedence: AI summary > static description (no tool-call activity noise) + const displayDescription = task.progress?.summary || task.description + + const highlighted = isSelected || hover + const prefix = highlighted ? figures.pointer + ' ' : ' ' + const bullet = isViewed ? BLACK_CIRCLE : figures.circle + const dim = !highlighted && !isViewed + + const sep = isRunning ? PLAY_ICON : PAUSE_ICON + // Name is the steering handle — kept out of truncation and undimmed so it + // stays readable even when the row is inactive. Short by convention (the + // Agent tool prompt asks for "one or two words, lowercase"). + const namePart = name ? `${name}: ` : '' + const hintPart = + isSelected && !isViewed ? ` · x to ${isRunning ? 'stop' : 'clear'}` : '' + const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}` + const availableForDesc = + columns - + stringWidth(prefix) - + stringWidth(`${bullet} `) - + stringWidth(namePart) - + stringWidth(suffixPart) + const truncated = wrapText( + displayDescription, + Math.max(0, availableForDesc), + 'truncate-end', + ) + + const line = ( + + {prefix} + {bullet}{' '} + {name && ( + <> + + {name} + + {': '} + + )} + {truncated} {sep} {elapsed} + {tokenText} + {queuedCount > 0 && {queuedText}} + {hintPart && {hintPart}} + + ) + + if (!onClick) return line + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {line} + + ) } diff --git a/src/components/CostThresholdDialog.tsx b/src/components/CostThresholdDialog.tsx index 8c9528073..584d864a3 100644 --- a/src/components/CostThresholdDialog.tsx +++ b/src/components/CostThresholdDialog.tsx @@ -1,49 +1,31 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Link, Text } from '../ink.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { Box, Link, Text } from '../ink.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onDone: () => void; -}; -export function CostThresholdDialog(t0) { - const $ = _c(7); - const { - onDone - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Learn more about how to monitor your spending:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = [{ - value: "ok", - label: "Got it, thanks!" - }]; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== onDone) { - t3 = + + ) } diff --git a/src/components/CtrlOToExpand.tsx b/src/components/CtrlOToExpand.tsx index b1232479e..24b4add81 100644 --- a/src/components/CtrlOToExpand.tsx +++ b/src/components/CtrlOToExpand.tsx @@ -1,50 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import React, { useContext } from 'react'; -import { Text } from '../ink.js'; -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { InVirtualListContext } from './messageActions.js'; +import chalk from 'chalk' +import React, { useContext } from 'react' +import { Text } from '../ink.js' +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { InVirtualListContext } from './messageActions.js' // Context to track if we're inside a sub agent // Similar to MessageResponseContext, this helps us avoid showing // too many "(ctrl+o to expand)" hints in sub agent output -const SubAgentContext = React.createContext(false); -export function SubAgentProvider(t0) { - const $ = _c(2); - const { - children - } = t0; - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const SubAgentContext = React.createContext(false) + +export function SubAgentProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + {children} + ) } -export function CtrlOToExpand() { - const $ = _c(2); - const isInSubAgent = useContext(SubAgentContext); - const inVirtualList = useContext(InVirtualListContext); - const expandShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); + +export function CtrlOToExpand(): React.ReactNode { + const isInSubAgent = useContext(SubAgentContext) + const inVirtualList = useContext(InVirtualListContext) + const expandShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) if (isInSubAgent || inVirtualList) { - return null; + return null } - let t0; - if ($[0] !== expandShortcut) { - t0 = ; - $[0] = expandShortcut; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; + return ( + + + + ) } + export function ctrlOToExpand(): string { - const shortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); - return chalk.dim(`(${shortcut} to expand)`); + const shortcut = getShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + return chalk.dim(`(${shortcut} to expand)`) } diff --git a/src/components/CustomSelect/SelectMulti.tsx b/src/components/CustomSelect/SelectMulti.tsx index 581146a6b..bb43e9e1e 100644 --- a/src/components/CustomSelect/SelectMulti.tsx +++ b/src/components/CustomSelect/SelectMulti.tsx @@ -1,69 +1,97 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import type { PastedContent } from '../../utils/config.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import type { OptionWithDescription } from './select.js'; -import { SelectInputOption } from './select-input-option.js'; -import { SelectOption } from './select-option.js'; -import { useMultiSelectState } from './use-multi-select-state.js'; +import figures from 'figures' +import React from 'react' +import { Box, Text } from '../../ink.js' +import type { PastedContent } from '../../utils/config.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import type { OptionWithDescription } from './select.js' +import { SelectInputOption } from './select-input-option.js' +import { SelectOption } from './select-option.js' +import { useMultiSelectState } from './use-multi-select-state.js' + export type SelectMultiProps = { - readonly isDisabled?: boolean; - readonly visibleOptionCount?: number; - readonly options: OptionWithDescription[]; - readonly defaultValue?: T[]; - readonly onCancel: () => void; - readonly onChange?: (values: T[]) => void; - readonly onFocus?: (value: T) => void; - readonly focusValue?: T; + readonly isDisabled?: boolean + readonly visibleOptionCount?: number + readonly options: OptionWithDescription[] + readonly defaultValue?: T[] + readonly onCancel: () => void + readonly onChange?: (values: T[]) => void + readonly onFocus?: (value: T) => void + readonly focusValue?: T /** * Text for the submit button. When provided, a submit button is shown and * Enter toggles selection (submit only fires when the button is focused). * When omitted, Enter submits directly and Space toggles selection. */ - readonly submitButtonText?: string; + readonly submitButtonText?: string /** * Callback when user submits. Receives the currently selected values. */ - readonly onSubmit?: (values: T[]) => void; + readonly onSubmit?: (values: T[]) => void /** * When true, hides the numeric indexes next to each option. */ - readonly hideIndexes?: boolean; + readonly hideIndexes?: boolean /** * Callback when user presses down from the last item (submit button). * If provided, navigation will not wrap to the first item. */ - readonly onDownFromLastItem?: () => void; + readonly onDownFromLastItem?: () => void /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ - readonly onUpFromFirstItem?: () => void; + readonly onUpFromFirstItem?: () => void /** * Focus the last option initially instead of the first. */ - readonly initialFocusLast?: boolean; + readonly initialFocusLast?: boolean /** * Callback to open external editor for editing input option values. * When provided, ctrl+g will trigger this callback in input options * with the current value and a setter function to update the internal state. */ - readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; - readonly onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; - readonly pastedContents?: Record; - readonly onRemoveImage?: (id: number) => void; -}; -export function SelectMulti(t0) { - const $ = _c(44); - const { - isDisabled: t1, - visibleOptionCount: t2, + readonly onOpenEditor?: ( + currentValue: string, + setValue: (value: string) => void, + ) => void + readonly onImagePaste?: ( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) => void + readonly pastedContents?: Record + readonly onRemoveImage?: (id: number) => void +} + +export function SelectMulti({ + isDisabled = false, + visibleOptionCount = 5, + options, + defaultValue = [], + onCancel, + onChange, + onFocus, + focusValue, + submitButtonText, + onSubmit, + onDownFromLastItem, + onUpFromFirstItem, + initialFocusLast, + onOpenEditor, + hideIndexes = false, + onImagePaste, + pastedContents, + onRemoveImage, +}: SelectMultiProps): React.ReactNode { + const state = useMultiSelectState({ + isDisabled, + visibleOptionCount, options, - defaultValue: t3, - onCancel, + defaultValue, onChange, + onCancel, onFocus, focusValue, submitButtonText, @@ -71,142 +99,111 @@ export function SelectMulti(t0) { onDownFromLastItem, onUpFromFirstItem, initialFocusLast, - onOpenEditor, - hideIndexes: t4, - onImagePaste, - pastedContents, - onRemoveImage - } = t0; - const isDisabled = t1 === undefined ? false : t1; - const visibleOptionCount = t2 === undefined ? 5 : t2; - let t5; - if ($[0] !== t3) { - t5 = t3 === undefined ? [] : t3; - $[0] = t3; - $[1] = t5; - } else { - t5 = $[1]; - } - const defaultValue = t5; - const hideIndexes = t4 === undefined ? false : t4; - let t6; - if ($[2] !== defaultValue || $[3] !== focusValue || $[4] !== hideIndexes || $[5] !== initialFocusLast || $[6] !== isDisabled || $[7] !== onCancel || $[8] !== onChange || $[9] !== onDownFromLastItem || $[10] !== onFocus || $[11] !== onSubmit || $[12] !== onUpFromFirstItem || $[13] !== options || $[14] !== submitButtonText || $[15] !== visibleOptionCount) { - t6 = { - isDisabled, - visibleOptionCount, - options, - defaultValue, - onChange, - onCancel, - onFocus, - focusValue, - submitButtonText, - onSubmit, - onDownFromLastItem, - onUpFromFirstItem, - initialFocusLast, - hideIndexes - }; - $[2] = defaultValue; - $[3] = focusValue; - $[4] = hideIndexes; - $[5] = initialFocusLast; - $[6] = isDisabled; - $[7] = onCancel; - $[8] = onChange; - $[9] = onDownFromLastItem; - $[10] = onFocus; - $[11] = onSubmit; - $[12] = onUpFromFirstItem; - $[13] = options; - $[14] = submitButtonText; - $[15] = visibleOptionCount; - $[16] = t6; - } else { - t6 = $[16]; - } - const state = useMultiSelectState(t6); - let T0; - let T1; - let t7; - let t8; - let t9; - if ($[17] !== hideIndexes || $[18] !== isDisabled || $[19] !== onCancel || $[20] !== onImagePaste || $[21] !== onOpenEditor || $[22] !== onRemoveImage || $[23] !== options.length || $[24] !== pastedContents || $[25] !== state) { - const maxIndexWidth = options.length.toString().length; - T1 = Box; - t9 = "column"; - T0 = Box; - t7 = "column"; - t8 = state.visibleOptions.map((option, index) => { - const isOptionFocused = !isDisabled && state.focusedValue === option.value && !state.isSubmitFocused; - const isSelected = state.selectedValues.includes(option.value); - const isFirstVisibleOption = option.index === state.visibleFromIndex; - const isLastVisibleOption = option.index === state.visibleToIndex - 1; - const areMoreOptionsBelow = state.visibleToIndex < options.length; - const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1; - if (option.type === "input") { - const inputValue = state.inputValues.get(option.value) || ""; - return { - state.updateInputValue(option.value, value); - }} onSubmit={_temp} onExit={() => { - onCancel(); - }} layout="compact" onOpenEditor={onOpenEditor} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage}>[{isSelected ? figures.tick : " "}]{" "}; - } - return {!hideIndexes && {`${i}.`.padEnd(maxIndexWidth)}}[{isSelected ? figures.tick : " "}]{option.label}; - }); - $[17] = hideIndexes; - $[18] = isDisabled; - $[19] = onCancel; - $[20] = onImagePaste; - $[21] = onOpenEditor; - $[22] = onRemoveImage; - $[23] = options.length; - $[24] = pastedContents; - $[25] = state; - $[26] = T0; - $[27] = T1; - $[28] = t7; - $[29] = t8; - $[30] = t9; - } else { - T0 = $[26]; - T1 = $[27]; - t7 = $[28]; - t8 = $[29]; - t9 = $[30]; - } - let t10; - if ($[31] !== T0 || $[32] !== t7 || $[33] !== t8) { - t10 = {t8}; - $[31] = T0; - $[32] = t7; - $[33] = t8; - $[34] = t10; - } else { - t10 = $[34]; - } - let t11; - if ($[35] !== onSubmit || $[36] !== state.isSubmitFocused || $[37] !== submitButtonText) { - t11 = submitButtonText && onSubmit && {state.isSubmitFocused ? {figures.pointer} : }{submitButtonText}; - $[35] = onSubmit; - $[36] = state.isSubmitFocused; - $[37] = submitButtonText; - $[38] = t11; - } else { - t11 = $[38]; - } - let t12; - if ($[39] !== T1 || $[40] !== t10 || $[41] !== t11 || $[42] !== t9) { - t12 = {t10}{t11}; - $[39] = T1; - $[40] = t10; - $[41] = t11; - $[42] = t9; - $[43] = t12; - } else { - t12 = $[43]; - } - return t12; + hideIndexes, + }) + + const maxIndexWidth = options.length.toString().length + + return ( + + + {state.visibleOptions.map((option, index) => { + const isOptionFocused = + !isDisabled && + state.focusedValue === option.value && + !state.isSubmitFocused + const isSelected = state.selectedValues.includes(option.value) + + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + if (option.type === 'input') { + const inputValue = state.inputValues.get(option.value) || '' + + return ( + + { + state.updateInputValue(option.value, value) + }} + onSubmit={() => {}} /* We handle submit higher up */ + onExit={() => { + onCancel() + }} + layout="compact" + onOpenEditor={onOpenEditor} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + > + + [{isSelected ? figures.tick : ' '}]{' '} + + + + ) + } + + return ( + + + {!hideIndexes && ( + {`${i}.`.padEnd(maxIndexWidth)} + )} + + [{isSelected ? figures.tick : ' '}] + + + {option.label} + + + + ) + })} + + {submitButtonText && onSubmit && ( + + {state.isSubmitFocused ? ( + {figures.pointer} + ) : ( + + )} + + + {submitButtonText} + + + + )} + + ) } -function _temp() {} diff --git a/src/components/CustomSelect/select-input-option.tsx b/src/components/CustomSelect/select-input-option.tsx index cd959c8f0..0f3f9483f 100644 --- a/src/components/CustomSelect/select-input-option.tsx +++ b/src/components/CustomSelect/select-input-option.tsx @@ -1,487 +1,412 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useEffect, useRef, useState } from 'react'; +import React, { type ReactNode, useEffect, useRef, useState } from 'react' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- UP arrow exit not in Attachments bindings -import { Box, Text, useInput } from '../../ink.js'; -import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { PastedContent } from '../../utils/config.js'; -import { getImageFromClipboard } from '../../utils/imagePaste.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import { ClickableImageRef } from '../ClickableImageRef.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import TextInput from '../TextInput.js'; -import type { OptionWithDescription } from './select.js'; -import { SelectOption } from './select-option.js'; +import { Box, Text, useInput } from '../../ink.js' +import { + useKeybinding, + useKeybindings, +} from '../../keybindings/useKeybinding.js' +import type { PastedContent } from '../../utils/config.js' +import { getImageFromClipboard } from '../../utils/imagePaste.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import { ClickableImageRef } from '../ClickableImageRef.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import TextInput from '../TextInput.js' +import type { OptionWithDescription } from './select.js' +import { SelectOption } from './select-option.js' + type Props = { - option: Extract, { - type: 'input'; - }>; - isFocused: boolean; - isSelected: boolean; - shouldShowDownArrow: boolean; - shouldShowUpArrow: boolean; - maxIndexWidth: number; - index: number; - inputValue: string; - onInputChange: (value: string) => void; - onSubmit: (value: string) => void; - onExit?: () => void; - layout: 'compact' | 'expanded'; - children?: ReactNode; + option: Extract, { type: 'input' }> + isFocused: boolean + isSelected: boolean + shouldShowDownArrow: boolean + shouldShowUpArrow: boolean + maxIndexWidth: number + index: number + inputValue: string + onInputChange: (value: string) => void + onSubmit: (value: string) => void + onExit?: () => void + layout: 'compact' | 'expanded' + children?: ReactNode /** * When true, shows the label before the input field. * When false (default), uses the label as the placeholder. */ - showLabel?: boolean; + showLabel?: boolean /** * Callback to open external editor for editing the input value. * When provided, ctrl+g will trigger this callback with the current value * and a setter function to update the internal state. */ - onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; + onOpenEditor?: ( + currentValue: string, + setValue: (value: string) => void, + ) => void /** * When true, automatically reset cursor to end of line when: * - Option becomes focused * - Input value changes * This prevents cursor position bugs when the input value updates asynchronously. */ - resetCursorOnUpdate?: boolean; + resetCursorOnUpdate?: boolean /** * Optional callback when an image is pasted into the input. */ - onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; + onImagePaste?: ( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) => void /** * Pasted content to display inline above the input when focused. */ - pastedContents?: Record; + pastedContents?: Record /** * Callback to remove a pasted image by its ID. */ - onRemoveImage?: (id: number) => void; + onRemoveImage?: (id: number) => void /** * Whether image selection mode is active. */ - imagesSelected?: boolean; + imagesSelected?: boolean /** * Currently selected image index within the image attachments array. */ - selectedImageIndex?: number; + selectedImageIndex?: number /** * Callback to set image selection mode on/off. */ - onImagesSelectedChange?: (selected: boolean) => void; + onImagesSelectedChange?: (selected: boolean) => void /** * Callback to change the selected image index. */ - onSelectedImageIndexChange?: (index: number) => void; -}; -export function SelectInputOption(t0) { - const $ = _c(100); - const { - option, - isFocused, - isSelected, - shouldShowDownArrow, - shouldShowUpArrow, - maxIndexWidth, - index, - inputValue, - onInputChange, - onSubmit, - onExit, - layout, - children, - showLabel: t1, - onOpenEditor, - resetCursorOnUpdate: t2, - onImagePaste, - pastedContents, - onRemoveImage, - imagesSelected, - selectedImageIndex: t3, - onImagesSelectedChange, - onSelectedImageIndexChange - } = t0; - const showLabelProp = t1 === undefined ? false : t1; - const resetCursorOnUpdate = t2 === undefined ? false : t2; - const selectedImageIndex = t3 === undefined ? 0 : t3; - let t4; - if ($[0] !== pastedContents) { - t4 = pastedContents ? Object.values(pastedContents).filter(_temp) : []; - $[0] = pastedContents; - $[1] = t4; - } else { - t4 = $[1]; - } - const imageAttachments = t4; - const showLabel = showLabelProp || option.showLabelWithValue === true; - const [cursorOffset, setCursorOffset] = useState(inputValue.length); - const isUserEditing = useRef(false); - let t5; - if ($[2] !== inputValue.length || $[3] !== isFocused || $[4] !== resetCursorOnUpdate) { - t5 = () => { - if (resetCursorOnUpdate && isFocused) { - if (isUserEditing.current) { - isUserEditing.current = false; - } else { - setCursorOffset(inputValue.length); - } - } - }; - $[2] = inputValue.length; - $[3] = isFocused; - $[4] = resetCursorOnUpdate; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== inputValue || $[7] !== isFocused || $[8] !== resetCursorOnUpdate) { - t6 = [resetCursorOnUpdate, isFocused, inputValue]; - $[6] = inputValue; - $[7] = isFocused; - $[8] = resetCursorOnUpdate; - $[9] = t6; - } else { - t6 = $[9]; - } - useEffect(t5, t6); - let t7; - if ($[10] !== inputValue || $[11] !== onInputChange || $[12] !== onOpenEditor) { - t7 = () => { - onOpenEditor?.(inputValue, onInputChange); - }; - $[10] = inputValue; - $[11] = onInputChange; - $[12] = onOpenEditor; - $[13] = t7; - } else { - t7 = $[13]; - } - const t8 = isFocused && !!onOpenEditor; - let t9; - if ($[14] !== t8) { - t9 = { - context: "Chat", - isActive: t8 - }; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - useKeybinding("chat:externalEditor", t7, t9); - let t10; - if ($[16] !== onImagePaste) { - t10 = () => { - if (!onImagePaste) { - return; + onSelectedImageIndexChange?: (index: number) => void +} + +export function SelectInputOption({ + option, + isFocused, + isSelected, + shouldShowDownArrow, + shouldShowUpArrow, + maxIndexWidth, + index, + inputValue, + onInputChange, + onSubmit, + onExit, + layout, + children, + showLabel: showLabelProp = false, + onOpenEditor, + resetCursorOnUpdate = false, + onImagePaste, + pastedContents, + onRemoveImage, + imagesSelected, + selectedImageIndex = 0, + onImagesSelectedChange, + onSelectedImageIndexChange, +}: Props): React.ReactNode { + const imageAttachments = pastedContents + ? Object.values(pastedContents).filter(c => c.type === 'image') + : [] + + // Allow individual options to force showing the label via showLabelWithValue + const showLabel = showLabelProp || option.showLabelWithValue === true + const [cursorOffset, setCursorOffset] = useState(inputValue.length) + + // Track whether the latest inputValue change was from user typing/pasting, + // so we can skip resetting cursor to end on user-initiated changes. + const isUserEditing = useRef(false) + + // Reset cursor to end of line when: + // 1. Option becomes focused (user navigates to it) + // 2. Input value changes externally (e.g., async classifier description updates) + // Skip reset when the change was from user typing (which sets isUserEditing ref) + // Only enabled when resetCursorOnUpdate prop is true + useEffect(() => { + if (resetCursorOnUpdate && isFocused) { + if (isUserEditing.current) { + isUserEditing.current = false + } else { + setCursorOffset(inputValue.length) } - getImageFromClipboard().then(imageData => { + } + }, [resetCursorOnUpdate, isFocused, inputValue]) + + // ctrl+g to open external editor (reuses chat:externalEditor keybinding) + useKeybinding( + 'chat:externalEditor', + () => { + onOpenEditor?.(inputValue, onInputChange) + }, + { context: 'Chat', isActive: isFocused && !!onOpenEditor }, + ) + + // ctrl+v to paste image from clipboard (same as PromptInput) + useKeybinding( + 'chat:imagePaste', + () => { + if (!onImagePaste) return + void getImageFromClipboard().then(imageData => { if (imageData) { - onImagePaste(imageData.base64, imageData.mediaType, undefined, imageData.dimensions); + onImagePaste( + imageData.base64, + imageData.mediaType, + undefined, + imageData.dimensions, + ) } - }); - }; - $[16] = onImagePaste; - $[17] = t10; - } else { - t10 = $[17]; - } - const t11 = isFocused && !!onImagePaste; - let t12; - if ($[18] !== t11) { - t12 = { - context: "Chat", - isActive: t11 - }; - $[18] = t11; - $[19] = t12; - } else { - t12 = $[19]; - } - useKeybinding("chat:imagePaste", t10, t12); - let t13; - if ($[20] !== imageAttachments || $[21] !== onRemoveImage) { - t13 = () => { + }) + }, + { context: 'Chat', isActive: isFocused && !!onImagePaste }, + ) + + // Backspace with empty input removes the last pasted image (non-image-selection mode) + useKeybinding( + 'attachments:remove', + () => { if (imageAttachments.length > 0 && onRemoveImage) { - onRemoveImage(imageAttachments.at(-1).id); - } - }; - $[20] = imageAttachments; - $[21] = onRemoveImage; - $[22] = t13; - } else { - t13 = $[22]; - } - const t14 = isFocused && !imagesSelected && inputValue === "" && imageAttachments.length > 0 && !!onRemoveImage; - let t15; - if ($[23] !== t14) { - t15 = { - context: "Attachments", - isActive: t14 - }; - $[23] = t14; - $[24] = t15; - } else { - t15 = $[24]; - } - useKeybinding("attachments:remove", t13, t15); - let t16; - let t17; - if ($[25] !== imageAttachments.length || $[26] !== onSelectedImageIndexChange || $[27] !== selectedImageIndex) { - t16 = () => { - if (imageAttachments.length > 1) { - onSelectedImageIndexChange?.((selectedImageIndex + 1) % imageAttachments.length); - } - }; - t17 = () => { - if (imageAttachments.length > 1) { - onSelectedImageIndexChange?.((selectedImageIndex - 1 + imageAttachments.length) % imageAttachments.length); + onRemoveImage(imageAttachments.at(-1)!.id) } - }; - $[25] = imageAttachments.length; - $[26] = onSelectedImageIndexChange; - $[27] = selectedImageIndex; - $[28] = t16; - $[29] = t17; - } else { - t16 = $[28]; - t17 = $[29]; - } - let t18; - if ($[30] !== imageAttachments || $[31] !== onImagesSelectedChange || $[32] !== onRemoveImage || $[33] !== onSelectedImageIndexChange || $[34] !== selectedImageIndex) { - t18 = () => { - const img = imageAttachments[selectedImageIndex]; - if (img && onRemoveImage) { - onRemoveImage(img.id); - if (imageAttachments.length <= 1) { - onImagesSelectedChange?.(false); - } else { - onSelectedImageIndexChange?.(Math.min(selectedImageIndex, imageAttachments.length - 2)); + }, + { + context: 'Attachments', + isActive: + isFocused && + !imagesSelected && + inputValue === '' && + imageAttachments.length > 0 && + !!onRemoveImage, + }, + ) + + // Image selection mode keybindings — reuses existing Attachments actions + useKeybindings( + { + 'attachments:next': () => { + if (imageAttachments.length > 1) { + onSelectedImageIndexChange?.( + (selectedImageIndex + 1) % imageAttachments.length, + ) } - } - }; - $[30] = imageAttachments; - $[31] = onImagesSelectedChange; - $[32] = onRemoveImage; - $[33] = onSelectedImageIndexChange; - $[34] = selectedImageIndex; - $[35] = t18; - } else { - t18 = $[35]; - } - let t19; - if ($[36] !== onImagesSelectedChange) { - t19 = () => { - onImagesSelectedChange?.(false); - }; - $[36] = onImagesSelectedChange; - $[37] = t19; - } else { - t19 = $[37]; - } - let t20; - if ($[38] !== t16 || $[39] !== t17 || $[40] !== t18 || $[41] !== t19) { - t20 = { - "attachments:next": t16, - "attachments:previous": t17, - "attachments:remove": t18, - "attachments:exit": t19 - }; - $[38] = t16; - $[39] = t17; - $[40] = t18; - $[41] = t19; - $[42] = t20; - } else { - t20 = $[42]; - } - const t21 = isFocused && !!imagesSelected; - let t22; - if ($[43] !== t21) { - t22 = { - context: "Attachments", - isActive: t21 - }; - $[43] = t21; - $[44] = t22; - } else { - t22 = $[44]; - } - useKeybindings(t20, t22); - let t23; - if ($[45] !== onImagesSelectedChange) { - t23 = (_input, key) => { + }, + 'attachments:previous': () => { + if (imageAttachments.length > 1) { + onSelectedImageIndexChange?.( + (selectedImageIndex - 1 + imageAttachments.length) % + imageAttachments.length, + ) + } + }, + 'attachments:remove': () => { + const img = imageAttachments[selectedImageIndex] + if (img && onRemoveImage) { + onRemoveImage(img.id) + // If no images left after removal, exit image selection + if (imageAttachments.length <= 1) { + onImagesSelectedChange?.(false) + } else { + // Adjust index if we deleted the last image + onSelectedImageIndexChange?.( + Math.min(selectedImageIndex, imageAttachments.length - 2), + ) + } + } + }, + 'attachments:exit': () => { + onImagesSelectedChange?.(false) + }, + }, + { context: 'Attachments', isActive: isFocused && !!imagesSelected }, + ) + + // UP arrow exits image selection mode (UP isn't bound to attachments:exit) + useInput( + (_input, key) => { if (key.upArrow) { - onImagesSelectedChange?.(false); + onImagesSelectedChange?.(false) } - }; - $[45] = onImagesSelectedChange; - $[46] = t23; - } else { - t23 = $[46]; - } - const t24 = isFocused && !!imagesSelected; - let t25; - if ($[47] !== t24) { - t25 = { - isActive: t24 - }; - $[47] = t24; - $[48] = t25; - } else { - t25 = $[48]; - } - useInput(t23, t25); - let t26; - let t27; - if ($[49] !== imagesSelected || $[50] !== isFocused || $[51] !== onImagesSelectedChange) { - t26 = () => { - if (!isFocused && imagesSelected) { - onImagesSelectedChange?.(false); - } - }; - t27 = [isFocused, imagesSelected, onImagesSelectedChange]; - $[49] = imagesSelected; - $[50] = isFocused; - $[51] = onImagesSelectedChange; - $[52] = t26; - $[53] = t27; - } else { - t26 = $[52]; - t27 = $[53]; - } - useEffect(t26, t27); - const descriptionPaddingLeft = layout === "expanded" ? maxIndexWidth + 3 : maxIndexWidth + 4; - const t28 = layout === "compact" ? 0 : undefined; - const t29 = `${index}.`; - let t30; - if ($[54] !== maxIndexWidth || $[55] !== t29) { - t30 = t29.padEnd(maxIndexWidth + 2); - $[54] = maxIndexWidth; - $[55] = t29; - $[56] = t30; - } else { - t30 = $[56]; - } - let t31; - if ($[57] !== t30) { - t31 = {t30}; - $[57] = t30; - $[58] = t31; - } else { - t31 = $[58]; - } - let t32; - if ($[59] !== cursorOffset || $[60] !== imagesSelected || $[61] !== inputValue || $[62] !== isFocused || $[63] !== onExit || $[64] !== onImagePaste || $[65] !== onInputChange || $[66] !== onSubmit || $[67] !== option || $[68] !== showLabel) { - t32 = showLabel ? <>{option.label}{isFocused ? <>{option.labelValueSeparator ?? ", "} { - isUserEditing.current = true; - onInputChange(value); - option.onChange(value); - }} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={pastedText => { - isUserEditing.current = true; - const before = inputValue.slice(0, cursorOffset); - const after = inputValue.slice(cursorOffset); - const newValue = before + pastedText + after; - onInputChange(newValue); - option.onChange(newValue); - setCursorOffset(before.length + pastedText.length); - }} /> : inputValue && {option.labelValueSeparator ?? ", "}{inputValue}} : isFocused ? { - isUserEditing.current = true; - onInputChange(value_0); - option.onChange(value_0); - }} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder || (typeof option.label === "string" ? option.label : undefined)} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={pastedText_0 => { - isUserEditing.current = true; - const before_0 = inputValue.slice(0, cursorOffset); - const after_0 = inputValue.slice(cursorOffset); - const newValue_0 = before_0 + pastedText_0 + after_0; - onInputChange(newValue_0); - option.onChange(newValue_0); - setCursorOffset(before_0.length + pastedText_0.length); - }} /> : {inputValue || option.placeholder || option.label}; - $[59] = cursorOffset; - $[60] = imagesSelected; - $[61] = inputValue; - $[62] = isFocused; - $[63] = onExit; - $[64] = onImagePaste; - $[65] = onInputChange; - $[66] = onSubmit; - $[67] = option; - $[68] = showLabel; - $[69] = t32; - } else { - t32 = $[69]; - } - let t33; - if ($[70] !== children || $[71] !== t28 || $[72] !== t31 || $[73] !== t32) { - t33 = {t31}{children}{t32}; - $[70] = children; - $[71] = t28; - $[72] = t31; - $[73] = t32; - $[74] = t33; - } else { - t33 = $[74]; - } - let t34; - if ($[75] !== isFocused || $[76] !== isSelected || $[77] !== shouldShowDownArrow || $[78] !== shouldShowUpArrow || $[79] !== t33) { - t34 = {t33}; - $[75] = isFocused; - $[76] = isSelected; - $[77] = shouldShowDownArrow; - $[78] = shouldShowUpArrow; - $[79] = t33; - $[80] = t34; - } else { - t34 = $[80]; - } - let t35; - if ($[81] !== descriptionPaddingLeft || $[82] !== isFocused || $[83] !== isSelected || $[84] !== option.description || $[85] !== option.dimDescription) { - t35 = option.description && {option.description}; - $[81] = descriptionPaddingLeft; - $[82] = isFocused; - $[83] = isSelected; - $[84] = option.description; - $[85] = option.dimDescription; - $[86] = t35; - } else { - t35 = $[86]; - } - let t36; - if ($[87] !== descriptionPaddingLeft || $[88] !== imageAttachments || $[89] !== imagesSelected || $[90] !== isFocused || $[91] !== selectedImageIndex) { - t36 = imageAttachments.length > 0 && {imageAttachments.map((img_0, idx) => )}{imagesSelected ? {imageAttachments.length > 1 && <>} : isFocused ? "(\u2193 to select)" : null}; - $[87] = descriptionPaddingLeft; - $[88] = imageAttachments; - $[89] = imagesSelected; - $[90] = isFocused; - $[91] = selectedImageIndex; - $[92] = t36; - } else { - t36 = $[92]; - } - let t37; - if ($[93] !== layout) { - t37 = layout === "expanded" && ; - $[93] = layout; - $[94] = t37; - } else { - t37 = $[94]; - } - let t38; - if ($[95] !== t34 || $[96] !== t35 || $[97] !== t36 || $[98] !== t37) { - t38 = {t34}{t35}{t36}{t37}; - $[95] = t34; - $[96] = t35; - $[97] = t36; - $[98] = t37; - $[99] = t38; - } else { - t38 = $[99]; - } - return t38; -} -function _temp(c) { - return c.type === "image"; + }, + { isActive: isFocused && !!imagesSelected }, + ) + + // Exit image mode when option loses focus + useEffect(() => { + if (!isFocused && imagesSelected) { + onImagesSelectedChange?.(false) + } + }, [isFocused, imagesSelected, onImagesSelectedChange]) + + const descriptionPaddingLeft = + layout === 'expanded' ? maxIndexWidth + 3 : maxIndexWidth + 4 + + return ( + + + + {`${index}.`.padEnd(maxIndexWidth + 2)} + {children} + {showLabel ? ( + <> + + {option.label} + + {isFocused ? ( + <> + + {option.labelValueSeparator ?? ', '} + + { + isUserEditing.current = true + onInputChange(value) + option.onChange(value) + }} + onSubmit={onSubmit} + onExit={onExit} + placeholder={option.placeholder} + focus={!imagesSelected} + showCursor={true} + multiline={true} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + columns={80} + onImagePaste={onImagePaste} + onPaste={(pastedText: string) => { + isUserEditing.current = true + const before = inputValue.slice(0, cursorOffset) + const after = inputValue.slice(cursorOffset) + const newValue = before + pastedText + after + onInputChange(newValue) + option.onChange(newValue) + setCursorOffset(before.length + pastedText.length) + }} + /> + + ) : ( + inputValue && ( + + {option.labelValueSeparator ?? ', '} + {inputValue} + + ) + )} + + ) : isFocused ? ( + { + isUserEditing.current = true + onInputChange(value) + option.onChange(value) + }} + onSubmit={onSubmit} + onExit={onExit} + placeholder={ + option.placeholder || + (typeof option.label === 'string' ? option.label : undefined) + } + focus={!imagesSelected} + showCursor={true} + multiline={true} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + columns={80} + onImagePaste={onImagePaste} + onPaste={(pastedText: string) => { + isUserEditing.current = true + const before = inputValue.slice(0, cursorOffset) + const after = inputValue.slice(cursorOffset) + const newValue = before + pastedText + after + onInputChange(newValue) + option.onChange(newValue) + setCursorOffset(before.length + pastedText.length) + }} + /> + ) : ( + + {inputValue || option.placeholder || option.label} + + )} + + + {option.description && ( + + + {option.description} + + + )} + {imageAttachments.length > 0 && ( + + {imageAttachments.map((img, idx) => ( + + ))} + + + {imagesSelected ? ( + + {imageAttachments.length > 1 && ( + <> + + + + )} + + + + ) : isFocused ? ( + '(↓ to select)' + ) : null} + + + + )} + {layout === 'expanded' && } + + ) } diff --git a/src/components/CustomSelect/select-option.tsx b/src/components/CustomSelect/select-option.tsx index 04903fcbb..2f84affd9 100644 --- a/src/components/CustomSelect/select-option.tsx +++ b/src/components/CustomSelect/select-option.tsx @@ -1,67 +1,64 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { ListItem } from '../design-system/ListItem.js'; +import React, { type ReactNode } from 'react' +import { ListItem } from '../design-system/ListItem.js' + export type SelectOptionProps = { /** * Determines if option is focused. */ - readonly isFocused: boolean; + readonly isFocused: boolean /** * Determines if option is selected. */ - readonly isSelected: boolean; + readonly isSelected: boolean /** * Option label. */ - readonly children: ReactNode; + readonly children: ReactNode /** * Optional description to display below the label. */ - readonly description?: string; + readonly description?: string /** * Determines if the down arrow should be shown. */ - readonly shouldShowDownArrow?: boolean; + readonly shouldShowDownArrow?: boolean /** * Determines if the up arrow should be shown. */ - readonly shouldShowUpArrow?: boolean; + readonly shouldShowUpArrow?: boolean /** * Whether ListItem should declare the terminal cursor position. * Set false when a child declares its own cursor (e.g. BaseTextInput). */ - readonly declareCursor?: boolean; -}; -export function SelectOption(t0) { - const $ = _c(8); - const { - isFocused, - isSelected, - children, - description, - shouldShowDownArrow, - shouldShowUpArrow, - declareCursor - } = t0; - let t1; - if ($[0] !== children || $[1] !== declareCursor || $[2] !== description || $[3] !== isFocused || $[4] !== isSelected || $[5] !== shouldShowDownArrow || $[6] !== shouldShowUpArrow) { - t1 = {children}; - $[0] = children; - $[1] = declareCursor; - $[2] = description; - $[3] = isFocused; - $[4] = isSelected; - $[5] = shouldShowDownArrow; - $[6] = shouldShowUpArrow; - $[7] = t1; - } else { - t1 = $[7]; - } - return t1; + readonly declareCursor?: boolean +} + +export function SelectOption({ + isFocused, + isSelected, + children, + description, + shouldShowDownArrow, + shouldShowUpArrow, + declareCursor, +}: SelectOptionProps): React.ReactNode { + return ( + + {children} + + ) } diff --git a/src/components/CustomSelect/select.tsx b/src/components/CustomSelect/select.tsx index a48114293..d3a144772 100644 --- a/src/components/CustomSelect/select.tsx +++ b/src/components/CustomSelect/select.tsx @@ -1,137 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { type ReactNode, useEffect, useRef, useState } from 'react'; -import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Ansi, Box, Text } from '../../ink.js'; -import { count } from '../../utils/array.js'; -import type { PastedContent } from '../../utils/config.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import { SelectInputOption } from './select-input-option.js'; -import { SelectOption } from './select-option.js'; -import { useSelectInput } from './use-select-input.js'; -import { useSelectState } from './use-select-state.js'; +import figures from 'figures' +import React, { type ReactNode, useEffect, useRef, useState } from 'react' +import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Ansi, Box, Text } from '../../ink.js' +import { count } from '../../utils/array.js' +import type { PastedContent } from '../../utils/config.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import { SelectInputOption } from './select-input-option.js' +import { SelectOption } from './select-option.js' +import { useSelectInput } from './use-select-input.js' +import { useSelectState } from './use-select-state.js' // Extract text content from ReactNode for width calculation function getTextContent(node: ReactNode): string { - if (typeof node === 'string') return node; - if (typeof node === 'number') return String(node); - if (!node) return ''; - if (Array.isArray(node)) return node.map(getTextContent).join(''); - if (React.isValidElement<{ - children?: ReactNode; - }>(node)) { - return getTextContent(node.props.children); + if (typeof node === 'string') return node + if (typeof node === 'number') return String(node) + if (!node) return '' + if (Array.isArray(node)) return node.map(getTextContent).join('') + if (React.isValidElement<{ children?: ReactNode }>(node)) { + return getTextContent(node.props.children) } - return ''; + return '' } + type BaseOption = { - description?: string; - dimDescription?: boolean; - label: ReactNode; - value: T; - disabled?: boolean; -}; -export type OptionWithDescription = (BaseOption & { - type?: 'text'; -}) | (BaseOption & { - type: 'input'; - onChange: (value: string) => void; - placeholder?: string; - initialValue?: string; - /** - * Controls behavior when submitting with empty input: - * - true: calls onChange (treats empty as valid submission) - * - false (default): calls onCancel (treats empty as cancellation) - * - * Also affects initial Enter press: when true, submits immediately; - * when false, enters input mode first so user can type. - */ - allowEmptySubmitToCancel?: boolean; - /** - * When true, always shows the label alongside the input value, regardless of - * the global inlineDescriptions/showLabel setting. Use this when the label - * provides important context that should always be visible (e.g., "Yes, and allow..."). - */ - showLabelWithValue?: boolean; - /** - * Custom separator between label and value when showLabel is true. - * Defaults to ", ". Use ": " for labels that read better with a colon. - */ - labelValueSeparator?: string; - /** - * When true, automatically reset cursor to end of line when: - * - Option becomes focused - * - Input value changes - * This prevents cursor position bugs when the input value updates asynchronously. - */ - resetCursorOnUpdate?: boolean; -}); + description?: string + dimDescription?: boolean + label: ReactNode + value: T + disabled?: boolean +} + +export type OptionWithDescription = + | (BaseOption & { + type?: 'text' + }) + | (BaseOption & { + type: 'input' + onChange: (value: string) => void + placeholder?: string + initialValue?: string + /** + * Controls behavior when submitting with empty input: + * - true: calls onChange (treats empty as valid submission) + * - false (default): calls onCancel (treats empty as cancellation) + * + * Also affects initial Enter press: when true, submits immediately; + * when false, enters input mode first so user can type. + */ + allowEmptySubmitToCancel?: boolean + /** + * When true, always shows the label alongside the input value, regardless of + * the global inlineDescriptions/showLabel setting. Use this when the label + * provides important context that should always be visible (e.g., "Yes, and allow..."). + */ + showLabelWithValue?: boolean + /** + * Custom separator between label and value when showLabel is true. + * Defaults to ", ". Use ": " for labels that read better with a colon. + */ + labelValueSeparator?: string + /** + * When true, automatically reset cursor to end of line when: + * - Option becomes focused + * - Input value changes + * This prevents cursor position bugs when the input value updates asynchronously. + */ + resetCursorOnUpdate?: boolean + }) + export type SelectProps = { /** * When disabled, user input is ignored. * * @default false */ - readonly isDisabled?: boolean; + readonly isDisabled?: boolean /** * When true, prevents selection on Enter but allows scrolling. * * @default false */ - readonly disableSelection?: boolean; + readonly disableSelection?: boolean /** * When true, hides the numeric indexes next to each option. * * @default false */ - readonly hideIndexes?: boolean; + readonly hideIndexes?: boolean /** * Number of visible options. * * @default 5 */ - readonly visibleOptionCount?: number; + readonly visibleOptionCount?: number /** * Highlight text in option labels. */ - readonly highlightText?: string; + readonly highlightText?: string /** * Options. */ - readonly options: OptionWithDescription[]; + readonly options: OptionWithDescription[] /** * Default value. */ - readonly defaultValue?: T; + readonly defaultValue?: T /** * Callback when cancel is pressed. */ - readonly onCancel?: () => void; + readonly onCancel?: () => void /** * Callback when selected option changes. */ - readonly onChange?: (value: T) => void; + readonly onChange?: (value: T) => void /** * Callback when focused option changes. * Note: This is for one-way notification only. Avoid combining with focusValue * for bidirectional sync, as this can cause feedback loops. */ - readonly onFocus?: (value: T) => void; + readonly onFocus?: (value: T) => void /** * Initial value to focus. This is used to set focus when the component mounts. */ - readonly defaultFocusValue?: T; + readonly defaultFocusValue?: T /** * Layout of the options. @@ -139,7 +141,7 @@ export type SelectProps = { * - `expanded` uses multiple lines and an empty line between options * - `compact-vertical` uses compact index formatting with descriptions below labels */ - readonly layout?: 'compact' | 'expanded' | 'compact-vertical'; + readonly layout?: 'compact' | 'expanded' | 'compact-vertical' /** * When true, descriptions are rendered inline after the label instead of @@ -147,543 +149,785 @@ export type SelectProps = { * * @default false */ - readonly inlineDescriptions?: boolean; + readonly inlineDescriptions?: boolean /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ - readonly onUpFromFirstItem?: () => void; + readonly onUpFromFirstItem?: () => void /** * Callback when user presses down from the last item. * If provided, navigation will not wrap to the first item. */ - readonly onDownFromLastItem?: () => void; + readonly onDownFromLastItem?: () => void /** * Callback when input mode should be toggled for an option. * Called when Tab is pressed (to enter or exit input mode). */ - readonly onInputModeToggle?: (value: T) => void; + readonly onInputModeToggle?: (value: T) => void /** * Callback to open external editor for editing input option values. * When provided, ctrl+g will trigger this callback in input options * with the current value and a setter function to update the internal state. */ - readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; + readonly onOpenEditor?: ( + currentValue: string, + setValue: (value: string) => void, + ) => void /** * Optional callback when an image is pasted into an input option. */ - readonly onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; + readonly onImagePaste?: ( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) => void /** * Pasted content to display inline in input options. */ - readonly pastedContents?: Record; + readonly pastedContents?: Record /** * Callback to remove a pasted image by its ID. */ - readonly onRemoveImage?: (id: number) => void; -}; -export function Select(t0) { - const $ = _c(72); - const { - isDisabled: t1, - hideIndexes: t2, - visibleOptionCount: t3, - highlightText, + readonly onRemoveImage?: (id: number) => void +} + +export function Select({ + isDisabled = false, + hideIndexes = false, + visibleOptionCount = 5, + highlightText, + options, + defaultValue, + onCancel, + onChange, + onFocus, + defaultFocusValue, + layout = 'compact', + disableSelection = false, + inlineDescriptions = false, + onUpFromFirstItem, + onDownFromLastItem, + onInputModeToggle, + onOpenEditor, + onImagePaste, + pastedContents, + onRemoveImage, +}: SelectProps): React.ReactNode { + // Image selection mode state + const [imagesSelected, setImagesSelected] = useState(false) + const [selectedImageIndex, setSelectedImageIndex] = useState(0) + + // State for input type options + const [inputValues, setInputValues] = useState>(() => { + const initialMap = new Map() + options.forEach(option => { + if (option.type === 'input' && option.initialValue) { + initialMap.set(option.value, option.initialValue) + } + }) + return initialMap + }) + + // Track the last initialValue we synced, so we can detect user edits + const lastInitialValues = useRef>(new Map()) + + // Sync initialValue changes to inputValues state, but only if user hasn't edited + useEffect(() => { + for (const option of options) { + if (option.type === 'input' && option.initialValue !== undefined) { + const lastInitial = lastInitialValues.current.get(option.value) ?? '' + const currentValue = inputValues.get(option.value) ?? '' + const newInitial = option.initialValue + + // Only update if: + // 1. The initialValue has changed + // 2. The user hasn't edited (current value still matches the last initialValue we set) + if (newInitial !== lastInitial && currentValue === lastInitial) { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, newInitial) + return next + }) + } + + // Always track the latest initialValue + lastInitialValues.current.set(option.value, newInitial) + } + } + }, [options, inputValues]) + + const state = useSelectState({ + visibleOptionCount, options, defaultValue, - onCancel, onChange, + onCancel, onFocus, - defaultFocusValue, - layout: t4, - disableSelection: t5, - inlineDescriptions: t6, + focusValue: defaultFocusValue, + }) + + useSelectInput({ + isDisabled, + disableSelection: disableSelection || (hideIndexes ? 'numeric' : false), + state, + options, + isMultiSelect: false, // Select is always single-choice onUpFromFirstItem, onDownFromLastItem, onInputModeToggle, - onOpenEditor, - onImagePaste, - pastedContents, - onRemoveImage - } = t0; - const isDisabled = t1 === undefined ? false : t1; - const hideIndexes = t2 === undefined ? false : t2; - const visibleOptionCount = t3 === undefined ? 5 : t3; - const layout = t4 === undefined ? "compact" : t4; - const disableSelection = t5 === undefined ? false : t5; - const inlineDescriptions = t6 === undefined ? false : t6; - const [imagesSelected, setImagesSelected] = useState(false); - const [selectedImageIndex, setSelectedImageIndex] = useState(0); - let t7; - if ($[0] !== options) { - t7 = () => { - const initialMap = new Map(); - options.forEach(option => { - if (option.type === "input" && option.initialValue) { - initialMap.set(option.value, option.initialValue); - } - }); - return initialMap; - }; - $[0] = options; - $[1] = t7; - } else { - t7 = $[1]; - } - const [inputValues, setInputValues] = useState(t7); - let t8; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t8 = new Map(); - $[2] = t8; - } else { - t8 = $[2]; - } - const lastInitialValues = useRef(t8); - let t10; - let t9; - if ($[3] !== inputValues || $[4] !== options) { - t9 = () => { - for (const option_0 of options) { - if (option_0.type === "input" && option_0.initialValue !== undefined) { - const lastInitial = lastInitialValues.current.get(option_0.value) ?? ""; - const currentValue = inputValues.get(option_0.value) ?? ""; - const newInitial = option_0.initialValue; - if (newInitial !== lastInitial && currentValue === lastInitial) { - setInputValues(prev => { - const next = new Map(prev); - next.set(option_0.value, newInitial); - return next; - }); - } - lastInitialValues.current.set(option_0.value, newInitial); - } + inputValues, + imagesSelected, + onEnterImageSelection: () => { + if ( + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + ) { + const imageCount = count( + Object.values(pastedContents), + c => c.type === 'image', + ) + setImagesSelected(true) + setSelectedImageIndex(imageCount - 1) + return true } - }; - t10 = [options, inputValues]; - $[3] = inputValues; - $[4] = options; - $[5] = t10; - $[6] = t9; - } else { - t10 = $[5]; - t9 = $[6]; + return false + }, + }) + + const styles = { + container: () => ({ flexDirection: 'column' as const }), + highlightedText: () => ({ bold: true }), } - useEffect(t9, t10); - let t11; - if ($[7] !== defaultFocusValue || $[8] !== defaultValue || $[9] !== onCancel || $[10] !== onChange || $[11] !== onFocus || $[12] !== options || $[13] !== visibleOptionCount) { - t11 = { - visibleOptionCount, - options, - defaultValue, - onChange, - onCancel, - onFocus, - focusValue: defaultFocusValue - }; - $[7] = defaultFocusValue; - $[8] = defaultValue; - $[9] = onCancel; - $[10] = onChange; - $[11] = onFocus; - $[12] = options; - $[13] = visibleOptionCount; - $[14] = t11; - } else { - t11 = $[14]; + + if (layout === 'expanded') { + const maxIndexWidth = state.options.length.toString().length + + return ( + + {state.visibleOptions.map((option, index) => { + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + + // Handle input type options + if (option.type === 'input') { + const inputValue = inputValues.has(option.value) + ? inputValues.get(option.value)! + : option.initialValue || '' + + return ( + { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, value) + return next + }) + }} + onSubmit={(value: string) => { + const hasImageAttachments = + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + if ( + value.trim() || + hasImageAttachments || + option.allowEmptySubmitToCancel + ) { + onChange?.(option.value) + } else { + onCancel?.() + } + }} + onExit={onCancel} + layout="expanded" + showLabel={inlineDescriptions} + onOpenEditor={onOpenEditor} + resetCursorOnUpdate={option.resetCursorOnUpdate} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + imagesSelected={imagesSelected} + selectedImageIndex={selectedImageIndex} + onImagesSelectedChange={setImagesSelected} + onSelectedImageIndexChange={setSelectedImageIndex} + /> + ) + } + + // Handle text type options + let label: ReactNode = option.label + + // Only apply highlight when label is a string + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const index = labelText.indexOf(highlightText) + + label = ( + <> + {labelText.slice(0, index)} + {highlightText} + {labelText.slice(index + highlightText.length)} + + ) + } + + const isOptionDisabled = option.disabled === true + const optionColor = isOptionDisabled + ? undefined + : isSelected + ? 'success' + : isFocused + ? 'suggestion' + : undefined + + return ( + + + + {label} + + + {option.description && ( + + + {option.description} + + + )} + + + ) + })} + + ) } - const state = useSelectState(t11); - const t12 = disableSelection || (hideIndexes ? "numeric" : false); - let t13; - if ($[15] !== pastedContents) { - t13 = () => { - if (pastedContents && Object.values(pastedContents).some(_temp)) { - const imageCount = count(Object.values(pastedContents), _temp2); - setImagesSelected(true); - setSelectedImageIndex(imageCount - 1); - return true; - } - return false; - }; - $[15] = pastedContents; - $[16] = t13; - } else { - t13 = $[16]; + + if (layout === 'compact-vertical') { + const maxIndexWidth = hideIndexes + ? 0 + : state.options.length.toString().length + + return ( + + {state.visibleOptions.map((option, index) => { + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + + // Handle input type options + if (option.type === 'input') { + const inputValue = inputValues.has(option.value) + ? inputValues.get(option.value)! + : option.initialValue || '' + + return ( + { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, value) + return next + }) + }} + onSubmit={(value: string) => { + const hasImageAttachments = + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + if ( + value.trim() || + hasImageAttachments || + option.allowEmptySubmitToCancel + ) { + onChange?.(option.value) + } else { + onCancel?.() + } + }} + onExit={onCancel} + layout="compact" + showLabel={inlineDescriptions} + onOpenEditor={onOpenEditor} + resetCursorOnUpdate={option.resetCursorOnUpdate} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + imagesSelected={imagesSelected} + selectedImageIndex={selectedImageIndex} + onImagesSelectedChange={setImagesSelected} + onSelectedImageIndexChange={setSelectedImageIndex} + /> + ) + } + + // Handle text type options + let label: ReactNode = option.label + + // Only apply highlight when label is a string + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const index = labelText.indexOf(highlightText) + + label = ( + <> + {labelText.slice(0, index)} + {highlightText} + {labelText.slice(index + highlightText.length)} + + ) + } + + const isOptionDisabled = option.disabled === true + + return ( + + + <> + {!hideIndexes && ( + {`${i}.`.padEnd(maxIndexWidth + 1)} + )} + + {label} + + + + {option.description && ( + + + {option.description} + + + )} + + ) + })} + + ) } - let t14; - if ($[17] !== imagesSelected || $[18] !== inputValues || $[19] !== isDisabled || $[20] !== onDownFromLastItem || $[21] !== onInputModeToggle || $[22] !== onUpFromFirstItem || $[23] !== options || $[24] !== state || $[25] !== t12 || $[26] !== t13) { - t14 = { - isDisabled, - disableSelection: t12, - state, - options, - isMultiSelect: false, - onUpFromFirstItem, - onDownFromLastItem, - onInputModeToggle, - inputValues, - imagesSelected, - onEnterImageSelection: t13 - }; - $[17] = imagesSelected; - $[18] = inputValues; - $[19] = isDisabled; - $[20] = onDownFromLastItem; - $[21] = onInputModeToggle; - $[22] = onUpFromFirstItem; - $[23] = options; - $[24] = state; - $[25] = t12; - $[26] = t13; - $[27] = t14; - } else { - t14 = $[27]; + + const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length + + // Check if any visible options have descriptions (for two-column layout) + // Also check that there are NO input options, since they're not supported in two-column layout + // Skip two-column layout when inlineDescriptions is enabled + const hasInputOptions = state.visibleOptions.some(opt => opt.type === 'input') + const hasDescriptions = + !inlineDescriptions && + !hasInputOptions && + state.visibleOptions.some(opt => opt.description) + + // Pre-compute option data for two-column layout + const optionData = state.visibleOptions.map((option, index) => { + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + const i = state.visibleFromIndex + index + 1 + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + const isOptionDisabled = option.disabled === true + + let label: ReactNode = option.label + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const idx = labelText.indexOf(highlightText) + label = ( + <> + {labelText.slice(0, idx)} + {highlightText} + {labelText.slice(idx + highlightText.length)} + + ) + } + + return { + option, + index: i, + label, + isFocused, + isSelected, + isOptionDisabled, + shouldShowDownArrow: areMoreOptionsBelow && isLastVisibleOption, + shouldShowUpArrow: areMoreOptionsAbove && isFirstVisibleOption, + } + }) + + // Calculate max label width for alignment when descriptions exist + if (hasDescriptions) { + const maxLabelWidth = Math.max( + ...optionData.map(data => { + if (data.option.type === 'input') return 0 + const labelText = getTextContent(data.option.label) + // Width: indicator (1) + space (1) + index + label + space + checkmark (1) + const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2 + const checkmarkWidth = data.isSelected ? 2 : 0 + return 2 + indexWidth + stringWidth(labelText) + checkmarkWidth + }), + ) + + return ( + + {optionData.map(data => { + if (data.option.type === 'input') { + // Input options not supported in two-column layout + return null + } + const labelText = getTextContent(data.option.label) + const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2 + const checkmarkWidth = data.isSelected ? 2 : 0 + const currentLabelWidth = + 2 + indexWidth + stringWidth(labelText) + checkmarkWidth + const padding = maxLabelWidth - currentLabelWidth + + return ( + + {/* Label part - no gap, handle spacing explicitly */} + + {data.isFocused ? ( + {figures.pointer} + ) : data.shouldShowDownArrow ? ( + {figures.arrowDown} + ) : data.shouldShowUpArrow ? ( + {figures.arrowUp} + ) : ( + + )} + + + {!hideIndexes && ( + + {`${data.index}.`.padEnd(maxIndexWidth + 2)} + + )} + {data.label} + + {data.isSelected && ( + {figures.tick} + )} + {/* Padding to align descriptions */} + {padding > 0 && {' '.repeat(padding)}} + + {/* Description part */} + + + {data.option.description || ' '} + + + + ) + })} + + ) } - useSelectInput(t14); - let T0; - let t15; - let t16; - let t17; - if ($[28] !== hideIndexes || $[29] !== highlightText || $[30] !== imagesSelected || $[31] !== inlineDescriptions || $[32] !== inputValues || $[33] !== isDisabled || $[34] !== layout || $[35] !== onCancel || $[36] !== onChange || $[37] !== onImagePaste || $[38] !== onOpenEditor || $[39] !== onRemoveImage || $[40] !== options.length || $[41] !== pastedContents || $[42] !== selectedImageIndex || $[43] !== state.focusedValue || $[44] !== state.options || $[45] !== state.value || $[46] !== state.visibleFromIndex || $[47] !== state.visibleOptions || $[48] !== state.visibleToIndex) { - t17 = Symbol.for("react.early_return_sentinel"); - bb0: { - const styles = { - container: _temp3, - highlightedText: _temp4 - }; - if (layout === "expanded") { - let t18; - if ($[53] !== state.options.length) { - t18 = state.options.length.toString(); - $[53] = state.options.length; - $[54] = t18; - } else { - t18 = $[54]; - } - const maxIndexWidth = t18.length; - t17 = {state.visibleOptions.map((option_1, index) => { - const isFirstVisibleOption = option_1.index === state.visibleFromIndex; - const isLastVisibleOption = option_1.index === state.visibleToIndex - 1; - const areMoreOptionsBelow = state.visibleToIndex < options.length; - const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1; - const isFocused = !isDisabled && state.focusedValue === option_1.value; - const isSelected = state.value === option_1.value; - if (option_1.type === "input") { - const inputValue = inputValues.has(option_1.value) ? inputValues.get(option_1.value) : option_1.initialValue || ""; - return { - setInputValues(prev_0 => { - const next_0 = new Map(prev_0); - next_0.set(option_1.value, value); - return next_0; - }); - }} onSubmit={value_0 => { - const hasImageAttachments = pastedContents && Object.values(pastedContents).some(_temp5); - if (value_0.trim() || hasImageAttachments || option_1.allowEmptySubmitToCancel) { - onChange?.(option_1.value); - } else { - onCancel?.(); - } - }} onExit={onCancel} layout="expanded" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_1.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />; - } - let label = option_1.label; - if (typeof option_1.label === "string" && highlightText && option_1.label.includes(highlightText)) { - const labelText = option_1.label; - const index_0 = labelText.indexOf(highlightText); - label = <>{labelText.slice(0, index_0)}{highlightText}{labelText.slice(index_0 + highlightText.length)}; - } - const isOptionDisabled = option_1.disabled === true; - const optionColor = isOptionDisabled ? undefined : isSelected ? "success" : isFocused ? "suggestion" : undefined; - return {label}{option_1.description && {option_1.description}} ; - })}; - break bb0; - } - if (layout === "compact-vertical") { - let t18; - if ($[55] !== hideIndexes || $[56] !== state.options) { - t18 = hideIndexes ? 0 : state.options.length.toString().length; - $[55] = hideIndexes; - $[56] = state.options; - $[57] = t18; - } else { - t18 = $[57]; - } - const maxIndexWidth_0 = t18; - t17 = {state.visibleOptions.map((option_2, index_1) => { - const isFirstVisibleOption_0 = option_2.index === state.visibleFromIndex; - const isLastVisibleOption_0 = option_2.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_0 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_0 = state.visibleFromIndex > 0; - const i_0 = state.visibleFromIndex + index_1 + 1; - const isFocused_0 = !isDisabled && state.focusedValue === option_2.value; - const isSelected_0 = state.value === option_2.value; - if (option_2.type === "input") { - const inputValue_0 = inputValues.has(option_2.value) ? inputValues.get(option_2.value) : option_2.initialValue || ""; - return { - setInputValues(prev_1 => { - const next_1 = new Map(prev_1); - next_1.set(option_2.value, value_1); - return next_1; - }); - }} onSubmit={value_2 => { - const hasImageAttachments_0 = pastedContents && Object.values(pastedContents).some(_temp6); - if (value_2.trim() || hasImageAttachments_0 || option_2.allowEmptySubmitToCancel) { - onChange?.(option_2.value); + + return ( + + {state.visibleOptions.map((option, index) => { + // Handle input type options + if (option.type === 'input') { + const inputValue = inputValues.has(option.value) + ? inputValues.get(option.value)! + : option.initialValue || '' + + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + + return ( + { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, value) + return next + }) + }} + onSubmit={(value: string) => { + const hasImageAttachments = + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + if ( + value.trim() || + hasImageAttachments || + option.allowEmptySubmitToCancel + ) { + onChange?.(option.value) } else { - onCancel?.(); + onCancel?.() } - }} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_2.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />; - } - let label_0 = option_2.label; - if (typeof option_2.label === "string" && highlightText && option_2.label.includes(highlightText)) { - const labelText_0 = option_2.label; - const index_2 = labelText_0.indexOf(highlightText); - label_0 = <>{labelText_0.slice(0, index_2)}{highlightText}{labelText_0.slice(index_2 + highlightText.length)}; - } - const isOptionDisabled_0 = option_2.disabled === true; - return <>{!hideIndexes && {`${i_0}.`.padEnd(maxIndexWidth_0 + 1)}}{label_0}{option_2.description && {option_2.description}}; - })}; - break bb0; - } - let t18; - if ($[58] !== hideIndexes || $[59] !== state.options) { - t18 = hideIndexes ? 0 : state.options.length.toString().length; - $[58] = hideIndexes; - $[59] = state.options; - $[60] = t18; - } else { - t18 = $[60]; - } - const maxIndexWidth_1 = t18; - const hasInputOptions = state.visibleOptions.some(_temp7); - const hasDescriptions = !inlineDescriptions && !hasInputOptions && state.visibleOptions.some(_temp8); - const optionData = state.visibleOptions.map((option_3, index_3) => { - const isFirstVisibleOption_1 = option_3.index === state.visibleFromIndex; - const isLastVisibleOption_1 = option_3.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_1 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_1 = state.visibleFromIndex > 0; - const i_1 = state.visibleFromIndex + index_3 + 1; - const isFocused_1 = !isDisabled && state.focusedValue === option_3.value; - const isSelected_1 = state.value === option_3.value; - const isOptionDisabled_1 = option_3.disabled === true; - let label_1 = option_3.label; - if (typeof option_3.label === "string" && highlightText && option_3.label.includes(highlightText)) { - const labelText_1 = option_3.label; - const idx = labelText_1.indexOf(highlightText); - label_1 = <>{labelText_1.slice(0, idx)}{highlightText}{labelText_1.slice(idx + highlightText.length)}; - } - return { - option: option_3, - index: i_1, - label: label_1, - isFocused: isFocused_1, - isSelected: isSelected_1, - isOptionDisabled: isOptionDisabled_1, - shouldShowDownArrow: areMoreOptionsBelow_1 && isLastVisibleOption_1, - shouldShowUpArrow: areMoreOptionsAbove_1 && isFirstVisibleOption_1 - }; - }); - if (hasDescriptions) { - let t19; - if ($[61] !== hideIndexes || $[62] !== maxIndexWidth_1) { - t19 = data => { - if (data.option.type === "input") { - return 0; - } - const labelText_2 = getTextContent(data.option.label); - const indexWidth = hideIndexes ? 0 : maxIndexWidth_1 + 2; - const checkmarkWidth = data.isSelected ? 2 : 0; - return 2 + indexWidth + stringWidth(labelText_2) + checkmarkWidth; - }; - $[61] = hideIndexes; - $[62] = maxIndexWidth_1; - $[63] = t19; - } else { - t19 = $[63]; + }} + onExit={onCancel} + layout="compact" + showLabel={inlineDescriptions} + onOpenEditor={onOpenEditor} + resetCursorOnUpdate={option.resetCursorOnUpdate} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + imagesSelected={imagesSelected} + selectedImageIndex={selectedImageIndex} + onImagesSelectedChange={setImagesSelected} + onSelectedImageIndexChange={setSelectedImageIndex} + /> + ) } - const maxLabelWidth = Math.max(...optionData.map(t19) as number[]); - let t20; - if ($[64] !== hideIndexes || $[65] !== maxIndexWidth_1 || $[66] !== maxLabelWidth) { - t20 = data_0 => { - if (data_0.option.type === "input") { - return null; - } - const labelText_3 = getTextContent(data_0.option.label); - const indexWidth_0 = hideIndexes ? 0 : maxIndexWidth_1 + 2; - const checkmarkWidth_0 = data_0.isSelected ? 2 : 0; - const currentLabelWidth = 2 + indexWidth_0 + stringWidth(labelText_3) + checkmarkWidth_0; - const padding = maxLabelWidth - currentLabelWidth; - return {data_0.isFocused ? {figures.pointer} : data_0.shouldShowDownArrow ? {figures.arrowDown} : data_0.shouldShowUpArrow ? {figures.arrowUp} : } {!hideIndexes && {`${data_0.index}.`.padEnd(maxIndexWidth_1 + 2)}}{data_0.label}{data_0.isSelected && {figures.tick}}{padding > 0 && {" ".repeat(padding)}}{data_0.option.description || " "}; - }; - $[64] = hideIndexes; - $[65] = maxIndexWidth_1; - $[66] = maxLabelWidth; - $[67] = t20; - } else { - t20 = $[67]; - } - t17 = {optionData.map(t20)}; - break bb0; - } - T0 = Box; - t15 = styles.container(); - t16 = state.visibleOptions.map((option_4, index_4) => { - if (option_4.type === "input") { - const inputValue_1 = inputValues.has(option_4.value) ? inputValues.get(option_4.value) : option_4.initialValue || ""; - const isFirstVisibleOption_2 = option_4.index === state.visibleFromIndex; - const isLastVisibleOption_2 = option_4.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_2 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_2 = state.visibleFromIndex > 0; - const i_2 = state.visibleFromIndex + index_4 + 1; - const isFocused_2 = !isDisabled && state.focusedValue === option_4.value; - const isSelected_2 = state.value === option_4.value; - return { - setInputValues(prev_2 => { - const next_2 = new Map(prev_2); - next_2.set(option_4.value, value_3); - return next_2; - }); - }} onSubmit={value_4 => { - const hasImageAttachments_1 = pastedContents && Object.values(pastedContents).some(_temp9); - if (value_4.trim() || hasImageAttachments_1 || option_4.allowEmptySubmitToCancel) { - onChange?.(option_4.value); - } else { - onCancel?.(); - } - }} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_4.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />; - } - let label_2 = option_4.label; - if (typeof option_4.label === "string" && highlightText && option_4.label.includes(highlightText)) { - const labelText_4 = option_4.label; - const index_5 = labelText_4.indexOf(highlightText); - label_2 = <>{labelText_4.slice(0, index_5)}{highlightText}{labelText_4.slice(index_5 + highlightText.length)}; + + // Handle text type options + let label: ReactNode = option.label + + // Only apply highlight when label is a string + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const index = labelText.indexOf(highlightText) + + label = ( + <> + {labelText.slice(0, index)} + {highlightText} + {labelText.slice(index + highlightText.length)} + + ) } - const isFirstVisibleOption_3 = option_4.index === state.visibleFromIndex; - const isLastVisibleOption_3 = option_4.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_3 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_3 = state.visibleFromIndex > 0; - const i_3 = state.visibleFromIndex + index_4 + 1; - const isFocused_3 = !isDisabled && state.focusedValue === option_4.value; - const isSelected_3 = state.value === option_4.value; - const isOptionDisabled_2 = option_4.disabled === true; - return {!hideIndexes && {`${i_3}.`.padEnd(maxIndexWidth_1 + 2)}}{label_2}{inlineDescriptions && option_4.description && {" "}{option_4.description}}{!inlineDescriptions && option_4.description && {option_4.description}}; - }); - } - $[28] = hideIndexes; - $[29] = highlightText; - $[30] = imagesSelected; - $[31] = inlineDescriptions; - $[32] = inputValues; - $[33] = isDisabled; - $[34] = layout; - $[35] = onCancel; - $[36] = onChange; - $[37] = onImagePaste; - $[38] = onOpenEditor; - $[39] = onRemoveImage; - $[40] = options.length; - $[41] = pastedContents; - $[42] = selectedImageIndex; - $[43] = state.focusedValue; - $[44] = state.options; - $[45] = state.value; - $[46] = state.visibleFromIndex; - $[47] = state.visibleOptions; - $[48] = state.visibleToIndex; - $[49] = T0; - $[50] = t15; - $[51] = t16; - $[52] = t17; - } else { - T0 = $[49]; - t15 = $[50]; - t16 = $[51]; - t17 = $[52]; - } - if (t17 !== Symbol.for("react.early_return_sentinel")) { - return t17; - } - let t18; - if ($[68] !== T0 || $[69] !== t15 || $[70] !== t16) { - t18 = {t16}; - $[68] = T0; - $[69] = t15; - $[70] = t16; - $[71] = t18; - } else { - t18 = $[71]; - } - return t18; + + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + const isOptionDisabled = option.disabled === true + + return ( + + + {!hideIndexes && ( + {`${i}.`.padEnd(maxIndexWidth + 2)} + )} + + {label} + {inlineDescriptions && option.description && ( + + {' '} + {option.description} + + )} + + + {!inlineDescriptions && option.description && ( + + + {option.description} + + + )} + + ) + })} + + ) } // Row container for the two-column (label + description) layout. Unlike // the other Select layouts, this one doesn't render through SelectOption → // ListItem, so it declares the native cursor directly. Parks the cursor // on the pointer indicator so screen readers / magnifiers track focus. -function _temp9(c_3) { - return c_3.type === "image"; -} -function _temp8(opt_0) { - return opt_0.description; -} -function _temp7(opt) { - return opt.type === "input"; -} -function _temp6(c_2) { - return c_2.type === "image"; -} -function _temp5(c_1) { - return c_1.type === "image"; -} -function _temp4() { - return { - bold: true - }; -} -function _temp3() { - return { - flexDirection: "column" as const - }; -} -function _temp2(c) { - return c.type === "image"; -} -function _temp(c_0) { - return c_0.type === "image"; -} -function TwoColumnRow(t0) { - const $ = _c(5); - const { - isFocused, - children - } = t0; - let t1; - if ($[0] !== isFocused) { - t1 = { - line: 0, - column: 0, - active: isFocused - }; - $[0] = isFocused; - $[1] = t1; - } else { - t1 = $[1]; - } - const cursorRef = useDeclaredCursor(t1); - let t2; - if ($[2] !== children || $[3] !== cursorRef) { - t2 = {children}; - $[2] = children; - $[3] = cursorRef; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; +function TwoColumnRow({ + isFocused, + children, +}: { + isFocused: boolean + children: ReactNode +}): React.ReactNode { + const cursorRef = useDeclaredCursor({ + line: 0, + column: 0, + active: isFocused, + }) + return ( + + {children} + + ) } diff --git a/src/components/DesktopHandoff.tsx b/src/components/DesktopHandoff.tsx index a305e70ec..8e0632fd4 100644 --- a/src/components/DesktopHandoff.tsx +++ b/src/components/DesktopHandoff.tsx @@ -1,192 +1,151 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useState } from 'react'; -import type { CommandResultDisplay } from '../commands.js'; +import React, { useEffect, useState } from 'react' +import type { CommandResultDisplay } from '../commands.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for "any key" dismiss and y/n prompt -import { Box, Text, useInput } from '../ink.js'; -import { openBrowser } from '../utils/browser.js'; -import { getDesktopInstallStatus, openCurrentSessionInDesktop } from '../utils/desktopDeepLink.js'; -import { errorMessage } from '../utils/errors.js'; -import { gracefulShutdown } from '../utils/gracefulShutdown.js'; -import { flushSessionStorage } from '../utils/sessionStorage.js'; -import { LoadingState } from './design-system/LoadingState.js'; -const DESKTOP_DOCS_URL = 'https://clau.de/desktop'; +import { Box, Text, useInput } from '../ink.js' +import { openBrowser } from '../utils/browser.js' +import { + getDesktopInstallStatus, + openCurrentSessionInDesktop, +} from '../utils/desktopDeepLink.js' +import { errorMessage } from '../utils/errors.js' +import { gracefulShutdown } from '../utils/gracefulShutdown.js' +import { flushSessionStorage } from '../utils/sessionStorage.js' +import { LoadingState } from './design-system/LoadingState.js' + +const DESKTOP_DOCS_URL = 'https://clau.de/desktop' + export function getDownloadUrl(): string { switch (process.platform) { case 'win32': - return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect'; + return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect' default: - return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect'; + return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect' } } -type DesktopHandoffState = 'checking' | 'prompt-download' | 'flushing' | 'opening' | 'success' | 'error'; + +type DesktopHandoffState = + | 'checking' + | 'prompt-download' + | 'flushing' + | 'opening' + | 'success' + | 'error' + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function DesktopHandoff(t0) { - const $ = _c(20); - const { - onDone - } = t0; - const [state, setState] = useState("checking"); - const [error, setError] = useState(null); - const [downloadMessage, setDownloadMessage] = useState(""); - let t1; - if ($[0] !== error || $[1] !== onDone || $[2] !== state) { - t1 = input => { - if (state === "error") { - onDone(error ?? "Unknown error", { - display: "system" - }); - return; - } - if (state === "prompt-download") { - if (input === "y" || input === "Y") { - openBrowser(getDownloadUrl()).catch(_temp); - onDone(`Starting download. Re-run /desktop once you\u2019ve installed the app.\nLearn more at ${DESKTOP_DOCS_URL}`, { - display: "system" - }); - } else { - if (input === "n" || input === "N") { - onDone(`The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`, { - display: "system" - }); - } - } - } - }; - $[0] = error; - $[1] = onDone; - $[2] = state; - $[3] = t1; - } else { - t1 = $[3]; - } - useInput(t1); - let t2; - let t3; - if ($[4] !== onDone) { - t2 = () => { - const performHandoff = async function performHandoff() { - setState("checking"); - const installStatus = await getDesktopInstallStatus(); - if (installStatus.status === "not-installed") { - setDownloadMessage("Claude Desktop is not installed."); - setState("prompt-download"); - return; - } - if (installStatus.status === "version-too-old") { - setDownloadMessage(`Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`); - setState("prompt-download"); - return; - } - setState("flushing"); - await flushSessionStorage(); - setState("opening"); - const result = await openCurrentSessionInDesktop(); - if (!result.success) { - setError(result.error ?? "Failed to open Claude Desktop"); - setState("error"); - return; - } - setState("success"); - setTimeout(_temp2, 500, onDone); - }; - performHandoff().catch(err => { - setError(errorMessage(err)); - setState("error"); - }); - }; - t3 = [onDone]; - $[4] = onDone; - $[5] = t2; - $[6] = t3; - } else { - t2 = $[5]; - t3 = $[6]; - } - useEffect(t2, t3); - if (state === "error") { - let t4; - if ($[7] !== error) { - t4 = Error: {error}; - $[7] = error; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Press any key to continue…; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t4) { - t6 = {t4}{t5}; - $[10] = t4; - $[11] = t6; - } else { - t6 = $[11]; - } - return t6; - } - if (state === "prompt-download") { - let t4; - if ($[12] !== downloadMessage) { - t4 = {downloadMessage}; - $[12] = downloadMessage; - $[13] = t4; - } else { - t4 = $[13]; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function DesktopHandoff({ onDone }: Props): React.ReactNode { + const [state, setState] = useState('checking') + const [error, setError] = useState(null) + const [downloadMessage, setDownloadMessage] = useState('') + + // Handle keyboard input for error and prompt-download states + useInput(input => { + if (state === 'error') { + onDone(error ?? 'Unknown error', { display: 'system' }) + return } - let t5; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Download now? (y/n); - $[14] = t5; - } else { - t5 = $[14]; + if (state === 'prompt-download') { + if (input === 'y' || input === 'Y') { + openBrowser(getDownloadUrl()).catch(() => {}) + onDone( + `Starting download. Re-run /desktop once you\u2019ve installed the app.\nLearn more at ${DESKTOP_DOCS_URL}`, + { display: 'system' }, + ) + } else if (input === 'n' || input === 'N') { + onDone( + `The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`, + { display: 'system' }, + ) + } } - let t6; - if ($[15] !== t4) { - t6 = {t4}{t5}; - $[15] = t4; - $[16] = t6; - } else { - t6 = $[16]; + }) + + useEffect(() => { + async function performHandoff(): Promise { + // Check Desktop install status + setState('checking') + const installStatus = await getDesktopInstallStatus() + + if (installStatus.status === 'not-installed') { + setDownloadMessage('Claude Desktop is not installed.') + setState('prompt-download') + return + } + + if (installStatus.status === 'version-too-old') { + setDownloadMessage( + `Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`, + ) + setState('prompt-download') + return + } + + // Flush session storage to ensure transcript is fully written + setState('flushing') + await flushSessionStorage() + + // Open the deep link (uses claude-dev:// in dev mode) + setState('opening') + const result = await openCurrentSessionInDesktop() + + if (!result.success) { + setError(result.error ?? 'Failed to open Claude Desktop') + setState('error') + return + } + + // Success - exit the CLI + setState('success') + + // Give the user a moment to see the success message + setTimeout( + async (onDone: Props['onDone']) => { + onDone('Session transferred to Claude Desktop', { display: 'system' }) + await gracefulShutdown(0, 'other') + }, + 500, + onDone, + ) } - return t6; + + performHandoff().catch(err => { + setError(errorMessage(err)) + setState('error') + }) + }, [onDone]) + + if (state === 'error') { + return ( + + Error: {error} + Press any key to continue… + + ) } - let t4; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - checking: "Checking for Claude Desktop\u2026", - flushing: "Saving session\u2026", - opening: "Opening Claude Desktop\u2026", - success: "Opening in Claude Desktop\u2026" - }; - $[17] = t4; - } else { - t4 = $[17]; + + if (state === 'prompt-download') { + return ( + + {downloadMessage} + Download now? (y/n) + + ) } - const messages = t4; - const t5 = messages[state]; - let t6; - if ($[18] !== t5) { - t6 = ; - $[18] = t5; - $[19] = t6; - } else { - t6 = $[19]; + + const messages: Record< + Exclude, + string + > = { + checking: 'Checking for Claude Desktop…', + flushing: 'Saving session…', + opening: 'Opening Claude Desktop…', + success: 'Opening in Claude Desktop…', } - return t6; -} -async function _temp2(onDone_0) { - onDone_0("Session transferred to Claude Desktop", { - display: "system" - }); - await gracefulShutdown(0, "other"); + + return } -function _temp() {} diff --git a/src/components/DesktopUpsell/DesktopUpsellStartup.tsx b/src/components/DesktopUpsell/DesktopUpsellStartup.tsx index 1a48baca4..9f5f233a4 100644 --- a/src/components/DesktopUpsell/DesktopUpsellStartup.tsx +++ b/src/components/DesktopUpsell/DesktopUpsellStartup.tsx @@ -1,170 +1,108 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { Select } from '../CustomSelect/select.js'; -import { DesktopHandoff } from '../DesktopHandoff.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { logEvent } from '../../services/analytics/index.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { Select } from '../CustomSelect/select.js' +import { DesktopHandoff } from '../DesktopHandoff.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' + type DesktopUpsellConfig = { - enable_shortcut_tip: boolean; - enable_startup_dialog: boolean; -}; + enable_shortcut_tip: boolean + enable_startup_dialog: boolean +} + const DESKTOP_UPSELL_DEFAULT: DesktopUpsellConfig = { enable_shortcut_tip: false, - enable_startup_dialog: false -}; + enable_startup_dialog: false, +} + export function getDesktopUpsellConfig(): DesktopUpsellConfig { - return getDynamicConfig_CACHED_MAY_BE_STALE('tengu_desktop_upsell', DESKTOP_UPSELL_DEFAULT); + return getDynamicConfig_CACHED_MAY_BE_STALE( + 'tengu_desktop_upsell', + DESKTOP_UPSELL_DEFAULT, + ) } + function isSupportedPlatform(): boolean { - return process.platform === 'darwin' || process.platform === 'win32' && process.arch === 'x64'; + return ( + process.platform === 'darwin' || + (process.platform === 'win32' && process.arch === 'x64') + ) } + export function shouldShowDesktopUpsellStartup(): boolean { - if (!isSupportedPlatform()) return false; - if (!getDesktopUpsellConfig().enable_startup_dialog) return false; - const config = getGlobalConfig(); - if (config.desktopUpsellDismissed) return false; - if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false; - return true; + if (!isSupportedPlatform()) return false + if (!getDesktopUpsellConfig().enable_startup_dialog) return false + const config = getGlobalConfig() + if (config.desktopUpsellDismissed) return false + if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false + return true } -type DesktopUpsellSelection = 'try' | 'not-now' | 'never'; + +type DesktopUpsellSelection = 'try' | 'not-now' | 'never' + type Props = { - onDone: () => void; -}; -export function DesktopUpsellStartup(t0) { - const $ = _c(14); - const { - onDone - } = t0; - const [showHandoff, setShowHandoff] = useState(false); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - useEffect(_temp, t1); - if (showHandoff) { - let t2; - if ($[1] !== onDone) { - t2 = onDone()} />; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; - } - let t2; - if ($[3] !== onDone) { - t2 = function handleSelect(value) { - switch (value) { - case "try": - { - setShowHandoff(true); - return; - } - case "never": - { - saveGlobalConfig(_temp2); - onDone(); - return; - } - case "not-now": - { - onDone(); - return; - } - } - }; - $[3] = onDone; - $[4] = t2; - } else { - t2 = $[4]; - } - const handleSelect = t2; - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - label: "Open in Claude Code Desktop", - value: "try" as const - }; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Not now", - value: "not-now" as const - }; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = [t3, t4, { - label: "Don't ask again", - value: "never" as const - }]; - $[7] = t5; - } else { - t5 = $[7]; - } - const options = t5; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Same Claude Code with visual diffs, live app preview, parallel sessions, and more.; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== handleSelect) { - t7 = () => handleSelect("not-now"); - $[9] = handleSelect; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== handleSelect || $[12] !== t7) { - t8 = {t6} handleSelect('not-now')} + /> + + + ) } diff --git a/src/components/DevBar.tsx b/src/components/DevBar.tsx index bf99f32ef..95ff6b983 100644 --- a/src/components/DevBar.tsx +++ b/src/components/DevBar.tsx @@ -1,48 +1,46 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { getSlowOperations } from '../bootstrap/state.js'; -import { Text, useInterval } from '../ink.js'; +import * as React from 'react' +import { useState } from 'react' +import { getSlowOperations } from '../bootstrap/state.js' +import { Text, useInterval } from '../ink.js' // Show DevBar for dev builds or all ants function shouldShowDevBar(): boolean { - return ("production" as string) === 'development' || (process.env.USER_TYPE) === 'ant'; + return ( + "production" === 'development' || process.env.USER_TYPE === 'ant' + ) } -export function DevBar() { - const $ = _c(5); - const [slowOps, setSlowOps] = useState(getSlowOperations); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - setSlowOps(getSlowOperations()); - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useInterval(t0, shouldShowDevBar() ? 500 : null); + +export function DevBar(): React.ReactNode { + const [slowOps, setSlowOps] = + useState< + ReadonlyArray<{ + operation: string + durationMs: number + timestamp: number + }> + >(getSlowOperations) + + useInterval( + () => { + setSlowOps(getSlowOperations()) + }, + shouldShowDevBar() ? 500 : null, + ) + + // Only show when there's something to display if (!shouldShowDevBar() || slowOps.length === 0) { - return null; - } - let t1; - if ($[1] !== slowOps) { - t1 = slowOps.slice(-3).map(_temp).join(" \xB7 "); - $[1] = slowOps; - $[2] = t1; - } else { - t1 = $[2]; + return null } - const recentOps = t1; - let t2; - if ($[3] !== recentOps) { - t2 = [ANT-ONLY] slow sync: {recentOps}; - $[3] = recentOps; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; -} -function _temp(op) { - return `${op.operation} (${Math.round(op.durationMs)}ms)`; + + // Single-line format so short terminals don't lose rows to dev noise. + const recentOps = slowOps + .slice(-3) + .map(op => `${op.operation} (${Math.round(op.durationMs)}ms)`) + .join(' · ') + + return ( + + [ANT-ONLY] slow sync: {recentOps} + + ) } diff --git a/src/components/DevChannelsDialog.tsx b/src/components/DevChannelsDialog.tsx index 820b62731..7dfc674cc 100644 --- a/src/components/DevChannelsDialog.tsx +++ b/src/components/DevChannelsDialog.tsx @@ -1,104 +1,66 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import type { ChannelEntry } from '../bootstrap/state.js'; -import { Box, Text } from '../ink.js'; -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import type { ChannelEntry } from '../bootstrap/state.js' +import { Box, Text } from '../ink.js' +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - channels: ChannelEntry[]; - onAccept(): void; -}; -export function DevChannelsDialog(t0) { - const $ = _c(14); - const { - channels, - onAccept - } = t0; - let t1; - if ($[0] !== onAccept) { - t1 = function onChange(value) { - bb2: switch (value) { - case "accept": - { - onAccept(); - break bb2; - } - case "exit": - { - gracefulShutdownSync(1); - } - } - }; - $[0] = onAccept; - $[1] = t1; - } else { - t1 = $[1]; - } - const onChange = t1; - const handleEscape = _temp; - let t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = --dangerously-load-development-channels is for local channel development only. Do not use this option to run channels you have downloaded off the internet.; - t3 = Please use --channels to run a list of approved channels.; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - let t4; - if ($[4] !== channels) { - t4 = channels.map(_temp2).join(", "); - $[4] = channels; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t4) { - t5 = {t2}{t3}Channels:{" "}{t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [{ - label: "I am using this for local development", - value: "accept" - }, { - label: "Exit", - value: "exit" - }]; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== onChange) { - t7 = onChange(value as 'accept' | 'exit')} + /> + + ) } diff --git a/src/components/DiagnosticsDisplay.tsx b/src/components/DiagnosticsDisplay.tsx index 1a1027316..ad01c7756 100644 --- a/src/components/DiagnosticsDisplay.tsx +++ b/src/components/DiagnosticsDisplay.tsx @@ -1,94 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -import { relative } from 'path'; -import React from 'react'; -import { Box, Text } from '../ink.js'; -import { DiagnosticTrackingService } from '../services/diagnosticTracking.js'; -import type { Attachment } from '../utils/attachments.js'; -import { getCwd } from '../utils/cwd.js'; -import { CtrlOToExpand } from './CtrlOToExpand.js'; -import { MessageResponse } from './MessageResponse.js'; -type DiagnosticsAttachment = Extract; +import { relative } from 'path' +import React from 'react' +import { Box, Text } from '../ink.js' +import { DiagnosticTrackingService } from '../services/diagnosticTracking.js' +import type { Attachment } from '../utils/attachments.js' +import { getCwd } from '../utils/cwd.js' +import { CtrlOToExpand } from './CtrlOToExpand.js' +import { MessageResponse } from './MessageResponse.js' + +type DiagnosticsAttachment = Extract + type DiagnosticsDisplayProps = { - attachment: DiagnosticsAttachment; - verbose: boolean; -}; -export function DiagnosticsDisplay(t0) { - const $ = _c(14); - const { - attachment, - verbose - } = t0; - if (attachment.files.length === 0) { - return null; - } - let t1; - if ($[0] !== attachment.files) { - t1 = attachment.files.reduce(_temp, 0); - $[0] = attachment.files; - $[1] = t1; - } else { - t1 = $[1]; - } - const totalIssues = t1; - const fileCount = attachment.files.length; + attachment: DiagnosticsAttachment + verbose: boolean +} + +export function DiagnosticsDisplay({ + attachment, + verbose, +}: DiagnosticsDisplayProps): React.ReactNode { + // Only show if there are diagnostics to report + if (attachment.files.length === 0) return null + + // Count total issues + const totalIssues = attachment.files.reduce( + (sum, file) => sum + file.diagnostics.length, + 0, + ) + + const fileCount = attachment.files.length + if (verbose) { - let t2; - if ($[2] !== attachment.files) { - t2 = attachment.files.map(_temp3); - $[2] = attachment.files; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t2) { - t3 = {t2}; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; + // Show all diagnostics in verbose mode (ctrl+o) + return ( + + {attachment.files.map((file, fileIndex) => ( + + + + + {relative( + getCwd(), + file.uri + .replace('file://', '') + .replace('_claude_fs_right:', ''), + )} + {' '} + + {file.uri.startsWith('file://') + ? '(file://)' + : file.uri.startsWith('_claude_fs_right:') + ? '(claude_fs_right)' + : `(${file.uri.split(':')[0]})`} + + : + + + {file.diagnostics.map((diagnostic, diagIndex) => ( + + + {' '} + {DiagnosticTrackingService.getSeveritySymbol( + diagnostic.severity, + )} + {' [Line '} + {diagnostic.range.start.line + 1}: + {diagnostic.range.start.character + 1} + {'] '} + {diagnostic.message} + {diagnostic.code ? ` [${diagnostic.code}]` : ''} + {diagnostic.source ? ` (${diagnostic.source})` : ''} + + + ))} + + ))} + + ) } else { - let t2; - if ($[6] !== totalIssues) { - t2 = {totalIssues}; - $[6] = totalIssues; - $[7] = t2; - } else { - t2 = $[7]; - } - const t3 = totalIssues === 1 ? "issue" : "issues"; - const t4 = fileCount === 1 ? "file" : "files"; - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== fileCount || $[10] !== t2 || $[11] !== t3 || $[12] !== t4) { - t6 = Found {t2} new diagnostic{" "}{t3} in {fileCount}{" "}{t4} {t5}; - $[9] = fileCount; - $[10] = t2; - $[11] = t3; - $[12] = t4; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; + // Show summary in normal mode + return ( + + + Found {totalIssues} new diagnostic{' '} + {totalIssues === 1 ? 'issue' : 'issues'} in {fileCount}{' '} + {fileCount === 1 ? 'file' : 'files'} + + + ) } } -function _temp3(file_0, fileIndex) { - return {relative(getCwd(), file_0.uri.replace("file://", "").replace("_claude_fs_right:", ""))}{" "}{file_0.uri.startsWith("file://") ? "(file://)" : file_0.uri.startsWith("_claude_fs_right:") ? "(claude_fs_right)" : `(${file_0.uri.split(":")[0]})`}:{file_0.diagnostics.map(_temp2)}; -} -function _temp2(diagnostic, diagIndex) { - return {" "}{DiagnosticTrackingService.getSeveritySymbol(diagnostic.severity)}{" [Line "}{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}{"] "}{diagnostic.message}{diagnostic.code ? ` [${diagnostic.code}]` : ""}{diagnostic.source ? ` (${diagnostic.source})` : ""}; -} -function _temp(sum, file) { - return sum + file.diagnostics.length; -} diff --git a/src/components/EffortCallout.tsx b/src/components/EffortCallout.tsx index 5b352fd4d..00feda439 100644 --- a/src/components/EffortCallout.tsx +++ b/src/components/EffortCallout.tsx @@ -1,211 +1,125 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useRef } from 'react'; -import { Box, Text } from '../ink.js'; -import { isMaxSubscriber, isProSubscriber, isTeamSubscriber } from '../utils/auth.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import type { EffortLevel } from '../utils/effort.js'; -import { convertEffortValueToLevel, getDefaultEffortForModel, getOpusDefaultEffortConfig, toPersistableEffort } from '../utils/effort.js'; -import { parseUserSpecifiedModel } from '../utils/model/model.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import type { OptionWithDescription } from './CustomSelect/select.js'; -import { Select } from './CustomSelect/select.js'; -import { effortLevelToSymbol } from './EffortIndicator.js'; -import { PermissionDialog } from './permissions/PermissionDialog.js'; -type EffortCalloutSelection = EffortLevel | undefined | 'dismiss'; +import React, { useCallback, useEffect, useRef } from 'react' +import { Box, Text } from '../ink.js' +import { + isMaxSubscriber, + isProSubscriber, + isTeamSubscriber, +} from '../utils/auth.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import type { EffortLevel } from '../utils/effort.js' +import { + convertEffortValueToLevel, + getDefaultEffortForModel, + getOpusDefaultEffortConfig, + toPersistableEffort, +} from '../utils/effort.js' +import { parseUserSpecifiedModel } from '../utils/model/model.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import type { OptionWithDescription } from './CustomSelect/select.js' +import { Select } from './CustomSelect/select.js' +import { effortLevelToSymbol } from './EffortIndicator.js' +import { PermissionDialog } from './permissions/PermissionDialog.js' + +type EffortCalloutSelection = EffortLevel | undefined | 'dismiss' + type Props = { - model: string; - onDone: (selection: EffortCalloutSelection) => void; -}; -const AUTO_DISMISS_MS = 30_000; -export function EffortCallout(t0) { - const $ = _c(18); - const { - model, - onDone - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getOpusDefaultEffortConfig(); - $[0] = t1; - } else { - t1 = $[0]; - } - const defaultEffortConfig = t1; - const onDoneRef = useRef(onDone); - let t2; - if ($[1] !== onDone) { - t2 = () => { - onDoneRef.current = onDone; - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - useEffect(t2); - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => { - onDoneRef.current("dismiss"); - }; - $[3] = t3; - } else { - t3 = $[3]; - } - const handleCancel = t3; - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = []; - $[4] = t4; - } else { - t4 = $[4]; - } - useEffect(_temp, t4); - let t5; - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = () => { - const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS); - return () => clearTimeout(timeoutId); - }; - t6 = [handleCancel]; - $[5] = t5; - $[6] = t6; - } else { - t5 = $[5]; - t6 = $[6]; - } - useEffect(t5, t6); - let t7; - if ($[7] !== model) { - const defaultEffort = getDefaultEffortForModel(model); - t7 = defaultEffort ? convertEffortValueToLevel(defaultEffort) : "high"; - $[7] = model; - $[8] = t7; - } else { - t7 = $[8]; - } - const defaultLevel = t7; - let t8; - if ($[9] !== defaultLevel) { - t8 = value => { - const effortLevel = value === defaultLevel ? undefined : value; - updateSettingsForSource("userSettings", { - effortLevel: toPersistableEffort(effortLevel) - }); - onDoneRef.current(value); - }; - $[9] = defaultLevel; - $[10] = t8; - } else { - t8 = $[10]; - } - const handleSelect = t8; - let t9; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [{ - label: , - value: "medium" - }, { - label: , - value: "high" - }, { - label: , - value: "low" - }]; - $[11] = t9; - } else { - t9 = $[11]; - } - const options = t9; - let t10; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {defaultEffortConfig.dialogDescription}; - $[12] = t10; - } else { - t10 = $[12]; - } - let t11; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t11 = ; - $[13] = t11; - } else { - t11 = $[13]; - } - let t12; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t12 = ; - $[14] = t12; - } else { - t12 = $[14]; - } - let t13; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {t11} low {"\xB7"}{" "}{t12} medium {"\xB7"}{" "} high; - $[15] = t13; - } else { - t13 = $[15]; - } - let t14; - if ($[16] !== handleSelect) { - t14 = {t10}{t13} + + + ) } -function EffortIndicatorSymbol(t0) { - const $ = _c(4); - const { - level - } = t0; - let t1; - if ($[0] !== level) { - t1 = effortLevelToSymbol(level); - $[0] = level; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = {t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; + +function EffortIndicatorSymbol({ + level, +}: { + level: EffortLevel +}): React.ReactNode { + return {effortLevelToSymbol(level)} } -function EffortOptionLabel(t0) { - const $ = _c(5); - const { - level, - text - } = t0; - let t1; - if ($[0] !== level) { - t1 = ; - $[0] = level; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1 || $[3] !== text) { - t2 = <>{t1} {text}; - $[2] = t1; - $[3] = text; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + +function EffortOptionLabel({ + level, + text, +}: { + level: EffortLevel + text: string +}): React.ReactNode { + return ( + <> + {text} + + ) } /** @@ -218,47 +132,46 @@ function EffortOptionLabel(t0) { */ export function shouldShowEffortCallout(model: string): boolean { // Only show for Opus 4.6 for now - const parsed = parseUserSpecifiedModel(model); + const parsed = parseUserSpecifiedModel(model) if (!parsed.toLowerCase().includes('opus-4-6')) { - return false; + return false } - const config = getGlobalConfig(); - if (config.effortCalloutV2Dismissed) return false; + + const config = getGlobalConfig() + if (config.effortCalloutV2Dismissed) return false // Don't show to brand-new users — they never knew the old default, so this // isn't a change for them. Mark as dismissed so it stays suppressed. if (config.numStartups <= 1) { - markV2Dismissed(); - return false; + markV2Dismissed() + return false } // Pro users already had medium default before this PR. Show the new copy, // but skip if they already saw the v1 dialog — no point nagging twice. if (isProSubscriber()) { if (config.effortCalloutDismissed) { - markV2Dismissed(); - return false; + markV2Dismissed() + return false } - return getOpusDefaultEffortConfig().enabled; + return getOpusDefaultEffortConfig().enabled } // Max/Team are the target of the tengu_grey_step2 config. // Don't mark dismissed when config is disabled — they should see the dialog // once it's enabled for them. if (isMaxSubscriber() || isTeamSubscriber()) { - return getOpusDefaultEffortConfig().enabled; + return getOpusDefaultEffortConfig().enabled } // Everyone else (free tier, API key, non-subscribers): not in scope. - markV2Dismissed(); - return false; + markV2Dismissed() + return false } + function markV2Dismissed(): void { saveGlobalConfig(current => { - if (current.effortCalloutV2Dismissed) return current; - return { - ...current, - effortCalloutV2Dismissed: true - }; - }); + if (current.effortCalloutV2Dismissed) return current + return { ...current, effortCalloutV2Dismissed: true } + }) } diff --git a/src/components/ExitFlow.tsx b/src/components/ExitFlow.tsx index c4e5cff52..c2e527054 100644 --- a/src/components/ExitFlow.tsx +++ b/src/components/ExitFlow.tsx @@ -1,47 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import sample from 'lodash-es/sample.js'; -import React from 'react'; -import { gracefulShutdown } from '../utils/gracefulShutdown.js'; -import { WorktreeExitDialog } from './WorktreeExitDialog.js'; -const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']; +import sample from 'lodash-es/sample.js' +import React from 'react' +import { gracefulShutdown } from '../utils/gracefulShutdown.js' +import { WorktreeExitDialog } from './WorktreeExitDialog.js' + +const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'] + function getRandomGoodbyeMessage(): string { - return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'; + return sample(GOODBYE_MESSAGES) ?? 'Goodbye!' } + type Props = { - onDone: (message?: string) => void; - onCancel?: () => void; - showWorktree: boolean; -}; -export function ExitFlow(t0) { - const $ = _c(5); - const { - showWorktree, - onDone, - onCancel - } = t0; - let t1; - if ($[0] !== onDone) { - t1 = async function onExit(resultMessage) { - onDone(resultMessage ?? getRandomGoodbyeMessage()); - await gracefulShutdown(0, "prompt_input_exit"); - }; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; + onDone: (message?: string) => void + onCancel?: () => void + showWorktree: boolean +} + +export function ExitFlow({ + showWorktree, + onDone, + onCancel, +}: Props): React.ReactNode { + async function onExit(resultMessage?: string) { + onDone(resultMessage ?? getRandomGoodbyeMessage()) + await gracefulShutdown(0, 'prompt_input_exit') } - const onExit = t1; + if (showWorktree) { - let t2; - if ($[2] !== onCancel || $[3] !== onExit) { - t2 = ; - $[2] = onCancel; - $[3] = onExit; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + return } - return null; + + return null } diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 872481760..f4f1560a4 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -1,127 +1,173 @@ -import { join } from 'path'; -import React, { useCallback, useState } from 'react'; -import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { getCwd } from '../utils/cwd.js'; -import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/select.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import TextInput from './TextInput.js'; +import { join } from 'path' +import React, { useCallback, useState } from 'react' +import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { setClipboard } from '../ink/termio/osc.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { getCwd } from '../utils/cwd.js' +import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/select.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import TextInput from './TextInput.js' + type ExportDialogProps = { - content: string; - defaultFilename: string; - onDone: (result: { - success: boolean; - message: string; - }) => void; -}; -type ExportOption = 'clipboard' | 'file'; + content: string + defaultFilename: string + onDone: (result: { success: boolean; message: string }) => void +} + +type ExportOption = 'clipboard' | 'file' + export function ExportDialog({ content, defaultFilename, - onDone + onDone, }: ExportDialogProps): React.ReactNode { - const [, setSelectedOption] = useState(null); - const [filename, setFilename] = useState(defaultFilename); - const [cursorOffset, setCursorOffset] = useState(defaultFilename.length); - const [showFilenameInput, setShowFilenameInput] = useState(false); - const { - columns - } = useTerminalSize(); + const [, setSelectedOption] = useState(null) + const [filename, setFilename] = useState(defaultFilename) + const [cursorOffset, setCursorOffset] = useState( + defaultFilename.length, + ) + const [showFilenameInput, setShowFilenameInput] = useState(false) + const { columns } = useTerminalSize() // Handle going back from filename input to option selection const handleGoBack = useCallback(() => { - setShowFilenameInput(false); - setSelectedOption(null); - }, []); + setShowFilenameInput(false) + setSelectedOption(null) + }, []) + const handleSelectOption = async (value: string): Promise => { if (value === 'clipboard') { // Copy to clipboard immediately - const raw = await setClipboard(content); - if (raw) process.stdout.write(raw); - onDone({ - success: true, - message: 'Conversation copied to clipboard' - }); + const raw = await setClipboard(content) + if (raw) process.stdout.write(raw) + onDone({ success: true, message: 'Conversation copied to clipboard' }) } else if (value === 'file') { - setSelectedOption('file'); - setShowFilenameInput(true); + setSelectedOption('file') + setShowFilenameInput(true) } - }; + } + const handleFilenameSubmit = () => { - const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt'; - const filepath = join(getCwd(), finalFilename); + const finalFilename = filename.endsWith('.txt') + ? filename + : filename.replace(/\.[^.]+$/, '') + '.txt' + const filepath = join(getCwd(), finalFilename) + try { writeFileSync_DEPRECATED(filepath, content, { encoding: 'utf-8', - flush: true - }); + flush: true, + }) onDone({ success: true, - message: `Conversation exported to: ${filepath}` - }); + message: `Conversation exported to: ${filepath}`, + }) } catch (error) { onDone({ success: false, - message: `Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}` - }); + message: `Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) } - }; + } // Dialog calls onCancel when Escape is pressed. If we are in the filename // input sub-screen, go back to the option list instead of closing entirely. const handleCancel = useCallback(() => { if (showFilenameInput) { - handleGoBack(); + handleGoBack() } else { - onDone({ - success: false, - message: 'Export cancelled' - }); + onDone({ success: false, message: 'Export cancelled' }) } - }, [showFilenameInput, handleGoBack, onDone]); - const options = [{ - label: 'Copy to clipboard', - value: 'clipboard', - description: 'Copy the conversation to your system clipboard' - }, { - label: 'Save to file', - value: 'file', - description: 'Save the conversation to a file in the current directory' - }]; + }, [showFilenameInput, handleGoBack, onDone]) + + const options = [ + { + label: 'Copy to clipboard', + value: 'clipboard', + description: 'Copy the conversation to your system clipboard', + }, + { + label: 'Save to file', + value: 'file', + description: 'Save the conversation to a file in the current directory', + }, + ] // Custom input guide that changes based on dialog state function renderInputGuide(exitState: ExitState): React.ReactNode { if (showFilenameInput) { - return + return ( + - - ; + + + ) } + if (exitState.pending) { - return Press {exitState.keyName} again to exit; + return Press {exitState.keyName} again to exit } - return ; + + return ( + + ) } // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in filename input) useKeybinding('confirm:no', handleCancel, { context: 'Settings', - isActive: showFilenameInput - }); - return - {!showFilenameInput ? + ) : ( + Enter filename: > - + - } - ; + + )} + + ) } diff --git a/src/components/FallbackToolUseErrorMessage.tsx b/src/components/FallbackToolUseErrorMessage.tsx index 0f38b351e..d86ac2b7c 100644 --- a/src/components/FallbackToolUseErrorMessage.tsx +++ b/src/components/FallbackToolUseErrorMessage.tsx @@ -1,115 +1,79 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; -import * as React from 'react'; -import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js'; -import { extractTag } from 'src/utils/messages.js'; -import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js'; -import { Box, Text } from '../ink.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { countCharInString } from '../utils/stringUtils.js'; -import { MessageResponse } from './MessageResponse.js'; -const MAX_RENDERED_LINES = 10; +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs' +import * as React from 'react' +import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js' +import { extractTag } from 'src/utils/messages.js' +import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js' +import { Box, Text } from '../ink.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { countCharInString } from '../utils/stringUtils.js' +import { MessageResponse } from './MessageResponse.js' + +const MAX_RENDERED_LINES = 10 + type Props = { - result: ToolResultBlockParam['content']; - verbose: boolean; -}; -export function FallbackToolUseErrorMessage(t0) { - const $ = _c(25); - const { - result, - verbose - } = t0; - const transcriptShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let T0; - let T1; - let T2; - let plusLines; - let t1; - let t2; - let t3; - if ($[0] !== result || $[1] !== verbose) { - let error; - if (typeof result !== "string") { - error = "Tool execution failed"; + result: ToolResultBlockParam['content'] + verbose: boolean +} + +export function FallbackToolUseErrorMessage({ + result, + verbose, +}: Props): React.ReactNode { + const transcriptShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + let error: string + + if (typeof result !== 'string') { + error = 'Tool execution failed' + } else { + const extractedError = extractTag(result, 'tool_use_error') ?? result + // Remove sandbox_violations tags from error display (Claude still sees them in the tool result) + const withoutSandboxViolations = removeSandboxViolationTags(extractedError) + // Strip tags but keep their content (tags are for the model, not the UI) + const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, '') + const trimmed = withoutErrorTags.trim() + if (!verbose && trimmed.includes('InputValidationError: ')) { + error = 'Invalid tool parameters' + } else if ( + trimmed.startsWith('Error: ') || + trimmed.startsWith('Cancelled: ') + ) { + error = trimmed } else { - const extractedError = extractTag(result, "tool_use_error") ?? result; - const withoutSandboxViolations = removeSandboxViolationTags(extractedError); - const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, ""); - const trimmed = withoutErrorTags.trim(); - if (!verbose && trimmed.includes("InputValidationError: ")) { - error = "Invalid tool parameters"; - } else { - if (trimmed.startsWith("Error: ") || trimmed.startsWith("Cancelled: ")) { - error = trimmed; - } else { - error = `Error: ${trimmed}`; - } - } + error = `Error: ${trimmed}` } - plusLines = countCharInString(error, "\n") + 1 - MAX_RENDERED_LINES; - T2 = MessageResponse; - T1 = Box; - t3 = "column"; - T0 = Text; - t1 = "error"; - t2 = stripUnderlineAnsi(verbose ? error : error.split("\n").slice(0, MAX_RENDERED_LINES).join("\n")); - $[0] = result; - $[1] = verbose; - $[2] = T0; - $[3] = T1; - $[4] = T2; - $[5] = plusLines; - $[6] = t1; - $[7] = t2; - $[8] = t3; - } else { - T0 = $[2]; - T1 = $[3]; - T2 = $[4]; - plusLines = $[5]; - t1 = $[6]; - t2 = $[7]; - t3 = $[8]; - } - let t4; - if ($[9] !== T0 || $[10] !== t1 || $[11] !== t2) { - t4 = {t2}; - $[9] = T0; - $[10] = t1; - $[11] = t2; - $[12] = t4; - } else { - t4 = $[12]; - } - let t5; - if ($[13] !== plusLines || $[14] !== transcriptShortcut || $[15] !== verbose) { - t5 = !verbose && plusLines > 0 && … +{plusLines} {plusLines === 1 ? "line" : "lines"} ({transcriptShortcut} to see all); - $[13] = plusLines; - $[14] = transcriptShortcut; - $[15] = verbose; - $[16] = t5; - } else { - t5 = $[16]; - } - let t6; - if ($[17] !== T1 || $[18] !== t3 || $[19] !== t4 || $[20] !== t5) { - t6 = {t4}{t5}; - $[17] = T1; - $[18] = t3; - $[19] = t4; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - let t7; - if ($[22] !== T2 || $[23] !== t6) { - t7 = {t6}; - $[22] = T2; - $[23] = t6; - $[24] = t7; - } else { - t7 = $[24]; } - return t7; + + const plusLines = countCharInString(error, '\n') + 1 - MAX_RENDERED_LINES + + return ( + + + + {stripUnderlineAnsi( + verbose + ? error + : error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n'), + )} + + {!verbose && plusLines > 0 && ( + // The careful layout is a workaround for the dim-bold + // rendering bug + + + … +{plusLines} {plusLines === 1 ? 'line' : 'lines'} ( + + + {transcriptShortcut} + + + to see all) + + )} + + + ) } diff --git a/src/components/FallbackToolUseRejectedMessage.tsx b/src/components/FallbackToolUseRejectedMessage.tsx index 0c7252527..95ec389cc 100644 --- a/src/components/FallbackToolUseRejectedMessage.tsx +++ b/src/components/FallbackToolUseRejectedMessage.tsx @@ -1,15 +1,11 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { InterruptedByUser } from './InterruptedByUser.js'; -import { MessageResponse } from './MessageResponse.js'; -export function FallbackToolUseRejectedMessage() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { InterruptedByUser } from './InterruptedByUser.js' +import { MessageResponse } from './MessageResponse.js' + +export function FallbackToolUseRejectedMessage(): React.ReactNode { + return ( + + + + ) } diff --git a/src/components/FastIcon.tsx b/src/components/FastIcon.tsx index 127d4e562..956c6290a 100644 --- a/src/components/FastIcon.tsx +++ b/src/components/FastIcon.tsx @@ -1,45 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import * as React from 'react'; -import { LIGHTNING_BOLT } from '../constants/figures.js'; -import { Text } from '../ink.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { resolveThemeSetting } from '../utils/systemTheme.js'; -import { color } from './design-system/color.js'; +import chalk from 'chalk' +import * as React from 'react' +import { LIGHTNING_BOLT } from '../constants/figures.js' +import { Text } from '../ink.js' +import { getGlobalConfig } from '../utils/config.js' +import { resolveThemeSetting } from '../utils/systemTheme.js' +import { color } from './design-system/color.js' + type Props = { - cooldown?: boolean; -}; -export function FastIcon(t0) { - const $ = _c(2); - const { - cooldown - } = t0; + cooldown?: boolean +} + +export function FastIcon({ cooldown }: Props): React.ReactNode { if (cooldown) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {LIGHTNING_BOLT}; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {LIGHTNING_BOLT}; - $[1] = t1; - } else { - t1 = $[1]; + return ( + + {LIGHTNING_BOLT} + + ) } - return t1; + return {LIGHTNING_BOLT} } + export function getFastIconString(applyColor = true, cooldown = false): string { if (!applyColor) { - return LIGHTNING_BOLT; + return LIGHTNING_BOLT } - const themeName = resolveThemeSetting(getGlobalConfig().theme); + const themeName = resolveThemeSetting(getGlobalConfig().theme) if (cooldown) { - return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT)); + return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT)) } - return color('fastMode', themeName)(LIGHTNING_BOLT); + return color('fastMode', themeName)(LIGHTNING_BOLT) } diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index 18603d613..5d3a3678f 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -1,213 +1,242 @@ -import axios from 'axios'; -import { readFile, stat } from 'fs/promises'; -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { getLastAPIRequest } from 'src/bootstrap/state.js'; -import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { getLastAssistantMessage, normalizeMessagesForAPI } from 'src/utils/messages.js'; -import type { CommandResultDisplay } from '../commands.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box, Text, useInput } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { queryHaiku } from '../services/api/claude.js'; -import { startsWithApiErrorPrefix } from '../services/api/errors.js'; -import type { Message } from '../types/message.js'; -import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js'; -import { openBrowser } from '../utils/browser.js'; -import { logForDebugging } from '../utils/debug.js'; -import { env } from '../utils/env.js'; -import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js'; -import { getAuthHeaders, getUserAgent } from '../utils/http.js'; -import { getInMemoryErrors, logError } from '../utils/log.js'; -import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'; -import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js'; -import { jsonStringify } from '../utils/slowOperations.js'; -import { asSystemPrompt } from '../utils/systemPromptType.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import TextInput from './TextInput.js'; +import axios from 'axios' +import { readFile, stat } from 'fs/promises' +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import { getLastAPIRequest } from 'src/bootstrap/state.js' +import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + getLastAssistantMessage, + normalizeMessagesForAPI, +} from 'src/utils/messages.js' +import type { CommandResultDisplay } from '../commands.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box, Text, useInput } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { queryHaiku } from '../services/api/claude.js' +import { startsWithApiErrorPrefix } from '../services/api/errors.js' +import type { Message } from '../types/message.js' +import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js' +import { openBrowser } from '../utils/browser.js' +import { logForDebugging } from '../utils/debug.js' +import { env } from '../utils/env.js' +import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js' +import { getAuthHeaders, getUserAgent } from '../utils/http.js' +import { getInMemoryErrors, logError } from '../utils/log.js' +import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' +import { + extractTeammateTranscriptsFromTasks, + getTranscriptPath, + loadAllSubagentTranscriptsFromDisk, + MAX_TRANSCRIPT_READ_BYTES, +} from '../utils/sessionStorage.js' +import { jsonStringify } from '../utils/slowOperations.js' +import { asSystemPrompt } from '../utils/systemPromptType.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import TextInput from './TextInput.js' // This value was determined experimentally by testing the URL length limit -const GITHUB_URL_LIMIT = 7250; -const GITHUB_ISSUES_REPO_URL = (process.env.USER_TYPE) === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' : 'https://github.com/anthropics/claude-code/issues'; +const GITHUB_URL_LIMIT = 7250 +const GITHUB_ISSUES_REPO_URL = + process.env.USER_TYPE === 'ant' + ? 'https://github.com/anthropics/claude-cli-internal/issues' + : 'https://github.com/anthropics/claude-code/issues' + type Props = { - abortSignal: AbortSignal; - messages: Message[]; - initialDescription?: string; - onDone(result: string, options?: { - display?: CommandResultDisplay; - }): void; + abortSignal: AbortSignal + messages: Message[] + initialDescription?: string + onDone(result: string, options?: { display?: CommandResultDisplay }): void backgroundTasks?: { [taskId: string]: { - type: string; - identity?: { - agentId: string; - }; - messages?: Message[]; - }; - }; -}; -type Step = 'userInput' | 'consent' | 'submitting' | 'done'; + type: string + identity?: { agentId: string } + messages?: Message[] + } + } +} + +type Step = 'userInput' | 'consent' | 'submitting' | 'done' + type FeedbackData = { // latestAssistantMessageId is the message ID from the latest main model call - latestAssistantMessageId: string | null; - message_count: number; - datetime: string; - description: string; - platform: string; - gitRepo: boolean; - terminal: string; - version: string | null; - transcript: Message[]; - errors: unknown; - lastApiRequest: unknown; - subagentTranscripts?: { - [agentId: string]: Message[]; - }; - rawTranscriptJsonl?: string; -}; + latestAssistantMessageId: string | null + message_count: number + datetime: string + description: string + platform: string + gitRepo: boolean + version: string | null + transcript: Message[] + subagentTranscripts?: { [agentId: string]: Message[] } + rawTranscriptJsonl?: string +} // Utility function to redact sensitive information from strings export function redactSensitiveInfo(text: string): string { - let redacted = text; + let redacted = text // Anthropic API keys (sk-ant...) with or without quotes // First handle the case with quotes - redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"'); + redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"') // Then handle the cases without quotes - more general pattern redacted = redacted.replace( - // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, string) on /bug path: no-match returns same string (Object.is) - /(? { // Sanitize error logs to remove any API keys return getInMemoryErrors().map(errorInfo => { // Create a copy of the error info to avoid modifying the original - const errorCopy = { - ...errorInfo - } as { - error?: string; - timestamp?: string; - }; + const errorCopy = { ...errorInfo } as { error?: string; timestamp?: string } // Sanitize error if present and is a string if (errorCopy && typeof errorCopy.error === 'string') { - errorCopy.error = redactSensitiveInfo(errorCopy.error); + errorCopy.error = redactSensitiveInfo(errorCopy.error) } - return errorCopy; - }); + + return errorCopy + }) } + async function loadRawTranscriptJsonl(): Promise { try { - const transcriptPath = getTranscriptPath(); - const { - size - } = await stat(transcriptPath); + const transcriptPath = getTranscriptPath() + const { size } = await stat(transcriptPath) if (size > MAX_TRANSCRIPT_READ_BYTES) { - logForDebugging(`Skipping raw transcript read: file too large (${size} bytes)`, { - level: 'warn' - }); - return null; + logForDebugging( + `Skipping raw transcript read: file too large (${size} bytes)`, + { level: 'warn' }, + ) + return null } - return await readFile(transcriptPath, 'utf-8'); + return await readFile(transcriptPath, 'utf-8') } catch { - return null; + return null } } + export function Feedback({ abortSignal, messages, initialDescription, onDone, - backgroundTasks = {} + backgroundTasks = {}, }: Props): React.ReactNode { - const [step, setStep] = useState('userInput'); - const [cursorOffset, setCursorOffset] = useState(0); - const [description, setDescription] = useState(initialDescription ?? ''); - const [feedbackId, setFeedbackId] = useState(null); - const [error, setError] = useState(null); + const [step, setStep] = useState('userInput') + const [cursorOffset, setCursorOffset] = useState(0) + const [description, setDescription] = useState(initialDescription ?? '') + const [feedbackId, setFeedbackId] = useState(null) + const [error, setError] = useState(null) const [envInfo, setEnvInfo] = useState<{ - isGit: boolean; - gitState: GitRepoState | null; - }>({ - isGit: false, - gitState: null - }); - const [title, setTitle] = useState(null); - const textInputColumns = useTerminalSize().columns - 4; + isGit: boolean + gitState: GitRepoState | null + }>({ isGit: false, gitState: null }) + const [title, setTitle] = useState(null) + const textInputColumns = useTerminalSize().columns - 4 + useEffect(() => { async function loadEnvInfo() { - const isGit = await getIsGit(); - let gitState: GitRepoState | null = null; + const isGit = await getIsGit() + let gitState: GitRepoState | null = null if (isGit) { - gitState = await getGitState(); + gitState = await getGitState() } - setEnvInfo({ - isGit, - gitState - }); + setEnvInfo({ isGit, gitState }) } - void loadEnvInfo(); - }, []); + void loadEnvInfo() + }, []) + const submitReport = useCallback(async () => { - setStep('submitting'); - setError(null); - setFeedbackId(null); + setStep('submitting') + setError(null) + setFeedbackId(null) // Get sanitized errors for the report - const sanitizedErrors = getSanitizedErrorLogs(); + const sanitizedErrors = getSanitizedErrorLogs() // Extract last assistant message ID from messages array - const lastAssistantMessage = getLastAssistantMessage(messages); - const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null; - const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([loadAllSubagentTranscriptsFromDisk(), loadRawTranscriptJsonl()]); - const teammateTranscripts = extractTeammateTranscriptsFromTasks(backgroundTasks); - const subagentTranscripts = { - ...diskTranscripts, - ...teammateTranscripts - }; - const reportData: FeedbackData = { - latestAssistantMessageId: lastAssistantMessageId as string | null, + const lastAssistantMessage = getLastAssistantMessage(messages) + const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null + + const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([ + loadAllSubagentTranscriptsFromDisk(), + loadRawTranscriptJsonl(), + ]) + const teammateTranscripts = + extractTeammateTranscriptsFromTasks(backgroundTasks) + const subagentTranscripts = { ...diskTranscripts, ...teammateTranscripts } + + const reportData = { + latestAssistantMessageId: lastAssistantMessageId, message_count: messages.length, datetime: new Date().toISOString(), description, @@ -219,38 +248,49 @@ export function Feedback({ errors: sanitizedErrors, lastApiRequest: getLastAPIRequest(), ...(Object.keys(subagentTranscripts).length > 0 && { - subagentTranscripts + subagentTranscripts, }), - ...(rawTranscriptJsonl && { - rawTranscriptJsonl - }) - }; - const [result, t] = await Promise.all([submitFeedback(reportData, abortSignal), generateTitle(description, abortSignal)]); - setTitle(t); + ...(rawTranscriptJsonl && { rawTranscriptJsonl }), + } + + const [result, t] = await Promise.all([ + submitFeedback(reportData, abortSignal), + generateTitle(description, abortSignal), + ]) + + setTitle(t) + if (result.success) { if (result.feedbackId) { - setFeedbackId(result.feedbackId); + setFeedbackId(result.feedbackId) logEvent('tengu_bug_report_submitted', { - feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + feedback_id: + result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) // 1P-only: freeform text approved for BQ. Join on feedback_id. logEventTo1P('tengu_bug_report_description', { - feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - description: redactSensitiveInfo(description) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + feedback_id: + result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + description: redactSensitiveInfo( + description, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } - setStep('done'); + setStep('done') } else { if (result.isZdrOrg) { - setError('Feedback collection is not available for organizations with custom data retention policies.'); + setError( + 'Feedback collection is not available for organizations with custom data retention policies.', + ) } else { - setError('Could not submit feedback. Please try again later.'); + setError('Could not submit feedback. Please try again later.') } // Stay on userInput step so user can retry with their content preserved - setStep('userInput'); + setStep('userInput') } - }, [description, envInfo.isGit, messages]); + }, [description, envInfo.isGit, messages]) // Handle cancel - this will be called by Dialog's automatic Esc handling const handleCancel = useCallback(() => { @@ -258,85 +298,125 @@ export function Feedback({ if (step === 'done') { if (error) { onDone('Error submitting feedback / bug report', { - display: 'system' - }); + display: 'system', + }) } else { - onDone('Feedback / bug report submitted', { - display: 'system' - }); + onDone('Feedback / bug report submitted', { display: 'system' }) } - return; + return } - onDone('Feedback / bug report cancelled', { - display: 'system' - }); - }, [step, error, onDone]); + onDone('Feedback / bug report cancelled', { display: 'system' }) + }, [step, error, onDone]) // During text input, use Settings context where only Escape (not 'n') triggers confirm:no. // This allows typing 'n' in the text field while still supporting Escape to cancel. useKeybinding('confirm:no', handleCancel, { context: 'Settings', - isActive: step === 'userInput' - }); + isActive: step === 'userInput', + }) + useInput((input, key) => { // Allow any key press to close the dialog when done or when there's an error if (step === 'done') { if (key.return && title) { // Open GitHub issue URL when Enter is pressed - const issueUrl = createGitHubIssueUrl(feedbackId ?? '', title, description, getSanitizedErrorLogs()); - void openBrowser(issueUrl); + const issueUrl = createGitHubIssueUrl( + feedbackId ?? '', + title, + description, + getSanitizedErrorLogs(), + ) + void openBrowser(issueUrl) } if (error) { onDone('Error submitting feedback / bug report', { - display: 'system' - }); + display: 'system', + }) } else { - onDone('Feedback / bug report submitted', { - display: 'system' - }); + onDone('Feedback / bug report submitted', { display: 'system' }) } - return; + return } // When in userInput step with error, allow user to edit and retry // (don't close on any keypress - they can still press Esc to cancel) if (error && step !== 'userInput') { onDone('Error submitting feedback / bug report', { - display: 'system' - }); - return; + display: 'system', + }) + return } + if (step === 'consent' && (key.return || input === ' ')) { - void submitReport(); + void submitReport() } - }); - return exitState.pending ? Press {exitState.keyName} again to exit : step === 'userInput' ? + }) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : step === 'userInput' ? ( + - - : step === 'consent' ? + + + ) : step === 'consent' ? ( + - - : null}> - {step === 'userInput' && + + + ) : null + } + > + {step === 'userInput' && ( + Describe the issue below: - { - setDescription(value); - // Clear error when user starts editing to allow retry - if (error) { - setError(null); - } - }} columns={textInputColumns} onSubmit={() => setStep('consent')} onExitMessage={() => onDone('Feedback cancelled', { - display: 'system' - })} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} showCursor /> - {error && + { + setDescription(value) + // Clear error when user starts editing to allow retry + if (error) { + setError(null) + } + }} + columns={textInputColumns} + onSubmit={() => setStep('consent')} + onExitMessage={() => + onDone('Feedback cancelled', { display: 'system' }) + } + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + showCursor + /> + {error && ( + {error} Edit and press Enter to retry, or Esc to cancel - } - } + + )} + + )} - {step === 'consent' && + {step === 'consent' && ( + This report will include: @@ -349,16 +429,22 @@ export function Feedback({ {env.platform}, {env.terminal}, v{MACRO.VERSION} - {envInfo.gitState && + {envInfo.gitState && ( + - Git repo metadata:{' '} {envInfo.gitState.branchName} - {envInfo.gitState.commitHash ? `, ${envInfo.gitState.commitHash.slice(0, 7)}` : ''} - {envInfo.gitState.remoteUrl ? ` @ ${envInfo.gitState.remoteUrl}` : ''} + {envInfo.gitState.commitHash + ? `, ${envInfo.gitState.commitHash.slice(0, 7)}` + : ''} + {envInfo.gitState.remoteUrl + ? ` @ ${envInfo.gitState.remoteUrl}` + : ''} {!envInfo.gitState.isHeadOnRemote && ', not synced'} {!envInfo.gitState.isClean && ', has local changes'} - } + + )} - Current session transcript @@ -373,14 +459,22 @@ export function Feedback({ Press Enter to confirm and submit. - } + + )} - {step === 'submitting' && + {step === 'submitting' && ( + Submitting report… - } - - {step === 'done' && - {error ? {error} : Thank you for your report!} + + )} + + {step === 'done' && ( + + {error ? ( + {error} + ) : ( + Thank you for your report! + )} {feedbackId && Feedback ID: {feedbackId}} Press @@ -390,67 +484,125 @@ export function Feedback({ close. - } - ; + + )} + + ) } -export function createGitHubIssueUrl(feedbackId: string, title: string, description: string, errors: Array<{ - error?: string; - timestamp?: string; -}>): string { - const sanitizedTitle = redactSensitiveInfo(title); - const sanitizedDescription = redactSensitiveInfo(description); - const bodyPrefix = `**Bug Description**\n${sanitizedDescription}\n\n` + `**Environment Info**\n` + `- Platform: ${env.platform}\n` + `- Terminal: ${env.terminal}\n` + `- Version: ${MACRO.VERSION || 'unknown'}\n` + `- Feedback ID: ${feedbackId}\n` + `\n**Errors**\n\`\`\`json\n`; - const errorSuffix = `\n\`\`\`\n`; - const errorsJson = jsonStringify(errors); - const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`; - const truncationNote = `\n**Note:** Content was truncated.\n`; - const encodedPrefix = encodeURIComponent(bodyPrefix); - const encodedSuffix = encodeURIComponent(errorSuffix); - const encodedNote = encodeURIComponent(truncationNote); - const encodedErrors = encodeURIComponent(errorsJson); + +export function createGitHubIssueUrl( + feedbackId: string, + title: string, + description: string, + errors: Array<{ + error?: string + timestamp?: string + }>, +): string { + const sanitizedTitle = redactSensitiveInfo(title) + const sanitizedDescription = redactSensitiveInfo(description) + + const bodyPrefix = + `**Bug Description**\n${sanitizedDescription}\n\n` + + `**Environment Info**\n` + + `- Platform: ${env.platform}\n` + + `- Terminal: ${env.terminal}\n` + + `- Version: ${MACRO.VERSION || 'unknown'}\n` + + `- Feedback ID: ${feedbackId}\n` + + `\n**Errors**\n\`\`\`json\n` + const errorSuffix = `\n\`\`\`\n` + const errorsJson = jsonStringify(errors) + + const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=` + const truncationNote = `\n**Note:** Content was truncated.\n` + + const encodedPrefix = encodeURIComponent(bodyPrefix) + const encodedSuffix = encodeURIComponent(errorSuffix) + const encodedNote = encodeURIComponent(truncationNote) + const encodedErrors = encodeURIComponent(errorsJson) // Calculate space available for errors - const spaceForErrors = GITHUB_URL_LIMIT - baseUrl.length - encodedPrefix.length - encodedSuffix.length - encodedNote.length; + const spaceForErrors = + GITHUB_URL_LIMIT - + baseUrl.length - + encodedPrefix.length - + encodedSuffix.length - + encodedNote.length // If description alone exceeds limit, truncate everything if (spaceForErrors <= 0) { - const ellipsis = encodeURIComponent('…'); - const buffer = 50; // Extra safety margin - const maxEncodedLength = GITHUB_URL_LIMIT - baseUrl.length - ellipsis.length - encodedNote.length - buffer; - const fullBody = bodyPrefix + errorsJson + errorSuffix; - let encodedFullBody = encodeURIComponent(fullBody); + const ellipsis = encodeURIComponent('…') + const buffer = 50 // Extra safety margin + const maxEncodedLength = + GITHUB_URL_LIMIT - + baseUrl.length - + ellipsis.length - + encodedNote.length - + buffer + const fullBody = bodyPrefix + errorsJson + errorSuffix + let encodedFullBody = encodeURIComponent(fullBody) + if (encodedFullBody.length > maxEncodedLength) { - encodedFullBody = encodedFullBody.slice(0, maxEncodedLength); + encodedFullBody = encodedFullBody.slice(0, maxEncodedLength) // Don't cut in middle of %XX sequence - const lastPercent = encodedFullBody.lastIndexOf('%'); + const lastPercent = encodedFullBody.lastIndexOf('%') if (lastPercent >= encodedFullBody.length - 2) { - encodedFullBody = encodedFullBody.slice(0, lastPercent); + encodedFullBody = encodedFullBody.slice(0, lastPercent) } } - return baseUrl + encodedFullBody + ellipsis + encodedNote; + + return baseUrl + encodedFullBody + ellipsis + encodedNote } // If errors fit, no truncation needed if (encodedErrors.length <= spaceForErrors) { - return baseUrl + encodedPrefix + encodedErrors + encodedSuffix; + return baseUrl + encodedPrefix + encodedErrors + encodedSuffix } // Truncate errors to fit (prioritize keeping description) // Slice encoded errors directly, then trim to avoid cutting %XX sequences - const ellipsis = encodeURIComponent('…'); - const buffer = 50; // Extra safety margin - let truncatedEncodedErrors = encodedErrors.slice(0, spaceForErrors - ellipsis.length - buffer); + const ellipsis = encodeURIComponent('…') + const buffer = 50 // Extra safety margin + let truncatedEncodedErrors = encodedErrors.slice( + 0, + spaceForErrors - ellipsis.length - buffer, + ) // If we cut in middle of %XX, back up to before the % - const lastPercent = truncatedEncodedErrors.lastIndexOf('%'); + const lastPercent = truncatedEncodedErrors.lastIndexOf('%') if (lastPercent >= truncatedEncodedErrors.length - 2) { - truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent); + truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent) } - return baseUrl + encodedPrefix + truncatedEncodedErrors + ellipsis + encodedSuffix + encodedNote; + + return ( + baseUrl + + encodedPrefix + + truncatedEncodedErrors + + ellipsis + + encodedSuffix + + encodedNote + ) } -async function generateTitle(description: string, abortSignal: AbortSignal): Promise { + +async function generateTitle( + description: string, + abortSignal: AbortSignal, +): Promise { try { const response = await queryHaiku({ - systemPrompt: asSystemPrompt(['Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for Claude Code.', 'Claude Code is an agentic coding CLI based on the Anthropic API.', 'The title should:', '- Include the type of issue [Bug] or [Feature Request] as the first thing in the title', '- Be concise, specific and descriptive of the actual problem', '- Use technical terminology appropriate for a software issue', '- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)', '- Be direct and clear for developers to understand the problem', '- If you cannot determine a clear issue, use "Bug Report: [brief description]"', '- Any LLM API errors are from the Anthropic API, not from any other model provider', 'Your response will be directly used as the title of the Github issue, and as such should not contain any other commentary or explaination', 'Examples of good titles include: "[Bug] Auto-Compact triggers to soon", "[Bug] Anthropic API Error: Missing Tool Result Block", "[Bug] Error: Invalid Model Name for Opus"']), + systemPrompt: asSystemPrompt([ + 'Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for Claude Code.', + 'Claude Code is an agentic coding CLI based on the Anthropic API.', + 'The title should:', + '- Include the type of issue [Bug] or [Feature Request] as the first thing in the title', + '- Be concise, specific and descriptive of the actual problem', + '- Use technical terminology appropriate for a software issue', + '- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)', + '- Be direct and clear for developers to understand the problem', + '- If you cannot determine a clear issue, use "Bug Report: [brief description]"', + '- Any LLM API errors are from the Anthropic API, not from any other model provider', + 'Your response will be directly used as the title of the Github issue, and as such should not contain any other commentary or explaination', + 'Examples of good titles include: "[Bug] Auto-Compact triggers to soon", "[Bug] Anthropic API Error: Missing Tool Result Block", "[Bug] Error: Invalid Model Name for Opus"', + ]), userPrompt: description, signal: abortSignal, options: { @@ -459,137 +611,149 @@ async function generateTitle(description: string, abortSignal: AbortSignal): Pro isNonInteractiveSession: false, agents: [], querySource: 'feedback', - mcpTools: [] - } - }); - const firstBlock = Array.isArray(response.message.content) ? response.message.content[0] : undefined; - const title = firstBlock && typeof firstBlock !== 'string' && firstBlock.type === 'text' ? firstBlock.text : 'Bug Report'; + mcpTools: [], + }, + }) + + const title = + response.message.content[0]?.type === 'text' + ? response.message.content[0].text + : 'Bug Report' // Check if the title contains an API error message if (startsWithApiErrorPrefix(title)) { - return createFallbackTitle(description); + return createFallbackTitle(description) } - return title; + + return title } catch (error) { // If there's any error in title generation, use a fallback title - logError(error); - return createFallbackTitle(description); + logError(error) + return createFallbackTitle(description) } } + function createFallbackTitle(description: string): string { // Create a safe fallback title based on the bug description // Try to extract a meaningful title from the first line - const firstLine = description.split('\n')[0] || ''; + const firstLine = description.split('\n')[0] || '' // If the first line is very short, use it directly if (firstLine.length <= 60 && firstLine.length > 5) { - return firstLine; + return firstLine } // For longer descriptions, create a truncated version // Truncate at word boundaries when possible - let truncated = firstLine.slice(0, 60); + let truncated = firstLine.slice(0, 60) if (firstLine.length > 60) { // Find the last space before the 60 char limit - const lastSpace = truncated.lastIndexOf(' '); + const lastSpace = truncated.lastIndexOf(' ') if (lastSpace > 30) { // Only trim at word if we're not cutting too much - truncated = truncated.slice(0, lastSpace); + truncated = truncated.slice(0, lastSpace) } - truncated += '...'; + truncated += '...' } - return truncated.length < 10 ? 'Bug Report' : truncated; + + return truncated.length < 10 ? 'Bug Report' : truncated } // Helper function to sanitize and log errors without exposing API keys function sanitizeAndLogError(err: unknown): void { if (err instanceof Error) { // Create a copy with potentially sensitive info redacted - const safeError = new Error(redactSensitiveInfo(err.message)); + const safeError = new Error(redactSensitiveInfo(err.message)) // Also redact the stack trace if present if (err.stack) { - safeError.stack = redactSensitiveInfo(err.stack); + safeError.stack = redactSensitiveInfo(err.stack) } - logError(safeError); + + logError(safeError) } else { // For non-Error objects, convert to string and redact sensitive info - const errorString = redactSensitiveInfo(String(err)); - logError(new Error(errorString)); + const errorString = redactSensitiveInfo(String(err)) + logError(new Error(errorString)) } } -async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{ - success: boolean; - feedbackId?: string; - isZdrOrg?: boolean; -}> { + +async function submitFeedback( + data: FeedbackData, + signal?: AbortSignal, +): Promise<{ success: boolean; feedbackId?: string; isZdrOrg?: boolean }> { if (isEssentialTrafficOnly()) { - return { - success: false - }; + return { success: false } } + try { // Ensure OAuth token is fresh before getting auth headers // This prevents 401 errors from stale cached tokens - await checkAndRefreshOAuthTokenIfNeeded(); - const authResult = getAuthHeaders(); + await checkAndRefreshOAuthTokenIfNeeded() + + const authResult = getAuthHeaders() if (authResult.error) { - return { - success: false - }; + return { success: false } } + const headers: Record = { 'Content-Type': 'application/json', 'User-Agent': getUserAgent(), - ...authResult.headers - }; - const response = await axios.post('https://api.anthropic.com/api/claude_cli_feedback', { - content: jsonStringify(data) - }, { - headers, - timeout: 30000, - // 30 second timeout to prevent hanging - signal - }); + ...authResult.headers, + } + + const response = await axios.post( + 'https://api.anthropic.com/api/claude_cli_feedback', + { + content: jsonStringify(data), + }, + { + headers, + timeout: 30000, // 30 second timeout to prevent hanging + signal, + }, + ) + if (response.status === 200) { - const result = response.data; + const result = response.data if (result?.feedback_id) { - return { - success: true, - feedbackId: result.feedback_id - }; + return { success: true, feedbackId: result.feedback_id } } - sanitizeAndLogError(new Error('Failed to submit feedback: request did not return feedback_id')); - return { - success: false - }; + sanitizeAndLogError( + new Error( + 'Failed to submit feedback: request did not return feedback_id', + ), + ) + return { success: false } } - sanitizeAndLogError(new Error('Failed to submit feedback:' + response.status)); - return { - success: false - }; + + sanitizeAndLogError( + new Error('Failed to submit feedback:' + response.status), + ) + return { success: false } } catch (err) { // Handle cancellation/abort - don't log as error if (axios.isCancel(err)) { - return { - success: false - }; + return { success: false } } + if (axios.isAxiosError(err) && err.response?.status === 403) { - const errorData = err.response.data; - if (errorData?.error?.type === 'permission_error' && errorData?.error?.message?.includes('Custom data retention settings')) { - sanitizeAndLogError(new Error('Cannot submit feedback because custom data retention settings are enabled')); - return { - success: false, - isZdrOrg: true - }; + const errorData = err.response.data + if ( + errorData?.error?.type === 'permission_error' && + errorData?.error?.message?.includes('Custom data retention settings') + ) { + sanitizeAndLogError( + new Error( + 'Cannot submit feedback because custom data retention settings are enabled', + ), + ) + return { success: false, isZdrOrg: true } } } // Use our safe error logging function to avoid leaking API keys - sanitizeAndLogError(err); - return { - success: false - }; + sanitizeAndLogError(err) + return { success: false } } } diff --git a/src/components/FileEditToolDiff.tsx b/src/components/FileEditToolDiff.tsx index 7b3219563..a5146d3c8 100644 --- a/src/components/FileEditToolDiff.tsx +++ b/src/components/FileEditToolDiff.tsx @@ -1,180 +1,180 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import * as React from 'react'; -import { Suspense, use, useState } from 'react'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box, Text } from '../ink.js'; -import type { FileEdit } from '../tools/FileEditTool/types.js'; -import { findActualString, preserveQuoteStyle } from '../tools/FileEditTool/utils.js'; -import { adjustHunkLineNumbers, CONTEXT_LINES, getPatchForDisplay } from '../utils/diff.js'; -import { logError } from '../utils/log.js'; -import { CHUNK_SIZE, openForScan, readCapped, scanForContext } from '../utils/readEditContext.js'; -import { firstLineOf } from '../utils/stringUtils.js'; -import { StructuredDiffList } from './StructuredDiffList.js'; +import type { StructuredPatchHunk } from 'diff' +import * as React from 'react' +import { Suspense, use, useState } from 'react' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box, Text } from '../ink.js' +import type { FileEdit } from '../tools/FileEditTool/types.js' +import { + findActualString, + preserveQuoteStyle, +} from '../tools/FileEditTool/utils.js' +import { + adjustHunkLineNumbers, + CONTEXT_LINES, + getPatchForDisplay, +} from '../utils/diff.js' +import { logError } from '../utils/log.js' +import { + CHUNK_SIZE, + openForScan, + readCapped, + scanForContext, +} from '../utils/readEditContext.js' +import { firstLineOf } from '../utils/stringUtils.js' +import { StructuredDiffList } from './StructuredDiffList.js' + type Props = { - file_path: string; - edits: FileEdit[]; -}; + file_path: string + edits: FileEdit[] +} + type DiffData = { - patch: StructuredPatchHunk[]; - firstLine: string | null; - fileContent: string | undefined; -}; -export function FileEditToolDiff(props) { - const $ = _c(7); - let t0; - if ($[0] !== props.edits || $[1] !== props.file_path) { - t0 = () => loadDiffData(props.file_path, props.edits); - $[0] = props.edits; - $[1] = props.file_path; - $[2] = t0; - } else { - t0 = $[2]; - } - const [dataPromise] = useState(t0); - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== dataPromise || $[5] !== props.file_path) { - t2 = ; - $[4] = dataPromise; - $[5] = props.file_path; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; + patch: StructuredPatchHunk[] + firstLine: string | null + fileContent: string | undefined } -function DiffBody(t0: { promise: Promise; file_path: string }) { - const $ = _c(6); - const { - promise, - file_path - } = t0; - const { - patch, - firstLine, - fileContent - } = use(promise); - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== columns || $[1] !== fileContent || $[2] !== file_path || $[3] !== firstLine || $[4] !== patch) { - t1 = ; - $[0] = columns; - $[1] = fileContent; - $[2] = file_path; - $[3] = firstLine; - $[4] = patch; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; + +export function FileEditToolDiff(props: Props): React.ReactNode { + // Snapshot on mount — the diff must stay consistent even if the file changes + // while the dialog is open. useMemo on props.edits would re-read the file on + // every render because callers pass fresh array literals. + const [dataPromise] = useState(() => + loadDiffData(props.file_path, props.edits), + ) + return ( + }> + + + ) } -function DiffFrame(t0) { - const $ = _c(5); - const { - children, - placeholder - } = t0; - let t1; - if ($[0] !== children || $[1] !== placeholder) { - t1 = placeholder ? : children; - $[0] = children; - $[1] = placeholder; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== t1) { - t2 = {t1}; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + +function DiffBody({ + promise, + file_path, +}: { + promise: Promise + file_path: string +}): React.ReactNode { + const { patch, firstLine, fileContent } = use(promise) + const { columns } = useTerminalSize() + return ( + + + + ) } -async function loadDiffData(file_path: string, edits: FileEdit[]): Promise { - const valid = edits.filter(e => e.old_string != null && e.new_string != null); - const single = valid.length === 1 ? valid[0]! : undefined; + +function DiffFrame({ + children, + placeholder, +}: { + children?: React.ReactNode + placeholder?: boolean +}): React.ReactNode { + return ( + + + {placeholder ? : children} + + + ) +} + +async function loadDiffData( + file_path: string, + edits: FileEdit[], +): Promise { + const valid = edits.filter(e => e.old_string != null && e.new_string != null) + const single = valid.length === 1 ? valid[0]! : undefined // SedEditPermissionRequest passes the entire file as old_string. Scanning for // a needle ≥ CHUNK_SIZE allocates O(needle) for the overlap buffer — skip the // file read entirely and diff the inputs we already have. if (single && single.old_string.length >= CHUNK_SIZE) { - return diffToolInputsOnly(file_path, [single]); + return diffToolInputsOnly(file_path, [single]) } + try { - const handle = await openForScan(file_path); - if (handle === null) return diffToolInputsOnly(file_path, valid); + const handle = await openForScan(file_path) + if (handle === null) return diffToolInputsOnly(file_path, valid) try { // Multi-edit and empty old_string genuinely need full-file for sequential // replacements — structuredPatch needs before/after strings. replace_all // routes through the chunked path below (shows first-occurrence window; // matches within the slice still replace via edit.replace_all). if (!single || single.old_string === '') { - const file = await readCapped(handle); - if (file === null) return diffToolInputsOnly(file_path, valid); - const normalized = valid.map(e => normalizeEdit(file, e)); + const file = await readCapped(handle) + if (file === null) return diffToolInputsOnly(file_path, valid) + const normalized = valid.map(e => normalizeEdit(file, e)) return { patch: getPatchForDisplay({ filePath: file_path, fileContents: file, - edits: normalized + edits: normalized, }), firstLine: firstLineOf(file), - fileContent: file - }; + fileContent: file, + } } - const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES); + + const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES) if (ctx.truncated || ctx.content === '') { - return diffToolInputsOnly(file_path, [single]); + return diffToolInputsOnly(file_path, [single]) } - const normalized = normalizeEdit(ctx.content, single); + const normalized = normalizeEdit(ctx.content, single) const hunks = getPatchForDisplay({ filePath: file_path, fileContents: ctx.content, - edits: [normalized] - }); + edits: [normalized], + }) return { patch: adjustHunkLineNumbers(hunks, ctx.lineOffset - 1), firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null, - fileContent: ctx.content - }; + fileContent: ctx.content, + } } finally { - await handle.close(); + await handle.close() } } catch (e) { - logError(e as Error); - return diffToolInputsOnly(file_path, valid); + logError(e as Error) + return diffToolInputsOnly(file_path, valid) } } + function diffToolInputsOnly(filePath: string, edits: FileEdit[]): DiffData { return { - patch: edits.flatMap(e => getPatchForDisplay({ - filePath, - fileContents: e.old_string, - edits: [e] - })), + patch: edits.flatMap(e => + getPatchForDisplay({ + filePath, + fileContents: e.old_string, + edits: [e], + }), + ), firstLine: null, - fileContent: undefined - }; + fileContent: undefined, + } } + function normalizeEdit(fileContent: string, edit: FileEdit): FileEdit { - const actualOld = findActualString(fileContent, edit.old_string) || edit.old_string; - const actualNew = preserveQuoteStyle(edit.old_string, actualOld, edit.new_string); - return { - ...edit, - old_string: actualOld, - new_string: actualNew - }; + const actualOld = + findActualString(fileContent, edit.old_string) || edit.old_string + const actualNew = preserveQuoteStyle( + edit.old_string, + actualOld, + edit.new_string, + ) + return { ...edit, old_string: actualOld, new_string: actualNew } } diff --git a/src/components/FileEditToolUpdatedMessage.tsx b/src/components/FileEditToolUpdatedMessage.tsx index 6685ee857..0248583af 100644 --- a/src/components/FileEditToolUpdatedMessage.tsx +++ b/src/components/FileEditToolUpdatedMessage.tsx @@ -1,123 +1,86 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import * as React from 'react'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box, Text } from '../ink.js'; -import { count } from '../utils/array.js'; -import { MessageResponse } from './MessageResponse.js'; -import { StructuredDiffList } from './StructuredDiffList.js'; +import type { StructuredPatchHunk } from 'diff' +import * as React from 'react' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box, Text } from '../ink.js' +import { count } from '../utils/array.js' +import { MessageResponse } from './MessageResponse.js' +import { StructuredDiffList } from './StructuredDiffList.js' + type Props = { - filePath: string; - structuredPatch: StructuredPatchHunk[]; - firstLine: string | null; - fileContent?: string; - style?: 'condensed'; - verbose: boolean; - previewHint?: string; -}; -export function FileEditToolUpdatedMessage(t0) { - const $ = _c(22); - const { - filePath, - structuredPatch, - firstLine, - fileContent, - style, - verbose, - previewHint - } = t0; - const { - columns - } = useTerminalSize(); - const numAdditions = structuredPatch.reduce(_temp2, 0); - const numRemovals = structuredPatch.reduce(_temp4, 0); - let t1; - if ($[0] !== numAdditions) { - t1 = numAdditions > 0 ? <>Added {numAdditions}{" "}{numAdditions > 1 ? "lines" : "line"} : null; - $[0] = numAdditions; - $[1] = t1; - } else { - t1 = $[1]; - } - const t2 = numAdditions > 0 && numRemovals > 0 ? ", " : null; - let t3; - if ($[2] !== numAdditions || $[3] !== numRemovals) { - t3 = numRemovals > 0 ? <>{numAdditions === 0 ? "R" : "r"}emoved {numRemovals}{" "}{numRemovals > 1 ? "lines" : "line"} : null; - $[2] = numAdditions; - $[3] = numRemovals; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t1 || $[6] !== t2 || $[7] !== t3) { - t4 = {t1}{t2}{t3}; - $[5] = t1; - $[6] = t2; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; - } - const text = t4; + filePath: string + structuredPatch: StructuredPatchHunk[] + firstLine: string | null + fileContent?: string + style?: 'condensed' + verbose: boolean + previewHint?: string +} + +export function FileEditToolUpdatedMessage({ + filePath, + structuredPatch, + firstLine, + fileContent, + style, + verbose, + previewHint, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const numAdditions = structuredPatch.reduce( + (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), + 0, + ) + const numRemovals = structuredPatch.reduce( + (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), + 0, + ) + + const text = ( + + {numAdditions > 0 ? ( + <> + Added {numAdditions}{' '} + {numAdditions > 1 ? 'lines' : 'line'} + + ) : null} + {numAdditions > 0 && numRemovals > 0 ? ', ' : null} + {numRemovals > 0 ? ( + <> + {numAdditions === 0 ? 'R' : 'r'}emoved {numRemovals}{' '} + {numRemovals > 1 ? 'lines' : 'line'} + + ) : null} + + ) + + // Plan files: invert condensed behavior + // - Regular mode: just show the hint (user can type /plan to see full content) + // - Condensed mode (subagent view): show the diff if (previewHint) { - if (style !== "condensed" && !verbose) { - let t5; - if ($[9] !== previewHint) { - t5 = {previewHint}; - $[9] = previewHint; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; - } - } else { - if (style === "condensed" && !verbose) { - return text; + if (style !== 'condensed' && !verbose) { + return ( + + {previewHint} + + ) } + } else if (style === 'condensed' && !verbose) { + return text } - let t5; - if ($[11] !== text) { - t5 = {text}; - $[11] = text; - $[12] = t5; - } else { - t5 = $[12]; - } - const t6 = columns - 12; - let t7; - if ($[13] !== fileContent || $[14] !== filePath || $[15] !== firstLine || $[16] !== structuredPatch || $[17] !== t6) { - t7 = ; - $[13] = fileContent; - $[14] = filePath; - $[15] = firstLine; - $[16] = structuredPatch; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - let t8; - if ($[19] !== t5 || $[20] !== t7) { - t8 = {t5}{t7}; - $[19] = t5; - $[20] = t7; - $[21] = t8; - } else { - t8 = $[21]; - } - return t8; -} -function _temp4(acc_0, hunk_0) { - return acc_0 + count(hunk_0.lines, _temp3); -} -function _temp3(__0) { - return __0.startsWith("-"); -} -function _temp2(acc, hunk) { - return acc + count(hunk.lines, _temp); -} -function _temp(_) { - return _.startsWith("+"); + + return ( + + + {text} + + + + ) } diff --git a/src/components/FileEditToolUseRejectedMessage.tsx b/src/components/FileEditToolUseRejectedMessage.tsx index 09d4524f9..6171b0f65 100644 --- a/src/components/FileEditToolUseRejectedMessage.tsx +++ b/src/components/FileEditToolUseRejectedMessage.tsx @@ -1,169 +1,98 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import { relative } from 'path'; -import * as React from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { Box, Text } from '../ink.js'; -import { HighlightedCode } from './HighlightedCode.js'; -import { MessageResponse } from './MessageResponse.js'; -import { StructuredDiffList } from './StructuredDiffList.js'; -const MAX_LINES_TO_RENDER = 10; +import type { StructuredPatchHunk } from 'diff' +import { relative } from 'path' +import * as React from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { getCwd } from 'src/utils/cwd.js' +import { Box, Text } from '../ink.js' +import { HighlightedCode } from './HighlightedCode.js' +import { MessageResponse } from './MessageResponse.js' +import { StructuredDiffList } from './StructuredDiffList.js' + +const MAX_LINES_TO_RENDER = 10 + type Props = { - file_path: string; - operation: 'write' | 'update'; + file_path: string + operation: 'write' | 'update' // For updates - show diff - patch?: StructuredPatchHunk[]; - firstLine: string | null; - fileContent?: string; + patch?: StructuredPatchHunk[] + firstLine: string | null + fileContent?: string // For new file creation - show content preview - content?: string; - style?: 'condensed'; - verbose: boolean; -}; -export function FileEditToolUseRejectedMessage(t0) { - const $ = _c(38); - const { - file_path, - operation, - patch, - firstLine, - fileContent, - content, - style, - verbose - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== operation) { - t1 = User rejected {operation} to ; - $[0] = operation; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== file_path || $[3] !== verbose) { - t2 = verbose ? file_path : relative(getCwd(), file_path); - $[2] = file_path; - $[3] = verbose; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== t2) { - t3 = {t2}; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t1 || $[8] !== t3) { - t4 = {t1}{t3}; - $[7] = t1; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - const text = t4; - if (style === "condensed" && !verbose) { - let t5; - if ($[10] !== text) { - t5 = {text}; - $[10] = text; - $[11] = t5; - } else { - t5 = $[11]; - } - return t5; + content?: string + style?: 'condensed' + verbose: boolean +} + +export function FileEditToolUseRejectedMessage({ + file_path, + operation, + patch, + firstLine, + fileContent, + content, + style, + verbose, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const text = ( + + User rejected {operation} to + + {verbose ? file_path : relative(getCwd(), file_path)} + + + ) + + // For condensed style, just show the text + if (style === 'condensed' && !verbose) { + return {text} } - if (operation === "write" && content !== undefined) { - let plusLines; - let t5; - if ($[12] !== content || $[13] !== verbose) { - const lines = content.split("\n"); - const numLines = lines.length; - plusLines = numLines - MAX_LINES_TO_RENDER; - t5 = verbose ? content : lines.slice(0, MAX_LINES_TO_RENDER).join("\n"); - $[12] = content; - $[13] = verbose; - $[14] = plusLines; - $[15] = t5; - } else { - plusLines = $[14]; - t5 = $[15]; - } - const truncatedContent = t5; - const t6 = truncatedContent || "(No content)"; - const t7 = columns - 12; - let t8; - if ($[16] !== file_path || $[17] !== t6 || $[18] !== t7) { - t8 = ; - $[16] = file_path; - $[17] = t6; - $[18] = t7; - $[19] = t8; - } else { - t8 = $[19]; - } - let t9; - if ($[20] !== plusLines || $[21] !== verbose) { - t9 = !verbose && plusLines > 0 && … +{plusLines} lines; - $[20] = plusLines; - $[21] = verbose; - $[22] = t9; - } else { - t9 = $[22]; - } - let t10; - if ($[23] !== t8 || $[24] !== t9 || $[25] !== text) { - t10 = {text}{t8}{t9}; - $[23] = t8; - $[24] = t9; - $[25] = text; - $[26] = t10; - } else { - t10 = $[26]; - } - return t10; + + // For new file creation, show content preview (dimmed) + if (operation === 'write' && content !== undefined) { + const lines = content.split('\n') + const numLines = lines.length + const plusLines = numLines - MAX_LINES_TO_RENDER + const truncatedContent = verbose + ? content + : lines.slice(0, MAX_LINES_TO_RENDER).join('\n') + + return ( + + + {text} + + {!verbose && plusLines > 0 && ( + … +{plusLines} lines + )} + + + ) } + + // For updates, show diff if (!patch || patch.length === 0) { - let t5; - if ($[27] !== text) { - t5 = {text}; - $[27] = text; - $[28] = t5; - } else { - t5 = $[28]; - } - return t5; - } - const t5 = columns - 12; - let t6; - if ($[29] !== fileContent || $[30] !== file_path || $[31] !== firstLine || $[32] !== patch || $[33] !== t5) { - t6 = ; - $[29] = fileContent; - $[30] = file_path; - $[31] = firstLine; - $[32] = patch; - $[33] = t5; - $[34] = t6; - } else { - t6 = $[34]; - } - let t7; - if ($[35] !== t6 || $[36] !== text) { - t7 = {text}{t6}; - $[35] = t6; - $[36] = text; - $[37] = t7; - } else { - t7 = $[37]; + return {text} } - return t7; + + return ( + + + {text} + + + + ) } diff --git a/src/components/FilePathLink.tsx b/src/components/FilePathLink.tsx index 42a6adcfe..05a6167a2 100644 --- a/src/components/FilePathLink.tsx +++ b/src/components/FilePathLink.tsx @@ -1,42 +1,19 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { pathToFileURL } from 'url'; -import Link from '../ink/components/Link.js'; +import React from 'react' +import { pathToFileURL } from 'url' +import Link from '../ink/components/Link.js' + type Props = { /** The absolute file path */ - filePath: string; + filePath: string /** Optional display text (defaults to filePath) */ - children?: React.ReactNode; -}; + children?: React.ReactNode +} /** * Renders a file path as an OSC 8 hyperlink. * This helps terminals like iTerm correctly identify file paths * even when they appear inside parentheses or other text. */ -export function FilePathLink(t0) { - const $ = _c(5); - const { - filePath, - children - } = t0; - let t1; - if ($[0] !== filePath) { - t1 = pathToFileURL(filePath); - $[0] = filePath; - $[1] = t1; - } else { - t1 = $[1]; - } - const t2 = children ?? filePath; - let t3; - if ($[2] !== t1.href || $[3] !== t2) { - t3 = {t2}; - $[2] = t1.href; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; +export function FilePathLink({ filePath, children }: Props): React.ReactNode { + return {children ?? filePath} } diff --git a/src/components/FullscreenLayout.tsx b/src/components/FullscreenLayout.tsx index 9842ef505..8502e46de 100644 --- a/src/components/FullscreenLayout.tsx +++ b/src/components/FullscreenLayout.tsx @@ -1,70 +1,83 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { createContext, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { fileURLToPath } from 'url'; -import { ModalContext } from '../context/modalContext.js'; -import { PromptOverlayProvider, usePromptOverlay, usePromptOverlayDialog } from '../context/promptOverlayContext.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import ScrollBox, { type ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import instances from '../ink/instances.js'; -import { Box, Text } from '../ink.js'; -import type { Message } from '../types/message.js'; -import { openBrowser, openPath } from '../utils/browser.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import { plural } from '../utils/stringUtils.js'; -import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'; -import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js'; -import type { StickyPrompt } from './VirtualMessageList.js'; +import figures from 'figures' +import React, { + createContext, + type ReactNode, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { fileURLToPath } from 'url' +import { ModalContext } from '../context/modalContext.js' +import { + PromptOverlayProvider, + usePromptOverlay, + usePromptOverlayDialog, +} from '../context/promptOverlayContext.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import ScrollBox, { type ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import instances from '../ink/instances.js' +import { Box, Text } from '../ink.js' +import type { Message } from '../types/message.js' +import { openBrowser, openPath } from '../utils/browser.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import { plural } from '../utils/stringUtils.js' +import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js' +import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js' +import type { StickyPrompt } from './VirtualMessageList.js' /** Rows of transcript context kept visible above the modal pane's ▔ divider. */ -const MODAL_TRANSCRIPT_PEEK = 2; +const MODAL_TRANSCRIPT_PEEK = 2 /** Context for scroll-derived chrome (sticky header, pill). StickyTracker * in VirtualMessageList writes via this instead of threading a callback * up through Messages → REPL → FullscreenLayout. The setter is stable so * consuming this context never causes re-renders. */ export const ScrollChromeContext = createContext<{ - setStickyPrompt: (p: StickyPrompt | null) => void; -}>({ - setStickyPrompt: () => {} -}); + setStickyPrompt: (p: StickyPrompt | null) => void +}>({ setStickyPrompt: () => {} }) + type Props = { /** Content that scrolls (messages, tool output) */ - scrollable: ReactNode; + scrollable: ReactNode /** Content pinned to the bottom (spinner, prompt, permissions) */ - bottom: ReactNode; + bottom: ReactNode /** Content rendered inside the ScrollBox after messages — user can scroll * up to see context while it's showing (used by PermissionRequest). */ - overlay?: ReactNode; + overlay?: ReactNode /** Absolute-positioned content anchored at the bottom-right of the * ScrollBox area, floating over scrollback. Rendered inside the flexGrow * region (not the bottom slot) so the overflowY:hidden cap doesn't clip * it. Fullscreen only — used for the companion speech bubble. */ - bottomFloat?: ReactNode; + bottomFloat?: ReactNode /** Slash-command dialog content. Rendered in an absolute-positioned * bottom-anchored pane (▔ divider, paddingX=2) that paints over the * ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside * skip their own frame. Fullscreen only; inline after overlay otherwise. */ - modal?: ReactNode; + modal?: ReactNode /** Ref passed via ModalContext so Tabs (or any scroll-owning descendant) * can attach it to their own ScrollBox for tall content. */ - modalScrollRef?: React.RefObject; + modalScrollRef?: React.RefObject /** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so * pillVisible's useSyncExternalStore can subscribe to scroll changes. */ - scrollRef?: RefObject; + scrollRef?: RefObject /** Y-position (scrollHeight at snapshot) of the unseen-divider. Pill * shows while viewport bottom hasn't reached this. Ref so REPL doesn't * re-render on the one-shot snapshot write. */ - dividerYRef?: RefObject; + dividerYRef?: RefObject /** Force-hide the pill (e.g. viewing a sub-agent task). */ - hidePill?: boolean; + hidePill?: boolean /** Force-hide the sticky prompt header (e.g. viewing a teammate task). */ - hideSticky?: boolean; + hideSticky?: boolean /** Count for the pill text. 0 → "Jump to bottom", >0 → "N new messages". */ - newMessageCount?: number; + newMessageCount?: number /** Called when the user clicks the "N new" pill. */ - onPillClick?: () => void; -}; + onPillClick?: () => void +} /** * Tracks the in-transcript "N new messages" divider position while the @@ -87,41 +100,43 @@ export function useUnseenDivider(messageCount: number): { /** Index into messages[] where the divider line renders. Cleared on * sticky-resume (scroll back to bottom) so the "N new" line doesn't * linger once everything is visible. */ - dividerIndex: number | null; + dividerIndex: number | null /** scrollHeight snapshot at first scroll-away — the divider's y-position. * FullscreenLayout subscribes to ScrollBox and compares viewport bottom * against this for pillVisible. Ref so writes don't re-render REPL. */ - dividerYRef: RefObject; - onScrollAway: (handle: ScrollBoxHandle) => void; - onRepin: () => void; + dividerYRef: RefObject + onScrollAway: (handle: ScrollBoxHandle) => void + onRepin: () => void /** Scroll the handle so the divider line is at the top of the viewport. */ - jumpToNew: (handle: ScrollBoxHandle | null) => void; + jumpToNew: (handle: ScrollBoxHandle | null) => void /** Shift dividerIndex and dividerYRef when messages are prepended * (infinite scroll-back). indexDelta = number of messages prepended; * heightDelta = content height growth in rows. */ - shiftDivider: (indexDelta: number, heightDelta: number) => void; + shiftDivider: (indexDelta: number, heightDelta: number) => void } { - const [dividerIndex, setDividerIndex] = useState(null); + const [dividerIndex, setDividerIndex] = useState(null) // Ref holds the current count for onScrollAway to snapshot. Written in // the render body (not useEffect) so wheel events arriving between a // message-append render and its effect flush don't capture a stale // count (off-by-one in the baseline). React Compiler bails out here — // acceptable for a hook instantiated once in REPL. - const countRef = useRef(messageCount); - countRef.current = messageCount; + const countRef = useRef(messageCount) + countRef.current = messageCount // scrollHeight snapshot — the divider's y in content coords. Ref-only: // read synchronously in onScrollAway (setState is batched, can't // read-then-write in the same callback) AND by FullscreenLayout's // pillVisible subscription. null = pinned to bottom. - const dividerYRef = useRef(null); + const dividerYRef = useRef(null) + const onRepin = useCallback(() => { // Don't clear dividerYRef here — a trackpad momentum wheel event // racing in the same stdin batch would see null and re-snapshot, // overriding the setDividerIndex(null) below. The useEffect below // clears the ref after React commits the null dividerIndex, so the // ref stays non-null until the state settles. - setDividerIndex(null); - }, []); + setDividerIndex(null) + }, []) + const onScrollAway = useCallback((handle: ScrollBoxHandle) => { // Nothing below the viewport → nothing to jump to. Covers both: // • empty/short session: scrollUp calls scrollTo(0) which breaks sticky @@ -132,20 +147,24 @@ export function useUnseenDivider(messageCount: number): { // at max (Sarah Deaton, #claude-code-feedback 2026-03-15) // pendingDelta: scrollBy accumulates without updating scrollTop. Without // it, wheeling up from max would see scrollTop==max and suppress the pill. - const max = Math.max(0, handle.getScrollHeight() - handle.getViewportHeight()); - if (handle.getScrollTop() + handle.getPendingDelta() >= max) return; + const max = Math.max( + 0, + handle.getScrollHeight() - handle.getViewportHeight(), + ) + if (handle.getScrollTop() + handle.getPendingDelta() >= max) return // Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY // scroll action (not just the initial break from sticky) — this guard // preserves the original baseline so the count doesn't reset on the // second PageUp. Subsequent calls are ref-only no-ops (no REPL re-render). if (dividerYRef.current === null) { - dividerYRef.current = handle.getScrollHeight(); + dividerYRef.current = handle.getScrollHeight() // New scroll-away session → move the divider here (replaces old one) - setDividerIndex(countRef.current); + setDividerIndex(countRef.current) } - }, []); - const jumpToNew = useCallback((handle_0: ScrollBoxHandle | null) => { - if (!handle_0) return; + }, []) + + const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => { + if (!handle) return // scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so // useVirtualScroll mounts the tail and render-node-to-output pins // scrollTop=maxScroll. scrollTo sets stickyScroll=false → the clamp @@ -153,8 +172,8 @@ export function useUnseenDivider(messageCount: number): { // back, stopping short. The divider stays rendered (dividerIndex // unchanged) so users see where new messages started; the clear on // next submit/explicit scroll-to-bottom handles cleanup. - handle_0.scrollToBottom(); - }, []); + handle.scrollToBottom() + }, []) // Sync dividerYRef with dividerIndex. When onRepin fires (submit, // scroll-to-bottom), it sets dividerIndex=null but leaves the ref @@ -167,26 +186,31 @@ export function useUnseenDivider(messageCount: number): { // below the divider index, the divider would point at nothing. useEffect(() => { if (dividerIndex === null) { - dividerYRef.current = null; + dividerYRef.current = null } else if (messageCount < dividerIndex) { - dividerYRef.current = null; - setDividerIndex(null); - } - }, [messageCount, dividerIndex]); - const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => { - setDividerIndex(idx => idx === null ? null : idx + indexDelta); - if (dividerYRef.current !== null) { - dividerYRef.current += heightDelta; + dividerYRef.current = null + setDividerIndex(null) } - }, []); + }, [messageCount, dividerIndex]) + + const shiftDivider = useCallback( + (indexDelta: number, heightDelta: number) => { + setDividerIndex(idx => (idx === null ? null : idx + indexDelta)) + if (dividerYRef.current !== null) { + dividerYRef.current += heightDelta + } + }, + [], + ) + return { dividerIndex, dividerYRef, onScrollAway, onRepin, jumpToNew, - shiftDivider - }; + shiftDivider, + } } /** @@ -197,35 +221,37 @@ export function useUnseenDivider(messageCount: number): { * carry text — tool-use-only entries are skipped (like progress messages) * so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill. */ -export function countUnseenAssistantTurns(messages: readonly Message[], dividerIndex: number): number { - let count = 0; - let prevWasAssistant = false; +export function countUnseenAssistantTurns( + messages: readonly Message[], + dividerIndex: number, +): number { + let count = 0 + let prevWasAssistant = false for (let i = dividerIndex; i < messages.length; i++) { - const m = messages[i]!; - if (m.type === 'progress') continue; + const m = messages[i]! + if (m.type === 'progress') continue // Tool-use-only assistant entries aren't "new messages" to the user — // skip them the same way we skip progress. prevWasAssistant is NOT // updated, so a text block immediately following still counts as the // same turn (tool_use + text from one API response = 1). - if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue; - const isAssistant = m.type === 'assistant'; - if (isAssistant && !prevWasAssistant) count++; - prevWasAssistant = isAssistant; + if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue + const isAssistant = m.type === 'assistant' + if (isAssistant && !prevWasAssistant) count++ + prevWasAssistant = isAssistant } - return count; + return count } + function assistantHasVisibleText(m: Message): boolean { - if (m.type !== 'assistant') return false; - if (!Array.isArray(m.message.content)) return false; + if (m.type !== 'assistant') return false + if (!Array.isArray(m.message.content)) return false for (const b of m.message.content) { - if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true; + if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true } - return false; + return false } -export type UnseenDivider = { - firstUnseenUuid: Message['uuid']; - count: number; -}; + +export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number } /** * Builds the unseenDivider object REPL passes to Messages + the pill. @@ -237,23 +263,27 @@ export type UnseenDivider = { * the pill stays "Jump to bottom" through an entire tool-call sequence * until Claude's text response lands. */ -export function computeUnseenDivider(messages: readonly Message[], dividerIndex: number | null): UnseenDivider | undefined { - if (dividerIndex === null) return undefined; +export function computeUnseenDivider( + messages: readonly Message[], + dividerIndex: number | null, +): UnseenDivider | undefined { + if (dividerIndex === null) return undefined // Skip progress and null-rendering attachments when picking the divider // anchor — Messages.tsx filters these out of renderableMessages before the // dividerBeforeIndex search, so their UUID wouldn't be found (CC-724). // Hook attachments use randomUUID() so nothing shares their 24-char prefix. - let anchorIdx = dividerIndex; - while (anchorIdx < messages.length && (messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!))) { - anchorIdx++; + let anchorIdx = dividerIndex + while ( + anchorIdx < messages.length && + (messages[anchorIdx]?.type === 'progress' || + isNullRenderingAttachment(messages[anchorIdx]!)) + ) { + anchorIdx++ } - const uuid = messages[anchorIdx]?.uuid; - if (!uuid) return undefined; - const count = countUnseenAssistantTurns(messages, dividerIndex); - return { - firstUnseenUuid: uuid, - count: Math.max(1, count) - }; + const uuid = messages[anchorIdx]?.uuid + if (!uuid) return undefined + const count = countUnseenAssistantTurns(messages, dividerIndex) + return { firstUnseenUuid: uuid, count: Math.max(1, count) } } /** @@ -268,195 +298,198 @@ export function computeUnseenDivider(messages: readonly Message[], dividerIndex: * (alt buffer + mouse tracking + height constraint) lives at REPL's root * so nothing can accidentally render outside it. */ -export function FullscreenLayout(t0) { - const $ = _c(47); - const { - scrollable, - bottom, - overlay, - bottomFloat, - modal, - modalScrollRef, - scrollRef, - dividerYRef, - hidePill: t1, - hideSticky: t2, - newMessageCount: t3, - onPillClick - } = t0; - const hidePill = t1 === undefined ? false : t1; - const hideSticky = t2 === undefined ? false : t2; - const newMessageCount = t3 === undefined ? 0 : t3; - const { - rows: terminalRows, - columns - } = useTerminalSize(); - const [stickyPrompt, setStickyPrompt] = useState(null); - let t4; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - setStickyPrompt - }; - $[0] = t4; - } else { - t4 = $[0]; - } - const chromeCtx = t4; - let t5; - if ($[1] !== scrollRef) { - t5 = listener => scrollRef?.current?.subscribe(listener) ?? _temp; - $[1] = scrollRef; - $[2] = t5; - } else { - t5 = $[2]; - } - const subscribe = t5; - let t6; - if ($[3] !== dividerYRef || $[4] !== scrollRef) { - t6 = () => { - const s = scrollRef?.current; - const dividerY = dividerYRef?.current; - if (!s || dividerY == null) { - return false; +export function FullscreenLayout({ + scrollable, + bottom, + overlay, + bottomFloat, + modal, + modalScrollRef, + scrollRef, + dividerYRef, + hidePill = false, + hideSticky = false, + newMessageCount = 0, + onPillClick, +}: Props): React.ReactNode { + const { rows: terminalRows, columns } = useTerminalSize() + // Scroll-derived chrome state lives HERE, not in REPL. StickyTracker + // writes via ScrollChromeContext; pillVisible subscribes directly to + // ScrollBox. Both change rarely (pill flips once per threshold crossing, + // sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on + // those is fine; re-rendering the 6966-line REPL + its 22+ useAppState + // selectors per-scroll-frame was not. + const [stickyPrompt, setStickyPrompt] = useState(null) + const chromeCtx = useMemo(() => ({ setStickyPrompt }), []) + // Boolean-quantized scroll subscription. Snapshot is "is viewport bottom + // above the divider y?" — Object.is on a boolean → FullscreenLayout only + // re-renders when the pill should actually flip, not per-frame. + const subscribe = useCallback( + (listener: () => void) => + scrollRef?.current?.subscribe(listener) ?? (() => {}), + [scrollRef], + ) + const pillVisible = useSyncExternalStore(subscribe, () => { + const s = scrollRef?.current + const dividerY = dividerYRef?.current + if (!s || dividerY == null) return false + return ( + s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY + ) + }) + // Wire up hyperlink click handling — in fullscreen mode, mouse tracking + // intercepts clicks before the terminal can open OSC 8 links natively. + useLayoutEffect(() => { + if (!isFullscreenEnvEnabled()) return + const ink = instances.get(process.stdout) + if (!ink) return + ink.onHyperlinkClick = url => { + // Most OSC 8 links emitted by Claude Code are file:// URLs from + // FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser + // rejects non-http(s) protocols — route file: to openPath instead. + if (url.startsWith('file:')) { + try { + void openPath(fileURLToPath(url)) + } catch { + // Malformed file: URLs (e.g. file://host/path from plain-text + // detection) cause fileURLToPath to throw — ignore silently. + } + } else { + void openBrowser(url) } - return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY; - }; - $[3] = dividerYRef; - $[4] = scrollRef; - $[5] = t6; - } else { - t6 = $[5]; - } - const pillVisible = useSyncExternalStore(subscribe, t6); - let t7; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t7 = []; - $[6] = t7; - } else { - t7 = $[6]; - } - useLayoutEffect(_temp3, t7); - if (isFullscreenEnvEnabled()) { - const sticky = hideSticky ? null : stickyPrompt; - const headerPrompt = sticky != null && sticky !== "clicked" && overlay == null ? sticky : null; - const padCollapsed = sticky != null && overlay == null; - let t8; - if ($[7] !== headerPrompt) { - t8 = headerPrompt && ; - $[7] = headerPrompt; - $[8] = t8; - } else { - t8 = $[8]; - } - const t9 = padCollapsed ? 0 : 1; - let t10; - if ($[9] !== scrollable) { - t10 = {scrollable}; - $[9] = scrollable; - $[10] = t10; - } else { - t10 = $[10]; - } - let t11; - if ($[11] !== overlay || $[12] !== scrollRef || $[13] !== t10 || $[14] !== t9) { - t11 = {t10}{overlay}; - $[11] = overlay; - $[12] = scrollRef; - $[13] = t10; - $[14] = t9; - $[15] = t11; - } else { - t11 = $[15]; - } - let t12; - if ($[16] !== hidePill || $[17] !== newMessageCount || $[18] !== onPillClick || $[19] !== overlay || $[20] !== pillVisible) { - t12 = !hidePill && pillVisible && overlay == null && ; - $[16] = hidePill; - $[17] = newMessageCount; - $[18] = onPillClick; - $[19] = overlay; - $[20] = pillVisible; - $[21] = t12; - } else { - t12 = $[21]; - } - let t13; - if ($[22] !== bottomFloat) { - t13 = bottomFloat != null && {bottomFloat}; - $[22] = bottomFloat; - $[23] = t13; - } else { - t13 = $[23]; - } - let t14; - if ($[24] !== t11 || $[25] !== t12 || $[26] !== t13 || $[27] !== t8) { - t14 = {t8}{t11}{t12}{t13}; - $[24] = t11; - $[25] = t12; - $[26] = t13; - $[27] = t8; - $[28] = t14; - } else { - t14 = $[28]; - } - let t15; - let t16; - if ($[29] === Symbol.for("react.memo_cache_sentinel")) { - t15 = ; - t16 = ; - $[29] = t15; - $[30] = t16; - } else { - t15 = $[29]; - t16 = $[30]; } - let t17; - if ($[31] !== bottom) { - t17 = {t15}{t16}{bottom}; - $[31] = bottom; - $[32] = t17; - } else { - t17 = $[32]; + return () => { + ink.onHyperlinkClick = undefined } - let t18; - if ($[33] !== columns || $[34] !== modal || $[35] !== modalScrollRef || $[36] !== terminalRows) { - t18 = modal != null && {"\u2594".repeat(columns)}{modal}; - $[33] = columns; - $[34] = modal; - $[35] = modalScrollRef; - $[36] = terminalRows; - $[37] = t18; - } else { - t18 = $[37]; - } - let t19; - if ($[38] !== t14 || $[39] !== t17 || $[40] !== t18) { - t19 = {t14}{t17}{t18}; - $[38] = t14; - $[39] = t17; - $[40] = t18; - $[41] = t19; - } else { - t19 = $[41]; - } - return t19; - } - let t8; - if ($[42] !== bottom || $[43] !== modal || $[44] !== overlay || $[45] !== scrollable) { - t8 = <>{scrollable}{bottom}{overlay}{modal}; - $[42] = bottom; - $[43] = modal; - $[44] = overlay; - $[45] = scrollable; - $[46] = t8; - } else { - t8 = $[46]; + }, []) + + if (isFullscreenEnvEnabled()) { + // Overlay renders BELOW messages inside the same ScrollBox — user can + // scroll up to see prior context while a permission dialog is showing. + // The ScrollBox never unmounts across overlay transitions, so scroll + // position is preserved without save/restore. stickyScroll auto-scrolls + // to the appended overlay when it mounts (if user was already at + // bottom); REPL re-pins on the overlay appear/dismiss transition for + // the case where sticky was broken. Tall dialogs (FileEdit diffs) still + // get PgUp/PgDn/wheel — same scrollRef drives the same ScrollBox. + // Three sticky states: null (at bottom), {text,scrollTo} (scrolled up, + // header shows), 'clicked' (just clicked header — hide it so the + // content ❯ takes row 0). padCollapsed covers the latter two: once + // scrolled away from bottom, padding drops to 0 and stays there until + // repin. headerVisible is only the middle state. After click: + // scrollBox_y=0 (header gone) + padding=0 → viewportTop=0 → ❯ at + // row 0. On next scroll the onChange fires with a fresh {text} and + // header comes back (viewportTop 0→1, a single 1-row shift — + // acceptable since user explicitly scrolled). + const sticky = hideSticky ? null : stickyPrompt + const headerPrompt = + sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null + const padCollapsed = sticky != null && overlay == null + return ( + + + {headerPrompt && ( + + )} + + + {scrollable} + + {overlay} + + {!hidePill && pillVisible && overlay == null && ( + + )} + {bottomFloat != null && ( + + {bottomFloat} + + )} + + + + + + {bottom} + + + {modal != null && ( + + {/* Bottom-anchored, grows upward to fit content. maxHeight keeps a + few rows of transcript peek above the ▔ divider. Short modals + (/model) sit small at the bottom with lots of transcript above; + tall modals (/buddy Card) grow as needed, clipped by overflow. + Previously fixed-height (top+bottom anchored) — any fixed cap + either clipped tall content or left short content floating in + a mostly-empty pane. + + flexShrink=0 on the inner Box is load-bearing: with Shrink=1, + yoga squeezes deep children to h=0 when content > maxHeight, + and sibling Texts land on the same row → ghost overlap + ("5 serversP servers"). Clipping at the outer Box's maxHeight + keeps children at natural size. + + Divider wrapped in flexShrink=0: when the inner box overflows + (tall /config option list), yoga shrinks the divider Text to + h=0 to absorb the deficit — it's the only shrinkable sibling. + The wrapper keeps it at 1 row; overflow past maxHeight is + clipped at the bottom by overflow=hidden instead. */} + + + {'▔'.repeat(columns)} + + + {modal} + + + + )} + + ) } - return t8; + + return ( + <> + {scrollable} + {bottom} + {overlay} + {modal} + + ) } // Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats @@ -466,75 +499,42 @@ export function FullscreenLayout(t0) { // (absoluteRectsPrev third-pass in render-node-to-output.ts, #23939). Shows // "Jump to bottom" when count is 0 (scrolled away but no new messages yet — // the dead zone where users previously thought chat stalled). -function _temp3() { - if (!isFullscreenEnvEnabled()) { - return; - } - const ink = instances.get(process.stdout); - if (!ink) { - return; - } - ink.onHyperlinkClick = _temp2; - return () => { - ink.onHyperlinkClick = undefined; - }; -} -function _temp2(url) { - if (url.startsWith("file:")) { - try { - openPath(fileURLToPath(url)); - } catch {} - } else { - openBrowser(url); - } -} -function _temp() {} -function NewMessagesPill(t0) { - const $ = _c(10); - const { - count, - onClick - } = t0; - const [hover, setHover] = useState(false); - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setHover(true); - t2 = () => setHover(false); - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - const t3 = hover ? "userMessageBackgroundHover" : "userMessageBackground"; - let t4; - if ($[2] !== count) { - t4 = count > 0 ? `${count} new ${plural(count, "message")}` : "Jump to bottom"; - $[2] = count; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== t3 || $[5] !== t4) { - t5 = {" "}{t4}{" "}{figures.arrowDown}{" "}; - $[4] = t3; - $[5] = t4; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== onClick || $[8] !== t5) { - t6 = {t5}; - $[7] = onClick; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - return t6; +function NewMessagesPill({ + count, + onClick, +}: { + count: number + onClick?: () => void +}): React.ReactNode { + const [hover, setHover] = useState(false) + return ( + + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {' '} + {count > 0 + ? `${count} new ${plural(count, 'message')}` + : 'Jump to bottom'}{' '} + {figures.arrowDown}{' '} + + + + ) } // Context breadcrumb: when scrolled up into history, pin the current @@ -549,44 +549,32 @@ function NewMessagesPill(t0) { // even with scrollTop unchanged (the DECSTBM region top shifts with the // ScrollBox, and the diff engine sees "everything moved"). Fixed height // keeps the ScrollBox anchored; only the header TEXT changes, not its box. -function StickyPromptHeader(t0) { - const $ = _c(8); - const { - text, - onClick - } = t0; - const [hover, setHover] = useState(false); - const t1 = hover ? "userMessageBackgroundHover" : "userMessageBackground"; - let t2; - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => setHover(true); - t3 = () => setHover(false); - $[0] = t2; - $[1] = t3; - } else { - t2 = $[0]; - t3 = $[1]; - } - let t4; - if ($[2] !== text) { - t4 = {figures.pointer} {text}; - $[2] = text; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== onClick || $[5] !== t1 || $[6] !== t4) { - t5 = {t4}; - $[4] = onClick; - $[5] = t1; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; +function StickyPromptHeader({ + text, + onClick, +}: { + text: string + onClick: () => void +}): React.ReactNode { + const [hover, setHover] = useState(false) + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {figures.pointer} {text} + + + ) } // Slash-command suggestion overlay — see promptOverlayContext.tsx for why @@ -597,41 +585,39 @@ function StickyPromptHeader(t0) { // even when the overlay extends above the viewport. We omit minHeight and // flex-end here: they would create empty padding rows that shift visible // items down into the prompt area when the list has fewer items than max. -function SuggestionsOverlay() { - const $ = _c(4); - const data = usePromptOverlay(); - if (!data || data.suggestions.length === 0) { - return null; - } - let t0; - if ($[0] !== data.maxColumnWidth || $[1] !== data.selectedSuggestion || $[2] !== data.suggestions) { - t0 = ; - $[0] = data.maxColumnWidth; - $[1] = data.selectedSuggestion; - $[2] = data.suggestions; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; +function SuggestionsOverlay(): React.ReactNode { + const data = usePromptOverlay() + if (!data || data.suggestions.length === 0) return null + return ( + + + + ) } // Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape // pattern as SuggestionsOverlay. Renders later in tree order so it paints // over suggestions if both are ever up (they shouldn't be). -function DialogOverlay() { - const $ = _c(2); - const node = usePromptOverlayDialog(); - if (!node) { - return null; - } - let t0; - if ($[0] !== node) { - t0 = {node}; - $[0] = node; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +function DialogOverlay(): React.ReactNode { + const node = usePromptOverlayDialog() + if (!node) return null + return ( + + {node} + + ) } diff --git a/src/components/GlobalSearchDialog.tsx b/src/components/GlobalSearchDialog.tsx index 6598a8b48..0df3231ce 100644 --- a/src/components/GlobalSearchDialog.tsx +++ b/src/components/GlobalSearchDialog.tsx @@ -1,324 +1,308 @@ -import { c as _c } from "react/compiler-runtime"; -import { resolve as resolvePath } from 'path'; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { useRegisterOverlay } from '../context/overlayContext.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Text } from '../ink.js'; -import { logEvent } from '../services/analytics/index.js'; -import { getCwd } from '../utils/cwd.js'; -import { openFileInExternalEditor } from '../utils/editor.js'; -import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'; -import { highlightMatch } from '../utils/highlightMatch.js'; -import { relativePath } from '../utils/permissions/filesystem.js'; -import { readFileInRange } from '../utils/readFileInRange.js'; -import { ripGrepStream } from '../utils/ripgrep.js'; -import { FuzzyPicker } from './design-system/FuzzyPicker.js'; -import { LoadingState } from './design-system/LoadingState.js'; +import { resolve as resolvePath } from 'path' +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { useRegisterOverlay } from '../context/overlayContext.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Text } from '../ink.js' +import { logEvent } from '../services/analytics/index.js' +import { getCwd } from '../utils/cwd.js' +import { openFileInExternalEditor } from '../utils/editor.js' +import { truncatePathMiddle, truncateToWidth } from '../utils/format.js' +import { highlightMatch } from '../utils/highlightMatch.js' +import { relativePath } from '../utils/permissions/filesystem.js' +import { readFileInRange } from '../utils/readFileInRange.js' +import { ripGrepStream } from '../utils/ripgrep.js' +import { FuzzyPicker } from './design-system/FuzzyPicker.js' +import { LoadingState } from './design-system/LoadingState.js' + type Props = { - onDone: () => void; - onInsert: (text: string) => void; -}; + onDone: () => void + onInsert: (text: string) => void +} + type Match = { - file: string; - line: number; - text: string; -}; -const VISIBLE_RESULTS = 12; -const DEBOUNCE_MS = 100; -const PREVIEW_CONTEXT_LINES = 4; + file: string + line: number + text: string +} + +const VISIBLE_RESULTS = 12 +const DEBOUNCE_MS = 100 +const PREVIEW_CONTEXT_LINES = 4 // rg -m is per-file; we also cap the parsed array to keep memory bounded. -const MAX_MATCHES_PER_FILE = 10; -const MAX_TOTAL_MATCHES = 500; +const MAX_MATCHES_PER_FILE = 10 +const MAX_TOTAL_MATCHES = 500 /** * Global Search dialog (ctrl+shift+f / cmd+shift+f). * Debounced ripgrep search across the workspace. */ -export function GlobalSearchDialog(t0) { - const $ = _c(40); - const { - onDone, - onInsert - } = t0; - useRegisterOverlay("global-search", undefined); - const { - columns, - rows - } = useTerminalSize(); - const previewOnRight = columns >= 140; - const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [matches, setMatches] = useState(t1); - const [truncated, setTruncated] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [query, setQuery] = useState(""); - const [focused, setFocused] = useState(undefined); - const [preview, setPreview] = useState(null); - const abortRef = useRef(null); - const timeoutRef = useRef(null); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - abortRef.current?.abort(); - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - let t4; - let t5; - if ($[3] !== focused) { - t4 = () => { - if (!focused) { - setPreview(null); - return; - } - const controller = new AbortController(); - const absolute = resolvePath(getCwd(), focused.file); - const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1); - readFileInRange(absolute, start, PREVIEW_CONTEXT_LINES * 2 + 1, undefined, controller.signal).then(r => { - if (controller.signal.aborted) { - return; - } +export function GlobalSearchDialog({ + onDone, + onInsert, +}: Props): React.ReactNode { + useRegisterOverlay('global-search') + const { columns, rows } = useTerminalSize() + const previewOnRight = columns >= 140 + // Chrome (title + search + matchLabel + hints + pane border + gaps) eats + // ~14 rows. Shrink the list on short terminals so the dialog doesn't clip. + const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)) + + const [matches, setMatches] = useState([]) + const [truncated, setTruncated] = useState(false) + const [isSearching, setIsSearching] = useState(false) + const [query, setQuery] = useState('') + const [focused, setFocused] = useState(undefined) + const [preview, setPreview] = useState<{ + file: string + line: number + content: string + } | null>(null) + const abortRef = useRef(null) + const timeoutRef = useRef | null>(null) + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + abortRef.current?.abort() + } + }, []) + + // Load context lines around the focused match. AbortController prevents + // holding ↓ from piling up reads. + useEffect(() => { + if (!focused) { + setPreview(null) + return + } + const controller = new AbortController() + const absolute = resolvePath(getCwd(), focused.file) + const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1) + void readFileInRange( + absolute, + start, + PREVIEW_CONTEXT_LINES * 2 + 1, + undefined, + controller.signal, + ) + .then(r => { + if (controller.signal.aborted) return setPreview({ file: focused.file, line: focused.line, - content: r.content - }); - }).catch(() => { - if (controller.signal.aborted) { - return; - } + content: r.content, + }) + }) + .catch(() => { + if (controller.signal.aborted) return setPreview({ file: focused.file, line: focused.line, - content: "(preview unavailable)" - }); - }); - return () => controller.abort(); - }; - t5 = [focused]; - $[3] = focused; - $[4] = t4; - $[5] = t5; - } else { - t4 = $[4]; - t5 = $[5]; - } - useEffect(t4, t5); - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = q => { - setQuery(q); - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - abortRef.current?.abort(); - if (!q.trim()) { - setMatches(_temp); - setIsSearching(false); - setTruncated(false); - return; - } - const controller_0 = new AbortController(); - abortRef.current = controller_0; - setIsSearching(true); - setTruncated(false); - const queryLower = q.toLowerCase(); - setMatches(m_0 => { - const filtered = m_0.filter(match => match.text.toLowerCase().includes(queryLower)); - return filtered.length === m_0.length ? m_0 : filtered; - }); - timeoutRef.current = setTimeout(_temp4, DEBOUNCE_MS, q, controller_0, setMatches, setTruncated, setIsSearching); - }; - $[6] = t6; - } else { - t6 = $[6]; - } - const handleQueryChange = t6; - const listWidth = previewOnRight ? Math.floor((columns - 10) * 0.5) : columns - 8; - const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4)); - const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4); - const previewWidth = previewOnRight ? Math.max(40, columns - listWidth - 14) : columns - 6; - let t7; - if ($[7] !== matches.length || $[8] !== onDone) { - t7 = m_3 => { - const opened = openFileInExternalEditor(resolvePath(getCwd(), m_3.file), m_3.line); - logEvent("tengu_global_search_select", { - result_count: matches.length, - opened_editor: opened - }); - onDone(); - }; - $[7] = matches.length; - $[8] = onDone; - $[9] = t7; - } else { - t7 = $[9]; - } - const handleOpen = t7; - let t8; - if ($[10] !== matches.length || $[11] !== onDone || $[12] !== onInsert) { - t8 = (m_4, mention) => { - onInsert(mention ? `@${m_4.file}#L${m_4.line} ` : `${m_4.file}:${m_4.line} `); - logEvent("tengu_global_search_insert", { - result_count: matches.length, - mention - }); - onDone(); - }; - $[10] = matches.length; - $[11] = onDone; - $[12] = onInsert; - $[13] = t8; - } else { - t8 = $[13]; - } - const handleInsert = t8; - const matchLabel = matches.length > 0 ? `${matches.length}${truncated ? "+" : ""} matches${isSearching ? "\u2026" : ""}` : " "; - const t9 = previewOnRight ? "right" : "bottom"; - let t10; - if ($[14] !== handleInsert) { - t10 = { - action: "mention", - handler: m_5 => handleInsert(m_5, true) - }; - $[14] = handleInsert; - $[15] = t10; - } else { - t10 = $[15]; - } - let t11; - if ($[16] !== handleInsert) { - t11 = { - action: "insert path", - handler: m_6 => handleInsert(m_6, false) - }; - $[16] = handleInsert; - $[17] = t11; - } else { - t11 = $[17]; - } - let t12; - if ($[18] !== isSearching) { - t12 = q_0 => isSearching ? "Searching\u2026" : q_0 ? "No matches" : "Type to search\u2026"; - $[18] = isSearching; - $[19] = t12; - } else { - t12 = $[19]; - } - let t13; - if ($[20] !== maxPathWidth || $[21] !== maxTextWidth || $[22] !== query) { - t13 = (m_7, isFocused) => {truncatePathMiddle(m_7.file, maxPathWidth)}:{m_7.line}{" "}{highlightMatch(truncateToWidth(m_7.text.trimStart(), maxTextWidth), query)}; - $[20] = maxPathWidth; - $[21] = maxTextWidth; - $[22] = query; - $[23] = t13; - } else { - t13 = $[23]; + content: '(preview unavailable)', + }) + }) + return () => controller.abort() + }, [focused]) + + const handleQueryChange = (q: string) => { + setQuery(q) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + abortRef.current?.abort() + + if (!q.trim()) { + setMatches(m => (m.length ? [] : m)) + setIsSearching(false) + setTruncated(false) + return + } + const controller = new AbortController() + abortRef.current = controller + setIsSearching(true) + setTruncated(false) + // Client-filter existing results while rg walks — keeps something on + // screen instead of flashing blank. rg results are merged in (deduped by + // file:line) rather than replaced, so the count is monotonic within a + // query: it only grows as rg streams, never dips to the first chunk's + // size. Narrowing (new query extends old): filter is exact — any line + // that matched the old -F -i literal contains the new one iff its text + // includes the new query lowered. Non-narrowing (broadening/different): + // filter is best-effort — may briefly show a subset until rg fills in + // the rest. + const queryLower = q.toLowerCase() + setMatches(m => { + const filtered = m.filter(match => + match.text.toLowerCase().includes(queryLower), + ) + return filtered.length === m.length ? m : filtered + }) + + timeoutRef.current = setTimeout( + (query, controller, setMatches, setTruncated, setIsSearching) => { + // ripgrep outputs absolute paths when given an absolute target, so + // relativize against cwd to preserve directory context in the truncated + // display (otherwise the cwd prefix eats the width budget). + // relativePath() returns POSIX-normalized output so truncatePathMiddle + // (which uses lastIndexOf('/')) works on Windows too. + const cwd = getCwd() + let collected = 0 + void ripGrepStream( + // -e disambiguates pattern from options when the query starts with '-' + // (e.g. searching for "--verbose" or "-rf"). See GrepTool.ts for the + // same precaution. + [ + '-n', + '--no-heading', + '-i', + '-m', + String(MAX_MATCHES_PER_FILE), + '-F', + '-e', + query, + ], + cwd, + controller.signal, + lines => { + if (controller.signal.aborted) return + const parsed: Match[] = [] + for (const line of lines) { + const m = parseRipgrepLine(line) + if (!m) continue + const rel = relativePath(cwd, m.file) + parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel }) + } + if (!parsed.length) return + collected += parsed.length + setMatches(prev => { + // Append+dedupe instead of replace: prev may hold client- + // filtered results that are valid matches for this query. + // Replacing would drop the count to this chunk's size then + // grow it back — visible as a flicker. + const seen = new Set(prev.map(matchKey)) + const fresh = parsed.filter(p => !seen.has(matchKey(p))) + if (!fresh.length) return prev + const next = prev.concat(fresh) + return next.length > MAX_TOTAL_MATCHES + ? next.slice(0, MAX_TOTAL_MATCHES) + : next + }) + if (collected >= MAX_TOTAL_MATCHES) { + controller.abort() + setTruncated(true) + setIsSearching(false) + } + }, + ) + .catch(() => {}) + // Stream closed with zero chunks — clear stale results so + // "No matches" renders instead of the previous query's list. + .finally(() => { + if (controller.signal.aborted) return + if (collected === 0) setMatches(m => (m.length ? [] : m)) + setIsSearching(false) + }) + }, + DEBOUNCE_MS, + q, + controller, + setMatches, + setTruncated, + setIsSearching, + ) } - let t14; - if ($[24] !== preview || $[25] !== previewWidth || $[26] !== query) { - t14 = m_8 => preview?.file === m_8.file && preview.line === m_8.line ? <>{truncatePathMiddle(m_8.file, previewWidth)}:{m_8.line}{preview.content.split("\n").map((line_0, i) => {highlightMatch(truncateToWidth(line_0, previewWidth), query)})} : ; - $[24] = preview; - $[25] = previewWidth; - $[26] = query; - $[27] = t14; - } else { - t14 = $[27]; + + const listWidth = previewOnRight + ? Math.floor((columns - 10) * 0.5) + : columns - 8 + const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4)) + const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4) + const previewWidth = previewOnRight + ? Math.max(40, columns - listWidth - 14) + : columns - 6 + + const handleOpen = (m: Match) => { + const opened = openFileInExternalEditor( + resolvePath(getCwd(), m.file), + m.line, + ) + logEvent('tengu_global_search_select', { + result_count: matches.length, + opened_editor: opened, + }) + onDone() } - let t15; - if ($[28] !== handleOpen || $[29] !== matchLabel || $[30] !== matches || $[31] !== onDone || $[32] !== t10 || $[33] !== t11 || $[34] !== t12 || $[35] !== t13 || $[36] !== t14 || $[37] !== t9 || $[38] !== visibleResults) { - t15 = ; - $[28] = handleOpen; - $[29] = matchLabel; - $[30] = matches; - $[31] = onDone; - $[32] = t10; - $[33] = t11; - $[34] = t12; - $[35] = t13; - $[36] = t14; - $[37] = t9; - $[38] = visibleResults; - $[39] = t15; - } else { - t15 = $[39]; + + const handleInsert = (m: Match, mention: boolean) => { + onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `) + logEvent('tengu_global_search_insert', { + result_count: matches.length, + mention, + }) + onDone() } - return t15; -} -function _temp4(query_0, controller_1, setMatches_0, setTruncated_0, setIsSearching_0) { - const cwd = getCwd(); - let collected = 0; - ripGrepStream(["-n", "--no-heading", "-i", "-m", String(MAX_MATCHES_PER_FILE), "-F", "-e", query_0], cwd, controller_1.signal, lines => { - if (controller_1.signal.aborted) { - return; - } - const parsed = []; - for (const line of lines) { - const m_1 = parseRipgrepLine(line); - if (!m_1) { - continue; + + // Always pass a non-empty string so the line is reserved — prevents the + // searchBox from bouncing when the count appears/disappears. + const matchLabel = + matches.length > 0 + ? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}` + : ' ' + + return ( + handleInsert(m, true) }} + onShiftTab={{ + action: 'insert path', + handler: m => handleInsert(m, false), + }} + onCancel={onDone} + emptyMessage={q => + isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…' } - const rel = relativePath(cwd, m_1.file); - parsed.push({ - ...m_1, - file: rel.startsWith("..") ? m_1.file : rel - }); - } - if (!parsed.length) { - return; - } - collected = collected + parsed.length; - collected; - setMatches_0(prev => { - const seen = new Set(prev.map(matchKey)); - const fresh = parsed.filter(p => !seen.has(matchKey(p))); - if (!fresh.length) { - return prev; + matchLabel={matchLabel} + selectAction="open in editor" + renderItem={(m, isFocused) => ( + + + {truncatePathMiddle(m.file, maxPathWidth)}:{m.line} + {' '} + {highlightMatch( + truncateToWidth(m.text.trimStart(), maxTextWidth), + query, + )} + + )} + renderPreview={m => + preview?.file === m.file && preview.line === m.line ? ( + <> + + {truncatePathMiddle(m.file, previewWidth)}:{m.line} + + {preview.content.split('\n').map((line, i) => ( + + {highlightMatch(truncateToWidth(line, previewWidth), query)} + + ))} + + ) : ( + + ) } - const next = prev.concat(fresh); - return next.length > MAX_TOTAL_MATCHES ? next.slice(0, MAX_TOTAL_MATCHES) : next; - }); - if (collected >= MAX_TOTAL_MATCHES) { - controller_1.abort(); - setTruncated_0(true); - setIsSearching_0(false); - } - }).catch(_temp2).finally(() => { - if (controller_1.signal.aborted) { - return; - } - if (collected === 0) { - setMatches_0(_temp3); - } - setIsSearching_0(false); - }); -} -function _temp3(m_2) { - return m_2.length ? [] : m_2; -} -function _temp2() {} -function _temp(m) { - return m.length ? [] : m; + /> + ) } + function matchKey(m: Match): string { - return `${m.file}:${m.line}`; + return `${m.file}:${m.line}` } /** @@ -329,14 +313,10 @@ function matchKey(m: Match): string { * @internal exported for testing */ export function parseRipgrepLine(line: string): Match | null { - const m = /^(.*?):(\d+):(.*)$/.exec(line); - if (!m) return null; - const [, file, lineStr, text] = m; - const lineNum = Number(lineStr); - if (!file || !Number.isFinite(lineNum)) return null; - return { - file, - line: lineNum, - text: text ?? '' - }; + const m = /^(.*?):(\d+):(.*)$/.exec(line) + if (!m) return null + const [, file, lineStr, text] = m + const lineNum = Number(lineStr) + if (!file || !Number.isFinite(lineNum)) return null + return { file, line: lineNum, text: text ?? '' } } diff --git a/src/components/HelpV2/Commands.tsx b/src/components/HelpV2/Commands.tsx index 0490e5ed2..9e34e5539 100644 --- a/src/components/HelpV2/Commands.tsx +++ b/src/components/HelpV2/Commands.tsx @@ -1,81 +1,71 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useMemo } from 'react'; -import { type Command, formatDescriptionWithSource } from '../../commands.js'; -import { Box, Text } from '../../ink.js'; -import { truncate } from '../../utils/format.js'; -import { Select } from '../CustomSelect/select.js'; -import { useTabHeaderFocus } from '../design-system/Tabs.js'; +import * as React from 'react' +import { useMemo } from 'react' +import { type Command, formatDescriptionWithSource } from '../../commands.js' +import { Box, Text } from '../../ink.js' +import { truncate } from '../../utils/format.js' +import { Select } from '../CustomSelect/select.js' +import { useTabHeaderFocus } from '../design-system/Tabs.js' + type Props = { - commands: Command[]; - maxHeight: number; - columns: number; - title: string; - onCancel: () => void; - emptyMessage?: string; -}; -export function Commands(t0) { - const $ = _c(14); - const { - commands, - maxHeight, - columns, - title, - onCancel, - emptyMessage - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - const maxWidth = Math.max(1, columns - 10); - const visibleCount = Math.max(1, Math.floor((maxHeight - 10) / 2)); - let t1; - if ($[0] !== commands || $[1] !== maxWidth) { - const seen = new Set(); - let t2; - if ($[3] !== maxWidth) { - t2 = cmd_0 => ({ - label: `/${cmd_0.name}`, - value: cmd_0.name, - description: truncate(formatDescriptionWithSource(cmd_0), maxWidth, true) - }); - $[3] = maxWidth; - $[4] = t2; - } else { - t2 = $[4]; - } - t1 = commands.filter(cmd => { - if (seen.has(cmd.name)) { - return false; - } - seen.add(cmd.name); - return true; - }).sort(_temp).map(t2); - $[0] = commands; - $[1] = maxWidth; - $[2] = t1; - } else { - t1 = $[2]; - } - const options = t1; - let t2; - if ($[5] !== commands.length || $[6] !== emptyMessage || $[7] !== focusHeader || $[8] !== headerFocused || $[9] !== onCancel || $[10] !== options || $[11] !== title || $[12] !== visibleCount) { - t2 = {commands.length === 0 && emptyMessage ? {emptyMessage} : <>{title} + + + )} + + ) } diff --git a/src/components/HelpV2/General.tsx b/src/components/HelpV2/General.tsx index 15a844e47..69b5c6509 100644 --- a/src/components/HelpV2/General.tsx +++ b/src/components/HelpV2/General.tsx @@ -1,22 +1,22 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js'; -export function General() { - const $ = _c(2); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Claude understands your codebase, makes edits with your permission, and executes commands — right from your terminal.; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {t0}Shortcuts; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js' + +export function General(): React.ReactNode { + return ( + + + + Claude understands your codebase, makes edits with your permission, + and executes commands — right from your terminal. + + + + + Shortcuts + + + + + ) } diff --git a/src/components/HelpV2/HelpV2.tsx b/src/components/HelpV2/HelpV2.tsx index e81421fb9..9e2b0ce27 100644 --- a/src/components/HelpV2/HelpV2.tsx +++ b/src/components/HelpV2/HelpV2.tsx @@ -1,183 +1,138 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js'; -import { builtInCommandNames, type Command, type CommandResultDisplay, INTERNAL_ONLY_COMMANDS } from '../../commands.js'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Box, Link, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { Pane } from '../design-system/Pane.js'; -import { Tab, Tabs } from '../design-system/Tabs.js'; -import { Commands } from './Commands.js'; -import { General } from './General.js'; +import * as React from 'react' +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js' +import { + builtInCommandNames, + type Command, + type CommandResultDisplay, + INTERNAL_ONLY_COMMANDS, +} from '../../commands.js' +import { useIsInsideModal } from '../../context/modalContext.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Link, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { Pane } from '../design-system/Pane.js' +import { Tab, Tabs } from '../design-system/Tabs.js' +import { Commands } from './Commands.js' +import { General } from './General.js' + type Props = { - onClose: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - commands: Command[]; -}; -export function HelpV2(t0) { - const $ = _c(44); - const { - onClose, - commands - } = t0; - const { - rows, - columns - } = useTerminalSize(); - const maxHeight = Math.floor(rows / 2); - const insideModal = useIsInsideModal(); - let t1; - if ($[0] !== onClose) { - t1 = () => onClose("Help dialog dismissed", { - display: "system" - }); - $[0] = onClose; - $[1] = t1; - } else { - t1 = $[1]; - } - const close = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Help" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useKeybinding("help:dismiss", close, t2); - const exitState = useExitOnCtrlCDWithKeybindings(close); - const dismissShortcut = useShortcutDisplay("help:dismiss", "Help", "esc"); - let antOnlyCommands; - let builtinCommands; - let t3; - if ($[3] !== commands) { - const builtinNames = builtInCommandNames(); - builtinCommands = commands.filter(cmd => builtinNames.has(cmd.name) && !cmd.isHidden); - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = []; - $[7] = t4; - } else { - t4 = $[7]; - } - antOnlyCommands = t4; - t3 = commands.filter(cmd_2 => !builtinNames.has(cmd_2.name) && !cmd_2.isHidden); - $[3] = commands; - $[4] = antOnlyCommands; - $[5] = builtinCommands; - $[6] = t3; - } else { - antOnlyCommands = $[4]; - builtinCommands = $[5]; - t3 = $[6]; - } - const customCommands = t3; - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[8] = t4; - } else { - t4 = $[8]; - } - let tabs; - if ($[9] !== antOnlyCommands || $[10] !== builtinCommands || $[11] !== close || $[12] !== columns || $[13] !== customCommands || $[14] !== maxHeight) { - tabs = [t4]; - let t5; - if ($[16] !== builtinCommands || $[17] !== close || $[18] !== columns || $[19] !== maxHeight) { - t5 = ; - $[16] = builtinCommands; - $[17] = close; - $[18] = columns; - $[19] = maxHeight; - $[20] = t5; - } else { - t5 = $[20]; - } - tabs.push(t5); - let t6; - if ($[21] !== close || $[22] !== columns || $[23] !== customCommands || $[24] !== maxHeight) { - t6 = ; - $[21] = close; - $[22] = columns; - $[23] = customCommands; - $[24] = maxHeight; - $[25] = t6; - } else { - t6 = $[25]; - } - tabs.push(t6); - if (false && antOnlyCommands.length > 0) { - let t7; - if ($[26] !== antOnlyCommands || $[27] !== close || $[28] !== columns || $[29] !== maxHeight) { - t7 = ; - $[26] = antOnlyCommands; - $[27] = close; - $[28] = columns; - $[29] = maxHeight; - $[30] = t7; - } else { - t7 = $[30]; - } - tabs.push(t7); - } - $[9] = antOnlyCommands; - $[10] = builtinCommands; - $[11] = close; - $[12] = columns; - $[13] = customCommands; - $[14] = maxHeight; - $[15] = tabs; - } else { - tabs = $[15]; - } - const t5 = insideModal ? undefined : maxHeight; - let t6; - if ($[31] !== tabs) { - t6 = {tabs}; - $[31] = tabs; - $[32] = t6; - } else { - t6 = $[32]; - } - let t7; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t7 = For more help:{" "}; - $[33] = t7; - } else { - t7 = $[33]; - } - let t8; - if ($[34] !== dismissShortcut || $[35] !== exitState.keyName || $[36] !== exitState.pending) { - t8 = {exitState.pending ? <>Press {exitState.keyName} again to exit : {dismissShortcut} to cancel}; - $[34] = dismissShortcut; - $[35] = exitState.keyName; - $[36] = exitState.pending; - $[37] = t8; - } else { - t8 = $[37]; - } - let t9; - if ($[38] !== t6 || $[39] !== t8) { - t9 = {t6}{t7}{t8}; - $[38] = t6; - $[39] = t8; - $[40] = t9; - } else { - t9 = $[40]; + onClose: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + commands: Command[] +} + +export function HelpV2({ onClose, commands }: Props): React.ReactNode { + const { rows, columns } = useTerminalSize() + const maxHeight = Math.floor(rows / 2) + // Inside the modal slot, FullscreenLayout already caps height and Pane/Tabs + // use flexShrink=0 (see #23592) — our own height= constraint would clip the + // footer since Tabs won't shrink to fit. Let the modal slot handle sizing. + const insideModal = useIsInsideModal() + + const close = () => onClose('Help dialog dismissed', { display: 'system' }) + useKeybinding('help:dismiss', close, { context: 'Help' }) + const exitState = useExitOnCtrlCDWithKeybindings(close) + const dismissShortcut = useShortcutDisplay('help:dismiss', 'Help', 'esc') + + const builtinNames = builtInCommandNames() + let builtinCommands = commands.filter( + cmd => builtinNames.has(cmd.name) && !cmd.isHidden, + ) + let antOnlyCommands: Command[] = [] + + // We have to do this in an `if` to help treeshaking + if (process.env.USER_TYPE === 'ant') { + const internalOnlyNames = new Set(INTERNAL_ONLY_COMMANDS.map(_ => _.name)) + builtinCommands = builtinCommands.filter( + cmd => !internalOnlyNames.has(cmd.name), + ) + antOnlyCommands = commands.filter( + cmd => internalOnlyNames.has(cmd.name) && !cmd.isHidden, + ) } - let t10; - if ($[41] !== t5 || $[42] !== t9) { - t10 = {t9}; - $[41] = t5; - $[42] = t9; - $[43] = t10; - } else { - t10 = $[43]; + + const customCommands = commands.filter( + cmd => !builtinNames.has(cmd.name) && !cmd.isHidden, + ) + + const tabs = [ + + + , + ] + + tabs.push( + + + , + ) + + tabs.push( + + + , + ) + + if (process.env.USER_TYPE === 'ant' && antOnlyCommands.length > 0) { + tabs.push( + + + , + ) } - return t10; + + return ( + + + + {tabs} + + + + For more help:{' '} + + + + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + {dismissShortcut} to cancel + )} + + + + + ) } diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx index 302002b25..47f7271bc 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -1,189 +1,127 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { useSettings } from '../hooks/useSettings.js'; -import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '../ink.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import sliceAnsi from '../utils/sliceAnsi.js'; -import { countCharInString } from '../utils/stringUtils.js'; -import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js'; -import { expectColorFile } from './StructuredDiff/colorDiff.js'; +import * as React from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { useSettings } from '../hooks/useSettings.js' +import { + Ansi, + Box, + type DOMElement, + measureElement, + NoSelect, + Text, + useTheme, +} from '../ink.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import sliceAnsi from '../utils/sliceAnsi.js' +import { countCharInString } from '../utils/stringUtils.js' +import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js' +import { expectColorFile } from './StructuredDiff/colorDiff.js' + type Props = { - code: string; - filePath: string; - width?: number; - dim?: boolean; -}; -const DEFAULT_WIDTH = 80; -export const HighlightedCode = memo(function HighlightedCode(t0: Props) { - const $ = _c(21); - const { - code, - filePath, - width, - dim: t1 - } = t0; - const dim = t1 === undefined ? false : t1; - const ref = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH); - const [theme] = useTheme(); - const settings = useSettings(); - const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false; - let t2; - bb0: { + code: string + filePath: string + width?: number + dim?: boolean +} + +const DEFAULT_WIDTH = 80 + +export const HighlightedCode = memo(function HighlightedCode({ + code, + filePath, + width, + dim = false, +}: Props): React.ReactElement { + const ref = useRef(null) + const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH) + const [theme] = useTheme() + const settings = useSettings() + const syntaxHighlightingDisabled = + settings.syntaxHighlightingDisabled ?? false + + const colorFile = useMemo(() => { if (syntaxHighlightingDisabled) { - t2 = null; - break bb0; - } - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = expectColorFile(); - $[0] = t3; - } else { - t3 = $[0]; + return null } - const ColorFile = t3; + const ColorFile = expectColorFile() if (!ColorFile) { - t2 = null; - break bb0; + return null } - let t4; - if ($[1] !== code || $[2] !== filePath) { - t4 = new ColorFile(code, filePath); - $[1] = code; - $[2] = filePath; - $[3] = t4; - } else { - t4 = $[3]; - } - t2 = t4; - } - const colorFile = t2; - let t3; - let t4; - if ($[4] !== width) { - t3 = () => { - if (!width && ref.current) { - const { - width: elementWidth - } = measureElement(ref.current); - if (elementWidth > 0) { - setMeasuredWidth(elementWidth - 2); - } + return new ColorFile(code, filePath) + }, [code, filePath, syntaxHighlightingDisabled]) + + useEffect(() => { + if (!width && ref.current) { + const { width: elementWidth } = measureElement(ref.current) + if (elementWidth > 0) { + setMeasuredWidth(elementWidth - 2) } - }; - t4 = [width]; - $[4] = width; - $[5] = t3; - $[6] = t4; - } else { - t3 = $[5]; - t4 = $[6]; - } - useEffect(t3, t4); - let t5; - bb1: { - if (colorFile === null) { - t5 = null; - break bb1; } - let t6; - if ($[7] !== colorFile || $[8] !== dim || $[9] !== measuredWidth || $[10] !== theme) { - t6 = colorFile.render(theme, measuredWidth, dim); - $[7] = colorFile; - $[8] = dim; - $[9] = measuredWidth; - $[10] = theme; - $[11] = t6; - } else { - t6 = $[11]; - } - t5 = t6; - } - const lines = t5; - let t6; - bb2: { - if (!isFullscreenEnvEnabled()) { - t6 = 0; - break bb2; - } - const lineCount = countCharInString(code, "\n") + 1; - let t7; - if ($[12] !== lineCount) { - t7 = lineCount.toString(); - $[12] = lineCount; - $[13] = t7; - } else { - t7 = $[13]; + }, [width]) + + const lines = useMemo(() => { + if (colorFile === null) { + return null } - t6 = t7.length + 2; - } - const gutterWidth = t6; - let t7; - if ($[14] !== code || $[15] !== dim || $[16] !== filePath || $[17] !== gutterWidth || $[18] !== lines || $[19] !== syntaxHighlightingDisabled) { - t7 = {lines ? {lines.map((line, i) => gutterWidth > 0 ? : {line})} : }; - $[14] = code; - $[15] = dim; - $[16] = filePath; - $[17] = gutterWidth; - $[18] = lines; - $[19] = syntaxHighlightingDisabled; - $[20] = t7; - } else { - t7 = $[20]; - } - return t7; -}); -function CodeLine(t0) { - const $ = _c(13); - const { - line, - gutterWidth - } = t0; - let t1; - if ($[0] !== gutterWidth || $[1] !== line) { - t1 = sliceAnsi(line, 0, gutterWidth); - $[0] = gutterWidth; - $[1] = line; - $[2] = t1; - } else { - t1 = $[2]; - } - const gutter = t1; - let t2; - if ($[3] !== gutterWidth || $[4] !== line) { - t2 = sliceAnsi(line, gutterWidth); - $[3] = gutterWidth; - $[4] = line; - $[5] = t2; - } else { - t2 = $[5]; - } - const content = t2; - let t3; - if ($[6] !== gutter) { - t3 = {gutter}; - $[6] = gutter; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== content) { - t4 = {content}; - $[8] = content; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== t3 || $[11] !== t4) { - t5 = {t3}{t4}; - $[10] = t3; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - return t5; + return colorFile.render(theme, measuredWidth, dim) + }, [colorFile, theme, measuredWidth, dim]) + + // Gutter width matches ColorFile's layout in lib.rs: space + right-aligned + // line number (max_digits = lineCount.toString().length) + space. No marker + // column like the diff path. Wrap in so fullscreen selection + // yields clean code without line numbers. Only split in fullscreen mode + // (~4× DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native + // selection where noSelect is meaningless. + const gutterWidth = useMemo(() => { + if (!isFullscreenEnvEnabled()) return 0 + const lineCount = countCharInString(code, '\n') + 1 + return lineCount.toString().length + 2 + }, [code]) + + return ( + + {lines ? ( + + {lines.map((line, i) => + gutterWidth > 0 ? ( + + ) : ( + + {line} + + ), + )} + + ) : ( + + )} + + ) +}) + +function CodeLine({ + line, + gutterWidth, +}: { + line: string + gutterWidth: number +}): React.ReactNode { + const gutter = sliceAnsi(line, 0, gutterWidth) + const content = sliceAnsi(line, gutterWidth) + return ( + + + + {gutter} + + + + {content} + + + ) } diff --git a/src/components/HighlightedCode/Fallback.tsx b/src/components/HighlightedCode/Fallback.tsx index 2a20c9ad2..3d1f70112 100644 --- a/src/components/HighlightedCode/Fallback.tsx +++ b/src/components/HighlightedCode/Fallback.tsx @@ -1,192 +1,99 @@ -import { c as _c } from "react/compiler-runtime"; -import { extname } from 'path'; -import React, { Suspense, use, useMemo } from 'react'; -import { Ansi, Text } from '../../ink.js'; -import { getCliHighlightPromise } from '../../utils/cliHighlight.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { convertLeadingTabsToSpaces } from '../../utils/file.js'; -import { hashPair } from '../../utils/hash.js'; +import { extname } from 'path' +import React, { Suspense, use, useMemo } from 'react' +import { Ansi, Text } from '../../ink.js' +import { getCliHighlightPromise } from '../../utils/cliHighlight.js' +import { logForDebugging } from '../../utils/debug.js' +import { convertLeadingTabsToSpaces } from '../../utils/file.js' +import { hashPair } from '../../utils/hash.js' + type Props = { - code: string; - filePath: string; - dim?: boolean; - skipColoring?: boolean; -}; + code: string + filePath: string + dim?: boolean + skipColoring?: boolean +} // Module-level highlight cache — hl.highlight() is the hot cost on virtual- // scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash // of code+language to avoid retaining full source strings (#24180 RSS fix). -const HL_CACHE_MAX = 500; -const hlCache = new Map(); -function cachedHighlight(hl: NonNullable>>, code: string, language: string): string { - const key = hashPair(language, code); - const hit = hlCache.get(key); +const HL_CACHE_MAX = 500 +const hlCache = new Map() +function cachedHighlight( + hl: NonNullable>>, + code: string, + language: string, +): string { + const key = hashPair(language, code) + const hit = hlCache.get(key) if (hit !== undefined) { - hlCache.delete(key); - hlCache.set(key, hit); - return hit; + hlCache.delete(key) + hlCache.set(key, hit) + return hit } - const out = hl.highlight(code, { - language - }); + const out = hl.highlight(code, { language }) if (hlCache.size >= HL_CACHE_MAX) { - const first = hlCache.keys().next().value; - if (first !== undefined) hlCache.delete(first); + const first = hlCache.keys().next().value + if (first !== undefined) hlCache.delete(first) } - hlCache.set(key, out); - return out; + hlCache.set(key, out) + return out } -export function HighlightedCodeFallback(t0) { - const $ = _c(20); - const { - code, - filePath, - dim: t1, - skipColoring: t2 - } = t0; - const dim = t1 === undefined ? false : t1; - const skipColoring = t2 === undefined ? false : t2; - let t3; - if ($[0] !== code) { - t3 = convertLeadingTabsToSpaces(code); - $[0] = code; - $[1] = t3; - } else { - t3 = $[1]; - } - const codeWithSpaces = t3; + +export function HighlightedCodeFallback({ + code, + filePath, + dim = false, + skipColoring = false, +}: Props): React.ReactElement { + const codeWithSpaces = convertLeadingTabsToSpaces(code) if (skipColoring) { - let t4; - if ($[2] !== codeWithSpaces) { - t4 = {codeWithSpaces}; - $[2] = codeWithSpaces; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== dim || $[5] !== t4) { - t5 = {t4}; - $[4] = dim; - $[5] = t4; - $[6] = t5; - } else { - t5 = $[6]; - } - return t5; - } - let t4; - if ($[7] !== filePath) { - t4 = extname(filePath).slice(1); - $[7] = filePath; - $[8] = t4; - } else { - t4 = $[8]; - } - const language = t4; - let t5; - if ($[9] !== codeWithSpaces) { - t5 = {codeWithSpaces}; - $[9] = codeWithSpaces; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== codeWithSpaces || $[12] !== language) { - t6 = ; - $[11] = codeWithSpaces; - $[12] = language; - $[13] = t6; - } else { - t6 = $[13]; + return ( + + {codeWithSpaces} + + ) } - let t7; - if ($[14] !== t5 || $[15] !== t6) { - t7 = {t6}; - $[14] = t5; - $[15] = t6; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== dim || $[18] !== t7) { - t8 = {t7}; - $[17] = dim; - $[18] = t7; - $[19] = t8; - } else { - t8 = $[19]; - } - return t8; + const language = extname(filePath).slice(1) + return ( + + {codeWithSpaces}}> + + + + ) } -function Highlighted(t0) { - const $ = _c(10); - const { - codeWithSpaces, - language - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getCliHighlightPromise(); - $[0] = t1; - } else { - t1 = $[0]; - } - const hl = use(t1) as NonNullable>> | null; - let t2; - if ($[1] !== codeWithSpaces || $[2] !== hl || $[3] !== language) { - bb0: { - if (!hl) { - t2 = codeWithSpaces; - break bb0; - } - let highlightLang = "markdown"; - if (language) { - if (hl.supportsLanguage(language)) { - highlightLang = language; - } else { - logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${language}`); - } + +function Highlighted({ + codeWithSpaces, + language, +}: { + codeWithSpaces: string + language: string +}): React.ReactElement { + const hl = use(getCliHighlightPromise()) + const out = useMemo(() => { + if (!hl) return codeWithSpaces + let highlightLang = 'markdown' + if (language) { + if (hl.supportsLanguage(language)) { + highlightLang = language + } else { + logForDebugging( + `Language not supported while highlighting code, falling back to markdown: ${language}`, + ) } - ; - try { - t2 = cachedHighlight(hl, codeWithSpaces, highlightLang); - } catch (t3) { - const e = t3; - if (e instanceof Error && e.message.includes("Unknown language")) { - logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${e}`); - let t4; - if ($[5] !== codeWithSpaces || $[6] !== hl) { - t4 = cachedHighlight(hl, codeWithSpaces, "markdown"); - $[5] = codeWithSpaces; - $[6] = hl; - $[7] = t4; - } else { - t4 = $[7]; - } - t2 = t4; - break bb0; - } - t2 = codeWithSpaces; + } + try { + return cachedHighlight(hl, codeWithSpaces, highlightLang) + } catch (e) { + if (e instanceof Error && e.message.includes('Unknown language')) { + logForDebugging( + `Language not supported while highlighting code, falling back to markdown: ${e}`, + ) + return cachedHighlight(hl, codeWithSpaces, 'markdown') } + return codeWithSpaces } - $[1] = codeWithSpaces; - $[2] = hl; - $[3] = language; - $[4] = t2; - } else { - t2 = $[4]; - } - const out = t2; - let t3; - if ($[8] !== out) { - t3 = {out}; - $[8] = out; - $[9] = t3; - } else { - t3 = $[9]; - } - return t3; + }, [codeWithSpaces, language, hl]) + return {out} } diff --git a/src/components/HistorySearchDialog.tsx b/src/components/HistorySearchDialog.tsx index 9ec63d5ca..dd2e02da5 100644 --- a/src/components/HistorySearchDialog.tsx +++ b/src/components/HistorySearchDialog.tsx @@ -1,117 +1,170 @@ -import * as React from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import { useRegisterOverlay } from '../context/overlayContext.js'; -import { getTimestampedHistory, type TimestampedHistoryEntry } from '../history.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { wrapAnsi } from '../ink/wrapAnsi.js'; -import { Box, Text } from '../ink.js'; -import { logEvent } from '../services/analytics/index.js'; -import type { HistoryEntry } from '../utils/config.js'; -import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js'; -import { FuzzyPicker } from './design-system/FuzzyPicker.js'; +import * as React from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useRegisterOverlay } from '../context/overlayContext.js' +import { + getTimestampedHistory, + type TimestampedHistoryEntry, +} from '../history.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { wrapAnsi } from '../ink/wrapAnsi.js' +import { Box, Text } from '../ink.js' +import { logEvent } from '../services/analytics/index.js' +import type { HistoryEntry } from '../utils/config.js' +import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js' +import { FuzzyPicker } from './design-system/FuzzyPicker.js' + type Props = { - initialQuery?: string; - onSelect: (entry: HistoryEntry) => void; - onCancel: () => void; -}; -const PREVIEW_ROWS = 6; -const AGE_WIDTH = 8; + initialQuery?: string + onSelect: (entry: HistoryEntry) => void + onCancel: () => void +} + +const PREVIEW_ROWS = 6 +const AGE_WIDTH = 8 + type Item = { - entry: TimestampedHistoryEntry; - display: string; - lower: string; - firstLine: string; - age: string; -}; + entry: TimestampedHistoryEntry + display: string + lower: string + firstLine: string + age: string +} + export function HistorySearchDialog({ initialQuery, onSelect, - onCancel + onCancel, }: Props): React.ReactNode { - useRegisterOverlay('history-search', undefined); - const { - columns - } = useTerminalSize(); - const [items, setItems] = useState(null); - const [query, setQuery] = useState(initialQuery ?? ''); + useRegisterOverlay('history-search') + const { columns } = useTerminalSize() + + const [items, setItems] = useState(null) + const [query, setQuery] = useState(initialQuery ?? '') + useEffect(() => { - let cancelled = false; + let cancelled = false void (async () => { - const reader = getTimestampedHistory(); - const loaded: Item[] = []; + const reader = getTimestampedHistory() + const loaded: Item[] = [] for await (const entry of reader) { if (cancelled) { - void reader.return(undefined); - return; + void reader.return(undefined) + return } - const display = entry.display; - const nl = display.indexOf('\n'); - const age = formatRelativeTimeAgo(new Date(entry.timestamp)); + const display = entry.display + const nl = display.indexOf('\n') + const age = formatRelativeTimeAgo(new Date(entry.timestamp)) loaded.push({ entry, display, lower: display.toLowerCase(), firstLine: nl === -1 ? display : display.slice(0, nl), - age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))) - }); + age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))), + }) } - if (!cancelled) setItems(loaded); - })(); + if (!cancelled) setItems(loaded) + })() return () => { - cancelled = true; - }; - }, []); + cancelled = true + } + }, []) + const filtered = useMemo(() => { - if (!items) return []; - const q = query.trim().toLowerCase(); - if (!q) return items; - const exact: Item[] = []; - const fuzzy: Item[] = []; + if (!items) return [] + const q = query.trim().toLowerCase() + if (!q) return items + const exact: Item[] = [] + const fuzzy: Item[] = [] for (const item of items) { if (item.lower.includes(q)) { - exact.push(item); + exact.push(item) } else if (isSubsequence(item.lower, q)) { - fuzzy.push(item); + fuzzy.push(item) } } - return exact.concat(fuzzy); - }, [items, query]); - const previewOnRight = columns >= 100; - const listWidth = previewOnRight ? Math.floor((columns - 6) * 0.5) : columns - 6; - const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1); - const previewWidth = previewOnRight ? Math.max(20, columns - listWidth - 12) : Math.max(20, columns - 10); - return String(item_0.entry.timestamp)} onQueryChange={setQuery} onSelect={item_1 => { - logEvent('tengu_history_picker_select', { - result_count: filtered.length, - query_length: query.length - }); - void item_1.entry.resolve().then(onSelect); - }} onCancel={onCancel} emptyMessage={q_0 => items === null ? 'Loading…' : q_0 ? 'No matching prompts' : 'No history yet'} selectAction="use" direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} renderItem={(item_2, isFocused) => - {item_2.age} + return exact.concat(fuzzy) + }, [items, query]) + + const previewOnRight = columns >= 100 + const listWidth = previewOnRight + ? Math.floor((columns - 6) * 0.5) + : columns - 6 + const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1) + const previewWidth = previewOnRight + ? Math.max(20, columns - listWidth - 12) + : Math.max(20, columns - 10) + + return ( + String(item.entry.timestamp)} + onQueryChange={setQuery} + onSelect={item => { + logEvent('tengu_history_picker_select', { + result_count: filtered.length, + query_length: query.length, + }) + void item.entry.resolve().then(onSelect) + }} + onCancel={onCancel} + emptyMessage={q => + items === null + ? 'Loading…' + : q + ? 'No matching prompts' + : 'No history yet' + } + selectAction="use" + direction="up" + previewPosition={previewOnRight ? 'right' : 'bottom'} + renderItem={(item, isFocused) => ( + + {item.age} {' '} - {truncateToWidth(item_2.firstLine, rowWidth)} + {truncateToWidth(item.firstLine, rowWidth)} - } renderPreview={item_3 => { - const wrapped = wrapAnsi(item_3.display, previewWidth, { - hard: true - }).split('\n').filter(l => l.trim() !== ''); - const overflow = wrapped.length > PREVIEW_ROWS; - const shown = wrapped.slice(0, overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS); - const more = wrapped.length - shown.length; - return - {shown.map((row, i) => + + )} + renderPreview={item => { + const wrapped = wrapAnsi(item.display, previewWidth, { hard: true }) + .split('\n') + .filter(l => l.trim() !== '') + const overflow = wrapped.length > PREVIEW_ROWS + const shown = wrapped.slice( + 0, + overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS, + ) + const more = wrapped.length - shown.length + return ( + + {shown.map((row, i) => ( + {row} - )} + + ))} {more > 0 && {`… +${more} more lines`}} - ; - }} />; + + ) + }} + /> + ) } + function isSubsequence(text: string, query: string): boolean { - let j = 0; + let j = 0 for (let i = 0; i < text.length && j < query.length; i++) { - if (text[i] === query[j]) j++; + if (text[i] === query[j]) j++ } - return j === query.length; + return j === query.length } diff --git a/src/components/IdeAutoConnectDialog.tsx b/src/components/IdeAutoConnectDialog.tsx index 623f3bf4b..2377cfb3a 100644 --- a/src/components/IdeAutoConnectDialog.tsx +++ b/src/components/IdeAutoConnectDialog.tsx @@ -1,153 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import { Text } from '../ink.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import { isSupportedTerminal } from '../utils/ide.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import { Text } from '../ink.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { isSupportedTerminal } from '../utils/ide.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type IdeAutoConnectDialogProps = { - onComplete: () => void; -}; -export function IdeAutoConnectDialog(t0) { - const $ = _c(9); - const { - onComplete - } = t0; - let t1; - if ($[0] !== onComplete) { - t1 = async value => { - const autoConnect = value === "yes"; + onComplete: () => void +} + +export function IdeAutoConnectDialog({ + onComplete, +}: IdeAutoConnectDialogProps): React.ReactNode { + const handleSelect = useCallback( + async (value: string) => { + const autoConnect = value === 'yes' + + // Save the preference and mark dialog as shown saveGlobalConfig(current => ({ ...current, autoConnectIde: autoConnect, - hasIdeAutoConnectDialogBeenShown: true - })); - onComplete(); - }; - $[0] = onComplete; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSelect = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[2] = t2; - } else { - t2 = $[2]; - } - const options = t2; - let t3; - if ($[3] !== handleSelect) { - t3 = + + You can also configure this in /config or with the --ide flag + + + ) } + export function shouldShowAutoConnectDialog(): boolean { - const config = getGlobalConfig(); - return !isSupportedTerminal() && config.autoConnectIde !== true && config.hasIdeAutoConnectDialogBeenShown !== true; + const config = getGlobalConfig() + return ( + !isSupportedTerminal() && + config.autoConnectIde !== true && + config.hasIdeAutoConnectDialogBeenShown !== true + ) } + type IdeDisableAutoConnectDialogProps = { - onComplete: (disableAutoConnect: boolean) => void; -}; -export function IdeDisableAutoConnectDialog(t0) { - const $ = _c(10); - const { - onComplete - } = t0; - let t1; - if ($[0] !== onComplete) { - t1 = value => { - const disableAutoConnect = value === "yes"; + onComplete: (disableAutoConnect: boolean) => void +} + +export function IdeDisableAutoConnectDialog({ + onComplete, +}: IdeDisableAutoConnectDialogProps): React.ReactNode { + const handleSelect = useCallback( + (value: string) => { + const disableAutoConnect = value === 'yes' + if (disableAutoConnect) { - saveGlobalConfig(_temp); + saveGlobalConfig(current => ({ + ...current, + autoConnectIde: false, + })) } - onComplete(disableAutoConnect); - }; - $[0] = onComplete; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSelect = t1; - let t2; - if ($[2] !== onComplete) { - t2 = () => { - onComplete(false); - }; - $[2] = onComplete; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleCancel = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = [{ - label: "No", - value: "no" - }, { - label: "Yes", - value: "yes" - }]; - $[4] = t3; - } else { - t3 = $[4]; - } - const options = t3; - let t4; - if ($[5] !== handleSelect) { - t4 = + + ) } + export function shouldShowDisableAutoConnectDialog(): boolean { - const config = getGlobalConfig(); - return !isSupportedTerminal() && config.autoConnectIde === true; + const config = getGlobalConfig() + return !isSupportedTerminal() && config.autoConnectIde === true } diff --git a/src/components/IdeOnboardingDialog.tsx b/src/components/IdeOnboardingDialog.tsx index e47120b40..86f03018e 100644 --- a/src/components/IdeOnboardingDialog.tsx +++ b/src/components/IdeOnboardingDialog.tsx @@ -1,166 +1,108 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { envDynamic } from 'src/utils/envDynamic.js'; -import { Box, Text } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import { env } from '../utils/env.js'; -import { getTerminalIdeType, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName } from '../utils/ide.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { envDynamic } from 'src/utils/envDynamic.js' +import { Box, Text } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { env } from '../utils/env.js' +import { + getTerminalIdeType, + type IDEExtensionInstallationStatus, + isJetBrainsIde, + toIDEDisplayName, +} from '../utils/ide.js' +import { Dialog } from './design-system/Dialog.js' + interface Props { - onDone: () => void; - installationStatus: IDEExtensionInstallationStatus | null; + onDone: () => void + installationStatus: IDEExtensionInstallationStatus | null } -export function IdeOnboardingDialog(t0) { - const $ = _c(23); - const { - onDone, - installationStatus - } = t0; - markDialogAsShown(); - let t1; - if ($[0] !== onDone) { - t1 = { - "confirm:yes": onDone, - "confirm:no": onDone - }; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useKeybindings(t1, t2); - let t3; - if ($[3] !== installationStatus?.ideType) { - t3 = installationStatus?.ideType ?? getTerminalIdeType(); - $[3] = installationStatus?.ideType; - $[4] = t3; - } else { - t3 = $[4]; - } - const ideType = t3; - const isJetBrains = isJetBrainsIde(ideType); - let t4; - if ($[5] !== ideType) { - t4 = toIDEDisplayName(ideType); - $[5] = ideType; - $[6] = t4; - } else { - t4 = $[6]; - } - const ideName = t4; - const installedVersion = installationStatus?.installedVersion; - const pluginOrExtension = isJetBrains ? "plugin" : "extension"; - const mentionShortcut = env.platform === "darwin" ? "Cmd+Option+K" : "Ctrl+Alt+K"; - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== ideName) { - t6 = <>{t5}Welcome to Claude Code for {ideName}; - $[8] = ideName; - $[9] = t6; - } else { - t6 = $[9]; - } - const t7 = installedVersion ? `installed ${pluginOrExtension} v${installedVersion}` : undefined; - let t8; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t8 = ⧉ open files; - $[10] = t8; - } else { - t8 = $[10]; - } - let t9; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t9 = • Claude has context of {t8}{" "}and ⧉ selected lines; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = +11; - $[12] = t10; - } else { - t10 = $[12]; - } - let t11; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t11 = • Review Claude Code's changes{" "}{t10}{" "}-22 in the comfort of your IDE; - $[13] = t11; - } else { - t11 = $[13]; - } - let t12; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t12 = • Cmd+Esc for Quick Launch; - $[14] = t12; - } else { - t12 = $[14]; - } - let t13; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {t9}{t11}{t12}• {mentionShortcut} to reference files or lines in your input; - $[15] = t13; - } else { - t13 = $[15]; - } - let t14; - if ($[16] !== onDone || $[17] !== t6 || $[18] !== t7) { - t14 = {t13}; - $[16] = onDone; - $[17] = t6; - $[18] = t7; - $[19] = t14; - } else { - t14 = $[19]; - } - let t15; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t15 = Press Enter to continue; - $[20] = t15; - } else { - t15 = $[20]; - } - let t16; - if ($[21] !== t14) { - t16 = <>{t14}{t15}; - $[21] = t14; - $[22] = t16; - } else { - t16 = $[22]; - } - return t16; + +export function IdeOnboardingDialog({ + onDone, + installationStatus, +}: Props): React.ReactNode { + markDialogAsShown() + + // Handle Enter/Escape to dismiss + useKeybindings( + { + 'confirm:yes': onDone, + 'confirm:no': onDone, + }, + { context: 'Confirmation' }, + ) + + const ideType = installationStatus?.ideType ?? getTerminalIdeType() + const isJetBrains = isJetBrainsIde(ideType) + + const ideName = toIDEDisplayName(ideType) + const installedVersion = installationStatus?.installedVersion + const pluginOrExtension = isJetBrains ? 'plugin' : 'extension' + const mentionShortcut = + env.platform === 'darwin' ? 'Cmd+Option+K' : 'Ctrl+Alt+K' + + return ( + <> + + + Welcome to Claude Code for {ideName} + + } + subtitle={ + installedVersion + ? `installed ${pluginOrExtension} v${installedVersion}` + : undefined + } + color="ide" + onCancel={onDone} + hideInputGuide + > + + + • Claude has context of ⧉ open files{' '} + and ⧉ selected lines + + + • Review Claude Code's changes{' '} + +11{' '} + -22 in the comfort of your IDE + + + • Cmd+Esc for Quick Launch + + + • {mentionShortcut} + to reference files or lines in your input + + + + + + Press Enter to continue + + + + ) } + export function hasIdeOnboardingDialogBeenShown(): boolean { - const config = getGlobalConfig(); - const terminal = envDynamic.terminal || 'unknown'; - return config.hasIdeOnboardingBeenShown?.[terminal] === true; + const config = getGlobalConfig() + const terminal = envDynamic.terminal || 'unknown' + return config.hasIdeOnboardingBeenShown?.[terminal] === true } + function markDialogAsShown(): void { if (hasIdeOnboardingDialogBeenShown()) { - return; + return } - const terminal = envDynamic.terminal || 'unknown'; + const terminal = envDynamic.terminal || 'unknown' saveGlobalConfig(current => ({ ...current, hasIdeOnboardingBeenShown: { ...current.hasIdeOnboardingBeenShown, - [terminal]: true - } - })); + [terminal]: true, + }, + })) } diff --git a/src/components/IdeStatusIndicator.tsx b/src/components/IdeStatusIndicator.tsx index 7de51479a..13c1846c0 100644 --- a/src/components/IdeStatusIndicator.tsx +++ b/src/components/IdeStatusIndicator.tsx @@ -1,57 +1,45 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename } from 'path'; -import * as React from 'react'; -import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js'; -import type { IDESelection } from '../hooks/useIdeSelection.js'; -import { Text } from '../ink.js'; -import type { MCPServerConnection } from '../services/mcp/types.js'; +import { basename } from 'path' +import * as React from 'react' +import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js' +import type { IDESelection } from '../hooks/useIdeSelection.js' +import { Text } from '../ink.js' +import type { MCPServerConnection } from '../services/mcp/types.js' + type IdeStatusIndicatorProps = { - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; -}; -export function IdeStatusIndicator(t0) { - const $ = _c(7); - const { - ideSelection, - mcpClients - } = t0; - const { - status: ideStatus - } = useIdeConnectionStatus(mcpClients); - const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0); + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] +} + +export function IdeStatusIndicator({ + ideSelection, + mcpClients, +}: IdeStatusIndicatorProps): React.ReactNode { + const { status: ideStatus } = useIdeConnectionStatus(mcpClients) + + // Check if we should show the IDE selection indicator + const shouldShowIdeSelection = + ideStatus === 'connected' && + (ideSelection?.filePath || + (ideSelection?.text && ideSelection.lineCount > 0)) + if (ideStatus === null || !shouldShowIdeSelection || !ideSelection) { - return null; + return null } + if (ideSelection.text && ideSelection.lineCount > 0) { - const t1 = ideSelection.lineCount === 1 ? "line" : "lines"; - let t2; - if ($[0] !== ideSelection.lineCount || $[1] !== t1) { - t2 = ⧉ {ideSelection.lineCount}{" "}{t1} selected; - $[0] = ideSelection.lineCount; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + return ( + + ⧉ {ideSelection.lineCount}{' '} + {ideSelection.lineCount === 1 ? 'line' : 'lines'} selected + + ) } + if (ideSelection.filePath) { - let t1; - if ($[3] !== ideSelection.filePath) { - t1 = basename(ideSelection.filePath); - $[3] = ideSelection.filePath; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] !== t1) { - t2 = ⧉ In {t1}; - $[5] = t1; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; + return ( + + ⧉ In {basename(ideSelection.filePath)} + + ) } } diff --git a/src/components/IdleReturnDialog.tsx b/src/components/IdleReturnDialog.tsx index b7d0de851..d651cfe38 100644 --- a/src/components/IdleReturnDialog.tsx +++ b/src/components/IdleReturnDialog.tsx @@ -1,117 +1,67 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../ink.js'; -import { formatTokens } from '../utils/format.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never'; +import React from 'react' +import { Box, Text } from '../ink.js' +import { formatTokens } from '../utils/format.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + +type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never' + type Props = { - idleMinutes: number; - totalInputTokens: number; - onDone: (action: IdleReturnAction) => void; -}; -export function IdleReturnDialog(t0) { - const $ = _c(16); - const { - idleMinutes, - totalInputTokens, - onDone - } = t0; - let t1; - if ($[0] !== idleMinutes) { - t1 = formatIdleDuration(idleMinutes); - $[0] = idleMinutes; - $[1] = t1; - } else { - t1 = $[1]; - } - const formattedIdle = t1; - let t2; - if ($[2] !== totalInputTokens) { - t2 = formatTokens(totalInputTokens); - $[2] = totalInputTokens; - $[3] = t2; - } else { - t2 = $[3]; - } - const formattedTokens = t2; - const t3 = `You've been away ${formattedIdle} and this conversation is ${formattedTokens} tokens.`; - let t4; - if ($[4] !== onDone) { - t4 = () => onDone("dismiss"); - $[4] = onDone; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = If this is a new task, clearing context will save usage and be faster.; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - value: "continue" as const, - label: "Continue this conversation" - }; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - value: "clear" as const, - label: "Send message as a new conversation" - }; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = [t6, t7, { - value: "never" as const, - label: "Don't ask me again" - }]; - $[9] = t8; - } else { - t8 = $[9]; - } - let t9; - if ($[10] !== onDone) { - t9 = onDone(value)} + /> + + ) } + function formatIdleDuration(minutes: number): string { if (minutes < 1) { - return '< 1m'; + return '< 1m' } if (minutes < 60) { - return `${Math.floor(minutes)}m`; + return `${Math.floor(minutes)}m` } - const hours = Math.floor(minutes / 60); - const remainingMinutes = Math.floor(minutes % 60); + const hours = Math.floor(minutes / 60) + const remainingMinutes = Math.floor(minutes % 60) if (remainingMinutes === 0) { - return `${hours}h`; + return `${hours}h` } - return `${hours}h ${remainingMinutes}m`; + return `${hours}h ${remainingMinutes}m` } diff --git a/src/components/InterruptedByUser.tsx b/src/components/InterruptedByUser.tsx index ecea3556a..0a77c7153 100644 --- a/src/components/InterruptedByUser.tsx +++ b/src/components/InterruptedByUser.tsx @@ -1,14 +1,15 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../ink.js'; -export function InterruptedByUser() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = <>Interrupted {false ? · [ANT-ONLY] /issue to report a model issue : · What should Claude do instead?}; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { Text } from '../ink.js' + +export function InterruptedByUser(): React.ReactNode { + return ( + <> + Interrupted + {process.env.USER_TYPE === 'ant' ? ( + · [ANT-ONLY] /issue to report a model issue + ) : ( + · What should Claude do instead? + )} + + ) } diff --git a/src/components/InvalidConfigDialog.tsx b/src/components/InvalidConfigDialog.tsx index e038d04e4..8fa3bba97 100644 --- a/src/components/InvalidConfigDialog.tsx +++ b/src/components/InvalidConfigDialog.tsx @@ -1,155 +1,115 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, render, Text } from '../ink.js'; -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; -import { AppStateProvider } from '../state/AppState.js'; -import type { ConfigParseError } from '../utils/errors.js'; -import { getBaseRenderOptions } from '../utils/renderOptions.js'; -import { jsonStringify, writeFileSync_DEPRECATED } from '../utils/slowOperations.js'; -import type { ThemeName } from '../utils/theme.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { Box, render, Text } from '../ink.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../state/AppState.js' +import type { ConfigParseError } from '../utils/errors.js' +import { getBaseRenderOptions } from '../utils/renderOptions.js' +import { + jsonStringify, + writeFileSync_DEPRECATED, +} from '../utils/slowOperations.js' +import type { ThemeName } from '../utils/theme.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + interface InvalidConfigHandlerProps { - error: ConfigParseError; + error: ConfigParseError } + interface InvalidConfigDialogProps { - filePath: string; - errorDescription: string; - onExit: () => void; - onReset: () => void; + filePath: string + errorDescription: string + onExit: () => void + onReset: () => void } /** * Dialog shown when the Claude config file contains invalid JSON */ -function InvalidConfigDialog(t0) { - const $ = _c(19); - const { - filePath, - errorDescription, - onExit, - onReset - } = t0; - let t1; - if ($[0] !== onExit || $[1] !== onReset) { - t1 = value => { - if (value === "exit") { - onExit(); - } else { - onReset(); - } - }; - $[0] = onExit; - $[1] = onReset; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleSelect = t1; - let t2; - if ($[3] !== filePath) { - t2 = The configuration file at {filePath} contains invalid JSON.; - $[3] = filePath; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== errorDescription) { - t3 = {errorDescription}; - $[5] = errorDescription; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t2 || $[8] !== t3) { - t4 = {t2}{t3}; - $[7] = t2; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Choose an option:; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [{ - label: "Exit and fix manually", - value: "exit" - }, { - label: "Reset with default configuration", - value: "reset" - }]; - $[11] = t6; - } else { - t6 = $[11]; +function InvalidConfigDialog({ + filePath, + errorDescription, + onExit, + onReset, +}: InvalidConfigDialogProps): React.ReactNode { + // Handler for Select onChange + const handleSelect = (value: string) => { + if (value === 'exit') { + onExit() + } else { + onReset() + } } - let t7; - if ($[12] !== handleSelect || $[13] !== onExit) { - t7 = {t5} + + + ) } /** * Safe fallback theme name for error dialogs to avoid circular dependency. * Uses a hardcoded dark theme that doesn't require reading from config. */ -const SAFE_ERROR_THEME_NAME: ThemeName = 'dark'; +const SAFE_ERROR_THEME_NAME: ThemeName = 'dark' + export async function showInvalidConfigDialog({ - error + error, }: InvalidConfigHandlerProps): Promise { // Extend RenderOptions with theme property for this specific usage - type SafeRenderOptions = Parameters[1] & { - theme?: ThemeName; - }; + type SafeRenderOptions = Parameters[1] & { theme?: ThemeName } + const renderOptions: SafeRenderOptions = { ...getBaseRenderOptions(false), // IMPORTANT: Use hardcoded theme name to avoid circular dependency with getGlobalConfig() // This allows the error dialog to show even when config file has JSON syntax errors - theme: SAFE_ERROR_THEME_NAME - }; + theme: SAFE_ERROR_THEME_NAME, + } + await new Promise(async resolve => { - const { - unmount - } = await render( + const { unmount } = await render( + - { - unmount(); - void resolve(); - process.exit(1); - }} onReset={() => { - writeFileSync_DEPRECATED(error.filePath, jsonStringify(error.defaultConfig, null, 2), { - flush: false, - encoding: 'utf8' - }); - unmount(); - void resolve(); - process.exit(0); - }} /> + { + unmount() + void resolve() + process.exit(1) + }} + onReset={() => { + writeFileSync_DEPRECATED( + error.filePath, + jsonStringify(error.defaultConfig, null, 2), + { flush: false, encoding: 'utf8' }, + ) + unmount() + void resolve() + process.exit(0) + }} + /> - , renderOptions); - }); + , + renderOptions, + ) + }) } diff --git a/src/components/InvalidSettingsDialog.tsx b/src/components/InvalidSettingsDialog.tsx index 097293ebf..c1fddf96a 100644 --- a/src/components/InvalidSettingsDialog.tsx +++ b/src/components/InvalidSettingsDialog.tsx @@ -1,88 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../ink.js'; -import type { ValidationError } from '../utils/settings/validation.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -import { ValidationErrorsList } from './ValidationErrorsList.js'; +import React from 'react' +import { Text } from '../ink.js' +import type { ValidationError } from '../utils/settings/validation.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { ValidationErrorsList } from './ValidationErrorsList.js' + type Props = { - settingsErrors: ValidationError[]; - onContinue: () => void; - onExit: () => void; -}; + settingsErrors: ValidationError[] + onContinue: () => void + onExit: () => void +} /** * Dialog shown when settings files have validation errors. * User must choose to continue (skipping invalid files) or exit to fix them. */ -export function InvalidSettingsDialog(t0) { - const $ = _c(13); - const { - settingsErrors, - onContinue, - onExit - } = t0; - let t1; - if ($[0] !== onContinue || $[1] !== onExit) { - t1 = function handleSelect(value) { - if (value === "exit") { - onExit(); - } else { - onContinue(); - } - }; - $[0] = onContinue; - $[1] = onExit; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleSelect = t1; - let t2; - if ($[3] !== settingsErrors) { - t2 = ; - $[3] = settingsErrors; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Files with errors are skipped entirely, not just the invalid settings.; - $[5] = t3; - } else { - t3 = $[5]; +export function InvalidSettingsDialog({ + settingsErrors, + onContinue, + onExit, +}: Props): React.ReactNode { + function handleSelect(value: string): void { + if (value === 'exit') { + onExit() + } else { + onContinue() + } } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Exit and fix manually", - value: "exit" - }, { - label: "Continue without these settings", - value: "continue" - }]; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== handleSelect) { - t5 = + + ) } diff --git a/src/components/KeybindingWarnings.tsx b/src/components/KeybindingWarnings.tsx index 0ce351d72..8f6957c3e 100644 --- a/src/components/KeybindingWarnings.tsx +++ b/src/components/KeybindingWarnings.tsx @@ -1,7 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../ink.js'; -import { getCachedKeybindingWarnings, getKeybindingsPath, isKeybindingCustomizationEnabled } from '../keybindings/loadUserBindings.js'; +import React from 'react' +import { Box, Text } from '../ink.js' +import { + getCachedKeybindingWarnings, + getKeybindingsPath, + isKeybindingCustomizationEnabled, +} from '../keybindings/loadUserBindings.js' /** * Displays keybinding validation warnings in the UI. @@ -10,45 +13,60 @@ import { getCachedKeybindingWarnings, getKeybindingsPath, isKeybindingCustomizat * * Only shown when keybinding customization is enabled (ant users + feature gate). */ -export function KeybindingWarnings() { - const $ = _c(2); +export function KeybindingWarnings(): React.ReactNode { + // Only show warnings when keybinding customization is enabled if (!isKeybindingCustomizationEnabled()) { - return null; + return null } - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const warnings = getCachedKeybindingWarnings(); - if (warnings.length === 0) { - t1 = null; - break bb0; - } - const errors = warnings.filter(_temp); - const warns = warnings.filter(_temp2); - t0 = 0 ? "error" : "warning"}>Keybinding Configuration IssuesLocation: {getKeybindingsPath()}{errors.map(_temp3)}{warns.map(_temp4)}; - } - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; - } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; + + const warnings = getCachedKeybindingWarnings() + + if (warnings.length === 0) { + return null } - return t0; -} -function _temp4(warning, i_0) { - return [Warning] {warning.message}{warning.suggestion && → {warning.suggestion}}; -} -function _temp3(error, i) { - return [Error] {error.message}{error.suggestion && → {error.suggestion}}; -} -function _temp2(w_0) { - return w_0.severity === "warning"; -} -function _temp(w) { - return w.severity === "error"; + + const errors = warnings.filter(w => w.severity === 'error') + const warns = warnings.filter(w => w.severity === 'warning') + + return ( + + 0 ? 'error' : 'warning'}> + Keybinding Configuration Issues + + + Location: + {getKeybindingsPath()} + + + {errors.map((error, i) => ( + + + + [Error] + {error.message} + + {error.suggestion && ( + + → {error.suggestion} + + )} + + ))} + {warns.map((warning, i) => ( + + + + [Warning] + {warning.message} + + {warning.suggestion && ( + + → {warning.suggestion} + + )} + + ))} + + + ) } diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx index c28dc53c2..53be69d48 100644 --- a/src/components/LanguagePicker.tsx +++ b/src/components/LanguagePicker.tsx @@ -1,85 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useState } from 'react'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import TextInput from './TextInput.js'; +import figures from 'figures' +import React, { useState } from 'react' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import TextInput from './TextInput.js' + type Props = { - initialLanguage: string | undefined; - onComplete: (language: string | undefined) => void; - onCancel: () => void; -}; -export function LanguagePicker(t0) { - const $ = _c(13); - const { - initialLanguage, - onComplete, - onCancel - } = t0; - const [language, setLanguage] = useState(initialLanguage); - const [cursorOffset, setCursorOffset] = useState((initialLanguage ?? "").length); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Settings" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - let t2; - if ($[1] !== language || $[2] !== onComplete) { - t2 = function handleSubmit() { - const trimmed = language?.trim(); - onComplete(trimmed || undefined); - }; - $[1] = language; - $[2] = onComplete; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleSubmit = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Enter your preferred response and voice language:; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {figures.pointer}; - $[5] = t4; - } else { - t4 = $[5]; - } - const t5 = language ?? ""; - let t6; - if ($[6] !== cursorOffset || $[7] !== handleSubmit || $[8] !== t5) { - t6 = {t4}; - $[6] = cursorOffset; - $[7] = handleSubmit; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Leave empty for default (English); - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t6) { - t8 = {t3}{t6}{t7}; - $[11] = t6; - $[12] = t8; - } else { - t8 = $[12]; + initialLanguage: string | undefined + onComplete: (language: string | undefined) => void + onCancel: () => void +} + +export function LanguagePicker({ + initialLanguage, + onComplete, + onCancel, +}: Props): React.ReactNode { + const [language, setLanguage] = useState(initialLanguage) + const [cursorOffset, setCursorOffset] = useState( + (initialLanguage ?? '').length, + ) + + // Use configurable keybinding for ESC to cancel + // Use Settings context so 'n' key doesn't trigger cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + function handleSubmit(): void { + const trimmed = language?.trim() + onComplete(trimmed || undefined) } - return t8; + + return ( + + Enter your preferred response and voice language: + + {figures.pointer} + + + Leave empty for default (English) + + ) } diff --git a/src/components/LogSelector.tsx b/src/components/LogSelector.tsx index bb206feda..d1fb9f607 100644 --- a/src/components/LogSelector.tsx +++ b/src/components/LogSelector.tsx @@ -1,1533 +1,1373 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import Fuse from 'fuse.js'; -import React from 'react'; -import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useSearchInput } from '../hooks/useSearchInput.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { applyColor } from '../ink/colorize.js'; -import type { Color } from '../ink/styles.js'; -import { Box, Text, useInput, useTerminalFocus, useTheme } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { logEvent } from '../services/analytics/index.js'; -import type { LogOption, SerializedMessage } from '../types/logs.js'; -import { formatLogMetadata, truncateToWidth } from '../utils/format.js'; -import { getWorktreePaths } from '../utils/getWorktreePaths.js'; -import { getBranch } from '../utils/git.js'; -import { getLogDisplayTitle } from '../utils/log.js'; -import { getFirstMeaningfulUserMessageTextContent, getSessionIdFromLog, isCustomTitleEnabled, saveCustomTitle } from '../utils/sessionStorage.js'; -import { getTheme } from '../utils/theme.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/select.js'; -import { Byline } from './design-system/Byline.js'; -import { Divider } from './design-system/Divider.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { SearchBox } from './SearchBox.js'; -import { SessionPreview } from './SessionPreview.js'; -import { Spinner } from './Spinner.js'; -import { TagTabs } from './TagTabs.js'; -import TextInput from './TextInput.js'; -import { type TreeNode, TreeSelect } from './ui/TreeSelect.js'; -type AgenticSearchState = { - status: 'idle'; -} | { - status: 'searching'; -} | { - status: 'results'; - results: LogOption[]; - query: string; -} | { - status: 'error'; - message: string; -}; +import chalk from 'chalk' +import figures from 'figures' +import Fuse from 'fuse.js' +import React from 'react' +import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useSearchInput } from '../hooks/useSearchInput.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { applyColor } from '../ink/colorize.js' +import type { Color } from '../ink/styles.js' +import { Box, Text, useInput, useTerminalFocus, useTheme } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { logEvent } from '../services/analytics/index.js' +import type { LogOption, SerializedMessage } from '../types/logs.js' +import { formatLogMetadata, truncateToWidth } from '../utils/format.js' +import { getWorktreePaths } from '../utils/getWorktreePaths.js' +import { getBranch } from '../utils/git.js' +import { getLogDisplayTitle } from '../utils/log.js' +import { + getFirstMeaningfulUserMessageTextContent, + getSessionIdFromLog, + isCustomTitleEnabled, + saveCustomTitle, +} from '../utils/sessionStorage.js' +import { getTheme } from '../utils/theme.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/select.js' +import { Byline } from './design-system/Byline.js' +import { Divider } from './design-system/Divider.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { SearchBox } from './SearchBox.js' +import { SessionPreview } from './SessionPreview.js' +import { Spinner } from './Spinner.js' +import { TagTabs } from './TagTabs.js' +import TextInput from './TextInput.js' +import { type TreeNode, TreeSelect } from './ui/TreeSelect.js' + +type AgenticSearchState = + | { status: 'idle' } + | { status: 'searching' } + | { status: 'results'; results: LogOption[]; query: string } + | { status: 'error'; message: string } + export type LogSelectorProps = { - logs: LogOption[]; - maxHeight?: number; - forceWidth?: number; - onCancel?: () => void; - onSelect: (log: LogOption) => void; - onLogsChanged?: () => void; - onLoadMore?: (count: number) => void; - initialSearchQuery?: string; - showAllProjects?: boolean; - onToggleAllProjects?: () => void; - onAgenticSearch?: (query: string, logs: LogOption[], signal?: AbortSignal) => Promise; -}; -type LogTreeNode = TreeNode<{ - log: LogOption; - indexInFiltered: number; -}>; + logs: LogOption[] + maxHeight?: number + forceWidth?: number + onCancel?: () => void + onSelect: (log: LogOption) => void + onLogsChanged?: () => void + onLoadMore?: (count: number) => void + initialSearchQuery?: string + showAllProjects?: boolean + onToggleAllProjects?: () => void + onAgenticSearch?: ( + query: string, + logs: LogOption[], + signal?: AbortSignal, + ) => Promise +} + +type LogTreeNode = TreeNode<{ log: LogOption; indexInFiltered: number }> + function normalizeAndTruncateToWidth(text: string, maxWidth: number): string { - const normalized = text.replace(/\s+/g, ' ').trim(); - return truncateToWidth(normalized, maxWidth); + const normalized = text.replace(/\s+/g, ' ').trim() + return truncateToWidth(normalized, maxWidth) } // Width of prefixes that TreeSelect will add -const PARENT_PREFIX_WIDTH = 2; // '▼ ' or '▶ ' -const CHILD_PREFIX_WIDTH = 4; // ' ▸ ' +const PARENT_PREFIX_WIDTH = 2 // '▼ ' or '▶ ' +const CHILD_PREFIX_WIDTH = 4 // ' ▸ ' // Deep search constants -const DEEP_SEARCH_MAX_MESSAGES = 2000; -const DEEP_SEARCH_CROP_SIZE = 1000; -const DEEP_SEARCH_MAX_TEXT_LENGTH = 50000; // Cap searchable text per session -const FUSE_THRESHOLD = 0.3; -const DATE_TIE_THRESHOLD_MS = 60 * 1000; // 1 minute - use relevance as tie-breaker within this window -const SNIPPET_CONTEXT_CHARS = 50; // Characters to show before/after match - -type Snippet = { - before: string; - match: string; - after: string; -}; -function formatSnippet({ - before, - match, - after -}: Snippet, highlightColor: (text: string) => string): string { - return chalk.dim(before) + highlightColor(match) + chalk.dim(after); +const DEEP_SEARCH_MAX_MESSAGES = 2000 +const DEEP_SEARCH_CROP_SIZE = 1000 +const DEEP_SEARCH_MAX_TEXT_LENGTH = 50000 // Cap searchable text per session +const FUSE_THRESHOLD = 0.3 +const DATE_TIE_THRESHOLD_MS = 60 * 1000 // 1 minute - use relevance as tie-breaker within this window +const SNIPPET_CONTEXT_CHARS = 50 // Characters to show before/after match + +type Snippet = { before: string; match: string; after: string } + +function formatSnippet( + { before, match, after }: Snippet, + highlightColor: (text: string) => string, +): string { + return chalk.dim(before) + highlightColor(match) + chalk.dim(after) } -function extractSnippet(text: string, query: string, contextChars: number): Snippet | null { + +function extractSnippet( + text: string, + query: string, + contextChars: number, +): Snippet | null { // Find exact query occurrence (case-insensitive). // Note: Fuse does fuzzy matching, so this may miss some fuzzy matches. // This is acceptable for now - in the future we could use Fuse's includeMatches // option and work with the match indices directly. - const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()); - if (matchIndex === -1) return null; - const matchEnd = matchIndex + query.length; - const snippetStart = Math.max(0, matchIndex - contextChars); - const snippetEnd = Math.min(text.length, matchEnd + contextChars); - const beforeRaw = text.slice(snippetStart, matchIndex); - const matchText = text.slice(matchIndex, matchEnd); - const afterRaw = text.slice(matchEnd, snippetEnd); + const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()) + if (matchIndex === -1) return null + + const matchEnd = matchIndex + query.length + const snippetStart = Math.max(0, matchIndex - contextChars) + const snippetEnd = Math.min(text.length, matchEnd + contextChars) + + const beforeRaw = text.slice(snippetStart, matchIndex) + const matchText = text.slice(matchIndex, matchEnd) + const afterRaw = text.slice(matchEnd, snippetEnd) + return { - before: (snippetStart > 0 ? '…' : '') + beforeRaw.replace(/\s+/g, ' ').trimStart(), + before: + (snippetStart > 0 ? '…' : '') + + beforeRaw.replace(/\s+/g, ' ').trimStart(), match: matchText.trim(), - after: afterRaw.replace(/\s+/g, ' ').trimEnd() + (snippetEnd < text.length ? '…' : '') - }; + after: + afterRaw.replace(/\s+/g, ' ').trimEnd() + + (snippetEnd < text.length ? '…' : ''), + } } -function buildLogLabel(log: LogOption, maxLabelWidth: number, options?: { - isGroupHeader?: boolean; - isChild?: boolean; - forkCount?: number; -}): string { + +function buildLogLabel( + log: LogOption, + maxLabelWidth: number, + options?: { + isGroupHeader?: boolean + isChild?: boolean + forkCount?: number + }, +): string { const { isGroupHeader = false, isChild = false, - forkCount = 0 - } = options || {}; + forkCount = 0, + } = options || {} // TreeSelect will add the prefix, so we just need to account for its width - const prefixWidth = isGroupHeader && forkCount > 0 ? PARENT_PREFIX_WIDTH : isChild ? CHILD_PREFIX_WIDTH : 0; - const sessionCountSuffix = isGroupHeader && forkCount > 0 ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` : ''; - const sidechainSuffix = log.isSidechain ? ' (sidechain)' : ''; - const maxSummaryWidth = maxLabelWidth - prefixWidth - sidechainSuffix.length - sessionCountSuffix.length; - const truncatedSummary = normalizeAndTruncateToWidth(getLogDisplayTitle(log), maxSummaryWidth); - return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}`; + const prefixWidth = + isGroupHeader && forkCount > 0 + ? PARENT_PREFIX_WIDTH + : isChild + ? CHILD_PREFIX_WIDTH + : 0 + + const sessionCountSuffix = + isGroupHeader && forkCount > 0 + ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` + : '' + + const sidechainSuffix = log.isSidechain ? ' (sidechain)' : '' + + const maxSummaryWidth = + maxLabelWidth - + prefixWidth - + sidechainSuffix.length - + sessionCountSuffix.length + const truncatedSummary = normalizeAndTruncateToWidth( + getLogDisplayTitle(log), + maxSummaryWidth, + ) + return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}` } -function buildLogMetadata(log: LogOption, options?: { - isChild?: boolean; - showProjectPath?: boolean; -}): string { - const { - isChild = false, - showProjectPath = false - } = options || {}; + +function buildLogMetadata( + log: LogOption, + options?: { isChild?: boolean; showProjectPath?: boolean }, +): string { + const { isChild = false, showProjectPath = false } = options || {} // Match the child prefix width for proper alignment - const childPadding = isChild ? ' ' : ''; // 4 spaces to match ' ▸ ' - const baseMetadata = formatLogMetadata(log); - const projectSuffix = showProjectPath && log.projectPath ? ` · ${log.projectPath}` : ''; - return childPadding + baseMetadata + projectSuffix; + const childPadding = isChild ? ' ' : '' // 4 spaces to match ' ▸ ' + const baseMetadata = formatLogMetadata(log) + const projectSuffix = + showProjectPath && log.projectPath ? ` · ${log.projectPath}` : '' + return childPadding + baseMetadata + projectSuffix } -export function LogSelector(t0) { - const $ = _c(247); - const { - logs, - maxHeight: t1, - forceWidth, - onCancel, - onSelect, - onLogsChanged, - onLoadMore, - initialSearchQuery, - showAllProjects: t2, - onToggleAllProjects, - onAgenticSearch - } = t0; - const maxHeight = t1 === undefined ? Infinity : t1; - const showAllProjects = t2 === undefined ? false : t2; - const terminalSize = useTerminalSize(); - const columns = forceWidth === undefined ? terminalSize.columns : forceWidth; - const exitState = useExitOnCtrlCDWithKeybindings(onCancel); - const isTerminalFocused = useTerminalFocus(); - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = isCustomTitleEnabled(); - $[0] = t3; - } else { - t3 = $[0]; - } - const isResumeWithRenameEnabled = t3; - const isDeepSearchEnabled = false; - const [themeName] = useTheme(); - let t4; - if ($[1] !== themeName) { - t4 = getTheme(themeName); - $[1] = themeName; - $[2] = t4; - } else { - t4 = $[2]; - } - const theme = t4; - let t5; - if ($[3] !== theme.warning) { - t5 = text => applyColor(text, theme.warning as Color); - $[3] = theme.warning; - $[4] = t5; - } else { - t5 = $[4]; - } - const highlightColor = t5; - const isAgenticSearchEnabled = false; - const [currentBranch, setCurrentBranch] = React.useState(null); - const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false); - const [showAllWorktrees, setShowAllWorktrees] = React.useState(false); - const [hasMultipleWorktrees, setHasMultipleWorktrees] = React.useState(false); - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = getOriginalCwd(); - $[5] = t6; - } else { - t6 = $[5]; - } - const currentCwd = t6; - const [renameValue, setRenameValue] = React.useState(""); - const [renameCursorOffset, setRenameCursorOffset] = React.useState(0); - let t7; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t7 = new Set(); - $[6] = t7; - } else { - t7 = $[6]; - } - const [expandedGroupSessionIds, setExpandedGroupSessionIds] = React.useState(t7); - const [focusedNode, setFocusedNode] = React.useState(null); - const [focusedIndex, setFocusedIndex] = React.useState(1); - const [viewMode, setViewMode] = React.useState("list"); - const [previewLog, setPreviewLog] = React.useState(null); - const prevFocusedIdRef = React.useRef(null); - const [selectedTagIndex, setSelectedTagIndex] = React.useState(0); - let t8; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - status: "idle" - }; - $[7] = t8; - } else { - t8 = $[7]; - } - const [agenticSearchState, setAgenticSearchState] = React.useState(t8); - const [isAgenticSearchOptionFocused, setIsAgenticSearchOptionFocused] = React.useState(false); - const agenticSearchAbortRef = React.useRef(null); - const t9 = viewMode === "search" && agenticSearchState.status !== "searching"; - let t10; - let t11; - let t12; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t10 = () => { - setViewMode("list"); - logEvent("tengu_session_search_toggled", { - enabled: false - }); - }; - t11 = () => { - setViewMode("list"); - logEvent("tengu_session_search_toggled", { - enabled: false - }); - }; - t12 = ["n"]; - $[8] = t10; - $[9] = t11; - $[10] = t12; - } else { - t10 = $[8]; - t11 = $[9]; - t12 = $[10]; - } - const t13 = initialSearchQuery || ""; - let t14; - if ($[11] !== t13 || $[12] !== t9) { - t14 = { - isActive: t9, - onExit: t10, - onExitUp: t11, - passthroughCtrlKeys: t12, - initialQuery: t13 - }; - $[11] = t13; - $[12] = t9; - $[13] = t14; - } else { - t14 = $[13]; - } + +export function LogSelector({ + logs, + maxHeight = Infinity, + forceWidth, + onCancel, + onSelect, + onLogsChanged, + onLoadMore, + initialSearchQuery, + showAllProjects = false, + onToggleAllProjects, + onAgenticSearch, +}: LogSelectorProps): React.ReactNode { + const terminalSize = useTerminalSize() + const columns = forceWidth === undefined ? terminalSize.columns : forceWidth + const exitState = useExitOnCtrlCDWithKeybindings(onCancel) + const isTerminalFocused = useTerminalFocus() + const isResumeWithRenameEnabled = isCustomTitleEnabled() + const isDeepSearchEnabled = process.env.USER_TYPE === 'ant' + const [themeName] = useTheme() + const theme = getTheme(themeName) + const highlightColor = React.useMemo( + () => (text: string) => applyColor(text, theme.warning as Color), + [theme.warning], + ) + const isAgenticSearchEnabled = process.env.USER_TYPE === 'ant' + + const [currentBranch, setCurrentBranch] = React.useState(null) + const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false) + const [showAllWorktrees, setShowAllWorktrees] = React.useState(false) + const [hasMultipleWorktrees, setHasMultipleWorktrees] = React.useState(false) + const currentCwd = React.useMemo(() => getOriginalCwd(), []) + const [renameValue, setRenameValue] = React.useState('') + const [renameCursorOffset, setRenameCursorOffset] = React.useState(0) + const [expandedGroupSessionIds, setExpandedGroupSessionIds] = React.useState< + Set + >(new Set()) + const [focusedNode, setFocusedNode] = React.useState(null) + // Track focused index for scroll position display in title + const [focusedIndex, setFocusedIndex] = React.useState(1) + const [viewMode, setViewMode] = React.useState< + 'list' | 'preview' | 'rename' | 'search' + >('list') + const [previewLog, setPreviewLog] = React.useState(null) + const prevFocusedIdRef = React.useRef(null) + const [selectedTagIndex, setSelectedTagIndex] = React.useState(0) + + // Agentic search state + const [agenticSearchState, setAgenticSearchState] = + React.useState({ status: 'idle' }) + // Track if the "Search deeply using Claude" option is focused + const [isAgenticSearchOptionFocused, setIsAgenticSearchOptionFocused] = + React.useState(false) + // AbortController for cancelling agentic search + const agenticSearchAbortRef = React.useRef(null) + const { query: searchQuery, setQuery: setSearchQuery, - cursorOffset: searchCursorOffset - } = useSearchInput(t14); - const deferredSearchQuery = React.useDeferredValue(searchQuery); - const [debouncedDeepSearchQuery, setDebouncedDeepSearchQuery] = React.useState(""); - let t15; - let t16; - if ($[14] !== deferredSearchQuery) { - t15 = () => { - if (!deferredSearchQuery) { - setDebouncedDeepSearchQuery(""); - return; - } - const timeoutId = setTimeout(setDebouncedDeepSearchQuery, 300, deferredSearchQuery); - return () => clearTimeout(timeoutId); - }; - t16 = [deferredSearchQuery]; - $[14] = deferredSearchQuery; - $[15] = t15; - $[16] = t16; - } else { - t15 = $[15]; - t16 = $[16]; - } - React.useEffect(t15, t16); - const [deepSearchResults, setDeepSearchResults] = React.useState(null); - const [isSearching, setIsSearching] = React.useState(false); - let t17; - let t18; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t17 = () => { - getBranch().then(branch => setCurrentBranch(branch)); - getWorktreePaths(currentCwd).then(paths => { - setHasMultipleWorktrees(paths.length > 1); - }); - }; - t18 = [currentCwd]; - $[17] = t17; - $[18] = t18; - } else { - t17 = $[17]; - t18 = $[18]; - } - React.useEffect(t17, t18); - const searchableTextByLog = new Map(logs.map(_temp)); - let t19; - t19 = null; - let t20; - if ($[19] !== logs) { - t20 = getUniqueTags(logs); - $[19] = logs; - $[20] = t20; - } else { - t20 = $[20]; - } - const uniqueTags = t20; - const hasTags = uniqueTags.length > 0; - let t21; - if ($[21] !== hasTags || $[22] !== uniqueTags) { - t21 = hasTags ? ["All", ...uniqueTags] : []; - $[21] = hasTags; - $[22] = uniqueTags; - $[23] = t21; - } else { - t21 = $[23]; - } - const tagTabs = t21; - const effectiveTagIndex = tagTabs.length > 0 && selectedTagIndex < tagTabs.length ? selectedTagIndex : 0; - const selectedTab = tagTabs[effectiveTagIndex]; - const tagFilter = selectedTab === "All" ? undefined : selectedTab; - const tagTabsLines = hasTags ? 1 : 0; - let filtered = logs; - if (isResumeWithRenameEnabled) { - let t22; - if ($[24] !== logs) { - t22 = logs.filter(_temp2); - $[24] = logs; - $[25] = t22; - } else { - t22 = $[25]; + cursorOffset: searchCursorOffset, + } = useSearchInput({ + isActive: + viewMode === 'search' && agenticSearchState.status !== 'searching', + onExit: () => { + setViewMode('list') + logEvent('tengu_session_search_toggled', { enabled: false }) + }, + onExitUp: () => { + setViewMode('list') + logEvent('tengu_session_search_toggled', { enabled: false }) + }, + passthroughCtrlKeys: ['n'], + initialQuery: initialSearchQuery || '', + }) + + // Debounce transcript search for performance (title search is instant) + const deferredSearchQuery = React.useDeferredValue(searchQuery) + + // Additional debounce for deep search - wait 300ms after typing stops + const [debouncedDeepSearchQuery, setDebouncedDeepSearchQuery] = + React.useState('') + React.useEffect(() => { + if (!deferredSearchQuery) { + setDebouncedDeepSearchQuery('') + return } - filtered = t22; - } - if (tagFilter !== undefined) { - let t22; - if ($[26] !== filtered || $[27] !== tagFilter) { - let t23; - if ($[29] !== tagFilter) { - t23 = log_2 => log_2.tag === tagFilter; - $[29] = tagFilter; - $[30] = t23; - } else { - t23 = $[30]; - } - t22 = filtered.filter(t23); - $[26] = filtered; - $[27] = tagFilter; - $[28] = t22; - } else { - t22 = $[28]; + const timeoutId = setTimeout( + setDebouncedDeepSearchQuery, + 300, + deferredSearchQuery, + ) + return () => clearTimeout(timeoutId) + }, [deferredSearchQuery]) + + // State for async deep search results + const [deepSearchResults, setDeepSearchResults] = React.useState<{ + results: Array<{ log: LogOption; score?: number; searchableText: string }> + query: string + } | null>(null) + const [isSearching, setIsSearching] = React.useState(false) + + React.useEffect(() => { + void getBranch().then(branch => setCurrentBranch(branch)) + void getWorktreePaths(currentCwd).then(paths => { + setHasMultipleWorktrees(paths.length > 1) + }) + }, [currentCwd]) + + // Memoize searchable text extraction - only recompute when logs change + const searchableTextByLog = React.useMemo( + () => new Map(logs.map(log => [log, buildSearchableText(log)])), + [logs], + ) + + // Pre-build Fuse index once when logs change (not on every search query) + const fuseIndex = React.useMemo(() => { + if (!isDeepSearchEnabled) return null + + const logsWithText = logs + .map(log => ({ + log, + searchableText: searchableTextByLog.get(log) ?? '', + })) + .filter(item => item.searchableText) + + return new Fuse(logsWithText, { + keys: ['searchableText'], + threshold: FUSE_THRESHOLD, + ignoreLocation: true, + includeScore: true, + }) + }, [logs, searchableTextByLog, isDeepSearchEnabled]) + + // Compute unique tags from logs (before any filtering) + const uniqueTags = React.useMemo(() => getUniqueTags(logs), [logs]) + const hasTags = uniqueTags.length > 0 + const tagTabs = React.useMemo( + () => (hasTags ? ['All', ...uniqueTags] : []), + [hasTags, uniqueTags], + ) + + // Clamp out-of-bounds index (e.g., after logs change) without an extra render + const effectiveTagIndex = + tagTabs.length > 0 && selectedTagIndex < tagTabs.length + ? selectedTagIndex + : 0 + const selectedTab = tagTabs[effectiveTagIndex] + const tagFilter = selectedTab === 'All' ? undefined : selectedTab + + // Tag tabs are now a single line with horizontal scrolling + const tagTabsLines = hasTags ? 1 : 0 + + // Base filtering (instant) - applies tag, branch, and resume filters + const baseFilteredLogs = React.useMemo(() => { + let filtered = logs + if (isResumeWithRenameEnabled) { + filtered = logs.filter(log => { + const currentSessionId = getSessionId() + const logSessionId = getSessionIdFromLog(log) + const isCurrentSession = + currentSessionId && logSessionId === currentSessionId + // Always show current session + if (isCurrentSession) { + return true + } + // Always show sessions with custom titles (e.g., loop mode sessions) + if (log.customTitle) { + return true + } + // For full logs, check messages array + const fromMessages = getFirstMeaningfulUserMessageTextContent( + log.messages, + ) + if (fromMessages) { + return true + } + // All logs reaching this component are enriched — include if + // they have a prompt or custom title + if (log.firstPrompt || log.customTitle) { + return true + } + return false + }) } - filtered = t22; - } - if (branchFilterEnabled && currentBranch) { - let t22; - if ($[31] !== currentBranch || $[32] !== filtered) { - let t23; - if ($[34] !== currentBranch) { - t23 = log_3 => log_3.gitBranch === currentBranch; - $[34] = currentBranch; - $[35] = t23; - } else { - t23 = $[35]; - } - t22 = filtered.filter(t23); - $[31] = currentBranch; - $[32] = filtered; - $[33] = t22; - } else { - t22 = $[33]; + + // Apply tag filter if specified + if (tagFilter !== undefined) { + filtered = filtered.filter(log => log.tag === tagFilter) } - filtered = t22; - } - if (hasMultipleWorktrees && !showAllWorktrees) { - let t22; - if ($[36] !== filtered) { - let t23; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t23 = log_4 => log_4.projectPath === currentCwd; - $[38] = t23; - } else { - t23 = $[38]; - } - t22 = filtered.filter(t23); - $[36] = filtered; - $[37] = t22; - } else { - t22 = $[37]; + + if (branchFilterEnabled && currentBranch) { + filtered = filtered.filter(log => log.gitBranch === currentBranch) } - filtered = t22; - } - const baseFilteredLogs = filtered; - let t22; - bb0: { + + if (hasMultipleWorktrees && !showAllWorktrees) { + filtered = filtered.filter(log => log.projectPath === currentCwd) + } + + return filtered + }, [ + logs, + isResumeWithRenameEnabled, + tagFilter, + branchFilterEnabled, + currentBranch, + hasMultipleWorktrees, + showAllWorktrees, + currentCwd, + ]) + + // Instant title/branch/tag/PR filtering (runs on every keystroke, but is fast) + const titleFilteredLogs = React.useMemo(() => { if (!searchQuery) { - t22 = baseFilteredLogs; - break bb0; + return baseFilteredLogs } - let t23; - if ($[39] !== baseFilteredLogs || $[40] !== searchQuery) { - const query = searchQuery.toLowerCase(); - t23 = baseFilteredLogs.filter(log_5 => { - const displayedTitle = getLogDisplayTitle(log_5).toLowerCase(); - const branch_0 = (log_5.gitBranch || "").toLowerCase(); - const tag = (log_5.tag || "").toLowerCase(); - const prInfo = log_5.prNumber ? `pr #${log_5.prNumber} ${log_5.prRepository || ""}`.toLowerCase() : ""; - return displayedTitle.includes(query) || branch_0.includes(query) || tag.includes(query) || prInfo.includes(query); - }); - $[39] = baseFilteredLogs; - $[40] = searchQuery; - $[41] = t23; - } else { - t23 = $[41]; + const query = searchQuery.toLowerCase() + return baseFilteredLogs.filter(log => { + const displayedTitle = getLogDisplayTitle(log).toLowerCase() + const branch = (log.gitBranch || '').toLowerCase() + const tag = (log.tag || '').toLowerCase() + const prInfo = log.prNumber + ? `pr #${log.prNumber} ${log.prRepository || ''}`.toLowerCase() + : '' + return ( + displayedTitle.includes(query) || + branch.includes(query) || + tag.includes(query) || + prInfo.includes(query) + ) + }) + }, [baseFilteredLogs, searchQuery]) + + // Show searching indicator when query is pending debounce + React.useEffect(() => { + if ( + isDeepSearchEnabled && + deferredSearchQuery && + deferredSearchQuery !== debouncedDeepSearchQuery + ) { + setIsSearching(true) } - t22 = t23; - } - const titleFilteredLogs = t22; - let t23; - let t24; - if ($[42] !== debouncedDeepSearchQuery || $[43] !== deferredSearchQuery) { - t23 = () => { - if (false && deferredSearchQuery && deferredSearchQuery !== debouncedDeepSearchQuery) { - setIsSearching(true); - } - }; - t24 = [deferredSearchQuery, debouncedDeepSearchQuery, false]; - $[42] = debouncedDeepSearchQuery; - $[43] = deferredSearchQuery; - $[44] = t23; - $[45] = t24; - } else { - t23 = $[44]; - t24 = $[45]; - } - React.useEffect(t23, t24); - let t25; - let t26; - if ($[46] !== debouncedDeepSearchQuery) { - t25 = () => { - if (true || !debouncedDeepSearchQuery || true) { - setDeepSearchResults(null); - setIsSearching(false); - return; - } - const timeoutId_0 = setTimeout(_temp5, 0, null, debouncedDeepSearchQuery, setDeepSearchResults, setIsSearching); - return () => { - clearTimeout(timeoutId_0); - }; - }; - t26 = [debouncedDeepSearchQuery, null, false]; - $[46] = debouncedDeepSearchQuery; - $[47] = t25; - $[48] = t26; - } else { - t25 = $[47]; - t26 = $[48]; - } - React.useEffect(t25, t26); - let filtered_0; - let snippetMap; - if ($[49] !== debouncedDeepSearchQuery || $[50] !== deepSearchResults || $[51] !== titleFilteredLogs) { - snippetMap = new Map(); - filtered_0 = titleFilteredLogs; - if (deepSearchResults && debouncedDeepSearchQuery && deepSearchResults.query === debouncedDeepSearchQuery) { + }, [deferredSearchQuery, debouncedDeepSearchQuery, isDeepSearchEnabled]) + + // Async deep search effect - runs after 300ms debounce + React.useEffect(() => { + if (!isDeepSearchEnabled || !debouncedDeepSearchQuery || !fuseIndex) { + setDeepSearchResults(null) + setIsSearching(false) + return + } + + // Use setTimeout(0) to yield to the event loop - prevents UI freeze + const timeoutId = setTimeout( + ( + fuseIndex, + debouncedDeepSearchQuery, + setDeepSearchResults, + setIsSearching, + ) => { + const results = fuseIndex.search(debouncedDeepSearchQuery) + + // Sort by date (newest first), with relevance as tie-breaker within same minute + results.sort((a, b) => { + const aTime = new Date(a.item.log.modified).getTime() + const bTime = new Date(b.item.log.modified).getTime() + const timeDiff = bTime - aTime + if (Math.abs(timeDiff) > DATE_TIE_THRESHOLD_MS) { + return timeDiff + } + // Within same minute window, use relevance score (lower is better) + return (a.score ?? 1) - (b.score ?? 1) + }) + + setDeepSearchResults({ + results: results.map(r => ({ + log: r.item.log, + score: r.score, + searchableText: r.item.searchableText, + })), + query: debouncedDeepSearchQuery, + }) + setIsSearching(false) + }, + 0, + fuseIndex, + debouncedDeepSearchQuery, + setDeepSearchResults, + setIsSearching, + ) + + return () => { + clearTimeout(timeoutId) + } + }, [debouncedDeepSearchQuery, fuseIndex, isDeepSearchEnabled]) + + // Merge title matches with async deep search results + const { filteredLogs, snippets } = React.useMemo(() => { + const snippetMap = new Map() + + // Start with instant title matches + let filtered = titleFilteredLogs + + // Merge in deep search results if available and query matches + if ( + deepSearchResults && + debouncedDeepSearchQuery && + deepSearchResults.query === debouncedDeepSearchQuery + ) { + // Extract snippets from deep search results for (const result of deepSearchResults.results) { if (result.searchableText) { - const snippet = extractSnippet(result.searchableText, debouncedDeepSearchQuery, SNIPPET_CONTEXT_CHARS); + const snippet = extractSnippet( + result.searchableText, + debouncedDeepSearchQuery, + SNIPPET_CONTEXT_CHARS, + ) if (snippet) { - snippetMap.set(result.log, snippet); + snippetMap.set(result.log, snippet) } } } - let t27; - if ($[54] !== filtered_0) { - t27 = new Set(filtered_0.map(_temp6)); - $[54] = filtered_0; - $[55] = t27; - } else { - t27 = $[55]; - } - const titleMatchIds = t27; - let t28; - if ($[56] !== deepSearchResults.results || $[57] !== filtered_0 || $[58] !== titleMatchIds) { - let t29; - if ($[60] !== titleMatchIds) { - t29 = log_7 => !titleMatchIds.has(log_7.messages[0]?.uuid); - $[60] = titleMatchIds; - $[61] = t29; - } else { - t29 = $[61]; - } - const transcriptOnlyMatches = deepSearchResults.results.map(_temp7).filter(t29); - t28 = [...filtered_0, ...transcriptOnlyMatches]; - $[56] = deepSearchResults.results; - $[57] = filtered_0; - $[58] = titleMatchIds; - $[59] = t28; - } else { - t28 = $[59]; - } - filtered_0 = t28; + + // Add transcript-only matches (not already in title matches) + const titleMatchIds = new Set(filtered.map(log => log.messages[0]?.uuid)) + const transcriptOnlyMatches = deepSearchResults.results + .map(r => r.log) + .filter(log => !titleMatchIds.has(log.messages[0]?.uuid)) + filtered = [...filtered, ...transcriptOnlyMatches] } - $[49] = debouncedDeepSearchQuery; - $[50] = deepSearchResults; - $[51] = titleFilteredLogs; - $[52] = filtered_0; - $[53] = snippetMap; - } else { - filtered_0 = $[52]; - snippetMap = $[53]; - } - let t27; - if ($[62] !== filtered_0 || $[63] !== snippetMap) { - t27 = { - filteredLogs: filtered_0, - snippets: snippetMap - }; - $[62] = filtered_0; - $[63] = snippetMap; - $[64] = t27; - } else { - t27 = $[64]; - } - const { - filteredLogs, - snippets - } = t27; - let t28; - bb1: { - if (agenticSearchState.status === "results" && agenticSearchState.results.length > 0) { - t28 = agenticSearchState.results; - break bb1; + + return { filteredLogs: filtered, snippets: snippetMap } + }, [titleFilteredLogs, deepSearchResults, debouncedDeepSearchQuery]) + + // Use agentic search results when available and non-empty, otherwise use regular filtered logs + const displayedLogs = React.useMemo(() => { + if ( + agenticSearchState.status === 'results' && + agenticSearchState.results.length > 0 + ) { + return agenticSearchState.results } - t28 = filteredLogs; - } - const displayedLogs = t28; - const maxLabelWidth = Math.max(30, columns - 4); - let t29; - bb2: { + return filteredLogs + }, [agenticSearchState, filteredLogs]) + + // Calculate available width for the summary text + const maxLabelWidth = Math.max(30, columns - 4) + + // Build tree nodes for grouped view + const treeNodes = React.useMemo(() => { if (!isResumeWithRenameEnabled) { - let t30; - if ($[65] === Symbol.for("react.memo_cache_sentinel")) { - t30 = []; - $[65] = t30; - } else { - t30 = $[65]; - } - t29 = t30; - break bb2; + return [] } - let t30; - if ($[66] !== displayedLogs || $[67] !== highlightColor || $[68] !== maxLabelWidth || $[69] !== showAllProjects || $[70] !== snippets) { - const sessionGroups = groupLogsBySessionId(displayedLogs); - t30 = Array.from(sessionGroups.entries()).map(t31 => { - const [sessionId, groupLogs] = t31; - const latestLog = groupLogs[0]; - const indexInFiltered = displayedLogs.indexOf(latestLog); - const snippet_0 = snippets.get(latestLog); - const snippetStr = snippet_0 ? formatSnippet(snippet_0, highlightColor) : null; + + const sessionGroups = groupLogsBySessionId(displayedLogs) + + return Array.from(sessionGroups.entries()).map( + ([sessionId, groupLogs]): LogTreeNode => { + const latestLog = groupLogs[0]! + const indexInFiltered = displayedLogs.indexOf(latestLog) + const snippet = snippets.get(latestLog) + const snippetStr = snippet + ? formatSnippet(snippet, highlightColor) + : null + if (groupLogs.length === 1) { + // Single log - no children const metadata = buildLogMetadata(latestLog, { - showProjectPath: showAllProjects - }); + showProjectPath: showAllProjects, + }) return { id: `log:${sessionId}:0`, - value: { - log: latestLog, - indexInFiltered - }, + value: { log: latestLog, indexInFiltered }, label: buildLogLabel(latestLog, maxLabelWidth), description: snippetStr ? `${metadata}\n ${snippetStr}` : metadata, - dimDescription: true - }; + dimDescription: true, + } } - const forkCount = groupLogs.length - 1; - const children = groupLogs.slice(1).map((log_8, index) => { - const childIndexInFiltered = displayedLogs.indexOf(log_8); - const childSnippet = snippets.get(log_8); - const childSnippetStr = childSnippet ? formatSnippet(childSnippet, highlightColor) : null; - const childMetadata = buildLogMetadata(log_8, { + + // Multiple logs - parent with children + const forkCount = groupLogs.length - 1 + const children: LogTreeNode[] = groupLogs.slice(1).map((log, index) => { + const childIndexInFiltered = displayedLogs.indexOf(log) + const childSnippet = snippets.get(log) + const childSnippetStr = childSnippet + ? formatSnippet(childSnippet, highlightColor) + : null + const childMetadata = buildLogMetadata(log, { isChild: true, - showProjectPath: showAllProjects - }); + showProjectPath: showAllProjects, + }) return { id: `log:${sessionId}:${index + 1}`, - value: { - log: log_8, - indexInFiltered: childIndexInFiltered - }, - label: buildLogLabel(log_8, maxLabelWidth, { - isChild: true - }), - description: childSnippetStr ? `${childMetadata}\n ${childSnippetStr}` : childMetadata, - dimDescription: true - }; - }); + value: { log, indexInFiltered: childIndexInFiltered }, + label: buildLogLabel(log, maxLabelWidth, { isChild: true }), + description: childSnippetStr + ? `${childMetadata}\n ${childSnippetStr}` + : childMetadata, + dimDescription: true, + } + }) + const parentMetadata = buildLogMetadata(latestLog, { - showProjectPath: showAllProjects - }); + showProjectPath: showAllProjects, + }) return { id: `group:${sessionId}`, - value: { - log: latestLog, - indexInFiltered - }, + value: { log: latestLog, indexInFiltered }, label: buildLogLabel(latestLog, maxLabelWidth, { isGroupHeader: true, - forkCount + forkCount, }), - description: snippetStr ? `${parentMetadata}\n ${snippetStr}` : parentMetadata, + description: snippetStr + ? `${parentMetadata}\n ${snippetStr}` + : parentMetadata, dimDescription: true, - children - }; - }); - $[66] = displayedLogs; - $[67] = highlightColor; - $[68] = maxLabelWidth; - $[69] = showAllProjects; - $[70] = snippets; - $[71] = t30; - } else { - t30 = $[71]; - } - t29 = t30; - } - const treeNodes = t29; - let t30; - bb3: { + children, + } + }, + ) + }, [ + isResumeWithRenameEnabled, + displayedLogs, + maxLabelWidth, + showAllProjects, + snippets, + highlightColor, + ]) + + // Build options for old flat list view + const flatOptions = React.useMemo(() => { if (isResumeWithRenameEnabled) { - let t31; - if ($[72] === Symbol.for("react.memo_cache_sentinel")) { - t31 = []; - $[72] = t31; - } else { - t31 = $[72]; - } - t30 = t31; - break bb3; + return [] } - let t31; - if ($[73] !== displayedLogs || $[74] !== highlightColor || $[75] !== maxLabelWidth || $[76] !== showAllProjects || $[77] !== snippets) { - let t32; - if ($[79] !== highlightColor || $[80] !== maxLabelWidth || $[81] !== showAllProjects || $[82] !== snippets) { - t32 = (log_9, index_0) => { - const rawSummary = getLogDisplayTitle(log_9); - const summaryWithSidechain = rawSummary + (log_9.isSidechain ? " (sidechain)" : ""); - const summary = normalizeAndTruncateToWidth(summaryWithSidechain, maxLabelWidth); - const baseDescription = formatLogMetadata(log_9); - const projectSuffix = showAllProjects && log_9.projectPath ? ` · ${log_9.projectPath}` : ""; - const snippet_1 = snippets.get(log_9); - const snippetStr_0 = snippet_1 ? formatSnippet(snippet_1, highlightColor) : null; - return { - label: summary, - description: snippetStr_0 ? `${baseDescription}${projectSuffix}\n ${snippetStr_0}` : baseDescription + projectSuffix, - dimDescription: true, - value: index_0.toString() - }; - }; - $[79] = highlightColor; - $[80] = maxLabelWidth; - $[81] = showAllProjects; - $[82] = snippets; - $[83] = t32; - } else { - t32 = $[83]; + + return displayedLogs.map((log, index) => { + const rawSummary = getLogDisplayTitle(log) + const summaryWithSidechain = + rawSummary + (log.isSidechain ? ' (sidechain)' : '') + const summary = normalizeAndTruncateToWidth( + summaryWithSidechain, + maxLabelWidth, + ) + + const baseDescription = formatLogMetadata(log) + const projectSuffix = + showAllProjects && log.projectPath ? ` · ${log.projectPath}` : '' + const snippet = snippets.get(log) + const snippetStr = snippet ? formatSnippet(snippet, highlightColor) : null + + return { + label: summary, + description: snippetStr + ? `${baseDescription}${projectSuffix}\n ${snippetStr}` + : baseDescription + projectSuffix, + dimDescription: true, + value: index.toString(), } - t31 = displayedLogs.map(t32); - $[73] = displayedLogs; - $[74] = highlightColor; - $[75] = maxLabelWidth; - $[76] = showAllProjects; - $[77] = snippets; - $[78] = t31; - } else { - t31 = $[78]; + }) + }, [ + isResumeWithRenameEnabled, + displayedLogs, + highlightColor, + maxLabelWidth, + showAllProjects, + snippets, + ]) + + // Derive the focused log from focusedNode + const focusedLog = focusedNode?.value.log ?? null + + const getExpandCollapseHint = (): string => { + if (!isResumeWithRenameEnabled || !focusedLog) return '' + const sessionId = getSessionIdFromLog(focusedLog) + if (!sessionId) return '' + + const sessionLogs = displayedLogs.filter( + log => getSessionIdFromLog(log) === sessionId, + ) + const hasMultipleLogs = sessionLogs.length > 1 + + if (!hasMultipleLogs) return '' + + const isExpanded = expandedGroupSessionIds.has(sessionId) + const isChildNode = sessionLogs.indexOf(focusedLog) > 0 + + if (isChildNode) { + return '← to collapse' } - t30 = t31; - } - const flatOptions = t30; - const focusedLog = focusedNode?.value.log ?? null; - let t31; - if ($[84] !== displayedLogs || $[85] !== expandedGroupSessionIds || $[86] !== focusedLog) { - t31 = () => { - if (!isResumeWithRenameEnabled || !focusedLog) { - return ""; - } - const sessionId_0 = getSessionIdFromLog(focusedLog); - if (!sessionId_0) { - return ""; - } - const sessionLogs = displayedLogs.filter(log_10 => getSessionIdFromLog(log_10) === sessionId_0); - const hasMultipleLogs = sessionLogs.length > 1; - if (!hasMultipleLogs) { - return ""; - } - const isExpanded = expandedGroupSessionIds.has(sessionId_0); - const isChildNode = sessionLogs.indexOf(focusedLog) > 0; - if (isChildNode) { - return "\u2190 to collapse"; - } - return isExpanded ? "\u2190 to collapse" : "\u2192 to expand"; - }; - $[84] = displayedLogs; - $[85] = expandedGroupSessionIds; - $[86] = focusedLog; - $[87] = t31; - } else { - t31 = $[87]; + + return isExpanded ? '← to collapse' : '→ to expand' } - const getExpandCollapseHint = t31; - let t32; - if ($[88] !== focusedLog || $[89] !== onLogsChanged || $[90] !== renameValue) { - t32 = async () => { - const sessionId_1 = focusedLog ? getSessionIdFromLog(focusedLog) : undefined; - if (!focusedLog || !sessionId_1) { - setViewMode("list"); - setRenameValue(""); - return; + + const handleRenameSubmit = React.useCallback(async () => { + const sessionId = focusedLog ? getSessionIdFromLog(focusedLog) : undefined + if (!focusedLog || !sessionId) { + setViewMode('list') + setRenameValue('') + return + } + + if (renameValue.trim()) { + // Pass fullPath for cross-project sessions (different worktrees) + await saveCustomTitle(sessionId, renameValue.trim(), focusedLog.fullPath) + if (isResumeWithRenameEnabled && onLogsChanged) { + onLogsChanged() } - if (renameValue.trim()) { - await saveCustomTitle(sessionId_1, renameValue.trim(), focusedLog.fullPath); - if (isResumeWithRenameEnabled && onLogsChanged) { - onLogsChanged(); - } + } + setViewMode('list') + setRenameValue('') + }, [focusedLog, renameValue, onLogsChanged, isResumeWithRenameEnabled]) + + const exitSearchMode = React.useCallback(() => { + setViewMode('list') + logEvent('tengu_session_search_toggled', { enabled: false }) + }, []) + + const enterSearchMode = React.useCallback(() => { + setViewMode('search') + logEvent('tengu_session_search_toggled', { enabled: true }) + }, []) + + // Handler for triggering agentic search + const handleAgenticSearch = React.useCallback(async () => { + if (!searchQuery.trim() || !onAgenticSearch || !isAgenticSearchEnabled) { + return + } + + // Abort any previous search + agenticSearchAbortRef.current?.abort() + const abortController = new AbortController() + agenticSearchAbortRef.current = abortController + + setAgenticSearchState({ status: 'searching' }) + logEvent('tengu_agentic_search_started', { + query_length: searchQuery.length, + }) + + try { + const results = await onAgenticSearch( + searchQuery, + logs, + abortController.signal, + ) + // Check if aborted before updating state + if (abortController.signal.aborted) { + return } - setViewMode("list"); - setRenameValue(""); - }; - $[88] = focusedLog; - $[89] = onLogsChanged; - $[90] = renameValue; - $[91] = t32; - } else { - t32 = $[91]; - } - const handleRenameSubmit = t32; - let t33; - if ($[92] === Symbol.for("react.memo_cache_sentinel")) { - t33 = () => { - setViewMode("list"); - logEvent("tengu_session_search_toggled", { - enabled: false - }); - }; - $[92] = t33; - } else { - t33 = $[92]; - } - const exitSearchMode = t33; - let t34; - if ($[93] === Symbol.for("react.memo_cache_sentinel")) { - t34 = () => { - setViewMode("search"); - logEvent("tengu_session_search_toggled", { - enabled: true - }); - }; - $[93] = t34; - } else { - t34 = $[93]; - } - const enterSearchMode = t34; - let t35; - if ($[94] !== logs || $[95] !== onAgenticSearch || $[96] !== searchQuery) { - t35 = async () => { - if (!searchQuery.trim() || !onAgenticSearch || true) { - return; + setAgenticSearchState({ status: 'results', results, query: searchQuery }) + logEvent('tengu_agentic_search_completed', { + query_length: searchQuery.length, + results_count: results.length, + }) + } catch (error) { + // Don't show error for aborted requests + if (abortController.signal.aborted) { + return } - agenticSearchAbortRef.current?.abort(); - const abortController = new AbortController(); - agenticSearchAbortRef.current = abortController; setAgenticSearchState({ - status: "searching" - }); - logEvent("tengu_agentic_search_started", { - query_length: searchQuery.length - }); - ; - try { - const results_0 = await onAgenticSearch(searchQuery, logs, abortController.signal); - if (abortController.signal.aborted) { - return; - } - setAgenticSearchState({ - status: "results", - results: results_0, - query: searchQuery - }); - logEvent("tengu_agentic_search_completed", { - query_length: searchQuery.length, - results_count: results_0.length - }); - } catch (t36) { - const error = t36; - if (abortController.signal.aborted) { - return; - } - setAgenticSearchState({ - status: "error", - message: error instanceof Error ? error.message : "Search failed" - }); - logEvent("tengu_agentic_search_error", { - query_length: searchQuery.length - }); - } - }; - $[94] = logs; - $[95] = onAgenticSearch; - $[96] = searchQuery; - $[97] = t35; - } else { - t35 = $[97]; - } - const handleAgenticSearch = t35; - let t36; - if ($[98] !== agenticSearchState.query || $[99] !== agenticSearchState.status || $[100] !== searchQuery) { - t36 = () => { - if (agenticSearchState.status !== "idle" && agenticSearchState.status !== "searching") { - if (agenticSearchState.status === "results" && agenticSearchState.query !== searchQuery || agenticSearchState.status === "error") { - setAgenticSearchState({ - status: "idle" - }); - } + status: 'error', + message: error instanceof Error ? error.message : 'Search failed', + }) + logEvent('tengu_agentic_search_error', { + query_length: searchQuery.length, + }) + } + }, [searchQuery, onAgenticSearch, isAgenticSearchEnabled, logs]) + + // Clear agentic search results/error when query changes + React.useEffect(() => { + if ( + agenticSearchState.status !== 'idle' && + agenticSearchState.status !== 'searching' + ) { + // Clear if the query has changed from the one used for results/error + if ( + (agenticSearchState.status === 'results' && + agenticSearchState.query !== searchQuery) || + agenticSearchState.status === 'error' + ) { + setAgenticSearchState({ status: 'idle' }) } - }; - $[98] = agenticSearchState.query; - $[99] = agenticSearchState.status; - $[100] = searchQuery; - $[101] = t36; - } else { - t36 = $[101]; - } - let t37; - if ($[102] !== agenticSearchState || $[103] !== searchQuery) { - t37 = [searchQuery, agenticSearchState]; - $[102] = agenticSearchState; - $[103] = searchQuery; - $[104] = t37; - } else { - t37 = $[104]; - } - React.useEffect(t36, t37); - let t38; - let t39; - if ($[105] === Symbol.for("react.memo_cache_sentinel")) { - t38 = () => () => { - agenticSearchAbortRef.current?.abort(); - }; - t39 = []; - $[105] = t38; - $[106] = t39; - } else { - t38 = $[105]; - t39 = $[106]; - } - React.useEffect(t38, t39); - const prevAgenticStatusRef = React.useRef(agenticSearchState.status); - let t40; - if ($[107] !== agenticSearchState.status || $[108] !== displayedLogs[0] || $[109] !== displayedLogs.length || $[110] !== treeNodes) { - t40 = () => { - const prevStatus = prevAgenticStatusRef.current; - prevAgenticStatusRef.current = agenticSearchState.status; - if (prevStatus === "searching" && agenticSearchState.status === "results") { - if (isResumeWithRenameEnabled && treeNodes.length > 0) { - setFocusedNode(treeNodes[0]); - } else { - if (!isResumeWithRenameEnabled && displayedLogs.length > 0) { - const firstLog = displayedLogs[0]; - setFocusedNode({ - id: "0", - value: { - log: firstLog, - indexInFiltered: 0 - }, - label: "" - }); - } - } + } + }, [searchQuery, agenticSearchState]) + + // Cleanup: abort any in-progress agentic search on unmount + React.useEffect(() => { + return () => { + agenticSearchAbortRef.current?.abort() + } + }, []) + + // Focus first item when agentic search completes with results + const prevAgenticStatusRef = React.useRef(agenticSearchState.status) + React.useEffect(() => { + const prevStatus = prevAgenticStatusRef.current + prevAgenticStatusRef.current = agenticSearchState.status + + // When search just completed, focus the first item in the list + if (prevStatus === 'searching' && agenticSearchState.status === 'results') { + if (isResumeWithRenameEnabled && treeNodes.length > 0) { + setFocusedNode(treeNodes[0]!) + } else if (!isResumeWithRenameEnabled && displayedLogs.length > 0) { + const firstLog = displayedLogs[0]! + setFocusedNode({ + id: '0', + value: { log: firstLog, indexInFiltered: 0 }, + label: '', + }) } - }; - $[107] = agenticSearchState.status; - $[108] = displayedLogs[0]; - $[109] = displayedLogs.length; - $[110] = treeNodes; - $[111] = t40; - } else { - t40 = $[111]; - } - let t41; - if ($[112] !== agenticSearchState.status || $[113] !== displayedLogs || $[114] !== treeNodes) { - t41 = [agenticSearchState.status, isResumeWithRenameEnabled, treeNodes, displayedLogs]; - $[112] = agenticSearchState.status; - $[113] = displayedLogs; - $[114] = treeNodes; - $[115] = t41; - } else { - t41 = $[115]; - } - React.useEffect(t40, t41); - let t42; - if ($[116] !== displayedLogs) { - t42 = value => { - const index_1 = parseInt(value, 10); - const log_11 = displayedLogs[index_1]; - if (!log_11 || prevFocusedIdRef.current === index_1.toString()) { - return; + } + }, [ + agenticSearchState.status, + isResumeWithRenameEnabled, + treeNodes, + displayedLogs, + ]) + + const handleFlatOptionsSelectFocus = React.useCallback( + (value: string) => { + const index = parseInt(value, 10) + const log = displayedLogs[index] + if (!log || prevFocusedIdRef.current === index.toString()) { + return } - prevFocusedIdRef.current = index_1.toString(); + prevFocusedIdRef.current = index.toString() setFocusedNode({ - id: index_1.toString(), - value: { - log: log_11, - indexInFiltered: index_1 - }, - label: "" - }); - setFocusedIndex(index_1 + 1); - }; - $[116] = displayedLogs; - $[117] = t42; - } else { - t42 = $[117]; - } - const handleFlatOptionsSelectFocus = t42; - let t43; - if ($[118] !== displayedLogs) { - t43 = node => { - setFocusedNode(node); - const index_2 = displayedLogs.findIndex(log_12 => getSessionIdFromLog(log_12) === getSessionIdFromLog(node.value.log)); - if (index_2 >= 0) { - setFocusedIndex(index_2 + 1); + id: index.toString(), + value: { log, indexInFiltered: index }, + label: '', + }) + setFocusedIndex(index + 1) + }, + [displayedLogs], + ) + + const handleTreeSelectFocus = React.useCallback( + (node: LogTreeNode) => { + setFocusedNode(node) + // Update focused index for scroll position display + const index = displayedLogs.findIndex( + log => getSessionIdFromLog(log) === getSessionIdFromLog(node.value.log), + ) + if (index >= 0) { + setFocusedIndex(index + 1) } - }; - $[118] = displayedLogs; - $[119] = t43; - } else { - t43 = $[119]; - } - const handleTreeSelectFocus = t43; - let t44; - if ($[120] === Symbol.for("react.memo_cache_sentinel")) { - t44 = () => { - agenticSearchAbortRef.current?.abort(); - setAgenticSearchState({ - status: "idle" - }); - logEvent("tengu_agentic_search_cancelled", {}); - }; - $[120] = t44; - } else { - t44 = $[120]; - } - const t45 = viewMode !== "preview" && agenticSearchState.status === "searching"; - let t46; - if ($[121] !== t45) { - t46 = { - context: "Confirmation", - isActive: t45 - }; - $[121] = t45; - $[122] = t46; - } else { - t46 = $[122]; - } - useKeybinding("confirm:no", t44, t46); - let t47; - if ($[123] === Symbol.for("react.memo_cache_sentinel")) { - t47 = () => { - setViewMode("list"); - setRenameValue(""); - }; - $[123] = t47; - } else { - t47 = $[123]; - } - const t48 = viewMode === "rename" && agenticSearchState.status !== "searching"; - let t49; - if ($[124] !== t48) { - t49 = { - context: "Settings", - isActive: t48 - }; - $[124] = t48; - $[125] = t49; - } else { - t49 = $[125]; - } - useKeybinding("confirm:no", t47, t49); - let t50; - if ($[126] !== onCancel || $[127] !== setSearchQuery) { - t50 = () => { - setSearchQuery(""); - setIsAgenticSearchOptionFocused(false); - onCancel?.(); - }; - $[126] = onCancel; - $[127] = setSearchQuery; - $[128] = t50; - } else { - t50 = $[128]; - } - const t51 = viewMode !== "preview" && viewMode !== "rename" && viewMode !== "search" && isAgenticSearchOptionFocused && agenticSearchState.status !== "searching"; - let t52; - if ($[129] !== t51) { - t52 = { - context: "Confirmation", - isActive: t51 - }; - $[129] = t51; - $[130] = t52; - } else { - t52 = $[130]; - } - useKeybinding("confirm:no", t50, t52); - let t53; - if ($[131] !== agenticSearchState.status || $[132] !== branchFilterEnabled || $[133] !== focusedLog || $[134] !== handleAgenticSearch || $[135] !== hasMultipleWorktrees || $[136] !== hasTags || $[137] !== isAgenticSearchOptionFocused || $[138] !== onAgenticSearch || $[139] !== onToggleAllProjects || $[140] !== searchQuery || $[141] !== setSearchQuery || $[142] !== showAllProjects || $[143] !== showAllWorktrees || $[144] !== tagTabs || $[145] !== uniqueTags || $[146] !== viewMode) { - t53 = (input, key) => { - if (viewMode === "preview") { - return; + }, + [displayedLogs], + ) + + // Escape to abort agentic search in progress + useKeybinding( + 'confirm:no', + () => { + agenticSearchAbortRef.current?.abort() + setAgenticSearchState({ status: 'idle' }) + logEvent('tengu_agentic_search_cancelled', {}) + }, + { + context: 'Confirmation', + isActive: + viewMode !== 'preview' && agenticSearchState.status === 'searching', + }, + ) + + // Escape in rename mode - exit rename mode + // Use Settings context so 'n' key doesn't exit (allows typing 'n' in rename input) + useKeybinding( + 'confirm:no', + () => { + setViewMode('list') + setRenameValue('') + }, + { + context: 'Settings', + isActive: + viewMode === 'rename' && agenticSearchState.status !== 'searching', + }, + ) + + // Escape when agentic search option focused - clear and cancel + useKeybinding( + 'confirm:no', + () => { + setSearchQuery('') + setIsAgenticSearchOptionFocused(false) + onCancel?.() + }, + { + context: 'Confirmation', + isActive: + viewMode !== 'preview' && + viewMode !== 'rename' && + viewMode !== 'search' && + isAgenticSearchOptionFocused && + agenticSearchState.status !== 'searching', + }, + ) + + // Handle non-escape input + useInput( + (input, key) => { + if (viewMode === 'preview') { + // Preview mode handles its own input + return } - if (agenticSearchState.status === "searching") { - return; + + // Agentic search abort is now handled via keybinding + if (agenticSearchState.status === 'searching') { + return } - if (viewMode === "rename") {} else { - if (viewMode === "search") { - if (input.toLowerCase() === "n" && key.ctrl) { - exitSearchMode(); - } else { - if (key.return || key.downArrow) { - if (searchQuery.trim() && onAgenticSearch && false && agenticSearchState.status !== "results") { - setIsAgenticSearchOptionFocused(true); - } - } - } - } else { - if (isAgenticSearchOptionFocused) { - if (key.return) { - handleAgenticSearch(); - setIsAgenticSearchOptionFocused(false); - return; - } else { - if (key.downArrow) { - setIsAgenticSearchOptionFocused(false); - return; - } else { - if (key.upArrow) { - setViewMode("search"); - setIsAgenticSearchOptionFocused(false); - return; - } - } - } - } - if (hasTags && key.tab) { - const offset = key.shift ? -1 : 1; - setSelectedTagIndex(prev => { - const current = prev < tagTabs.length ? prev : 0; - const newIndex = (current + tagTabs.length + offset) % tagTabs.length; - const newTab = tagTabs[newIndex]; - logEvent("tengu_session_tag_filter_changed", { - is_all: newTab === "All", - tag_count: uniqueTags.length - }); - return newIndex; - }); - return; + + if (viewMode === 'rename') { + // Rename mode escape is now handled via keybinding + // This branch only handles non-escape input in rename mode (via TextInput) + } else if (viewMode === 'search') { + // Text input is handled by useSearchInput hook + if (input.toLowerCase() === 'n' && key.ctrl) { + exitSearchMode() + } else if (key.return || key.downArrow) { + // Focus agentic search option if applicable + if ( + searchQuery.trim() && + onAgenticSearch && + isAgenticSearchEnabled && + agenticSearchState.status !== 'results' + ) { + setIsAgenticSearchOptionFocused(true) } - const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta; - const lowerInput = input.toLowerCase(); - if (lowerInput === "a" && key.ctrl && onToggleAllProjects) { - onToggleAllProjects(); - logEvent("tengu_session_all_projects_toggled", { - enabled: !showAllProjects - }); - } else { - if (lowerInput === "b" && key.ctrl) { - const newEnabled = !branchFilterEnabled; - setBranchFilterEnabled(newEnabled); - logEvent("tengu_session_branch_filter_toggled", { - enabled: newEnabled - }); - } else { - if (lowerInput === "w" && key.ctrl && hasMultipleWorktrees) { - const newValue = !showAllWorktrees; - setShowAllWorktrees(newValue); - logEvent("tengu_session_worktree_filter_toggled", { - enabled: newValue - }); - } else { - if (lowerInput === "/" && keyIsNotCtrlOrMeta) { - setViewMode("search"); - logEvent("tengu_session_search_toggled", { - enabled: true - }); - } else { - if (lowerInput === "r" && key.ctrl && focusedLog) { - setViewMode("rename"); - setRenameValue(""); - logEvent("tengu_session_rename_started", {}); - } else { - if (lowerInput === "v" && key.ctrl && focusedLog) { - setPreviewLog(focusedLog); - setViewMode("preview"); - logEvent("tengu_session_preview_opened", { - messageCount: focusedLog.messageCount - }); - } else { - if (focusedLog && keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input)) { - setViewMode("search"); - setSearchQuery(input); - logEvent("tengu_session_search_toggled", { - enabled: true - }); - } - } - } - } - } - } + } + } else { + // Handle agentic search option when focused (escape handled via keybinding) + if (isAgenticSearchOptionFocused) { + if (key.return) { + // Trigger agentic search + void handleAgenticSearch() + setIsAgenticSearchOptionFocused(false) + return + } else if (key.downArrow) { + // Move focus to the session list + setIsAgenticSearchOptionFocused(false) + return + } else if (key.upArrow) { + // Go back to search mode + setViewMode('search') + setIsAgenticSearchOptionFocused(false) + return } } + + // Handle tab cycling for tag tabs + if (hasTags && key.tab) { + const offset = key.shift ? -1 : 1 + setSelectedTagIndex(prev => { + const current = prev < tagTabs.length ? prev : 0 + const newIndex = + (current + tagTabs.length + offset) % tagTabs.length + const newTab = tagTabs[newIndex] + logEvent('tengu_session_tag_filter_changed', { + is_all: newTab === 'All', + tag_count: uniqueTags.length, + }) + return newIndex + }) + return + } + + const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta + const lowerInput = input.toLowerCase() + // Ctrl+letter shortcuts for actions (freeing up plain letters for type-to-search) + if (lowerInput === 'a' && key.ctrl && onToggleAllProjects) { + onToggleAllProjects() + logEvent('tengu_session_all_projects_toggled', { + enabled: !showAllProjects, + }) + } else if (lowerInput === 'b' && key.ctrl) { + const newEnabled = !branchFilterEnabled + setBranchFilterEnabled(newEnabled) + logEvent('tengu_session_branch_filter_toggled', { + enabled: newEnabled, + }) + } else if (lowerInput === 'w' && key.ctrl && hasMultipleWorktrees) { + const newValue = !showAllWorktrees + setShowAllWorktrees(newValue) + logEvent('tengu_session_worktree_filter_toggled', { + enabled: newValue, + }) + } else if (lowerInput === '/' && keyIsNotCtrlOrMeta) { + setViewMode('search') + logEvent('tengu_session_search_toggled', { enabled: true }) + } else if (lowerInput === 'r' && key.ctrl && focusedLog) { + setViewMode('rename') + setRenameValue('') + logEvent('tengu_session_rename_started', {}) + } else if (lowerInput === 'v' && key.ctrl && focusedLog) { + setPreviewLog(focusedLog) + setViewMode('preview') + logEvent('tengu_session_preview_opened', { + messageCount: focusedLog.messageCount, + }) + } else if ( + focusedLog && + keyIsNotCtrlOrMeta && + input.length > 0 && + !/^\s+$/.test(input) + ) { + // Any printable character enters search mode and starts typing + setViewMode('search') + setSearchQuery(input) + logEvent('tengu_session_search_toggled', { enabled: true }) + } } - }; - $[131] = agenticSearchState.status; - $[132] = branchFilterEnabled; - $[133] = focusedLog; - $[134] = handleAgenticSearch; - $[135] = hasMultipleWorktrees; - $[136] = hasTags; - $[137] = isAgenticSearchOptionFocused; - $[138] = onAgenticSearch; - $[139] = onToggleAllProjects; - $[140] = searchQuery; - $[141] = setSearchQuery; - $[142] = showAllProjects; - $[143] = showAllWorktrees; - $[144] = tagTabs; - $[145] = uniqueTags; - $[146] = viewMode; - $[147] = t53; - } else { - t53 = $[147]; + }, + { isActive: true }, + ) + + const filterIndicators = [] + if (branchFilterEnabled && currentBranch) { + filterIndicators.push(currentBranch) } - let t54; - if ($[148] === Symbol.for("react.memo_cache_sentinel")) { - t54 = { - isActive: true - }; - $[148] = t54; - } else { - t54 = $[148]; + if (hasMultipleWorktrees && !showAllWorktrees) { + filterIndicators.push('current worktree') } - useInput(t53, t54); - let filterIndicators; - if ($[149] !== branchFilterEnabled || $[150] !== currentBranch || $[151] !== hasMultipleWorktrees || $[152] !== showAllWorktrees) { - filterIndicators = []; - if (branchFilterEnabled && currentBranch) { - filterIndicators.push(currentBranch); - } - if (hasMultipleWorktrees && !showAllWorktrees) { - filterIndicators.push("current worktree"); + + const showAdditionalFilterLine = + filterIndicators.length > 0 && viewMode !== 'search' + + // Search box takes 3 lines (border top, content, border bottom) + const searchBoxLines = 3 + const headerLines = + 5 + searchBoxLines + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines + const footerLines = 2 + const visibleCount = Math.max( + 1, + Math.floor((maxHeight - headerLines - footerLines) / 3), + ) + + // Progressive loading: request more logs when user scrolls near the end + React.useEffect(() => { + if (!onLoadMore) return + const buffer = visibleCount * 2 + if (focusedIndex + buffer >= displayedLogs.length) { + onLoadMore(visibleCount * 3) } - $[149] = branchFilterEnabled; - $[150] = currentBranch; - $[151] = hasMultipleWorktrees; - $[152] = showAllWorktrees; - $[153] = filterIndicators; - } else { - filterIndicators = $[153]; - } - const showAdditionalFilterLine = filterIndicators.length > 0 && viewMode !== "search"; - const headerLines = 8 + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines; - const visibleCount = Math.max(1, Math.floor((maxHeight - headerLines - 2) / 3)); - let t55; - let t56; - if ($[154] !== displayedLogs.length || $[155] !== focusedIndex || $[156] !== onLoadMore || $[157] !== visibleCount) { - t55 = () => { - if (!onLoadMore) { - return; - } - const buffer = visibleCount * 2; - if (focusedIndex + buffer >= displayedLogs.length) { - onLoadMore(visibleCount * 3); - } - }; - t56 = [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]; - $[154] = displayedLogs.length; - $[155] = focusedIndex; - $[156] = onLoadMore; - $[157] = visibleCount; - $[158] = t55; - $[159] = t56; - } else { - t55 = $[158]; - t56 = $[159]; - } - React.useEffect(t55, t56); + }, [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]) + + // Early return if no logs if (logs.length === 0) { - return null; - } - if (viewMode === "preview" && previewLog && isResumeWithRenameEnabled) { - let t57; - if ($[160] === Symbol.for("react.memo_cache_sentinel")) { - t57 = () => { - setViewMode("list"); - setPreviewLog(null); - }; - $[160] = t57; - } else { - t57 = $[160]; - } - let t58; - if ($[161] !== onSelect || $[162] !== previewLog) { - t58 = ; - $[161] = onSelect; - $[162] = previewLog; - $[163] = t58; - } else { - t58 = $[163]; - } - return t58; - } - const t57 = maxHeight - 1; - let t58; - if ($[164] === Symbol.for("react.memo_cache_sentinel")) { - t58 = ; - $[164] = t58; - } else { - t58 = $[164]; - } - let t59; - if ($[165] === Symbol.for("react.memo_cache_sentinel")) { - t59 = ; - $[165] = t59; - } else { - t59 = $[165]; - } - let t60; - if ($[166] !== columns || $[167] !== displayedLogs.length || $[168] !== effectiveTagIndex || $[169] !== focusedIndex || $[170] !== hasTags || $[171] !== showAllProjects || $[172] !== tagTabs || $[173] !== viewMode || $[174] !== visibleCount) { - t60 = hasTags ? : Resume Session{viewMode === "list" && displayedLogs.length > visibleCount && {" "}({focusedIndex} of {displayedLogs.length})}; - $[166] = columns; - $[167] = displayedLogs.length; - $[168] = effectiveTagIndex; - $[169] = focusedIndex; - $[170] = hasTags; - $[171] = showAllProjects; - $[172] = tagTabs; - $[173] = viewMode; - $[174] = visibleCount; - $[175] = t60; - } else { - t60 = $[175]; - } - const t61 = viewMode === "search"; - let t62; - if ($[176] !== isTerminalFocused || $[177] !== searchCursorOffset || $[178] !== searchQuery || $[179] !== t61) { - t62 = ; - $[176] = isTerminalFocused; - $[177] = searchCursorOffset; - $[178] = searchQuery; - $[179] = t61; - $[180] = t62; - } else { - t62 = $[180]; - } - let t63; - if ($[181] !== filterIndicators || $[182] !== viewMode) { - t63 = filterIndicators.length > 0 && viewMode !== "search" && {filterIndicators}; - $[181] = filterIndicators; - $[182] = viewMode; - $[183] = t63; - } else { - t63 = $[183]; - } - let t64; - if ($[184] === Symbol.for("react.memo_cache_sentinel")) { - t64 = ; - $[184] = t64; - } else { - t64 = $[184]; - } - let t65; - if ($[185] !== agenticSearchState.status) { - t65 = agenticSearchState.status === "searching" && Searching…; - $[185] = agenticSearchState.status; - $[186] = t65; - } else { - t65 = $[186]; - } - let t66; - if ($[187] !== agenticSearchState.results || $[188] !== agenticSearchState.status) { - t66 = agenticSearchState.status === "results" && agenticSearchState.results.length > 0 && Claude found these results:; - $[187] = agenticSearchState.results; - $[188] = agenticSearchState.status; - $[189] = t66; - } else { - t66 = $[189]; - } - let t67; - if ($[190] !== agenticSearchState.results || $[191] !== agenticSearchState.status || $[192] !== filteredLogs) { - t67 = agenticSearchState.status === "results" && agenticSearchState.results.length === 0 && filteredLogs.length === 0 && No matching sessions found.; - $[190] = agenticSearchState.results; - $[191] = agenticSearchState.status; - $[192] = filteredLogs; - $[193] = t67; - } else { - t67 = $[193]; - } - let t68; - if ($[194] !== agenticSearchState.status || $[195] !== filteredLogs) { - t68 = agenticSearchState.status === "error" && filteredLogs.length === 0 && No matching sessions found.; - $[194] = agenticSearchState.status; - $[195] = filteredLogs; - $[196] = t68; - } else { - t68 = $[196]; - } - let t69; - if ($[197] !== agenticSearchState.status || $[198] !== isAgenticSearchOptionFocused || $[199] !== onAgenticSearch || $[200] !== searchQuery) { - t69 = Boolean(searchQuery.trim()) && onAgenticSearch && false && agenticSearchState.status !== "searching" && agenticSearchState.status !== "results" && agenticSearchState.status !== "error" && {isAgenticSearchOptionFocused ? figures.pointer : " "}Search deeply using Claude →; - $[197] = agenticSearchState.status; - $[198] = isAgenticSearchOptionFocused; - $[199] = onAgenticSearch; - $[200] = searchQuery; - $[201] = t69; - } else { - t69 = $[201]; - } - let t70; - if ($[202] !== agenticSearchState.status || $[203] !== branchFilterEnabled || $[204] !== columns || $[205] !== displayedLogs || $[206] !== expandedGroupSessionIds || $[207] !== flatOptions || $[208] !== focusedLog || $[209] !== focusedNode?.id || $[210] !== handleFlatOptionsSelectFocus || $[211] !== handleRenameSubmit || $[212] !== handleTreeSelectFocus || $[213] !== isAgenticSearchOptionFocused || $[214] !== onCancel || $[215] !== onSelect || $[216] !== renameCursorOffset || $[217] !== renameValue || $[218] !== treeNodes || $[219] !== viewMode || $[220] !== visibleCount) { - t70 = agenticSearchState.status === "searching" ? null : viewMode === "rename" && focusedLog ? Rename session: : isResumeWithRenameEnabled ? { - onSelect(node_0.value.log); - }} onFocus={handleTreeSelectFocus} onCancel={onCancel} focusNodeId={focusedNode?.id} visibleOptionCount={visibleCount} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused} hideIndexes={false} isNodeExpanded={nodeId => { - if (viewMode === "search" || branchFilterEnabled) { - return true; - } - const sessionId_2 = typeof nodeId === "string" && nodeId.startsWith("group:") ? nodeId.substring(6) : null; - return sessionId_2 ? expandedGroupSessionIds.has(sessionId_2) : false; - }} onExpand={nodeId_0 => { - const sessionId_3 = typeof nodeId_0 === "string" && nodeId_0.startsWith("group:") ? nodeId_0.substring(6) : null; - if (sessionId_3) { - setExpandedGroupSessionIds(prev_0 => new Set(prev_0).add(sessionId_3)); - logEvent("tengu_session_group_expanded", {}); - } - }} onCollapse={nodeId_1 => { - const sessionId_4 = typeof nodeId_1 === "string" && nodeId_1.startsWith("group:") ? nodeId_1.substring(6) : null; - if (sessionId_4) { - setExpandedGroupSessionIds(prev_1 => { - const newSet = new Set(prev_1); - newSet.delete(sessionId_4); - return newSet; - }); - } - }} onUpFromFirstItem={enterSearchMode} /> : { + // Old flat list mode - index directly maps to displayedLogs + const itemIndex = parseInt(value, 10) + const log = displayedLogs[itemIndex] + if (log) { + onSelect(log) + } + }} + visibleOptionCount={visibleCount} + onCancel={onCancel} + onFocus={handleFlatOptionsSelectFocus} + defaultFocusValue={focusedNode?.id.toString()} + layout="expanded" + isDisabled={viewMode === 'search' || isAgenticSearchOptionFocused} + onUpFromFirstItem={enterSearchMode} + /> + )} + + {exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : viewMode === 'rename' ? ( + + + + + + + ) : agenticSearchState.status === 'searching' ? ( + + + Searching with Claude… + + + + ) : isAgenticSearchOptionFocused ? ( + + + + + + + + ) : viewMode === 'search' ? ( + + + + {isSearching && isDeepSearchEnabled + ? 'Searching…' + : 'Type to Search'} + + + + + + ) : ( + + + {onToggleAllProjects && ( + + )} + {currentBranch && ( + + )} + {hasMultipleWorktrees && ( + + )} + + + Type to search + + {getExpandCollapseHint() && ( + {getExpandCollapseHint()} + )} + + + )} + + + ) } /** * Extracts searchable text content from a message. * Handles both string content and structured content blocks. */ -function _temp7(r_0) { - return r_0.log; -} -function _temp6(log_6) { - return log_6.messages[0]?.uuid; -} -function _temp5(fuseIndex_0, debouncedDeepSearchQuery_0, setDeepSearchResults_0, setIsSearching_0) { - const results = fuseIndex_0.search(debouncedDeepSearchQuery_0); - results.sort(_temp3); - setDeepSearchResults_0({ - results: results.map(_temp4), - query: debouncedDeepSearchQuery_0 - }); - setIsSearching_0(false); -} -function _temp4(r) { - return { - log: r.item.log, - score: r.score, - searchableText: r.item.searchableText - }; -} -function _temp3(a, b) { - const aTime = new Date(a.item.log.modified).getTime(); - const bTime = new Date(b.item.log.modified).getTime(); - const timeDiff = bTime - aTime; - if (Math.abs(timeDiff) > DATE_TIE_THRESHOLD_MS) { - return timeDiff; - } - return (a.score ?? 1) - (b.score ?? 1); -} -function _temp2(log_1) { - const currentSessionId = getSessionId(); - const logSessionId = getSessionIdFromLog(log_1); - const isCurrentSession = currentSessionId && logSessionId === currentSessionId; - if (isCurrentSession) { - return true; - } - if (log_1.customTitle) { - return true; - } - const fromMessages = getFirstMeaningfulUserMessageTextContent(log_1.messages); - if (fromMessages) { - return true; - } - if (log_1.firstPrompt || log_1.customTitle) { - return true; - } - return false; -} -function _temp(log) { - return [log, buildSearchableText(log)]; -} function extractSearchableText(message: SerializedMessage): string { // Only extract from user and assistant messages that have content if (message.type !== 'user' && message.type !== 'assistant') { - return ''; + return '' } - const content = 'message' in message ? message.message?.content : undefined; - if (!content) return ''; + + const content = 'message' in message ? message.message?.content : undefined + if (!content) return '' // Handle string content (simple messages) if (typeof content === 'string') { - return content; + return content } // Handle array of content blocks if (Array.isArray(content)) { - return content.map(block => { - if (typeof block === 'string') return block; - if ('text' in block && typeof block.text === 'string') return block.text; - return ''; - // we don't return thinking blocks and tool names here; - // they're not useful for search, as they can add noise to the fuzzy matching - }).filter(Boolean).join(' '); + return content + .map(block => { + if (typeof block === 'string') return block + if ('text' in block && typeof block.text === 'string') return block.text + return '' + // we don't return thinking blocks and tool names here; + // they're not useful for search, as they can add noise to the fuzzy matching + }) + .filter(Boolean) + .join(' ') } - return ''; + + return '' } /** @@ -1535,40 +1375,72 @@ function extractSearchableText(message: SerializedMessage): string { * Crops long transcripts to first/last N messages for performance. */ function buildSearchableText(log: LogOption): string { - const searchableMessages = log.messages.length <= DEEP_SEARCH_MAX_MESSAGES ? log.messages : [...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE)]; - const messageText = searchableMessages.map(extractSearchableText).filter(Boolean).join(' '); - const metadata = [log.customTitle, log.summary, log.firstPrompt, log.gitBranch, log.tag, log.prNumber ? `PR #${log.prNumber}` : undefined, log.prRepository].filter(Boolean).join(' '); - const fullText = `${metadata} ${messageText}`.trim(); - return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) : fullText; + const searchableMessages = + log.messages.length <= DEEP_SEARCH_MAX_MESSAGES + ? log.messages + : [ + ...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), + ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE), + ] + const messageText = searchableMessages + .map(extractSearchableText) + .filter(Boolean) + .join(' ') + + const metadata = [ + log.customTitle, + log.summary, + log.firstPrompt, + log.gitBranch, + log.tag, + log.prNumber ? `PR #${log.prNumber}` : undefined, + log.prRepository, + ] + .filter(Boolean) + .join(' ') + + const fullText = `${metadata} ${messageText}`.trim() + return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH + ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) + : fullText } -function groupLogsBySessionId(filteredLogs: LogOption[]): Map { - const groups = new Map(); + +function groupLogsBySessionId( + filteredLogs: LogOption[], +): Map { + const groups = new Map() + for (const log of filteredLogs) { - const sessionId = getSessionIdFromLog(log); + const sessionId = getSessionIdFromLog(log) if (sessionId) { - const existing = groups.get(sessionId); + const existing = groups.get(sessionId) if (existing) { - existing.push(log); + existing.push(log) } else { - groups.set(sessionId, [log]); + groups.set(sessionId, [log]) } } } // Sort logs within each group by modified date (newest first) - groups.forEach(logs => logs.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime())); - return groups; + groups.forEach(logs => + logs.sort( + (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime(), + ), + ) + + return groups } /** * Get unique tags from a list of logs, sorted alphabetically */ function getUniqueTags(logs: LogOption[]): string[] { - const tags = new Set(); + const tags = new Set() for (const log of logs) { if (log.tag) { - tags.add(log.tag); + tags.add(log.tag) } } - return Array.from(tags).sort((a, b) => a.localeCompare(b)); + return Array.from(tags).sort((a, b) => a.localeCompare(b)) } diff --git a/src/components/LogoV2/AnimatedAsterisk.tsx b/src/components/LogoV2/AnimatedAsterisk.tsx index 94463c436..1c5adcf06 100644 --- a/src/components/LogoV2/AnimatedAsterisk.tsx +++ b/src/components/LogoV2/AnimatedAsterisk.tsx @@ -1,49 +1,57 @@ -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { TEARDROP_ASTERISK } from '../../constants/figures.js'; -import { Box, Text, useAnimationFrame } from '../../ink.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { hueToRgb, toRGBColor } from '../Spinner/utils.js'; -const SWEEP_DURATION_MS = 1500; -const SWEEP_COUNT = 2; -const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT; -const SETTLED_GREY = toRGBColor({ - r: 153, - g: 153, - b: 153 -}); +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { TEARDROP_ASTERISK } from '../../constants/figures.js' +import { Box, Text, useAnimationFrame } from '../../ink.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { hueToRgb, toRGBColor } from '../Spinner/utils.js' + +const SWEEP_DURATION_MS = 1500 +const SWEEP_COUNT = 2 +const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT +const SETTLED_GREY = toRGBColor({ r: 153, g: 153, b: 153 }) + export function AnimatedAsterisk({ - char = TEARDROP_ASTERISK + char = TEARDROP_ASTERISK, }: { - char?: string; + char?: string }): React.ReactNode { // Read prefersReducedMotion once at mount — no useSettings() subscription, // since that would re-render whenever settings change. - const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false); - const [done, setDone] = useState(reducedMotion); + const [reducedMotion] = useState( + () => getInitialSettings().prefersReducedMotion ?? false, + ) + const [done, setDone] = useState(reducedMotion) // useAnimationFrame's clock is shared — capture our start offset so the // sweep always begins at hue 0 regardless of when we mount. - const startTimeRef = useRef(null); + const startTimeRef = useRef(null) // Wire the ref so useAnimationFrame's viewport-pause kicks in: if the // user submits a message before the sweep finishes, the clock stops // automatically once this row enters scrollback (prevents flicker). - const [ref, time] = useAnimationFrame(done ? null : 50); + const [ref, time] = useAnimationFrame(done ? null : 50) + useEffect(() => { - if (done) return; - const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true); - return () => clearTimeout(t); - }, [done]); + if (done) return + const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true) + return () => clearTimeout(t) + }, [done]) + if (done) { - return + return ( + {char} - ; + + ) } + if (startTimeRef.current === null) { - startTimeRef.current = time; + startTimeRef.current = time } - const elapsed = time - startTimeRef.current; - const hue = elapsed / SWEEP_DURATION_MS * 360 % 360; - return + const elapsed = time - startTimeRef.current + const hue = ((elapsed / SWEEP_DURATION_MS) * 360) % 360 + + return ( + {char} - ; + + ) } diff --git a/src/components/LogoV2/AnimatedClawd.tsx b/src/components/LogoV2/AnimatedClawd.tsx index 877a811ab..ed3060066 100644 --- a/src/components/LogoV2/AnimatedClawd.tsx +++ b/src/components/LogoV2/AnimatedClawd.tsx @@ -1,22 +1,14 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { Box } from '../../ink.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { Clawd, type ClawdPose } from './Clawd.js'; -type Frame = { - pose: ClawdPose; - offset: number; -}; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { Box } from '../../ink.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { Clawd, type ClawdPose } from './Clawd.js' + +type Frame = { pose: ClawdPose; offset: number } /** Hold a pose for n frames (60ms each). */ function hold(pose: ClawdPose, offset: number, frames: number): Frame[] { - return Array.from({ - length: frames - }, () => ({ - pose, - offset - })); + return Array.from({ length: frames }, () => ({ pose, offset })) } // Offset semantics: marginTop in a fixed-height-3 container. 0 = normal, @@ -25,26 +17,28 @@ function hold(pose: ClawdPose, offset: number, frames: number): Frame[] { // clipped — reads as "ducking below the frame" before springing back up. // Click animation: crouch, then spring up with both arms raised. Twice. -const JUMP_WAVE: readonly Frame[] = [...hold('default', 1, 2), -// crouch -...hold('arms-up', 0, 3), -// spring! -...hold('default', 0, 1), ...hold('default', 1, 2), -// crouch again -...hold('arms-up', 0, 3), -// spring! -...hold('default', 0, 1)]; +const JUMP_WAVE: readonly Frame[] = [ + ...hold('default', 1, 2), // crouch + ...hold('arms-up', 0, 3), // spring! + ...hold('default', 0, 1), + ...hold('default', 1, 2), // crouch again + ...hold('arms-up', 0, 3), // spring! + ...hold('default', 0, 1), +] // Click animation: glance right, then left, then back. -const LOOK_AROUND: readonly Frame[] = [...hold('look-right', 0, 5), ...hold('look-left', 0, 5), ...hold('default', 0, 1)]; -const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND]; -const IDLE: Frame = { - pose: 'default', - offset: 0 -}; -const FRAME_MS = 60; -const incrementFrame = (i: number) => i + 1; -const CLAWD_HEIGHT = 3; +const LOOK_AROUND: readonly Frame[] = [ + ...hold('look-right', 0, 5), + ...hold('look-left', 0, 5), + ...hold('default', 0, 1), +] + +const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND] + +const IDLE: Frame = { pose: 'default', offset: 0 } +const FRAME_MS = 60 +const incrementFrame = (i: number) => i + 1 +const CLAWD_HEIGHT = 3 /** * Clawd with click-triggered animations (crouch-jump with arms up, or @@ -54,70 +48,49 @@ const CLAWD_HEIGHT = 3; * mouse tracking is enabled (i.e. inside `` / fullscreen); * elsewhere this renders and behaves identically to plain ``. */ -export function AnimatedClawd() { - const $ = _c(8); - const { - pose, - bounceOffset, - onClick - } = useClawdAnimation(); - let t0; - if ($[0] !== pose) { - t0 = ; - $[0] = pose; - $[1] = t0; - } else { - t0 = $[1]; - } - let t1; - if ($[2] !== bounceOffset || $[3] !== t0) { - t1 = {t0}; - $[2] = bounceOffset; - $[3] = t0; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] !== onClick || $[6] !== t1) { - t2 = {t1}; - $[5] = onClick; - $[6] = t1; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; +export function AnimatedClawd(): React.ReactNode { + const { pose, bounceOffset, onClick } = useClawdAnimation() + return ( + + + + + + ) } + function useClawdAnimation(): { - pose: ClawdPose; - bounceOffset: number; - onClick: () => void; + pose: ClawdPose + bounceOffset: number + onClick: () => void } { // Read once at mount — no useSettings() subscription, since that would // re-render on any settings change. - const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false); - const [frameIndex, setFrameIndex] = useState(-1); - const sequenceRef = useRef(JUMP_WAVE); + const [reducedMotion] = useState( + () => getInitialSettings().prefersReducedMotion ?? false, + ) + const [frameIndex, setFrameIndex] = useState(-1) + const sequenceRef = useRef(JUMP_WAVE) + const onClick = () => { - if (reducedMotion || frameIndex !== -1) return; - sequenceRef.current = CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]!; - setFrameIndex(0); - }; + if (reducedMotion || frameIndex !== -1) return + sequenceRef.current = + CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]! + setFrameIndex(0) + } + useEffect(() => { - if (frameIndex === -1) return; + if (frameIndex === -1) return if (frameIndex >= sequenceRef.current.length) { - setFrameIndex(-1); - return; + setFrameIndex(-1) + return } - const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame); - return () => clearTimeout(timer); - }, [frameIndex]); - const seq = sequenceRef.current; - const current = frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE; - return { - pose: current.pose, - bounceOffset: current.offset, - onClick - }; + const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame) + return () => clearTimeout(timer) + }, [frameIndex]) + + const seq = sequenceRef.current + const current = + frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE + return { pose: current.pose, bounceOffset: current.offset, onClick } } diff --git a/src/components/LogoV2/ChannelsNotice.tsx b/src/components/LogoV2/ChannelsNotice.tsx index 9c24e4a67..66f41b303 100644 --- a/src/components/LogoV2/ChannelsNotice.tsx +++ b/src/components/LogoV2/ChannelsNotice.tsx @@ -1,265 +1,208 @@ -import { c as _c } from "react/compiler-runtime"; // Conditionally require()'d in LogoV2.tsx behind feature('KAIROS') || // feature('KAIROS_CHANNELS'). No feature() guard here — the whole file // tree-shakes via the require pattern when both flags are false (see // docs/feature-gating.md). Do NOT import this module statically from // unguarded code. -import * as React from 'react'; -import { useState } from 'react'; -import { type ChannelEntry, getAllowedChannels, getHasDevChannels } from '../../bootstrap/state.js'; -import { Box, Text } from '../../ink.js'; -import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js'; -import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js'; -import { getMcpConfigsByScope } from '../../services/mcp/config.js'; -import { getClaudeAIOAuthTokens, getSubscriptionType } from '../../utils/auth.js'; -import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'; -import { getSettingsForSource } from '../../utils/settings/settings.js'; -export function ChannelsNotice() { - const $ = _c(32); - const [t0] = useState(_temp); - const { - channels, - disabled, - noAuth, - policyBlocked, - list, - unmatched - } = t0; - if (channels.length === 0) { - return null; - } - const hasNonDev = channels.some(_temp2); - const flag = getHasDevChannels() && hasNonDev ? "Channels" : getHasDevChannels() ? "--dangerously-load-development-channels" : "--channels"; +import * as React from 'react' +import { useState } from 'react' +import { + type ChannelEntry, + getAllowedChannels, + getHasDevChannels, +} from '../../bootstrap/state.js' +import { Box, Text } from '../../ink.js' +import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js' +import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js' +import { getMcpConfigsByScope } from '../../services/mcp/config.js' +import { + getClaudeAIOAuthTokens, + getSubscriptionType, +} from '../../utils/auth.js' +import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js' +import { getSettingsForSource } from '../../utils/settings/settings.js' + +export function ChannelsNotice(): React.ReactNode { + // Snapshot all reads at mount. This notice enters scrollback immediately + // after the logo; any re-render past that point forces a full terminal + // reset. getAllowedChannels (bootstrap state), getSettingsForSource + // (session cache updated by background polling / /login), and + // isChannelsEnabled (GrowthBook 5-min refresh) must be captured once + // so a later re-render cannot flip branches. + const [{ channels, disabled, noAuth, policyBlocked, list, unmatched }] = + useState(() => { + const ch = getAllowedChannels() + if (ch.length === 0) + return { + channels: ch, + disabled: false, + noAuth: false, + policyBlocked: false, + list: '', + unmatched: [] as Unmatched[], + } + const l = ch.map(formatEntry).join(', ') + const sub = getSubscriptionType() + const managed = sub === 'team' || sub === 'enterprise' + const policy = getSettingsForSource('policySettings') + const allowlist = getEffectiveChannelAllowlist( + sub, + policy?.allowedChannelPlugins, + ) + return { + channels: ch, + disabled: !isChannelsEnabled(), + noAuth: !getClaudeAIOAuthTokens()?.accessToken, + policyBlocked: managed && policy?.channelsEnabled !== true, + list: l, + unmatched: findUnmatched(ch, allowlist), + } + }) + if (channels.length === 0) return null + + // When both flags are passed, the list mixes entries and a single flag + // name would be wrong for half of it. entry.dev distinguishes origin. + const hasNonDev = channels.some(c => !c.dev) + const flag = + getHasDevChannels() && hasNonDev + ? 'Channels' + : getHasDevChannels() + ? '--dangerously-load-development-channels' + : '--channels' + if (disabled) { - let t1; - if ($[0] !== flag || $[1] !== list) { - t1 = {flag} ignored ({list}); - $[0] = flag; - $[1] = list; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Channels are not currently available; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1) { - t3 = {t1}{t2}; - $[4] = t1; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; + return ( + + + {flag} ignored ({list}) + + Channels are not currently available + + ) } + if (noAuth) { - let t1; - if ($[6] !== flag || $[7] !== list) { - t1 = {flag} ignored ({list}); - $[6] = flag; - $[7] = list; - $[8] = t1; - } else { - t1 = $[8]; - } - let t2; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Channels require claude.ai authentication · run /login, then restart; - $[9] = t2; - } else { - t2 = $[9]; - } - let t3; - if ($[10] !== t1) { - t3 = {t1}{t2}; - $[10] = t1; - $[11] = t3; - } else { - t3 = $[11]; - } - return t3; + return ( + + + {flag} ignored ({list}) + + + Channels require claude.ai authentication · run /login, then restart + + + ) } + if (policyBlocked) { - let t1; - if ($[12] !== flag || $[13] !== list) { - t1 = {flag} blocked by org policy ({list}); - $[12] = flag; - $[13] = list; - $[14] = t1; - } else { - t1 = $[14]; - } - let t2; - let t3; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Inbound messages will be silently dropped; - t3 = Have an administrator set channelsEnabled: true in managed settings to enable; - $[15] = t2; - $[16] = t3; - } else { - t2 = $[15]; - t3 = $[16]; - } - let t4; - if ($[17] !== unmatched) { - t4 = unmatched.map(_temp3); - $[17] = unmatched; - $[18] = t4; - } else { - t4 = $[18]; - } - let t5; - if ($[19] !== t1 || $[20] !== t4) { - t5 = {t1}{t2}{t3}{t4}; - $[19] = t1; - $[20] = t4; - $[21] = t5; - } else { - t5 = $[21]; - } - return t5; + return ( + + + {flag} blocked by org policy ({list}) + + Inbound messages will be silently dropped + + Have an administrator set channelsEnabled: true in managed settings to + enable + + {unmatched.map(u => ( + + {formatEntry(u.entry)} · {u.why} + + ))} + + ) } - let t1; - if ($[22] !== list) { - t1 = Listening for channel messages from: {list}; - $[22] = list; - $[23] = t1; - } else { - t1 = $[23]; - } - let t2; - if ($[24] !== flag) { - t2 = Experimental · inbound messages will be pushed into this session, this carries prompt injection risks. Restart Claude Code without {flag} to disable.; - $[24] = flag; - $[25] = t2; - } else { - t2 = $[25]; - } - let t3; - if ($[26] !== unmatched) { - t3 = unmatched.map(_temp4); - $[26] = unmatched; - $[27] = t3; - } else { - t3 = $[27]; - } - let t4; - if ($[28] !== t1 || $[29] !== t2 || $[30] !== t3) { - t4 = {t1}{t2}{t3}; - $[28] = t1; - $[29] = t2; - $[30] = t3; - $[31] = t4; - } else { - t4 = $[31]; - } - return t4; -} -function _temp4(u_0) { - return {formatEntry(u_0.entry)} · {u_0.why}; -} -function _temp3(u) { - return {formatEntry(u.entry)} · {u.why}; -} -function _temp2(c) { - return !c.dev; -} -function _temp() { - const ch = getAllowedChannels(); - if (ch.length === 0) { - return { - channels: ch, - disabled: false, - noAuth: false, - policyBlocked: false, - list: "", - unmatched: [] as Unmatched[] - }; - } - const l = ch.map(formatEntry).join(", "); - const sub = getSubscriptionType(); - const managed = sub === "team" || sub === "enterprise"; - const policy = getSettingsForSource("policySettings"); - const allowlist = getEffectiveChannelAllowlist(sub, policy?.allowedChannelPlugins); - return { - channels: ch, - disabled: !isChannelsEnabled(), - noAuth: !getClaudeAIOAuthTokens()?.accessToken, - policyBlocked: managed && policy?.channelsEnabled !== true, - list: l, - unmatched: findUnmatched(ch, allowlist) - }; + + // "Listening for" not "active" — at this point we only know the allowlist + // was set. Server connection, capability declaration, and whether the name + // even matches a configured MCP server are all still unknown. + return ( + + Listening for channel messages from: {list} + + Experimental · inbound messages will be pushed into this session, this + carries prompt injection risks. Restart Claude Code without {flag} to + disable. + + {unmatched.map(u => ( + + {formatEntry(u.entry)} · {u.why} + + ))} + + ) } + function formatEntry(c: ChannelEntry): string { - return c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`; + return c.kind === 'plugin' + ? `plugin:${c.name}@${c.marketplace}` + : `server:${c.name}` } -type Unmatched = { - entry: ChannelEntry; - why: string; -}; -function findUnmatched(entries: readonly ChannelEntry[], allowlist: ReturnType): Unmatched[] { + +type Unmatched = { entry: ChannelEntry; why: string } + +function findUnmatched( + entries: readonly ChannelEntry[], + allowlist: ReturnType, +): Unmatched[] { // Server-kind: build one Set from all scopes up front. getMcpConfigsByScope // is not cached (project scope walks the dir tree); getMcpConfigByName would // redo that walk per entry. - const scopes = ['enterprise', 'user', 'project', 'local'] as const; - const configured = new Set(); + const scopes = ['enterprise', 'user', 'project', 'local'] as const + const configured = new Set() for (const scope of scopes) { for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) { - configured.add(name); + configured.add(name) } } // Plugin-kind installed check: installed_plugins.json keys are // `name@marketplace`. loadInstalledPluginsV2 is cached. - const installedPluginIds = new Set(Object.keys(loadInstalledPluginsV2().plugins)); + const installedPluginIds = new Set( + Object.keys(loadInstalledPluginsV2().plugins), + ) // Plugin-kind allowlist check: same {marketplace, plugin} test as the // gate at channelNotification.ts. entry.dev bypasses (dev flag opts out // of the allowlist). Org list replaces ledger when set (team/enterprise). // GrowthBook _CACHED_MAY_BE_STALE — cold cache yields [] so every plugin // entry warns; same tradeoff the gate already accepts. - const { - entries: allowed, - source - } = allowlist; + const { entries: allowed, source } = allowlist // Independent ifs — a plugin entry that's both uninstalled AND // unlisted shows two lines. Server kind checks config + dev flag. - const out: Unmatched[] = []; + const out: Unmatched[] = [] for (const entry of entries) { if (entry.kind === 'server') { if (!configured.has(entry.name)) { - out.push({ - entry, - why: 'no MCP server configured with that name' - }); + out.push({ entry, why: 'no MCP server configured with that name' }) } if (!entry.dev) { out.push({ entry, - why: 'server: entries need --dangerously-load-development-channels' - }); + why: 'server: entries need --dangerously-load-development-channels', + }) } - continue; + continue } if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) { - out.push({ - entry, - why: 'plugin not installed' - }); + out.push({ entry, why: 'plugin not installed' }) } - if (!entry.dev && !allowed.some(e => e.plugin === entry.name && e.marketplace === entry.marketplace)) { + if ( + !entry.dev && + !allowed.some( + e => e.plugin === entry.name && e.marketplace === entry.marketplace, + ) + ) { out.push({ entry, - why: source === 'org' ? "not on your org's approved channels list" : 'not on the approved channels allowlist' - }); + why: + source === 'org' + ? "not on your org's approved channels list" + : 'not on the approved channels allowlist', + }) } } - return out; + return out } diff --git a/src/components/LogoV2/Clawd.tsx b/src/components/LogoV2/Clawd.tsx index ba48bf833..8ddc1bf8e 100644 --- a/src/components/LogoV2/Clawd.tsx +++ b/src/components/LogoV2/Clawd.tsx @@ -1,14 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { env } from '../../utils/env.js'; -export type ClawdPose = 'default' | 'arms-up' // both arms raised (used during jump) -| 'look-left' // both pupils shifted left -| 'look-right'; // both pupils shifted right +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { env } from '../../utils/env.js' + +export type ClawdPose = + | 'default' + | 'arms-up' // both arms raised (used during jump) + | 'look-left' // both pupils shifted left + | 'look-right' // both pupils shifted right type Props = { - pose?: ClawdPose; -}; + pose?: ClawdPose +} // Standard-terminal pose fragments. Each row is split into segments so we can // vary only the parts that change (eyes, arms) while keeping the body/bg spans @@ -21,46 +23,23 @@ type Props = { // default (▛/▜, bottom pupils) — otherwise only one eye would appear to move. type Segments = { /** row 1 left (no bg): optional raised arm + side */ - r1L: string; + r1L: string /** row 1 eyes (with bg): left-eye, forehead, right-eye */ - r1E: string; + r1E: string /** row 1 right (no bg): side + optional raised arm */ - r1R: string; + r1R: string /** row 2 left (no bg): arm + body curve */ - r2L: string; + r2L: string /** row 2 right (no bg): body curve + arm */ - r2R: string; -}; + r2R: string +} + const POSES: Record = { - default: { - r1L: ' ▐', - r1E: '▛███▜', - r1R: '▌', - r2L: '▝▜', - r2R: '▛▘' - }, - 'look-left': { - r1L: ' ▐', - r1E: '▟███▟', - r1R: '▌', - r2L: '▝▜', - r2R: '▛▘' - }, - 'look-right': { - r1L: ' ▐', - r1E: '▙███▙', - r1R: '▌', - r2L: '▝▜', - r2R: '▛▘' - }, - 'arms-up': { - r1L: '▗▟', - r1E: '▛███▜', - r1R: '▙▖', - r2L: ' ▜', - r2R: '▛ ' - } -}; + default: { r1L: ' ▐', r1E: '▛███▜', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, + 'look-left': { r1L: ' ▐', r1E: '▟███▟', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, + 'look-right': { r1L: ' ▐', r1E: '▙███▙', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, + 'arms-up': { r1L: '▗▟', r1E: '▛███▜', r1R: '▙▖', r2L: ' ▜', r2R: '▛ ' }, +} // Apple Terminal uses a bg-fill trick (see below), so only eye poses make // sense. Arm poses fall back to default. @@ -68,172 +47,52 @@ const APPLE_EYES: Record = { default: ' ▗ ▖ ', 'look-left': ' ▘ ▘ ', 'look-right': ' ▝ ▝ ', - 'arms-up': ' ▗ ▖ ' -}; -export function Clawd(t0) { - const $ = _c(26); - let t1; - if ($[0] !== t0) { - t1 = t0 === undefined ? {} : t0; - $[0] = t0; - $[1] = t1; - } else { - t1 = $[1]; - } - const { - pose: t2 - } = t1; - const pose = t2 === undefined ? "default" : t2; - if (env.terminal === "Apple_Terminal") { - let t3; - if ($[2] !== pose) { - t3 = ; - $[2] = pose; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; - } - const p = POSES[pose]; - let t3; - if ($[4] !== p.r1L) { - t3 = {p.r1L}; - $[4] = p.r1L; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== p.r1E) { - t4 = {p.r1E}; - $[6] = p.r1E; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== p.r1R) { - t5 = {p.r1R}; - $[8] = p.r1R; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t3 || $[11] !== t4 || $[12] !== t5) { - t6 = {t3}{t4}{t5}; - $[10] = t3; - $[11] = t4; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== p.r2L) { - t7 = {p.r2L}; - $[14] = p.r2L; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t8 = █████; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== p.r2R) { - t9 = {p.r2R}; - $[17] = p.r2R; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== t7 || $[20] !== t9) { - t10 = {t7}{t8}{t9}; - $[19] = t7; - $[20] = t9; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] === Symbol.for("react.memo_cache_sentinel")) { - t11 = {" "}▘▘ ▝▝{" "}; - $[22] = t11; - } else { - t11 = $[22]; - } - let t12; - if ($[23] !== t10 || $[24] !== t6) { - t12 = {t6}{t10}{t11}; - $[23] = t10; - $[24] = t6; - $[25] = t12; - } else { - t12 = $[25]; - } - return t12; + 'arms-up': ' ▗ ▖ ', } -function AppleTerminalClawd(t0) { - const $ = _c(10); - const { - pose - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - const t2 = APPLE_EYES[pose]; - let t3; - if ($[1] !== t2) { - t3 = {t2}; - $[1] = t2; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== t3) { - t5 = {t1}{t3}{t4}; - $[4] = t3; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - let t7; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {" ".repeat(7)}; - t7 = ▘▘ ▝▝; - $[6] = t6; - $[7] = t7; - } else { - t6 = $[6]; - t7 = $[7]; - } - let t8; - if ($[8] !== t5) { - t8 = {t5}{t6}{t7}; - $[8] = t5; - $[9] = t8; - } else { - t8 = $[9]; - } - return t8; + +export function Clawd({ pose = 'default' }: Props = {}): React.ReactNode { + if (env.terminal === 'Apple_Terminal') { + return + } + const p = POSES[pose] + return ( + + + {p.r1L} + + {p.r1E} + + {p.r1R} + + + {p.r2L} + + █████ + + {p.r2R} + + + {' '}▘▘ ▝▝{' '} + + + ) +} + +function AppleTerminalClawd({ pose }: { pose: ClawdPose }): React.ReactNode { + // Apple's Terminal renders vertical space between chars by default. + // It does NOT render vertical space between background colors + // so we use background color to draw the main shape. + return ( + + + + + {APPLE_EYES[pose]} + + + + {' '.repeat(7)} + ▘▘ ▝▝ + + ) } diff --git a/src/components/LogoV2/CondensedLogo.tsx b/src/components/LogoV2/CondensedLogo.tsx index 2f2d6307b..be587bf83 100644 --- a/src/components/LogoV2/CondensedLogo.tsx +++ b/src/components/LogoV2/CondensedLogo.tsx @@ -1,160 +1,119 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { type ReactNode, useEffect } from 'react'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import { getEffortSuffix } from '../../utils/effort.js'; -import { truncate } from '../../utils/format.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import { formatModelAndBilling, getLogoDisplayData, truncatePath } from '../../utils/logoV2Utils.js'; -import { renderModelSetting } from '../../utils/model/model.js'; -import { OffscreenFreeze } from '../OffscreenFreeze.js'; -import { AnimatedClawd } from './AnimatedClawd.js'; -import { Clawd } from './Clawd.js'; -import { GuestPassesUpsell, incrementGuestPassesSeenCount, useShowGuestPassesUpsell } from './GuestPassesUpsell.js'; -import { incrementOverageCreditUpsellSeenCount, OverageCreditUpsell, useShowOverageCreditUpsell } from './OverageCreditUpsell.js'; -export function CondensedLogo() { - const $ = _c(29); - const { - columns - } = useTerminalSize(); - const agent = useAppState(_temp); - const effortValue = useAppState(_temp2); - const model = useMainLoopModel(); - const modelDisplayName = renderModelSetting(model); - const { +import * as React from 'react' +import { type ReactNode, useEffect } from 'react' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import { getEffortSuffix } from '../../utils/effort.js' +import { truncate } from '../../utils/format.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import { + formatModelAndBilling, + getLogoDisplayData, + truncatePath, +} from '../../utils/logoV2Utils.js' +import { renderModelSetting } from '../../utils/model/model.js' +import { OffscreenFreeze } from '../OffscreenFreeze.js' +import { AnimatedClawd } from './AnimatedClawd.js' +import { Clawd } from './Clawd.js' +import { + GuestPassesUpsell, + incrementGuestPassesSeenCount, + useShowGuestPassesUpsell, +} from './GuestPassesUpsell.js' +import { + incrementOverageCreditUpsellSeenCount, + OverageCreditUpsell, + useShowOverageCreditUpsell, +} from './OverageCreditUpsell.js' + +export function CondensedLogo(): ReactNode { + const { columns } = useTerminalSize() + const agent = useAppState(s => s.agent) + const effortValue = useAppState(s => s.effortValue) + const model = useMainLoopModel() + const modelDisplayName = renderModelSetting(model) + const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData() + + // Prefer AppState.agent (set from --agent CLI flag) over settings + const agentName = agent ?? agentNameFromSettings + const showGuestPassesUpsell = useShowGuestPassesUpsell() + const showOverageCreditUpsell = useShowOverageCreditUpsell() + + useEffect(() => { + if (showGuestPassesUpsell) { + incrementGuestPassesSeenCount() + } + }, [showGuestPassesUpsell]) + + useEffect(() => { + if (showOverageCreditUpsell && !showGuestPassesUpsell) { + incrementOverageCreditUpsellSeenCount() + } + }, [showOverageCreditUpsell, showGuestPassesUpsell]) + + // Calculate available width for text content + // Account for: condensed clawd width (11 chars) + gap (2) + padding (2) = 15 chars + const textWidth = Math.max(columns - 15, 20) + + // Truncate version to fit within available width, accounting for "Claude Code v" prefix + const versionPrefix = 'Claude Code v' + const truncatedVersion = truncate( version, - cwd, - billingType, - agentName: agentNameFromSettings - } = getLogoDisplayData(); - const agentName = agent ?? agentNameFromSettings; - const showGuestPassesUpsell = useShowGuestPassesUpsell(); - const showOverageCreditUpsell = useShowOverageCreditUpsell(); - let t0; - let t1; - if ($[0] !== showGuestPassesUpsell) { - t0 = () => { - if (showGuestPassesUpsell) { - incrementGuestPassesSeenCount(); - } - }; - t1 = [showGuestPassesUpsell]; - $[0] = showGuestPassesUpsell; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; - } - useEffect(t0, t1); - let t2; - let t3; - if ($[3] !== showGuestPassesUpsell || $[4] !== showOverageCreditUpsell) { - t2 = () => { - if (showOverageCreditUpsell && !showGuestPassesUpsell) { - incrementOverageCreditUpsellSeenCount(); - } - }; - t3 = [showOverageCreditUpsell, showGuestPassesUpsell]; - $[3] = showGuestPassesUpsell; - $[4] = showOverageCreditUpsell; - $[5] = t2; - $[6] = t3; - } else { - t2 = $[5]; - t3 = $[6]; - } - useEffect(t2, t3); - const textWidth = Math.max(columns - 15, 20); - const truncatedVersion = truncate(version, Math.max(textWidth - 13, 6)); - const effortSuffix = getEffortSuffix(model, effortValue); - const { - shouldSplit, - truncatedModel, - truncatedBilling - } = formatModelAndBilling(modelDisplayName + effortSuffix, billingType, textWidth); - const cwdAvailableWidth = agentName ? textWidth - 1 - stringWidth(agentName) - 3 : textWidth; - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = isFullscreenEnvEnabled() ? : ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Claude Code; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== truncatedVersion) { - t6 = {t5}{" "}v{truncatedVersion}; - $[9] = truncatedVersion; - $[10] = t6; - } else { - t6 = $[10]; - } - let t7; - if ($[11] !== shouldSplit || $[12] !== truncatedBilling || $[13] !== truncatedModel) { - t7 = shouldSplit ? <>{truncatedModel}{truncatedBilling} : {truncatedModel} · {truncatedBilling}; - $[11] = shouldSplit; - $[12] = truncatedBilling; - $[13] = truncatedModel; - $[14] = t7; - } else { - t7 = $[14]; - } - const t8 = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd; - let t9; - if ($[15] !== t8) { - t9 = {t8}; - $[15] = t8; - $[16] = t9; - } else { - t9 = $[16]; - } - let t10; - if ($[17] !== showGuestPassesUpsell) { - t10 = showGuestPassesUpsell && ; - $[17] = showGuestPassesUpsell; - $[18] = t10; - } else { - t10 = $[18]; - } - let t11; - if ($[19] !== showGuestPassesUpsell || $[20] !== showOverageCreditUpsell || $[21] !== textWidth) { - t11 = !showGuestPassesUpsell && showOverageCreditUpsell && ; - $[19] = showGuestPassesUpsell; - $[20] = showOverageCreditUpsell; - $[21] = textWidth; - $[22] = t11; - } else { - t11 = $[22]; - } - let t12; - if ($[23] !== t10 || $[24] !== t11 || $[25] !== t6 || $[26] !== t7 || $[27] !== t9) { - t12 = {t4}{t6}{t7}{t9}{t10}{t11}; - $[23] = t10; - $[24] = t11; - $[25] = t6; - $[26] = t7; - $[27] = t9; - $[28] = t12; - } else { - t12 = $[28]; - } - return t12; -} -function _temp2(s_0) { - return s_0.effortValue; -} -function _temp(s) { - return s.agent; + Math.max(textWidth - versionPrefix.length, 6), + ) + + const effortSuffix = getEffortSuffix(model, effortValue) + const { shouldSplit, truncatedModel, truncatedBilling } = + formatModelAndBilling( + modelDisplayName + effortSuffix, + billingType, + textWidth, + ) + + // Truncate path, accounting for agent name if present + const separator = ' · ' + const atPrefix = '@' + const cwdAvailableWidth = agentName + ? textWidth - atPrefix.length - stringWidth(agentName) - separator.length + : textWidth + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + + // OffscreenFreeze: the logo sits at the top of the message list and is the + // first thing to enter scrollback. useMainLoopModel() subscribes to model + // changes and getLogoDisplayData() reads getCwd()/subscription state — any + // of which changing while in scrollback would force a full terminal reset. + return ( + + + {isFullscreenEnvEnabled() ? : } + + {/* Info */} + + + Claude Code{' '} + v{truncatedVersion} + + {shouldSplit ? ( + <> + {truncatedModel} + {truncatedBilling} + + ) : ( + + {truncatedModel} · {truncatedBilling} + + )} + + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} + + {showGuestPassesUpsell && } + {!showGuestPassesUpsell && showOverageCreditUpsell && ( + + )} + + + + ) } diff --git a/src/components/LogoV2/EmergencyTip.tsx b/src/components/LogoV2/EmergencyTip.tsx index 014ef6f2b..c0a8235ba 100644 --- a/src/components/LogoV2/EmergencyTip.tsx +++ b/src/components/LogoV2/EmergencyTip.tsx @@ -1,57 +1,65 @@ -import * as React from 'react'; -import { useEffect, useMemo } from 'react'; -import { Box, Text } from 'src/ink.js'; -import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; -const CONFIG_NAME = 'tengu-top-of-feed-tip'; +import * as React from 'react' +import { useEffect, useMemo } from 'react' +import { Box, Text } from 'src/ink.js' +import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' + +const CONFIG_NAME = 'tengu-top-of-feed-tip' + export function EmergencyTip(): React.ReactNode { - const tip = useMemo(getTipOfFeed, []); + const tip = useMemo(getTipOfFeed, []) // Memoize to prevent re-reads after we save - we want the value at mount time - const lastShownTip = useMemo(() => getGlobalConfig().lastShownEmergencyTip, []); + const lastShownTip = useMemo( + () => getGlobalConfig().lastShownEmergencyTip, + [], + ) // Only show if this is a new/different tip - const shouldShow = tip.tip && tip.tip !== lastShownTip; + const shouldShow = tip.tip && tip.tip !== lastShownTip // Save the tip we're showing so we don't show it again useEffect(() => { if (shouldShow) { saveGlobalConfig(current => { - if (current.lastShownEmergencyTip === tip.tip) return current; - return { - ...current, - lastShownEmergencyTip: tip.tip - }; - }); + if (current.lastShownEmergencyTip === tip.tip) return current + return { ...current, lastShownEmergencyTip: tip.tip } + }) } - }, [shouldShow, tip.tip]); + }, [shouldShow, tip.tip]) + if (!shouldShow) { - return null; + return null } - return - + + return ( + + {tip.tip} - ; + + ) } + type TipOfFeed = { - tip: string; - color?: 'dim' | 'warning' | 'error'; -}; -const DEFAULT_TIP: TipOfFeed = { - tip: '', - color: 'dim' -}; + tip: string + color?: 'dim' | 'warning' | 'error' +} + +const DEFAULT_TIP: TipOfFeed = { tip: '', color: 'dim' } /** * Get the tip of the feed from dynamic config with caching * Returns cached value immediately, updates in background */ function getTipOfFeed(): TipOfFeed { - return getDynamicConfig_CACHED_MAY_BE_STALE(CONFIG_NAME, DEFAULT_TIP); + return getDynamicConfig_CACHED_MAY_BE_STALE( + CONFIG_NAME, + DEFAULT_TIP, + ) } diff --git a/src/components/LogoV2/Feed.tsx b/src/components/LogoV2/Feed.tsx index 853aae361..15a7d84d6 100644 --- a/src/components/LogoV2/Feed.tsx +++ b/src/components/LogoV2/Feed.tsx @@ -1,111 +1,113 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { truncate } from '../../utils/format.js'; +import * as React from 'react' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { truncate } from '../../utils/format.js' + export type FeedLine = { - text: string; - timestamp?: string; -}; + text: string + timestamp?: string +} + export type FeedConfig = { - title: string; - lines: FeedLine[]; - footer?: string; - emptyMessage?: string; - customContent?: { - content: React.ReactNode; - width: number; - }; -}; + title: string + lines: FeedLine[] + footer?: string + emptyMessage?: string + customContent?: { content: React.ReactNode; width: number } +} + type FeedProps = { - config: FeedConfig; - actualWidth: number; -}; + config: FeedConfig + actualWidth: number +} + export function calculateFeedWidth(config: FeedConfig): number { - const { - title, - lines, - footer, - emptyMessage, - customContent - } = config; - let maxWidth = stringWidth(title); + const { title, lines, footer, emptyMessage, customContent } = config + + let maxWidth = stringWidth(title) + if (customContent !== undefined) { - maxWidth = Math.max(maxWidth, customContent.width); + maxWidth = Math.max(maxWidth, customContent.width) } else if (lines.length === 0 && emptyMessage) { - maxWidth = Math.max(maxWidth, stringWidth(emptyMessage)); + maxWidth = Math.max(maxWidth, stringWidth(emptyMessage)) } else { - const gap = ' '; - const maxTimestampWidth = Math.max(0, ...lines.map(line => line.timestamp ? stringWidth(line.timestamp) : 0)); + const gap = ' ' + const maxTimestampWidth = Math.max( + 0, + ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)), + ) + for (const line of lines) { - const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0; - const lineWidth = stringWidth(line.text) + (timestampWidth > 0 ? timestampWidth + gap.length : 0); - maxWidth = Math.max(maxWidth, lineWidth); + const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0 + const lineWidth = + stringWidth(line.text) + + (timestampWidth > 0 ? timestampWidth + gap.length : 0) + maxWidth = Math.max(maxWidth, lineWidth) } } + if (footer) { - maxWidth = Math.max(maxWidth, stringWidth(footer)); - } - return maxWidth; -} -export function Feed(t0) { - const $ = _c(15); - const { - config, - actualWidth - } = t0; - const { - title, - lines, - footer, - emptyMessage, - customContent - } = config; - let t1; - if ($[0] !== lines) { - t1 = Math.max(0, ...lines.map(_temp)); - $[0] = lines; - $[1] = t1; - } else { - t1 = $[1]; - } - const maxTimestampWidth = t1; - let t2; - if ($[2] !== title) { - t2 = {title}; - $[2] = title; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== actualWidth || $[5] !== customContent || $[6] !== emptyMessage || $[7] !== footer || $[8] !== lines || $[9] !== maxTimestampWidth) { - t3 = customContent ? <>{customContent.content}{footer && {truncate(footer, actualWidth)}} : lines.length === 0 && emptyMessage ? {truncate(emptyMessage, actualWidth)} : <>{lines.map((line_0, index) => { - const textWidth = Math.max(10, actualWidth - (maxTimestampWidth > 0 ? maxTimestampWidth + 2 : 0)); - return {maxTimestampWidth > 0 && <>{(line_0.timestamp || "").padEnd(maxTimestampWidth)}{" "}}{truncate(line_0.text, textWidth)}; - })}{footer && {truncate(footer, actualWidth)}}; - $[4] = actualWidth; - $[5] = customContent; - $[6] = emptyMessage; - $[7] = footer; - $[8] = lines; - $[9] = maxTimestampWidth; - $[10] = t3; - } else { - t3 = $[10]; - } - let t4; - if ($[11] !== actualWidth || $[12] !== t2 || $[13] !== t3) { - t4 = {t2}{t3}; - $[11] = actualWidth; - $[12] = t2; - $[13] = t3; - $[14] = t4; - } else { - t4 = $[14]; + maxWidth = Math.max(maxWidth, stringWidth(footer)) } - return t4; + + return maxWidth } -function _temp(line) { - return line.timestamp ? stringWidth(line.timestamp) : 0; + +export function Feed({ config, actualWidth }: FeedProps): React.ReactNode { + const { title, lines, footer, emptyMessage, customContent } = config + + const gap = ' ' + const maxTimestampWidth = Math.max( + 0, + ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)), + ) + + return ( + + + {title} + + {customContent ? ( + <> + {customContent.content} + {footer && ( + + {truncate(footer, actualWidth)} + + )} + + ) : lines.length === 0 && emptyMessage ? ( + {truncate(emptyMessage, actualWidth)} + ) : ( + <> + {lines.map((line, index) => { + const textWidth = Math.max( + 10, + actualWidth - + (maxTimestampWidth > 0 ? maxTimestampWidth + gap.length : 0), + ) + + return ( + + {maxTimestampWidth > 0 && ( + <> + + {(line.timestamp || '').padEnd(maxTimestampWidth)} + + {gap} + + )} + {truncate(line.text, textWidth)} + + ) + })} + {footer && ( + + {truncate(footer, actualWidth)} + + )} + + )} + + ) } diff --git a/src/components/LogoV2/FeedColumn.tsx b/src/components/LogoV2/FeedColumn.tsx index bc68bc864..0b08ec84a 100644 --- a/src/components/LogoV2/FeedColumn.tsx +++ b/src/components/LogoV2/FeedColumn.tsx @@ -1,58 +1,32 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box } from '../../ink.js'; -import { Divider } from '../design-system/Divider.js'; -import type { FeedConfig } from './Feed.js'; -import { calculateFeedWidth, Feed } from './Feed.js'; +import * as React from 'react' +import { Box } from '../../ink.js' +import { Divider } from '../design-system/Divider.js' +import type { FeedConfig } from './Feed.js' +import { calculateFeedWidth, Feed } from './Feed.js' + type FeedColumnProps = { - feeds: FeedConfig[]; - maxWidth: number; -}; -export function FeedColumn(t0) { - const $ = _c(10); - const { - feeds, - maxWidth - } = t0; - let t1; - if ($[0] !== feeds) { - const feedWidths = feeds.map(_temp); - t1 = Math.max(...feedWidths); - $[0] = feeds; - $[1] = t1; - } else { - t1 = $[1]; - } - const maxOfAllFeeds = t1; - const actualWidth = Math.min(maxOfAllFeeds, maxWidth); - let t2; - if ($[2] !== actualWidth || $[3] !== feeds) { - let t3; - if ($[5] !== actualWidth || $[6] !== feeds.length) { - t3 = (feed_0, index) => {index < feeds.length - 1 && }; - $[5] = actualWidth; - $[6] = feeds.length; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = feeds.map(t3); - $[2] = actualWidth; - $[3] = feeds; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[8] !== t2) { - t3 = {t2}; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - return t3; + feeds: FeedConfig[] + maxWidth: number } -function _temp(feed) { - return calculateFeedWidth(feed); + +export function FeedColumn({ + feeds, + maxWidth, +}: FeedColumnProps): React.ReactNode { + const feedWidths = feeds.map(feed => calculateFeedWidth(feed)) + const maxOfAllFeeds = Math.max(...feedWidths) + const actualWidth = Math.min(maxOfAllFeeds, maxWidth) + + return ( + + {feeds.map((feed, index) => ( + + + {index < feeds.length - 1 && ( + + )} + + ))} + + ) } diff --git a/src/components/LogoV2/GuestPassesUpsell.tsx b/src/components/LogoV2/GuestPassesUpsell.tsx index a4ad5eb65..12796e43b 100644 --- a/src/components/LogoV2/GuestPassesUpsell.tsx +++ b/src/components/LogoV2/GuestPassesUpsell.tsx @@ -1,69 +1,73 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { checkCachedPassesEligibility, formatCreditAmount, getCachedReferrerReward, getCachedRemainingPasses } from '../../services/api/referral.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import * as React from 'react' +import { useState } from 'react' +import { Text } from '../../ink.js' +import { logEvent } from '../../services/analytics/index.js' +import { + checkCachedPassesEligibility, + formatCreditAmount, + getCachedReferrerReward, + getCachedRemainingPasses, +} from '../../services/api/referral.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' + function resetIfPassesRefreshed(): void { - const remaining = getCachedRemainingPasses(); - if (remaining == null || remaining <= 0) return; - const config = getGlobalConfig(); - const lastSeen = config.passesLastSeenRemaining ?? 0; + const remaining = getCachedRemainingPasses() + if (remaining == null || remaining <= 0) return + const config = getGlobalConfig() + const lastSeen = config.passesLastSeenRemaining ?? 0 if (remaining > lastSeen) { saveGlobalConfig(prev => ({ ...prev, passesUpsellSeenCount: 0, hasVisitedPasses: false, - passesLastSeenRemaining: remaining - })); + passesLastSeenRemaining: remaining, + })) } } + function shouldShowGuestPassesUpsell(): boolean { - const { - eligible, - hasCache - } = checkCachedPassesEligibility(); + const { eligible, hasCache } = checkCachedPassesEligibility() // Only show if eligible and cache exists (don't block on fetch) - if (!eligible || !hasCache) return false; + if (!eligible || !hasCache) return false // Reset upsell counters if passes were refreshed (covers both campaign change and pass refresh) - resetIfPassesRefreshed(); - const config = getGlobalConfig(); - if ((config.passesUpsellSeenCount ?? 0) >= 3) return false; - if (config.hasVisitedPasses) return false; - return true; -} -export function useShowGuestPassesUpsell() { - const [show] = useState(_temp); - return show; + resetIfPassesRefreshed() + + const config = getGlobalConfig() + if ((config.passesUpsellSeenCount ?? 0) >= 3) return false + if (config.hasVisitedPasses) return false + + return true } -function _temp() { - return shouldShowGuestPassesUpsell(); + +export function useShowGuestPassesUpsell(): boolean { + const [show] = useState(() => shouldShowGuestPassesUpsell()) + return show } + export function incrementGuestPassesSeenCount(): void { - let newCount = 0; + let newCount = 0 saveGlobalConfig(prev => { - newCount = (prev.passesUpsellSeenCount ?? 0) + 1; + newCount = (prev.passesUpsellSeenCount ?? 0) + 1 return { ...prev, - passesUpsellSeenCount: newCount - }; - }); + passesUpsellSeenCount: newCount, + } + }) logEvent('tengu_guest_passes_upsell_shown', { - seen_count: newCount - }); + seen_count: newCount, + }) } // Condensed layout for mini welcome screen -export function GuestPassesUpsell() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - const reward = getCachedReferrerReward(); - t0 = [✻] [✻]{" "}[✻] ·{" "}{reward ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage · /passes` : "3 guest passes at /passes"}; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +export function GuestPassesUpsell(): React.ReactNode { + const reward = getCachedReferrerReward() + return ( + + [✻] [✻]{' '} + [✻] ·{' '} + {reward + ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage · /passes` + : '3 guest passes at /passes'} + + ) } diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index 3d3359838..d65c24fe3 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -1,31 +1,55 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import * as React from 'react'; -import { Box, Text, color } from '../../ink.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { getLayoutMode, calculateLayoutDimensions, calculateOptimalLeftWidth, formatWelcomeMessage, truncatePath, getRecentActivitySync, getRecentReleaseNotesSync, getLogoDisplayData } from '../../utils/logoV2Utils.js'; -import { truncate } from '../../utils/format.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { Clawd } from './Clawd.js'; -import { FeedColumn } from './FeedColumn.js'; -import { createRecentActivityFeed, createWhatsNewFeed, createProjectOnboardingFeed, createGuestPassesFeed } from './feedConfigs.js'; -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; -import { resolveThemeSetting } from 'src/utils/systemTheme.js'; -import { getInitialSettings } from 'src/utils/settings/settings.js'; -import { isDebugMode, isDebugToStdErr, getDebugLogPath } from 'src/utils/debug.js'; -import { useEffect, useState } from 'react'; -import { getSteps, shouldShowProjectOnboarding, incrementProjectOnboardingSeenCount } from '../../projectOnboardingState.js'; -import { CondensedLogo } from './CondensedLogo.js'; -import { OffscreenFreeze } from '../OffscreenFreeze.js'; -import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js'; -import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js'; -import { isEnvTruthy } from 'src/utils/envUtils.js'; -import { getStartupPerfLogPath, isDetailedProfilingEnabled } from 'src/utils/startupProfiler.js'; -import { EmergencyTip } from './EmergencyTip.js'; -import { VoiceModeNotice } from './VoiceModeNotice.js'; -import { Opus1mMergeNotice } from './Opus1mMergeNotice.js'; -import { feature } from 'bun:bundle'; +import * as React from 'react' +import { Box, Text, color } from '../../ink.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { + getLayoutMode, + calculateLayoutDimensions, + calculateOptimalLeftWidth, + formatWelcomeMessage, + truncatePath, + getRecentActivitySync, + getRecentReleaseNotesSync, + getLogoDisplayData, +} from '../../utils/logoV2Utils.js' +import { truncate } from '../../utils/format.js' +import { getDisplayPath } from '../../utils/file.js' +import { Clawd } from './Clawd.js' +import { FeedColumn } from './FeedColumn.js' +import { + createRecentActivityFeed, + createWhatsNewFeed, + createProjectOnboardingFeed, + createGuestPassesFeed, +} from './feedConfigs.js' +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' +import { resolveThemeSetting } from 'src/utils/systemTheme.js' +import { getInitialSettings } from 'src/utils/settings/settings.js' +import { + isDebugMode, + isDebugToStdErr, + getDebugLogPath, +} from 'src/utils/debug.js' +import { useEffect, useState } from 'react' +import { + getSteps, + shouldShowProjectOnboarding, + incrementProjectOnboardingSeenCount, +} from '../../projectOnboardingState.js' +import { CondensedLogo } from './CondensedLogo.js' +import { OffscreenFreeze } from '../OffscreenFreeze.js' +import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js' +import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js' +import { isEnvTruthy } from 'src/utils/envUtils.js' +import { + getStartupPerfLogPath, + isDetailedProfilingEnabled, +} from 'src/utils/startupProfiler.js' +import { EmergencyTip } from './EmergencyTip.js' +import { VoiceModeNotice } from './VoiceModeNotice.js' +import { Opus1mMergeNotice } from './Opus1mMergeNotice.js' +import { feature } from 'bun:bundle' // Conditional require so ChannelsNotice.tsx tree-shakes when both flags are // false. A module-scope helper component inside a feature() ternary does NOT @@ -33,510 +57,444 @@ import { feature } from 'bun:bundle'; // whole file. VoiceModeNotice uses the unsafe helper pattern but VOICE_MODE // is external: true so it's moot there. /* eslint-disable @typescript-eslint/no-require-imports */ -const ChannelsNoticeModule = feature('KAIROS') || feature('KAIROS_CHANNELS') ? require('./ChannelsNotice.js') as typeof import('./ChannelsNotice.js') : null; +const ChannelsNoticeModule = + feature('KAIROS') || feature('KAIROS_CHANNELS') + ? (require('./ChannelsNotice.js') as typeof import('./ChannelsNotice.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; -import { useShowGuestPassesUpsell, incrementGuestPassesSeenCount } from './GuestPassesUpsell.js'; -import { useShowOverageCreditUpsell, incrementOverageCreditUpsellSeenCount, createOverageCreditFeed } from './OverageCreditUpsell.js'; -import { plural } from '../../utils/stringUtils.js'; -import { useAppState } from '../../state/AppState.js'; -import { getEffortSuffix } from '../../utils/effort.js'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { renderModelSetting } from '../../utils/model/model.js'; -const LEFT_PANEL_MAX_WIDTH = 50; -export function LogoV2() { - const $ = _c(94); - const activities = getRecentActivitySync(); - const username = getGlobalConfig().oauthAccount?.displayName ?? ""; - const { - columns - } = useTerminalSize(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = shouldShowProjectOnboarding(); - $[0] = t0; - } else { - t0 = $[0]; - } - const showOnboarding = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = SandboxManager.isSandboxingEnabled(); - $[1] = t1; - } else { - t1 = $[1]; - } - const showSandboxStatus = t1; - const showGuestPassesUpsell = useShowGuestPassesUpsell(); - const showOverageCreditUpsell = useShowOverageCreditUpsell(); - const agent = useAppState(_temp); - const effortValue = useAppState(_temp2); - const config = getGlobalConfig(); - let changelog; +import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' +import { + useShowGuestPassesUpsell, + incrementGuestPassesSeenCount, +} from './GuestPassesUpsell.js' +import { + useShowOverageCreditUpsell, + incrementOverageCreditUpsellSeenCount, + createOverageCreditFeed, +} from './OverageCreditUpsell.js' +import { plural } from '../../utils/stringUtils.js' +import { useAppState } from '../../state/AppState.js' +import { getEffortSuffix } from '../../utils/effort.js' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { renderModelSetting } from '../../utils/model/model.js' + +const LEFT_PANEL_MAX_WIDTH = 50 + +export function LogoV2(): React.ReactNode { + const activities = getRecentActivitySync() + const username = getGlobalConfig().oauthAccount?.displayName ?? '' + + const { columns } = useTerminalSize() + const showOnboarding = shouldShowProjectOnboarding() + const showSandboxStatus = SandboxManager.isSandboxingEnabled() + const showGuestPassesUpsell = useShowGuestPassesUpsell() + const showOverageCreditUpsell = useShowOverageCreditUpsell() + const agent = useAppState(s => s.agent) + const effortValue = useAppState(s => s.effortValue) + + const config = getGlobalConfig() + + let changelog: string[] try { - changelog = getRecentReleaseNotesSync(3); + changelog = getRecentReleaseNotesSync(3) } catch { - changelog = []; + changelog = [] } + + // Get company announcements and select one: + // - First startup (numStartups === 1): show first announcement + // - All other startups: randomly select from announcements const [announcement] = useState(() => { - const announcements = getInitialSettings().companyAnnouncements; - if (!announcements || announcements.length === 0) { - return; + const announcements = getInitialSettings().companyAnnouncements + if (!announcements || announcements.length === 0) return undefined + return config.numStartups === 1 + ? announcements[0] + : announcements[Math.floor(Math.random() * announcements.length)] + }) + const { hasReleaseNotes } = checkForReleaseNotesSync( + config.lastReleaseNotesSeen, + ) + + useEffect(() => { + const currentConfig = getGlobalConfig() + if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) { + return } - return config.numStartups === 1 ? announcements[0] : announcements[Math.floor(Math.random() * announcements.length)]; - }); - const { - hasReleaseNotes - } = checkForReleaseNotesSync(config.lastReleaseNotesSeen); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - const currentConfig = getGlobalConfig(); - if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) { - return; - } - saveGlobalConfig(_temp3); - if (showOnboarding) { - incrementProjectOnboardingSeenCount(); - } - }; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== config) { - t3 = [config, showOnboarding]; - $[3] = config; - $[4] = t3; - } else { - t3 = $[4]; - } - useEffect(t2, t3); - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = !hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO); - $[5] = t4; - } else { - t4 = $[5]; - } - const isCondensedMode = t4; - let t5; - let t6; - if ($[6] !== showGuestPassesUpsell) { - t5 = () => { - if (showGuestPassesUpsell && !showOnboarding && !isCondensedMode) { - incrementGuestPassesSeenCount(); - } - }; - t6 = [showGuestPassesUpsell, showOnboarding, isCondensedMode]; - $[6] = showGuestPassesUpsell; - $[7] = t5; - $[8] = t6; - } else { - t5 = $[7]; - t6 = $[8]; - } - useEffect(t5, t6); - let t7; - let t8; - if ($[9] !== showGuestPassesUpsell || $[10] !== showOverageCreditUpsell) { - t7 = () => { - if (showOverageCreditUpsell && !showOnboarding && !showGuestPassesUpsell && !isCondensedMode) { - incrementOverageCreditUpsellSeenCount(); - } - }; - t8 = [showOverageCreditUpsell, showOnboarding, showGuestPassesUpsell, isCondensedMode]; - $[9] = showGuestPassesUpsell; - $[10] = showOverageCreditUpsell; - $[11] = t7; - $[12] = t8; - } else { - t7 = $[11]; - t8 = $[12]; - } - useEffect(t7, t8); - const model = useMainLoopModel(); - const fullModelDisplayName = renderModelSetting(model); + saveGlobalConfig(current => { + if (current.lastReleaseNotesSeen === MACRO.VERSION) return current + return { ...current, lastReleaseNotesSeen: MACRO.VERSION } + }) + if (showOnboarding) { + incrementProjectOnboardingSeenCount() + } + }, [config, showOnboarding]) + + // In condensed mode (early-return below renders ), + // CondensedLogo's own useEffect handles the impression count. Skipping + // here avoids double-counting since hooks fire before the early return. + const isCondensedMode = + !hasReleaseNotes && + !showOnboarding && + !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) + + useEffect(() => { + if (showGuestPassesUpsell && !showOnboarding && !isCondensedMode) { + incrementGuestPassesSeenCount() + } + }, [showGuestPassesUpsell, showOnboarding, isCondensedMode]) + + useEffect(() => { + if ( + showOverageCreditUpsell && + !showOnboarding && + !showGuestPassesUpsell && + !isCondensedMode + ) { + incrementOverageCreditUpsellSeenCount() + } + }, [ + showOverageCreditUpsell, + showOnboarding, + showGuestPassesUpsell, + isCondensedMode, + ]) + + const model = useMainLoopModel() + const fullModelDisplayName = renderModelSetting(model) const { version, cwd, billingType, - agentName: agentNameFromSettings - } = getLogoDisplayData(); - const agentName = agent ?? agentNameFromSettings; - const effortSuffix = getEffortSuffix(model, effortValue); - const t9 = fullModelDisplayName + effortSuffix; - let t10; - if ($[13] !== t9) { - t10 = truncate(t9, LEFT_PANEL_MAX_WIDTH - 20); - $[13] = t9; - $[14] = t10; - } else { - t10 = $[14]; - } - const modelDisplayName = t10; - if (!hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO)) { - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t11 = ; - t12 = ; - t13 = ; - t14 = ChannelsNoticeModule && ; - t15 = isDebugMode() && Debug mode enabledLogging to: {isDebugToStdErr() ? "stderr" : getDebugLogPath()}; - t16 = ; - t17 = process.env.CLAUDE_CODE_TMUX_SESSION && tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}{process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`}; - $[15] = t11; - $[16] = t12; - $[17] = t13; - $[18] = t14; - $[19] = t15; - $[20] = t16; - $[21] = t17; - } else { - t11 = $[15]; - t12 = $[16]; - t13 = $[17]; - t14 = $[18]; - t15 = $[19]; - t16 = $[20]; - t17 = $[21]; - } - let t18; - if ($[22] !== announcement || $[23] !== config) { - t18 = announcement && {!process.env.IS_DEMO && config.oauthAccount?.organizationName && Message from {config.oauthAccount.organizationName}:}{announcement}; - $[22] = announcement; - $[23] = config; - $[24] = t18; - } else { - t18 = $[24]; - } - let t19; - let t20; - let t21; - let t22; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t19 = false && !process.env.DEMO_VERSION && Use /issue to report model behavior issues; - t20 = false && !process.env.DEMO_VERSION && [ANT-ONLY] Logs:API calls: {getDisplayPath(getDumpPromptsPath())}Debug logs: {getDisplayPath(getDebugLogPath())}{isDetailedProfilingEnabled() && Startup Perf: {getDisplayPath(getStartupPerfLogPath())}}; - t21 = false && ; - t22 = false && ; - $[25] = t19; - $[26] = t20; - $[27] = t21; - $[28] = t22; - } else { - t19 = $[25]; - t20 = $[26]; - t21 = $[27]; - t22 = $[28]; - } - let t23; - if ($[29] !== t18) { - t23 = <>{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t18}{t19}{t20}{t21}{t22}; - $[29] = t18; - $[30] = t23; - } else { - t23 = $[30]; - } - return t23; + agentName: agentNameFromSettings, + } = getLogoDisplayData() + // Prefer AppState.agent (set from --agent CLI flag) over settings + const agentName = agent ?? agentNameFromSettings + // -20 to account for the max length of subscription name " · Claude Enterprise". + const effortSuffix = getEffortSuffix(model, effortValue) + const modelDisplayName = truncate( + fullModelDisplayName + effortSuffix, + LEFT_PANEL_MAX_WIDTH - 20, + ) + + // Show condensed logo if no new changelog and not showing onboarding and not forcing full logo + if ( + !hasReleaseNotes && + !showOnboarding && + !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) + ) { + return ( + <> + + + + {ChannelsNoticeModule && } + {isDebugMode() && ( + + Debug mode enabled + + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} + + + )} + + {process.env.CLAUDE_CODE_TMUX_SESSION && ( + + + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} + + + {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS + ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` + : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`} + + + )} + {announcement && ( + + {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( + + Message from {config.oauthAccount.organizationName}: + + )} + {announcement} + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + Use /issue to report model behavior issues + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + [ANT-ONLY] Logs: + + API calls: {getDisplayPath(getDumpPromptsPath())} + + + Debug logs: {getDisplayPath(getDebugLogPath())} + + {isDetailedProfilingEnabled() && ( + + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} + + )} + + )} + {process.env.USER_TYPE === 'ant' && } + {process.env.USER_TYPE === 'ant' && } + + ) } - const layoutMode = getLayoutMode(columns); - const userTheme = resolveThemeSetting(getGlobalConfig().theme); - const borderTitle = ` ${color("claude", userTheme)("Claude Code")} ${color("inactive", userTheme)(`v${version}`)} `; - const compactBorderTitle = color("claude", userTheme)(" Claude Code "); - if (layoutMode === "compact") { - let welcomeMessage = formatWelcomeMessage(username); - if (stringWidth(welcomeMessage) > columns - 4) { - let t11; - if ($[31] === Symbol.for("react.memo_cache_sentinel")) { - t11 = formatWelcomeMessage(null); - $[31] = t11; - } else { - t11 = $[31]; - } - welcomeMessage = t11; - } - const cwdAvailableWidth = agentName ? columns - 4 - 1 - stringWidth(agentName) - 3 : columns - 4; - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); - let t11; - if ($[32] !== compactBorderTitle) { - t11 = { - content: compactBorderTitle, - position: "top", - align: "start", - offset: 1 - }; - $[32] = compactBorderTitle; - $[33] = t11; - } else { - t11 = $[33]; - } - let t12; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t12 = ; - $[34] = t12; - } else { - t12 = $[34]; - } - let t13; - if ($[35] !== modelDisplayName) { - t13 = {modelDisplayName}; - $[35] = modelDisplayName; - $[36] = t13; - } else { - t13 = $[36]; - } - let t14; - let t15; - let t16; - if ($[37] === Symbol.for("react.memo_cache_sentinel")) { - t14 = ; - t15 = ; - t16 = ChannelsNoticeModule && ; - $[37] = t14; - $[38] = t15; - $[39] = t16; - } else { - t14 = $[37]; - t15 = $[38]; - t16 = $[39]; - } - let t17; - if ($[40] !== showSandboxStatus) { - t17 = showSandboxStatus && Your bash commands will be sandboxed. Disable with /sandbox.; - $[40] = showSandboxStatus; - $[41] = t17; - } else { - t17 = $[41]; - } - let t18; - let t19; - if ($[42] === Symbol.for("react.memo_cache_sentinel")) { - t18 = false && ; - t19 = false && ; - $[42] = t18; - $[43] = t19; - } else { - t18 = $[42]; - t19 = $[43]; + + // Calculate layout and display values + const layoutMode = getLayoutMode(columns) + + const userTheme = resolveThemeSetting(getGlobalConfig().theme) + const borderTitle = ` ${color('claude', userTheme)('Claude Code')} ${color('inactive', userTheme)(`v${version}`)} ` + const compactBorderTitle = color('claude', userTheme)(' Claude Code ') + + // Early return for compact mode + if (layoutMode === 'compact') { + const layoutWidth = 4 // border + padding + let welcomeMessage = formatWelcomeMessage(username) + if (stringWidth(welcomeMessage) > columns - layoutWidth) { + welcomeMessage = formatWelcomeMessage(null) } - return <>{welcomeMessage}{t12}{t13}{billingType}{agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd}{t14}{t15}{t16}{t17}{t18}{t19}; - } - const welcomeMessage_0 = formatWelcomeMessage(username); - const modelLine = !process.env.IS_DEMO && config.oauthAccount?.organizationName ? `${modelDisplayName} · ${billingType} · ${config.oauthAccount.organizationName}` : `${modelDisplayName} · ${billingType}`; - const cwdAvailableWidth_0 = agentName ? LEFT_PANEL_MAX_WIDTH - 1 - stringWidth(agentName) - 3 : LEFT_PANEL_MAX_WIDTH; - const truncatedCwd_0 = truncatePath(cwd, Math.max(cwdAvailableWidth_0, 10)); - const cwdLine = agentName ? `@${agentName} · ${truncatedCwd_0}` : truncatedCwd_0; - const optimalLeftWidth = calculateOptimalLeftWidth(welcomeMessage_0, cwdLine, modelLine); - const { - leftWidth, - rightWidth - } = calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth); - const T0 = OffscreenFreeze; - const T1 = Box; - const t11 = "column"; - const t12 = "round"; - const t13 = "claude"; - let t14; - if ($[44] !== borderTitle) { - t14 = { - content: borderTitle, - position: "top", - align: "start", - offset: 3 - }; - $[44] = borderTitle; - $[45] = t14; - } else { - t14 = $[45]; - } - const T2 = Box; - const t15 = layoutMode === "horizontal" ? "row" : "column"; - const t16 = 1; - const t17 = 1; - let t18; - if ($[46] !== welcomeMessage_0) { - t18 = {welcomeMessage_0}; - $[46] = welcomeMessage_0; - $[47] = t18; - } else { - t18 = $[47]; - } - let t19; - if ($[48] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[48] = t19; - } else { - t19 = $[48]; - } - let t20; - if ($[49] !== modelLine) { - t20 = {modelLine}; - $[49] = modelLine; - $[50] = t20; - } else { - t20 = $[50]; - } - let t21; - if ($[51] !== cwdLine) { - t21 = {cwdLine}; - $[51] = cwdLine; - $[52] = t21; - } else { - t21 = $[52]; - } - let t22; - if ($[53] !== t20 || $[54] !== t21) { - t22 = {t20}{t21}; - $[53] = t20; - $[54] = t21; - $[55] = t22; - } else { - t22 = $[55]; - } - let t23; - if ($[56] !== leftWidth || $[57] !== t18 || $[58] !== t22) { - t23 = {t18}{t19}{t22}; - $[56] = leftWidth; - $[57] = t18; - $[58] = t22; - $[59] = t23; - } else { - t23 = $[59]; - } - let t24; - if ($[60] !== layoutMode) { - t24 = layoutMode === "horizontal" && ; - $[60] = layoutMode; - $[61] = t24; - } else { - t24 = $[61]; - } - const t25 = layoutMode === "horizontal" && ; - let t26; - if ($[62] !== T2 || $[63] !== t15 || $[64] !== t23 || $[65] !== t24 || $[66] !== t25) { - t26 = {t23}{t24}{t25}; - $[62] = T2; - $[63] = t15; - $[64] = t23; - $[65] = t24; - $[66] = t25; - $[67] = t26; - } else { - t26 = $[67]; - } - let t27; - if ($[68] !== T1 || $[69] !== t14 || $[70] !== t26) { - t27 = {t26}; - $[68] = T1; - $[69] = t14; - $[70] = t26; - $[71] = t27; - } else { - t27 = $[71]; - } - let t28; - if ($[72] !== T0 || $[73] !== t27) { - t28 = {t27}; - $[72] = T0; - $[73] = t27; - $[74] = t28; - } else { - t28 = $[74]; - } - let t29; - let t30; - let t31; - let t32; - let t33; - let t34; - if ($[75] === Symbol.for("react.memo_cache_sentinel")) { - t29 = ; - t30 = ; - t31 = ChannelsNoticeModule && ; - t32 = isDebugMode() && Debug mode enabledLogging to: {isDebugToStdErr() ? "stderr" : getDebugLogPath()}; - t33 = ; - t34 = process.env.CLAUDE_CODE_TMUX_SESSION && tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}{process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`}; - $[75] = t29; - $[76] = t30; - $[77] = t31; - $[78] = t32; - $[79] = t33; - $[80] = t34; - } else { - t29 = $[75]; - t30 = $[76]; - t31 = $[77]; - t32 = $[78]; - t33 = $[79]; - t34 = $[80]; - } - let t35; - if ($[81] !== announcement || $[82] !== config) { - t35 = announcement && {!process.env.IS_DEMO && config.oauthAccount?.organizationName && Message from {config.oauthAccount.organizationName}:}{announcement}; - $[81] = announcement; - $[82] = config; - $[83] = t35; - } else { - t35 = $[83]; - } - let t36; - if ($[84] !== showSandboxStatus) { - t36 = showSandboxStatus && Your bash commands will be sandboxed. Disable with /sandbox.; - $[84] = showSandboxStatus; - $[85] = t36; - } else { - t36 = $[85]; - } - let t37; - let t38; - let t39; - let t40; - if ($[86] === Symbol.for("react.memo_cache_sentinel")) { - t37 = false && !process.env.DEMO_VERSION && Use /issue to report model behavior issues; - t38 = false && !process.env.DEMO_VERSION && [ANT-ONLY] Logs:API calls: {getDisplayPath(getDumpPromptsPath())}Debug logs: {getDisplayPath(getDebugLogPath())}{isDetailedProfilingEnabled() && Startup Perf: {getDisplayPath(getStartupPerfLogPath())}}; - t39 = false && ; - t40 = false && ; - $[86] = t37; - $[87] = t38; - $[88] = t39; - $[89] = t40; - } else { - t37 = $[86]; - t38 = $[87]; - t39 = $[88]; - t40 = $[89]; - } - let t41; - if ($[90] !== t28 || $[91] !== t35 || $[92] !== t36) { - t41 = <>{t28}{t29}{t30}{t31}{t32}{t33}{t34}{t35}{t36}{t37}{t38}{t39}{t40}; - $[90] = t28; - $[91] = t35; - $[92] = t36; - $[93] = t41; - } else { - t41 = $[93]; - } - return t41; -} -function _temp3(current) { - if (current.lastReleaseNotesSeen === MACRO.VERSION) { - return current; + + // Calculate cwd width accounting for agent name if present + const separator = ' · ' + const atPrefix = '@' + const cwdAvailableWidth = agentName + ? columns - + layoutWidth - + atPrefix.length - + stringWidth(agentName) - + separator.length + : columns - layoutWidth + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + // OffscreenFreeze: logo is the first thing to enter scrollback; useMainLoopModel() + // subscribes to model changes and getLogoDisplayData() reads cwd/subscription — + // any change while in scrollback forces a full reset. + return ( + <> + + + {welcomeMessage} + + + + {modelDisplayName} + {billingType} + + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} + + + + + + {ChannelsNoticeModule && } + {showSandboxStatus && ( + + + Your bash commands will be sandboxed. Disable with /sandbox. + + + )} + {process.env.USER_TYPE === 'ant' && } + {process.env.USER_TYPE === 'ant' && } + + ) } - return { - ...current, - lastReleaseNotesSeen: MACRO.VERSION - }; -} -function _temp2(s_0) { - return s_0.effortValue; -} -function _temp(s) { - return s.agent; + + const welcomeMessage = formatWelcomeMessage(username) + const modelLine = + !process.env.IS_DEMO && config.oauthAccount?.organizationName + ? `${modelDisplayName} · ${billingType} · ${config.oauthAccount.organizationName}` + : `${modelDisplayName} · ${billingType}` + // Calculate cwd width accounting for agent name if present + const cwdSeparator = ' · ' + const cwdAtPrefix = '@' + const cwdAvailableWidth = agentName + ? LEFT_PANEL_MAX_WIDTH - + cwdAtPrefix.length - + stringWidth(agentName) - + cwdSeparator.length + : LEFT_PANEL_MAX_WIDTH + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd + const optimalLeftWidth = calculateOptimalLeftWidth( + welcomeMessage, + cwdLine, + modelLine, + ) + + // Calculate layout dimensions + const { leftWidth, rightWidth } = calculateLayoutDimensions( + columns, + layoutMode, + optimalLeftWidth, + ) + + return ( + <> + + + {/* Main content */} + + {/* Left Panel */} + + + {welcomeMessage} + + + + + + {modelLine} + {cwdLine} + + + + {/* Vertical divider */} + {layoutMode === 'horizontal' && ( + + )} + + {/* Right Panel - Project Onboarding or Recent Activity and What's New */} + {layoutMode === 'horizontal' && ( + + )} + + + + + + {ChannelsNoticeModule && } + {isDebugMode() && ( + + Debug mode enabled + + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} + + + )} + + {process.env.CLAUDE_CODE_TMUX_SESSION && ( + + + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} + + + {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS + ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` + : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`} + + + )} + {announcement && ( + + {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( + + Message from {config.oauthAccount.organizationName}: + + )} + {announcement} + + )} + {showSandboxStatus && ( + + + Your bash commands will be sandboxed. Disable with /sandbox. + + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + Use /issue to report model behavior issues + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + [ANT-ONLY] Logs: + + API calls: {getDisplayPath(getDumpPromptsPath())} + + Debug logs: {getDisplayPath(getDebugLogPath())} + {isDetailedProfilingEnabled() && ( + + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} + + )} + + )} + {process.env.USER_TYPE === 'ant' && } + {process.env.USER_TYPE === 'ant' && } + + ) } + diff --git a/src/components/LogoV2/Opus1mMergeNotice.tsx b/src/components/LogoV2/Opus1mMergeNotice.tsx index f07efc672..63c42ab66 100644 --- a/src/components/LogoV2/Opus1mMergeNotice.tsx +++ b/src/components/LogoV2/Opus1mMergeNotice.tsx @@ -1,54 +1,41 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { UP_ARROW } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { isOpus1mMergeEnabled } from '../../utils/model/model.js'; -import { AnimatedAsterisk } from './AnimatedAsterisk.js'; -const MAX_SHOW_COUNT = 6; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { UP_ARROW } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { isOpus1mMergeEnabled } from '../../utils/model/model.js' +import { AnimatedAsterisk } from './AnimatedAsterisk.js' + +const MAX_SHOW_COUNT = 6 + export function shouldShowOpus1mMergeNotice(): boolean { - return isOpus1mMergeEnabled() && (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT; + return ( + isOpus1mMergeEnabled() && + (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT + ) } -export function Opus1mMergeNotice() { - const $ = _c(4); - const [show] = useState(shouldShowOpus1mMergeNotice); - let t0; - let t1; - if ($[0] !== show) { - t0 = () => { - if (!show) { - return; - } - const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1; - saveGlobalConfig(prev => { - if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) { - return prev; - } - return { - ...prev, - opus1mMergeNoticeSeenCount: newCount - }; - }); - }; - t1 = [show]; - $[0] = show; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; - } - useEffect(t0, t1); - if (!show) { - return null; - } - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {" "}Opus now defaults to 1M context · 5x more room, same pricing; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; + +export function Opus1mMergeNotice(): React.ReactNode { + const [show] = useState(shouldShowOpus1mMergeNotice) + + useEffect(() => { + if (!show) return + const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1 + saveGlobalConfig(prev => { + if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) return prev + return { ...prev, opus1mMergeNoticeSeenCount: newCount } + }) + }, [show]) + + if (!show) return null + + return ( + + + + {' '} + Opus now defaults to 1M context · 5x more room, same pricing + + + ) } diff --git a/src/components/LogoV2/OverageCreditUpsell.tsx b/src/components/LogoV2/OverageCreditUpsell.tsx index fd5e6dea8..ce006e0d0 100644 --- a/src/components/LogoV2/OverageCreditUpsell.tsx +++ b/src/components/LogoV2/OverageCreditUpsell.tsx @@ -1,13 +1,17 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { formatGrantAmount, getCachedOverageCreditGrant, refreshOverageCreditGrantCache } from '../../services/api/overageCreditGrant.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { truncate } from '../../utils/format.js'; -import type { FeedConfig } from './Feed.js'; -const MAX_IMPRESSIONS = 3; +import * as React from 'react' +import { useState } from 'react' +import { Text } from '../../ink.js' +import { logEvent } from '../../services/analytics/index.js' +import { + formatGrantAmount, + getCachedOverageCreditGrant, + refreshOverageCreditGrantCache, +} from '../../services/api/overageCreditGrant.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { truncate } from '../../utils/format.js' +import type { FeedConfig } from './Feed.js' + +const MAX_IMPRESSIONS = 3 /** * Whether to show the overage credit upsell on any surface. @@ -25,16 +29,20 @@ const MAX_IMPRESSIONS = 3; * (welcome feed, tips). */ export function isEligibleForOverageCreditGrant(): boolean { - const info = getCachedOverageCreditGrant(); - if (!info || !info.available || info.granted) return false; - return formatGrantAmount(info) !== null; + const info = getCachedOverageCreditGrant() + if (!info || !info.available || info.granted) return false + return formatGrantAmount(info) !== null } + export function shouldShowOverageCreditUpsell(): boolean { - if (!isEligibleForOverageCreditGrant()) return false; - const config = getGlobalConfig(); - if (config.hasVisitedExtraUsage) return false; - if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS) return false; - return true; + if (!isEligibleForOverageCreditGrant()) return false + + const config = getGlobalConfig() + if (config.hasVisitedExtraUsage) return false + if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS) + return false + + return true } /** @@ -42,105 +50,78 @@ export function shouldShowOverageCreditUpsell(): boolean { * unconditionally on mount — it no-ops if cache is fresh. */ export function maybeRefreshOverageCreditCache(): void { - if (getCachedOverageCreditGrant() !== null) return; - void refreshOverageCreditGrantCache(); -} -export function useShowOverageCreditUpsell() { - const [show] = useState(_temp); - return show; + if (getCachedOverageCreditGrant() !== null) return + void refreshOverageCreditGrantCache() } -function _temp() { - maybeRefreshOverageCreditCache(); - return shouldShowOverageCreditUpsell(); + +export function useShowOverageCreditUpsell(): boolean { + const [show] = useState(() => { + maybeRefreshOverageCreditCache() + return shouldShowOverageCreditUpsell() + }) + return show } + export function incrementOverageCreditUpsellSeenCount(): void { - let newCount = 0; + let newCount = 0 saveGlobalConfig(prev => { - newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1; + newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1 return { ...prev, - overageCreditUpsellSeenCount: newCount - }; - }); - logEvent('tengu_overage_credit_upsell_shown', { - seen_count: newCount - }); + overageCreditUpsellSeenCount: newCount, + } + }) + logEvent('tengu_overage_credit_upsell_shown', { seen_count: newCount }) } // Copy from "OC & Bulk Overages copy" doc (#6 — CLI /usage) function getUsageText(amount: string): string { - return `${amount} in extra usage for third-party apps · /extra-usage`; + return `${amount} in extra usage for third-party apps · /extra-usage` } // Copy from "OC & Bulk Overages copy" doc (#4 — CLI Welcome screen). // Char budgets: title ≤19, subtitle ≤48. -const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage'; +const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage' + function getFeedTitle(amount: string): string { - return `${amount} in extra usage`; + return `${amount} in extra usage` } -type Props = { - maxWidth?: number; - twoLine?: boolean; -}; -export function OverageCreditUpsell(t0) { - const $ = _c(8); - const { - maxWidth, - twoLine - } = t0; - let t1; - let t2; - if ($[0] !== maxWidth || $[1] !== twoLine) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const info = getCachedOverageCreditGrant(); - if (!info) { - t2 = null; - break bb0; - } - const amount = formatGrantAmount(info); - if (!amount) { - t2 = null; - break bb0; - } - if (twoLine) { - const title = getFeedTitle(amount); - let t3; - if ($[4] !== maxWidth) { - t3 = maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE; - $[4] = maxWidth; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== t3) { - t4 = {t3}; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - t2 = <>{maxWidth ? truncate(title, maxWidth) : title}{t4}; - break bb0; - } - const text = getUsageText(amount); - const display = maxWidth ? truncate(text, maxWidth) : text; - const highlightLen = Math.min(getFeedTitle(amount).length, display.length); - t1 = {display.slice(0, highlightLen)}{display.slice(highlightLen)}; - } - $[0] = maxWidth; - $[1] = twoLine; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; + +type Props = { maxWidth?: number; twoLine?: boolean } + +export function OverageCreditUpsell({ + maxWidth, + twoLine, +}: Props): React.ReactNode { + const info = getCachedOverageCreditGrant() + if (!info) return null + const amount = formatGrantAmount(info) + if (!amount) return null + + if (twoLine) { + const title = getFeedTitle(amount) + return ( + <> + + {maxWidth ? truncate(title, maxWidth) : title} + + + {maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE} + + + ) } - return t1; + + const text = getUsageText(amount) + const display = maxWidth ? truncate(text, maxWidth) : text + const highlightLen = Math.min(getFeedTitle(amount).length, display.length) + + return ( + + {display.slice(0, highlightLen)} + {display.slice(highlightLen)} + + ) } /** @@ -151,15 +132,15 @@ export function OverageCreditUpsell(t0) { * Char budgets: title ≤19, subtitle ≤48. */ export function createOverageCreditFeed(): FeedConfig { - const info = getCachedOverageCreditGrant(); - const amount = info ? formatGrantAmount(info) : null; - const title = amount ? getFeedTitle(amount) : 'extra usage credit'; + const info = getCachedOverageCreditGrant() + const amount = info ? formatGrantAmount(info) : null + const title = amount ? getFeedTitle(amount) : 'extra usage credit' return { title, lines: [], customContent: { content: {FEED_SUBTITLE}, - width: Math.max(title.length, FEED_SUBTITLE.length) - } - }; + width: Math.max(title.length, FEED_SUBTITLE.length), + }, + } } diff --git a/src/components/LogoV2/VoiceModeNotice.tsx b/src/components/LogoV2/VoiceModeNotice.tsx index 5028ac75f..531460533 100644 --- a/src/components/LogoV2/VoiceModeNotice.tsx +++ b/src/components/LogoV2/VoiceModeNotice.tsx @@ -1,67 +1,51 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js'; -import { AnimatedAsterisk } from './AnimatedAsterisk.js'; -import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js'; -const MAX_SHOW_COUNT = 3; -export function VoiceModeNotice() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = feature("VOICE_MODE") ? : null; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js' +import { AnimatedAsterisk } from './AnimatedAsterisk.js' +import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js' + +const MAX_SHOW_COUNT = 3 + +export function VoiceModeNotice(): React.ReactNode { + // Positive ternary pattern — see docs/feature-gating.md. + // All strings must be inside the guarded branch for dead-code elimination. + return feature('VOICE_MODE') ? : null } -function VoiceModeNoticeInner() { - const $ = _c(4); - const [show] = useState(_temp); - let t0; - let t1; - if ($[0] !== show) { - t0 = () => { - if (!show) { - return; - } - const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1; - saveGlobalConfig(prev => { - if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) { - return prev; - } - return { - ...prev, - voiceNoticeSeenCount: newCount - }; - }); - }; - t1 = [show]; - $[0] = show; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; - } - useEffect(t0, t1); - if (!show) { - return null; - } - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Voice mode is now available · /voice to enable; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; -} -function _temp() { - return isVoiceModeEnabled() && getInitialSettings().voiceEnabled !== true && (getGlobalConfig().voiceNoticeSeenCount ?? 0) < MAX_SHOW_COUNT && !shouldShowOpus1mMergeNotice(); + +function VoiceModeNoticeInner(): React.ReactNode { + // Capture eligibility once at mount — no reactive subscriptions. This sits + // at the top of the message list and enters scrollback quickly; any + // re-render after it's in scrollback would force a full terminal reset. + // If the user runs /voice this session, the notice stays visible; it won't + // show next session since voiceEnabled will be true on disk. + const [show] = useState( + () => + isVoiceModeEnabled() && + getInitialSettings().voiceEnabled !== true && + (getGlobalConfig().voiceNoticeSeenCount ?? 0) < MAX_SHOW_COUNT && + !shouldShowOpus1mMergeNotice(), + ) + + useEffect(() => { + if (!show) return + // Capture outside the updater so StrictMode's second invocation is a no-op. + const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1 + saveGlobalConfig(prev => { + if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) return prev + return { ...prev, voiceNoticeSeenCount: newCount } + }) + }, [show]) + + if (!show) return null + + return ( + + + Voice mode is now available · /voice to enable + + ) } diff --git a/src/components/LogoV2/WelcomeV2.tsx b/src/components/LogoV2/WelcomeV2.tsx index 0094ef170..354e1182b 100644 --- a/src/components/LogoV2/WelcomeV2.tsx +++ b/src/components/LogoV2/WelcomeV2.tsx @@ -1,432 +1,326 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text, useTheme } from 'src/ink.js'; -import { env } from '../../utils/env.js'; -const WELCOME_V2_WIDTH = 58; -export function WelcomeV2() { - const $ = _c(35); - const [theme] = useTheme(); - if (env.terminal === "Apple_Terminal") { - let t0; - if ($[0] !== theme) { - t0 = ; - $[0] = theme; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +import React from 'react' +import { Box, Text, useTheme } from 'src/ink.js' +import { env } from '../../utils/env.js' + +const WELCOME_V2_WIDTH = 58 + +export function WelcomeV2(): React.ReactNode { + const [theme] = useTheme() + const welcomeMessage = 'Welcome to Claude Code' + + if (env.terminal === 'Apple_Terminal') { + return ( + + ) } - if (["light", "light-daltonized", "light-ansi"].includes(theme)) { - let t0; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t0 = {"Welcome to Claude Code"} v{MACRO.VERSION} ; - t1 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t2 = {" "}; - t3 = {" "}; - t4 = {" "}; - t5 = {" \u2591\u2591\u2591\u2591\u2591\u2591 "}; - t6 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t7 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t8 = {" "}; - $[2] = t0; - $[3] = t1; - $[4] = t2; - $[5] = t3; - $[6] = t4; - $[7] = t5; - $[8] = t6; - $[9] = t7; - $[10] = t8; - } else { - t0 = $[2]; - t1 = $[3]; - t2 = $[4]; - t3 = $[5]; - t4 = $[6]; - t5 = $[7]; - t6 = $[8]; - t7 = $[9]; - t8 = $[10]; - } - let t9; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t9 = {" \u2591\u2591\u2591\u2591"}{" \u2588\u2588 "}; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - let t11; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591"}{" \u2588\u2588\u2592\u2592\u2588\u2588 "}; - t11 = {" \u2592\u2592 \u2588\u2588 \u2592"}; - $[12] = t10; - $[13] = t11; - } else { - t10 = $[12]; - t11 = $[13]; - } - let t12; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t12 = {" "} █████████ {" \u2592\u2592\u2591\u2591\u2592\u2592 \u2592 \u2592\u2592"}; - $[14] = t12; - } else { - t12 = $[14]; - } - let t13; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {" "}██▄█████▄██{" \u2592\u2592 \u2592\u2592 "}; - $[15] = t13; - } else { - t13 = $[15]; - } - let t14; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t14 = {" "} █████████ {" \u2591 \u2592 "}; - $[16] = t14; - } else { - t14 = $[16]; - } - let t15; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {t0}{t1}{t2}{t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}{"\u2588 \u2588 \u2588 \u2588"}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2591\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2592\u2026\u2026\u2026\u2026"}; - $[17] = t15; - } else { - t15 = $[17]; - } - return t15; + + if (['light', 'light-daltonized', 'light-ansi'].includes(theme)) { + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' '} + + + {' '} + + + {' ░░░░░░ '} + + + {' ░░░ ░░░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + + + {' '} + + + {' ░░░░'} + {' ██ '} + + + {' ░░░░░░░░░░'} + {' ██▒▒██ '} + + + {' ▒▒ ██ ▒'} + + + {' '} + █████████ + {' ▒▒░░▒▒ ▒ ▒▒'} + + + {' '} + + ██▄█████▄██ + + {' ▒▒ ▒▒ '} + + + {' '} + █████████ + {' ░ ▒ '} + + + {'…………………'} + {'█ █ █ █'} + {'……………………………………………………………………░…………………………▒…………'} + + + + ) } - let t0; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t0 = {"Welcome to Claude Code"} v{MACRO.VERSION} ; - t1 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t2 = {" "}; - t3 = {" * \u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t4 = {" * \u2588\u2588\u2588\u2593\u2591 \u2591\u2591 "}; - t5 = {" \u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - t6 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - $[18] = t0; - $[19] = t1; - $[20] = t2; - $[21] = t3; - $[22] = t4; - $[23] = t5; - $[24] = t6; - } else { - t0 = $[18]; - t1 = $[19]; - t2 = $[20]; - t3 = $[21]; - t4 = $[22]; - t5 = $[23]; - t6 = $[24]; - } - let t10; - let t11; - let t7; - let t8; - let t9; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t7 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}*{" \u2588\u2588\u2593\u2591\u2591 \u2593 "}; - t8 = {" \u2591\u2593\u2593\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t9 = {" * \u2591\u2591\u2591\u2591 "}; - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t11 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - $[25] = t10; - $[26] = t11; - $[27] = t7; - $[28] = t8; - $[29] = t9; - } else { - t10 = $[25]; - t11 = $[26]; - t7 = $[27]; - t8 = $[28]; - t9 = $[29]; - } - let t12; - if ($[30] === Symbol.for("react.memo_cache_sentinel")) { - t12 = █████████ ; - $[30] = t12; - } else { - t12 = $[30]; - } - let t13; - if ($[31] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {" "}{t12}{" "}* ; - $[31] = t13; - } else { - t13 = $[31]; - } - let t14; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t14 = {" "}██▄█████▄██{" "}*{" "}; - $[32] = t14; - } else { - t14 = $[32]; - } - let t15; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {" "} █████████ {" * "}; - $[33] = t15; - } else { - t15 = $[33]; - } - let t16; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t16 = {t0}{t1}{t2}{t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t13}{t14}{t15}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}{"\u2588 \u2588 \u2588 \u2588"}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - $[34] = t16; - } else { - t16 = $[34]; - } - return t16; + + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' * █████▓▓░ '} + + + {' * ███▓░ ░░ '} + + + {' ░░░░░░ ███▓░ '} + + + {' ░░░ ░░░░░░░░░░ ███▓░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + * + {' ██▓░░ ▓ '} + + + {' ░▓▓███▓▓░ '} + + + {' * ░░░░ '} + + + {' ░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░ '} + + + {' '} + █████████ + {' '} + * + + + + {' '} + ██▄█████▄██ + {' '} + * + {' '} + + + {' '} + █████████ + {' * '} + + + {'…………………'} + {'█ █ █ █'} + {'………………………………………………………………………………………………………………'} + + + + ) } + type AppleTerminalWelcomeV2Props = { - theme: string; - welcomeMessage: string; -}; -function AppleTerminalWelcomeV2(t0) { - const $ = _c(44); - const { + theme: string + welcomeMessage: string +} + +function AppleTerminalWelcomeV2({ + theme, + welcomeMessage, +}: AppleTerminalWelcomeV2Props): React.ReactNode { + const isLightTheme = ['light', 'light-daltonized', 'light-ansi'].includes( theme, - welcomeMessage - } = t0; - const isLightTheme = ["light", "light-daltonized", "light-ansi"].includes(theme); + ) + if (isLightTheme) { - let t1; - if ($[0] !== welcomeMessage) { - t1 = {welcomeMessage} ; - $[0] = welcomeMessage; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = v{MACRO.VERSION} ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t1) { - t3 = {t1}{t2}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - let t10; - let t11; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t5 = {" "}; - t6 = {" "}; - t7 = {" "}; - t8 = {" \u2591\u2591\u2591\u2591\u2591\u2591 "}; - t9 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t11 = {" "}; - $[5] = t10; - $[6] = t11; - $[7] = t4; - $[8] = t5; - $[9] = t6; - $[10] = t7; - $[11] = t8; - $[12] = t9; - } else { - t10 = $[5]; - t11 = $[6]; - t4 = $[7]; - t5 = $[8]; - t6 = $[9]; - t7 = $[10]; - t8 = $[11]; - t9 = $[12]; - } - let t12; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t12 = {" \u2591\u2591\u2591\u2591"}{" \u2588\u2588 "}; - $[13] = t12; - } else { - t12 = $[13]; - } - let t13; - let t14; - let t15; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591"}{" \u2588\u2588\u2592\u2592\u2588\u2588 "}; - t14 = {" \u2592\u2592 \u2588\u2588 \u2592"}; - t15 = {" \u2592\u2592\u2591\u2591\u2592\u2592 \u2592 \u2592\u2592"}; - $[14] = t13; - $[15] = t14; - $[16] = t15; - } else { - t13 = $[14]; - t14 = $[15]; - t15 = $[16]; - } - let t16; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t16 = {" "}{" "}▗{" "}▖{" "}{" \u2592\u2592 \u2592\u2592 "}; - $[17] = t16; - } else { - t16 = $[17]; - } - let t17; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t17 = {" "}{" ".repeat(9)}{" \u2591 \u2592 "}; - $[18] = t17; - } else { - t17 = $[18]; - } - let t18; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t18 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"} {" "} {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2591\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2592\u2026\u2026\u2026\u2026"}; - $[19] = t18; - } else { - t18 = $[19]; - } - let t19; - if ($[20] !== t3) { - t19 = {t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t18}; - $[20] = t3; - $[21] = t19; - } else { - t19 = $[21]; - } - return t19; - } - let t1; - if ($[22] !== welcomeMessage) { - t1 = {welcomeMessage} ; - $[22] = welcomeMessage; - $[23] = t1; - } else { - t1 = $[23]; - } - let t2; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t2 = v{MACRO.VERSION} ; - $[24] = t2; - } else { - t2 = $[24]; - } - let t3; - if ($[25] !== t1) { - t3 = {t1}{t2}; - $[25] = t1; - $[26] = t3; - } else { - t3 = $[26]; - } - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t5 = {" "}; - t6 = {" * \u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t7 = {" * \u2588\u2588\u2588\u2593\u2591 \u2591\u2591 "}; - t8 = {" \u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - t9 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - $[27] = t4; - $[28] = t5; - $[29] = t6; - $[30] = t7; - $[31] = t8; - $[32] = t9; - } else { - t4 = $[27]; - t5 = $[28]; - t6 = $[29]; - t7 = $[30]; - t8 = $[31]; - t9 = $[32]; - } - let t10; - let t11; - let t12; - let t13; - let t14; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}*{" \u2588\u2588\u2593\u2591\u2591 \u2593 "}; - t11 = {" \u2591\u2593\u2593\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t12 = {" * \u2591\u2591\u2591\u2591 "}; - t13 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t14 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - $[33] = t10; - $[34] = t11; - $[35] = t12; - $[36] = t13; - $[37] = t14; - } else { - t10 = $[33]; - t11 = $[34]; - t12 = $[35]; - t13 = $[36]; - t14 = $[37]; - } - let t15; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {" "}* ; - $[38] = t15; - } else { - t15 = $[38]; - } - let t16; - if ($[39] === Symbol.for("react.memo_cache_sentinel")) { - t16 = {" "}{" "}▗{" "}▖{" "}{" "}*{" "}; - $[39] = t16; - } else { - t16 = $[39]; - } - let t17; - if ($[40] === Symbol.for("react.memo_cache_sentinel")) { - t17 = {" "}{" ".repeat(9)}{" * "}; - $[40] = t17; - } else { - t17 = $[40]; - } - let t18; - if ($[41] === Symbol.for("react.memo_cache_sentinel")) { - t18 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"} {" "} {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - $[41] = t18; - } else { - t18 = $[41]; - } - let t19; - if ($[42] !== t3) { - t19 = {t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t18}; - $[42] = t3; - $[43] = t19; - } else { - t19 = $[43]; + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' '} + + + {' '} + + + {' ░░░░░░ '} + + + {' ░░░ ░░░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + + + {' '} + + + {' ░░░░'} + {' ██ '} + + + {' ░░░░░░░░░░'} + {' ██▒▒██ '} + + + {' ▒▒ ██ ▒'} + + + {' ▒▒░░▒▒ ▒ ▒▒'} + + + {' '} + + + {' '} + ▗{' '}▖{' '} + + + {' ▒▒ ▒▒ '} + + + {' '} + {' '.repeat(9)} + {' ░ ▒ '} + + + {'…………………'} + + + + {' '} + + + + {'……………………………………………………………………░…………………………▒…………'} + + + + ) } - return t19; + + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' * █████▓▓░ '} + + + {' * ███▓░ ░░ '} + + + {' ░░░░░░ ███▓░ '} + + + {' ░░░ ░░░░░░░░░░ ███▓░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + * + {' ██▓░░ ▓ '} + + + {' ░▓▓███▓▓░ '} + + + {' * ░░░░ '} + + + {' ░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░ '} + + + {' '} + * + + + + {' '} + + + {' '} + ▗{' '}▖{' '} + + + {' '} + * + {' '} + + + {' '} + {' '.repeat(9)} + {' * '} + + + {'…………………'} + + + + {' '} + + + + {'………………………………………………………………………………………………………………'} + + + + ) } diff --git a/src/components/LogoV2/feedConfigs.tsx b/src/components/LogoV2/feedConfigs.tsx index cf8841967..50ec4575c 100644 --- a/src/components/LogoV2/feedConfigs.tsx +++ b/src/components/LogoV2/feedConfigs.tsx @@ -1,91 +1,117 @@ -import figures from 'figures'; -import { homedir } from 'os'; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import type { Step } from '../../projectOnboardingState.js'; -import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/referral.js'; -import type { LogOption } from '../../types/logs.js'; -import { getCwd } from '../../utils/cwd.js'; -import { formatRelativeTimeAgo } from '../../utils/format.js'; -import type { FeedConfig, FeedLine } from './Feed.js'; +import figures from 'figures' +import { homedir } from 'os' +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import type { Step } from '../../projectOnboardingState.js' +import { + formatCreditAmount, + getCachedReferrerReward, +} from '../../services/api/referral.js' +import type { LogOption } from '../../types/logs.js' +import { getCwd } from '../../utils/cwd.js' +import { formatRelativeTimeAgo } from '../../utils/format.js' +import type { FeedConfig, FeedLine } from './Feed.js' + export function createRecentActivityFeed(activities: LogOption[]): FeedConfig { const lines: FeedLine[] = activities.map(log => { - const time = formatRelativeTimeAgo(log.modified); - const description = log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt; + const time = formatRelativeTimeAgo(log.modified) + const description = + log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt + return { text: description || '', - timestamp: time - }; - }); + timestamp: time, + } + }) + return { title: 'Recent activity', lines, footer: lines.length > 0 ? '/resume for more' : undefined, - emptyMessage: 'No recent activity' - }; + emptyMessage: 'No recent activity', + } } + export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig { const lines: FeedLine[] = releaseNotes.map(note => { - if ((process.env.USER_TYPE) === 'ant') { - const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/); + if (process.env.USER_TYPE === 'ant') { + const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/) if (match) { return { timestamp: match[1], - text: match[2] || '' - }; + text: match[2] || '', + } } } return { - text: note - }; - }); - const emptyMessage = (process.env.USER_TYPE) === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check the Claude Code changelog for updates'; + text: note, + } + }) + + const emptyMessage = + process.env.USER_TYPE === 'ant' + ? 'Unable to fetch latest claude-cli-internal commits' + : 'Check the Claude Code changelog for updates' + return { - title: (process.env.USER_TYPE) === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new", + title: + process.env.USER_TYPE === 'ant' + ? "What's new [ANT-ONLY: Latest CC commits]" + : "What's new", lines, footer: lines.length > 0 ? '/release-notes for more' : undefined, - emptyMessage - }; + emptyMessage, + } } + export function createProjectOnboardingFeed(steps: Step[]): FeedConfig { - const enabledSteps = steps.filter(({ - isEnabled - }) => isEnabled).sort((a, b) => Number(a.isComplete) - Number(b.isComplete)); - const lines: FeedLine[] = enabledSteps.map(({ - text, - isComplete - }) => { - const checkmark = isComplete ? `${figures.tick} ` : ''; + const enabledSteps = steps + .filter(({ isEnabled }) => isEnabled) + .sort((a, b) => Number(a.isComplete) - Number(b.isComplete)) + + const lines: FeedLine[] = enabledSteps.map(({ text, isComplete }) => { + const checkmark = isComplete ? `${figures.tick} ` : '' return { - text: `${checkmark}${text}` - }; - }); - const warningText = getCwd() === homedir() ? 'Note: You have launched claude in your home directory. For the best experience, launch it in a project directory instead.' : undefined; + text: `${checkmark}${text}`, + } + }) + + const warningText = + getCwd() === homedir() + ? 'Note: You have launched claude in your home directory. For the best experience, launch it in a project directory instead.' + : undefined + if (warningText) { lines.push({ - text: warningText - }); + text: warningText, + }) } + return { title: 'Tips for getting started', - lines - }; + lines, + } } + export function createGuestPassesFeed(): FeedConfig { - const reward = getCachedReferrerReward(); - const subtitle = reward ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage` : 'Share Claude Code with friends'; + const reward = getCachedReferrerReward() + const subtitle = reward + ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage` + : 'Share Claude Code with friends' return { title: '3 guest passes', lines: [], customContent: { - content: <> + content: ( + <> [✻] [✻] [✻] {subtitle} - , - width: 48 + + ), + width: 48, }, - footer: '/passes' - }; + footer: '/passes', + } } diff --git a/src/components/LspRecommendation/LspRecommendationMenu.tsx b/src/components/LspRecommendation/LspRecommendationMenu.tsx index 538c94375..7dc41ac39 100644 --- a/src/components/LspRecommendation/LspRecommendationMenu.tsx +++ b/src/components/LspRecommendation/LspRecommendationMenu.tsx @@ -1,63 +1,83 @@ -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Select } from '../CustomSelect/select.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { Select } from '../CustomSelect/select.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' + type Props = { - pluginName: string; - pluginDescription?: string; - fileExtension: string; - onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void; -}; -const AUTO_DISMISS_MS = 30_000; + pluginName: string + pluginDescription?: string + fileExtension: string + onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void +} + +const AUTO_DISMISS_MS = 30_000 + export function LspRecommendationMenu({ pluginName, pluginDescription, fileExtension, - onResponse + onResponse, }: Props): React.ReactNode { // Use ref to avoid timer reset when onResponse changes - const onResponseRef = React.useRef(onResponse); - onResponseRef.current = onResponse; + const onResponseRef = React.useRef(onResponse) + onResponseRef.current = onResponse // 30-second auto-dismiss timer - counts as ignored (no) React.useEffect(() => { - const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef); - return () => clearTimeout(timeoutId); - }, []); + const timeoutId = setTimeout( + ref => ref.current('no'), + AUTO_DISMISS_MS, + onResponseRef, + ) + return () => clearTimeout(timeoutId) + }, []) + function onSelect(value: string): void { switch (value) { case 'yes': - onResponse('yes'); - break; + onResponse('yes') + break case 'no': - onResponse('no'); - break; + onResponse('no') + break case 'never': - onResponse('never'); - break; + onResponse('never') + break case 'disable': - onResponse('disable'); - break; + onResponse('disable') + break } } - const options = [{ - label: + + const options = [ + { + label: ( + Yes, install {pluginName} - , - value: 'yes' - }, { - label: 'No, not now', - value: 'no' - }, { - label: + + ), + value: 'yes', + }, + { + label: 'No, not now', + value: 'no', + }, + { + label: ( + Never for {pluginName} - , - value: 'never' - }, { - label: 'Disable all LSP recommendations', - value: 'disable' - }]; - return + + ), + value: 'never', + }, + { + label: 'Disable all LSP recommendations', + value: 'disable', + }, + ] + + return ( + @@ -69,9 +89,11 @@ export function LspRecommendationMenu({ Plugin: {pluginName} - {pluginDescription && + {pluginDescription && ( + {pluginDescription} - } + + )} Triggered by: {fileExtension} files @@ -80,8 +102,13 @@ export function LspRecommendationMenu({ Would you like to install this LSP plugin? - onResponse('no')} + /> - ; + + ) } diff --git a/src/components/MCPServerApprovalDialog.tsx b/src/components/MCPServerApprovalDialog.tsx index af0c7d0d3..5d5e00898 100644 --- a/src/components/MCPServerApprovalDialog.tsx +++ b/src/components/MCPServerApprovalDialog.tsx @@ -1,114 +1,90 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'; +import React from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + getSettings_DEPRECATED, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { MCPServerDialogCopy } from './MCPServerDialogCopy.js' + type Props = { - serverName: string; - onDone(): void; -}; -export function MCPServerApprovalDialog(t0) { - const $ = _c(13); - const { - serverName, - onDone - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== serverName) { - t1 = function onChange(value) { - logEvent("tengu_mcp_dialog_choice", { - choice: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - bb2: switch (value) { - case "yes": - case "yes_all": - { - const currentSettings_0 = getSettings_DEPRECATED() || {}; - const enabledServers = currentSettings_0.enabledMcpjsonServers || []; - if (!enabledServers.includes(serverName)) { - updateSettingsForSource("localSettings", { - enabledMcpjsonServers: [...enabledServers, serverName] - }); - } - if (value === "yes_all") { - updateSettingsForSource("localSettings", { - enableAllProjectMcpServers: true - }); - } - onDone(); - break bb2; - } - case "no": - { - const currentSettings = getSettings_DEPRECATED() || {}; - const disabledServers = currentSettings.disabledMcpjsonServers || []; - if (!disabledServers.includes(serverName)) { - updateSettingsForSource("localSettings", { - disabledMcpjsonServers: [...disabledServers, serverName] - }); - } - onDone(); - } + serverName: string + onDone(): void +} + +export function MCPServerApprovalDialog({ + serverName, + onDone, +}: Props): React.ReactNode { + function onChange(value: 'yes' | 'yes_all' | 'no') { + logEvent('tengu_mcp_dialog_choice', { + choice: + value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + switch (value) { + case 'yes': + case 'yes_all': { + // Get current enabled servers from settings + const currentSettings = getSettings_DEPRECATED() || {} + const enabledServers = currentSettings.enabledMcpjsonServers || [] + + // Add server if not already enabled + if (!enabledServers.includes(serverName)) { + updateSettingsForSource('localSettings', { + enabledMcpjsonServers: [...enabledServers, serverName], + }) + } + + if (value === 'yes_all') { + updateSettingsForSource('localSettings', { + enableAllProjectMcpServers: true, + }) + } + onDone() + break } - }; - $[0] = onDone; - $[1] = serverName; - $[2] = t1; - } else { - t1 = $[2]; - } - const onChange = t1; - const t2 = `New MCP server found in .mcp.json: ${serverName}`; - let t3; - if ($[3] !== onChange) { - t3 = () => onChange("no"); - $[3] = onChange; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = [{ - label: "Use this and all future MCP servers in this project", - value: "yes_all" - }, { - label: "Use this MCP server", - value: "yes" - }, { - label: "Continue without using this MCP server", - value: "no" - }]; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== onChange) { - t6 = onChange(value as 'yes_all' | 'yes' | 'no')} + onCancel={() => onChange('no')} + /> + + ) } diff --git a/src/components/MCPServerDesktopImportDialog.tsx b/src/components/MCPServerDesktopImportDialog.tsx index 779c278a5..50b9ef6d6 100644 --- a/src/components/MCPServerDesktopImportDialog.tsx +++ b/src/components/MCPServerDesktopImportDialog.tsx @@ -1,202 +1,134 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useState } from 'react'; -import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'; -import { writeToStdout } from 'src/utils/process.js'; -import { Box, color, Text, useTheme } from '../ink.js'; -import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js'; -import type { ConfigScope, McpServerConfig, ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { plural } from '../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { SelectMulti } from './CustomSelect/SelectMulti.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import React, { useCallback, useEffect, useState } from 'react' +import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' +import { writeToStdout } from 'src/utils/process.js' +import { Box, color, Text, useTheme } from '../ink.js' +import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js' +import type { + ConfigScope, + McpServerConfig, + ScopedMcpServerConfig, +} from '../services/mcp/types.js' +import { plural } from '../utils/stringUtils.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { SelectMulti } from './CustomSelect/SelectMulti.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' + type Props = { - servers: Record; - scope: ConfigScope; - onDone(): void; -}; -export function MCPServerDesktopImportDialog(t0) { - const $ = _c(36); - const { - servers, - scope, - onDone - } = t0; - let t1; - if ($[0] !== servers) { - t1 = Object.keys(servers); - $[0] = servers; - $[1] = t1; - } else { - t1 = $[1]; - } - const serverNames = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {}; - $[2] = t2; - } else { - t2 = $[2]; - } - const [existingServers, setExistingServers] = useState(t2); - let t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => { - getAllMcpConfigs().then(t5 => { - const { - servers: servers_0 - } = t5; - return setExistingServers(servers_0); - }); - }; - t4 = []; - $[3] = t3; - $[4] = t4; - } else { - t3 = $[3]; - t4 = $[4]; - } - useEffect(t3, t4); - let t5; - if ($[5] !== existingServers || $[6] !== serverNames) { - t5 = serverNames.filter(name => existingServers[name] !== undefined); - $[5] = existingServers; - $[6] = serverNames; - $[7] = t5; - } else { - t5 = $[7]; - } - const collisions = t5; - const onSubmit = async function onSubmit(selectedServers) { - let importedCount = 0; + servers: Record + scope: ConfigScope + onDone(): void +} + +export function MCPServerDesktopImportDialog({ + servers, + scope, + onDone, +}: Props): React.ReactNode { + const serverNames = Object.keys(servers) + const [existingServers, setExistingServers] = useState< + Record + >({}) + + useEffect(() => { + void getAllMcpConfigs().then(({ servers }) => setExistingServers(servers)) + }, []) + + const collisions = serverNames.filter( + name => existingServers[name] !== undefined, + ) + + async function onSubmit(selectedServers: string[]) { + let importedCount = 0 + for (const serverName of selectedServers) { - const serverConfig = servers[serverName]; + const serverConfig = servers[serverName] if (serverConfig) { - let finalName = serverName; + // If the server name already exists, find a new name with _1, _2, etc. + let finalName = serverName if (existingServers[finalName] !== undefined) { - let counter = 1; + let counter = 1 while (existingServers[`${serverName}_${counter}`] !== undefined) { - counter++; + counter++ } - finalName = `${serverName}_${counter}`; + finalName = `${serverName}_${counter}` } - await addMcpConfig(finalName, serverConfig, scope); - importedCount++; + + await addMcpConfig(finalName, serverConfig, scope) + importedCount++ } } - done(importedCount); - }; - const [theme] = useTheme(); - let t6; - if ($[8] !== onDone || $[9] !== scope || $[10] !== theme) { - t6 = importedCount_0 => { - if (importedCount_0 > 0) { - writeToStdout(`\n${color("success", theme)(`Successfully imported ${importedCount_0} MCP ${plural(importedCount_0, "server")} to ${scope} config.`)}\n`); + + done(importedCount) + } + + const [theme] = useTheme() + + // Define done before using in useCallback + const done = useCallback( + (importedCount: number) => { + if (importedCount > 0) { + writeToStdout( + `\n${color('success', theme)(`Successfully imported ${importedCount} MCP ${plural(importedCount, 'server')} to ${scope} config.`)}\n`, + ) } else { - writeToStdout("\nNo servers were imported."); + writeToStdout('\nNo servers were imported.') } - onDone(); - gracefulShutdown(); - }; - $[8] = onDone; - $[9] = scope; - $[10] = theme; - $[11] = t6; - } else { - t6 = $[11]; - } - const done = t6; - let t7; - if ($[12] !== done) { - t7 = () => { - done(0); - }; - $[12] = done; - $[13] = t7; - } else { - t7 = $[13]; - } - done; - const handleEscCancel = t7; - const t8 = serverNames.length; - let t9; - if ($[14] !== serverNames.length) { - t9 = plural(serverNames.length, "server"); - $[14] = serverNames.length; - $[15] = t9; - } else { - t9 = $[15]; - } - const t10 = `Found ${t8} MCP ${t9} in Claude Desktop.`; - let t11; - if ($[16] !== collisions.length) { - t11 = collisions.length > 0 && Note: Some servers already exist with the same name. If selected, they will be imported with a numbered suffix.; - $[16] = collisions.length; - $[17] = t11; - } else { - t11 = $[17]; - } - let t12; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Please select the servers you want to import:; - $[18] = t12; - } else { - t12 = $[18]; - } - let t13; - let t14; - if ($[19] !== collisions || $[20] !== serverNames) { - t13 = serverNames.map(server => ({ - label: `${server}${collisions.includes(server) ? " (already exists)" : ""}`, - value: server - })); - t14 = serverNames.filter(name_0 => !collisions.includes(name_0)); - $[19] = collisions; - $[20] = serverNames; - $[21] = t13; - $[22] = t14; - } else { - t13 = $[21]; - t14 = $[22]; - } - let t15; - if ($[23] !== handleEscCancel || $[24] !== onSubmit || $[25] !== t13 || $[26] !== t14) { - t15 = ; - $[23] = handleEscCancel; - $[24] = onSubmit; - $[25] = t13; - $[26] = t14; - $[27] = t15; - } else { - t15 = $[27]; - } - let t16; - if ($[28] !== handleEscCancel || $[29] !== t10 || $[30] !== t11 || $[31] !== t15) { - t16 = {t11}{t12}{t15}; - $[28] = handleEscCancel; - $[29] = t10; - $[30] = t11; - $[31] = t15; - $[32] = t16; - } else { - t16 = $[32]; - } - let t17; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t17 = ; - $[33] = t17; - } else { - t17 = $[33]; - } - let t18; - if ($[34] !== t16) { - t18 = <>{t16}{t17}; - $[34] = t16; - $[35] = t18; - } else { - t18 = $[35]; - } - return t18; + onDone() + + void gracefulShutdown() + }, + [theme, scope, onDone], + ) + + // Handle ESC to cancel (import 0 servers) + const handleEscCancel = useCallback(() => { + done(0) + }, [done]) + + return ( + <> + + {collisions.length > 0 && ( + + Note: Some servers already exist with the same name. If selected, + they will be imported with a numbered suffix. + + )} + Please select the servers you want to import: + + ({ + label: `${server}${collisions.includes(server) ? ' (already exists)' : ''}`, + value: server, + }))} + defaultValue={serverNames.filter(name => !collisions.includes(name))} // Only preselect non-colliding servers + onSubmit={onSubmit} + onCancel={handleEscCancel} + hideIndexes + /> + + + + + + + + + + + + ) } diff --git a/src/components/MCPServerDialogCopy.tsx b/src/components/MCPServerDialogCopy.tsx index 12a8ada2e..93dce3655 100644 --- a/src/components/MCPServerDialogCopy.tsx +++ b/src/components/MCPServerDialogCopy.tsx @@ -1,14 +1,12 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Link, Text } from '../ink.js'; -export function MCPServerDialogCopy() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = MCP servers may execute code or access system resources. All tool calls require approval. Learn more in the{" "}MCP documentation.; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import React from 'react' +import { Link, Text } from '../ink.js' + +export function MCPServerDialogCopy(): React.ReactNode { + return ( + + MCP servers may execute code or access system resources. All tool calls + require approval. Learn more in the{' '} + MCP documentation. + + ) } diff --git a/src/components/MCPServerMultiselectDialog.tsx b/src/components/MCPServerMultiselectDialog.tsx index f4ba343e5..e14c46d46 100644 --- a/src/components/MCPServerMultiselectDialog.tsx +++ b/src/components/MCPServerMultiselectDialog.tsx @@ -1,132 +1,117 @@ -import { c as _c } from "react/compiler-runtime"; -import partition from 'lodash-es/partition.js'; -import React, { useCallback } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Text } from '../ink.js'; -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { SelectMulti } from './CustomSelect/SelectMulti.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'; +import partition from 'lodash-es/partition.js' +import React, { useCallback } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Text } from '../ink.js' +import { + getSettings_DEPRECATED, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { SelectMulti } from './CustomSelect/SelectMulti.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { MCPServerDialogCopy } from './MCPServerDialogCopy.js' + type Props = { - serverNames: string[]; - onDone(): void; -}; -export function MCPServerMultiselectDialog(t0) { - const $ = _c(21); - const { - serverNames, - onDone - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== serverNames) { - t1 = function onSubmit(selectedServers) { - const currentSettings = getSettings_DEPRECATED() || {}; - const enabledServers = currentSettings.enabledMcpjsonServers || []; - const disabledServers = currentSettings.disabledMcpjsonServers || []; - const [approvedServers, rejectedServers] = partition(serverNames, server => selectedServers.includes(server)); - logEvent("tengu_mcp_multidialog_choice", { - approved: approvedServers.length, - rejected: rejectedServers.length - }); - if (approvedServers.length > 0) { - const newEnabledServers = [...new Set([...enabledServers, ...approvedServers])]; - updateSettingsForSource("localSettings", { - enabledMcpjsonServers: newEnabledServers - }); - } - if (rejectedServers.length > 0) { - const newDisabledServers = [...new Set([...disabledServers, ...rejectedServers])]; - updateSettingsForSource("localSettings", { - disabledMcpjsonServers: newDisabledServers - }); - } - onDone(); - }; - $[0] = onDone; - $[1] = serverNames; - $[2] = t1; - } else { - t1 = $[2]; - } - const onSubmit = t1; - let t2; - if ($[3] !== onDone || $[4] !== serverNames) { - t2 = () => { - const currentSettings_0 = getSettings_DEPRECATED() || {}; - const disabledServers_0 = currentSettings_0.disabledMcpjsonServers || []; - const newDisabledServers_0 = [...new Set([...disabledServers_0, ...serverNames])]; - updateSettingsForSource("localSettings", { - disabledMcpjsonServers: newDisabledServers_0 - }); - onDone(); - }; - $[3] = onDone; - $[4] = serverNames; - $[5] = t2; - } else { - t2 = $[5]; - } - const handleEscRejectAll = t2; - const t3 = `${serverNames.length} new MCP servers found in .mcp.json`; - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== serverNames) { - t5 = serverNames.map(_temp); - $[7] = serverNames; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== handleEscRejectAll || $[10] !== onSubmit || $[11] !== serverNames || $[12] !== t5) { - t6 = ; - $[9] = handleEscRejectAll; - $[10] = onSubmit; - $[11] = serverNames; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== handleEscRejectAll || $[15] !== t3 || $[16] !== t6) { - t7 = {t4}{t6}; - $[14] = handleEscRejectAll; - $[15] = t3; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; - } - let t8; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t8 = ; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== t7) { - t9 = <>{t7}{t8}; - $[19] = t7; - $[20] = t9; - } else { - t9 = $[20]; - } - return t9; + serverNames: string[] + onDone(): void } -function _temp(server_0) { - return { - label: server_0, - value: server_0 - }; + +export function MCPServerMultiselectDialog({ + serverNames, + onDone, +}: Props): React.ReactNode { + function onSubmit(selectedServers: string[]) { + const currentSettings = getSettings_DEPRECATED() || {} + const enabledServers = currentSettings.enabledMcpjsonServers || [] + const disabledServers = currentSettings.disabledMcpjsonServers || [] + + // Use partition to separate approved and rejected servers + const [approvedServers, rejectedServers] = partition(serverNames, server => + selectedServers.includes(server), + ) + + logEvent('tengu_mcp_multidialog_choice', { + approved: approvedServers.length, + rejected: rejectedServers.length, + }) + + // Update settings with approved servers + if (approvedServers.length > 0) { + const newEnabledServers = [ + ...new Set([...enabledServers, ...approvedServers]), + ] + updateSettingsForSource('localSettings', { + enabledMcpjsonServers: newEnabledServers, + }) + } + + // Update settings with rejected servers + if (rejectedServers.length > 0) { + const newDisabledServers = [ + ...new Set([...disabledServers, ...rejectedServers]), + ] + updateSettingsForSource('localSettings', { + disabledMcpjsonServers: newDisabledServers, + }) + } + + onDone() + } + + // Handle ESC to reject all servers + const handleEscRejectAll = useCallback(() => { + const currentSettings = getSettings_DEPRECATED() || {} + const disabledServers = currentSettings.disabledMcpjsonServers || [] + + const newDisabledServers = [ + ...new Set([...disabledServers, ...serverNames]), + ] + + updateSettingsForSource('localSettings', { + disabledMcpjsonServers: newDisabledServers, + }) + + onDone() + }, [serverNames, onDone]) + + return ( + <> + + + + ({ + label: server, + value: server, + }))} + defaultValue={serverNames} + onSubmit={onSubmit} + onCancel={handleEscRejectAll} + hideIndexes + /> + + + + + + + + + + + + ) } diff --git a/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx b/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx index 345e592a7..392979770 100644 --- a/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx +++ b/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx @@ -1,148 +1,88 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { SettingsJson } from '../../utils/settings/types.js'; -import { Select } from '../CustomSelect/index.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; -import { extractDangerousSettings, formatDangerousSettingsList } from './utils.js'; +import React from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { SettingsJson } from '../../utils/settings/types.js' +import { Select } from '../CustomSelect/index.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' +import { + extractDangerousSettings, + formatDangerousSettingsList, +} from './utils.js' + type Props = { - settings: SettingsJson; - onAccept: () => void; - onReject: () => void; -}; -export function ManagedSettingsSecurityDialog(t0) { - const $ = _c(26); - const { - settings, - onAccept, - onReject - } = t0; - const dangerous = extractDangerousSettings(settings); - const settingsList = formatDangerousSettingsList(dangerous); - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onReject, t1); - let t2; - if ($[1] !== onAccept || $[2] !== onReject) { - t2 = function onChange(value) { - if (value === "exit") { - onReject(); - return; - } - onAccept(); - }; - $[1] = onAccept; - $[2] = onReject; - $[3] = t2; - } else { - t2 = $[3]; - } - const onChange = t2; - const T0 = PermissionDialog; - const t3 = "warning"; - const t4 = "warning"; - const t5 = "Managed settings require approval"; - const T1 = Box; - const t6 = "column"; - const t7 = 1; - const t8 = 1; - let t9; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t9 = Your organization has configured managed settings that could allow execution of arbitrary code or interception of your prompts and responses.; - $[4] = t9; - } else { - t9 = $[4]; - } - const T2 = Box; - const t10 = "column"; - let t11; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Settings requiring approval:; - $[5] = t11; - } else { - t11 = $[5]; - } - const t12 = settingsList.map(_temp); - let t13; - if ($[6] !== T2 || $[7] !== t11 || $[8] !== t12) { - t13 = {t11}{t12}; - $[6] = T2; - $[7] = t11; - $[8] = t12; - $[9] = t13; - } else { - t13 = $[9]; - } - let t14; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t14 = Only accept if you trust your organization's IT administration and expect these settings to be configured.; - $[10] = t14; - } else { - t14 = $[10]; - } - let t15; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t15 = [{ - label: "Yes, I trust these settings", - value: "accept" - }, { - label: "No, exit Claude Code", - value: "exit" - }]; - $[11] = t15; - } else { - t15 = $[11]; - } - let t16; - if ($[12] !== onChange) { - t16 = onChange(value as 'accept' | 'exit')} + onCancel={() => onChange('exit')} + /> + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Enter to confirm · Esc to exit + )} + + + + ) } diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index dc089dffb..4616f46c2 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,26 +1,29 @@ -import { c as _c } from "react/compiler-runtime"; -import { marked, type Token, type Tokens } from 'marked'; -import React, { Suspense, use, useMemo, useRef } from 'react'; -import { useSettings } from '../hooks/useSettings.js'; -import { Ansi, Box, useTheme } from '../ink.js'; -import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js'; -import { hashContent } from '../utils/hash.js'; -import { configureMarked, formatToken } from '../utils/markdown.js'; -import { stripPromptXMLTags } from '../utils/messages.js'; -import { MarkdownTable } from './MarkdownTable.js'; +import { marked, type Token, type Tokens } from 'marked' +import React, { Suspense, use, useMemo, useRef } from 'react' +import { useSettings } from '../hooks/useSettings.js' +import { Ansi, Box, useTheme } from '../ink.js' +import { + type CliHighlight, + getCliHighlightPromise, +} from '../utils/cliHighlight.js' +import { hashContent } from '../utils/hash.js' +import { configureMarked, formatToken } from '../utils/markdown.js' +import { stripPromptXMLTags } from '../utils/messages.js' +import { MarkdownTable } from './MarkdownTable.js' + type Props = { - children: string; + children: string /** When true, render all text content as dim */ - dimColor?: boolean; -}; + dimColor?: boolean +} // Module-level token cache — marked.lexer is the hot cost on virtual-scroll // remounts (~3ms per message). useMemo doesn't survive unmount→remount, so // scrolling back to a previously-visible message re-parses. Messages are // immutable in history; same content → same tokens. Keyed by hash to avoid // retaining full content strings (turn50→turn99 RSS regression, #24180). -const TOKEN_CACHE_MAX = 500; -const tokenCache = new Map(); +const TOKEN_CACHE_MAX = 500 +const tokenCache = new Map() // Characters that indicate markdown syntax. If none are present, skip the // ~3ms marked.lexer call entirely — render as a single paragraph. Covers @@ -28,46 +31,45 @@ const tokenCache = new Map(); // plain sentences. Checked via indexOf (not regex) for speed. // Single regex: matches any MD marker or ordered-list start (N. at line start). // One pass instead of 10× includes scans. -const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /; +const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. / function hasMarkdownSyntax(s: string): boolean { // Sample first 500 chars — if markdown exists it's usually early (headers, // code fence, list). Long tool outputs are mostly plain text tails. - return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s); + return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s) } + function cachedLexer(content: string): Token[] { // Fast path: plain text with no markdown syntax → single paragraph token. // Skips marked.lexer's full GFM parse (~3ms on long content). Not cached — // reconstruction is a single object allocation, and caching would retain // 4× content in raw/text fields plus the hash key for zero benefit. if (!hasMarkdownSyntax(content)) { - return [{ - type: 'paragraph', - raw: content, - text: content, - tokens: [{ - type: 'text', + return [ + { + type: 'paragraph', raw: content, - text: content - }] - } as Token]; + text: content, + tokens: [{ type: 'text', raw: content, text: content }], + } as Token, + ] } - const key = hashContent(content); - const hit = tokenCache.get(key); + const key = hashContent(content) + const hit = tokenCache.get(key) if (hit) { // Promote to MRU — without this the eviction is FIFO (scrolling back to // an early message evicts the very item you're looking at). - tokenCache.delete(key); - tokenCache.set(key, hit); - return hit; + tokenCache.delete(key) + tokenCache.set(key, hit) + return hit } - const tokens = marked.lexer(content); + const tokens = marked.lexer(content) if (tokenCache.size >= TOKEN_CACHE_MAX) { // LRU-ish: drop oldest. Map preserves insertion order. - const first = tokenCache.keys().next().value; - if (first !== undefined) tokenCache.delete(first); + const first = tokenCache.keys().next().value + if (first !== undefined) tokenCache.delete(first) } - tokenCache.set(key, tokens); - return tokens; + tokenCache.set(key, tokens) + return tokens } /** @@ -75,103 +77,78 @@ function cachedLexer(content: string): Token[] { * - Tables are rendered as React components with proper flexbox layout * - Other content is rendered as ANSI strings via formatToken */ -export function Markdown(props) { - const $ = _c(4); - const settings = useSettings(); +export function Markdown(props: Props): React.ReactNode { + const settings = useSettings() if (settings.syntaxHighlightingDisabled) { - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; - } - let t0; - if ($[2] !== props) { - t0 = }>; - $[2] = props; - $[3] = t0; - } else { - t0 = $[3]; + return } - return t0; + // Suspense fallback renders with highlight=null — plain markdown shows + // for ~50ms on first ever render while cli-highlight loads. + return ( + }> + + + ) } -function MarkdownWithHighlight(props) { - const $ = _c(4); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = getCliHighlightPromise(); - $[0] = t0; - } else { - t0 = $[0]; - } - const highlight = use(t0); - let t1; - if ($[1] !== highlight || $[2] !== props) { - t1 = ; - $[1] = highlight; - $[2] = props; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + +function MarkdownWithHighlight(props: Props): React.ReactNode { + const highlight = use(getCliHighlightPromise()) + return } -function MarkdownBody(t0) { - const $ = _c(7); - const { - children, - dimColor, - highlight - } = t0; - const [theme] = useTheme(); - configureMarked(); - let elements: React.ReactNode[]; - if ($[0] !== children || $[1] !== dimColor || $[2] !== highlight || $[3] !== theme) { - const tokens = cachedLexer(stripPromptXMLTags(children)); - elements = []; - let nonTableContent = ""; - const flushNonTableContent = function flushNonTableContent() { + +function MarkdownBody({ + children, + dimColor, + highlight, +}: Props & { highlight: CliHighlight | null }): React.ReactNode { + const [theme] = useTheme() + configureMarked() + + const elements = useMemo(() => { + const tokens = cachedLexer(stripPromptXMLTags(children)) + const elements: React.ReactNode[] = [] + let nonTableContent = '' + + function flushNonTableContent(): void { if (nonTableContent) { - elements.push({nonTableContent.trim()}); - nonTableContent = ""; + elements.push( + + {nonTableContent.trim()} + , + ) + nonTableContent = '' } - }; + } + for (const token of tokens) { - if (token.type === "table") { - flushNonTableContent(); - elements.push(); + if (token.type === 'table') { + flushNonTableContent() + elements.push( + , + ) } else { - nonTableContent = nonTableContent + formatToken(token, theme, 0, null, null, highlight); - nonTableContent; + nonTableContent += formatToken(token, theme, 0, null, null, highlight) } } - flushNonTableContent(); - $[0] = children; - $[1] = dimColor; - $[2] = highlight; - $[3] = theme; - $[4] = elements; - } else { - elements = $[4] as React.ReactNode[]; - } - const elements_0 = elements; - let t1; - if ($[5] !== elements_0) { - t1 = {elements_0}; - $[5] = elements_0; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + + flushNonTableContent() + return elements + }, [children, dimColor, highlight, theme]) + + return ( + + {elements} + + ) } + type StreamingProps = { - children: string; -}; + children: string +} /** * Renders markdown during streaming by splitting at the last top-level block @@ -184,52 +161,55 @@ type StreamingProps = { * between turns (streamingText → null), resetting the ref. */ export function StreamingMarkdown({ - children + children, }: StreamingProps): React.ReactNode { // React Compiler: this component reads and writes stablePrefixRef.current // during render by design. The boundary only advances (monotonic), so // the ref mutation is idempotent under StrictMode double-render — but the // compiler can't prove that, and memoizing around the ref reads would // break the algorithm (stale boundary). Opt out. - 'use no memo'; - - configureMarked(); + 'use no memo' + configureMarked() // Strip before boundary tracking so it matches 's stripping // (line 29). When a closing tag arrives, stripped(N+1) is not a prefix // of stripped(N), but the startsWith reset below handles that with a // one-time re-lex on the smaller stripped string. - const stripped = stripPromptXMLTags(children); - const stablePrefixRef = useRef(''); + const stripped = stripPromptXMLTags(children) + + const stablePrefixRef = useRef('') // Reset if text was replaced (defensive; normally unmount handles this) if (!stripped.startsWith(stablePrefixRef.current)) { - stablePrefixRef.current = ''; + stablePrefixRef.current = '' } // Lex only from current boundary — O(unstable length), not O(full text) - const boundary = stablePrefixRef.current.length; - const tokens = marked.lexer(stripped.substring(boundary)); + const boundary = stablePrefixRef.current.length + const tokens = marked.lexer(stripped.substring(boundary)) // Last non-space token is the growing block; everything before is final - let lastContentIdx = tokens.length - 1; + let lastContentIdx = tokens.length - 1 while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') { - lastContentIdx--; + lastContentIdx-- } - let advance = 0; + let advance = 0 for (let i = 0; i < lastContentIdx; i++) { - advance += tokens[i]!.raw.length; + advance += tokens[i]!.raw.length } if (advance > 0) { - stablePrefixRef.current = stripped.substring(0, boundary + advance); + stablePrefixRef.current = stripped.substring(0, boundary + advance) } - const stablePrefix = stablePrefixRef.current; - const unstableSuffix = stripped.substring(stablePrefix.length); + + const stablePrefix = stablePrefixRef.current + const unstableSuffix = stripped.substring(stablePrefix.length) // stablePrefix is memoized inside via useMemo([children, ...]) // so it never re-parses as the unstable suffix grows - return + return ( + {stablePrefix && {stablePrefix}} {unstableSuffix && {unstableSuffix}} - ; + + ) } diff --git a/src/components/MarkdownTable.tsx b/src/components/MarkdownTable.tsx index b3670ced0..c8997d9a1 100644 --- a/src/components/MarkdownTable.tsx +++ b/src/components/MarkdownTable.tsx @@ -1,38 +1,39 @@ -import type { Token, Tokens } from 'marked'; -import React from 'react'; -import stripAnsi from 'strip-ansi'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { wrapAnsi } from '../ink/wrapAnsi.js'; -import { Ansi, useTheme } from '../ink.js'; -import type { CliHighlight } from '../utils/cliHighlight.js'; -import { formatToken, padAligned } from '../utils/markdown.js'; +import type { Token, Tokens } from 'marked' +import React from 'react' +import stripAnsi from 'strip-ansi' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { wrapAnsi } from '../ink/wrapAnsi.js' +import { Ansi, useTheme } from '../ink.js' +import type { CliHighlight } from '../utils/cliHighlight.js' +import { formatToken, padAligned } from '../utils/markdown.js' /** Accounts for parent indentation (e.g. message dot prefix) and terminal * resize races. Without enough margin the table overflows its layout box * and Ink's clip truncates differently on alternating frames, causing an * infinite flicker loop in scrollback. */ -const SAFETY_MARGIN = 4; +const SAFETY_MARGIN = 4 /** Minimum column width to prevent degenerate layouts */ -const MIN_COLUMN_WIDTH = 3; +const MIN_COLUMN_WIDTH = 3 /** * Maximum number of lines per row before switching to vertical format. * When wrapping would make rows taller than this, vertical (key-value) * format provides better readability. */ -const MAX_ROW_LINES = 4; +const MAX_ROW_LINES = 4 /** ANSI escape codes for text formatting */ -const ANSI_BOLD_START = '\x1b[1m'; -const ANSI_BOLD_END = '\x1b[22m'; +const ANSI_BOLD_START = '\x1b[1m' +const ANSI_BOLD_END = '\x1b[22m' + type Props = { - token: Tokens.Table; - highlight: CliHighlight | null; + token: Tokens.Table + highlight: CliHighlight | null /** Override terminal width (useful for testing) */ - forceWidth?: number; -}; + forceWidth?: number +} /** * Wrap text to fit within a given width, returning array of lines. @@ -41,24 +42,26 @@ type Props = { * @param hard - If true, break words that exceed width (needed when columns * are narrower than the longest word). Default false. */ -function wrapText(text: string, width: number, options?: { - hard?: boolean; -}): string[] { - if (width <= 0) return [text]; +function wrapText( + text: string, + width: number, + options?: { hard?: boolean }, +): string[] { + if (width <= 0) return [text] // Strip trailing whitespace/newlines before wrapping. // formatToken() adds EOL to paragraphs and other token types, // which would otherwise create extra blank lines in table cells. - const trimmedText = text.trimEnd(); + const trimmedText = text.trimEnd() const wrapped = wrapAnsi(trimmedText, width, { hard: options?.hard ?? false, trim: false, - wordWrap: true - }); + wordWrap: true, + }) // Filter out empty lines that result from trailing newlines or // multiple consecutive newlines in the source content. - const lines = wrapped.split('\n').filter(line => line.length > 0); + const lines = wrapped.split('\n').filter(line => line.length > 0) // Ensure we always return at least one line (empty string for empty cells) - return lines.length > 0 ? lines : ['']; + return lines.length > 0 ? lines : [''] } /** @@ -72,154 +75,171 @@ function wrapText(text: string, width: number, options?: { export function MarkdownTable({ token, highlight, - forceWidth + forceWidth, }: Props): React.ReactNode { - const [theme] = useTheme(); - const { - columns: actualTerminalWidth - } = useTerminalSize(); - const terminalWidth = forceWidth ?? actualTerminalWidth; + const [theme] = useTheme() + const { columns: actualTerminalWidth } = useTerminalSize() + const terminalWidth = forceWidth ?? actualTerminalWidth // Format cell content to ANSI string function formatCell(tokens: Token[] | undefined): string { - return tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? ''; + return ( + tokens + ?.map(_ => formatToken(_, theme, 0, null, null, highlight)) + .join('') ?? '' + ) } // Get plain text (stripped of ANSI codes) - function getPlainText(tokens_0: Token[] | undefined): string { - return stripAnsi(formatCell(tokens_0)); + function getPlainText(tokens: Token[] | undefined): string { + return stripAnsi(formatCell(tokens)) } // Get the longest word width in a cell (minimum width to avoid breaking words) - function getMinWidth(tokens_1: Token[] | undefined): number { - const text = getPlainText(tokens_1); - const words = text.split(/\s+/).filter(w => w.length > 0); - if (words.length === 0) return MIN_COLUMN_WIDTH; - return Math.max(...words.map(w_0 => stringWidth(w_0)), MIN_COLUMN_WIDTH); + function getMinWidth(tokens: Token[] | undefined): number { + const text = getPlainText(tokens) + const words = text.split(/\s+/).filter(w => w.length > 0) + if (words.length === 0) return MIN_COLUMN_WIDTH + return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH) } // Get ideal width (full content without wrapping) - function getIdealWidth(tokens_2: Token[] | undefined): number { - return Math.max(stringWidth(getPlainText(tokens_2)), MIN_COLUMN_WIDTH); + function getIdealWidth(tokens: Token[] | undefined): number { + return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH) } // Calculate column widths // Step 1: Get minimum (longest word) and ideal (full content) widths const minWidths = token.header.map((header, colIndex) => { - let maxMinWidth = getMinWidth(header.tokens); + let maxMinWidth = getMinWidth(header.tokens) for (const row of token.rows) { - maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)); + maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)) } - return maxMinWidth; - }); - const idealWidths = token.header.map((header_0, colIndex_0) => { - let maxIdeal = getIdealWidth(header_0.tokens); - for (const row_0 of token.rows) { - maxIdeal = Math.max(maxIdeal, getIdealWidth(row_0[colIndex_0]?.tokens)); + return maxMinWidth + }) + + const idealWidths = token.header.map((header, colIndex) => { + let maxIdeal = getIdealWidth(header.tokens) + for (const row of token.rows) { + maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens)) } - return maxIdeal; - }); + return maxIdeal + }) // Step 2: Calculate available space // Border overhead: │ content │ content │ = 1 + (width + 3) per column - const numCols = token.header.length; - const borderOverhead = 1 + numCols * 3; // │ + (2 padding + 1 border) per col + const numCols = token.header.length + const borderOverhead = 1 + numCols * 3 // │ + (2 padding + 1 border) per col // Account for SAFETY_MARGIN to avoid triggering the fallback safety check - const availableWidth = Math.max(terminalWidth - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH); + const availableWidth = Math.max( + terminalWidth - borderOverhead - SAFETY_MARGIN, + numCols * MIN_COLUMN_WIDTH, + ) // Step 3: Calculate column widths that fit available space - const totalMin = minWidths.reduce((sum, w_1) => sum + w_1, 0); - const totalIdeal = idealWidths.reduce((sum_0, w_2) => sum_0 + w_2, 0); + const totalMin = minWidths.reduce((sum, w) => sum + w, 0) + const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0) // Track whether columns are narrower than longest words (needs hard wrap) - let needsHardWrap = false; - let columnWidths: number[]; + let needsHardWrap = false + + let columnWidths: number[] if (totalIdeal <= availableWidth) { // Everything fits - use ideal widths - columnWidths = idealWidths; + columnWidths = idealWidths } else if (totalMin <= availableWidth) { // Need to shrink - give each column its min, distribute remaining space - const extraSpace = availableWidth - totalMin; - const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!); - const totalOverflow = overflows.reduce((sum_1, o) => sum_1 + o, 0); - columnWidths = minWidths.map((min, i_0) => { - if (totalOverflow === 0) return min; - const extra = Math.floor(overflows[i_0]! / totalOverflow * extraSpace); - return min + extra; - }); + const extraSpace = availableWidth - totalMin + const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!) + const totalOverflow = overflows.reduce((sum, o) => sum + o, 0) + + columnWidths = minWidths.map((min, i) => { + if (totalOverflow === 0) return min + const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace) + return min + extra + }) } else { // Table wider than terminal at minimum widths // Shrink columns proportionally to fit, allowing word breaks - needsHardWrap = true; - const scaleFactor = availableWidth / totalMin; - columnWidths = minWidths.map(w_3 => Math.max(Math.floor(w_3 * scaleFactor), MIN_COLUMN_WIDTH)); + needsHardWrap = true + const scaleFactor = availableWidth / totalMin + columnWidths = minWidths.map(w => + Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH), + ) } // Step 4: Calculate max row lines to determine if vertical format is needed function calculateMaxRowLines(): number { - let maxLines = 1; + let maxLines = 1 // Check header - for (let i_1 = 0; i_1 < token.header.length; i_1++) { - const content = formatCell(token.header[i_1]!.tokens); - const wrapped = wrapText(content, columnWidths[i_1]!, { - hard: needsHardWrap - }); - maxLines = Math.max(maxLines, wrapped.length); + for (let i = 0; i < token.header.length; i++) { + const content = formatCell(token.header[i]!.tokens) + const wrapped = wrapText(content, columnWidths[i]!, { + hard: needsHardWrap, + }) + maxLines = Math.max(maxLines, wrapped.length) } // Check rows - for (const row_1 of token.rows) { - for (let i_2 = 0; i_2 < row_1.length; i_2++) { - const content_0 = formatCell(row_1[i_2]?.tokens); - const wrapped_0 = wrapText(content_0, columnWidths[i_2]!, { - hard: needsHardWrap - }); - maxLines = Math.max(maxLines, wrapped_0.length); + for (const row of token.rows) { + for (let i = 0; i < row.length; i++) { + const content = formatCell(row[i]?.tokens) + const wrapped = wrapText(content, columnWidths[i]!, { + hard: needsHardWrap, + }) + maxLines = Math.max(maxLines, wrapped.length) } } - return maxLines; + return maxLines } // Use vertical format if wrapping would make rows too tall - const maxRowLines = calculateMaxRowLines(); - const useVerticalFormat = maxRowLines > MAX_ROW_LINES; + const maxRowLines = calculateMaxRowLines() + const useVerticalFormat = maxRowLines > MAX_ROW_LINES // Render a single row with potential multi-line cells // Returns an array of strings, one per line of the row - function renderRowLines(cells: Array<{ - tokens?: Token[]; - }>, isHeader: boolean): string[] { + function renderRowLines( + cells: Array<{ tokens?: Token[] }>, + isHeader: boolean, + ): string[] { // Get wrapped lines for each cell (preserving ANSI formatting) - const cellLines = cells.map((cell, colIndex_1) => { - const formattedText = formatCell(cell.tokens); - const width = columnWidths[colIndex_1]!; - return wrapText(formattedText, width, { - hard: needsHardWrap - }); - }); + const cellLines = cells.map((cell, colIndex) => { + const formattedText = formatCell(cell.tokens) + const width = columnWidths[colIndex]! + return wrapText(formattedText, width, { hard: needsHardWrap }) + }) // Find max number of lines in this row - const maxLines_0 = Math.max(...cellLines.map(lines => lines.length), 1); + const maxLines = Math.max(...cellLines.map(lines => lines.length), 1) // Calculate vertical offset for each cell (to center vertically) - const verticalOffsets = cellLines.map(lines_0 => Math.floor((maxLines_0 - lines_0.length) / 2)); + const verticalOffsets = cellLines.map(lines => + Math.floor((maxLines - lines.length) / 2), + ) // Build each line of the row as a single string - const result: string[] = []; - for (let lineIdx = 0; lineIdx < maxLines_0; lineIdx++) { - let line = '│'; - for (let colIndex_2 = 0; colIndex_2 < cells.length; colIndex_2++) { - const lines_1 = cellLines[colIndex_2]!; - const offset = verticalOffsets[colIndex_2]!; - const contentLineIdx = lineIdx - offset; - const lineText = contentLineIdx >= 0 && contentLineIdx < lines_1.length ? lines_1[contentLineIdx]! : ''; - const width_0 = columnWidths[colIndex_2]!; + const result: string[] = [] + for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { + let line = '│' + for (let colIndex = 0; colIndex < cells.length; colIndex++) { + const lines = cellLines[colIndex]! + const offset = verticalOffsets[colIndex]! + const contentLineIdx = lineIdx - offset + const lineText = + contentLineIdx >= 0 && contentLineIdx < lines.length + ? lines[contentLineIdx]! + : '' + const width = columnWidths[colIndex]! // Headers always centered; data uses table alignment - const align = isHeader ? 'center' : token.align?.[colIndex_2] ?? 'left'; - line += ' ' + padAligned(lineText, stringWidth(lineText), width_0, align) + ' │'; + const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left') + + line += + ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │' } - result.push(line); + result.push(line) } - return result; + + return result } // Render horizontal border as a single string @@ -227,95 +247,110 @@ export function MarkdownTable({ const [left, mid, cross, right] = { top: ['┌', '─', '┬', '┐'], middle: ['├', '─', '┼', '┤'], - bottom: ['└', '─', '┴', '┘'] - }[type] as [string, string, string, string]; - let line_0 = left; - columnWidths.forEach((width_1, colIndex_3) => { - line_0 += mid.repeat(width_1 + 2); - line_0 += colIndex_3 < columnWidths.length - 1 ? cross : right; - }); - return line_0; + bottom: ['└', '─', '┴', '┘'], + }[type] as [string, string, string, string] + + let line = left + columnWidths.forEach((width, colIndex) => { + line += mid.repeat(width + 2) + line += colIndex < columnWidths.length - 1 ? cross : right + }) + return line } // Render vertical format (key-value pairs) for extra-narrow terminals function renderVerticalFormat(): string { - const lines_2: string[] = []; - const headers = token.header.map(h => getPlainText(h.tokens)); - const separatorWidth = Math.min(terminalWidth - 1, 40); - const separator = '─'.repeat(separatorWidth); + const lines: string[] = [] + const headers = token.header.map(h => getPlainText(h.tokens)) + const separatorWidth = Math.min(terminalWidth - 1, 40) + const separator = '─'.repeat(separatorWidth) // Small indent for wrapped lines (just 2 spaces) - const wrapIndent = ' '; - token.rows.forEach((row_2, rowIndex) => { + const wrapIndent = ' ' + + token.rows.forEach((row, rowIndex) => { if (rowIndex > 0) { - lines_2.push(separator); + lines.push(separator) } - row_2.forEach((cell_0, colIndex_4) => { - const label = headers[colIndex_4] || `Column ${colIndex_4 + 1}`; + + row.forEach((cell, colIndex) => { + const label = headers[colIndex] || `Column ${colIndex + 1}` // Clean value: trim, remove extra internal whitespace/newlines - const rawValue = formatCell(cell_0.tokens).trimEnd(); - const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); + const rawValue = formatCell(cell.tokens).trimEnd() + const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() // Wrap value to fit terminal, accounting for label on first line - const firstLineWidth = terminalWidth - stringWidth(label) - 3; - const subsequentLineWidth = terminalWidth - wrapIndent.length - 1; + const firstLineWidth = terminalWidth - stringWidth(label) - 3 + const subsequentLineWidth = terminalWidth - wrapIndent.length - 1 // Two-pass wrap: first line is narrower (label takes space), // continuation lines get the full width minus indent. - const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)); - const firstLine = firstPassLines[0] || ''; - let wrappedValue: string[]; - if (firstPassLines.length <= 1 || subsequentLineWidth <= firstLineWidth) { - wrappedValue = firstPassLines; + const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)) + const firstLine = firstPassLines[0] || '' + + let wrappedValue: string[] + if ( + firstPassLines.length <= 1 || + subsequentLineWidth <= firstLineWidth + ) { + wrappedValue = firstPassLines } else { // Re-join remaining text and re-wrap to the wider continuation width - const remainingText = firstPassLines.slice(1).map(l => l.trim()).join(' '); - const rewrapped = wrapText(remainingText, subsequentLineWidth); - wrappedValue = [firstLine, ...rewrapped]; + const remainingText = firstPassLines + .slice(1) + .map(l => l.trim()) + .join(' ') + const rewrapped = wrapText(remainingText, subsequentLineWidth) + wrappedValue = [firstLine, ...rewrapped] } // First line: bold label + value - lines_2.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`); + lines.push( + `${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`, + ) // Subsequent lines with small indent (skip empty lines) - for (let i_3 = 1; i_3 < wrappedValue.length; i_3++) { - const line_1 = wrappedValue[i_3]!; - if (!line_1.trim()) continue; - lines_2.push(`${wrapIndent}${line_1}`); + for (let i = 1; i < wrappedValue.length; i++) { + const line = wrappedValue[i]! + if (!line.trim()) continue + lines.push(`${wrapIndent}${line}`) } - }); - }); - return lines_2.join('\n'); + }) + }) + + return lines.join('\n') } // Choose format based on available width if (useVerticalFormat) { - return {renderVerticalFormat()}; + return {renderVerticalFormat()} } // Build the complete horizontal table as an array of strings - const tableLines: string[] = []; - tableLines.push(renderBorderLine('top')); - tableLines.push(...renderRowLines(token.header, true)); - tableLines.push(renderBorderLine('middle')); - token.rows.forEach((row_3, rowIndex_0) => { - tableLines.push(...renderRowLines(row_3, false)); - if (rowIndex_0 < token.rows.length - 1) { - tableLines.push(renderBorderLine('middle')); + const tableLines: string[] = [] + tableLines.push(renderBorderLine('top')) + tableLines.push(...renderRowLines(token.header, true)) + tableLines.push(renderBorderLine('middle')) + token.rows.forEach((row, rowIndex) => { + tableLines.push(...renderRowLines(row, false)) + if (rowIndex < token.rows.length - 1) { + tableLines.push(renderBorderLine('middle')) } - }); - tableLines.push(renderBorderLine('bottom')); + }) + tableLines.push(renderBorderLine('bottom')) // Safety check: verify no line exceeds terminal width. // This catches edge cases during terminal resize where calculations // were based on a different width than the current render target. - const maxLineWidth = Math.max(...tableLines.map(line_2 => stringWidth(stripAnsi(line_2)))); + const maxLineWidth = Math.max( + ...tableLines.map(line => stringWidth(stripAnsi(line))), + ) // If we're within SAFETY_MARGIN characters of the edge, use vertical format // to account for terminal resize race conditions. if (maxLineWidth > terminalWidth - SAFETY_MARGIN) { - return {renderVerticalFormat()}; + return {renderVerticalFormat()} } // Render as a single Ansi block to prevent Ink from wrapping mid-row - return {tableLines.join('\n')}; + return {tableLines.join('\n')} } diff --git a/src/components/MemoryUsageIndicator.tsx b/src/components/MemoryUsageIndicator.tsx index c7b0fefe8..37c91e778 100644 --- a/src/components/MemoryUsageIndicator.tsx +++ b/src/components/MemoryUsageIndicator.tsx @@ -1,36 +1,40 @@ -import * as React from 'react'; -import { useMemoryUsage } from '../hooks/useMemoryUsage.js'; -import { Box, Text } from '../ink.js'; -import { formatFileSize } from '../utils/format.js'; +import * as React from 'react' +import { useMemoryUsage } from '../hooks/useMemoryUsage.js' +import { Box, Text } from '../ink.js' +import { formatFileSize } from '../utils/format.js' + export function MemoryUsageIndicator(): React.ReactNode { // Ant-only: the /heapdump link is an internal debugging aid. Gating before // the hook means the 10s polling interval is never set up in external builds. // USER_TYPE is a build-time constant, so the hook call below is either always // reached or dead-code-eliminated — never conditional at runtime. - if ((process.env.USER_TYPE) !== 'ant') { - return null; + if ("external" !== 'ant') { + return null } // eslint-disable-next-line react-hooks/rules-of-hooks // biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant - const memoryUsage = useMemoryUsage(); + const memoryUsage = useMemoryUsage() + if (!memoryUsage) { - return null; + return null } - const { - heapUsed, - status - } = memoryUsage; + + const { heapUsed, status } = memoryUsage // Only show indicator when memory usage is high or critical if (status === 'normal') { - return null; + return null } - const formattedSize = formatFileSize(heapUsed); - const color = status === 'critical' ? 'error' : 'warning'; - return + + const formattedSize = formatFileSize(heapUsed) + const color = status === 'critical' ? 'error' : 'warning' + + return ( + High memory usage ({formattedSize}) · /heapdump - ; + + ) } diff --git a/src/components/Message.tsx b/src/components/Message.tsx index 02f5fb2c4..79a152682 100644 --- a/src/components/Message.tsx +++ b/src/components/Message.tsx @@ -1,626 +1,526 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'; -import type { ImageBlockParam, TextBlockParam, ThinkingBlockParam, ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import type { Command } from '../commands.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box } from '../ink.js'; -import type { Tools } from '../Tool.js'; -import { type ConnectorTextBlock, isConnectorTextBlock } from '../types/connectorText.js'; -import type { AssistantMessage, AttachmentMessage as AttachmentMessageType, CollapsedReadSearchGroup as CollapsedReadSearchGroupType, GroupedToolUseMessage as GroupedToolUseMessageType, NormalizedUserMessage, ProgressMessage, SystemMessage } from '../types/message.js'; -import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import { logError } from '../utils/log.js'; -import type { buildMessageLookups } from '../utils/messages.js'; -import { CompactSummary } from './CompactSummary.js'; -import { AdvisorMessage } from './messages/AdvisorMessage.js'; -import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js'; -import { AssistantTextMessage } from './messages/AssistantTextMessage.js'; -import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; -import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js'; -import { AttachmentMessage } from './messages/AttachmentMessage.js'; -import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js'; -import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js'; -import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js'; -import { SystemTextMessage } from './messages/SystemTextMessage.js'; -import { UserImageMessage } from './messages/UserImageMessage.js'; -import { UserTextMessage } from './messages/UserTextMessage.js'; -import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js'; -import { OffscreenFreeze } from './OffscreenFreeze.js'; -import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js'; +import { feature } from 'bun:bundle' +import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { + ImageBlockParam, + TextBlockParam, + ThinkingBlockParam, + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import type { Command } from '../commands.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box } from '../ink.js' +import type { Tools } from '../Tool.js' +import { + type ConnectorTextBlock, + isConnectorTextBlock, +} from '../types/connectorText.js' +import type { + AssistantMessage, + AttachmentMessage as AttachmentMessageType, + CollapsedReadSearchGroup as CollapsedReadSearchGroupType, + GroupedToolUseMessage as GroupedToolUseMessageType, + NormalizedUserMessage, + ProgressMessage, + SystemMessage, +} from '../types/message.js' +import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import { logError } from '../utils/log.js' +import type { buildMessageLookups } from '../utils/messages.js' +import { CompactSummary } from './CompactSummary.js' +import { AdvisorMessage } from './messages/AdvisorMessage.js' +import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js' +import { AssistantTextMessage } from './messages/AssistantTextMessage.js' +import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js' +import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js' +import { AttachmentMessage } from './messages/AttachmentMessage.js' +import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js' +import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js' +import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js' +import { SystemTextMessage } from './messages/SystemTextMessage.js' +import { UserImageMessage } from './messages/UserImageMessage.js' +import { UserTextMessage } from './messages/UserTextMessage.js' +import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js' +import { OffscreenFreeze } from './OffscreenFreeze.js' +import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js' + export type Props = { - message: NormalizedUserMessage | AssistantMessage | AttachmentMessageType | SystemMessage | GroupedToolUseMessageType | CollapsedReadSearchGroupType; - lookups: ReturnType; + message: + | NormalizedUserMessage + | AssistantMessage + | AttachmentMessageType + | SystemMessage + | GroupedToolUseMessageType + | CollapsedReadSearchGroupType + lookups: ReturnType // TODO: Find a way to remove this, and leave spacing to the consumer /** Absolute width for the container Box. When provided, eliminates a wrapper Box in the caller. */ - containerWidth?: number; - addMargin: boolean; - tools: Tools; - commands: Command[]; - verbose: boolean; - inProgressToolUseIDs: Set; - progressMessagesForMessage: ProgressMessage[]; - shouldAnimate: boolean; - shouldShowDot: boolean; - style?: 'condensed'; - width?: number | string; - isTranscriptMode: boolean; - isStatic: boolean; - onOpenRateLimitOptions?: () => void; - isActiveCollapsedGroup?: boolean; - isUserContinuation?: boolean; + containerWidth?: number + addMargin: boolean + tools: Tools + commands: Command[] + verbose: boolean + inProgressToolUseIDs: Set + progressMessagesForMessage: ProgressMessage[] + shouldAnimate: boolean + shouldShowDot: boolean + style?: 'condensed' + width?: number | string + isTranscriptMode: boolean + isStatic: boolean + onOpenRateLimitOptions?: () => void + isActiveCollapsedGroup?: boolean + isUserContinuation?: boolean /** ID of the last thinking block (uuid:index) to show, used for hiding past thinking in transcript mode */ - lastThinkingBlockId?: string | null; + lastThinkingBlockId?: string | null /** UUID of the latest user bash output message (for auto-expanding) */ - latestBashOutputUUID?: string | null; -}; -function MessageImpl(t0) { - const $ = _c(94); - const { - message, - lookups, - containerWidth, - addMargin, - tools, - commands, - verbose, - inProgressToolUseIDs, - progressMessagesForMessage, - shouldAnimate, - shouldShowDot, - style, - width, - isTranscriptMode, - onOpenRateLimitOptions, - isActiveCollapsedGroup, - isUserContinuation: t1, - lastThinkingBlockId, - latestBashOutputUUID - } = t0; - const isUserContinuation = t1 === undefined ? false : t1; + latestBashOutputUUID?: string | null +} + +function MessageImpl({ + message, + lookups, + containerWidth, + addMargin, + tools, + commands, + verbose, + inProgressToolUseIDs, + progressMessagesForMessage, + shouldAnimate, + shouldShowDot, + style, + width, + isTranscriptMode, + onOpenRateLimitOptions, + isActiveCollapsedGroup, + isUserContinuation = false, + lastThinkingBlockId, + latestBashOutputUUID, +}: Props): React.ReactNode { switch (message.type) { - case "attachment": - { - let t2; - if ($[0] !== addMargin || $[1] !== isTranscriptMode || $[2] !== message.attachment || $[3] !== verbose) { - t2 = ; - $[0] = addMargin; - $[1] = isTranscriptMode; - $[2] = message.attachment; - $[3] = verbose; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + case 'attachment': + return ( + + ) + case 'assistant': + return ( + + {message.message.content.map((_, index) => ( + + ))} + + ) + case 'user': { + if (message.isCompactSummary) { + return ( + + ) } - case "assistant": - { - const t2 = containerWidth ?? "100%"; - let t3; - if ($[5] !== addMargin || $[6] !== commands || $[7] !== inProgressToolUseIDs || $[8] !== isTranscriptMode || $[9] !== lastThinkingBlockId || $[10] !== lookups || $[11] !== message.advisorModel || $[12] !== message.message.content || $[13] !== message.uuid || $[14] !== onOpenRateLimitOptions || $[15] !== progressMessagesForMessage || $[16] !== shouldAnimate || $[17] !== shouldShowDot || $[18] !== tools || $[19] !== verbose || $[20] !== width) { - let t4; - if ($[22] !== addMargin || $[23] !== commands || $[24] !== inProgressToolUseIDs || $[25] !== isTranscriptMode || $[26] !== lastThinkingBlockId || $[27] !== lookups || $[28] !== message.advisorModel || $[29] !== message.uuid || $[30] !== onOpenRateLimitOptions || $[31] !== progressMessagesForMessage || $[32] !== shouldAnimate || $[33] !== shouldShowDot || $[34] !== tools || $[35] !== verbose || $[36] !== width) { - t4 = (_, index_0) => ; - $[22] = addMargin; - $[23] = commands; - $[24] = inProgressToolUseIDs; - $[25] = isTranscriptMode; - $[26] = lastThinkingBlockId; - $[27] = lookups; - $[28] = message.advisorModel; - $[29] = message.uuid; - $[30] = onOpenRateLimitOptions; - $[31] = progressMessagesForMessage; - $[32] = shouldAnimate; - $[33] = shouldShowDot; - $[34] = tools; - $[35] = verbose; - $[36] = width; - $[37] = t4; - } else { - t4 = $[37]; - } - t3 = message.message.content.map(t4); - $[5] = addMargin; - $[6] = commands; - $[7] = inProgressToolUseIDs; - $[8] = isTranscriptMode; - $[9] = lastThinkingBlockId; - $[10] = lookups; - $[11] = message.advisorModel; - $[12] = message.message.content; - $[13] = message.uuid; - $[14] = onOpenRateLimitOptions; - $[15] = progressMessagesForMessage; - $[16] = shouldAnimate; - $[17] = shouldShowDot; - $[18] = tools; - $[19] = verbose; - $[20] = width; - $[21] = t3; - } else { - t3 = $[21]; - } - let t4; - if ($[38] !== t2 || $[39] !== t3) { - t4 = {t3}; - $[38] = t2; - $[39] = t3; - $[40] = t4; + // Precompute the imageIndex prop for each content block. The previous + // version incremented a counter inside the .map() callback, which + // React Compiler bails on ("UpdateExpression to variables captured + // within lambdas"). A plain for loop keeps the mutation out of a + // closure so the compiler can memoize MessageImpl. + const imageIndices: number[] = [] + let imagePosition = 0 + for (const param of message.message.content) { + if (param.type === 'image') { + const id = message.imagePasteIds?.[imagePosition] + imagePosition++ + imageIndices.push(id ?? imagePosition) } else { - t4 = $[40]; + imageIndices.push(imagePosition) } - return t4; } - case "user": - { - if (message.isCompactSummary) { - const t2 = isTranscriptMode ? "transcript" : "prompt"; - let t3; - if ($[41] !== message || $[42] !== t2) { - t3 = ; - $[41] = message; - $[42] = t2; - $[43] = t3; - } else { - t3 = $[43]; - } - return t3; - } - let imageIndices; - if ($[44] !== message.imagePasteIds || $[45] !== message.message.content) { - imageIndices = []; - let imagePosition = 0; - for (const param of message.message.content) { - if (param.type === "image") { - const id = message.imagePasteIds?.[imagePosition]; - imagePosition++; - imageIndices.push(id ?? imagePosition); - } else { - imageIndices.push(imagePosition); - } - } - $[44] = message.imagePasteIds; - $[45] = message.message.content; - $[46] = imageIndices; - } else { - imageIndices = $[46]; - } - const isLatestBashOutput = latestBashOutputUUID === message.uuid; - const t2 = containerWidth ?? "100%"; - let t3; - if ($[47] !== addMargin || $[48] !== imageIndices || $[49] !== isTranscriptMode || $[50] !== isUserContinuation || $[51] !== lookups || $[52] !== message || $[53] !== progressMessagesForMessage || $[54] !== style || $[55] !== tools || $[56] !== verbose) { - t3 = message.message.content.map((param_0, index) => ); - $[47] = addMargin; - $[48] = imageIndices; - $[49] = isTranscriptMode; - $[50] = isUserContinuation; - $[51] = lookups; - $[52] = message; - $[53] = progressMessagesForMessage; - $[54] = style; - $[55] = tools; - $[56] = verbose; - $[57] = t3; - } else { - t3 = $[57]; - } - let t4; - if ($[58] !== t2 || $[59] !== t3) { - t4 = {t3}; - $[58] = t2; - $[59] = t3; - $[60] = t4; - } else { - t4 = $[60]; - } - const content = t4; - let t5; - if ($[61] !== content || $[62] !== isLatestBashOutput) { - t5 = isLatestBashOutput ? {content} : content; - $[61] = content; - $[62] = isLatestBashOutput; - $[63] = t5; - } else { - t5 = $[63]; + // Check if this message is the latest bash output - if so, wrap content + // with provider so OutputLine can show full output via context + const isLatestBashOutput = latestBashOutputUUID === message.uuid + const content = ( + + {message.message.content.map((param, index) => ( + + ))} + + ) + return isLatestBashOutput ? ( + {content} + ) : ( + content + ) + } + case 'system': + if (message.subtype === 'compact_boundary') { + // Fullscreen keeps pre-compact messages in the ScrollBox (REPL.tsx + // appends instead of resetting, Messages.tsx skips the boundary + // filter) — scroll up for history, no need for the ctrl+o hint. + if (isFullscreenEnvEnabled()) { + return null } - return t5; + return } - case "system": - { - if (message.subtype === "compact_boundary") { - if (isFullscreenEnvEnabled()) { - return null; - } - let t2; - if ($[64] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[64] = t2; - } else { - t2 = $[64]; - } - return t2; - } - if (message.subtype === "microcompact_boundary") { - return null; - } - if (feature("HISTORY_SNIP")) { - const { - isSnipBoundaryMessage - } = require("../services/compact/snipProjection.js") as typeof import('../services/compact/snipProjection.js'); - const { - isSnipMarkerMessage - } = require("../services/compact/snipCompact.js") as typeof import('../services/compact/snipCompact.js'); - if (isSnipBoundaryMessage(message)) { - let t2; - if ($[65] === Symbol.for("react.memo_cache_sentinel")) { - t2 = require("./messages/SnipBoundaryMessage.js"); - $[65] = t2; - } else { - t2 = $[65]; - } - const { - SnipBoundaryMessage - } = t2 as typeof import('./messages/SnipBoundaryMessage.js'); - let t3; - if ($[66] !== message) { - t3 = ; - $[66] = message; - $[67] = t3; - } else { - t3 = $[67]; - } - return t3; - } - if (isSnipMarkerMessage(message)) { - return null; - } - } - if (message.subtype === "local_command") { - let t2; - if ($[68] !== message.content) { - t2 = { - type: "text", - text: message.content - }; - $[68] = message.content; - $[69] = t2; - } else { - t2 = $[69]; - } - let t3; - if ($[70] !== addMargin || $[71] !== isTranscriptMode || $[72] !== t2 || $[73] !== verbose) { - t3 = ; - $[70] = addMargin; - $[71] = isTranscriptMode; - $[72] = t2; - $[73] = verbose; - $[74] = t3; - } else { - t3 = $[74]; - } - return t3; - } - let t2; - if ($[75] !== addMargin || $[76] !== isTranscriptMode || $[77] !== message || $[78] !== verbose) { - t2 = ; - $[75] = addMargin; - $[76] = isTranscriptMode; - $[77] = message; - $[78] = verbose; - $[79] = t2; - } else { - t2 = $[79]; - } - return t2; + if (message.subtype === 'microcompact_boundary') { + // Logged at creation time in createMicrocompactBoundaryMessage + return null } - case "grouped_tool_use": - { - let t2; - if ($[80] !== inProgressToolUseIDs || $[81] !== lookups || $[82] !== message || $[83] !== shouldAnimate || $[84] !== tools) { - t2 = ; - $[80] = inProgressToolUseIDs; - $[81] = lookups; - $[82] = message; - $[83] = shouldAnimate; - $[84] = tools; - $[85] = t2; - } else { - t2 = $[85]; + if (feature('HISTORY_SNIP')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isSnipBoundaryMessage } = + require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js') + const { isSnipMarkerMessage } = + require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + if (isSnipBoundaryMessage(message)) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { SnipBoundaryMessage } = + require('./messages/SnipBoundaryMessage.js') as typeof import('./messages/SnipBoundaryMessage.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + return } - return t2; - } - case "collapsed_read_search": - { - const t2 = verbose || isTranscriptMode; - let t3; - if ($[86] !== inProgressToolUseIDs || $[87] !== isActiveCollapsedGroup || $[88] !== lookups || $[89] !== message || $[90] !== shouldAnimate || $[91] !== t2 || $[92] !== tools) { - t3 = ; - $[86] = inProgressToolUseIDs; - $[87] = isActiveCollapsedGroup; - $[88] = lookups; - $[89] = message; - $[90] = shouldAnimate; - $[91] = t2; - $[92] = tools; - $[93] = t3; - } else { - t3 = $[93]; + if (isSnipMarkerMessage(message)) { + // Internal registration marker — not user-facing. The boundary + // message (above) is what shows when snips actually execute. + return null } - return t3; } + if (message.subtype === 'local_command') { + return ( + + ) + } + return ( + + ) + case 'grouped_tool_use': + return ( + + ) + case 'collapsed_read_search': + // OffscreenFreeze: the verb flips "Reading…"→"Read" when tools complete. + // If the group has scrolled into scrollback by then, the update triggers + // a full terminal reset (CC-1155). This component is never marked static + // in prompt mode (shouldRenderStatically returns false to allow live + // updates between API turns), so the memo can't help. Freeze when + // offscreen — scrollback shows whatever state was visible when it left. + return ( + + + + ) } } -function UserMessage(t0) { - const $ = _c(20); - const { - message, - addMargin, - tools, - progressMessagesForMessage, - param, - style, - verbose, - imageIndex, - isUserContinuation, - lookups, - isTranscriptMode - } = t0; - const { - columns - } = useTerminalSize(); + +function UserMessage({ + message, + addMargin, + tools, + progressMessagesForMessage, + param, + style, + verbose, + imageIndex, + isUserContinuation, + lookups, + isTranscriptMode, +}: { + message: NormalizedUserMessage + addMargin: boolean + tools: Tools + progressMessagesForMessage: ProgressMessage[] + param: + | TextBlockParam + | ImageBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + style?: 'condensed' + verbose: boolean + imageIndex?: number + isUserContinuation: boolean + lookups: ReturnType + isTranscriptMode: boolean +}): React.ReactNode { + const { columns } = useTerminalSize() switch (param.type) { - case "text": - { - let t1; - if ($[0] !== addMargin || $[1] !== isTranscriptMode || $[2] !== message.planContent || $[3] !== message.timestamp || $[4] !== param || $[5] !== verbose) { - t1 = ; - $[0] = addMargin; - $[1] = isTranscriptMode; - $[2] = message.planContent; - $[3] = message.timestamp; - $[4] = param; - $[5] = verbose; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; - } - case "image": - { - const t1 = addMargin && !isUserContinuation; - let t2; - if ($[7] !== imageIndex || $[8] !== t1) { - t2 = ; - $[7] = imageIndex; - $[8] = t1; - $[9] = t2; - } else { - t2 = $[9]; - } - return t2; - } - case "tool_result": - { - const t1 = columns - 5; - let t2; - if ($[10] !== isTranscriptMode || $[11] !== lookups || $[12] !== message || $[13] !== param || $[14] !== progressMessagesForMessage || $[15] !== style || $[16] !== t1 || $[17] !== tools || $[18] !== verbose) { - t2 = ; - $[10] = isTranscriptMode; - $[11] = lookups; - $[12] = message; - $[13] = param; - $[14] = progressMessagesForMessage; - $[15] = style; - $[16] = t1; - $[17] = tools; - $[18] = verbose; - $[19] = t2; - } else { - t2 = $[19]; - } - return t2; - } + case 'text': + return ( + + ) + case 'image': + // If previous message is user (text or image), this is a continuation - use connector + // Otherwise this image starts a new user turn - use margin + return ( + + ) + case 'tool_result': + return ( + + ) default: - { - return; - } + return undefined } } -function AssistantMessageBlock(t0) { - const $ = _c(45); - const { - param, - addMargin, - tools, - commands, - verbose, - inProgressToolUseIDs, - progressMessagesForMessage, - shouldAnimate, - shouldShowDot, - width, - inProgressToolCallCount, - isTranscriptMode, - lookups, - onOpenRateLimitOptions, - thinkingBlockId, - lastThinkingBlockId, - advisorModel - } = t0; - if (feature("CONNECTOR_TEXT")) { + +function AssistantMessageBlock({ + param, + addMargin, + tools, + commands, + verbose, + inProgressToolUseIDs, + progressMessagesForMessage, + shouldAnimate, + shouldShowDot, + width, + inProgressToolCallCount, + isTranscriptMode, + lookups, + onOpenRateLimitOptions, + thinkingBlockId, + lastThinkingBlockId, + advisorModel, +}: { + param: + | BetaContentBlock + | ConnectorTextBlock + | AdvisorBlock + | TextBlockParam + | ImageBlockParam + | ThinkingBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + addMargin: boolean + tools: Tools + commands: Command[] + verbose: boolean + inProgressToolUseIDs: Set + progressMessagesForMessage: ProgressMessage[] + shouldAnimate: boolean + shouldShowDot: boolean + width?: number | string + inProgressToolCallCount?: number + isTranscriptMode: boolean + lookups: ReturnType + onOpenRateLimitOptions?: () => void + /** ID of this content block's message:index for thinking block comparison */ + thinkingBlockId: string + /** ID of the last thinking block to show, null means show all */ + lastThinkingBlockId?: string | null + advisorModel?: string +}): React.ReactNode { + if (feature('CONNECTOR_TEXT')) { if (isConnectorTextBlock(param)) { - let t1; - if ($[0] !== param.connector_text) { - t1 = { - type: "text", - text: param.connector_text - }; - $[0] = param.connector_text; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== addMargin || $[3] !== onOpenRateLimitOptions || $[4] !== shouldShowDot || $[5] !== t1 || $[6] !== verbose || $[7] !== width) { - t2 = ; - $[2] = addMargin; - $[3] = onOpenRateLimitOptions; - $[4] = shouldShowDot; - $[5] = t1; - $[6] = verbose; - $[7] = width; - $[8] = t2; - } else { - t2 = $[8]; - } - return t2; + return ( + + ) } } switch (param.type) { - case "tool_use": - { - let t1; - if ($[9] !== addMargin || $[10] !== commands || $[11] !== inProgressToolCallCount || $[12] !== inProgressToolUseIDs || $[13] !== isTranscriptMode || $[14] !== lookups || $[15] !== param || $[16] !== progressMessagesForMessage || $[17] !== shouldAnimate || $[18] !== shouldShowDot || $[19] !== tools || $[20] !== verbose) { - t1 = ; - $[9] = addMargin; - $[10] = commands; - $[11] = inProgressToolCallCount; - $[12] = inProgressToolUseIDs; - $[13] = isTranscriptMode; - $[14] = lookups; - $[15] = param; - $[16] = progressMessagesForMessage; - $[17] = shouldAnimate; - $[18] = shouldShowDot; - $[19] = tools; - $[20] = verbose; - $[21] = t1; - } else { - t1 = $[21]; - } - return t1; - } - case "text": - { - let t1; - if ($[22] !== addMargin || $[23] !== onOpenRateLimitOptions || $[24] !== param || $[25] !== shouldShowDot || $[26] !== verbose || $[27] !== width) { - t1 = ; - $[22] = addMargin; - $[23] = onOpenRateLimitOptions; - $[24] = param; - $[25] = shouldShowDot; - $[26] = verbose; - $[27] = width; - $[28] = t1; - } else { - t1 = $[28]; - } - return t1; + case 'tool_use': + return ( + + ) + case 'text': + return ( + + ) + case 'redacted_thinking': + if (!isTranscriptMode && !verbose) { + return null } - case "redacted_thinking": - { - if (!isTranscriptMode && !verbose) { - return null; - } - let t1; - if ($[29] !== addMargin) { - t1 = ; - $[29] = addMargin; - $[30] = t1; - } else { - t1 = $[30]; - } - return t1; - } - case "thinking": - { - if (!isTranscriptMode && !verbose) { - return null; - } - const isLastThinking = !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId; - const t1 = isTranscriptMode && !isLastThinking; - let t2; - if ($[31] !== addMargin || $[32] !== isTranscriptMode || $[33] !== param || $[34] !== t1 || $[35] !== verbose) { - t2 = ; - $[31] = addMargin; - $[32] = isTranscriptMode; - $[33] = param; - $[34] = t1; - $[35] = verbose; - $[36] = t2; - } else { - t2 = $[36]; - } - return t2; + return + case 'thinking': { + if (!isTranscriptMode && !verbose) { + return null } - case "server_tool_use": - case "advisor_tool_result": - { - if (isAdvisorBlock(param)) { - const t1 = verbose || isTranscriptMode; - let t2; - if ($[37] !== addMargin || $[38] !== advisorModel || $[39] !== lookups.erroredToolUseIDs || $[40] !== lookups.resolvedToolUseIDs || $[41] !== param || $[42] !== shouldAnimate || $[43] !== t1) { - t2 = ; - $[37] = addMargin; - $[38] = advisorModel; - $[39] = lookups.erroredToolUseIDs; - $[40] = lookups.resolvedToolUseIDs; - $[41] = param; - $[42] = shouldAnimate; - $[43] = t1; - $[44] = t2; - } else { - t2 = $[44]; - } - return t2; - } - logError(new Error(`Unable to render server tool block: ${param.type}`)); - return null; + // In transcript mode with hidePastThinking, only show the last thinking block + const isLastThinking = + !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId + return ( + + ) + } + case 'server_tool_use': + case 'advisor_tool_result': + if (isAdvisorBlock(param)) { + return ( + + ) } + logError(new Error(`Unable to render server tool block: ${param.type}`)) + return null default: - { - logError(new Error(`Unable to render message type: ${param.type}`)); - return null; - } + logError(new Error(`Unable to render message type: ${param.type}`)) + return null } } + export function hasThinkingContent(m: { - type: string; - message?: { - content: Array<{ - type: string; - }>; - }; + type: string + message?: { content: Array<{ type: string }> } }): boolean { - if (m.type !== 'assistant' || !m.message) return false; - return m.message.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking'); + if (m.type !== 'assistant' || !m.message) return false + return m.message.content.some( + b => b.type === 'thinking' || b.type === 'redacted_thinking', + ) } /** Exported for testing */ export function areMessagePropsEqual(prev: Props, next: Props): boolean { - if (prev.message.uuid !== next.message.uuid) return false; + if (prev.message.uuid !== next.message.uuid) return false // Only re-render on lastThinkingBlockId change if this message actually // has thinking content — otherwise every message in scrollback re-renders // whenever streaming thinking starts/stops (CC-941). - if (prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as any)) { - return false; + if ( + prev.lastThinkingBlockId !== next.lastThinkingBlockId && + hasThinkingContent(next.message) + ) { + return false } // Verbose toggle changes thinking block visibility/expansion - if (prev.verbose !== next.verbose) return false; + if (prev.verbose !== next.verbose) return false // Only re-render if this message's "is latest bash output" status changed, // not when the global latestBashOutputUUID changes to a different message - const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid; - const nextIsLatest = next.latestBashOutputUUID === next.message.uuid; - if (prevIsLatest !== nextIsLatest) return false; - if (prev.isTranscriptMode !== next.isTranscriptMode) return false; + const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid + const nextIsLatest = next.latestBashOutputUUID === next.message.uuid + if (prevIsLatest !== nextIsLatest) return false + if (prev.isTranscriptMode !== next.isTranscriptMode) return false // containerWidth is an absolute number in the no-metadata path (wrapper // Box is skipped). Static messages must re-render on terminal resize. - if (prev.containerWidth !== next.containerWidth) return false; - if (prev.isStatic && next.isStatic) return true; - return false; + if (prev.containerWidth !== next.containerWidth) return false + if (prev.isStatic && next.isStatic) return true + return false } -export const Message = React.memo(MessageImpl, areMessagePropsEqual); + +export const Message = React.memo(MessageImpl, areMessagePropsEqual) diff --git a/src/components/MessageModel.tsx b/src/components/MessageModel.tsx index aca627f0f..a99f861e6 100644 --- a/src/components/MessageModel.tsx +++ b/src/components/MessageModel.tsx @@ -1,42 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import type { NormalizedMessage } from '../types/message.js'; +import React from 'react' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import type { NormalizedMessage } from '../types/message.js' + type Props = { - message: NormalizedMessage; - isTranscriptMode: boolean; -}; -export function MessageModel(t0) { - const $ = _c(5); - const { - message, - isTranscriptMode - } = t0; - const shouldShowModel = isTranscriptMode && message.type === "assistant" && message.message.model && message.message.content.some(_temp); + message: NormalizedMessage + isTranscriptMode: boolean +} + +export function MessageModel({ + message, + isTranscriptMode, +}: Props): React.ReactNode { + const shouldShowModel = + isTranscriptMode && + message.type === 'assistant' && + message.message.model && + message.message.content.some(c => c.type === 'text') + if (!shouldShowModel) { - return null; - } - const t1 = stringWidth(message.message.model) + 8; - let t2; - if ($[0] !== message.message.model) { - t2 = {message.message.model}; - $[0] = message.message.model; - $[1] = t2; - } else { - t2 = $[1]; + return null } - let t3; - if ($[2] !== t1 || $[3] !== t2) { - t3 = {t2}; - $[2] = t1; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} -function _temp(c) { - return c.type === "text"; + + return ( + + {message.message.model} + + ) } diff --git a/src/components/MessageResponse.tsx b/src/components/MessageResponse.tsx index 8a23b29f5..f71d40ce8 100644 --- a/src/components/MessageResponse.tsx +++ b/src/components/MessageResponse.tsx @@ -1,77 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useContext } from 'react'; -import { Box, NoSelect, Text } from '../ink.js'; -import { Ratchet } from './design-system/Ratchet.js'; +import * as React from 'react' +import { useContext } from 'react' +import { Box, NoSelect, Text } from '../ink.js' +import { Ratchet } from './design-system/Ratchet.js' + type Props = { - children: React.ReactNode; - height?: number; -}; -export function MessageResponse(t0) { - const $ = _c(8); - const { - children, - height - } = t0; - const isMessageResponse = useContext(MessageResponseContext); + children: React.ReactNode + height?: number +} + +export function MessageResponse({ children, height }: Props): React.ReactNode { + const isMessageResponse = useContext(MessageResponseContext) if (isMessageResponse) { - return children; - } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {" "}⎿  ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== children) { - t2 = {children}; - $[1] = children; - $[2] = t2; - } else { - t2 = $[2]; + return children } - let t3; - if ($[3] !== height || $[4] !== t2) { - t3 = {t1}{t2}; - $[3] = height; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const content = t3; + const content = ( + + + + {' '}⎿   + + + {children} + + + + ) if (height !== undefined) { - return content; - } - let t4; - if ($[6] !== content) { - t4 = {content}; - $[6] = content; - $[7] = t4; - } else { - t4 = $[7]; + return content } - return t4; + return {content} } // This is a context that is used to determine if the message response // is rendered as a descendant of another MessageResponse. We use it // to avoid rendering nested ⎿ characters. -const MessageResponseContext = React.createContext(false); -function MessageResponseProvider(t0) { - const $ = _c(2); - const { - children - } = t0; - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const MessageResponseContext = React.createContext(false) + +function MessageResponseProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + + {children} + + ) } diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index fdbb19e73..e42fb9d96 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -1,44 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import type { Command } from '../commands.js'; -import { Box } from '../ink.js'; -import type { Screen } from '../screens/REPL.js'; -import type { Tools } from '../Tool.js'; -import type { RenderableMessage } from '../types/message.js'; -import { getDisplayMessageFromCollapsed, getToolSearchOrReadInfo, getToolUseIdsFromCollapsedGroup, hasAnyToolInProgress } from '../utils/collapseReadSearch.js'; -import { type buildMessageLookups, EMPTY_STRING_SET, getProgressMessagesFromLookup, getSiblingToolUseIDsFromLookup, getToolUseID } from '../utils/messages.js'; -import { hasThinkingContent, Message } from './Message.js'; -import { MessageModel } from './MessageModel.js'; -import { shouldRenderStatically } from './Messages.js'; -import { MessageTimestamp } from './MessageTimestamp.js'; +import * as React from 'react' +import type { Command } from '../commands.js' +import { Box } from '../ink.js' +import type { Screen } from '../screens/REPL.js' +import type { Tools } from '../Tool.js' +import type { RenderableMessage } from '../types/message.js' +import { + getDisplayMessageFromCollapsed, + getToolSearchOrReadInfo, + getToolUseIdsFromCollapsedGroup, + hasAnyToolInProgress, +} from '../utils/collapseReadSearch.js' +import { + type buildMessageLookups, + EMPTY_STRING_SET, + getProgressMessagesFromLookup, + getSiblingToolUseIDsFromLookup, + getToolUseID, +} from '../utils/messages.js' +import { hasThinkingContent, Message } from './Message.js' +import { MessageModel } from './MessageModel.js' +import { shouldRenderStatically } from './Messages.js' +import { MessageTimestamp } from './MessageTimestamp.js' +import { OffscreenFreeze } from './OffscreenFreeze.js' -/** Narrowed content block shape used for type assertions on MessageContent arrays. */ -type ContentBlockLike = { type: string; name?: string; input?: unknown; id?: string; text?: string }; -import { OffscreenFreeze } from './OffscreenFreeze.js'; export type Props = { - message: RenderableMessage; + message: RenderableMessage /** Whether the previous message in renderableMessages is also a user message. */ - isUserContinuation: boolean; + isUserContinuation: boolean /** * Whether there is non-skippable content after this message in renderableMessages. * Only needs to be accurate for `collapsed_read_search` messages — used to decide * if the collapsed group spinner should stay active. Pass `false` otherwise. */ - hasContentAfter: boolean; - tools: Tools; - commands: Command[]; - verbose: boolean; - inProgressToolUseIDs: Set; - streamingToolUseIDs: Set; - screen: Screen; - canAnimate: boolean; - onOpenRateLimitOptions?: () => void; - lastThinkingBlockId: string | null; - latestBashOutputUUID: string | null; - columns: number; - isLoading: boolean; - lookups: ReturnType; -}; + hasContentAfter: boolean + tools: Tools + commands: Command[] + verbose: boolean + inProgressToolUseIDs: Set + streamingToolUseIDs: Set + screen: Screen + canAnimate: boolean + onOpenRateLimitOptions?: () => void + lastThinkingBlockId: string | null + latestBashOutputUUID: string | null + columns: number + isLoading: boolean + lookups: ReturnType +} /** * Scans forward from `index+1` to check if any "real" content follows. Used to @@ -50,290 +58,244 @@ export type Props = { * to each MessageRow (which React Compiler would pin in the fiber's memoCache, * accumulating every historical version of the array ≈ 1-2MB over a 7-turn session). */ -export function hasContentAfterIndex(messages: RenderableMessage[], index: number, tools: Tools, streamingToolUseIDs: Set): boolean { +export function hasContentAfterIndex( + messages: RenderableMessage[], + index: number, + tools: Tools, + streamingToolUseIDs: Set, +): boolean { for (let i = index + 1; i < messages.length; i++) { - const msg = messages[i]; + const msg = messages[i] if (msg?.type === 'assistant') { - const content = (msg.message.content as ContentBlockLike[])[0]; - if (content?.type === 'thinking' || content?.type === 'redacted_thinking') { - continue; + const content = msg.message.content[0] + if ( + content?.type === 'thinking' || + content?.type === 'redacted_thinking' + ) { + continue } if (content?.type === 'tool_use') { - if (getToolSearchOrReadInfo(content.name!, content.input, tools).isCollapsible) { - continue; + if ( + getToolSearchOrReadInfo(content.name, content.input, tools) + .isCollapsible + ) { + continue } // Non-collapsible tool uses appear in syntheticStreamingToolUseMessages // before their ID is added to inProgressToolUseIDs. Skip while streaming // to avoid briefly finalizing the read group. - if (streamingToolUseIDs.has(content.id!)) { - continue; + if (streamingToolUseIDs.has(content.id)) { + continue } } - return true; + return true } if (msg?.type === 'system' || msg?.type === 'attachment') { - continue; + continue } // Tool results arrive while the collapsed group is still being built if (msg?.type === 'user') { - const content = (msg.message.content as ContentBlockLike[])[0]; + const content = msg.message.content[0] if (content?.type === 'tool_result') { - continue; + continue } } // Collapsible grouped_tool_use messages arrive transiently before being // merged into the current collapsed group on the next render cycle if (msg?.type === 'grouped_tool_use') { - const firstInput = (msg.messages[0]?.message.content as ContentBlockLike[])?.[0]?.input; - if (getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible) { - continue; + const firstInput = msg.messages[0]?.message.content[0]?.input + if ( + getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible + ) { + continue } } - return true; + return true } - return false; + return false } -function MessageRowImpl(t0) { - const $ = _c(64); - const { - message: msg, - isUserContinuation, - hasContentAfter, - tools, - commands, - verbose, - inProgressToolUseIDs, + +function MessageRowImpl({ + message: msg, + isUserContinuation, + hasContentAfter, + tools, + commands, + verbose, + inProgressToolUseIDs, + streamingToolUseIDs, + screen, + canAnimate, + onOpenRateLimitOptions, + lastThinkingBlockId, + latestBashOutputUUID, + columns, + isLoading, + lookups, +}: Props): React.ReactNode { + const isTranscriptMode = screen === 'transcript' + const isGrouped = msg.type === 'grouped_tool_use' + const isCollapsed = msg.type === 'collapsed_read_search' + + // A collapsed group is "active" (grey dot, present tense "Reading…") when its tools + // are still executing OR when the overall query is still running with nothing after it. + // hasAnyToolInProgress takes priority: if tools are running, always show active regardless + // of what else is in the message list (avoids false finalization during parallel execution). + const isActiveCollapsedGroup = + isCollapsed && + (hasAnyToolInProgress(msg, inProgressToolUseIDs) || + (isLoading && !hasContentAfter)) + + const displayMsg = isGrouped + ? msg.displayMessage + : isCollapsed + ? getDisplayMessageFromCollapsed(msg) + : msg + + const progressMessagesForMessage = + isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups) + + const siblingToolUseIDs = + isGrouped || isCollapsed + ? EMPTY_STRING_SET + : getSiblingToolUseIDsFromLookup(msg, lookups) + + const isStatic = shouldRenderStatically( + msg, streamingToolUseIDs, + inProgressToolUseIDs, + siblingToolUseIDs, screen, - canAnimate, - onOpenRateLimitOptions, - lastThinkingBlockId, - latestBashOutputUUID, - columns, - isLoading, - lookups - } = t0; - const isTranscriptMode = screen === "transcript"; - const isGrouped = msg.type === "grouped_tool_use"; - const isCollapsed = msg.type === "collapsed_read_search"; - let t1; - if ($[0] !== hasContentAfter || $[1] !== inProgressToolUseIDs || $[2] !== isCollapsed || $[3] !== isLoading || $[4] !== msg) { - t1 = isCollapsed && (hasAnyToolInProgress(msg, inProgressToolUseIDs) || isLoading && !hasContentAfter); - $[0] = hasContentAfter; - $[1] = inProgressToolUseIDs; - $[2] = isCollapsed; - $[3] = isLoading; - $[4] = msg; - $[5] = t1; - } else { - t1 = $[5]; - } - const isActiveCollapsedGroup = t1; - let t2; - if ($[6] !== isCollapsed || $[7] !== isGrouped || $[8] !== msg) { - t2 = isGrouped ? msg.displayMessage : isCollapsed ? getDisplayMessageFromCollapsed(msg) : msg; - $[6] = isCollapsed; - $[7] = isGrouped; - $[8] = msg; - $[9] = t2; - } else { - t2 = $[9]; - } - const displayMsg = t2; - let t3; - if ($[10] !== isCollapsed || $[11] !== isGrouped || $[12] !== lookups || $[13] !== msg) { - t3 = isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups); - $[10] = isCollapsed; - $[11] = isGrouped; - $[12] = lookups; - $[13] = msg; - $[14] = t3; - } else { - t3 = $[14]; - } - const progressMessagesForMessage = t3; - let t4; - if ($[15] !== inProgressToolUseIDs || $[16] !== isCollapsed || $[17] !== isGrouped || $[18] !== lookups || $[19] !== msg || $[20] !== screen || $[21] !== streamingToolUseIDs) { - const siblingToolUseIDs = isGrouped || isCollapsed ? EMPTY_STRING_SET : getSiblingToolUseIDsFromLookup(msg, lookups); - t4 = shouldRenderStatically(msg, streamingToolUseIDs, inProgressToolUseIDs, siblingToolUseIDs, screen, lookups); - $[15] = inProgressToolUseIDs; - $[16] = isCollapsed; - $[17] = isGrouped; - $[18] = lookups; - $[19] = msg; - $[20] = screen; - $[21] = streamingToolUseIDs; - $[22] = t4; - } else { - t4 = $[22]; - } - const isStatic = t4; - let shouldAnimate = false; + lookups, + ) + + let shouldAnimate = false if (canAnimate) { if (isGrouped) { - let t5; - if ($[23] !== inProgressToolUseIDs || $[24] !== msg.messages) { - let t6; - if ($[26] !== inProgressToolUseIDs) { - t6 = m => { - const content = m.message.content[0]; - return content?.type === "tool_use" && inProgressToolUseIDs.has(content.id); - }; - $[26] = inProgressToolUseIDs; - $[27] = t6; - } else { - t6 = $[27]; - } - t5 = msg.messages.some(t6); - $[23] = inProgressToolUseIDs; - $[24] = msg.messages; - $[25] = t5; - } else { - t5 = $[25]; - } - shouldAnimate = t5; + shouldAnimate = msg.messages.some(m => { + const content = m.message.content[0] + return ( + content?.type === 'tool_use' && inProgressToolUseIDs.has(content.id) + ) + }) + } else if (isCollapsed) { + shouldAnimate = hasAnyToolInProgress(msg, inProgressToolUseIDs) } else { - if (isCollapsed) { - let t5; - if ($[28] !== inProgressToolUseIDs || $[29] !== msg) { - t5 = hasAnyToolInProgress(msg, inProgressToolUseIDs); - $[28] = inProgressToolUseIDs; - $[29] = msg; - $[30] = t5; - } else { - t5 = $[30]; - } - shouldAnimate = t5; - } else { - let t5; - if ($[31] !== inProgressToolUseIDs || $[32] !== msg) { - const toolUseID = getToolUseID(msg); - t5 = !toolUseID || inProgressToolUseIDs.has(toolUseID); - $[31] = inProgressToolUseIDs; - $[32] = msg; - $[33] = t5; - } else { - t5 = $[33]; - } - shouldAnimate = t5; - } + const toolUseID = getToolUseID(msg) + shouldAnimate = !toolUseID || inProgressToolUseIDs.has(toolUseID) } } - let t5; - if ($[34] !== displayMsg || $[35] !== isTranscriptMode) { - t5 = isTranscriptMode && displayMsg.type === "assistant" && displayMsg.message.content.some(_temp) && (displayMsg.timestamp || displayMsg.message.model); - $[34] = displayMsg; - $[35] = isTranscriptMode; - $[36] = t5; - } else { - t5 = $[36]; - } - const hasMetadata = t5; - const t6 = !hasMetadata; - const t7 = hasMetadata ? undefined : columns; - let t8; - if ($[37] !== commands || $[38] !== inProgressToolUseIDs || $[39] !== isActiveCollapsedGroup || $[40] !== isStatic || $[41] !== isTranscriptMode || $[42] !== isUserContinuation || $[43] !== lastThinkingBlockId || $[44] !== latestBashOutputUUID || $[45] !== lookups || $[46] !== msg || $[47] !== onOpenRateLimitOptions || $[48] !== progressMessagesForMessage || $[49] !== shouldAnimate || $[50] !== t6 || $[51] !== t7 || $[52] !== tools || $[53] !== verbose) { - t8 = ; - $[37] = commands; - $[38] = inProgressToolUseIDs; - $[39] = isActiveCollapsedGroup; - $[40] = isStatic; - $[41] = isTranscriptMode; - $[42] = isUserContinuation; - $[43] = lastThinkingBlockId; - $[44] = latestBashOutputUUID; - $[45] = lookups; - $[46] = msg; - $[47] = onOpenRateLimitOptions; - $[48] = progressMessagesForMessage; - $[49] = shouldAnimate; - $[50] = t6; - $[51] = t7; - $[52] = tools; - $[53] = verbose; - $[54] = t8; - } else { - t8 = $[54]; - } - const messageEl = t8; + + const hasMetadata = + isTranscriptMode && + displayMsg.type === 'assistant' && + displayMsg.message.content.some(c => c.type === 'text') && + (displayMsg.timestamp || displayMsg.message.model) + + const messageEl = ( + + ) + // OffscreenFreeze: the outer React.memo already bails for static messages, + // so this only wraps rows that DO re-render — in-progress tools, collapsed + // read/search spinners, bash elapsed timers. When those rows have scrolled + // into terminal scrollback (non-fullscreen external builds), any content + // change forces log-update.ts into a full terminal reset per tick. Freezing + // returns the cached element ref so React bails and produces zero diff. if (!hasMetadata) { - let t9; - if ($[55] !== messageEl) { - t9 = {messageEl}; - $[55] = messageEl; - $[56] = t9; - } else { - t9 = $[56]; - } - return t9; - } - let t9; - if ($[57] !== displayMsg || $[58] !== isTranscriptMode) { - t9 = ; - $[57] = displayMsg; - $[58] = isTranscriptMode; - $[59] = t9; - } else { - t9 = $[59]; + return {messageEl} } - let t10; - if ($[60] !== columns || $[61] !== messageEl || $[62] !== t9) { - t10 = {t9}{messageEl}; - $[60] = columns; - $[61] = messageEl; - $[62] = t9; - $[63] = t10; - } else { - t10 = $[63]; - } - return t10; + // Margin on children, not here — else null items (hook_success etc.) get phantom 1-row spacing. + return ( + + + + + + + {messageEl} + + + ) } /** * Checks if a message is "streaming" - i.e., its content may still be changing. * Exported for testing. */ -function _temp(c) { - return c.type === "text"; -} -export function isMessageStreaming(msg: RenderableMessage, streamingToolUseIDs: Set): boolean { +export function isMessageStreaming( + msg: RenderableMessage, + streamingToolUseIDs: Set, +): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.some(m => { - const content = (m.message.content as ContentBlockLike[])[0]; - return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id!); - }); + const content = m.message.content[0] + return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id) + }) } if (msg.type === 'collapsed_read_search') { - const toolIds = getToolUseIdsFromCollapsedGroup(msg); - return toolIds.some(id => streamingToolUseIDs.has(id)); + const toolIds = getToolUseIdsFromCollapsedGroup(msg) + return toolIds.some(id => streamingToolUseIDs.has(id)) } - const toolUseID = getToolUseID(msg); - return !!toolUseID && streamingToolUseIDs.has(toolUseID); + const toolUseID = getToolUseID(msg) + return !!toolUseID && streamingToolUseIDs.has(toolUseID) } /** * Checks if all tools in a message are resolved. * Exported for testing. */ -export function allToolsResolved(msg: RenderableMessage, resolvedToolUseIDs: Set): boolean { +export function allToolsResolved( + msg: RenderableMessage, + resolvedToolUseIDs: Set, +): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.every(m => { - const content = (m.message.content as ContentBlockLike[])[0]; - return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id!); - }); + const content = m.message.content[0] + return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id) + }) } if (msg.type === 'collapsed_read_search') { - const toolIds = getToolUseIdsFromCollapsedGroup(msg); - return toolIds.every(id => resolvedToolUseIDs.has(id)); + const toolIds = getToolUseIdsFromCollapsedGroup(msg) + return toolIds.every(id => resolvedToolUseIDs.has(id)) } if (msg.type === 'assistant') { - const block = (msg.message.content as ContentBlockLike[])[0]; + const block = msg.message.content[0] if (block?.type === 'server_tool_use') { - return resolvedToolUseIDs.has(block.id!); + return resolvedToolUseIDs.has(block.id) } } - const toolUseID = getToolUseID(msg); - return !toolUseID || resolvedToolUseIDs.has(toolUseID); + const toolUseID = getToolUseID(msg) + return !toolUseID || resolvedToolUseIDs.has(toolUseID) } /** @@ -344,42 +306,52 @@ export function allToolsResolved(msg: RenderableMessage, resolvedToolUseIDs: Set */ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean { // Different message reference = content may have changed, must re-render - if (prev.message !== next.message) return false; + if (prev.message !== next.message) return false // Screen mode change = re-render - if (prev.screen !== next.screen) return false; + if (prev.screen !== next.screen) return false // Verbose toggle changes thinking block visibility - if (prev.verbose !== next.verbose) return false; + if (prev.verbose !== next.verbose) return false // collapsed_read_search is never static in prompt mode (matches shouldRenderStatically) - if (prev.message.type === 'collapsed_read_search' && next.screen !== 'transcript') { - return false; + if ( + prev.message.type === 'collapsed_read_search' && + next.screen !== 'transcript' + ) { + return false } // Width change affects Box layout - if (prev.columns !== next.columns) return false; + if (prev.columns !== next.columns) return false // latestBashOutputUUID affects rendering (full vs truncated output) - const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid; - const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid; - if (prevIsLatestBash !== nextIsLatestBash) return false; + const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid + const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid + if (prevIsLatestBash !== nextIsLatestBash) return false // lastThinkingBlockId affects thinking block visibility — but only for // messages that HAVE thinking content. Checking unconditionally busts the // memo for every scrollback message whenever thinking starts/stops (CC-941). - if (prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as Parameters[0])) { - return false; + if ( + prev.lastThinkingBlockId !== next.lastThinkingBlockId && + hasThinkingContent(next.message) + ) { + return false } // Check if this message is still "in flight" - const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs); - const isResolved = allToolsResolved(prev.message, prev.lookups.resolvedToolUseIDs); + const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs) + const isResolved = allToolsResolved( + prev.message, + prev.lookups.resolvedToolUseIDs, + ) // Only bail out for truly static messages - if (isStreaming || !isResolved) return false; + if (isStreaming || !isResolved) return false // Static message - safe to skip re-render - return true; + return true } -export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual); + +export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual) diff --git a/src/components/MessageSelector.tsx b/src/components/MessageSelector.tsx index 8ee089025..b372a4b5d 100644 --- a/src/components/MessageSelector.tsx +++ b/src/components/MessageSelector.tsx @@ -1,48 +1,96 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import { randomUUID, type UUID } from 'crypto'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useAppState } from 'src/state/AppState.js'; -import { type DiffStats, fileHistoryCanRestore, fileHistoryEnabled, fileHistoryGetDiffStats } from 'src/utils/fileHistory.js'; -import { logError } from 'src/utils/log.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js'; -import type { Message, PartialCompactDirection, UserMessage } from '../types/message.js'; -import { stripDisplayTags } from '../utils/displayTags.js'; -import { createUserMessage, extractTag, isEmptyMessageText, isSyntheticMessage, isToolUseResultMessage } from '../utils/messages.js'; -import { type OptionWithDescription, Select } from './CustomSelect/select.js'; -import { Spinner } from './Spinner.js'; +import type { + ContentBlockParam, + TextBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import { randomUUID, type UUID } from 'crypto' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { useAppState } from 'src/state/AppState.js' +import { + type DiffStats, + fileHistoryCanRestore, + fileHistoryEnabled, + fileHistoryGetDiffStats, +} from 'src/utils/fileHistory.js' +import { logError } from 'src/utils/log.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../ink.js' +import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js' +import type { + Message, + PartialCompactDirection, + UserMessage, +} from '../types/message.js' +import { stripDisplayTags } from '../utils/displayTags.js' +import { + createUserMessage, + extractTag, + isEmptyMessageText, + isSyntheticMessage, + isToolUseResultMessage, +} from '../utils/messages.js' +import { type OptionWithDescription, Select } from './CustomSelect/select.js' +import { Spinner } from './Spinner.js' + function isTextBlock(block: ContentBlockParam): block is TextBlockParam { - return block.type === 'text'; + return block.type === 'text' } -import * as path from 'path'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import type { FileEditOutput } from 'src/tools/FileEditTool/types.js'; -import type { Output as FileWriteToolOutput } from 'src/tools/FileWriteTool/FileWriteTool.js'; -import { BASH_STDERR_TAG, BASH_STDOUT_TAG, COMMAND_MESSAGE_TAG, LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, TASK_NOTIFICATION_TAG, TEAMMATE_MESSAGE_TAG, TICK_TAG } from '../constants/xml.js'; -import { count } from '../utils/array.js'; -import { formatRelativeTimeAgo, truncate } from '../utils/format.js'; -import type { Theme } from '../utils/theme.js'; -import { Divider } from './design-system/Divider.js'; -type RestoreOption = 'both' | 'conversation' | 'code' | 'summarize' | 'summarize_up_to' | 'nevermind'; -function isSummarizeOption(option: RestoreOption | null): option is 'summarize' | 'summarize_up_to' { - return option === 'summarize' || option === 'summarize_up_to'; + +import * as path from 'path' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import type { FileEditOutput } from 'src/tools/FileEditTool/types.js' +import type { Output as FileWriteToolOutput } from 'src/tools/FileWriteTool/FileWriteTool.js' +import { + BASH_STDERR_TAG, + BASH_STDOUT_TAG, + COMMAND_MESSAGE_TAG, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, + TASK_NOTIFICATION_TAG, + TEAMMATE_MESSAGE_TAG, + TICK_TAG, +} from '../constants/xml.js' +import { count } from '../utils/array.js' +import { formatRelativeTimeAgo, truncate } from '../utils/format.js' +import type { Theme } from '../utils/theme.js' +import { Divider } from './design-system/Divider.js' + +type RestoreOption = + | 'both' + | 'conversation' + | 'code' + | 'summarize' + | 'summarize_up_to' + | 'nevermind' + +function isSummarizeOption( + option: RestoreOption | null, +): option is 'summarize' | 'summarize_up_to' { + return option === 'summarize' || option === 'summarize_up_to' } + type Props = { - messages: Message[]; - onPreRestore: () => void; - onRestoreMessage: (message: UserMessage) => Promise; - onRestoreCode: (message: UserMessage) => Promise; - onSummarize: (message: UserMessage, feedback?: string, direction?: PartialCompactDirection) => Promise; - onClose: () => void; + messages: Message[] + onPreRestore: () => void + onRestoreMessage: (message: UserMessage) => Promise + onRestoreCode: (message: UserMessage) => Promise + onSummarize: ( + message: UserMessage, + feedback?: string, + direction?: PartialCompactDirection, + ) => Promise + onClose: () => void /** Skip pick-list, land on confirm. Caller ran skip-check first. Esc closes fully (no back-to-list). */ - preselectedMessage?: UserMessage; -}; -const MAX_VISIBLE_MESSAGES = 7; + preselectedMessage?: UserMessage +} + +const MAX_VISIBLE_MESSAGES = 7 + export function MessageSelector({ messages, onPreRestore, @@ -50,745 +98,845 @@ export function MessageSelector({ onRestoreCode, onSummarize, onClose, - preselectedMessage + preselectedMessage, }: Props): React.ReactNode { - const fileHistory = useAppState(s => s.fileHistory); - const [error, setError] = useState(undefined); - const isFileHistoryEnabled = fileHistoryEnabled(); + const fileHistory = useAppState(s => s.fileHistory) + const [error, setError] = useState(undefined) + const isFileHistoryEnabled = fileHistoryEnabled() // Add current prompt as a virtual message - const currentUUID = useMemo(randomUUID, []); - const messageOptions = useMemo(() => [...messages.filter(selectableUserMessagesFilter), { - ...createUserMessage({ - content: '' - }), - uuid: currentUUID - } as UserMessage], [messages, currentUUID]); - const [selectedIndex, setSelectedIndex] = useState(messageOptions.length - 1); + const currentUUID = useMemo(randomUUID, []) + const messageOptions = useMemo( + () => [ + ...messages.filter(selectableUserMessagesFilter), + { + ...createUserMessage({ + content: '', + }), + uuid: currentUUID, + } as UserMessage, + ], + [messages, currentUUID], + ) + const [selectedIndex, setSelectedIndex] = useState(messageOptions.length - 1) // Orient the selected message as the middle of the visible options - const firstVisibleIndex = Math.max(0, Math.min(selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2), messageOptions.length - MAX_VISIBLE_MESSAGES)); - const hasMessagesToSelect = messageOptions.length > 1; - const [messageToRestore, setMessageToRestore] = useState(preselectedMessage); - const [diffStatsForRestore, setDiffStatsForRestore] = useState(undefined); + const firstVisibleIndex = Math.max( + 0, + Math.min( + selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2), + messageOptions.length - MAX_VISIBLE_MESSAGES, + ), + ) + + const hasMessagesToSelect = messageOptions.length > 1 + + const [messageToRestore, setMessageToRestore] = useState< + UserMessage | undefined + >(preselectedMessage) + const [diffStatsForRestore, setDiffStatsForRestore] = useState< + DiffStats | undefined + >(undefined) + useEffect(() => { - if (!preselectedMessage || !isFileHistoryEnabled) return; - let cancelled = false; - void fileHistoryGetDiffStats(fileHistory, preselectedMessage.uuid).then(stats => { - if (!cancelled) setDiffStatsForRestore(stats); - }); + if (!preselectedMessage || !isFileHistoryEnabled) return + let cancelled = false + void fileHistoryGetDiffStats(fileHistory, preselectedMessage.uuid).then( + stats => { + if (!cancelled) setDiffStatsForRestore(stats) + }, + ) return () => { - cancelled = true; - }; - }, [preselectedMessage, isFileHistoryEnabled, fileHistory]); - const [isRestoring, setIsRestoring] = useState(false); - const [restoringOption, setRestoringOption] = useState(null); - const [selectedRestoreOption, setSelectedRestoreOption] = useState('both'); + cancelled = true + } + }, [preselectedMessage, isFileHistoryEnabled, fileHistory]) + + const [isRestoring, setIsRestoring] = useState(false) + const [restoringOption, setRestoringOption] = useState( + null, + ) + const [selectedRestoreOption, setSelectedRestoreOption] = + useState('both') // Per-option feedback state; Select's internal inputValues Map persists // per-option text independently, so sharing one variable would desync. - const [summarizeFromFeedback, setSummarizeFromFeedback] = useState(''); - const [summarizeUpToFeedback, setSummarizeUpToFeedback] = useState(''); + const [summarizeFromFeedback, setSummarizeFromFeedback] = useState('') + const [summarizeUpToFeedback, setSummarizeUpToFeedback] = useState('') // Generate options with summarize as input type for inline context - function getRestoreOptions(canRestoreCode: boolean): OptionWithDescription[] { - const baseOptions: OptionWithDescription[] = canRestoreCode ? [{ - value: 'both', - label: 'Restore code and conversation' - }, { - value: 'conversation', - label: 'Restore conversation' - }, { - value: 'code', - label: 'Restore code' - }] : [{ - value: 'conversation', - label: 'Restore conversation' - }]; + function getRestoreOptions( + canRestoreCode: boolean, + ): OptionWithDescription[] { + const baseOptions: OptionWithDescription[] = canRestoreCode + ? [ + { value: 'both', label: 'Restore code and conversation' }, + { value: 'conversation', label: 'Restore conversation' }, + { value: 'code', label: 'Restore code' }, + ] + : [{ value: 'conversation', label: 'Restore conversation' }] + const summarizeInputProps = { type: 'input' as const, placeholder: 'add context (optional)', initialValue: '', allowEmptySubmitToCancel: true, showLabelWithValue: true, - labelValueSeparator: ': ' - }; + labelValueSeparator: ': ', + } baseOptions.push({ value: 'summarize', label: 'Summarize from here', ...summarizeInputProps, - onChange: setSummarizeFromFeedback - }); - if ((process.env.USER_TYPE) === 'ant') { + onChange: setSummarizeFromFeedback, + }) + if (process.env.USER_TYPE === 'ant') { baseOptions.push({ value: 'summarize_up_to', label: 'Summarize up to here', ...summarizeInputProps, - onChange: setSummarizeUpToFeedback - }); + onChange: setSummarizeUpToFeedback, + }) } - baseOptions.push({ - value: 'nevermind', - label: 'Never mind' - }); - return baseOptions; + + baseOptions.push({ value: 'nevermind', label: 'Never mind' }) + return baseOptions } // Log when selector is opened useEffect(() => { - logEvent('tengu_message_selector_opened', {}); - }, []); + logEvent('tengu_message_selector_opened', {}) + }, []) // Helper to restore conversation without confirmation async function restoreConversationDirectly(message: UserMessage) { - onPreRestore(); - setIsRestoring(true); + onPreRestore() + setIsRestoring(true) try { - await onRestoreMessage(message); - setIsRestoring(false); - onClose(); - } catch (error_0) { - logError(error_0 as Error); - setIsRestoring(false); - setError(`Failed to restore the conversation:\n${error_0}`); + await onRestoreMessage(message) + setIsRestoring(false) + onClose() + } catch (error) { + logError(error as Error) + setIsRestoring(false) + setError(`Failed to restore the conversation:\n${error}`) } } - async function handleSelect(message_0: UserMessage) { - const index = messages.indexOf(message_0); - const indexFromEnd = messages.length - 1 - index; + + async function handleSelect(message: UserMessage) { + const index = messages.indexOf(message) + const indexFromEnd = messages.length - 1 - index + logEvent('tengu_message_selector_selected', { index_from_end: indexFromEnd, - message_type: message_0.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - is_current_prompt: false - }); + message_type: + message.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + is_current_prompt: false, + }) // Do nothing if the message is not found - if (!messages.includes(message_0)) { - onClose(); - return; + if (!messages.includes(message)) { + onClose() + return } + if (!isFileHistoryEnabled) { - await restoreConversationDirectly(message_0); - return; + await restoreConversationDirectly(message) + return } - const diffStats = await fileHistoryGetDiffStats(fileHistory, message_0.uuid); - setMessageToRestore(message_0); - setDiffStatsForRestore(diffStats); + + const diffStats = await fileHistoryGetDiffStats(fileHistory, message.uuid) + setMessageToRestore(message) + setDiffStatsForRestore(diffStats) } + async function onSelectRestoreOption(option: RestoreOption) { logEvent('tengu_message_selector_restore_option_selected', { - option: option as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + option: + option as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) if (!messageToRestore) { - setError('Message not found.'); - return; + setError('Message not found.') + return } if (option === 'nevermind') { - if (preselectedMessage) onClose();else setMessageToRestore(undefined); - return; + if (preselectedMessage) onClose() + else setMessageToRestore(undefined) + return } + if (isSummarizeOption(option)) { - onPreRestore(); - setIsRestoring(true); - setRestoringOption(option); - setError(undefined); + onPreRestore() + setIsRestoring(true) + setRestoringOption(option) + setError(undefined) try { - const direction = option === 'summarize_up_to' ? 'up_to' : 'from'; - const feedback = (direction === 'up_to' ? summarizeUpToFeedback : summarizeFromFeedback).trim() || undefined; - await onSummarize(messageToRestore, feedback, direction); - setIsRestoring(false); - setRestoringOption(null); - setMessageToRestore(undefined); - onClose(); - } catch (error_1) { - logError(error_1 as Error); - setIsRestoring(false); - setRestoringOption(null); - setMessageToRestore(undefined); - setError(`Failed to summarize:\n${error_1}`); + const direction = option === 'summarize_up_to' ? 'up_to' : 'from' + const feedback = + (direction === 'up_to' + ? summarizeUpToFeedback + : summarizeFromFeedback + ).trim() || undefined + await onSummarize(messageToRestore, feedback, direction) + setIsRestoring(false) + setRestoringOption(null) + setMessageToRestore(undefined) + onClose() + } catch (error) { + logError(error as Error) + setIsRestoring(false) + setRestoringOption(null) + setMessageToRestore(undefined) + setError(`Failed to summarize:\n${error}`) } - return; + return } - onPreRestore(); - setIsRestoring(true); - setError(undefined); - let codeError: Error | null = null; - let conversationError: Error | null = null; + + onPreRestore() + setIsRestoring(true) + setError(undefined) + + let codeError: Error | null = null + let conversationError: Error | null = null + if (option === 'code' || option === 'both') { try { - await onRestoreCode(messageToRestore); - } catch (error_2) { - codeError = error_2 as Error; - logError(codeError); + await onRestoreCode(messageToRestore) + } catch (error) { + codeError = error as Error + logError(codeError) } } + if (option === 'conversation' || option === 'both') { try { - await onRestoreMessage(messageToRestore); - } catch (error_3) { - conversationError = error_3 as Error; - logError(conversationError); + await onRestoreMessage(messageToRestore) + } catch (error) { + conversationError = error as Error + logError(conversationError) } } - setIsRestoring(false); - setMessageToRestore(undefined); + + setIsRestoring(false) + setMessageToRestore(undefined) // Handle errors if (conversationError && codeError) { - setError(`Failed to restore the conversation and code:\n${conversationError}\n${codeError}`); + setError( + `Failed to restore the conversation and code:\n${conversationError}\n${codeError}`, + ) } else if (conversationError) { - setError(`Failed to restore the conversation:\n${conversationError}`); + setError(`Failed to restore the conversation:\n${conversationError}`) } else if (codeError) { - setError(`Failed to restore the code:\n${codeError}`); + setError(`Failed to restore the code:\n${codeError}`) } else { // Success - close the selector - onClose(); + onClose() } } - const exitState = useExitOnCtrlCDWithKeybindings(); + + const exitState = useExitOnCtrlCDWithKeybindings() + const handleEscape = useCallback(() => { if (messageToRestore && !preselectedMessage) { // Go back to message list instead of closing entirely - setMessageToRestore(undefined); - return; + setMessageToRestore(undefined) + return } - logEvent('tengu_message_selector_cancelled', {}); - onClose(); - }, [onClose, messageToRestore, preselectedMessage]); - const moveUp = useCallback(() => setSelectedIndex(prev => Math.max(0, prev - 1)), []); - const moveDown = useCallback(() => setSelectedIndex(prev_0 => Math.min(messageOptions.length - 1, prev_0 + 1)), [messageOptions.length]); - const jumpToTop = useCallback(() => setSelectedIndex(0), []); - const jumpToBottom = useCallback(() => setSelectedIndex(messageOptions.length - 1), [messageOptions.length]); + logEvent('tengu_message_selector_cancelled', {}) + onClose() + }, [onClose, messageToRestore, preselectedMessage]) + + const moveUp = useCallback( + () => setSelectedIndex(prev => Math.max(0, prev - 1)), + [], + ) + const moveDown = useCallback( + () => + setSelectedIndex(prev => Math.min(messageOptions.length - 1, prev + 1)), + [messageOptions.length], + ) + const jumpToTop = useCallback(() => setSelectedIndex(0), []) + const jumpToBottom = useCallback( + () => setSelectedIndex(messageOptions.length - 1), + [messageOptions.length], + ) const handleSelectCurrent = useCallback(() => { - const selected = messageOptions[selectedIndex]; + const selected = messageOptions[selectedIndex] if (selected) { - void handleSelect(selected); + void handleSelect(selected) } - }, [messageOptions, selectedIndex, handleSelect]); + }, [messageOptions, selectedIndex, handleSelect]) // Escape to close - uses Confirmation context where escape is bound useKeybinding('confirm:no', handleEscape, { context: 'Confirmation', - isActive: !messageToRestore - }); + isActive: !messageToRestore, + }) // Message selector navigation keybindings - useKeybindings({ - 'messageSelector:up': moveUp, - 'messageSelector:down': moveDown, - 'messageSelector:top': jumpToTop, - 'messageSelector:bottom': jumpToBottom, - 'messageSelector:select': handleSelectCurrent - }, { - context: 'MessageSelector', - isActive: !isRestoring && !error && !messageToRestore && hasMessagesToSelect - }); - const [fileHistoryMetadata, setFileHistoryMetadata] = useState>({}); + useKeybindings( + { + 'messageSelector:up': moveUp, + 'messageSelector:down': moveDown, + 'messageSelector:top': jumpToTop, + 'messageSelector:bottom': jumpToBottom, + 'messageSelector:select': handleSelectCurrent, + }, + { + context: 'MessageSelector', + isActive: + !isRestoring && !error && !messageToRestore && hasMessagesToSelect, + }, + ) + + const [fileHistoryMetadata, setFileHistoryMetadata] = useState< + Record + >({}) + useEffect(() => { async function loadFileHistoryMetadata() { if (!isFileHistoryEnabled) { - return; + return } // Load file snapshot metadata - void Promise.all(messageOptions.map(async (userMessage, itemIndex) => { - if (userMessage.uuid !== currentUUID) { - const canRestore = fileHistoryCanRestore(fileHistory, userMessage.uuid); - const nextUserMessage = messageOptions.at(itemIndex + 1); - const diffStats_0 = canRestore ? computeDiffStatsBetweenMessages(messages, userMessage.uuid, nextUserMessage?.uuid !== currentUUID ? nextUserMessage?.uuid : undefined) : undefined; - if (diffStats_0 !== undefined) { - setFileHistoryMetadata(prev_1 => ({ - ...prev_1, - [itemIndex]: diffStats_0 - })); - } else { - setFileHistoryMetadata(prev_2 => ({ - ...prev_2, - [itemIndex]: undefined - })); + void Promise.all( + messageOptions.map(async (userMessage, itemIndex) => { + if (userMessage.uuid !== currentUUID) { + const canRestore = fileHistoryCanRestore( + fileHistory, + userMessage.uuid, + ) + + const nextUserMessage = messageOptions.at(itemIndex + 1) + const diffStats = canRestore + ? computeDiffStatsBetweenMessages( + messages, + userMessage.uuid, + nextUserMessage?.uuid !== currentUUID + ? nextUserMessage?.uuid + : undefined, + ) + : undefined + + if (diffStats !== undefined) { + setFileHistoryMetadata(prev => ({ + ...prev, + [itemIndex]: diffStats, + })) + } else { + setFileHistoryMetadata(prev => ({ + ...prev, + [itemIndex]: undefined, + })) + } } - } - })); + }), + ) } - void loadFileHistoryMetadata(); - }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]); - const canRestoreCode_0 = isFileHistoryEnabled && diffStatsForRestore?.filesChanged && diffStatsForRestore.filesChanged.length > 0; - const showPickList = !error && !messageToRestore && !preselectedMessage && hasMessagesToSelect; - return + void loadFileHistoryMetadata() + }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]) + + const canRestoreCode = + isFileHistoryEnabled && + diffStatsForRestore?.filesChanged && + diffStatsForRestore.filesChanged.length > 0 + const showPickList = + !error && !messageToRestore && !preselectedMessage && hasMessagesToSelect + + return ( + Rewind - {error && <> + {error && ( + <> Error: {error} - } - {!hasMessagesToSelect && <> + + )} + {!hasMessagesToSelect && ( + <> Nothing to rewind to yet. - } - {!error && messageToRestore && hasMessagesToSelect && <> + + )} + {!error && messageToRestore && hasMessagesToSelect && ( + <> Confirm you want to restore{' '} {!diffStatsForRestore && 'the conversation '}to the point before you sent this message: - - + + - ({formatRelativeTimeAgo(new Date(messageToRestore.timestamp as number))}) + ({formatRelativeTimeAgo(new Date(messageToRestore.timestamp))}) - - {isRestoring && isSummarizeOption(restoringOption) ? + + {isRestoring && isSummarizeOption(restoringOption) ? ( + Summarizing… - : + setSelectedRestoreOption(value as RestoreOption) + } + onChange={value => + onSelectRestoreOption(value as RestoreOption) + } + onCancel={() => + preselectedMessage + ? onClose() + : setMessageToRestore(undefined) + } + /> + )} + {canRestoreCode && ( + {figures.warning} Rewinding does not affect files edited manually or via bash. - } - } - {showPickList && <> - {isFileHistoryEnabled ? + + )} + + )} + {showPickList && ( + <> + {isFileHistoryEnabled ? ( + Restore the code and/or conversation to the point before… - : + + ) : ( + Restore and fork the conversation to the point before… - } + + )} - {messageOptions.slice(firstVisibleIndex, firstVisibleIndex + MAX_VISIBLE_MESSAGES).map((msg, visibleOptionIndex) => { - const optionIndex = firstVisibleIndex + visibleOptionIndex; - const isSelected = optionIndex === selectedIndex; - const isCurrent = msg.uuid === currentUUID; - const metadataLoaded = optionIndex in fileHistoryMetadata; - const metadata = fileHistoryMetadata[optionIndex]; - const numFilesChanged = metadata?.filesChanged && metadata.filesChanged.length; - return + {messageOptions + .slice( + firstVisibleIndex, + firstVisibleIndex + MAX_VISIBLE_MESSAGES, + ) + .map((msg, visibleOptionIndex) => { + const optionIndex = firstVisibleIndex + visibleOptionIndex + const isSelected = optionIndex === selectedIndex + const isCurrent = msg.uuid === currentUUID + + const metadataLoaded = optionIndex in fileHistoryMetadata + const metadata = fileHistoryMetadata[optionIndex] + const numFilesChanged = + metadata?.filesChanged && metadata.filesChanged.length + + return ( + - {isSelected ? + {isSelected ? ( + {figures.pointer}{' '} - : {' '}} + + ) : ( + {' '} + )} - + - {isFileHistoryEnabled && metadataLoaded && - {metadata ? <> + {isFileHistoryEnabled && metadataLoaded && ( + + {metadata ? ( + <> - {numFilesChanged ? <> - {numFilesChanged === 1 && metadata.filesChanged![0] ? `${path.basename(metadata.filesChanged![0])} ` : `${numFilesChanged} files changed `} + {numFilesChanged ? ( + <> + {numFilesChanged === 1 && + metadata.filesChanged![0] + ? `${path.basename(metadata.filesChanged![0])} ` + : `${numFilesChanged} files changed `} - : <>No code changes} + + ) : ( + <>No code changes + )} - : + + ) : ( + {figures.warning} No code restore - } - } + + )} + + )} - ; - })} + + ) + })} - } - {!messageToRestore && - {exitState.pending ? <>Press {exitState.keyName} again to exit : <> + + )} + {!messageToRestore && ( + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <> {!error && hasMessagesToSelect && 'Enter to continue · '}Esc to exit - } - } + + )} + + )} - ; + + ) } + function getRestoreOptionConversationText(option: RestoreOption): string { switch (option) { case 'summarize': - return 'Messages after this point will be summarized.'; + return 'Messages after this point will be summarized.' case 'summarize_up_to': - return 'Preceding messages will be summarized. This and subsequent messages will remain unchanged — you will stay at the end of the conversation.'; + return 'Preceding messages will be summarized. This and subsequent messages will remain unchanged — you will stay at the end of the conversation.' case 'both': case 'conversation': - return 'The conversation will be forked.'; + return 'The conversation will be forked.' case 'code': case 'nevermind': - return 'The conversation will be unchanged.'; + return 'The conversation will be unchanged.' } } -function RestoreOptionDescription(t0) { - const $ = _c(11); - const { - selectedRestoreOption, - canRestoreCode, - diffStatsForRestore - } = t0; - const showCodeRestore = canRestoreCode && (selectedRestoreOption === "both" || selectedRestoreOption === "code"); - let t1; - if ($[0] !== selectedRestoreOption) { - t1 = getRestoreOptionConversationText(selectedRestoreOption); - $[0] = selectedRestoreOption; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = {t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== diffStatsForRestore || $[5] !== selectedRestoreOption || $[6] !== showCodeRestore) { - t3 = !isSummarizeOption(selectedRestoreOption) && (showCodeRestore ? : The code will be unchanged.); - $[4] = diffStatsForRestore; - $[5] = selectedRestoreOption; - $[6] = showCodeRestore; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== t2 || $[9] !== t3) { - t4 = {t2}{t3}; - $[8] = t2; - $[9] = t3; - $[10] = t4; - } else { - t4 = $[10]; - } - return t4; + +function RestoreOptionDescription({ + selectedRestoreOption, + canRestoreCode, + diffStatsForRestore, +}: { + selectedRestoreOption: RestoreOption + canRestoreCode: boolean + diffStatsForRestore: DiffStats | undefined +}): React.ReactNode { + const showCodeRestore = + canRestoreCode && + (selectedRestoreOption === 'both' || selectedRestoreOption === 'code') + + return ( + + + {getRestoreOptionConversationText(selectedRestoreOption)} + + {!isSummarizeOption(selectedRestoreOption) && + (showCodeRestore ? ( + + ) : ( + The code will be unchanged. + ))} + + ) } -function RestoreCodeConfirmation(t0) { - const $ = _c(14); - const { - diffStatsForRestore - } = t0; + +function RestoreCodeConfirmation({ + diffStatsForRestore, +}: { + diffStatsForRestore: DiffStats | undefined +}): React.ReactNode { if (diffStatsForRestore === undefined) { - return; + return undefined } - if (!diffStatsForRestore.filesChanged || !diffStatsForRestore.filesChanged[0]) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = The code has not changed (nothing will be restored).; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + if ( + !diffStatsForRestore.filesChanged || + !diffStatsForRestore.filesChanged[0] + ) { + return ( + The code has not changed (nothing will be restored). + ) } - const numFilesChanged = diffStatsForRestore.filesChanged.length; - let fileLabel; + + const numFilesChanged = diffStatsForRestore.filesChanged.length + + let fileLabel = '' if (numFilesChanged === 1) { - let t1; - if ($[1] !== diffStatsForRestore.filesChanged[0]) { - t1 = path.basename(diffStatsForRestore.filesChanged[0] || ""); - $[1] = diffStatsForRestore.filesChanged[0]; - $[2] = t1; - } else { - t1 = $[2]; - } - fileLabel = t1; + fileLabel = path.basename(diffStatsForRestore.filesChanged[0] || '') + } else if (numFilesChanged === 2) { + const file1 = path.basename(diffStatsForRestore.filesChanged[0] || '') + const file2 = path.basename(diffStatsForRestore.filesChanged[1] || '') + fileLabel = `${file1} and ${file2}` } else { - if (numFilesChanged === 2) { - let t1; - if ($[3] !== diffStatsForRestore.filesChanged[0]) { - t1 = path.basename(diffStatsForRestore.filesChanged[0] || ""); - $[3] = diffStatsForRestore.filesChanged[0]; - $[4] = t1; - } else { - t1 = $[4]; - } - const file1 = t1; - let t2; - if ($[5] !== diffStatsForRestore.filesChanged[1]) { - t2 = path.basename(diffStatsForRestore.filesChanged[1] || ""); - $[5] = diffStatsForRestore.filesChanged[1]; - $[6] = t2; - } else { - t2 = $[6]; - } - const file2 = t2; - fileLabel = `${file1} and ${file2}`; - } else { - let t1; - if ($[7] !== diffStatsForRestore.filesChanged[0]) { - t1 = path.basename(diffStatsForRestore.filesChanged[0] || ""); - $[7] = diffStatsForRestore.filesChanged[0]; - $[8] = t1; - } else { - t1 = $[8]; - } - const file1_0 = t1; - fileLabel = `${file1_0} and ${diffStatsForRestore.filesChanged.length - 1} other files`; - } - } - let t1; - if ($[9] !== diffStatsForRestore) { - t1 = ; - $[9] = diffStatsForRestore; - $[10] = t1; - } else { - t1 = $[10]; + const file1 = path.basename(diffStatsForRestore.filesChanged[0] || '') + fileLabel = `${file1} and ${diffStatsForRestore.filesChanged.length - 1} other files` } - let t2; - if ($[11] !== fileLabel || $[12] !== t1) { - t2 = <>The code will be restored{" "}{t1} in {fileLabel}.; - $[11] = fileLabel; - $[12] = t1; - $[13] = t2; - } else { - t2 = $[13]; - } - return t2; + + return ( + <> + + The code will be restored{' '} + in {fileLabel}. + + + ) } -function DiffStatsText(t0) { - const $ = _c(7); - const { - diffStats - } = t0; + +function DiffStatsText({ + diffStats, +}: { + diffStats: DiffStats | undefined +}): React.ReactNode { if (!diffStats || !diffStats.filesChanged) { - return; - } - let t1; - if ($[0] !== diffStats.insertions) { - t1 = +{diffStats.insertions} ; - $[0] = diffStats.insertions; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== diffStats.deletions) { - t2 = -{diffStats.deletions}; - $[2] = diffStats.deletions; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1 || $[5] !== t2) { - t3 = <>{t1}{t2}; - $[4] = t1; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; + return undefined + } + return ( + <> + +{diffStats.insertions} + -{diffStats.deletions} + + ) } -function UserMessageOption(t0) { - const $ = _c(31); - const { - userMessage, - color, - dimColor, - isCurrent, - paddingRight - } = t0; - const { - columns - } = useTerminalSize(); + +function UserMessageOption({ + userMessage, + color, + dimColor, + isCurrent, + paddingRight, +}: { + userMessage: UserMessage + color?: keyof Theme + dimColor?: boolean + isCurrent: boolean + paddingRight?: number +}): React.ReactNode { + const { columns } = useTerminalSize() if (isCurrent) { - let t1; - if ($[0] !== color || $[1] !== dimColor) { - t1 = (current); - $[0] = color; - $[1] = dimColor; - $[2] = t1; - } else { - t1 = $[2]; + return ( + + + (current) + + + ) + } + + const content = userMessage.message.content + const lastBlock = + typeof content === 'string' ? null : content[content.length - 1] + const rawMessageText = + typeof content === 'string' + ? content.trim() + : lastBlock && isTextBlock(lastBlock) + ? lastBlock.text.trim() + : '(no prompt)' + + // Strip display-unfriendly tags (like ) before showing in the list + const messageText = stripDisplayTags(rawMessageText) + + if (isEmptyMessageText(messageText)) { + return ( + + + ((empty message)) + + + ) + } + + // Bash inputs + if (messageText.includes('')) { + const input = extractTag(messageText, 'bash-input') + if (input) { + return ( + + ! + + {' '} + {input} + + + ) } - return t1; } - const content = userMessage.message.content; - const lastBlock = typeof content === "string" ? null : content[content.length - 1]; - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - if ($[3] !== color || $[4] !== columns || $[5] !== content || $[6] !== dimColor || $[7] !== lastBlock || $[8] !== paddingRight) { - t6 = Symbol.for("react.early_return_sentinel"); - bb0: { - const rawMessageText = typeof content === "string" ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() : "(no prompt)"; - const messageText = stripDisplayTags(rawMessageText); - if (isEmptyMessageText(messageText)) { - let t7; - if ($[17] !== color || $[18] !== dimColor) { - t7 = ((empty message)); - $[17] = color; - $[18] = dimColor; - $[19] = t7; - } else { - t7 = $[19]; - } - t6 = t7; - break bb0; - } - if (messageText.includes("")) { - const input = extractTag(messageText, "bash-input"); - if (input) { - let t7; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t7 = !; - $[20] = t7; - } else { - t7 = $[20]; - } - t6 = {t7}{" "}{input}; - break bb0; - } - } - if (messageText.includes(`<${COMMAND_MESSAGE_TAG}>`)) { - const commandMessage = extractTag(messageText, COMMAND_MESSAGE_TAG); - const args = extractTag(messageText, "command-args"); - const isSkillFormat = extractTag(messageText, "skill-format") === "true"; - if (commandMessage) { - if (isSkillFormat) { - t6 = Skill({commandMessage}); - break bb0; - } else { - t6 = /{commandMessage} {args}; - break bb0; - } - } + + // Skills and slash commands + if (messageText.includes(`<${COMMAND_MESSAGE_TAG}>`)) { + const commandMessage = extractTag(messageText, COMMAND_MESSAGE_TAG) + const args = extractTag(messageText, 'command-args') + const isSkillFormat = extractTag(messageText, 'skill-format') === 'true' + if (commandMessage) { + if (isSkillFormat) { + // Skills: Display as "Skill(name)" + return ( + + + Skill({commandMessage}) + + + ) + } else { + // Slash commands: Add "/" prefix and include args + return ( + + + /{commandMessage} {args} + + + ) } - T1 = Box; - t4 = "row"; - t5 = "100%"; - T0 = Text; - t1 = color; - t2 = dimColor; - t3 = paddingRight ? truncate(messageText, columns - paddingRight, true) : messageText.slice(0, 500).split("\n").slice(0, 4).join("\n"); } - $[3] = color; - $[4] = columns; - $[5] = content; - $[6] = dimColor; - $[7] = lastBlock; - $[8] = paddingRight; - $[9] = T0; - $[10] = T1; - $[11] = t1; - $[12] = t2; - $[13] = t3; - $[14] = t4; - $[15] = t5; - $[16] = t6; - } else { - T0 = $[9]; - T1 = $[10]; - t1 = $[11]; - t2 = $[12]; - t3 = $[13]; - t4 = $[14]; - t5 = $[15]; - t6 = $[16]; - } - if (t6 !== Symbol.for("react.early_return_sentinel")) { - return t6; - } - let t7; - if ($[21] !== T0 || $[22] !== t1 || $[23] !== t2 || $[24] !== t3) { - t7 = {t3}; - $[21] = T0; - $[22] = t1; - $[23] = t2; - $[24] = t3; - $[25] = t7; - } else { - t7 = $[25]; } - let t8; - if ($[26] !== T1 || $[27] !== t4 || $[28] !== t5 || $[29] !== t7) { - t8 = {t7}; - $[26] = T1; - $[27] = t4; - $[28] = t5; - $[29] = t7; - $[30] = t8; - } else { - t8 = $[30]; - } - return t8; + + // User prompts + return ( + + + {paddingRight + ? truncate(messageText, columns - paddingRight, true) + : messageText.slice(0, 500).split('\n').slice(0, 4).join('\n')} + + + ) } /** * Computes the diff stats for all the file edits in-between two messages. */ -function computeDiffStatsBetweenMessages(messages: Message[], fromMessageId: UUID, toMessageId: UUID | undefined): DiffStats | undefined { - const startIndex = messages.findIndex(msg => msg.uuid === fromMessageId); +function computeDiffStatsBetweenMessages( + messages: Message[], + fromMessageId: UUID, + toMessageId: UUID | undefined, +): DiffStats | undefined { + const startIndex = messages.findIndex(msg => msg.uuid === fromMessageId) if (startIndex === -1) { - return undefined; + return undefined } - let endIndex = toMessageId ? messages.findIndex(msg => msg.uuid === toMessageId) : messages.length; + + let endIndex = toMessageId + ? messages.findIndex(msg => msg.uuid === toMessageId) + : messages.length if (endIndex === -1) { - endIndex = messages.length; + endIndex = messages.length } - const filesChanged: string[] = []; - let insertions = 0; - let deletions = 0; + + const filesChanged: string[] = [] + let insertions = 0 + let deletions = 0 + for (let i = startIndex + 1; i < endIndex; i++) { - const msg = messages[i]; + const msg = messages[i] if (!msg || !isToolUseResultMessage(msg)) { - continue; + continue } - const result = msg.toolUseResult as FileEditOutput | FileWriteToolOutput; + + const result = msg.toolUseResult as FileEditOutput | FileWriteToolOutput if (!result || !result.filePath || !result.structuredPatch) { - continue; + continue } + if (!filesChanged.includes(result.filePath)) { - filesChanged.push(result.filePath); + filesChanged.push(result.filePath) } + try { if ('type' in result && result.type === 'create') { - insertions += result.content.split(/\r?\n/).length; + insertions += result.content.split(/\r?\n/).length } else { for (const hunk of result.structuredPatch) { - const additions = count(hunk.lines, line => line.startsWith('+')); - const removals = count(hunk.lines, line => line.startsWith('-')); - insertions += additions; - deletions += removals; + const additions = count(hunk.lines, line => line.startsWith('+')) + const removals = count(hunk.lines, line => line.startsWith('-')) + + insertions += additions + deletions += removals } } } catch { - continue; + continue } } + return { filesChanged, insertions, - deletions - }; + deletions, + } } -export function selectableUserMessagesFilter(message: Message): message is UserMessage { + +export function selectableUserMessagesFilter( + message: Message, +): message is UserMessage { if (message.type !== 'user') { - return false; + return false } - if (Array.isArray(message.message.content) && message.message.content[0]?.type === 'tool_result') { - return false; + if ( + Array.isArray(message.message.content) && + message.message.content[0]?.type === 'tool_result' + ) { + return false } if (isSyntheticMessage(message)) { - return false; + return false } if (message.isMeta) { - return false; + return false } if (message.isCompactSummary || message.isVisibleInTranscriptOnly) { - return false; + return false } - const content = message.message.content; - const lastBlock = typeof content === 'string' ? null : content[content.length - 1]; - const messageText = typeof content === 'string' ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() : ''; + + const content = message.message.content + const lastBlock = + typeof content === 'string' ? null : content[content.length - 1] + const messageText = + typeof content === 'string' + ? content.trim() + : lastBlock && isTextBlock(lastBlock) + ? lastBlock.text.trim() + : '' // Filter out non-user-authored messages (command outputs, task notifications, ticks). - if (messageText.indexOf(`<${LOCAL_COMMAND_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${LOCAL_COMMAND_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${TASK_NOTIFICATION_TAG}>`) !== -1 || messageText.indexOf(`<${TICK_TAG}>`) !== -1 || messageText.indexOf(`<${TEAMMATE_MESSAGE_TAG}`) !== -1) { - return false; - } - return true; + if ( + messageText.indexOf(`<${LOCAL_COMMAND_STDOUT_TAG}>`) !== -1 || + messageText.indexOf(`<${LOCAL_COMMAND_STDERR_TAG}>`) !== -1 || + messageText.indexOf(`<${BASH_STDOUT_TAG}>`) !== -1 || + messageText.indexOf(`<${BASH_STDERR_TAG}>`) !== -1 || + messageText.indexOf(`<${TASK_NOTIFICATION_TAG}>`) !== -1 || + messageText.indexOf(`<${TICK_TAG}>`) !== -1 || + messageText.indexOf(`<${TEAMMATE_MESSAGE_TAG}`) !== -1 + ) { + return false + } + return true } /** @@ -796,35 +944,42 @@ export function selectableUserMessagesFilter(message: Message): message is UserM * or non-meaningful content. Returns true if there's nothing meaningful to confirm - * for example, if the user hit enter then immediately cancelled. */ -export function messagesAfterAreOnlySynthetic(messages: Message[], fromIndex: number): boolean { +export function messagesAfterAreOnlySynthetic( + messages: Message[], + fromIndex: number, +): boolean { for (let i = fromIndex + 1; i < messages.length; i++) { - const msg = messages[i]; - if (!msg) continue; + const msg = messages[i] + if (!msg) continue // Skip known non-meaningful message types - if (isSyntheticMessage(msg)) continue; - if (isToolUseResultMessage(msg)) continue; - if (msg.type === 'progress') continue; - if (msg.type === 'system') continue; - if (msg.type === 'attachment') continue; - if (msg.type === 'user' && msg.isMeta) continue; + if (isSyntheticMessage(msg)) continue + if (isToolUseResultMessage(msg)) continue + if (msg.type === 'progress') continue + if (msg.type === 'system') continue + if (msg.type === 'attachment') continue + if (msg.type === 'user' && msg.isMeta) continue // Assistant with actual content = meaningful if (msg.type === 'assistant') { - const content = msg.message.content; + const content = msg.message.content if (Array.isArray(content)) { - const hasMeaningfulContent = content.some(block => block.type === 'text' && block.text.trim() || block.type === 'tool_use'); - if (hasMeaningfulContent) return false; + const hasMeaningfulContent = content.some( + block => + (block.type === 'text' && block.text.trim()) || + block.type === 'tool_use', + ) + if (hasMeaningfulContent) return false } - continue; + continue } // User messages that aren't synthetic or meta = meaningful if (msg.type === 'user') { - return false; + return false } // Other types (e.g., tombstone) are non-meaningful, continue } - return true; + return true } diff --git a/src/components/MessageTimestamp.tsx b/src/components/MessageTimestamp.tsx index 60e08f4c5..8eac935e5 100644 --- a/src/components/MessageTimestamp.tsx +++ b/src/components/MessageTimestamp.tsx @@ -1,62 +1,39 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import type { NormalizedMessage } from '../types/message.js'; +import React from 'react' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import type { NormalizedMessage } from '../types/message.js' + type Props = { - message: NormalizedMessage; - isTranscriptMode: boolean; -}; -export function MessageTimestamp(t0) { - const $ = _c(10); - const { - message, - isTranscriptMode - } = t0; - const shouldShowTimestamp = isTranscriptMode && message.timestamp && message.type === "assistant" && message.message.content.some(_temp); + message: NormalizedMessage + isTranscriptMode: boolean +} + +export function MessageTimestamp({ + message, + isTranscriptMode, +}: Props): React.ReactNode { + const shouldShowTimestamp = + isTranscriptMode && + message.timestamp && + message.type === 'assistant' && + message.message.content.some(c => c.type === 'text') + if (!shouldShowTimestamp) { - return null; - } - let T0; - let formattedTimestamp; - let t1; - if ($[0] !== message.timestamp) { - formattedTimestamp = new Date(message.timestamp).toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: true - }); - T0 = Box; - t1 = stringWidth(formattedTimestamp); - $[0] = message.timestamp; - $[1] = T0; - $[2] = formattedTimestamp; - $[3] = t1; - } else { - T0 = $[1]; - formattedTimestamp = $[2]; - t1 = $[3]; - } - let t2; - if ($[4] !== formattedTimestamp) { - t2 = {formattedTimestamp}; - $[4] = formattedTimestamp; - $[5] = t2; - } else { - t2 = $[5]; + return null } - let t3; - if ($[6] !== T0 || $[7] !== t1 || $[8] !== t2) { - t3 = {t2}; - $[6] = T0; - $[7] = t1; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - return t3; -} -function _temp(c) { - return c.type === "text"; + + const formattedTimestamp = new Date(message.timestamp).toLocaleTimeString( + 'en-US', + { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }, + ) + + return ( + + {formattedTimestamp} + + ) } diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 64a24f220..c946a9fd7 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -1,48 +1,71 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import type { UUID } from 'crypto'; -import type { RefObject } from 'react'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { every } from 'src/utils/set.js'; -import { getIsRemoteMode } from '../bootstrap/state.js'; -import type { Command } from '../commands.js'; -import { BLACK_CIRCLE } from '../constants/figures.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import { useTerminalNotification } from '../ink/useTerminalNotification.js'; -import { Box, Text } from '../ink.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import type { Screen } from '../screens/REPL.js'; -import type { Tools } from '../Tool.js'; -import { findToolByName } from '../Tool.js'; -import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'; -import type { AssistantMessage, AttachmentMessage, Message as MessageType, NormalizedMessage, ProgressMessage as ProgressMessageType, RenderableMessage, SystemMessage, UserMessage } from '../types/message.js'; -import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; -import { collapseBackgroundBashNotifications } from '../utils/collapseBackgroundBashNotifications.js'; -import { collapseHookSummaries } from '../utils/collapseHookSummaries.js'; -import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js'; -import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { isEnvTruthy } from '../utils/envUtils.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import { applyGrouping } from '../utils/groupToolUses.js'; -import { buildMessageLookups, createAssistantMessage, deriveUUID, getMessagesAfterCompactBoundary, getToolUseID, getToolUseIDs, hasUnresolvedHooksFromLookup, isNotEmptyMessage, normalizeMessages, reorderMessagesInUI, type StreamingThinking, type StreamingToolUse, shouldShowUserMessage } from '../utils/messages.js'; -import { plural } from '../utils/stringUtils.js'; -import { renderableSearchText } from '../utils/transcriptSearch.js'; -import { Divider } from './design-system/Divider.js'; -import type { UnseenDivider } from './FullscreenLayout.js'; -import { LogoV2 } from './LogoV2/LogoV2.js'; -import { StreamingMarkdown } from './Markdown.js'; -import { hasContentAfterIndex, MessageRow } from './MessageRow.js'; -import { InVirtualListContext, type MessageActionsNav, MessageActionsSelectedContext, type MessageActionsState } from './messageActions.js'; -import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; -import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'; -import { OffscreenFreeze } from './OffscreenFreeze.js'; -import type { ToolUseConfirm } from './permissions/PermissionRequest.js'; -import { StatusNotices } from './StatusNotices.js'; -import type { JumpHandle } from './VirtualMessageList.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import type { UUID } from 'crypto' +import type { RefObject } from 'react' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { every } from 'src/utils/set.js' +import { getIsRemoteMode } from '../bootstrap/state.js' +import type { Command } from '../commands.js' +import { BLACK_CIRCLE } from '../constants/figures.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import { useTerminalNotification } from '../ink/useTerminalNotification.js' +import { Box, Text } from '../ink.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import type { Screen } from '../screens/REPL.js' +import type { Tools } from '../Tool.js' +import { findToolByName } from '../Tool.js' +import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js' +import type { + Message as MessageType, + NormalizedMessage, + ProgressMessage as ProgressMessageType, + RenderableMessage, +} from '../types/message.js' +import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js' +import { collapseBackgroundBashNotifications } from '../utils/collapseBackgroundBashNotifications.js' +import { collapseHookSummaries } from '../utils/collapseHookSummaries.js' +import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js' +import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js' +import { getGlobalConfig } from '../utils/config.js' +import { isEnvTruthy } from '../utils/envUtils.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import { applyGrouping } from '../utils/groupToolUses.js' +import { + buildMessageLookups, + createAssistantMessage, + deriveUUID, + getMessagesAfterCompactBoundary, + getToolUseID, + getToolUseIDs, + hasUnresolvedHooksFromLookup, + isNotEmptyMessage, + normalizeMessages, + reorderMessagesInUI, + type StreamingThinking, + type StreamingToolUse, + shouldShowUserMessage, +} from '../utils/messages.js' +import { plural } from '../utils/stringUtils.js' +import { renderableSearchText } from '../utils/transcriptSearch.js' +import { Divider } from './design-system/Divider.js' +import type { UnseenDivider } from './FullscreenLayout.js' +import { LogoV2 } from './LogoV2/LogoV2.js' +import { StreamingMarkdown } from './Markdown.js' +import { hasContentAfterIndex, MessageRow } from './MessageRow.js' +import { + InVirtualListContext, + type MessageActionsNav, + MessageActionsSelectedContext, + type MessageActionsState, +} from './messageActions.js' +import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js' +import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js' +import { OffscreenFreeze } from './OffscreenFreeze.js' +import type { ToolUseConfirm } from './permissions/PermissionRequest.js' +import { StatusNotices } from './StatusNotices.js' +import type { JumpHandle } from './VirtualMessageList.js' // Memoed logo header: this box is the FIRST sibling before all MessageRows // in main-screen mode. If it becomes dirty on every Messages re-render, @@ -52,37 +75,46 @@ import type { JumpHandle } from './VirtualMessageList.js'; // and pegs CPU at 100%. Memo on agentDefinitions so a new messages array // doesn't invalidate the logo subtree. LogoV2/StatusNotices internally // subscribe to useAppState/useSettings for their own updates. -const LogoHeader = React.memo(function LogoHeader(t0: { agentDefinitions: AgentDefinitionsResult }) { - const $ = _c(3); - const { - agentDefinitions - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== agentDefinitions) { - t2 = {t1}; - $[1] = agentDefinitions; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; -}); +const LogoHeader = React.memo(function LogoHeader({ + agentDefinitions, +}: { + agentDefinitions: AgentDefinitionsResult | undefined +}): React.ReactNode { + // LogoV2 has its own internal OffscreenFreeze (catches its useAppState + // re-renders). This outer freeze catches agentDefinitions changes and any + // future StatusNotices subscriptions while the header is in scrollback. + return ( + + + + + + + + + ) +}) // Dead code elimination: conditional import for proactive mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; -const BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? (require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js')).BRIEF_TOOL_NAME : null; -const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') ? (require('../tools/SendUserFileTool/prompt.js') as typeof import('../tools/SendUserFileTool/prompt.js')).SEND_USER_FILE_TOOL_NAME : null; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? require('../proactive/index.js') + : null +const BRIEF_TOOL_NAME: string | null = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? ( + require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') + ).BRIEF_TOOL_NAME + : null +const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') + ? ( + require('../tools/SendUserFileTool/prompt.js') as typeof import('../tools/SendUserFileTool/prompt.js') + ).SEND_USER_FILE_TOOL_NAME + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { VirtualMessageList } from './VirtualMessageList.js'; +import { VirtualMessageList } from './VirtualMessageList.js' /** * In brief-only mode, filter messages to show ONLY Brief tool_use blocks, @@ -90,58 +122,61 @@ import { VirtualMessageList } from './VirtualMessageList.js'; * if the model forgets to call Brief, the user sees nothing for that turn. * That's on the model to get right; the filter does not second-guess it. */ -export function filterForBriefTool; - }; - attachment?: { - type: string; - isMeta?: boolean; - origin?: unknown; - commandMode?: string; - }; -}>(messages: T[], briefToolNames: string[]): T[] { - const nameSet = new Set(briefToolNames); +export function filterForBriefTool< + T extends { + type: string + subtype?: string + isMeta?: boolean + isApiErrorMessage?: boolean + message?: { + content: Array<{ + type: string + name?: string + tool_use_id?: string + }> + } + attachment?: { + type: string + isMeta?: boolean + origin?: unknown + commandMode?: string + } + }, +>(messages: T[], briefToolNames: string[]): T[] { + const nameSet = new Set(briefToolNames) // tool_use always precedes its tool_result in the array, so we can collect // IDs and match against them in a single pass. - const briefToolUseIDs = new Set(); + const briefToolUseIDs = new Set() return messages.filter(msg => { // System messages (attach confirmation, remote errors, compact boundaries) // must stay visible — dropping them leaves the viewer with no feedback. // Exception: api_metrics is per-turn debug noise (TTFT, config writes, // hook timing) that defeats the point of brief mode. Still visible in // transcript mode (ctrl+o) which bypasses this filter. - if (msg.type === 'system') return msg.subtype !== 'api_metrics'; - const block = msg.message?.content[0]; + if (msg.type === 'system') return msg.subtype !== 'api_metrics' + const block = msg.message?.content[0] if (msg.type === 'assistant') { // API error messages (auth failures, rate limits, etc.) must stay visible - if (msg.isApiErrorMessage) return true; + if (msg.isApiErrorMessage) return true // Keep Brief tool_use blocks (renders with standard tool call chrome, // and must be in the list so buildMessageLookups can resolve tool results) if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { if ('id' in block) { - briefToolUseIDs.add((block as { - id: string; - }).id); + briefToolUseIDs.add((block as { id: string }).id) } - return true; + return true } - return false; + return false } if (msg.type === 'user') { if (block?.type === 'tool_result') { - return block.tool_use_id !== undefined && briefToolUseIDs.has(block.tool_use_id); + return ( + block.tool_use_id !== undefined && + briefToolUseIDs.has(block.tool_use_id) + ) } // Real user input only — drop meta/tick messages. - return !msg.isMeta; + return !msg.isMeta } if (msg.type === 'attachment') { // Human input drained mid-turn arrives as a queued_command attachment @@ -150,11 +185,16 @@ export function filterForBriefTool; - }; -}>(messages: T[], briefToolNames: string[]): T[] { - const nameSet = new Set(briefToolNames); +export function dropTextInBriefTurns< + T extends { + type: string + isMeta?: boolean + message?: { content: Array<{ type: string; name?: string }> } + }, +>(messages: T[], briefToolNames: string[]): T[] { + const nameSet = new Set(briefToolNames) // First pass: find which turns (bounded by non-meta user messages) contain // a Brief tool_use. Tag each assistant text block with its turn index. - const turnsWithBrief = new Set(); - const textIndexToTurn: number[] = []; - let turn = 0; + const turnsWithBrief = new Set() + const textIndexToTurn: number[] = [] + let turn = 0 for (let i = 0; i < messages.length; i++) { - const msg = messages[i]!; - const block = msg.message?.content[0]; + const msg = messages[i]! + const block = msg.message?.content[0] if (msg.type === 'user' && block?.type !== 'tool_result' && !msg.isMeta) { - turn++; - continue; + turn++ + continue } if (msg.type === 'assistant') { if (block?.type === 'text') { - textIndexToTurn[i] = turn; - } else if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { - turnsWithBrief.add(turn); + textIndexToTurn[i] = turn + } else if ( + block?.type === 'tool_use' && + block.name && + nameSet.has(block.name) + ) { + turnsWithBrief.add(turn) } } } - if (turnsWithBrief.size === 0) return messages; + if (turnsWithBrief.size === 0) return messages // Second pass: drop text blocks whose turn called Brief. return messages.filter((_, i) => { - const t = textIndexToTurn[i]; - return t === undefined || !turnsWithBrief.has(t); - }); + const t = textIndexToTurn[i] + return t === undefined || !turnsWithBrief.has(t) + }) } + type Props = { - messages: MessageType[]; - tools: Tools; - commands: Command[]; - verbose: boolean; + messages: MessageType[] + tools: Tools + commands: Command[] + verbose: boolean toolJSX: { - jsx: React.ReactNode | null; - shouldHidePromptInput: boolean; - shouldContinueAnimation?: true; - } | null; - toolUseConfirmQueue: ToolUseConfirm[]; - inProgressToolUseIDs: Set; - isMessageSelectorVisible: boolean; - conversationId: string; - screen: Screen; - streamingToolUses: StreamingToolUse[]; - showAllInTranscript?: boolean; - agentDefinitions?: AgentDefinitionsResult; - onOpenRateLimitOptions?: () => void; + jsx: React.ReactNode | null + shouldHidePromptInput: boolean + shouldContinueAnimation?: true + } | null + toolUseConfirmQueue: ToolUseConfirm[] + inProgressToolUseIDs: Set + isMessageSelectorVisible: boolean + conversationId: string + screen: Screen + streamingToolUses: StreamingToolUse[] + showAllInTranscript?: boolean + agentDefinitions?: AgentDefinitionsResult + onOpenRateLimitOptions?: () => void /** Hide the logo/header - used for subagent zoom view */ - hideLogo?: boolean; - isLoading: boolean; + hideLogo?: boolean + isLoading: boolean /** In transcript mode, hide all thinking blocks except the last one */ - hidePastThinking?: boolean; + hidePastThinking?: boolean /** Streaming thinking content (live updates, not frozen) */ - streamingThinking?: StreamingThinking | null; + streamingThinking?: StreamingThinking | null /** Streaming text preview (rendered as last item so transition to final message is positionally seamless) */ - streamingText?: string | null; + streamingText?: string | null /** When true, only show Brief tool output (hide everything else) */ - isBriefOnly?: boolean; + isBriefOnly?: boolean /** Fullscreen-mode "─── N new ───" divider. Renders before the first * renderableMessage derived from firstUnseenUuid (matched by the 24-char * prefix that deriveUUID preserves). */ - unseenDivider?: UnseenDivider; + unseenDivider?: UnseenDivider /** Fullscreen-mode ScrollBox handle. Enables React-level virtualization when present. */ - scrollRef?: RefObject; + scrollRef?: RefObject /** Fullscreen-mode: enable sticky-prompt tracking (writes via ScrollChromeContext). */ - trackStickyPrompt?: boolean; + trackStickyPrompt?: boolean /** Transcript search: jump-to-index + setSearchQuery/nextMatch/prevMatch. */ - jumpRef?: RefObject; + jumpRef?: RefObject /** Transcript search: fires when match count/position changes. */ - onSearchMatchesChange?: (count: number, current: number) => void; + onSearchMatchesChange?: (count: number, current: number) => void /** Paint an existing DOM subtree to fresh Screen, scan. Element comes * from the main tree (all real providers). Message-relative positions. */ - scanElement?: (el: import('../ink/dom.js').DOMElement) => import('../ink/render-to-screen.js').MatchPosition[]; + scanElement?: ( + el: import('../ink/dom.js').DOMElement, + ) => import('../ink/render-to-screen.js').MatchPosition[] /** Position-based CURRENT highlight. positions stable (msg-relative), * rowOffset tracks scroll. null clears. */ - setPositions?: (state: { - positions: import('../ink/render-to-screen.js').MatchPosition[]; - rowOffset: number; - currentIdx: number; - } | null) => void; + setPositions?: ( + state: { + positions: import('../ink/render-to-screen.js').MatchPosition[] + rowOffset: number + currentIdx: number + } | null, + ) => void /** Bypass MAX_MESSAGES_WITHOUT_VIRTUALIZATION. For one-shot headless renders * (e.g. /export via renderToString) where the memory concern doesn't apply * and the "already in scrollback" justification doesn't hold. */ - disableRenderCap?: boolean; + disableRenderCap?: boolean /** In-transcript cursor; expanded overrides verbose for selected message. */ - cursor?: MessageActionsState | null; - setCursor?: (cursor: MessageActionsState | null) => void; + cursor?: MessageActionsState | null + setCursor?: (cursor: MessageActionsState | null) => void /** Passed through to VirtualMessageList (heightCache owns visibility). */ - cursorNavRef?: React.Ref; + cursorNavRef?: React.Ref /** Render only collapsed.slice(start, end). For chunked headless export * (streamRenderedMessages in exportRenderer.tsx): prep runs on the FULL * messages array so grouping/lookups are correct, but only this slice * chunk instead of the full session. The logo renders only for chunk 0 * (start === 0); later chunks are mid-stream continuations. * Measured Mar 2026: 538-msg session, 20 slices → −55% plateau RSS. */ - renderRange?: readonly [start: number, end: number]; -}; -const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30; + renderRange?: readonly [start: number, end: number] +} + +const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30 // Safety cap for the non-virtualized render path (fullscreen off or // explicitly disabled). Ink mounts a full fiber tree per message (~250 KB @@ -304,40 +351,47 @@ const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30; // slice roughly where it was instead of resetting to 0 — which would // jump from ~200 rendered messages to the full history, orphaning // in-progress badge snapshots in scrollback. -const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200; -const MESSAGE_CAP_STEP = 50; -export type SliceAnchor = { - uuid: string; - idx: number; -} | null; +const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200 +const MESSAGE_CAP_STEP = 50 + +export type SliceAnchor = { uuid: string; idx: number } | null /** Exported for testing. Mutates anchorRef when the window needs to advance. */ -export function computeSliceStart(collapsed: ReadonlyArray<{ - uuid: string; -}>, anchorRef: { - current: SliceAnchor; -}, cap = MAX_MESSAGES_WITHOUT_VIRTUALIZATION, step = MESSAGE_CAP_STEP): number { - const anchor = anchorRef.current; - const anchorIdx = anchor ? collapsed.findIndex(m => m.uuid === anchor.uuid) : -1; +export function computeSliceStart( + collapsed: ReadonlyArray<{ uuid: string }>, + anchorRef: { current: SliceAnchor }, + cap = MAX_MESSAGES_WITHOUT_VIRTUALIZATION, + step = MESSAGE_CAP_STEP, +): number { + const anchor = anchorRef.current + const anchorIdx = anchor + ? collapsed.findIndex(m => m.uuid === anchor.uuid) + : -1 // Anchor found → use it. Anchor lost → fall back to stored index // (clamped) so collapse-regrouping uuid churn doesn't reset to 0. - let start = anchorIdx >= 0 ? anchorIdx : anchor ? Math.min(anchor.idx, Math.max(0, collapsed.length - cap)) : 0; + let start = + anchorIdx >= 0 + ? anchorIdx + : anchor + ? Math.min(anchor.idx, Math.max(0, collapsed.length - cap)) + : 0 if (collapsed.length - start > cap + step) { - start = collapsed.length - cap; + start = collapsed.length - cap } // Refresh anchor from whatever lives at the current start — heals a // stale uuid after fallback and captures a new one after advancement. - const msgAtStart = collapsed[start]; - if (msgAtStart && (anchor?.uuid !== msgAtStart.uuid || anchor.idx !== start)) { - anchorRef.current = { - uuid: msgAtStart.uuid, - idx: start - }; + const msgAtStart = collapsed[start] + if ( + msgAtStart && + (anchor?.uuid !== msgAtStart.uuid || anchor.idx !== start) + ) { + anchorRef.current = { uuid: msgAtStart.uuid, idx: start } } else if (!msgAtStart && anchor) { - anchorRef.current = null; + anchorRef.current = null } - return start; + return start } + const MessagesImpl = ({ messages, tools, @@ -370,107 +424,140 @@ const MessagesImpl = ({ cursor = null, setCursor, cursorNavRef, - renderRange + renderRange, }: Props): React.ReactNode => { - const { - columns - } = useTerminalSize(); - const toggleShowAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'Ctrl+E'); - const normalizedMessages = useMemo(() => normalizeMessages(messages).filter(isNotEmptyMessage), [messages]); + const { columns } = useTerminalSize() + const toggleShowAllShortcut = useShortcutDisplay( + 'transcript:toggleShowAll', + 'Transcript', + 'Ctrl+E', + ) + + const normalizedMessages = useMemo( + () => normalizeMessages(messages).filter(isNotEmptyMessage), + [messages], + ) // Check if streaming thinking should be visible (streaming or within 30s timeout) const isStreamingThinkingVisible = useMemo(() => { - if (!streamingThinking) return false; - if (streamingThinking.isStreaming) return true; + if (!streamingThinking) return false + if (streamingThinking.isStreaming) return true if (streamingThinking.streamingEndedAt) { - return Date.now() - streamingThinking.streamingEndedAt < 30000; + return Date.now() - streamingThinking.streamingEndedAt < 30000 } - return false; - }, [streamingThinking]); + return false + }, [streamingThinking]) // Find the last thinking block (message UUID + content index) for hiding past thinking in transcript mode // When streaming thinking is visible, use a special ID that won't match any completed thinking block // With adaptive thinking, only consider thinking blocks from the current turn and stop searching once we // hit the last user message. const lastThinkingBlockId = useMemo(() => { - if (!hidePastThinking) return null; + if (!hidePastThinking) return null // If streaming thinking is visible, hide all completed thinking blocks by using a non-matching ID - if (isStreamingThinkingVisible) return 'streaming'; + if (isStreamingThinkingVisible) return 'streaming' // Iterate backwards to find the last message with a thinking block for (let i = normalizedMessages.length - 1; i >= 0; i--) { - const msg = normalizedMessages[i]; + const msg = normalizedMessages[i] if (msg?.type === 'assistant') { - const content = msg.message.content as Array<{ type: string }>; + const content = msg.message.content // Find the last thinking block in this message for (let j = content.length - 1; j >= 0; j--) { if (content[j]?.type === 'thinking') { - return `${msg.uuid}:${j}`; + return `${msg.uuid}:${j}` } } } else if (msg?.type === 'user') { - const hasToolResult = (msg.message.content as Array<{ type: string }>).some(block => block.type === 'tool_result'); + const hasToolResult = msg.message.content.some( + block => block.type === 'tool_result', + ) if (!hasToolResult) { // Reached a previous user turn so don't show stale thinking from before - return 'no-thinking'; + return 'no-thinking' } } } - return null; - }, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]); + return null + }, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]) // Find the latest user bash output message (from ! commands) // This allows us to show full output for the most recent bash command const latestBashOutputUUID = useMemo(() => { // Iterate backwards to find the last user message with bash output - for (let i_0 = normalizedMessages.length - 1; i_0 >= 0; i_0--) { - const msg_0 = normalizedMessages[i_0]; - if (msg_0?.type === 'user') { - const content_0 = msg_0.message.content as Array<{ type: string; text?: string }>; + for (let i = normalizedMessages.length - 1; i >= 0; i--) { + const msg = normalizedMessages[i] + if (msg?.type === 'user') { + const content = msg.message.content // Check if any text content is bash output - for (const block_0 of content_0) { - if (block_0.type === 'text') { - const text = block_0.text!; - if (text.startsWith(' getToolUseIDs(normalizedMessages), [normalizedMessages]); - const streamingToolUsesWithoutInProgress = useMemo(() => streamingToolUses.filter(stu => !inProgressToolUseIDs.has(stu.contentBlock.id) && !normalizedToolUseIDs.has(stu.contentBlock.id)), [streamingToolUses, inProgressToolUseIDs, normalizedToolUseIDs]); - const syntheticStreamingToolUseMessages = useMemo(() => streamingToolUsesWithoutInProgress.flatMap(streamingToolUse => { - const msg_1 = createAssistantMessage({ - content: [streamingToolUse.contentBlock] - }); - // Override randomUUID with deterministic value derived from content - // block ID to prevent React key changes on every memo recomputation. - // Same class of bug fixed in normalizeMessages (commit 383326e613): - // fresh randomUUID → unstable React keys → component remounts → - // Ink rendering corruption (overlapping text from stale DOM nodes). - msg_1.uuid = deriveUUID(streamingToolUse.contentBlock.id as UUID, 0); - return normalizeMessages([msg_1]); - }), [streamingToolUsesWithoutInProgress]); - const isTranscriptMode = screen === 'transcript'; + const normalizedToolUseIDs = useMemo( + () => getToolUseIDs(normalizedMessages), + [normalizedMessages], + ) + + const streamingToolUsesWithoutInProgress = useMemo( + () => + streamingToolUses.filter( + stu => + !inProgressToolUseIDs.has(stu.contentBlock.id) && + !normalizedToolUseIDs.has(stu.contentBlock.id), + ), + [streamingToolUses, inProgressToolUseIDs, normalizedToolUseIDs], + ) + + const syntheticStreamingToolUseMessages = useMemo( + () => + streamingToolUsesWithoutInProgress.flatMap(streamingToolUse => { + const msg = createAssistantMessage({ + content: [streamingToolUse.contentBlock], + }) + // Override randomUUID with deterministic value derived from content + // block ID to prevent React key changes on every memo recomputation. + // Same class of bug fixed in normalizeMessages (commit 383326e613): + // fresh randomUUID → unstable React keys → component remounts → + // Ink rendering corruption (overlapping text from stale DOM nodes). + msg.uuid = deriveUUID(streamingToolUse.contentBlock.id as UUID, 0) + return normalizeMessages([msg]) + }), + [streamingToolUsesWithoutInProgress], + ) + + const isTranscriptMode = screen === 'transcript' // Hoisted to mount-time — this component re-renders on every scroll. - const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); + const disableVirtualScroll = useMemo( + () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), + [], + ) // Virtual scroll replaces the transcript cap: everything is scrollable and // memory is bounded by the mounted-item count, not the total. scrollRef is // only passed when isFullscreenEnvEnabled() is true (REPL.tsx gates it), // so scrollRef's presence is the signal. - const virtualScrollRuntimeGate = scrollRef != null && !disableVirtualScroll; - const shouldTruncate = isTranscriptMode && !showAllInTranscript && !virtualScrollRuntimeGate; + const virtualScrollRuntimeGate = scrollRef != null && !disableVirtualScroll + const shouldTruncate = + isTranscriptMode && !showAllInTranscript && !virtualScrollRuntimeGate // Anchor for the first rendered message in the non-virtualized cap slice. // Monotonic advance only — mutation during render is idempotent (safe // under StrictMode double-render). See MAX_MESSAGES_WITHOUT_VIRTUALIZATION // comment above for why this replaced count-based slicing. - const sliceAnchorRef = useRef(null); + const sliceAnchorRef = useRef(null) // Expensive message transforms — filter, reorder, group, collapse, lookups. // All O(n) over 27k messages. Split from the renderRange slice so scrolling @@ -478,55 +565,107 @@ const MessagesImpl = ({ // useMemo included renderRange → every scroll rebuilt 6 Maps over 27k // messages + 4 filter/map passes = ~50ms alloc per scroll → GC pressure → // 100-173ms stop-the-world pauses on the 1GB heap. - const { - collapsed: collapsed_0, - lookups: lookups_0, - hasTruncatedMessages: hasTruncatedMessages_0, - hiddenMessageCount: hiddenMessageCount_0 - } = useMemo(() => { - // In fullscreen mode the alt buffer has no native scrollback, so the - // compact-boundary filter just hides history the ScrollBox could - // otherwise scroll to. Main-screen mode keeps the filter — pre-compact - // rows live above the viewport in native scrollback there, and - // re-rendering them triggers full resets. - // includeSnipped: UI rendering keeps snipped messages for scrollback - // (this PR's core goal — full history in UI, filter only for the model). - // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so - // projectSnippedView's check against original removedUuids would fail. - const compactAwareMessages = verbose || isFullscreenEnvEnabled() ? normalizedMessages : getMessagesAfterCompactBoundary(normalizedMessages, { - includeSnipped: true - }); - const messagesToShowNotTruncated = reorderMessagesInUI(compactAwareMessages.filter((msg_2): msg_2 is Exclude => msg_2.type !== 'progress') - // CC-724: drop attachment messages that AttachmentMessage renders as - // null (hook_success, hook_additional_context, hook_cancelled, etc.) - // BEFORE counting/slicing so they don't inflate the "N messages" - // count in ctrl-o or consume slots in the 200-message render cap. - .filter(msg_3 => !isNullRenderingAttachment(msg_3)).filter(_ => shouldShowUserMessage(_, isTranscriptMode)) as (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage)[], syntheticStreamingToolUseMessages); - // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. - // Brief-only: SendUserMessage + user input only. Default: drop redundant - // assistant text in turns where SendUserMessage was called (the model's - // text is working-notes that duplicate the SendUserMessage content). - const briefToolNames = [BRIEF_TOOL_NAME, SEND_USER_FILE_TOOL_NAME].filter((n): n is string => n !== null); - // dropTextInBriefTurns should only trigger on SendUserMessage turns — - // SendUserFile delivers a file without replacement text, so dropping - // assistant text for file-only turns would leave the user with no context. - const dropTextToolNames = [BRIEF_TOOL_NAME].filter((n_0): n_0 is string => n_0 !== null); - const briefFiltered: MessageType[] = (briefToolNames.length > 0 && !isTranscriptMode ? isBriefOnly ? filterForBriefTool(messagesToShowNotTruncated as Parameters[0], briefToolNames) : dropTextToolNames.length > 0 ? dropTextInBriefTurns(messagesToShowNotTruncated as Parameters[0], dropTextToolNames) : messagesToShowNotTruncated : messagesToShowNotTruncated) as MessageType[]; - const messagesToShow = shouldTruncate ? briefFiltered.slice(-MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE) : briefFiltered; - const hasTruncatedMessages = shouldTruncate && briefFiltered.length > MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; - const { - messages: groupedMessages - } = applyGrouping(messagesToShow, tools, verbose); - const collapsed = collapseBackgroundBashNotifications(collapseHookSummaries(collapseTeammateShutdowns(collapseReadSearchGroups(groupedMessages, tools))), verbose); - const lookups = buildMessageLookups(normalizedMessages, messagesToShow); - const hiddenMessageCount = messagesToShowNotTruncated.length - MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; - return { - collapsed, - lookups, - hasTruncatedMessages, - hiddenMessageCount - }; - }, [verbose, normalizedMessages, isTranscriptMode, syntheticStreamingToolUseMessages, shouldTruncate, tools, isBriefOnly]); + const { collapsed, lookups, hasTruncatedMessages, hiddenMessageCount } = + useMemo(() => { + // In fullscreen mode the alt buffer has no native scrollback, so the + // compact-boundary filter just hides history the ScrollBox could + // otherwise scroll to. Main-screen mode keeps the filter — pre-compact + // rows live above the viewport in native scrollback there, and + // re-rendering them triggers full resets. + // includeSnipped: UI rendering keeps snipped messages for scrollback + // (this PR's core goal — full history in UI, filter only for the model). + // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so + // projectSnippedView's check against original removedUuids would fail. + const compactAwareMessages = + verbose || isFullscreenEnvEnabled() + ? normalizedMessages + : getMessagesAfterCompactBoundary(normalizedMessages, { + includeSnipped: true, + }) + + const messagesToShowNotTruncated = reorderMessagesInUI( + compactAwareMessages + .filter( + (msg): msg is Exclude => + msg.type !== 'progress', + ) + // CC-724: drop attachment messages that AttachmentMessage renders as + // null (hook_success, hook_additional_context, hook_cancelled, etc.) + // BEFORE counting/slicing so they don't inflate the "N messages" + // count in ctrl-o or consume slots in the 200-message render cap. + .filter(msg => !isNullRenderingAttachment(msg)) + .filter(_ => shouldShowUserMessage(_, isTranscriptMode)), + syntheticStreamingToolUseMessages, + ) + // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. + // Brief-only: SendUserMessage + user input only. Default: drop redundant + // assistant text in turns where SendUserMessage was called (the model's + // text is working-notes that duplicate the SendUserMessage content). + const briefToolNames = [BRIEF_TOOL_NAME, SEND_USER_FILE_TOOL_NAME].filter( + (n): n is string => n !== null, + ) + // dropTextInBriefTurns should only trigger on SendUserMessage turns — + // SendUserFile delivers a file without replacement text, so dropping + // assistant text for file-only turns would leave the user with no context. + const dropTextToolNames = [BRIEF_TOOL_NAME].filter( + (n): n is string => n !== null, + ) + const briefFiltered = + briefToolNames.length > 0 && !isTranscriptMode + ? isBriefOnly + ? filterForBriefTool(messagesToShowNotTruncated, briefToolNames) + : dropTextToolNames.length > 0 + ? dropTextInBriefTurns( + messagesToShowNotTruncated, + dropTextToolNames, + ) + : messagesToShowNotTruncated + : messagesToShowNotTruncated + + const messagesToShow = shouldTruncate + ? briefFiltered.slice(-MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE) + : briefFiltered + + const hasTruncatedMessages = + shouldTruncate && + briefFiltered.length > MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE + + const { messages: groupedMessages } = applyGrouping( + messagesToShow, + tools, + verbose, + ) + + const collapsed = collapseBackgroundBashNotifications( + collapseHookSummaries( + collapseTeammateShutdowns( + collapseReadSearchGroups(groupedMessages, tools), + ), + ), + verbose, + ) + + const lookups = buildMessageLookups(normalizedMessages, messagesToShow) + + const hiddenMessageCount = + messagesToShowNotTruncated.length - + MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE + + return { + collapsed, + lookups, + hasTruncatedMessages, + hiddenMessageCount, + } + }, [ + verbose, + normalizedMessages, + isTranscriptMode, + syntheticStreamingToolUseMessages, + shouldTruncate, + tools, + isBriefOnly, + ]) // Cheap slice — only runs when scroll range or slice config changes. const renderableMessages = useMemo(() => { @@ -537,39 +676,57 @@ const MessagesImpl = ({ // component's lifetime (scrollRef is either always passed or never). // renderRange is first: the chunked export path slices the // post-grouping array so each chunk gets correct tool-call grouping. - const capApplies = !virtualScrollRuntimeGate && !disableRenderCap; - const sliceStart = capApplies ? computeSliceStart(collapsed_0, sliceAnchorRef) : 0; - return renderRange ? collapsed_0.slice(renderRange[0], renderRange[1]) : sliceStart > 0 ? collapsed_0.slice(sliceStart) : collapsed_0; - }, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]); - const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]); + const capApplies = !virtualScrollRuntimeGate && !disableRenderCap + const sliceStart = capApplies + ? computeSliceStart(collapsed, sliceAnchorRef) + : 0 + return renderRange + ? collapsed.slice(renderRange[0], renderRange[1]) + : sliceStart > 0 + ? collapsed.slice(sliceStart) + : collapsed + }, [collapsed, renderRange, virtualScrollRuntimeGate, disableRenderCap]) + + const streamingToolUseIDs = useMemo( + () => new Set(streamingToolUses.map(_ => _.contentBlock.id)), + [streamingToolUses], + ) // Divider insertion point: first renderableMessage whose uuid shares the // 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24 // chars of the source message uuid, so this matches any block from it). const dividerBeforeIndex = useMemo(() => { - if (!unseenDivider) return -1; - const prefix = unseenDivider.firstUnseenUuid.slice(0, 24); - return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix); - }, [unseenDivider, renderableMessages]); + if (!unseenDivider) return -1 + const prefix = unseenDivider.firstUnseenUuid.slice(0, 24) + return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix) + }, [unseenDivider, renderableMessages]) + const selectedIdx = useMemo(() => { - if (!cursor) return -1; - return renderableMessages.findIndex(m_0 => m_0.uuid === cursor.uuid); - }, [cursor, renderableMessages]); + if (!cursor) return -1 + return renderableMessages.findIndex(m => m.uuid === cursor.uuid) + }, [cursor, renderableMessages]) // Fullscreen: click a message to toggle verbose rendering for it. Keyed by // tool_use_id where available so a tool_use and its tool_result (separate // rows) expand together; falls back to uuid for groups/thinking. Stale keys // are harmless — they never match anything in renderableMessages. - const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); - const onItemClick = useCallback((msg_4: RenderableMessage) => { - const k = expandKey(msg_4); + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set(), + ) + const onItemClick = useCallback((msg: RenderableMessage) => { + const k = expandKey(msg) setExpandedKeys(prev => { - const next = new Set(prev); - if (next.has(k)) next.delete(k);else next.add(k); - return next; - }); - }, []); - const isItemExpanded = useCallback((msg_5: RenderableMessage) => expandedKeys.size > 0 && expandedKeys.has(expandKey(msg_5)), [expandedKeys]); + const next = new Set(prev) + if (next.has(k)) next.delete(k) + else next.add(k) + return next + }) + }, []) + const isItemExpanded = useCallback( + (msg: RenderableMessage) => + expandedKeys.size > 0 && expandedKeys.has(expandKey(msg)), + [expandedKeys], + ) // Only hover/click messages where the verbose toggle reveals more: // collapsed read/search groups, or tool results that self-report truncation // via isResultTruncated. Callback must be stable across message updates: if @@ -577,64 +734,136 @@ const MessagesImpl = ({ // attaches after the mouse is already inside → hover never fires. tools is // session-stable; lookups is read via ref so the callback doesn't churn on // every new message. - const lookupsRef = useRef(lookups_0); - lookupsRef.current = lookups_0; - const isItemClickable = useCallback((msg_6: RenderableMessage): boolean => { - if (msg_6.type === 'collapsed_read_search') return true; - if (msg_6.type === 'assistant') { - const b = msg_6.message.content[0] as unknown as AdvisorBlock | undefined; - return b != null && isAdvisorBlock(b) && b.type === 'advisor_tool_result' && b.content.type === 'advisor_result'; - } - if (msg_6.type !== 'user') return false; - const b_0 = (msg_6.message.content as Array<{ type: string; is_error?: boolean; tool_use_id?: string }>)[0]; - if (b_0?.type !== 'tool_result' || b_0.is_error || !msg_6.toolUseResult) return false; - const name = lookupsRef.current.toolUseByToolUseID.get(b_0.tool_use_id!)?.name; - const tool = name ? findToolByName(tools, name) : undefined; - return tool?.isResultTruncated?.(msg_6.toolUseResult as never) ?? false; - }, [tools]); - const canAnimate = (!toolJSX || !!toolJSX.shouldContinueAnimation) && !toolUseConfirmQueue.length && !isMessageSelectorVisible; - const hasToolsInProgress = inProgressToolUseIDs.size > 0; + const lookupsRef = useRef(lookups) + lookupsRef.current = lookups + const isItemClickable = useCallback( + (msg: RenderableMessage): boolean => { + if (msg.type === 'collapsed_read_search') return true + if (msg.type === 'assistant') { + const b = msg.message.content[0] as unknown as AdvisorBlock | undefined + return ( + b != null && + isAdvisorBlock(b) && + b.type === 'advisor_tool_result' && + b.content.type === 'advisor_result' + ) + } + if (msg.type !== 'user') return false + const b = msg.message.content[0] + if (b?.type !== 'tool_result' || b.is_error || !msg.toolUseResult) + return false + const name = lookupsRef.current.toolUseByToolUseID.get( + b.tool_use_id, + )?.name + const tool = name ? findToolByName(tools, name) : undefined + return tool?.isResultTruncated?.(msg.toolUseResult as never) ?? false + }, + [tools], + ) + + const canAnimate = + (!toolJSX || !!toolJSX.shouldContinueAnimation) && + !toolUseConfirmQueue.length && + !isMessageSelectorVisible + + const hasToolsInProgress = inProgressToolUseIDs.size > 0 // Report progress to terminal (for terminals that support OSC 9;4) - const { - progress - } = useTerminalNotification(); - const prevProgressState = useRef(null); - const progressEnabled = getGlobalConfig().terminalProgressBarEnabled && !getIsRemoteMode() && !(proactiveModule?.isProactiveActive() ?? false); + const { progress } = useTerminalNotification() + const prevProgressState = useRef(null) + const progressEnabled = + getGlobalConfig().terminalProgressBarEnabled && + !getIsRemoteMode() && + !(proactiveModule?.isProactiveActive() ?? false) useEffect(() => { - const state = progressEnabled ? hasToolsInProgress ? 'indeterminate' : 'completed' : null; - if (prevProgressState.current === state) return; - prevProgressState.current = state; - progress(state); - }, [progress, progressEnabled, hasToolsInProgress]); + const state = progressEnabled + ? hasToolsInProgress + ? 'indeterminate' + : 'completed' + : null + if (prevProgressState.current === state) return + prevProgressState.current = state + progress(state) + }, [progress, progressEnabled, hasToolsInProgress]) useEffect(() => { - return () => progress(null); - }, [progress]); - const messageKey = useCallback((msg_7: RenderableMessage) => `${msg_7.uuid}-${conversationId}`, [conversationId]); - const renderMessageRow = (msg_8: RenderableMessage, index: number) => { - const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined; - const isUserContinuation = msg_8.type === 'user' && prevType === 'user'; + return () => progress(null) + }, [progress]) + + const messageKey = useCallback( + (msg: RenderableMessage) => `${msg.uuid}-${conversationId}`, + [conversationId], + ) + + const renderMessageRow = (msg: RenderableMessage, index: number) => { + const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined + const isUserContinuation = msg.type === 'user' && prevType === 'user' // hasContentAfter is only consumed for collapsed_read_search groups; // skip the scan for everything else. streamingText is rendered as a // sibling after this map, so it's never in renderableMessages — OR it // in explicitly so the group flips to past tense as soon as text starts // streaming instead of waiting for the block to finalize. - const hasContentAfter = msg_8.type === 'collapsed_read_search' && (!!streamingText || hasContentAfterIndex(renderableMessages, index, tools, streamingToolUseIDs)); - const k_0 = messageKey(msg_8); - const row = ; + const hasContentAfter = + msg.type === 'collapsed_read_search' && + (!!streamingText || + hasContentAfterIndex( + renderableMessages, + index, + tools, + streamingToolUseIDs, + )) + + const k = messageKey(msg) + const row = ( + + ) // Per-row Provider — only 2 rows re-render on selection change. // Wrapped BEFORE divider branch so both return paths get it. - const wrapped = + const wrapped = ( + {row} - ; + + ) + if (unseenDivider && index === dividerBeforeIndex) { - return [ - - , wrapped]; + return [ + + + , + wrapped, + ] } - return wrapped; - }; + return wrapped + } // Search indexing: for tool_result messages, look up the Tool and use // its extractSearchText — tool-owned, precise, matches what @@ -646,47 +875,72 @@ const MessagesImpl = ({ // A second-React-root reconcile approach was tried and ruled out // (measured 3.1ms/msg, growing — flushSyncWork processes all roots; // component hooks mutate shared state → main root accumulates updates). - const searchTextCache = useRef(new WeakMap()); - const extractSearchText = useCallback((msg_9: RenderableMessage): string => { - const cached = searchTextCache.current.get(msg_9); - if (cached !== undefined) return cached; - let text_0 = renderableSearchText(msg_9); - // If this is a tool_result message and the tool implements - // extractSearchText, prefer that — it's precise (tool-owned) - // vs renderableSearchText's field-name heuristic. - if (msg_9.type === 'user' && msg_9.toolUseResult && Array.isArray(msg_9.message.content)) { - const tr = msg_9.message.content.find(b_1 => b_1.type === 'tool_result'); - if (tr && 'tool_use_id' in tr) { - const tu = lookups_0.toolUseByToolUseID.get(tr.tool_use_id); - const tool_0 = tu && findToolByName(tools, tu.name); - const extracted = tool_0?.extractSearchText?.(msg_9.toolUseResult as never); - // undefined = tool didn't implement → keep heuristic. Empty - // string = tool says "nothing to index" → respect that. - if (extracted !== undefined) text_0 = extracted; + const searchTextCache = useRef(new WeakMap()) + const extractSearchText = useCallback( + (msg: RenderableMessage): string => { + const cached = searchTextCache.current.get(msg) + if (cached !== undefined) return cached + let text = renderableSearchText(msg) + // If this is a tool_result message and the tool implements + // extractSearchText, prefer that — it's precise (tool-owned) + // vs renderableSearchText's field-name heuristic. + if ( + msg.type === 'user' && + msg.toolUseResult && + Array.isArray(msg.message.content) + ) { + const tr = msg.message.content.find(b => b.type === 'tool_result') + if (tr && 'tool_use_id' in tr) { + const tu = lookups.toolUseByToolUseID.get(tr.tool_use_id) + const tool = tu && findToolByName(tools, tu.name) + const extracted = tool?.extractSearchText?.( + msg.toolUseResult as never, + ) + // undefined = tool didn't implement → keep heuristic. Empty + // string = tool says "nothing to index" → respect that. + if (extracted !== undefined) text = extracted + } } - } - // Cache LOWERED: setSearchQuery's hot loop indexOfs per keystroke. - // Lowering here (once, at warm) vs there (every keystroke) trades - // ~same steady-state memory for zero per-keystroke alloc. Cache - // GC's with messages on transcript exit. Tool methods return raw; - // renderableSearchText already lowercases (redundant but cheap). - const lowered = text_0.toLowerCase(); - searchTextCache.current.set(msg_9, lowered); - return lowered; - }, [tools, lookups_0]); - return <> + // Cache LOWERED: setSearchQuery's hot loop indexOfs per keystroke. + // Lowering here (once, at warm) vs there (every keystroke) trades + // ~same steady-state memory for zero per-keystroke alloc. Cache + // GC's with messages on transcript exit. Tool methods return raw; + // renderableSearchText already lowercases (redundant but cheap). + const lowered = text.toLowerCase() + searchTextCache.current.set(msg, lowered) + return lowered + }, + [tools, lookups], + ) + + return ( + <> {/* Logo */} - {!hideLogo && !(renderRange && renderRange[0] > 0) && } + {!hideLogo && !(renderRange && renderRange[0] > 0) && ( + + )} {/* Truncation indicator */} - {hasTruncatedMessages_0 && } + {hasTruncatedMessages && ( + + )} {/* Show all indicator */} - {isTranscriptMode && showAllInTranscript && hiddenMessageCount_0 > 0 && - // disableRenderCap (e.g. [ dump-to-scrollback) means we're uncapped - // as a one-shot escape hatch, not a toggle — ctrl+e is dead and - // nothing is actually "hidden" to restore. - !disableRenderCap && } + {isTranscriptMode && + showAllInTranscript && + hiddenMessageCount > 0 && + // disableRenderCap (e.g. [ dump-to-scrollback) means we're uncapped + // as a one-shot escape hatch, not a toggle — ctrl+e is dead and + // nothing is actually "hidden" to restore. + !disableRenderCap && ( + + )} {/* Messages - rendered as memoized MessageRow components. flatMap inserts the unseen-divider as a separate keyed sibling so @@ -696,11 +950,39 @@ const MessagesImpl = ({ each row - React Compiler pins props in the fiber's memoCache, so passing the array would accumulate every historical version (~1-2MB over a 7-turn session). */} - {virtualScrollRuntimeGate ? - = 0 ? selectedIdx : undefined} cursorNavRef={cursorNavRef} setCursor={setCursor} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} extractSearchText={extractSearchText} /> - : renderableMessages.flatMap(renderMessageRow)} + {virtualScrollRuntimeGate ? ( + + = 0 ? selectedIdx : undefined} + cursorNavRef={cursorNavRef} + setCursor={setCursor} + jumpRef={jumpRef} + onSearchMatchesChange={onSearchMatchesChange} + scanElement={scanElement} + setPositions={setPositions} + extractSearchText={extractSearchText} + /> + + ) : ( + renderableMessages.flatMap(renderMessageRow) + )} - {streamingText && !isBriefOnly && + {streamingText && !isBriefOnly && ( + {BLACK_CIRCLE} @@ -709,21 +991,35 @@ const MessagesImpl = ({ {streamingText} - } + + )} - {isStreamingThinkingVisible && streamingThinking && !isBriefOnly && - - } - ; -}; + {isStreamingThinkingVisible && streamingThinking && !isBriefOnly && ( + + + + )} + + ) +} /** Key for click-to-expand: tool_use_id where available (so tool_use + its * tool_result expand together), else uuid for groups/thinking. */ function expandKey(msg: RenderableMessage): string { - return (msg.type === 'assistant' || msg.type === 'user' ? getToolUseID(msg) : null) ?? msg.uuid; + return ( + (msg.type === 'assistant' || msg.type === 'user' + ? getToolUseID(msg) + : null) ?? msg.uuid + ) } // Custom comparator to prevent unnecessary re-renders during streaming. @@ -732,102 +1028,131 @@ function expandKey(msg: RenderableMessage): string { // 2. streamingToolUses array is recreated on every delta, but only contentBlock matters for rendering // 3. streamingThinking changes on every delta - we DO want to re-render for this function setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) return false; + if (a.size !== b.size) return false for (const item of a) { - if (!b.has(item)) return false; + if (!b.has(item)) return false } - return true; + return true } + export const Messages = React.memo(MessagesImpl, (prev, next) => { - const keys = Object.keys(prev) as (keyof typeof prev)[]; + const keys = Object.keys(prev) as (keyof typeof prev)[] for (const key of keys) { - if (key === 'onOpenRateLimitOptions' || key === 'scrollRef' || key === 'trackStickyPrompt' || key === 'setCursor' || key === 'cursorNavRef' || key === 'jumpRef' || key === 'onSearchMatchesChange' || key === 'scanElement' || key === 'setPositions') continue; + if ( + key === 'onOpenRateLimitOptions' || + key === 'scrollRef' || + key === 'trackStickyPrompt' || + key === 'setCursor' || + key === 'cursorNavRef' || + key === 'jumpRef' || + key === 'onSearchMatchesChange' || + key === 'scanElement' || + key === 'setPositions' + ) + continue if (prev[key] !== next[key]) { if (key === 'streamingToolUses') { - const p = prev.streamingToolUses; - const n = next.streamingToolUses; - if (p.length === n.length && p.every((item, i) => item.contentBlock === n[i]?.contentBlock)) { - continue; + const p = prev.streamingToolUses + const n = next.streamingToolUses + if ( + p.length === n.length && + p.every((item, i) => item.contentBlock === n[i]?.contentBlock) + ) { + continue } } if (key === 'inProgressToolUseIDs') { if (setsEqual(prev.inProgressToolUseIDs, next.inProgressToolUseIDs)) { - continue; + continue } } if (key === 'unseenDivider') { - const p = prev.unseenDivider; - const n = next.unseenDivider; - if (p?.firstUnseenUuid === n?.firstUnseenUuid && p?.count === n?.count) { - continue; + const p = prev.unseenDivider + const n = next.unseenDivider + if ( + p?.firstUnseenUuid === n?.firstUnseenUuid && + p?.count === n?.count + ) { + continue } } if (key === 'tools') { - const p = prev.tools; - const n = next.tools; - if (p.length === n.length && p.every((tool, i) => tool.name === n[i]?.name)) { - continue; + const p = prev.tools + const n = next.tools + if ( + p.length === n.length && + p.every((tool, i) => tool.name === n[i]?.name) + ) { + continue } } // streamingThinking changes frequently - always re-render when it changes // (no special handling needed, default behavior is correct) - return false; + return false } } - return true; -}); -export function shouldRenderStatically(message: RenderableMessage, streamingToolUseIDs: Set, inProgressToolUseIDs: Set, siblingToolUseIDs: ReadonlySet, screen: Screen, lookups: ReturnType): boolean { + return true +}) + +export function shouldRenderStatically( + message: RenderableMessage, + streamingToolUseIDs: Set, + inProgressToolUseIDs: Set, + siblingToolUseIDs: ReadonlySet, + screen: Screen, + lookups: ReturnType, +): boolean { if (screen === 'transcript') { - return true; + return true } switch (message.type) { case 'attachment': case 'user': - case 'assistant': - { - if (message.type === 'assistant') { - const block = (message.message.content as Array<{ type: string; id?: string }>)[0]; - if (block?.type === 'server_tool_use') { - return lookups.resolvedToolUseIDs.has(block.id!); - } - } - const toolUseID = getToolUseID(message); - if (!toolUseID) { - return true; - } - if (streamingToolUseIDs.has(toolUseID)) { - return false; - } - if (inProgressToolUseIDs.has(toolUseID)) { - return false; + case 'assistant': { + if (message.type === 'assistant') { + const block = message.message.content[0] + if (block?.type === 'server_tool_use') { + return lookups.resolvedToolUseIDs.has(block.id) } - - // Check if there are any unresolved PostToolUse hooks for this tool use - // If so, keep the message transient so the HookProgressMessage can update - if (hasUnresolvedHooksFromLookup(toolUseID, 'PostToolUse', lookups)) { - return false; - } - return every(siblingToolUseIDs, lookups.resolvedToolUseIDs); } - case 'system': - { - // api errors always render dynamically, since we hide - // them as soon as we see another non-error message. - return message.subtype !== 'api_error'; + const toolUseID = getToolUseID(message) + if (!toolUseID) { + return true } - case 'grouped_tool_use': - { - const allResolved = message.messages.every(msg => { - const content = (msg.message.content as Array<{ type: string; id?: string }>)[0]; - return content?.type === 'tool_use' && lookups.resolvedToolUseIDs.has(content.id!); - }); - return allResolved; + if (streamingToolUseIDs.has(toolUseID)) { + return false } - case 'collapsed_read_search': - { - // In prompt mode, never mark as static to prevent flicker between API turns - // (In transcript mode, we already returned true at the top of this function) - return false; + if (inProgressToolUseIDs.has(toolUseID)) { + return false } + + // Check if there are any unresolved PostToolUse hooks for this tool use + // If so, keep the message transient so the HookProgressMessage can update + if (hasUnresolvedHooksFromLookup(toolUseID, 'PostToolUse', lookups)) { + return false + } + + return every(siblingToolUseIDs, lookups.resolvedToolUseIDs) + } + case 'system': { + // api errors always render dynamically, since we hide + // them as soon as we see another non-error message. + return message.subtype !== 'api_error' + } + case 'grouped_tool_use': { + const allResolved = message.messages.every(msg => { + const content = msg.message.content[0] + return ( + content?.type === 'tool_use' && + lookups.resolvedToolUseIDs.has(content.id) + ) + }) + return allResolved + } + case 'collapsed_read_search': { + // In prompt mode, never mark as static to prevent flicker between API turns + // (In transcript mode, we already returned true at the top of this function) + return false + } } } diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 674f65794..6658ad62d 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -1,447 +1,368 @@ -import { c as _c } from "react/compiler-runtime"; -import capitalize from 'lodash-es/capitalize.js'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled } from 'src/utils/fastMode.js'; -import { Box, Text } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { convertEffortValueToLevel, type EffortLevel, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort } from '../utils/effort.js'; -import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel } from '../utils/model/model.js'; -import { getModelOptions } from '../utils/model/modelOptions.js'; -import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/index.js'; -import { Byline } from './design-system/Byline.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Pane } from './design-system/Pane.js'; -import { effortLevelToSymbol } from './EffortIndicator.js'; +import capitalize from 'lodash-es/capitalize.js' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + FAST_MODE_MODEL_DISPLAY, + isFastModeAvailable, + isFastModeCooldown, + isFastModeEnabled, +} from 'src/utils/fastMode.js' +import { Box, Text } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { + convertEffortValueToLevel, + type EffortLevel, + getDefaultEffortForModel, + modelSupportsEffort, + modelSupportsMaxEffort, + resolvePickerEffortPersistence, + toPersistableEffort, +} from '../utils/effort.js' +import { + getDefaultMainLoopModel, + type ModelSetting, + modelDisplayString, + parseUserSpecifiedModel, +} from '../utils/model/model.js' +import { getModelOptions } from '../utils/model/modelOptions.js' +import { + getSettingsForSource, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/index.js' +import { Byline } from './design-system/Byline.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Pane } from './design-system/Pane.js' +import { effortLevelToSymbol } from './EffortIndicator.js' + export type Props = { - initial: string | null; - sessionModel?: ModelSetting; - onSelect: (model: string | null, effort: EffortLevel | undefined) => void; - onCancel?: () => void; - isStandaloneCommand?: boolean; - showFastModeNotice?: boolean; + initial: string | null + sessionModel?: ModelSetting + onSelect: (model: string | null, effort: EffortLevel | undefined) => void + onCancel?: () => void + isStandaloneCommand?: boolean + showFastModeNotice?: boolean /** Overrides the dim header line below "Select model". */ - headerText?: string; + headerText?: string /** * When true, skip writing effortLevel to userSettings on selection. * Used by the assistant installer wizard where the model choice is * project-scoped (written to the assistant's .claude/settings.json via * install.ts) and should not leak to the user's global ~/.claude/settings. */ - skipSettingsWrite?: boolean; -}; -const NO_PREFERENCE = '__NO_PREFERENCE__'; -export function ModelPicker(t0) { - const $ = _c(82); - const { - initial, - sessionModel, - onSelect, - onCancel, - isStandaloneCommand, - showFastModeNotice, - headerText, - skipSettingsWrite - } = t0; - const setAppState = useSetAppState(); - const exitState = useExitOnCtrlCDWithKeybindings(); - const initialValue = initial === null ? NO_PREFERENCE : initial; - const [focusedValue, setFocusedValue] = useState(initialValue); - const isFastMode = useAppState(_temp); - const [hasToggledEffort, setHasToggledEffort] = useState(false); - const effortValue = useAppState(_temp2); - let t1; - if ($[0] !== effortValue) { - t1 = effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined; - $[0] = effortValue; - $[1] = t1; - } else { - t1 = $[1]; - } - const [effort, setEffort] = useState(t1); - const t2 = isFastMode ?? false; - let t3; - if ($[2] !== t2) { - t3 = getModelOptions(t2); - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const modelOptions = t3; - let t4; - bb0: { + skipSettingsWrite?: boolean +} + +const NO_PREFERENCE = '__NO_PREFERENCE__' + +export function ModelPicker({ + initial, + sessionModel, + onSelect, + onCancel, + isStandaloneCommand, + showFastModeNotice, + headerText, + skipSettingsWrite, +}: Props): React.ReactNode { + const setAppState = useSetAppState() + const exitState = useExitOnCtrlCDWithKeybindings() + const maxVisible = 10 + + const initialValue = initial === null ? NO_PREFERENCE : initial + const [focusedValue, setFocusedValue] = useState( + initialValue, + ) + + const isFastMode = useAppState(s => + isFastModeEnabled() ? s.fastMode : false, + ) + + const [hasToggledEffort, setHasToggledEffort] = useState(false) + const effortValue = useAppState(s => s.effortValue) + const [effort, setEffort] = useState( + effortValue !== undefined + ? convertEffortValueToLevel(effortValue) + : undefined, + ) + + // Memoize all derived values to prevent re-renders + const modelOptions = useMemo( + () => getModelOptions(isFastMode ?? false), + [isFastMode], + ) + + // Ensure the initial value is in the options list + // This handles edge cases where the user's current model (e.g., 'haiku' for 3P users) + // is not in the base options but should still be selectable and shown as selected + const optionsWithInitial = useMemo(() => { if (initial !== null && !modelOptions.some(opt => opt.value === initial)) { - let t5; - if ($[4] !== initial) { - t5 = modelDisplayString(initial); - $[4] = initial; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== initial || $[7] !== t5) { - t6 = { + return [ + ...modelOptions, + { value: initial, - label: t5, - description: "Current model" - }; - $[6] = initial; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== modelOptions || $[10] !== t6) { - t7 = [...modelOptions, t6]; - $[9] = modelOptions; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - t4 = t7; - break bb0; + label: modelDisplayString(initial), + description: 'Current model', + }, + ] } - t4 = modelOptions; - } - const optionsWithInitial = t4; - let t5; - if ($[12] !== optionsWithInitial) { - t5 = optionsWithInitial.map(_temp3); - $[12] = optionsWithInitial; - $[13] = t5; - } else { - t5 = $[13]; - } - const selectOptions = t5; - let t6; - if ($[14] !== initialValue || $[15] !== selectOptions) { - t6 = selectOptions.some(_ => _.value === initialValue) ? initialValue : selectOptions[0]?.value ?? undefined; - $[14] = initialValue; - $[15] = selectOptions; - $[16] = t6; - } else { - t6 = $[16]; - } - const initialFocusValue = t6; - const visibleCount = Math.min(10, selectOptions.length); - const hiddenCount = Math.max(0, selectOptions.length - visibleCount); - let t7; - if ($[17] !== focusedValue || $[18] !== selectOptions) { - t7 = selectOptions.find(opt_1 => opt_1.value === focusedValue)?.label; - $[17] = focusedValue; - $[18] = selectOptions; - $[19] = t7; - } else { - t7 = $[19]; - } - const focusedModelName = t7; - let focusedSupportsEffort; - let t8; - if ($[20] !== focusedValue) { - const focusedModel = resolveOptionModel(focusedValue); - focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false; - t8 = focusedModel ? modelSupportsMaxEffort(focusedModel) : false; - $[20] = focusedValue; - $[21] = focusedSupportsEffort; - $[22] = t8; - } else { - focusedSupportsEffort = $[21]; - t8 = $[22]; - } - const focusedSupportsMax = t8; - let t9; - if ($[23] !== focusedValue) { - t9 = getDefaultEffortLevelForOption(focusedValue); - $[23] = focusedValue; - $[24] = t9; - } else { - t9 = $[24]; - } - const focusedDefaultEffort = t9; - const displayEffort = effort === "max" && !focusedSupportsMax ? "high" : effort; - let t10; - if ($[25] !== effortValue || $[26] !== hasToggledEffort) { - t10 = value => { - setFocusedValue(value); + return modelOptions + }, [modelOptions, initial]) + + const selectOptions = useMemo( + () => + optionsWithInitial.map(opt => ({ + ...opt, + value: opt.value === null ? NO_PREFERENCE : opt.value, + })), + [optionsWithInitial], + ) + const initialFocusValue = useMemo( + () => + selectOptions.some(_ => _.value === initialValue) + ? initialValue + : (selectOptions[0]?.value ?? undefined), + [selectOptions, initialValue], + ) + const visibleCount = Math.min(maxVisible, selectOptions.length) + const hiddenCount = Math.max(0, selectOptions.length - visibleCount) + + const focusedModelName = selectOptions.find( + opt => opt.value === focusedValue, + )?.label + const focusedModel = resolveOptionModel(focusedValue) + const focusedSupportsEffort = focusedModel + ? modelSupportsEffort(focusedModel) + : false + const focusedSupportsMax = focusedModel + ? modelSupportsMaxEffort(focusedModel) + : false + const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue) + // Clamp display when 'max' is selected but the focused model doesn't support it. + // resolveAppliedEffort() does the same downgrade at API-send time. + const displayEffort = + effort === 'max' && !focusedSupportsMax ? 'high' : effort + + const handleFocus = useCallback( + (value: string) => { + setFocusedValue(value) if (!hasToggledEffort && effortValue === undefined) { - setEffort(getDefaultEffortLevelForOption(value)); - } - }; - $[25] = effortValue; - $[26] = hasToggledEffort; - $[27] = t10; - } else { - t10 = $[27]; - } - const handleFocus = t10; - let t11; - if ($[28] !== focusedDefaultEffort || $[29] !== focusedSupportsEffort || $[30] !== focusedSupportsMax) { - t11 = direction => { - if (!focusedSupportsEffort) { - return; - } - setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); - setHasToggledEffort(true); - }; - $[28] = focusedDefaultEffort; - $[29] = focusedSupportsEffort; - $[30] = focusedSupportsMax; - $[31] = t11; - } else { - t11 = $[31]; - } - const handleCycleEffort = t11; - let t12; - if ($[32] !== handleCycleEffort) { - t12 = { - "modelPicker:decreaseEffort": () => handleCycleEffort("left"), - "modelPicker:increaseEffort": () => handleCycleEffort("right") - }; - $[32] = handleCycleEffort; - $[33] = t12; - } else { - t12 = $[33]; - } - let t13; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t13 = { - context: "ModelPicker" - }; - $[34] = t13; - } else { - t13 = $[34]; - } - useKeybindings(t12, t13); - let t14; - if ($[35] !== effort || $[36] !== hasToggledEffort || $[37] !== onSelect || $[38] !== setAppState || $[39] !== skipSettingsWrite) { - t14 = function handleSelect(value_0) { - logEvent("tengu_model_command_menu_effort", { - effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - if (!skipSettingsWrite) { - const effortLevel = resolvePickerEffortPersistence(effort, getDefaultEffortLevelForOption(value_0), getSettingsForSource("userSettings")?.effortLevel, hasToggledEffort); - const persistable = toPersistableEffort(effortLevel); - if (persistable !== undefined) { - updateSettingsForSource("userSettings", { - effortLevel: persistable - }); - } - setAppState(prev_0 => ({ - ...prev_0, - effortValue: effortLevel - })); + setEffort(getDefaultEffortLevelForOption(value)) } - const selectedModel = resolveOptionModel(value_0); - const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined; - if (value_0 === NO_PREFERENCE) { - onSelect(null, selectedEffort); - return; + }, + [hasToggledEffort, effortValue], + ) + + // Effort level cycling keybindings + const handleCycleEffort = useCallback( + (direction: 'left' | 'right') => { + if (!focusedSupportsEffort) return + setEffort(prev => + cycleEffortLevel( + prev ?? focusedDefaultEffort, + direction, + focusedSupportsMax, + ), + ) + setHasToggledEffort(true) + }, + [focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort], + ) + + useKeybindings( + { + 'modelPicker:decreaseEffort': () => handleCycleEffort('left'), + 'modelPicker:increaseEffort': () => handleCycleEffort('right'), + }, + { context: 'ModelPicker' }, + ) + + function handleSelect(value: string): void { + logEvent('tengu_model_command_menu_effort', { + effort: + effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + if (!skipSettingsWrite) { + // Prior comes from userSettings on disk — NOT merged settings (which + // includes project/policy layers that must not leak into the user's + // global ~/.claude/settings.json), and NOT AppState.effortValue (which + // includes session-ephemeral sources like --effort CLI flag). + // See resolvePickerEffortPersistence JSDoc. + const effortLevel = resolvePickerEffortPersistence( + effort, + getDefaultEffortLevelForOption(value), + getSettingsForSource('userSettings')?.effortLevel, + hasToggledEffort, + ) + const persistable = toPersistableEffort(effortLevel) + if (persistable !== undefined) { + updateSettingsForSource('userSettings', { effortLevel: persistable }) } - onSelect(value_0, selectedEffort); - }; - $[35] = effort; - $[36] = hasToggledEffort; - $[37] = onSelect; - $[38] = setAppState; - $[39] = skipSettingsWrite; - $[40] = t14; - } else { - t14 = $[40]; - } - const handleSelect = t14; - let t15; - if ($[41] === Symbol.for("react.memo_cache_sentinel")) { - t15 = Select model; - $[41] = t15; - } else { - t15 = $[41]; - } - const t16 = headerText ?? "Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model."; - let t17; - if ($[42] !== t16) { - t17 = {t16}; - $[42] = t16; - $[43] = t17; - } else { - t17 = $[43]; - } - let t18; - if ($[44] !== sessionModel) { - t18 = sessionModel && Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model will undo this.; - $[44] = sessionModel; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] !== t17 || $[47] !== t18) { - t19 = {t15}{t17}{t18}; - $[46] = t17; - $[47] = t18; - $[48] = t19; - } else { - t19 = $[48]; - } - const t20 = onCancel ?? _temp4; - let t21; - if ($[49] !== handleFocus || $[50] !== handleSelect || $[51] !== initialFocusValue || $[52] !== initialValue || $[53] !== selectOptions || $[54] !== t20 || $[55] !== visibleCount) { - t21 = {})} + visibleOptionCount={visibleCount} + /> + + {hiddenCount > 0 && ( + + and {hiddenCount} more… + + )} + + + + {focusedSupportsEffort ? ( + + {' '} + {capitalize(displayEffort)} effort + {displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '} + ← → to adjust + + ) : ( + + Effort not supported + {focusedModelName ? ` for ${focusedModelName}` : ''} + + )} + + + {isFastModeEnabled() ? ( + showFastModeNotice ? ( + + + Fast mode is ON and available with{' '} + {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other + models turn off fast mode. + + + ) : isFastModeAvailable() && !isFastModeCooldown() ? ( + + + Use /fast to turn on Fast mode ( + {FAST_MODE_MODEL_DISPLAY} only). + + + ) : null + ) : null} + + + {isStandaloneCommand && ( + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + + + + + )} + + )} + + ) + if (!isStandaloneCommand) { - return content; - } - let t29; - if ($[80] !== content) { - t29 = {content}; - $[80] = content; - $[81] = t29; - } else { - t29 = $[81]; + return content } - return t29; -} -function _temp4() {} -function _temp3(opt_0) { - return { - ...opt_0, - value: opt_0.value === null ? NO_PREFERENCE : opt_0.value - }; -} -function _temp2(s_0) { - return s_0.effortValue; -} -function _temp(s) { - return isFastModeEnabled() ? s.fastMode : false; + + return {content} } + function resolveOptionModel(value?: string): string | undefined { - if (!value) return undefined; - return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value); + if (!value) return undefined + return value === NO_PREFERENCE + ? getDefaultMainLoopModel() + : parseUserSpecifiedModel(value) } -function EffortLevelIndicator(t0) { - const $ = _c(5); - const { - effort - } = t0; - const t1 = effort ? "claude" : "subtle"; - const t2 = effort ?? "low"; - let t3; - if ($[0] !== t2) { - t3 = effortLevelToSymbol(t2); - $[0] = t2; - $[1] = t3; - } else { - t3 = $[1]; - } - let t4; - if ($[2] !== t1 || $[3] !== t3) { - t4 = {t3}; - $[2] = t1; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - return t4; + +function EffortLevelIndicator({ + effort, +}: { + effort?: EffortLevel +}): React.ReactNode { + return ( + + {effortLevelToSymbol(effort ?? 'low')} + + ) } -function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel { - const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']; + +function cycleEffortLevel( + current: EffortLevel, + direction: 'left' | 'right', + includeMax: boolean, +): EffortLevel { + const levels: EffortLevel[] = includeMax + ? ['low', 'medium', 'high', 'max'] + : ['low', 'medium', 'high'] // If the current level isn't in the cycle (e.g. 'max' after switching to a // non-Opus model), clamp to 'high'. - const idx = levels.indexOf(current); - const currentIndex = idx !== -1 ? idx : levels.indexOf('high'); + const idx = levels.indexOf(current) + const currentIndex = idx !== -1 ? idx : levels.indexOf('high') if (direction === 'right') { - return levels[(currentIndex + 1) % levels.length]!; + return levels[(currentIndex + 1) % levels.length]! } else { - return levels[(currentIndex - 1 + levels.length) % levels.length]!; + return levels[(currentIndex - 1 + levels.length) % levels.length]! } } + function getDefaultEffortLevelForOption(value?: string): EffortLevel { - const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel(); - const defaultValue = getDefaultEffortForModel(resolved); - return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high'; + const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel() + const defaultValue = getDefaultEffortForModel(resolved) + return defaultValue !== undefined + ? convertEffortValueToLevel(defaultValue) + : 'high' } diff --git a/src/components/NativeAutoUpdater.tsx b/src/components/NativeAutoUpdater.tsx index 3d860c2b9..fcb448ade 100644 --- a/src/components/NativeAutoUpdater.tsx +++ b/src/components/NativeAutoUpdater.tsx @@ -1,134 +1,152 @@ -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { logForDebugging } from 'src/utils/debug.js'; -import { logError } from 'src/utils/log.js'; -import { useInterval } from 'usehooks-ts'; -import { useUpdateNotification } from '../hooks/useUpdateNotification.js'; -import { Box, Text } from '../ink.js'; -import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; -import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js'; -import { isAutoUpdaterDisabled } from '../utils/config.js'; -import { installLatest } from '../utils/nativeInstaller/index.js'; -import { gt } from '../utils/semver.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { logForDebugging } from 'src/utils/debug.js' +import { logError } from 'src/utils/log.js' +import { useInterval } from 'usehooks-ts' +import { useUpdateNotification } from '../hooks/useUpdateNotification.js' +import { Box, Text } from '../ink.js' +import type { AutoUpdaterResult } from '../utils/autoUpdater.js' +import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js' +import { isAutoUpdaterDisabled } from '../utils/config.js' +import { installLatest } from '../utils/nativeInstaller/index.js' +import { gt } from '../utils/semver.js' +import { getInitialSettings } from '../utils/settings/settings.js' /** * Categorize error messages for analytics */ function getErrorType(errorMessage: string): string { if (errorMessage.includes('timeout')) { - return 'timeout'; + return 'timeout' } if (errorMessage.includes('Checksum mismatch')) { - return 'checksum_mismatch'; + return 'checksum_mismatch' } if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) { - return 'not_found'; + return 'not_found' } if (errorMessage.includes('EACCES') || errorMessage.includes('permission')) { - return 'permission_denied'; + return 'permission_denied' } if (errorMessage.includes('ENOSPC')) { - return 'disk_full'; + return 'disk_full' } if (errorMessage.includes('npm')) { - return 'npm_error'; + return 'npm_error' } - if (errorMessage.includes('network') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ENOTFOUND')) { - return 'network_error'; + if ( + errorMessage.includes('network') || + errorMessage.includes('ECONNREFUSED') || + errorMessage.includes('ENOTFOUND') + ) { + return 'network_error' } - return 'unknown'; + return 'unknown' } + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + export function NativeAutoUpdater({ isUpdating, onChangeIsUpdating, onAutoUpdaterResult, autoUpdaterResult, showSuccessMessage, - verbose + verbose, }: Props): React.ReactNode { const [versions, setVersions] = useState<{ - current?: string | null; - latest?: string | null; - }>({}); - const [maxVersionIssue, setMaxVersionIssue] = useState(null); - const updateSemver = useUpdateNotification(autoUpdaterResult?.version); - const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'; + current?: string | null + latest?: string | null + }>({}) + const [maxVersionIssue, setMaxVersionIssue] = useState(null) + const updateSemver = useUpdateNotification(autoUpdaterResult?.version) + const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' // Track latest isUpdating value in a ref so the memoized checkForUpdates // callback always sees the current value without changing callback identity // (which would re-trigger the initial-check useEffect below and cause // repeated downloads on remount — the upstream trigger for #22413). - const isUpdatingRef = useRef(isUpdating); - isUpdatingRef.current = isUpdating; + const isUpdatingRef = useRef(isUpdating) + isUpdatingRef.current = isUpdating + const checkForUpdates = React.useCallback(async () => { if (isUpdatingRef.current) { - return; + return } - if (("production" as string) === 'test' || ("production" as string) === 'development') { - logForDebugging('NativeAutoUpdater: Skipping update check in test/dev environment'); - return; + + if ( + "production" === 'test' || + "production" === 'development' + ) { + logForDebugging( + 'NativeAutoUpdater: Skipping update check in test/dev environment', + ) + return } + if (isAutoUpdaterDisabled()) { - return; + return } - onChangeIsUpdating(true); - const startTime = Date.now(); + + onChangeIsUpdating(true) + const startTime = Date.now() // Log the start of an auto-update check for funnel analysis - logEvent('tengu_native_auto_updater_start', {}); + logEvent('tengu_native_auto_updater_start', {}) + try { // Check if current version is above the max allowed version - const maxVersion = await getMaxVersion(); + const maxVersion = await getMaxVersion() if (maxVersion && gt(MACRO.VERSION, maxVersion)) { - const msg = await getMaxVersionMessage(); - setMaxVersionIssue(msg ?? 'affects your version'); + const msg = await getMaxVersionMessage() + setMaxVersionIssue(msg ?? 'affects your version') } - const result = await installLatest(channel); - const currentVersion = MACRO.VERSION; - const latencyMs = Date.now() - startTime; + + const result = await installLatest(channel) + const currentVersion = MACRO.VERSION + const latencyMs = Date.now() - startTime // Handle lock contention gracefully - just return without treating as error if (result.lockFailed) { logEvent('tengu_native_auto_updater_lock_contention', { - latency_ms: latencyMs - }); - return; // Silently skip this update check, will try again later + latency_ms: latencyMs, + }) + return // Silently skip this update check, will try again later } // Update versions for display - setVersions({ - current: currentVersion, - latest: result.latestVersion - }); + setVersions({ current: currentVersion, latest: result.latestVersion }) + if (result.wasUpdated) { logEvent('tengu_native_auto_updater_success', { - latency_ms: latencyMs - }); + latency_ms: latencyMs, + }) + onAutoUpdaterResult({ version: result.latestVersion, - status: 'success' - }); + status: 'success', + }) } else { // Already up to date logEvent('tengu_native_auto_updater_up_to_date', { - latency_ms: latencyMs - }); + latency_ms: latencyMs, + }) } } catch (error) { - const latencyMs = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : String(error); - logError(error); - const errorType = getErrorType(errorMessage); + const latencyMs = Date.now() - startTime + const errorMessage = + error instanceof Error ? error.message : String(error) + logError(error) + + const errorType = getErrorType(errorMessage) logEvent('tengu_native_auto_updater_fail', { latency_ms: latencyMs, error_timeout: errorType === 'timeout', @@ -137,56 +155,77 @@ export function NativeAutoUpdater({ error_permission: errorType === 'permission_denied', error_disk_full: errorType === 'disk_full', error_npm: errorType === 'npm_error', - error_network: errorType === 'network_error' - }); + error_network: errorType === 'network_error', + }) + onAutoUpdaterResult({ version: null, - status: 'install_failed' - }); + status: 'install_failed', + }) } finally { - onChangeIsUpdating(false); + onChangeIsUpdating(false) } // isUpdating intentionally omitted from deps; we read isUpdatingRef // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref - }, [onAutoUpdaterResult, channel]); + }, [onAutoUpdaterResult, channel]) // Initial check useEffect(() => { - void checkForUpdates(); - }, [checkForUpdates]); + void checkForUpdates() + }, [checkForUpdates]) // Check every 30 minutes - useInterval(checkForUpdates, 30 * 60 * 1000); - const hasUpdateResult = !!autoUpdaterResult?.version; - const hasVersionInfo = !!versions.current && !!versions.latest; + useInterval(checkForUpdates, 30 * 60 * 1000) + + const hasUpdateResult = !!autoUpdaterResult?.version + const hasVersionInfo = !!versions.current && !!versions.latest // Show the component when: // - warning banner needed (above max version), or // - there's an update result to display (success/error), or // - actively checking and we have version info to show - const shouldRender = !!maxVersionIssue || hasUpdateResult || isUpdating && hasVersionInfo; + const shouldRender = + !!maxVersionIssue || hasUpdateResult || (isUpdating && hasVersionInfo) + if (!shouldRender) { - return null; + return null } - return - {verbose && + + return ( + + {verbose && ( + current: {versions.current} · {channel}: {versions.latest} - } - {isUpdating ? + + )} + {isUpdating ? ( + Checking for updates - : autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && + + ) : ( + autoUpdaterResult?.status === 'success' && + showSuccessMessage && + updateSemver && ( + ✓ Update installed · Restart to update - } - {autoUpdaterResult?.status === 'install_failed' && + + ) + )} + {autoUpdaterResult?.status === 'install_failed' && ( + ✗ Auto-update failed · Try /status - } - {maxVersionIssue && (process.env.USER_TYPE) === 'ant' && + + )} + {maxVersionIssue && process.env.USER_TYPE === 'ant' && ( + ⚠ Known issue: {maxVersionIssue} · Run{' '} claude rollback --safe to downgrade - } - ; + + )} + + ) } diff --git a/src/components/NotebookEditToolUseRejectedMessage.tsx b/src/components/NotebookEditToolUseRejectedMessage.tsx index 2a1aaade2..4eb3cf887 100644 --- a/src/components/NotebookEditToolUseRejectedMessage.tsx +++ b/src/components/NotebookEditToolUseRejectedMessage.tsx @@ -1,91 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import { relative } from 'path'; -import * as React from 'react'; -import { getCwd } from 'src/utils/cwd.js'; -import { Box, Text } from '../ink.js'; -import { HighlightedCode } from './HighlightedCode.js'; -import { MessageResponse } from './MessageResponse.js'; +import { relative } from 'path' +import * as React from 'react' +import { getCwd } from 'src/utils/cwd.js' +import { Box, Text } from '../ink.js' +import { HighlightedCode } from './HighlightedCode.js' +import { MessageResponse } from './MessageResponse.js' + type Props = { - notebook_path: string; - cell_id: string | undefined; - new_source: string; - cell_type?: 'code' | 'markdown'; - edit_mode?: 'replace' | 'insert' | 'delete'; - verbose: boolean; -}; -export function NotebookEditToolUseRejectedMessage(t0) { - const $ = _c(20); - const { - notebook_path, - cell_id, - new_source, - cell_type, - edit_mode: t1, - verbose - } = t0; - const edit_mode = t1 === undefined ? "replace" : t1; - const operation = edit_mode === "delete" ? "delete" : `${edit_mode} cell in`; - let t2; - if ($[0] !== operation) { - t2 = User rejected {operation} ; - $[0] = operation; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== notebook_path || $[3] !== verbose) { - t3 = verbose ? notebook_path : relative(getCwd(), notebook_path); - $[2] = notebook_path; - $[3] = verbose; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t3) { - t4 = {t3}; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== cell_id) { - t5 = at cell {cell_id}; - $[7] = cell_id; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== t2 || $[10] !== t4 || $[11] !== t5) { - t6 = {t2}{t4}{t5}; - $[9] = t2; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== cell_type || $[14] !== edit_mode || $[15] !== new_source) { - t7 = edit_mode !== "delete" && ; - $[13] = cell_type; - $[14] = edit_mode; - $[15] = new_source; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== t6 || $[18] !== t7) { - t8 = {t6}{t7}; - $[17] = t6; - $[18] = t7; - $[19] = t8; - } else { - t8 = $[19]; - } - return t8; + notebook_path: string + cell_id: string | undefined + new_source: string + cell_type?: 'code' | 'markdown' + edit_mode?: 'replace' | 'insert' | 'delete' + verbose: boolean +} + +export function NotebookEditToolUseRejectedMessage({ + notebook_path, + cell_id, + new_source, + cell_type, + edit_mode = 'replace', + verbose, +}: Props): React.ReactNode { + const operation = edit_mode === 'delete' ? 'delete' : `${edit_mode} cell in` + + return ( + + + + User rejected {operation} + + {verbose ? notebook_path : relative(getCwd(), notebook_path)} + + at cell {cell_id} + + {edit_mode !== 'delete' && ( + + + + )} + + + ) } diff --git a/src/components/OffscreenFreeze.tsx b/src/components/OffscreenFreeze.tsx index 1cb8e9007..51595bb6c 100644 --- a/src/components/OffscreenFreeze.tsx +++ b/src/components/OffscreenFreeze.tsx @@ -1,10 +1,11 @@ -import React, { useContext, useRef } from 'react'; -import { useTerminalViewport } from '../ink/hooks/use-terminal-viewport.js'; -import { Box } from '../ink.js'; -import { InVirtualListContext } from './messageActions.js'; +import React, { useContext, useRef } from 'react' +import { useTerminalViewport } from '../ink/hooks/use-terminal-viewport.js' +import { Box } from '../ink.js' +import { InVirtualListContext } from './messageActions.js' + type Props = { - children: React.ReactNode; -}; + children: React.ReactNode +} /** * Freezes children when they scroll above the terminal viewport (into scrollback). @@ -20,24 +21,19 @@ type Props = { * The cache is one slot deep: the first re-render after scrolling back into view * picks up the live children. Content still updates normally while visible. */ -export function OffscreenFreeze({ - children -}: Props): React.ReactNode { +export function OffscreenFreeze({ children }: Props): React.ReactNode { // React Compiler: reading cached.current in the return is the entire // freeze mechanism — memoizing this component would defeat it. Opt out. - 'use no memo'; - - const inVirtualList = useContext(InVirtualListContext); - const [ref, { - isVisible - }] = useTerminalViewport(); - const cached = useRef(children); + 'use no memo' + const inVirtualList = useContext(InVirtualListContext) + const [ref, { isVisible }] = useTerminalViewport() + const cached = useRef(children) // Virtual list has no terminal scrollback — the ScrollBox clips inside the // viewport, so there's nothing to freeze. Freezing there also blocks // click-to-expand since useTerminalViewport's visibility calc can disagree // with the ScrollBox's virtual scroll position. if (isVisible || inVirtualList) { - cached.current = children; + cached.current = children } - return {cached.current}; + return {cached.current} } diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index e0e1fed60..80083ff91 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -1,68 +1,96 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { setupTerminal, shouldOfferTerminalSetup } from '../commands/terminalSetup/terminalSetup.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Link, Newline, Text, useTheme } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { isAnthropicAuthEnabled } from '../utils/auth.js'; -import { normalizeApiKeyForConfig } from '../utils/authPortable.js'; -import { getCustomApiKeyStatus } from '../utils/config.js'; -import { env } from '../utils/env.js'; -import { isRunningOnHomespace } from '../utils/envUtils.js'; -import { PreflightStep } from '../utils/preflightChecks.js'; -import type { ThemeSetting } from '../utils/theme.js'; -import { ApproveApiKey } from './ApproveApiKey.js'; -import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'; -import { Select } from './CustomSelect/select.js'; -import { WelcomeV2 } from './LogoV2/WelcomeV2.js'; -import { PressEnterToContinue } from './PressEnterToContinue.js'; -import { ThemePicker } from './ThemePicker.js'; -import { OrderedList } from './ui/OrderedList.js'; -type StepId = 'preflight' | 'theme' | 'oauth' | 'api-key' | 'security' | 'terminal-setup'; +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + setupTerminal, + shouldOfferTerminalSetup, +} from '../commands/terminalSetup/terminalSetup.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Link, Newline, Text, useTheme } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { isAnthropicAuthEnabled } from '../utils/auth.js' +import { normalizeApiKeyForConfig } from '../utils/authPortable.js' +import { getCustomApiKeyStatus } from '../utils/config.js' +import { env } from '../utils/env.js' +import { isRunningOnHomespace } from '../utils/envUtils.js' +import { PreflightStep } from '../utils/preflightChecks.js' +import type { ThemeSetting } from '../utils/theme.js' +import { ApproveApiKey } from './ApproveApiKey.js' +import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js' +import { Select } from './CustomSelect/select.js' +import { WelcomeV2 } from './LogoV2/WelcomeV2.js' +import { PressEnterToContinue } from './PressEnterToContinue.js' +import { ThemePicker } from './ThemePicker.js' +import { OrderedList } from './ui/OrderedList.js' + +type StepId = + | 'preflight' + | 'theme' + | 'oauth' + | 'api-key' + | 'security' + | 'terminal-setup' + interface OnboardingStep { - id: StepId; - component: React.ReactNode; + id: StepId + component: React.ReactNode } + type Props = { - onDone(): void; -}; -export function Onboarding({ - onDone -}: Props): React.ReactNode { - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [skipOAuth, setSkipOAuth] = useState(false); - const [oauthEnabled] = useState(() => isAnthropicAuthEnabled()); - const [theme, setTheme] = useTheme(); + onDone(): void +} + +export function Onboarding({ onDone }: Props): React.ReactNode { + const [currentStepIndex, setCurrentStepIndex] = useState(0) + const [skipOAuth, setSkipOAuth] = useState(false) + const [oauthEnabled] = useState(() => isAnthropicAuthEnabled()) + const [theme, setTheme] = useTheme() + useEffect(() => { logEvent('tengu_began_setup', { - oauthEnabled - }); - }, [oauthEnabled]); + oauthEnabled, + }) + }, [oauthEnabled]) + function goToNextStep() { if (currentStepIndex < steps.length - 1) { - const nextIndex = currentStepIndex + 1; - setCurrentStepIndex(nextIndex); + const nextIndex = currentStepIndex + 1 + setCurrentStepIndex(nextIndex) + logEvent('tengu_onboarding_step', { oauthEnabled, - stepId: steps[nextIndex]?.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + stepId: steps[nextIndex] + ?.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } else { - onDone(); + onDone() } } + function handleThemeSelection(newTheme: ThemeSetting) { - setTheme(newTheme); - goToNextStep(); + setTheme(newTheme) + goToNextStep() } - const exitState = useExitOnCtrlCDWithKeybindings(); + + const exitState = useExitOnCtrlCDWithKeybindings() // Define all onboarding steps - const themeStep = - - ; - const securityStep = + const themeStep = ( + + + + ) + + const securityStep = ( + Security notes: {/** @@ -92,152 +120,182 @@ export function Onboarding({ - ; - const preflightStep = ; + + ) + + const preflightStep = // Create the steps array - determine which steps to include based on reAuth and oauthEnabled const apiKeyNeedingApproval = useMemo(() => { // Add API key step if needed // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). if (!process.env.ANTHROPIC_API_KEY || isRunningOnHomespace()) { - return ''; + return '' } - const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); + const customApiKeyTruncated = normalizeApiKeyForConfig( + process.env.ANTHROPIC_API_KEY, + ) if (getCustomApiKeyStatus(customApiKeyTruncated) === 'new') { - return customApiKeyTruncated; + return customApiKeyTruncated } - }, []); + }, []) + function handleApiKeyDone(approved: boolean) { if (approved) { - setSkipOAuth(true); + setSkipOAuth(true) } - goToNextStep(); + goToNextStep() } - const steps: OnboardingStep[] = []; + + const steps: OnboardingStep[] = [] if (oauthEnabled) { - steps.push({ - id: 'preflight', - component: preflightStep - }); + steps.push({ id: 'preflight', component: preflightStep }) } - steps.push({ - id: 'theme', - component: themeStep - }); + steps.push({ id: 'theme', component: themeStep }) + if (apiKeyNeedingApproval) { steps.push({ id: 'api-key', - component: - }); + component: ( + + ), + }) } + if (oauthEnabled) { steps.push({ id: 'oauth', - component: + component: ( + - }); + ), + }) } - steps.push({ - id: 'security', - component: securityStep - }); + + steps.push({ id: 'security', component: securityStep }) + if (shouldOfferTerminalSetup()) { steps.push({ id: 'terminal-setup', - component: + component: ( + Use Claude Code's terminal setup? For the optimal coding experience, enable the recommended settings for your terminal:{' '} - {env.terminal === 'Apple_Terminal' ? 'Option+Enter for newlines and visual bell' : 'Shift+Enter for newlines'} + {env.terminal === 'Apple_Terminal' + ? 'Option+Enter for newlines and visual bell' + : 'Shift+Enter for newlines'} - { + if (value === 'install') { + // Errors already logged in setupTerminal, just swallow and proceed + void setupTerminal(theme) + .catch(() => {}) + .finally(goToNextStep) + } else { + goToNextStep() + } + }} + onCancel={() => goToNextStep()} + /> - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to skip} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Enter to confirm · Esc to skip + )} - }); + ), + }) } - const currentStep = steps[currentStepIndex]; + + const currentStep = steps[currentStepIndex] // Handle Enter on security step and Escape on terminal-setup step // Dependencies match what goToNextStep uses internally const handleSecurityContinue = useCallback(() => { if (currentStepIndex === steps.length - 1) { - onDone(); + onDone() } else { - goToNextStep(); + goToNextStep() } - }, [currentStepIndex, steps.length, oauthEnabled, onDone]); + }, [currentStepIndex, steps.length, oauthEnabled, onDone]) + const handleTerminalSetupSkip = useCallback(() => { - goToNextStep(); - }, [currentStepIndex, steps.length, oauthEnabled, onDone]); - useKeybindings({ - 'confirm:yes': handleSecurityContinue - }, { - context: 'Confirmation', - isActive: currentStep?.id === 'security' - }); - useKeybindings({ - 'confirm:no': handleTerminalSetupSkip - }, { - context: 'Confirmation', - isActive: currentStep?.id === 'terminal-setup' - }); - return + goToNextStep() + }, [currentStepIndex, steps.length, oauthEnabled, onDone]) + + useKeybindings( + { + 'confirm:yes': handleSecurityContinue, + }, + { + context: 'Confirmation', + isActive: currentStep?.id === 'security', + }, + ) + + useKeybindings( + { + 'confirm:no': handleTerminalSetupSkip, + }, + { + context: 'Confirmation', + isActive: currentStep?.id === 'terminal-setup', + }, + ) + + return ( + {currentStep?.component} - {exitState.pending && + {exitState.pending && ( + Press {exitState.keyName} again to exit - } + + )} - ; + + ) } -export function SkippableStep(t0) { - const $ = _c(4); - const { - skip, - onSkip, - children - } = t0; - let t1; - let t2; - if ($[0] !== onSkip || $[1] !== skip) { - t1 = () => { - if (skip) { - onSkip(); - } - }; - t2 = [skip, onSkip]; - $[0] = onSkip; - $[1] = skip; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); + +export function SkippableStep({ + skip, + onSkip, + children, +}: { + skip: boolean + onSkip(): void + children: React.ReactNode +}): React.ReactNode { + useEffect(() => { + if (skip) { + onSkip() + } + }, [skip, onSkip]) if (skip) { - return null; + return null } - return children; + return children } diff --git a/src/components/OutputStylePicker.tsx b/src/components/OutputStylePicker.tsx index 534b6322d..4ff039a82 100644 --- a/src/components/OutputStylePicker.tsx +++ b/src/components/OutputStylePicker.tsx @@ -1,111 +1,95 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { getAllOutputStyles, OUTPUT_STYLE_CONFIG, type OutputStyleConfig } from '../constants/outputStyles.js'; -import { Box, Text } from '../ink.js'; -import type { OutputStyle } from '../utils/config.js'; -import { getCwd } from '../utils/cwd.js'; -import type { OptionWithDescription } from './CustomSelect/select.js'; -import { Select } from './CustomSelect/select.js'; -import { Dialog } from './design-system/Dialog.js'; -const DEFAULT_OUTPUT_STYLE_LABEL = 'Default'; -const DEFAULT_OUTPUT_STYLE_DESCRIPTION = 'Claude completes coding tasks efficiently and provides concise responses'; +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import { + getAllOutputStyles, + OUTPUT_STYLE_CONFIG, + type OutputStyleConfig, +} from '../constants/outputStyles.js' +import { Box, Text } from '../ink.js' +import type { OutputStyle } from '../utils/config.js' +import { getCwd } from '../utils/cwd.js' +import type { OptionWithDescription } from './CustomSelect/select.js' +import { Select } from './CustomSelect/select.js' +import { Dialog } from './design-system/Dialog.js' + +const DEFAULT_OUTPUT_STYLE_LABEL = 'Default' +const DEFAULT_OUTPUT_STYLE_DESCRIPTION = + 'Claude completes coding tasks efficiently and provides concise responses' + function mapConfigsToOptions(styles: { - [styleName: string]: OutputStyleConfig | null; + [styleName: string]: OutputStyleConfig | null }): OptionWithDescription[] { return Object.entries(styles).map(([style, config]) => ({ label: config?.name ?? DEFAULT_OUTPUT_STYLE_LABEL, value: style, - description: config?.description ?? DEFAULT_OUTPUT_STYLE_DESCRIPTION - })); + description: config?.description ?? DEFAULT_OUTPUT_STYLE_DESCRIPTION, + })) } + export type OutputStylePickerProps = { - initialStyle: OutputStyle; - onComplete: (style: OutputStyle) => void; - onCancel: () => void; - isStandaloneCommand?: boolean; -}; -export function OutputStylePicker(t0) { - const $ = _c(16); - const { - initialStyle, - onComplete, - onCancel, - isStandaloneCommand - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [styleOptions, setStyleOptions] = useState(t1); - const [isLoading, setIsLoading] = useState(true); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - getAllOutputStyles(getCwd()).then(allStyles => { - const options = mapConfigsToOptions(allStyles); - setStyleOptions(options); - setIsLoading(false); - }).catch(() => { - const builtInOptions = mapConfigsToOptions(OUTPUT_STYLE_CONFIG); - setStyleOptions(builtInOptions); - setIsLoading(false); - }); - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - let t4; - if ($[3] !== onComplete) { - t4 = style => { - const outputStyle = style as OutputStyle; - onComplete(outputStyle); - }; - $[3] = onComplete; - $[4] = t4; - } else { - t4 = $[4]; - } - const handleStyleSelect = t4; - const t5 = !isStandaloneCommand; - const t6 = !isStandaloneCommand; - let t7; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t7 = This changes how Claude Code communicates with you; - $[5] = t7; - } else { - t7 = $[5]; - } - let t8; - if ($[6] !== handleStyleSelect || $[7] !== initialStyle || $[8] !== isLoading || $[9] !== styleOptions) { - t8 = {t7}{isLoading ? Loading output styles… : + )} + + + ) } diff --git a/src/components/PackageManagerAutoUpdater.tsx b/src/components/PackageManagerAutoUpdater.tsx index f90b85818..e97d32b12 100644 --- a/src/components/PackageManagerAutoUpdater.tsx +++ b/src/components/PackageManagerAutoUpdater.tsx @@ -1,103 +1,119 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { useInterval } from 'usehooks-ts'; -import { Text } from '../ink.js'; -import { type AutoUpdaterResult, getLatestVersionFromGcs, getMaxVersion, shouldSkipVersion } from '../utils/autoUpdater.js'; -import { isAutoUpdaterDisabled } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { getPackageManager, type PackageManager } from '../utils/nativeInstaller/packageManagers.js'; -import { gt, gte } from '../utils/semver.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; +import * as React from 'react' +import { useState } from 'react' +import { useInterval } from 'usehooks-ts' +import { Text } from '../ink.js' +import { + type AutoUpdaterResult, + getLatestVersionFromGcs, + getMaxVersion, + shouldSkipVersion, +} from '../utils/autoUpdater.js' +import { isAutoUpdaterDisabled } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { + getPackageManager, + type PackageManager, +} from '../utils/nativeInstaller/packageManagers.js' +import { gt, gte } from '../utils/semver.js' +import { getInitialSettings } from '../utils/settings/settings.js' + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; -export function PackageManagerAutoUpdater(t0) { - const $ = _c(10); - const { - verbose - } = t0; - const [updateAvailable, setUpdateAvailable] = useState(false); - const [packageManager, setPackageManager] = useState("unknown"); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = async () => { - false || false; - if (isAutoUpdaterDisabled()) { - return; - } - const [channel, pm] = await Promise.all([Promise.resolve(getInitialSettings()?.autoUpdatesChannel ?? "latest"), getPackageManager()]); - setPackageManager(pm); - let latest = await getLatestVersionFromGcs(channel); - const maxVersion = await getMaxVersion(); - if (maxVersion && latest && gt(latest, maxVersion)) { - logForDebugging(`PackageManagerAutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latest} to ${maxVersion}`); - if (gte(MACRO.VERSION, maxVersion)) { - logForDebugging(`PackageManagerAutoUpdater: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`); - setUpdateAvailable(false); - return; - } - latest = maxVersion; - } - const hasUpdate = latest && !gte(MACRO.VERSION, latest) && !shouldSkipVersion(latest); - setUpdateAvailable(!!hasUpdate); - if (hasUpdate) { - logForDebugging(`PackageManagerAutoUpdater: Update available ${MACRO.VERSION} -> ${latest}`); + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + +export function PackageManagerAutoUpdater({ verbose }: Props): React.ReactNode { + const [updateAvailable, setUpdateAvailable] = useState(false) + const [packageManager, setPackageManager] = + useState('unknown') + + const checkForUpdates = React.useCallback(async () => { + if ( + "production" === 'test' || + "production" === 'development' + ) { + return + } + + if (isAutoUpdaterDisabled()) { + return + } + + const [channel, pm] = await Promise.all([ + Promise.resolve(getInitialSettings()?.autoUpdatesChannel ?? 'latest'), + getPackageManager(), + ]) + setPackageManager(pm) + + let latest = await getLatestVersionFromGcs(channel) + + // Check if max version is set (server-side kill switch for auto-updates) + const maxVersion = await getMaxVersion() + + if (maxVersion && latest && gt(latest, maxVersion)) { + logForDebugging( + `PackageManagerAutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latest} to ${maxVersion}`, + ) + if (gte(MACRO.VERSION, maxVersion)) { + logForDebugging( + `PackageManagerAutoUpdater: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`, + ) + setUpdateAvailable(false) + return } - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const checkForUpdates = t1; - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - checkForUpdates(); - }; - t3 = [checkForUpdates]; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - React.useEffect(t2, t3); - useInterval(checkForUpdates, 1800000); + latest = maxVersion + } + + const hasUpdate = + latest && !gte(MACRO.VERSION, latest) && !shouldSkipVersion(latest) + + setUpdateAvailable(!!hasUpdate) + + if (hasUpdate) { + logForDebugging( + `PackageManagerAutoUpdater: Update available ${MACRO.VERSION} -> ${latest}`, + ) + } + }, []) + + // Initial check + React.useEffect(() => { + void checkForUpdates() + }, [checkForUpdates]) + + // Check every 30 minutes + useInterval(checkForUpdates, 30 * 60 * 1000) + if (!updateAvailable) { - return null; - } - const updateCommand = packageManager === "homebrew" ? "brew upgrade claude-code" : packageManager === "winget" ? "winget upgrade Anthropic.ClaudeCode" : packageManager === "apk" ? "apk upgrade claude-code" : "your package manager update command"; - let t4; - if ($[3] !== verbose) { - t4 = verbose && currentVersion: {MACRO.VERSION}; - $[3] = verbose; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== updateCommand) { - t5 = Update available! Run: {updateCommand}; - $[5] = updateCommand; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== t4 || $[8] !== t5) { - t6 = <>{t4}{t5}; - $[7] = t4; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; + return null } - return t6; + + // pacman, deb, and rpm don't get specific commands because they each have + // multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala, + // rpm: dnf/yum/zypper) + const updateCommand = + packageManager === 'homebrew' + ? 'brew upgrade claude-code' + : packageManager === 'winget' + ? 'winget upgrade Anthropic.ClaudeCode' + : packageManager === 'apk' + ? 'apk upgrade claude-code' + : 'your package manager update command' + + return ( + <> + {verbose && ( + + currentVersion: {MACRO.VERSION} + + )} + + Update available! Run: {updateCommand} + + + ) } diff --git a/src/components/Passes/Passes.tsx b/src/components/Passes/Passes.tsx index 291fdbbf9..69388618a 100644 --- a/src/components/Passes/Passes.tsx +++ b/src/components/Passes/Passes.tsx @@ -1,148 +1,189 @@ -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { TEARDROP_ASTERISK } from '../../constants/figures.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { setClipboard } from '../../ink/termio/osc.js'; +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { TEARDROP_ASTERISK } from '../../constants/figures.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { setClipboard } from '../../ink/termio/osc.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to copy link -import { Box, Link, Text, useInput } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { fetchReferralRedemptions, formatCreditAmount, getCachedOrFetchPassesEligibility } from '../../services/api/referral.js'; -import type { ReferralRedemptionsResponse, ReferrerRewardInfo } from '../../services/oauth/types.js'; -import { count } from '../../utils/array.js'; -import { logError } from '../../utils/log.js'; -import { Pane } from '../design-system/Pane.js'; +import { Box, Link, Text, useInput } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { logEvent } from '../../services/analytics/index.js' +import { + fetchReferralRedemptions, + formatCreditAmount, + getCachedOrFetchPassesEligibility, +} from '../../services/api/referral.js' +import type { + ReferralRedemptionsResponse, + ReferrerRewardInfo, +} from '../../services/oauth/types.js' +import { count } from '../../utils/array.js' +import { logError } from '../../utils/log.js' +import { Pane } from '../design-system/Pane.js' + type PassStatus = { - passNumber: number; - isAvailable: boolean; -}; + passNumber: number + isAvailable: boolean +} + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function Passes({ - onDone -}: Props): React.ReactNode { - const [loading, setLoading] = useState(true); - const [passStatuses, setPassStatuses] = useState([]); - const [isAvailable, setIsAvailable] = useState(false); - const [referralLink, setReferralLink] = useState(null); - const [referrerReward, setReferrerReward] = useState(undefined); - const exitState = useExitOnCtrlCDWithKeybindings(() => onDone('Guest passes dialog dismissed', { - display: 'system' - })); + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function Passes({ onDone }: Props): React.ReactNode { + const [loading, setLoading] = useState(true) + const [passStatuses, setPassStatuses] = useState([]) + const [isAvailable, setIsAvailable] = useState(false) + const [referralLink, setReferralLink] = useState(null) + const [referrerReward, setReferrerReward] = useState< + ReferrerRewardInfo | null | undefined + >(undefined) + + const exitState = useExitOnCtrlCDWithKeybindings(() => + onDone('Guest passes dialog dismissed', { display: 'system' }), + ) + const handleCancel = useCallback(() => { - onDone('Guest passes dialog dismissed', { - display: 'system' - }); - }, [onDone]); - useKeybinding('confirm:no', handleCancel, { - context: 'Confirmation' - }); + onDone('Guest passes dialog dismissed', { display: 'system' }) + }, [onDone]) + + useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }) + useInput((_input, key) => { if (key.return && referralLink) { void setClipboard(referralLink).then(raw => { - if (raw) process.stdout.write(raw); - logEvent('tengu_guest_passes_link_copied', {}); - onDone(`Referral link copied to clipboard!`); - }); + if (raw) process.stdout.write(raw) + logEvent('tengu_guest_passes_link_copied', {}) + onDone(`Referral link copied to clipboard!`) + }) } - }); + }) + useEffect(() => { async function loadPassesData() { try { // Check eligibility first (uses cache if available) - const eligibilityData = await getCachedOrFetchPassesEligibility(); + const eligibilityData = await getCachedOrFetchPassesEligibility() + if (!eligibilityData || !eligibilityData.eligible) { - setIsAvailable(false); - setLoading(false); - return; + setIsAvailable(false) + setLoading(false) + return } - setIsAvailable(true); + + setIsAvailable(true) // Store the referral link if available if (eligibilityData.referral_code_details?.referral_link) { - setReferralLink(eligibilityData.referral_code_details.referral_link); + setReferralLink(eligibilityData.referral_code_details.referral_link) } // Store referrer reward info for v1 campaign messaging - setReferrerReward(eligibilityData.referrer_reward); + setReferrerReward(eligibilityData.referrer_reward) // Use the campaign returned from eligibility for redemptions - const campaign = eligibilityData.referral_code_details?.campaign ?? 'claude_code_guest_pass'; + const campaign = + eligibilityData.referral_code_details?.campaign ?? + 'claude_code_guest_pass' // Fetch redemptions data - let redemptionsData: ReferralRedemptionsResponse; + let redemptionsData: ReferralRedemptionsResponse try { - redemptionsData = await fetchReferralRedemptions(campaign); - } catch (err_0) { - logError(err_0 as Error); - setIsAvailable(false); - setLoading(false); - return; + redemptionsData = await fetchReferralRedemptions(campaign) + } catch (err) { + logError(err as Error) + setIsAvailable(false) + setLoading(false) + return } // Build pass statuses array - const redemptions = redemptionsData.redemptions || []; - const maxRedemptions = redemptionsData.limit || 3; - const statuses: PassStatus[] = []; + const redemptions = redemptionsData.redemptions || [] + const maxRedemptions = redemptionsData.limit || 3 + const statuses: PassStatus[] = [] + for (let i = 0; i < maxRedemptions; i++) { - const redemption = redemptions[i]; + const redemption = redemptions[i] statuses.push({ passNumber: i + 1, - isAvailable: !redemption - }); + isAvailable: !redemption, + }) } - setPassStatuses(statuses); - setLoading(false); + + setPassStatuses(statuses) + setLoading(false) } catch (err) { // For any error, just show passes as not available - logError(err as Error); - setIsAvailable(false); - setLoading(false); + logError(err as Error) + setIsAvailable(false) + setLoading(false) } } - void loadPassesData(); - }, []); + + void loadPassesData() + }, []) + if (loading) { - return + return ( + Loading guest pass information… - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Esc to cancel} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Esc to cancel + )} - ; + + ) } + if (!isAvailable) { - return + return ( + Guest passes are not currently available. - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Esc to cancel} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Esc to cancel + )} - ; + + ) } - const availableCount = count(passStatuses, p => p.isAvailable); + + const availableCount = count(passStatuses, p => p.isAvailable) // Sort passes: available first, then redeemed - const sortedPasses = [...passStatuses].sort((a, b) => +b.isAvailable - +a.isAvailable); + const sortedPasses = [...passStatuses].sort( + (a, b) => +b.isAvailable - +a.isAvailable, + ) // ASCII art for tickets const renderTicket = (pass: PassStatus) => { - const isRedeemed = !pass.isAvailable; + const isRedeemed = !pass.isAvailable + if (isRedeemed) { // Grayed out redeemed ticket with slashes - return + return ( + {'┌─────────╱'} {` ) CC ${TEARDROP_ASTERISK} ┊╱`} {'└───────╱'} - ; + + ) } - return + + return ( + {'┌──────────┐'} {' ) CC '} @@ -150,24 +191,37 @@ export function Passes({ {' ┊ ( '} {'└──────────┘'} - ; - }; - return + + ) + } + + return ( + Guest passes · {availableCount} left - {sortedPasses.slice(0, 3).map(pass_0 => renderTicket(pass_0))} + {sortedPasses.slice(0, 3).map(pass => renderTicket(pass))} - {referralLink && + {referralLink && ( + {referralLink} - } + + )} - {referrerReward ? `Share a free week of Claude Code with friends. If they love it and subscribe, you'll get ${formatCreditAmount(referrerReward)} of extra usage to keep building. ` : 'Share a free week of Claude Code with friends. '} - + {referrerReward + ? `Share a free week of Claude Code with friends. If they love it and subscribe, you'll get ${formatCreditAmount(referrerReward)} of extra usage to keep building. ` + : 'Share a free week of Claude Code with friends. '} + Terms apply. @@ -175,9 +229,14 @@ export function Passes({ - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to copy link · Esc to cancel} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Enter to copy link · Esc to cancel + )} - ; + + ) } diff --git a/src/components/PrBadge.tsx b/src/components/PrBadge.tsx index ccc94b139..bb0aef9e7 100644 --- a/src/components/PrBadge.tsx +++ b/src/components/PrBadge.tsx @@ -1,96 +1,56 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Link, Text } from '../ink.js'; -import type { PrReviewState } from '../utils/ghPrStatus.js'; +import React from 'react' +import { Link, Text } from '../ink.js' +import type { PrReviewState } from '../utils/ghPrStatus.js' + type Props = { - number: number; - url: string; - reviewState?: PrReviewState; - bold?: boolean; -}; -export function PrBadge(t0) { - const $ = _c(21); - const { - number, - url, - reviewState, - bold - } = t0; - let t1; - if ($[0] !== reviewState) { - t1 = getPrStatusColor(reviewState); - $[0] = reviewState; - $[1] = t1; - } else { - t1 = $[1]; - } - const statusColor = t1; - const t2 = !statusColor && !bold; - let t3; - if ($[2] !== bold || $[3] !== number || $[4] !== statusColor || $[5] !== t2) { - t3 = #{number}; - $[2] = bold; - $[3] = number; - $[4] = statusColor; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - const label = t3; - const t4 = !bold; - let t5; - if ($[7] !== t4) { - t5 = PR; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - const t6 = !statusColor && !bold; - let t7; - if ($[9] !== bold || $[10] !== number || $[11] !== statusColor || $[12] !== t6) { - t7 = #{number}; - $[9] = bold; - $[10] = number; - $[11] = statusColor; - $[12] = t6; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] !== label || $[15] !== t7 || $[16] !== url) { - t8 = {t7}; - $[14] = label; - $[15] = t7; - $[16] = url; - $[17] = t8; - } else { - t8 = $[17]; - } - let t9; - if ($[18] !== t5 || $[19] !== t8) { - t9 = {t5}{" "}{t8}; - $[18] = t5; - $[19] = t8; - $[20] = t9; - } else { - t9 = $[20]; - } - return t9; + number: number + url: string + reviewState?: PrReviewState + bold?: boolean +} + +export function PrBadge({ + number, + url, + reviewState, + bold, +}: Props): React.ReactNode { + const statusColor = getPrStatusColor(reviewState) + const label = ( + + #{number} + + ) + return ( + + PR{' '} + + + #{number} + + + + ) } -function getPrStatusColor(state?: PrReviewState): 'success' | 'error' | 'warning' | 'merged' | undefined { + +function getPrStatusColor( + state?: PrReviewState, +): 'success' | 'error' | 'warning' | 'merged' | undefined { switch (state) { case 'approved': - return 'success'; + return 'success' case 'changes_requested': - return 'error'; + return 'error' case 'pending': - return 'warning'; + return 'warning' case 'merged': - return 'merged'; + return 'merged' default: - return undefined; + return undefined } } diff --git a/src/components/PressEnterToContinue.tsx b/src/components/PressEnterToContinue.tsx index 04fa04a48..662c7af85 100644 --- a/src/components/PressEnterToContinue.tsx +++ b/src/components/PressEnterToContinue.tsx @@ -1,14 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../ink.js'; -export function PressEnterToContinue() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Press Enter to continue…; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { Text } from '../ink.js' + +export function PressEnterToContinue(): React.ReactNode { + return ( + + Press Enter to continue… + + ) } diff --git a/src/components/QuickOpenDialog.tsx b/src/components/QuickOpenDialog.tsx index 4e103524d..37b7bb7e1 100644 --- a/src/components/QuickOpenDialog.tsx +++ b/src/components/QuickOpenDialog.tsx @@ -1,243 +1,182 @@ -import { c as _c } from "react/compiler-runtime"; -import * as path from 'path'; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { useRegisterOverlay } from '../context/overlayContext.js'; -import { generateFileSuggestions } from '../hooks/fileSuggestions.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Text } from '../ink.js'; -import { logEvent } from '../services/analytics/index.js'; -import { getCwd } from '../utils/cwd.js'; -import { openFileInExternalEditor } from '../utils/editor.js'; -import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'; -import { highlightMatch } from '../utils/highlightMatch.js'; -import { readFileInRange } from '../utils/readFileInRange.js'; -import { FuzzyPicker } from './design-system/FuzzyPicker.js'; -import { LoadingState } from './design-system/LoadingState.js'; +import * as path from 'path' +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { useRegisterOverlay } from '../context/overlayContext.js' +import { generateFileSuggestions } from '../hooks/fileSuggestions.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Text } from '../ink.js' +import { logEvent } from '../services/analytics/index.js' +import { getCwd } from '../utils/cwd.js' +import { openFileInExternalEditor } from '../utils/editor.js' +import { truncatePathMiddle, truncateToWidth } from '../utils/format.js' +import { highlightMatch } from '../utils/highlightMatch.js' +import { readFileInRange } from '../utils/readFileInRange.js' +import { FuzzyPicker } from './design-system/FuzzyPicker.js' +import { LoadingState } from './design-system/LoadingState.js' + type Props = { - onDone: () => void; - onInsert: (text: string) => void; -}; -const VISIBLE_RESULTS = 8; -const PREVIEW_LINES = 20; + onDone: () => void + onInsert: (text: string) => void +} + +const VISIBLE_RESULTS = 8 +const PREVIEW_LINES = 20 /** * Quick Open dialog (ctrl+shift+p / cmd+shift+p). * Fuzzy file finder with a syntax-highlighted preview of the focused file. */ -export function QuickOpenDialog(t0) { - const $ = _c(35); - const { - onDone, - onInsert - } = t0; - useRegisterOverlay("quick-open", undefined); - const { - columns, - rows - } = useTerminalSize(); - const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; +export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { + useRegisterOverlay('quick-open') + const { columns, rows } = useTerminalSize() + // Chrome (title + search + hints + pane border + gaps) eats ~14 rows. + // Shrink the list on short terminals so the dialog doesn't clip. + const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)) + + const [results, setResults] = useState([]) + const [query, setQuery] = useState('') + const [focusedPath, setFocusedPath] = useState(undefined) + const [preview, setPreview] = useState<{ + path: string + content: string + } | null>(null) + const queryGenRef = useRef(0) + useEffect(() => () => void queryGenRef.current++, []) + + const previewOnRight = columns >= 120 + // Side preview sits in a fixed-height row alongside the list (visibleCount + // rows), so overflowing that height garbles the layout — cap to fit, minus + // one for the path header line. + const effectivePreviewLines = previewOnRight + ? VISIBLE_RESULTS - 1 + : PREVIEW_LINES + + // A generation counter invalidates stale results if the user types faster + // than the index can respond. + const handleQueryChange = (q: string) => { + setQuery(q) + const gen = ++queryGenRef.current + if (!q.trim()) { + // generateFileSuggestions('') returns raw readdir() of cwd (designed for + // @-mentions). For Quick Open that's just noise — show the empty state. + setResults([]) + return + } + void generateFileSuggestions(q, true).then(items => { + if (gen !== queryGenRef.current) return + // Filter out directory entries — they come back with a trailing path.sep + // from getTopLevelPaths() and would cause readFileInRange to throw EISDIR, + // leaving the preview pane stuck on "Loading preview…". + // Normalize separators to '/' so truncatePathMiddle (which uses + // lastIndexOf('/')) can find the filename on Windows too. + const paths = items + .filter(i => i.id.startsWith('file-')) + .map(i => i.displayText) + .filter(p => !p.endsWith(path.sep)) + .map(p => p.split(path.sep).join('/')) + setResults(paths) + }) } - const [results, setResults] = useState(t1); - const [query, setQuery] = useState(""); - const [focusedPath, setFocusedPath] = useState(undefined); - const [preview, setPreview] = useState(null); - const queryGenRef = useRef(0); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => () => { - queryGenRef.current = queryGenRef.current + 1; - return void queryGenRef.current; - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; + + // Load a short preview of the focused file. Each navigation aborts the + // previous read so holding ↓ doesn't pile up whole-file reads and so a + // slow early read can't overwrite a faster later one. The stale preview + // stays visible until the new one arrives — renderPreview overlays a dim + // loading indicator rather than blanking the pane. + useEffect(() => { + if (!focusedPath) { + // No results — clear so the empty-state renders instead of a stale + // preview from a previous query. + setPreview(null) + return + } + const controller = new AbortController() + const absolute = path.resolve(getCwd(), focusedPath) + void readFileInRange( + absolute, + 0, + effectivePreviewLines, + undefined, + controller.signal, + ) + .then(r => { + if (controller.signal.aborted) return + setPreview({ path: focusedPath, content: r.content }) + }) + .catch(() => { + if (controller.signal.aborted) return + setPreview({ path: focusedPath, content: '(preview unavailable)' }) + }) + return () => controller.abort() + }, [focusedPath, effectivePreviewLines]) + + const maxPathWidth = previewOnRight + ? Math.max(20, Math.floor((columns - 10) * 0.4)) + : Math.max(20, columns - 8) + const previewWidth = previewOnRight + ? Math.max(40, columns - maxPathWidth - 14) + : columns - 6 + + const handleOpen = (p: string) => { + const opened = openFileInExternalEditor(path.resolve(getCwd(), p)) + logEvent('tengu_quick_open_select', { + result_count: results.length, + opened_editor: opened, + }) + onDone() } - useEffect(t2, t3); - const previewOnRight = columns >= 120; - const effectivePreviewLines = previewOnRight ? VISIBLE_RESULTS - 1 : PREVIEW_LINES; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = q => { - setQuery(q); - const gen = queryGenRef.current = queryGenRef.current + 1; - if (!q.trim()) { - setResults([]); - return; - } - generateFileSuggestions(q, true).then(items => { - if (gen !== queryGenRef.current) { - return; - } - const paths = items.filter(_temp).map(_temp2).filter(_temp3).map(_temp4); - setResults(paths); - }); - }; - $[3] = t4; - } else { - t4 = $[3]; + + const handleInsert = (p: string, mention: boolean) => { + onInsert(mention ? `@${p} ` : `${p} `) + logEvent('tengu_quick_open_insert', { + result_count: results.length, + mention, + }) + onDone() } - const handleQueryChange = t4; - let t5; - let t6; - if ($[4] !== effectivePreviewLines || $[5] !== focusedPath) { - t5 = () => { - if (!focusedPath) { - setPreview(null); - return; + + return ( + p} + visibleCount={visibleResults} + direction="up" + previewPosition={previewOnRight ? 'right' : 'bottom'} + onQueryChange={handleQueryChange} + onFocus={setFocusedPath} + onSelect={handleOpen} + onTab={{ action: 'mention', handler: p => handleInsert(p, true) }} + onShiftTab={{ + action: 'insert path', + handler: p => handleInsert(p, false), + }} + onCancel={onDone} + emptyMessage={q => (q ? 'No matching files' : 'Start typing to search…')} + selectAction="open in editor" + renderItem={(p, isFocused) => ( + + {truncatePathMiddle(p, maxPathWidth)} + + )} + renderPreview={p => + preview ? ( + <> + + {truncatePathMiddle(p, previewWidth)} + {preview.path !== p ? ' · loading…' : ''} + + {preview.content.split('\n').map((line, i) => ( + + {highlightMatch(truncateToWidth(line, previewWidth), query)} + + ))} + + ) : ( + + ) } - const controller = new AbortController(); - const absolute = path.resolve(getCwd(), focusedPath); - readFileInRange(absolute, 0, effectivePreviewLines, undefined, controller.signal).then(r => { - if (controller.signal.aborted) { - return; - } - setPreview({ - path: focusedPath, - content: r.content - }); - }).catch(() => { - if (controller.signal.aborted) { - return; - } - setPreview({ - path: focusedPath, - content: "(preview unavailable)" - }); - }); - return () => controller.abort(); - }; - t6 = [focusedPath, effectivePreviewLines]; - $[4] = effectivePreviewLines; - $[5] = focusedPath; - $[6] = t5; - $[7] = t6; - } else { - t5 = $[6]; - t6 = $[7]; - } - useEffect(t5, t6); - const maxPathWidth = previewOnRight ? Math.max(20, Math.floor((columns - 10) * 0.4)) : Math.max(20, columns - 8); - const previewWidth = previewOnRight ? Math.max(40, columns - maxPathWidth - 14) : columns - 6; - let t7; - if ($[8] !== onDone || $[9] !== results.length) { - t7 = p_1 => { - const opened = openFileInExternalEditor(path.resolve(getCwd(), p_1)); - logEvent("tengu_quick_open_select", { - result_count: results.length, - opened_editor: opened - }); - onDone(); - }; - $[8] = onDone; - $[9] = results.length; - $[10] = t7; - } else { - t7 = $[10]; - } - const handleOpen = t7; - let t8; - if ($[11] !== onDone || $[12] !== onInsert || $[13] !== results.length) { - t8 = (p_2, mention) => { - onInsert(mention ? `@${p_2} ` : `${p_2} `); - logEvent("tengu_quick_open_insert", { - result_count: results.length, - mention - }); - onDone(); - }; - $[11] = onDone; - $[12] = onInsert; - $[13] = results.length; - $[14] = t8; - } else { - t8 = $[14]; - } - const handleInsert = t8; - const t9 = previewOnRight ? "right" : "bottom"; - let t10; - if ($[15] !== handleInsert) { - t10 = { - action: "mention", - handler: p_4 => handleInsert(p_4, true) - }; - $[15] = handleInsert; - $[16] = t10; - } else { - t10 = $[16]; - } - let t11; - if ($[17] !== handleInsert) { - t11 = { - action: "insert path", - handler: p_5 => handleInsert(p_5, false) - }; - $[17] = handleInsert; - $[18] = t11; - } else { - t11 = $[18]; - } - let t12; - if ($[19] !== maxPathWidth) { - t12 = (p_6, isFocused) => {truncatePathMiddle(p_6, maxPathWidth)}; - $[19] = maxPathWidth; - $[20] = t12; - } else { - t12 = $[20]; - } - let t13; - if ($[21] !== preview || $[22] !== previewWidth || $[23] !== query) { - t13 = p_7 => preview ? <>{truncatePathMiddle(p_7, previewWidth)}{preview.path !== p_7 ? " \xB7 loading\u2026" : ""}{preview.content.split("\n").map((line, i_1) => {highlightMatch(truncateToWidth(line, previewWidth), query)})} : ; - $[21] = preview; - $[22] = previewWidth; - $[23] = query; - $[24] = t13; - } else { - t13 = $[24]; - } - let t14; - if ($[25] !== handleOpen || $[26] !== onDone || $[27] !== results || $[28] !== t10 || $[29] !== t11 || $[30] !== t12 || $[31] !== t13 || $[32] !== t9 || $[33] !== visibleResults) { - t14 = ; - $[25] = handleOpen; - $[26] = onDone; - $[27] = results; - $[28] = t10; - $[29] = t11; - $[30] = t12; - $[31] = t13; - $[32] = t9; - $[33] = visibleResults; - $[34] = t14; - } else { - t14 = $[34]; - } - return t14; -} -function _temp6(q_0) { - return q_0 ? "No matching files" : "Start typing to search\u2026"; -} -function _temp5(p_3) { - return p_3; -} -function _temp4(p_0) { - return p_0.split(path.sep).join("/"); -} -function _temp3(p) { - return !p.endsWith(path.sep); -} -function _temp2(i_0) { - return i_0.displayText; -} -function _temp(i) { - return i.id.startsWith("file-"); + /> + ) } diff --git a/src/components/RemoteCallout.tsx b/src/components/RemoteCallout.tsx index 15da3a581..d6b0af589 100644 --- a/src/components/RemoteCallout.tsx +++ b/src/components/RemoteCallout.tsx @@ -1,47 +1,53 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import { isBridgeEnabled } from '../bridge/bridgeEnabled.js'; -import { Box, Text } from '../ink.js'; -import { getClaudeAIOAuthTokens } from '../utils/auth.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import type { OptionWithDescription } from './CustomSelect/select.js'; -import { Select } from './CustomSelect/select.js'; -import { PermissionDialog } from './permissions/PermissionDialog.js'; -type RemoteCalloutSelection = 'enable' | 'dismiss'; +import React, { useCallback, useEffect, useRef } from 'react' +import { isBridgeEnabled } from '../bridge/bridgeEnabled.js' +import { Box, Text } from '../ink.js' +import { getClaudeAIOAuthTokens } from '../utils/auth.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import type { OptionWithDescription } from './CustomSelect/select.js' +import { Select } from './CustomSelect/select.js' +import { PermissionDialog } from './permissions/PermissionDialog.js' + +type RemoteCalloutSelection = 'enable' | 'dismiss' + type Props = { - onDone: (selection: RemoteCalloutSelection) => void; -}; -export function RemoteCallout({ - onDone -}: Props): React.ReactNode { - const onDoneRef = useRef(onDone); - onDoneRef.current = onDone; + onDone: (selection: RemoteCalloutSelection) => void +} + +export function RemoteCallout({ onDone }: Props): React.ReactNode { + const onDoneRef = useRef(onDone) + onDoneRef.current = onDone + const handleCancel = useCallback((): void => { - onDoneRef.current('dismiss'); - }, []); + onDoneRef.current('dismiss') + }, []) // Permanently mark as seen on mount so it only shows once useEffect(() => { saveGlobalConfig(current => { - if (current.remoteDialogSeen) return current; - return { - ...current, - remoteDialogSeen: true - }; - }); - }, []); + if (current.remoteDialogSeen) return current + return { ...current, remoteDialogSeen: true } + }) + }, []) + const handleSelect = useCallback((value: RemoteCalloutSelection): void => { - onDoneRef.current(value); - }, []); - const options: OptionWithDescription[] = [{ - label: 'Enable Remote Control for this session', - description: 'Opens a secure connection to claude.ai.', - value: 'enable' - }, { - label: 'Never mind', - description: 'You can always enable it later with /remote-control.', - value: 'dismiss' - }]; - return + onDoneRef.current(value) + }, []) + + const options: OptionWithDescription[] = [ + { + label: 'Enable Remote Control for this session', + description: 'Opens a secure connection to claude.ai.', + value: 'enable', + }, + { + label: 'Never mind', + description: 'You can always enable it later with /remote-control.', + value: 'dismiss', + }, + ] + + return ( + @@ -56,20 +62,25 @@ export function RemoteCallout({ - - ; + + ) } /** * Check whether to show the remote callout (first-time dialog). */ export function shouldShowRemoteCallout(): boolean { - const config = getGlobalConfig(); - if (config.remoteDialogSeen) return false; - if (!isBridgeEnabled()) return false; - const tokens = getClaudeAIOAuthTokens(); - if (!tokens?.accessToken) return false; - return true; + const config = getGlobalConfig() + if (config.remoteDialogSeen) return false + if (!isBridgeEnabled()) return false + const tokens = getClaudeAIOAuthTokens() + if (!tokens?.accessToken) return false + return true } diff --git a/src/components/RemoteEnvironmentDialog.tsx b/src/components/RemoteEnvironmentDialog.tsx index ce1d98743..b0f47a60c 100644 --- a/src/components/RemoteEnvironmentDialog.tsx +++ b/src/components/RemoteEnvironmentDialog.tsx @@ -1,339 +1,237 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { toError } from '../utils/errors.js'; -import { logError } from '../utils/log.js'; -import { getSettingSourceName, type SettingSource } from '../utils/settings/constants.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import { getEnvironmentSelectionInfo } from '../utils/teleport/environmentSelection.js'; -import type { EnvironmentResource } from '../utils/teleport/environments.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/select.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { LoadingState } from './design-system/LoadingState.js'; -const DIALOG_TITLE = 'Select Remote Environment'; -const SETUP_HINT = `Configure environments at: https://claude.ai/code`; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { toError } from '../utils/errors.js' +import { logError } from '../utils/log.js' +import { + getSettingSourceName, + type SettingSource, +} from '../utils/settings/constants.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import { getEnvironmentSelectionInfo } from '../utils/teleport/environmentSelection.js' +import type { EnvironmentResource } from '../utils/teleport/environments.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/select.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { LoadingState } from './design-system/LoadingState.js' + +const DIALOG_TITLE = 'Select Remote Environment' +const SETUP_HINT = `Configure environments at: https://claude.ai/code` + type Props = { - onDone: (message?: string) => void; -}; -type LoadingState = 'loading' | 'updating' | null; -export function RemoteEnvironmentDialog(t0) { - const $ = _c(27); - const { - onDone - } = t0; - const [loadingState, setLoadingState] = useState("loading"); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [environments, setEnvironments] = useState(t1); - const [selectedEnvironment, setSelectedEnvironment] = useState(null); - const [selectedEnvironmentSource, setSelectedEnvironmentSource] = useState(null); - const [error, setError] = useState(null); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - let cancelled = false; - const fetchInfo = async function fetchInfo() { - ; - try { - const result = await getEnvironmentSelectionInfo(); - if (cancelled) { - return; - } - setEnvironments(result.availableEnvironments); - setSelectedEnvironment(result.selectedEnvironment); - setSelectedEnvironmentSource(result.selectedEnvironmentSource); - setLoadingState(null); - } catch (t4) { - const err = t4; - if (cancelled) { - return; - } - const fetchError = toError(err); - logError(fetchError); - setError(fetchError.message); - setLoadingState(null); - } - }; - fetchInfo(); - return () => { - cancelled = true; - }; - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - let t4; - if ($[3] !== environments || $[4] !== onDone) { - t4 = function handleSelect(value) { - if (value === "cancel") { - onDone(); - return; - } - setLoadingState("updating"); - const selectedEnv = environments.find(env => env.environment_id === value); - if (!selectedEnv) { - onDone("Error: Selected environment not found"); - return; + onDone: (message?: string) => void +} + +type LoadingState = 'loading' | 'updating' | null + +export function RemoteEnvironmentDialog({ onDone }: Props): React.ReactNode { + const [loadingState, setLoadingState] = useState('loading') + const [environments, setEnvironments] = useState([]) + const [selectedEnvironment, setSelectedEnvironment] = + useState(null) + const [selectedEnvironmentSource, setSelectedEnvironmentSource] = + useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function fetchInfo(): Promise { + try { + const result = await getEnvironmentSelectionInfo() + if (cancelled) return + setEnvironments(result.availableEnvironments) + setSelectedEnvironment(result.selectedEnvironment) + setSelectedEnvironmentSource(result.selectedEnvironmentSource) + setLoadingState(null) + } catch (err) { + if (cancelled) return + const fetchError = toError(err) + logError(fetchError) + setError(fetchError.message) + setLoadingState(null) } - updateSettingsForSource("localSettings", { - remote: { - defaultEnvironmentId: selectedEnv.environment_id - } - }); - onDone(`Set default remote environment to ${chalk.bold(selectedEnv.name)} (${selectedEnv.environment_id})`); - }; - $[3] = environments; - $[4] = onDone; - $[5] = t4; - } else { - t4 = $[5]; - } - const handleSelect = t4; - if (loadingState === "loading") { - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[6] = t5; - } else { - t5 = $[6]; } - let t6; - if ($[7] !== onDone) { - t6 = {t5}; - $[7] = onDone; - $[8] = t6; - } else { - t6 = $[8]; + void fetchInfo() + return () => { + cancelled = true } - return t6; - } - if (error) { - let t5; - if ($[9] !== error) { - t5 = Error: {error}; - $[9] = error; - $[10] = t5; - } else { - t5 = $[10]; + }, []) + + function handleSelect(value: string): void { + if (value === 'cancel') { + onDone() + return } - let t6; - if ($[11] !== onDone || $[12] !== t5) { - t6 = {t5}; - $[11] = onDone; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; + + setLoadingState('updating') + + const selectedEnv = environments.find(env => env.environment_id === value) + + if (!selectedEnv) { + onDone('Error: Selected environment not found') + return } - return t6; - } + + updateSettingsForSource('localSettings', { + remote: { + defaultEnvironmentId: selectedEnv.environment_id, + }, + }) + + onDone( + `Set default remote environment to ${chalk.bold(selectedEnv.name)} (${selectedEnv.environment_id})`, + ) + } + + // Loading state + if (loadingState === 'loading') { + return ( + + + + ) + } + + // Error state + if (error) { + return ( + + Error: {error} + + ) + } + + // No environments available if (!selectedEnvironment) { - let t5; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t5 = No remote environments available.; - $[14] = t5; - } else { - t5 = $[14]; - } - let t6; - if ($[15] !== onDone) { - t6 = {t5}; - $[15] = onDone; - $[16] = t6; - } else { - t6 = $[16]; - } - return t6; - } + return ( + + No remote environments available. + + ) + } + + // Single environment - just show info if (environments.length === 1) { - let t5; - if ($[17] !== onDone || $[18] !== selectedEnvironment) { - t5 = ; - $[17] = onDone; - $[18] = selectedEnvironment; - $[19] = t5; - } else { - t5 = $[19]; - } - return t5; - } - let t5; - if ($[20] !== environments || $[21] !== handleSelect || $[22] !== loadingState || $[23] !== onDone || $[24] !== selectedEnvironment || $[25] !== selectedEnvironmentSource) { - t5 = ; - $[20] = environments; - $[21] = handleSelect; - $[22] = loadingState; - $[23] = onDone; - $[24] = selectedEnvironment; - $[25] = selectedEnvironmentSource; - $[26] = t5; - } else { - t5 = $[26]; - } - return t5; + return ( + + ) + } + + // Multiple environments - show selection UI + return ( + + ) } -function EnvironmentLabel(t0) { - const $ = _c(7); - const { - environment - } = t0; - let t1; - if ($[0] !== environment.name) { - t1 = {environment.name}; - $[0] = environment.name; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== environment.environment_id) { - t2 = ({environment.environment_id}); - $[2] = environment.environment_id; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1 || $[5] !== t2) { - t3 = {figures.tick} Using {t1}{" "}{t2}; - $[4] = t1; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; -} -function SingleEnvironmentContent(t0) { - const $ = _c(6); - const { - environment, - onDone - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:yes", onDone, t1); - let t2; - if ($[1] !== environment) { - t2 = ; - $[1] = environment; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onDone || $[4] !== t2) { - t3 = {t2}; - $[3] = onDone; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; + +function EnvironmentLabel({ + environment, +}: { + environment: EnvironmentResource +}): React.ReactNode { + return ( + + {figures.tick} Using {environment.name}{' '} + ({environment.environment_id}) + + ) } -function MultipleEnvironmentsContent(t0) { - const $ = _c(18); - const { - environments, - selectedEnvironment, - selectedEnvironmentSource, - loadingState, - onSelect, - onCancel - } = t0; - let t1; - if ($[0] !== selectedEnvironmentSource) { - t1 = selectedEnvironmentSource && selectedEnvironmentSource !== "localSettings" ? ` (from ${getSettingSourceName(selectedEnvironmentSource)} settings)` : ""; - $[0] = selectedEnvironmentSource; - $[1] = t1; - } else { - t1 = $[1]; - } - const sourceSuffix = t1; - let t2; - if ($[2] !== selectedEnvironment.name) { - t2 = {selectedEnvironment.name}; - $[2] = selectedEnvironment.name; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== sourceSuffix || $[5] !== t2) { - t3 = Currently using: {t2}{sourceSuffix}; - $[4] = sourceSuffix; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - const subtitle = t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {SETUP_HINT}; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== environments || $[9] !== loadingState || $[10] !== onSelect || $[11] !== selectedEnvironment.environment_id) { - t5 = loadingState === "updating" ? : ({ + label: ( + + {env.name} ({env.environment_id}) + + ), + value: env.environment_id, + }))} + defaultValue={selectedEnvironment.environment_id} + onChange={onSelect} + onCancel={() => onSelect('cancel')} + layout="compact-vertical" + /> + )} + + + + + + + + ) } diff --git a/src/components/ResumeTask.tsx b/src/components/ResumeTask.tsx index b8f5e8241..8b657ab0d 100644 --- a/src/components/ResumeTask.tsx +++ b/src/components/ResumeTask.tsx @@ -1,122 +1,139 @@ -import React, { useCallback, useState } from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { type CodeSession, fetchCodeSessionsFromSessionsAPI } from 'src/utils/teleport/api.js'; +import React, { useCallback, useState } from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { + type CodeSession, + fetchCodeSessionsFromSessionsAPI, +} from 'src/utils/teleport/api.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow list navigation -import { Box, Text, useInput } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { logForDebugging } from '../utils/debug.js'; -import { detectCurrentRepository } from '../utils/detectRepository.js'; -import { formatRelativeTime } from '../utils/format.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/index.js'; -import { Byline } from './design-system/Byline.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Spinner } from './Spinner.js'; -import { TeleportError } from './TeleportError.js'; +import { Box, Text, useInput } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { logForDebugging } from '../utils/debug.js' +import { detectCurrentRepository } from '../utils/detectRepository.js' +import { formatRelativeTime } from '../utils/format.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/index.js' +import { Byline } from './design-system/Byline.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Spinner } from './Spinner.js' +import { TeleportError } from './TeleportError.js' + type Props = { - onSelect: (session: CodeSession) => void; - onCancel: () => void; - isEmbedded?: boolean; -}; -type LoadErrorType = 'network' | 'auth' | 'api' | 'other'; -const UPDATED_STRING = 'Updated'; -const SPACE_BETWEEN_TABLE_COLUMNS = ' '; + onSelect: (session: CodeSession) => void + onCancel: () => void + isEmbedded?: boolean +} + +type LoadErrorType = 'network' | 'auth' | 'api' | 'other' + +const UPDATED_STRING = 'Updated' +const SPACE_BETWEEN_TABLE_COLUMNS = ' ' + export function ResumeTask({ onSelect, onCancel, - isEmbedded = false + isEmbedded = false, }: Props): React.ReactNode { - const { - rows - } = useTerminalSize(); - const [sessions, setSessions] = useState([]); - const [currentRepo, setCurrentRepo] = useState(null); - const [loading, setLoading] = useState(true); - const [loadErrorType, setLoadErrorType] = useState(null); - const [retrying, setRetrying] = useState(false); - const [hasCompletedTeleportErrorFlow, setHasCompletedTeleportErrorFlow] = useState(false); + const { rows } = useTerminalSize() + const [sessions, setSessions] = useState([]) + const [currentRepo, setCurrentRepo] = useState(null) + + const [loading, setLoading] = useState(true) + const [loadErrorType, setLoadErrorType] = useState(null) + const [retrying, setRetrying] = useState(false) + + const [hasCompletedTeleportErrorFlow, setHasCompletedTeleportErrorFlow] = + useState(false) // Track focused index for scroll position display in title - const [focusedIndex, setFocusedIndex] = useState(1); - const escKey = useShortcutDisplay('confirm:no', 'Confirmation', 'Esc'); + const [focusedIndex, setFocusedIndex] = useState(1) + + const escKey = useShortcutDisplay('confirm:no', 'Confirmation', 'Esc') + const loadSessions = useCallback(async () => { try { - setLoading(true); - setLoadErrorType(null); + setLoading(true) + setLoadErrorType(null) // Detect current repository - const detectedRepo = await detectCurrentRepository(); - setCurrentRepo(detectedRepo); - logForDebugging(`Current repository: ${detectedRepo || 'not detected'}`); - const codeSessions = await fetchCodeSessionsFromSessionsAPI(); + const detectedRepo = await detectCurrentRepository() + setCurrentRepo(detectedRepo) + logForDebugging(`Current repository: ${detectedRepo || 'not detected'}`) + + const codeSessions = await fetchCodeSessionsFromSessionsAPI() // Filter sessions by current repository if detected - let filteredSessions = codeSessions; + let filteredSessions = codeSessions if (detectedRepo) { filteredSessions = codeSessions.filter(session => { - if (!session.repo) return false; - const sessionRepo = `${session.repo.owner.login}/${session.repo.name}`; - return sessionRepo === detectedRepo; - }); - logForDebugging(`Filtered ${filteredSessions.length} sessions for repo ${detectedRepo} from ${codeSessions.length} total`); + if (!session.repo) return false + const sessionRepo = `${session.repo.owner.login}/${session.repo.name}` + return sessionRepo === detectedRepo + }) + logForDebugging( + `Filtered ${filteredSessions.length} sessions for repo ${detectedRepo} from ${codeSessions.length} total`, + ) } // Sort by updated_at (newest first) const sortedSessions = [...filteredSessions].sort((a, b) => { - const dateA = new Date(a.updated_at); - const dateB = new Date(b.updated_at); - return dateB.getTime() - dateA.getTime(); - }); - setSessions(sortedSessions); + const dateA = new Date(a.updated_at) + const dateB = new Date(b.updated_at) + return dateB.getTime() - dateA.getTime() + }) + + setSessions(sortedSessions) } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - logForDebugging(`Error loading code sessions: ${errorMessage}`); - setLoadErrorType(determineErrorType(errorMessage)); + const errorMessage = err instanceof Error ? err.message : String(err) + logForDebugging(`Error loading code sessions: ${errorMessage}`) + setLoadErrorType(determineErrorType(errorMessage)) } finally { - setLoading(false); - setRetrying(false); + setLoading(false) + setRetrying(false) } - }, []); + }, []) + const handleRetry = () => { - setRetrying(true); - void loadSessions(); - }; + setRetrying(true) + void loadSessions() + } // Handle escape via keybinding - useKeybinding('confirm:no', onCancel, { - context: 'Confirmation' - }); + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + useInput((input, key) => { // We need to handle ctrl+c in case we don't render a { - const session_1 = sessions.find(s => s.id === value); - if (session_1) { - onSelect(session_1); - } - }} onFocus={value_0 => { - const index = options.findIndex(o => o.value === value_0); - if (index >= 0) { - setFocusedIndex(index + 1); - } - }} /> + { - isDirty.current = true; - setShowSubmenu(null); - setTabsHidden(false); - saveGlobalConfig(current_24 => ({ - ...current_24, - autoUpdates: true - })); - setGlobalConfig({ - ...getGlobalConfig(), - autoUpdates: true - }); - updateSettingsForSource('userSettings', { - autoUpdatesChannel: channel as 'latest' | 'stable', - minimumVersion: undefined - }); - setSettingsData(prev_26 => ({ - ...prev_26, - autoUpdatesChannel: channel as 'latest' | 'stable', - minimumVersion: undefined - })); - logEvent('tengu_autoupdate_enabled', { - channel: channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }} />} - : showSubmenu === 'ChannelDowngrade' ? { - setShowSubmenu(null); - setTabsHidden(false); - if (choice === 'cancel') { - // User cancelled - don't change anything - return; - } - isDirty.current = true; - // Switch to stable channel - const newSettings: { - autoUpdatesChannel: 'stable'; - minimumVersion?: string; - } = { - autoUpdatesChannel: 'stable' - }; - if (choice === 'stay') { - // User wants to stay on current version until stable catches up - newSettings.minimumVersion = MACRO.VERSION; - } - updateSettingsForSource('userSettings', newSettings); - setSettingsData(prev_27 => ({ - ...prev_27, - ...newSettings - })); - logEvent('tengu_autoupdate_channel_changed', { - channel: 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - minimum_version_set: choice === 'stay' - }); - }} /> : - + + )} + + ) : ( + ; - $[20] = onInputModeToggle; - $[21] = options; - $[22] = t6; - $[23] = t7; - $[24] = t8; - $[25] = t9; - } else { - t9 = $[25]; - } - let t10; - if ($[26] !== t5 || $[27] !== t9) { - t10 = {t5}{t9}; - $[26] = t5; - $[27] = t9; - $[28] = t10; - } else { - t10 = $[28]; - } - const t11 = (focusedOption === "yes" && !yesInputMode || focusedOption === "no" && !noInputMode) && " \xB7 Tab to amend"; - let t12; - if ($[29] !== t11) { - t12 = Esc to cancel{t11}; - $[29] = t11; - $[30] = t12; - } else { - t12 = $[30]; - } - let t13; - if ($[31] !== t1 || $[32] !== t10 || $[33] !== t12 || $[34] !== t2) { - t13 = {t1}{t2}{t3}{t10}{t12}; - $[31] = t1; - $[32] = t10; - $[33] = t12; - $[34] = t2; - $[35] = t13; - } else { - t13 = $[35]; - } - return t13; + filePath: string + input: A + onChange: (option: PermissionOption, args: A, feedback?: string) => void + options: PermissionOptionWithLabel[] + ideName: string + symlinkTarget?: string | null + rejectFeedback: string + acceptFeedback: string + setFocusedOption: (value: string) => void + onInputModeToggle: (value: string) => void + focusedOption: string + yesInputMode: boolean + noInputMode: boolean +} + +export function ShowInIDEPrompt({ + onChange, + options, + input, + filePath, + ideName, + symlinkTarget, + rejectFeedback, + acceptFeedback, + setFocusedOption, + onInputModeToggle, + focusedOption, + yesInputMode, + noInputMode, +}: Props): React.ReactNode { + return ( + + + + Opened changes in {ideName} ⧉ + + {symlinkTarget && ( + + {relative(getCwd(), symlinkTarget).startsWith('..') + ? `This will modify ${symlinkTarget} (outside working directory) via a symlink` + : `Symlink target: ${symlinkTarget}`} + + )} + {isSupportedVSCodeTerminal() && ( + Save file to continue… + )} + + + Do you want to make this edit to{' '} + {basename(filePath)}? + + ; - $[17] = t10; - } else { - t10 = $[17]; - } - return t10; + case 'needsGitStash': + return ( + + ) + + case 'needsLogin': { + if (isLoggingIn) { + return ( + + ) } + + return ( + + + Teleport requires a Claude.ai account. + + Your Claude Pro/Max subscription will be used by Claude Code. + + + void handleChange(value_0)} />} : {errorMessage && {errorMessage}}Run claude --teleport from a checkout of {targetRepo}; - $[8] = availablePaths.length; - $[9] = errorMessage; - $[10] = handleChange; - $[11] = options; - $[12] = targetRepo; - $[13] = validating; - $[14] = t3; - } else { - t3 = $[14]; - } - let t4; - if ($[15] !== onCancel || $[16] !== t3) { - t4 = {t3}; - $[15] = onCancel; - $[16] = t3; - $[17] = t4; - } else { - t4 = $[17]; - } - return t4; -} -function _temp(path) { - return { - label: Use {getDisplayPath(path)}, - value: path - }; + + // Path is invalid - remove it from config and update state + removePathFromRepo(targetRepo, value) + const updatedPaths = availablePaths.filter(p => p !== value) + setAvailablePaths(updatedPaths) + setValidating(false) + + setErrorMessage( + `${getDisplayPath(value)} no longer contains the correct repository. Select another path.`, + ) + }, + [targetRepo, availablePaths, onSelectPath, onCancel], + ) + + const options = [ + ...availablePaths.map(path => ({ + label: ( + + Use {getDisplayPath(path)} + + ), + value: path, + })), + { label: 'Cancel', value: 'cancel' }, + ] + + return ( + + {availablePaths.length > 0 ? ( + <> + + {errorMessage && {errorMessage}} + + Open Claude Code in {targetRepo}: + + + + {validating ? ( + + + Validating repository… + + ) : ( + } - ; + + ) : ( + ; - $[25] = t15; - $[26] = t16; - $[27] = t17; - $[28] = themeSetting; - $[29] = t18; - } else { - t18 = $[29]; - } - let t19; - if ($[30] !== t11 || $[31] !== t14 || $[32] !== t18) { - t19 = {t11}{t14}{t18}; - $[30] = t11; - $[31] = t14; - $[32] = t18; - $[33] = t19; - } else { - t19 = $[33]; - } - let t20; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t20 = { - oldStart: 1, - newStart: 1, - oldLines: 3, - newLines: 3, - lines: [" function greet() {", "- console.log(\"Hello, World!\");", "+ console.log(\"Hello, Claude!\");", " }"] - }; - $[34] = t20; - } else { - t20 = $[34]; - } - let t21; - if ($[35] !== columns) { - t21 = ; - $[35] = columns; - $[36] = t21; - } else { - t21 = $[36]; - } - const t22 = colorModuleUnavailableReason === "env" ? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` : syntaxHighlightingDisabled ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` : syntaxTheme ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ""} (${syntaxToggleShortcut} to disable)` : `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`; - let t23; - if ($[37] !== t22) { - t23 = {" "}{t22}; - $[37] = t22; - $[38] = t23; - } else { - t23 = $[38]; - } - let t24; - if ($[39] !== t21 || $[40] !== t23) { - t24 = {t21}{t23}; - $[39] = t21; - $[40] = t23; - $[41] = t24; - } else { - t24 = $[41]; - } - let t25; - if ($[42] !== t19 || $[43] !== t24) { - t25 = {t19}{t24}; - $[42] = t19; - $[43] = t24; - $[44] = t25; - } else { - t25 = $[44]; - } - const content = t25; + }, + { context: 'ThemePicker' }, + ) + // Always call the hook to follow React rules, but conditionally assign the exit handler + const exitState = useExitOnCtrlCDWithKeybindings( + skipExitHandling ? () => {} : undefined, + ) + + const themeOptions: { label: string; value: ThemeSetting }[] = [ + ...(feature('AUTO_THEME') + ? [{ label: 'Auto (match terminal)', value: 'auto' as const }] + : []), + { label: 'Dark mode', value: 'dark' }, + { label: 'Light mode', value: 'light' }, + { + label: 'Dark mode (colorblind-friendly)', + value: 'dark-daltonized', + }, + { + label: 'Light mode (colorblind-friendly)', + value: 'light-daltonized', + }, + { + label: 'Dark mode (ANSI colors only)', + value: 'dark-ansi', + }, + { + label: 'Light mode (ANSI colors only)', + value: 'light-ansi', + }, + ] + + const content = ( + + + {showIntroText ? ( + Let's get started. + ) : ( + + Theme + + )} + + + Choose the text style that looks best with your terminal + + {helpText && !showHelpTextBelow && {helpText}} + + }; - $[15] = confirmationPending; - $[16] = currentValue; - $[17] = handleSelectChange; - $[18] = onCancel; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== confirmationPending || $[21] !== exitState.keyName || $[22] !== exitState.pending) { - t10 = {exitState.pending ? <>Press {exitState.keyName} again to exit : confirmationPending !== null ? : }; - $[20] = confirmationPending; - $[21] = exitState.keyName; - $[22] = exitState.pending; - $[23] = t10; - } else { - t10 = $[23]; - } - let t11; - if ($[24] !== t10 || $[25] !== t9) { - t11 = {t9}{t10}; - $[24] = t10; - $[25] = t9; - $[26] = t11; - } else { - t11 = $[26]; + }, + { context: 'Confirmation', isActive: confirmationPending !== null }, + ) + + function handleSelectChange(value: string): void { + const selected = value === 'true' + if (isMidConversation && selected !== currentValue) { + setConfirmationPending(selected) + } else { + onSelect(selected) + } } - return t11; + + return ( + + + + + Toggle thinking mode + + Enable or disable thinking for this session. + + + {confirmationPending !== null ? ( + + + Changing thinking mode mid-conversation will increase latency and + may reduce quality. For best results, set this at the start of a + session. + + Do you want to proceed? + + ) : ( + + onChange(value_0 as 'enable_all' | 'exit')} onCancel={() => onChange("exit")} />; - $[25] = onChange; - $[26] = t21; - } else { - t21 = $[26]; - } - let t22; - if ($[27] !== exitState.keyName || $[28] !== exitState.pending) { - t22 = {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to cancel}; - $[27] = exitState.keyName; - $[28] = exitState.pending; - $[29] = t22; - } else { - t22 = $[29]; - } - let t23; - if ($[30] !== t21 || $[31] !== t22) { - t23 = {t16}{t17}{t18}{t19}{t21}{t22}; - $[30] = t21; - $[31] = t22; - $[32] = t23; - } else { - t23 = $[32]; - } - return t23; + onDone(): void + commands?: Command[] } -function _temp7() { - gracefulShutdownSync(0); -} -function _temp6() { - return gracefulShutdownSync(1); -} -function _temp5(current) { - return { - ...current, - hasTrustDialogAccepted: true - }; -} -function _temp4(command_0) { - return command_0.type === "prompt" && (command_0.loadedFrom === "skills" || command_0.loadedFrom === "plugin") && (command_0.source === "projectSettings" || command_0.source === "localSettings" || command_0.source === "plugin") && command_0.allowedTools?.some(_temp3); -} -function _temp3(tool_0) { - return tool_0 === BASH_TOOL_NAME || tool_0.startsWith(BASH_TOOL_NAME + "("); -} -function _temp2(command) { - return command.type === "prompt" && command.loadedFrom === "commands_DEPRECATED" && (command.source === "projectSettings" || command.source === "localSettings") && command.allowedTools?.some(_temp); -} -function _temp(tool) { - return tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + "("); + +export function TrustDialog({ onDone, commands }: Props): React.ReactNode { + const { servers: projectServers } = getMcpConfigsByScope('project') + + // In all cases, we generally check only the project-level and + // project-local-level settings, which we assume that users do not configure + // directly compared to user-level settings. + + // Check for MCPs + const hasMcpServers = Object.keys(projectServers).length > 0 + // Check for hooks + const hooksSettingSources = getHooksSources() + const hasHooks = hooksSettingSources.length > 0 + // Check whether code execution is allowed in permissions and slash commands + const bashSettingSources = getBashPermissionSources() + // Check for apiKeyHelper which executes arbitrary commands + const apiKeyHelperSources = getApiKeyHelperSources() + const hasApiKeyHelper = apiKeyHelperSources.length > 0 + // Check for AWS commands which execute arbitrary commands + const awsCommandsSources = getAwsCommandsSources() + const hasAwsCommands = awsCommandsSources.length > 0 + // Check for GCP commands which execute arbitrary commands + const gcpCommandsSources = getGcpCommandsSources() + const hasGcpCommands = gcpCommandsSources.length > 0 + // Check for otelHeadersHelper which executes arbitrary commands + const otelHeadersHelperSources = getOtelHeadersHelperSources() + const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0 + // Check for dangerous environment variables (not in SAFE_ENV_VARS) + const dangerousEnvVarsSources = getDangerousEnvVarsSources() + const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0 + + const hasSlashCommandBash = + commands?.some( + command => + command.type === 'prompt' && + command.loadedFrom === 'commands_DEPRECATED' && + (command.source === 'projectSettings' || + command.source === 'localSettings') && + command.allowedTools?.some( + (tool: string) => + tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('), + ), + ) ?? false + + const hasSkillsBash = + commands?.some( + command => + command.type === 'prompt' && + (command.loadedFrom === 'skills' || command.loadedFrom === 'plugin') && + (command.source === 'projectSettings' || + command.source === 'localSettings' || + command.source === 'plugin') && + command.allowedTools?.some( + (tool: string) => + tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('), + ), + ) ?? false + + const hasAnyBashExecution = + bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash + + const hasTrustDialogAccepted = checkHasTrustDialogAccepted() + + React.useEffect(() => { + const isHomeDir = homedir() === getCwd() + logEvent('tengu_trust_dialog_shown', { + isHomeDir, + hasMcpServers, + hasHooks, + hasBashExecution: hasAnyBashExecution, + hasApiKeyHelper, + hasAwsCommands, + hasGcpCommands, + hasOtelHeadersHelper, + hasDangerousEnvVars, + }) + }, [ + hasMcpServers, + hasHooks, + hasAnyBashExecution, + hasApiKeyHelper, + hasAwsCommands, + hasGcpCommands, + hasOtelHeadersHelper, + hasDangerousEnvVars, + ]) + + function onChange(value: 'enable_all' | 'exit') { + if (value === 'exit') { + gracefulShutdownSync(1) + return + } + + const isHomeDir = homedir() === getCwd() + + logEvent('tengu_trust_dialog_accept', { + isHomeDir, + hasMcpServers, + hasHooks, + hasBashExecution: hasAnyBashExecution, + hasApiKeyHelper, + hasAwsCommands, + hasGcpCommands, + hasOtelHeadersHelper, + hasDangerousEnvVars, + }) + + if (isHomeDir) { + // For home directory, store trust in session memory only (not persisted to disk) + // This allows hooks and other trust-requiring features to work during this session + // while preserving the security intent of not permanently trusting home dir + setSessionTrustAccepted(true) + } else { + saveCurrentProjectConfig(current => ({ + ...current, + hasTrustDialogAccepted: true, + })) + } + + // Do NOT write MCP server settings here. handleMcpjsonServerApprovals in + // interactiveHelpers.tsx runs right after this dialog and shows the per-server approval + // UI. Writing enabledMcpjsonServers/enableAllProjectMcpServers here would + // mark every server 'approved' and silently skip that dialog. See #15558. + + onDone() + } + + // Default onExit is useApp().exit() → Ink.unmount(), which tears down the + // React tree but never calls onDone(). showSetupScreens() in + // interactiveHelpers.tsx awaits a Promise that only resolves via onDone, + // so the default would hang the await forever. With keybinding + // customization enabled, the chokidar watcher (persistent: true) keeps the + // event loop alive and the process freezes. Explicitly exit 1 like "No". + const exitState = useExitOnCtrlCDWithKeybindings(() => + gracefulShutdownSync(1), + ) + + // Use configurable keybinding for ESC to cancel/exit + useKeybinding( + 'confirm:no', + () => { + gracefulShutdownSync(0) + }, + { context: 'Confirmation' }, + ) + + // Automatically resolve the trust dialog if there is nothing to be shown. + if (hasTrustDialogAccepted) { + setTimeout(onDone) + return null + } + + return ( + + + {getFsImplementation().cwd()} + + + Quick safety check: Is this a project you created or one you trust? + (Like your own code, a well-known open source project, or work from + your team). If not, take a moment to review what{"'"}s in this folder + first. + + + Claude Code{"'"}ll be able to read, edit, and execute files here. + + + + + Security guide + + + + - ; + + const removeDescription = + hasUncommitted || hasCommits + ? 'All changes and commits will be lost.' + : 'Clean up the worktree directory.' + + const hasTmuxSession = Boolean(worktreeSession.tmuxSessionName) + + const options = hasTmuxSession + ? [ + { + label: 'Keep worktree and tmux session', + value: 'keep-with-tmux', + description: `Stays at ${worktreeSession.worktreePath}. Reattach with: tmux attach -t ${worktreeSession.tmuxSessionName}`, + }, + { + label: 'Keep worktree, kill tmux session', + value: 'keep-kill-tmux', + description: `Keeps worktree at ${worktreeSession.worktreePath}, terminates tmux session.`, + }, + { + label: 'Remove worktree and tmux session', + value: 'remove-with-tmux', + description: removeDescription, + }, + ] + : [ + { + label: 'Keep worktree', + value: 'keep', + description: `Stays at ${worktreeSession.worktreePath}`, + }, + { + label: 'Remove worktree', + value: 'remove', + description: removeDescription, + }, + ] + + const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep' + + return ( + + ; - $[26] = handleCancel; - $[27] = t11; - $[28] = t12; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== handleCancel || $[31] !== t13 || $[32] !== t8) { - t14 = {t8}{t13}; - $[30] = handleCancel; - $[31] = t13; - $[32] = t8; - $[33] = t14; - } else { - t14 = $[33]; - } - return t14; -} -function _temp(exitState) { - return exitState.pending ? Press {exitState.keyName} again to exit : ; + case 'defer': + logEvent('tengu_grove_policy_dismissed', { + state: true, + }) + break + case 'escape': + logEvent('tengu_grove_policy_escaped', {}) + break + } + + onDone(value) + } + + const acceptOptions = groveConfig?.domain_excluded + ? [ + { + label: + 'Accept terms · Help improve Claude: OFF (for emails with your domain)', + value: 'accept_opt_out', + }, + ] + : [ + { + label: 'Accept terms · Help improve Claude: ON', + value: 'accept_opt_in', + }, + { + label: 'Accept terms · Help improve Claude: OFF', + value: 'accept_opt_out', + }, + ] + + function handleCancel(): void { + if (groveConfig?.notice_is_grace_period) { + void onChange('defer') + return + } + void onChange('escape') + } + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + + + + ) + } + > + + + {groveConfig?.notice_is_grace_period ? ( + + ) : ( + + )} + + + {NEW_TERMS_ASCII} + + + + + + Please select how you'd like to continue + Your choice takes effect immediately upon confirmation. + + + ; - $[7] = options; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== request.message || $[11] !== t3 || $[12] !== t5 || $[13] !== title) { - t6 = {t5}; - $[10] = request.message; - $[11] = t3; - $[12] = t5; - $[13] = title; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + title: string + toolInputSummary?: string | null + request: PromptRequest + onRespond: (key: string) => void + onAbort: () => void } -function _temp(opt) { - return { + +export function PromptDialog({ + title, + toolInputSummary, + request, + onRespond, + onAbort, +}: Props): React.ReactNode { + useKeybinding('app:interrupt', onAbort, { isActive: true }) + + const options = request.options.map(opt => ({ label: opt.label, value: opt.key, - description: opt.description - }; + description: opt.description, + })) + + return ( + {toolInputSummary} : undefined + } + > + + ; - $[12] = onCancel; - $[13] = t4; - $[14] = t6; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t2 || $[17] !== t7) { - t8 = {t2}{t3}{t7}; - $[16] = t2; - $[17] = t7; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== onCancel || $[20] !== subtitle || $[21] !== t8) { - t9 = {t8}; - $[19] = onCancel; - $[20] = subtitle; - $[21] = t8; - $[22] = t9; - } else { - t9 = $[22]; - } - return t9; + hookEventMetadata: Record + hooksByEvent: Partial> + totalHooksCount: number + restrictedByPolicy: boolean + onSelectEvent: (event: HookEvent) => void + onCancel: () => void +} + +export function SelectEventMode({ + hookEventMetadata, + hooksByEvent, + totalHooksCount, + restrictedByPolicy, + onSelectEvent, + onCancel, +}: Props): React.ReactNode { + const subtitle = `${totalHooksCount} ${plural(totalHooksCount, 'hook')} configured` + + return ( + + + {restrictedByPolicy && ( + + + {figures.info} Hooks Restricted by Policy + + + Only hooks from managed settings can run. User-defined hooks from + ~/.claude/settings.json, .claude/settings.json, and + .claude/settings.local.json are blocked. + + + )} + + + + {figures.info} This menu is read-only. To add or modify hooks, edit + settings.json directly or ask Claude.{' '} + Learn more + + + + + ; - $[10] = onCancel; - $[11] = t2; - $[12] = t3; - $[13] = t4; - } else { - t4 = $[13]; + return ( + Esc to go back} + > + + No hooks configured for this event. + + To add hooks, edit settings.json directly or ask Claude. + + + + ) } - let t5; - if ($[14] !== hookEventMetadata.description || $[15] !== onCancel || $[16] !== t4 || $[17] !== title) { - t5 = {t4}; - $[14] = hookEventMetadata.description; - $[15] = onCancel; - $[16] = t4; - $[17] = title; - $[18] = t5; - } else { - t5 = $[18]; - } - return t5; -} -function _temp2(hook, index) { - return { - label: `[${hook.config.type}] ${getHookDisplayText(hook.config)}`, - value: index.toString(), - description: hook.source === "pluginHook" && hook.pluginName ? `${hookSourceHeaderDisplayString(hook.source)} (${hook.pluginName})` : hookSourceHeaderDisplayString(hook.source) - }; -} -function _temp() { - return Esc to go back; + + return ( + + + ; - $[16] = onCancel; - $[17] = t3; - $[18] = t4; - $[19] = t5; - } else { - t5 = $[19]; - } - let t6; - if ($[20] !== eventDescription || $[21] !== onCancel || $[22] !== t2 || $[23] !== t5) { - t6 = {t5}; - $[20] = eventDescription; - $[21] = onCancel; - $[22] = t2; - $[23] = t5; - $[24] = t6; - } else { - t6 = $[24]; - } - return t6; -} -function _temp3(item) { - const sourceText = item.sources.map(hookSourceInlineDisplayString).join(", "); - const matcherLabel = item.matcher || "(all)"; - return { - label: `[${sourceText}] ${matcherLabel}`, - value: item.matcher, - description: `${item.hookCount} ${plural(item.hookCount, "hook")}` - }; -} -function _temp2() { - return Esc to go back; -} -function _temp(h) { - return h.source; + + return ( + + + ; - $[48] = initialPath; - $[49] = memoryOptions; - $[50] = onCancel; - $[51] = t20; - $[52] = t21; - $[53] = toggleFocused; - $[54] = t22; - } else { - t22 = $[54]; + + memoryOptions.push(...folderOptions) + + // Initialize with last selected path if it's still in the options, otherwise use first option + const initialPath = + lastSelectedPath && + memoryOptions.some(opt => opt.value === lastSelectedPath) + ? lastSelectedPath + : memoryOptions[0]?.value || '' + + // Toggle state (local copy of settings so the UI updates immediately) + const [autoMemoryOn, setAutoMemoryOn] = useState(isAutoMemoryEnabled) + const [autoDreamOn, setAutoDreamOn] = useState(isAutoDreamEnabled) + + // Dream row is only meaningful when auto-memory is on (dream consolidates + // that dir). Snapshot at mount so the row doesn't vanish mid-navigation + // if the user toggles auto-memory off. + const [showDreamRow] = useState(isAutoMemoryEnabled) + + // Dream status: prefer live task state (this session fired it), fall back + // to the cross-process lock mtime. + const isDreamRunning = useAppState(s => + Object.values(s.tasks).some( + t => t.type === 'dream' && t.status === 'running', + ), + ) + const [lastDreamAt, setLastDreamAt] = useState(null) + useEffect(() => { + if (!showDreamRow) return + void readLastConsolidatedAt().then(setLastDreamAt) + }, [showDreamRow, isDreamRunning]) + + const dreamStatus = isDreamRunning + ? 'running' + : lastDreamAt === null + ? '' // stat in flight + : lastDreamAt === 0 + ? 'never' + : `last ran ${formatRelativeTimeAgo(new Date(lastDreamAt))}` + + // null = Select has focus, 0 = auto-memory, 1 = auto-dream (if showDreamRow) + const [focusedToggle, setFocusedToggle] = useState(null) + const toggleFocused = focusedToggle !== null + const lastToggleIndex = showDreamRow ? 1 : 0 + + function handleToggleAutoMemory(): void { + const newValue = !autoMemoryOn + updateSettingsForSource('userSettings', { autoMemoryEnabled: newValue }) + setAutoMemoryOn(newValue) + logEvent('tengu_auto_memory_toggled', { enabled: newValue }) } - let t23; - if ($[55] !== t19 || $[56] !== t22) { - t23 = {t19}{t22}; - $[55] = t19; - $[56] = t22; - $[57] = t23; - } else { - t23 = $[57]; + + function handleToggleAutoDream(): void { + const newValue = !autoDreamOn + updateSettingsForSource('userSettings', { autoDreamEnabled: newValue }) + setAutoDreamOn(newValue) + logEvent('tengu_auto_dream_toggled', { enabled: newValue }) } - return t23; -} -function _temp8() {} -function _temp7(prev_0) { - return prev_0 !== null && prev_0 > 0 ? prev_0 - 1 : prev_0; -} -function _temp6(s_0) { - return Object.values(s_0.tasks).some(_temp5); -} -function _temp5(t) { - return t.type === "dream" && t.status === "running"; -} -function _temp4(opt) { - return opt.value === lastSelectedPath; -} -function _temp3(s) { - return s.agentDefinitions; -} -function _temp2(f_2) { - return { - ...f_2, - exists: true - }; -} -function _temp(f_1) { - return f_1.type !== "AutoMem" && f_1.type !== "TeamMem"; + + useExitOnCtrlCDWithKeybindings() + + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + + useKeybinding( + 'confirm:yes', + () => { + if (focusedToggle === 0) handleToggleAutoMemory() + else if (focusedToggle === 1) handleToggleAutoDream() + }, + { context: 'Confirmation', isActive: toggleFocused }, + ) + useKeybinding( + 'select:next', + () => { + setFocusedToggle(prev => + prev !== null && prev < lastToggleIndex ? prev + 1 : null, + ) + }, + { context: 'Select', isActive: toggleFocused }, + ) + useKeybinding( + 'select:previous', + () => { + setFocusedToggle(prev => (prev !== null && prev > 0 ? prev - 1 : prev)) + }, + { context: 'Select', isActive: toggleFocused }, + ) + + return ( + + + + Auto-memory: {autoMemoryOn ? 'on' : 'off'} + + {showDreamRow && ( + + + Auto-dream: {autoDreamOn ? 'on' : 'off'} + {dreamStatus && · {dreamStatus}} + {!isDreamRunning && autoDreamOn && ( + · /dream to run + )} + + + )} + + + ; - $[34] = focusNodeId; - $[35] = handleChange; - $[36] = handleFocus; - $[37] = hideIndexes; - $[38] = isDisabled; - $[39] = layout; - $[40] = onCancel; - $[41] = onUpFromFirstItem; - $[42] = options; - $[43] = visibleOptionCount; - $[44] = t13; - } else { - t13 = $[44]; - } - let t14; - if ($[45] !== handleKeyDown || $[46] !== t13) { - t14 = {t13}; - $[45] = handleKeyDown; - $[46] = t13; - $[47] = t14; - } else { - t14 = $[47]; - } - return t14; -} -function _temp2(_depth) { - return " \u25B8 "; -} -function _temp(isExpanded_0) { - return isExpanded_0 ? "\u25BC " : "\u25B6 "; + }, + [onFocus, nodeMap], + ) + + return ( + + = Record> = (tool: ToolType, input: Input, toolUseContext: ToolUseContext, assistantMessage: AssistantMessage, toolUseID: string, forceDecision?: PermissionDecision) => Promise>; -function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) { - const $ = _c(3); - let t0; - if ($[0] !== setToolPermissionContext || $[1] !== setToolUseConfirmQueue) { - t0 = async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => new Promise(resolve => { - const ctx = createPermissionContext(tool, input, toolUseContext, assistantMessage, toolUseID, setToolPermissionContext, createPermissionQueueOps(setToolUseConfirmQueue)); - if (ctx.resolveIfAborted(resolve)) { - return; - } - const decisionPromise = forceDecision !== undefined ? Promise.resolve(forceDecision) : hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID); - return decisionPromise.then(async result => { - if (result.behavior === "allow") { - if (ctx.resolveIfAborted(resolve)) { - return; - } - if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") { - setYoloClassifierApproval(toolUseID, result.decisionReason.reason); - } - ctx.logDecision({ - decision: "accept", - source: "config" - }); - resolve(ctx.buildAllow(result.updatedInput ?? input, { - decisionReason: result.decisionReason - })); - return; - } - const appState = toolUseContext.getAppState(); - const description = await tool.description(input as never, { - isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession, - toolPermissionContext: appState.toolPermissionContext, - tools: toolUseContext.options.tools - }); - if (ctx.resolveIfAborted(resolve)) { - return; - } - switch (result.behavior) { - case "deny": - { - logPermissionDecision({ +import { feature } from 'bun:bundle' +import { APIUserAbortError } from '@anthropic-ai/sdk' +import * as React from 'react' +import { useCallback } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' +import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' +import { Text } from '../ink.js' +import type { + ToolPermissionContext, + Tool as ToolType, + ToolUseContext, +} from '../Tool.js' +import { + consumeSpeculativeClassifierCheck, + peekSpeculativeClassifierCheck, +} from '../tools/BashTool/bashPermissions.js' +import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' +import type { AssistantMessage } from '../types/message.js' +import { recordAutoModeDenial } from '../utils/autoModeDenials.js' +import { + clearClassifierChecking, + setClassifierApproval, + setYoloClassifierApproval, +} from '../utils/classifierApprovals.js' +import { logForDebugging } from '../utils/debug.js' +import { AbortError } from '../utils/errors.js' +import { logError } from '../utils/log.js' +import type { PermissionDecision } from '../utils/permissions/PermissionResult.js' +import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js' +import { jsonStringify } from '../utils/slowOperations.js' +import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js' +import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js' +import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js' +import { + createPermissionContext, + createPermissionQueueOps, +} from './toolPermission/PermissionContext.js' +import { logPermissionDecision } from './toolPermission/permissionLogging.js' + +export type CanUseToolFn< + Input extends Record = Record, +> = ( + tool: ToolType, + input: Input, + toolUseContext: ToolUseContext, + assistantMessage: AssistantMessage, + toolUseID: string, + forceDecision?: PermissionDecision, +) => Promise> + +function useCanUseTool( + setToolUseConfirmQueue: React.Dispatch< + React.SetStateAction + >, + setToolPermissionContext: (context: ToolPermissionContext) => void, +): CanUseToolFn { + return useCallback( + async ( + tool, + input, + toolUseContext, + assistantMessage, + toolUseID, + forceDecision, + ) => { + return new Promise(resolve => { + const ctx = createPermissionContext( + tool, + input, + toolUseContext, + assistantMessage, + toolUseID, + setToolPermissionContext, + createPermissionQueueOps(setToolUseConfirmQueue), + ) + + if (ctx.resolveIfAborted(resolve)) return + + const decisionPromise = + forceDecision !== undefined + ? Promise.resolve(forceDecision) + : hasPermissionsToUseTool( tool, input, toolUseContext, - messageId: ctx.messageId, - toolUseID - }, { - decision: "reject", - source: "config" - }); - if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") { - recordAutoModeDenial({ - toolName: tool.name, - display: description, - reason: result.decisionReason.reason ?? "", - timestamp: Date.now() - }); - toolUseContext.addNotification?.({ - key: "auto-mode-denied", - priority: "immediate", - jsx: <>{tool.userFacingName(input).toLowerCase()} denied by auto mode · /permissions - }); + assistantMessage, + toolUseID, + ) + + return decisionPromise + .then(async result => { + // [ANT-ONLY] Log all tool permission decisions with tool name and args + if (process.env.USER_TYPE === 'ant') { + logEvent('tengu_internal_tool_permission_decision', { + toolName: sanitizeToolNameForAnalytics(tool.name), + behavior: + result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + // Note: input contains code/filepaths, only log for ants + input: jsonStringify( + input, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + messageID: + ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + isMcp: tool.isMcp ?? false, + }) + } + + // Has permissions to use tool, granted in config + if (result.behavior === 'allow') { + if (ctx.resolveIfAborted(resolve)) return + // Track auto mode classifier approvals for UI display + if ( + feature('TRANSCRIPT_CLASSIFIER') && + result.decisionReason?.type === 'classifier' && + result.decisionReason.classifier === 'auto-mode' + ) { + setYoloClassifierApproval( + toolUseID, + result.decisionReason.reason, + ) } - resolve(result); - return; + + ctx.logDecision({ decision: 'accept', source: 'config' }) + + resolve( + ctx.buildAllow(result.updatedInput ?? input, { + decisionReason: result.decisionReason, + }), + ) + return } - case "ask": - { - if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) { - const coordinatorDecision = await handleCoordinatorPermission({ - ctx, - ...(feature("BASH_CLASSIFIER") ? { - pendingClassifierCheck: result.pendingClassifierCheck - } : {}), - updatedInput: result.updatedInput, - suggestions: result.suggestions, - permissionMode: appState.toolPermissionContext.mode - }); - if (coordinatorDecision) { - resolve(coordinatorDecision); - return; + + const appState = toolUseContext.getAppState() + const description = await tool.description(input as never, { + isNonInteractiveSession: + toolUseContext.options.isNonInteractiveSession, + toolPermissionContext: appState.toolPermissionContext, + tools: toolUseContext.options.tools, + }) + + if (ctx.resolveIfAborted(resolve)) return + + // Does not have permissions to use tool, check the behavior + switch (result.behavior) { + case 'deny': { + logPermissionDecision( + { + tool, + input, + toolUseContext, + messageId: ctx.messageId, + toolUseID, + }, + { decision: 'reject', source: 'config' }, + ) + if ( + feature('TRANSCRIPT_CLASSIFIER') && + result.decisionReason?.type === 'classifier' && + result.decisionReason.classifier === 'auto-mode' + ) { + recordAutoModeDenial({ + toolName: tool.name, + display: description, + reason: result.decisionReason.reason ?? '', + timestamp: Date.now(), + }) + toolUseContext.addNotification?.({ + key: 'auto-mode-denied', + priority: 'immediate', + jsx: ( + <> + + {tool.userFacingName(input).toLowerCase()} denied by + auto mode + + · /permissions + + ), + }) } + resolve(result) + return } - if (ctx.resolveIfAborted(resolve)) { - return; - } - const swarmDecision = await handleSwarmWorkerPermission({ - ctx, - description, - ...(feature("BASH_CLASSIFIER") ? { - pendingClassifierCheck: result.pendingClassifierCheck - } : {}), - updatedInput: result.updatedInput, - suggestions: result.suggestions - }); - if (swarmDecision) { - resolve(swarmDecision); - return; - } - if (feature("BASH_CLASSIFIER") && result.pendingClassifierCheck && tool.name === BASH_TOOL_NAME && !appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) { - const speculativePromise = peekSpeculativeClassifierCheck((input as { - command: string; - }).command); - if (speculativePromise) { - const raceResult = await Promise.race([speculativePromise.then(_temp), new Promise(_temp2)]); - if (ctx.resolveIfAborted(resolve)) { - return; + + case 'ask': { + // For coordinator workers, await automated checks before showing dialog. + // Background workers should only interrupt the user when automated checks can't decide. + if ( + appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog + ) { + const coordinatorDecision = await handleCoordinatorPermission( + { + ctx, + ...(feature('BASH_CLASSIFIER') + ? { + pendingClassifierCheck: + result.pendingClassifierCheck, + } + : {}), + updatedInput: result.updatedInput, + suggestions: result.suggestions, + permissionMode: appState.toolPermissionContext.mode, + }, + ) + if (coordinatorDecision) { + resolve(coordinatorDecision) + return } - if ((raceResult as any).type === "result" && (raceResult as any).result.matches && (raceResult as any).result.confidence === "high" && feature("BASH_CLASSIFIER")) { - consumeSpeculativeClassifierCheck((input as { - command: string; - }).command); - const matchedRule = (raceResult as any).result.matchedDescription ?? undefined; - if (matchedRule) { - setClassifierApproval(toolUseID, matchedRule); - } - ctx.logDecision({ - decision: "accept", - source: { - type: "classifier" + // null means neither automated check resolved -- fall through to dialog below. + // Hooks already ran, classifier already consumed. + } + + // After awaiting automated checks, verify the request wasn't aborted + // while we were waiting. Without this check, a stale dialog could appear. + if (ctx.resolveIfAborted(resolve)) return + + // For swarm workers, try classifier auto-approval then + // forward permission requests to the leader via mailbox. + const swarmDecision = await handleSwarmWorkerPermission({ + ctx, + description, + ...(feature('BASH_CLASSIFIER') + ? { + pendingClassifierCheck: result.pendingClassifierCheck, } - }); - resolve(ctx.buildAllow(result.updatedInput ?? input as Record, { - decisionReason: { - type: "classifier" as const, - classifier: "bash_allow" as const, - reason: `Allowed by prompt rule: "${(raceResult as any).result.matchedDescription}"` + : {}), + updatedInput: result.updatedInput, + suggestions: result.suggestions, + }) + if (swarmDecision) { + resolve(swarmDecision) + return + } + + // Grace period: wait up to 2s for speculative classifier + // to resolve before showing the dialog (main agent only) + if ( + feature('BASH_CLASSIFIER') && + result.pendingClassifierCheck && + tool.name === BASH_TOOL_NAME && + !appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog + ) { + const speculativePromise = peekSpeculativeClassifierCheck( + (input as { command: string }).command, + ) + if (speculativePromise) { + const raceResult = await Promise.race([ + speculativePromise.then(r => ({ + type: 'result' as const, + result: r, + })), + new Promise<{ type: 'timeout' }>(res => + // eslint-disable-next-line no-restricted-syntax -- resolves with a value, not void + setTimeout(res, 2000, { type: 'timeout' as const }), + ), + ]) + + if (ctx.resolveIfAborted(resolve)) return + + if ( + raceResult.type === 'result' && + raceResult.result.matches && + raceResult.result.confidence === 'high' && + feature('BASH_CLASSIFIER') + ) { + // Classifier approved within grace period — skip dialog + void consumeSpeculativeClassifierCheck( + (input as { command: string }).command, + ) + + const matchedRule = + raceResult.result.matchedDescription ?? undefined + if (matchedRule) { + setClassifierApproval(toolUseID, matchedRule) } - })); - return; + + ctx.logDecision({ + decision: 'accept', + source: { type: 'classifier' }, + }) + resolve( + ctx.buildAllow( + result.updatedInput ?? + (input as Record), + { + decisionReason: { + type: 'classifier' as const, + classifier: 'bash_allow' as const, + reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`, + }, + }, + ), + ) + return + } + // Timeout or no match — fall through to show dialog } } + + // Show dialog and start hooks/classifier in background + handleInteractivePermission( + { + ctx, + description, + result, + awaitAutomatedChecksBeforeDialog: + appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog, + bridgeCallbacks: feature('BRIDGE_MODE') + ? appState.replBridgePermissionCallbacks + : undefined, + channelCallbacks: + feature('KAIROS') || feature('KAIROS_CHANNELS') + ? appState.channelPermissionCallbacks + : undefined, + }, + resolve, + ) + + return } - handleInteractivePermission({ - ctx, - description, - result, - awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog, - bridgeCallbacks: feature("BRIDGE_MODE") ? appState.replBridgePermissionCallbacks : undefined, - channelCallbacks: feature("KAIROS") || feature("KAIROS_CHANNELS") ? appState.channelPermissionCallbacks : undefined - }, resolve); - return; } - } - }).catch(error => { - if (error instanceof AbortError || error instanceof APIUserAbortError) { - logForDebugging(`Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`); - ctx.logCancelled(); - resolve(ctx.cancelAndAbort(undefined, true)); - } else { - logError(error); - resolve(ctx.cancelAndAbort(undefined, true)); - } - }).finally(() => { - clearClassifierChecking(toolUseID); - }); - }); - $[0] = setToolPermissionContext; - $[1] = setToolUseConfirmQueue; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; -} -function _temp2(res) { - return setTimeout(res, 2000, { - type: "timeout" as const - }); -} -function _temp(r) { - return { - type: "result" as const, - result: r - }; + }) + .catch(error => { + if ( + error instanceof AbortError || + error instanceof APIUserAbortError + ) { + logForDebugging( + `Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`, + ) + ctx.logCancelled() + resolve(ctx.cancelAndAbort(undefined, true)) + } else { + logError(error) + resolve(ctx.cancelAndAbort(undefined, true)) + } + }) + .finally(() => { + clearClassifierChecking(toolUseID) + }) + }) + }, + [setToolUseConfirmQueue, setToolPermissionContext], + ) } -export default useCanUseTool; + +export default useCanUseTool diff --git a/src/hooks/useChromeExtensionNotification.tsx b/src/hooks/useChromeExtensionNotification.tsx index a32aac968..dc058df0e 100644 --- a/src/hooks/useChromeExtensionNotification.tsx +++ b/src/hooks/useChromeExtensionNotification.tsx @@ -1,42 +1,66 @@ -import * as React from 'react'; -import { Text } from '../ink.js'; -import { isClaudeAISubscriber } from '../utils/auth.js'; -import { isChromeExtensionInstalled, shouldEnableClaudeInChrome } from '../utils/claudeInChrome/setup.js'; -import { isRunningOnHomespace } from '../utils/envUtils.js'; -import { useStartupNotification } from './notifs/useStartupNotification.js'; +import * as React from 'react' +import { Text } from '../ink.js' +import { isClaudeAISubscriber } from '../utils/auth.js' +import { + isChromeExtensionInstalled, + shouldEnableClaudeInChrome, +} from '../utils/claudeInChrome/setup.js' +import { isRunningOnHomespace } from '../utils/envUtils.js' +import { useStartupNotification } from './notifs/useStartupNotification.js' + function getChromeFlag(): boolean | undefined { if (process.argv.includes('--chrome')) { - return true; + return true } if (process.argv.includes('--no-chrome')) { - return false; + return false } - return undefined; + return undefined } -export function useChromeExtensionNotification() { - useStartupNotification(_temp); -} -async function _temp() { - const chromeFlag = getChromeFlag(); - if (!shouldEnableClaudeInChrome(chromeFlag)) { - return null; - } - // Subscription check bypassed - const installed = await isChromeExtensionInstalled(); - if (!installed && !isRunningOnHomespace()) { - return { - key: "chrome-extension-not-detected", - jsx: Chrome extension not detected · https://claude.ai/chrome to install, - priority: "immediate", - timeoutMs: 3000 - }; - } - if (chromeFlag === undefined) { - return { - key: "claude-in-chrome-default-enabled", - text: "Claude in Chrome enabled \xB7 /chrome", - priority: "low" - }; - } - return null; + +export function useChromeExtensionNotification(): void { + useStartupNotification(async () => { + const chromeFlag = getChromeFlag() + if (!shouldEnableClaudeInChrome(chromeFlag)) return null + + // Claude in Chrome is only supported for claude.ai subscribers (unless user is ant) + if ("external" !== 'ant' && !isClaudeAISubscriber()) { + return { + key: 'chrome-requires-subscription', + jsx: ( + + Claude in Chrome requires a claude.ai subscription + + ), + priority: 'immediate', + timeoutMs: 5000, + } + } + + const installed = await isChromeExtensionInstalled() + if (!installed && !isRunningOnHomespace()) { + // Skip notification on Homespace since Chrome setup requires different steps (see go/hsproxy) + return { + key: 'chrome-extension-not-detected', + jsx: ( + + Chrome extension not detected · https://claude.ai/chrome to install + + ), + // TODO(hackyon): Lower the priority if the claude-in-chrome integration is no longer opt-in + priority: 'immediate', + timeoutMs: 3000, + } + } + if (chromeFlag === undefined) { + // Show low priority notification only when Chrome is enabled by default + // (not explicitly enabled with --chrome or disabled with --no-chrome) + return { + key: 'claude-in-chrome-default-enabled', + text: `Claude in Chrome enabled · /chrome`, + priority: 'low', + } + } + return null + }) } diff --git a/src/hooks/useClaudeCodeHintRecommendation.tsx b/src/hooks/useClaudeCodeHintRecommendation.tsx index 0b2291167..9e9aa1cf3 100644 --- a/src/hooks/useClaudeCodeHintRecommendation.tsx +++ b/src/hooks/useClaudeCodeHintRecommendation.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Surfaces plugin-install prompts driven by `` tags * that CLIs/SDKs emit to stderr. See docs/claude-code-hints.md. @@ -9,120 +8,117 @@ import { c as _c } from "react/compiler-runtime"; * anything that reaches this hook is worth resolving. */ -import * as React from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent } from '../services/analytics/index.js'; -import { clearPendingHint, getPendingHintSnapshot, markShownThisSession, subscribeToPendingHint } from '../utils/claudeCodeHints.js'; -import { logForDebugging } from '../utils/debug.js'; -import { disableHintRecommendations, markHintPluginShown, type PluginHintRecommendation, resolvePluginHint } from '../utils/plugins/hintRecommendation.js'; -import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js'; -import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; +import * as React from 'react' +import { useNotifications } from '../context/notifications.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + logEvent, +} from '../services/analytics/index.js' +import { + clearPendingHint, + getPendingHintSnapshot, + markShownThisSession, + subscribeToPendingHint, +} from '../utils/claudeCodeHints.js' +import { logForDebugging } from '../utils/debug.js' +import { + disableHintRecommendations, + markHintPluginShown, + type PluginHintRecommendation, + resolvePluginHint, +} from '../utils/plugins/hintRecommendation.js' +import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js' +import { + installPluginAndNotify, + usePluginRecommendationBase, +} from './usePluginRecommendationBase.js' + type UseClaudeCodeHintRecommendationResult = { - recommendation: PluginHintRecommendation | null; - handleResponse: (response: 'yes' | 'no' | 'disable') => void; -}; -export function useClaudeCodeHintRecommendation() { - const $ = _c(11); - const pendingHint = React.useSyncExternalStore(subscribeToPendingHint, getPendingHintSnapshot); - const { - addNotification - } = useNotifications(); - const { - recommendation, - clearRecommendation, - tryResolve - } = usePluginRecommendationBase(); - let t0; - let t1; - if ($[0] !== pendingHint || $[1] !== tryResolve) { - t0 = () => { - if (!pendingHint) { - return; + recommendation: PluginHintRecommendation | null + handleResponse: (response: 'yes' | 'no' | 'disable') => void +} + +export function useClaudeCodeHintRecommendation(): UseClaudeCodeHintRecommendationResult { + const pendingHint = React.useSyncExternalStore( + subscribeToPendingHint, + getPendingHintSnapshot, + ) + const { addNotification } = useNotifications() + const { recommendation, clearRecommendation, tryResolve } = + usePluginRecommendationBase() + + React.useEffect(() => { + if (!pendingHint) return + tryResolve(async () => { + const resolved = await resolvePluginHint(pendingHint) + if (resolved) { + logForDebugging( + `[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`, + ) + markShownThisSession() } - tryResolve(async () => { - const resolved = await resolvePluginHint(pendingHint); - if (resolved) { - logForDebugging(`[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`); - markShownThisSession(); - } - if (getPendingHintSnapshot() === pendingHint) { - clearPendingHint(); - } - return resolved; - }); - }; - t1 = [pendingHint, tryResolve]; - $[0] = pendingHint; - $[1] = tryResolve; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - React.useEffect(t0, t1); - let t2; - if ($[4] !== addNotification || $[5] !== clearRecommendation || $[6] !== recommendation) { - t2 = response => { - if (!recommendation) { - return; + // Drop the slot — but only if it still holds the hint we just + // resolved. A newer hint may have overwritten it during the async + // lookup; don't clobber that. + if (getPendingHintSnapshot() === pendingHint) { + clearPendingHint() } - markHintPluginShown(recommendation.pluginId); - logEvent("tengu_plugin_hint_response", { - _PROTO_plugin_name: recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - _PROTO_marketplace_name: recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - response: response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - bb15: switch (response) { - case "yes": - { - const { - pluginId, - pluginName, - marketplaceName - } = recommendation; - installPluginAndNotify(pluginId, pluginName, "hint-plugin", addNotification, async pluginData => { + return resolved + }) + }, [pendingHint, tryResolve]) + + const handleResponse = React.useCallback( + (response: 'yes' | 'no' | 'disable') => { + if (!recommendation) return + + // Record show-once here, not at resolution-time — the dialog may have + // been blocked by a higher-priority focusedInputDialog and never + // rendered. Auto-dismiss reaches this via onResponse('no'). + markHintPluginShown(recommendation.pluginId) + logEvent('tengu_plugin_hint_response', { + _PROTO_plugin_name: + recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + _PROTO_marketplace_name: + recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + response: + response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + switch (response) { + case 'yes': { + const { pluginId, pluginName, marketplaceName } = recommendation + void installPluginAndNotify( + pluginId, + pluginName, + 'hint-plugin', + addNotification, + async pluginData => { const result = await installPluginFromMarketplace({ pluginId, entry: pluginData.entry, marketplaceName, - scope: "user", - trigger: "hint" - }); + scope: 'user', + trigger: 'hint', + }) if (!result.success) { - throw new Error((result as any).error); + throw new Error(result.error) } - }); - break bb15; - } - case "disable": - { - disableHintRecommendations(); - break bb15; - } - case "no": + }, + ) + break + } + case 'disable': + disableHintRecommendations() + break + case 'no': + break } - clearRecommendation(); - }; - $[4] = addNotification; - $[5] = clearRecommendation; - $[6] = recommendation; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleResponse = t2; - let t3; - if ($[8] !== handleResponse || $[9] !== recommendation) { - t3 = { - recommendation, - handleResponse - }; - $[8] = handleResponse; - $[9] = recommendation; - $[10] = t3; - } else { - t3 = $[10]; - } - return t3; + + clearRecommendation() + }, + [recommendation, addNotification, clearRecommendation], + ) + + return { recommendation, handleResponse } } diff --git a/src/hooks/useCommandKeybindings.tsx b/src/hooks/useCommandKeybindings.tsx index 581e43796..416a07ce7 100644 --- a/src/hooks/useCommandKeybindings.tsx +++ b/src/hooks/useCommandKeybindings.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Component that registers keybinding handlers for command bindings. * @@ -9,99 +8,75 @@ import { c as _c } from "react/compiler-runtime"; * Commands triggered via keybinding are treated as "immediate" - they execute right * away and preserve the user's existing input text (the prompt is not cleared). */ -import { useMemo } from 'react'; -import { useIsModalOverlayActive } from '../context/overlayContext.js'; -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js'; +import { useMemo } from 'react' +import { useIsModalOverlayActive } from '../context/overlayContext.js' +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js' + type Props = { // onSubmit accepts additional parameters beyond what we pass here, // so we use a rest parameter to allow any additional args - onSubmit: (input: string, helpers: PromptInputHelpers, ...rest: [speculationAccept?: undefined, options?: { - fromKeybinding?: boolean; - }]) => void; + onSubmit: ( + input: string, + helpers: PromptInputHelpers, + ...rest: [ + speculationAccept?: undefined, + options?: { fromKeybinding?: boolean }, + ] + ) => void /** Set to false to disable command keybindings (e.g., when a dialog is open) */ - isActive?: boolean; -}; + isActive?: boolean +} + const NOOP_HELPERS: PromptInputHelpers = { setCursorOffset: () => {}, clearBuffer: () => {}, - resetHistory: () => {} -}; + resetHistory: () => {}, +} /** * Registers keybinding handlers for all "command:*" actions found in the * user's keybinding configuration. When triggered, each handler submits * the corresponding slash command (e.g., "command:commit" submits "/commit"). */ -export function CommandKeybindingHandlers(t0) { - const $ = _c(8); - const { - onSubmit, - isActive: t1 - } = t0; - const isActive = t1 === undefined ? true : t1; - const keybindingContext = useOptionalKeybindingContext(); - const isModalOverlayActive = useIsModalOverlayActive(); - let t2; - bb0: { - if (!keybindingContext) { - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = new Set(); - $[0] = t3; - } else { - t3 = $[0]; - } - t2 = t3; - break bb0; - } - let actions; - if ($[1] !== keybindingContext.bindings) { - actions = new Set(); - for (const binding of keybindingContext.bindings) { - if (binding.action?.startsWith("command:")) { - actions.add(binding.action); - } +export function CommandKeybindingHandlers({ + onSubmit, + isActive = true, +}: Props): null { + const keybindingContext = useOptionalKeybindingContext() + const isModalOverlayActive = useIsModalOverlayActive() + + // Extract command actions from parsed bindings + const commandActions = useMemo(() => { + if (!keybindingContext) return new Set() + const actions = new Set() + for (const binding of keybindingContext.bindings) { + if (binding.action?.startsWith('command:')) { + actions.add(binding.action) } - $[1] = keybindingContext.bindings; - $[2] = actions; - } else { - actions = $[2]; } - t2 = actions; - } - const commandActions = t2; - let map; - if ($[3] !== commandActions || $[4] !== onSubmit) { - map = {}; + return actions + }, [keybindingContext]) + + // Build handler map for all command actions + const handlers = useMemo(() => { + const map: Record void> = {} for (const action of commandActions) { - const commandName = action.slice(8); + const commandName = action.slice('command:'.length) map[action] = () => { onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, { - fromKeybinding: true - }); - }; + fromKeybinding: true, + }) + } } - $[3] = commandActions; - $[4] = onSubmit; - $[5] = map; - } else { - map = $[5]; - } - const handlers = map; - const t3 = isActive && !isModalOverlayActive; - let t4; - if ($[6] !== t3) { - t4 = { - context: "Chat", - isActive: t3 - }; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - useKeybindings(handlers, t4); - return null; + return map + }, [commandActions, onSubmit]) + + useKeybindings(handlers, { + context: 'Chat', + isActive: isActive && !isModalOverlayActive, + }) + + return null } diff --git a/src/hooks/useGlobalKeybindings.tsx b/src/hooks/useGlobalKeybindings.tsx index faaf1fe82..a41b1b6a5 100644 --- a/src/hooks/useGlobalKeybindings.tsx +++ b/src/hooks/useGlobalKeybindings.tsx @@ -4,27 +4,31 @@ * Must be rendered inside KeybindingSetup to have access to the keybinding context. * This component renders nothing - it just registers the keybinding handlers. */ -import { feature } from 'bun:bundle'; -import { useCallback } from 'react'; -import instances from '../ink/instances.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import type { Screen } from '../screens/REPL.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { count } from '../utils/array.js'; -import { getTerminalPanel } from '../utils/terminalPanel.js'; +import { feature } from 'bun:bundle' +import { useCallback } from 'react' +import instances from '../ink/instances.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import type { Screen } from '../screens/REPL.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { count } from '../utils/array.js' +import { getTerminalPanel } from '../utils/terminalPanel.js' + type Props = { - screen: Screen; - setScreen: React.Dispatch>; - showAllInTranscript: boolean; - setShowAllInTranscript: React.Dispatch>; - messageCount: number; - onEnterTranscript?: () => void; - onExitTranscript?: () => void; - virtualScrollActive?: boolean; - searchBarOpen?: boolean; -}; + screen: Screen + setScreen: React.Dispatch> + showAllInTranscript: boolean + setShowAllInTranscript: React.Dispatch> + messageCount: number + onEnterTranscript?: () => void + onExitTranscript?: () => void + virtualScrollActive?: boolean + searchBarOpen?: boolean +} /** * Registers global keybinding handlers for: @@ -42,56 +46,55 @@ export function GlobalKeybindingHandlers({ onEnterTranscript, onExitTranscript, virtualScrollActive, - searchBarOpen = false + searchBarOpen = false, }: Props): null { - const expandedView = useAppState(s => s.expandedView); - const setAppState = useSetAppState(); + const expandedView = useAppState(s => s.expandedView) + const setAppState = useSetAppState() // Toggle todo list (ctrl+t) - cycles through views const handleToggleTodos = useCallback(() => { logEvent('tengu_toggle_todos', { - is_expanded: expandedView === 'tasks' - }); + is_expanded: expandedView === 'tasks', + }) setAppState(prev => { - const { - getAllInProcessTeammateTasks - } = - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js'); - const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0; + const { getAllInProcessTeammateTasks } = + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') + const hasTeammates = + count( + getAllInProcessTeammateTasks(prev.tasks), + t => t.status === 'running', + ) > 0 + if (hasTeammates) { // Both exist: none → tasks → teammates → none switch (prev.expandedView) { case 'none': - return { - ...prev, - expandedView: 'tasks' as const - }; + return { ...prev, expandedView: 'tasks' as const } case 'tasks': - return { - ...prev, - expandedView: 'teammates' as const - }; + return { ...prev, expandedView: 'teammates' as const } case 'teammates': - return { - ...prev, - expandedView: 'none' as const - }; + return { ...prev, expandedView: 'none' as const } } } // Only tasks: none ↔ tasks return { ...prev, - expandedView: prev.expandedView === 'tasks' ? 'none' as const : 'tasks' as const - }; - }); - }, [expandedView, setAppState]); + expandedView: + prev.expandedView === 'tasks' + ? ('none' as const) + : ('tasks' as const), + } + }) + }, [expandedView, setAppState]) // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript. // Brief view has its own dedicated toggle on ctrl+shift+b. - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.isBriefOnly) : false; + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false const handleToggleTranscript = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // Escape hatch: GB kill-switch while defaultView=chat was persisted @@ -100,58 +103,71 @@ export function GlobalKeybindingHandlers({ // Only needed in the prompt screen — transcript mode already ignores // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode). /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEnabled - } = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js'); + const { isBriefEnabled } = + require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') { - setAppState(prev_0 => { - if (!prev_0.isBriefOnly) return prev_0; - return { - ...prev_0, - isBriefOnly: false - }; - }); - return; + setAppState(prev => { + if (!prev.isBriefOnly) return prev + return { ...prev, isBriefOnly: false } + }) + return } } - const isEnteringTranscript = screen !== 'transcript'; + + const isEnteringTranscript = screen !== 'transcript' logEvent('tengu_toggle_transcript', { is_entering: isEnteringTranscript, show_all: showAllInTranscript, - message_count: messageCount - }); - setScreen(s_1 => s_1 === 'transcript' ? 'prompt' : 'transcript'); - setShowAllInTranscript(false); + message_count: messageCount, + }) + setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript')) + setShowAllInTranscript(false) if (isEnteringTranscript && onEnterTranscript) { - onEnterTranscript(); + onEnterTranscript() } if (!isEnteringTranscript && onExitTranscript) { - onExitTranscript(); + onExitTranscript() } - }, [screen, setScreen, isBriefOnly, showAllInTranscript, setShowAllInTranscript, messageCount, setAppState, onEnterTranscript, onExitTranscript]); + }, [ + screen, + setScreen, + isBriefOnly, + showAllInTranscript, + setShowAllInTranscript, + messageCount, + setAppState, + onEnterTranscript, + onExitTranscript, + ]) // Toggle showing all messages in transcript mode (ctrl+e) const handleToggleShowAll = useCallback(() => { logEvent('tengu_transcript_toggle_show_all', { is_expanding: !showAllInTranscript, - message_count: messageCount - }); - setShowAllInTranscript(prev_1 => !prev_1); - }, [showAllInTranscript, setShowAllInTranscript, messageCount]); + message_count: messageCount, + }) + setShowAllInTranscript(prev => !prev) + }, [showAllInTranscript, setShowAllInTranscript, messageCount]) // Exit transcript mode (ctrl+c or escape) const handleExitTranscript = useCallback(() => { logEvent('tengu_transcript_exit', { show_all: showAllInTranscript, - message_count: messageCount - }); - setScreen('prompt'); - setShowAllInTranscript(false); + message_count: messageCount, + }) + setScreen('prompt') + setShowAllInTranscript(false) if (onExitTranscript) { - onExitTranscript(); + onExitTranscript() } - }, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]); + }, [ + setScreen, + showAllInTranscript, + setShowAllInTranscript, + messageCount, + onExitTranscript, + ]) // Toggle brief-only view (ctrl+shift+b). Pure display filter toggle — // does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF @@ -160,81 +176,80 @@ export function GlobalKeybindingHandlers({ const handleToggleBrief = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEnabled: isBriefEnabled_0 - } = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js'); + const { isBriefEnabled } = + require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ - if (!isBriefEnabled_0() && !isBriefOnly) return; - const next = !isBriefOnly; + if (!isBriefEnabled() && !isBriefOnly) return + const next = !isBriefOnly logEvent('tengu_brief_mode_toggled', { enabled: next, gated: false, - source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - setAppState(prev_2 => { - if (prev_2.isBriefOnly === next) return prev_2; - return { - ...prev_2, - isBriefOnly: next - }; - }); + source: + 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + setAppState(prev => { + if (prev.isBriefOnly === next) return prev + return { ...prev, isBriefOnly: next } + }) } - }, [isBriefOnly, setAppState]); + }, [isBriefOnly, setAppState]) // Register keybinding handlers useKeybinding('app:toggleTodos', handleToggleTodos, { - context: 'Global' - }); + context: 'Global', + }) useKeybinding('app:toggleTranscript', handleToggleTranscript, { - context: 'Global' - }); + context: 'Global', + }) if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useKeybinding('app:toggleBrief', handleToggleBrief, { - context: 'Global' - }); + context: 'Global', + }) } // Register teammate keybinding - useKeybinding('app:toggleTeammatePreview', () => { - setAppState(prev_3 => ({ - ...prev_3, - showTeammateMessagePreview: !prev_3.showTeammateMessagePreview - })); - }, { - context: 'Global' - }); + useKeybinding( + 'app:toggleTeammatePreview', + () => { + setAppState(prev => ({ + ...prev, + showTeammateMessagePreview: !prev.showTeammateMessagePreview, + })) + }, + { + context: 'Global', + }, + ) // Toggle built-in terminal panel (meta+j). // toggle() blocks in spawnSync until the user detaches from tmux. const handleToggleTerminal = useCallback(() => { if (feature('TERMINAL_PANEL')) { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) { - return; + return } - getTerminalPanel().toggle(); + getTerminalPanel().toggle() } - }, []); + }, []) useKeybinding('app:toggleTerminal', handleToggleTerminal, { - context: 'Global' - }); + context: 'Global', + }) // Clear screen and force full redraw (ctrl+l). Recovery path when the // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine // thinks unchanged cells don't need repainting. const handleRedraw = useCallback(() => { - instances.get(process.stdout)?.forceRedraw(); - }, []); - useKeybinding('app:redraw', handleRedraw, { - context: 'Global' - }); + instances.get(process.stdout)?.forceRedraw() + }, []) + useKeybinding('app:redraw', handleRedraw, { context: 'Global' }) // Transcript-specific bindings (only active when in transcript mode) - const isInTranscript = screen === 'transcript'; + const isInTranscript = screen === 'transcript' useKeybinding('transcript:toggleShowAll', handleToggleShowAll, { context: 'Transcript', - isActive: isInTranscript && !virtualScrollActive - }); + isActive: isInTranscript && !virtualScrollActive, + }) useKeybinding('transcript:exit', handleExitTranscript, { context: 'Transcript', // Bar-open is a mode (owns keystrokes). Navigating (highlights @@ -242,7 +257,8 @@ export function GlobalKeybindingHandlers({ // directly, same as less q. useSearchInput doesn't stopPropagation, // so without this gate its onCancel AND this handler would both // fire on one Esc (child registers first, fires first, bubbles). - isActive: isInTranscript && !searchBarOpen - }); - return null; + isActive: isInTranscript && !searchBarOpen, + }) + + return null } diff --git a/src/hooks/useIDEIntegration.tsx b/src/hooks/useIDEIntegration.tsx index 29c50b7c6..786146ee7 100644 --- a/src/hooks/useIDEIntegration.tsx +++ b/src/hooks/useIDEIntegration.tsx @@ -1,69 +1,88 @@ -import { c as _c } from "react/compiler-runtime"; -import { useEffect } from 'react'; -import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js'; -import type { DetectedIDEInfo } from '../utils/ide.js'; -import { type IDEExtensionInstallationStatus, type IdeType, initializeIdeIntegration, isSupportedTerminal } from '../utils/ide.js'; +import { useEffect } from 'react' +import type { ScopedMcpServerConfig } from '../services/mcp/types.js' +import { getGlobalConfig } from '../utils/config.js' +import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js' +import type { DetectedIDEInfo } from '../utils/ide.js' +import { + type IDEExtensionInstallationStatus, + type IdeType, + initializeIdeIntegration, + isSupportedTerminal, +} from '../utils/ide.js' + type UseIDEIntegrationProps = { - autoConnectIdeFlag?: boolean; - ideToInstallExtension: IdeType | null; - setDynamicMcpConfig: React.Dispatch | undefined>>; - setShowIdeOnboarding: React.Dispatch>; - setIDEInstallationState: React.Dispatch>; -}; -export function useIDEIntegration(t0) { - const $ = _c(7); - const { + autoConnectIdeFlag?: boolean + ideToInstallExtension: IdeType | null + setDynamicMcpConfig: React.Dispatch< + React.SetStateAction | undefined> + > + setShowIdeOnboarding: React.Dispatch> + setIDEInstallationState: React.Dispatch< + React.SetStateAction + > +} + +export function useIDEIntegration({ + autoConnectIdeFlag, + ideToInstallExtension, + setDynamicMcpConfig, + setShowIdeOnboarding, + setIDEInstallationState, +}: UseIDEIntegrationProps): void { + useEffect(() => { + function addIde(ide: DetectedIDEInfo | null) { + if (!ide) { + return + } + + // Check if auto-connect is enabled + const globalConfig = getGlobalConfig() + const autoConnectEnabled = + (globalConfig.autoConnectIde || + autoConnectIdeFlag || + isSupportedTerminal() || + // tmux/screen overwrite TERM_PROGRAM, breaking terminal detection, but the + // IDE extension's port env var is inherited. If set, auto-connect anyway. + process.env.CLAUDE_CODE_SSE_PORT || + ideToInstallExtension || + isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && + !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE) + + if (!autoConnectEnabled) { + return + } + + setDynamicMcpConfig(prev => { + // Only add the IDE if we don't already have one + if (prev?.ide) { + return prev + } + return { + ...prev, + ide: { + type: ide.url.startsWith('ws:') ? 'ws-ide' : 'sse-ide', + url: ide.url, + ideName: ide.name, + authToken: ide.authToken, + ideRunningInWindows: ide.ideRunningInWindows, + scope: 'dynamic' as const, + }, + } + }) + } + + // Use the new utility function + void initializeIdeIntegration( + addIde, + ideToInstallExtension, + () => setShowIdeOnboarding(true), + status => setIDEInstallationState(status), + ) + }, [ autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, - setIDEInstallationState - } = t0; - let t1; - let t2; - if ($[0] !== autoConnectIdeFlag || $[1] !== ideToInstallExtension || $[2] !== setDynamicMcpConfig || $[3] !== setIDEInstallationState || $[4] !== setShowIdeOnboarding) { - t1 = () => { - const addIde = function addIde(ide) { - if (!ide) { - return; - } - const globalConfig = getGlobalConfig(); - const autoConnectEnabled = (globalConfig.autoConnectIde || autoConnectIdeFlag || isSupportedTerminal() || process.env.CLAUDE_CODE_SSE_PORT || ideToInstallExtension || isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE); - if (!autoConnectEnabled) { - return; - } - setDynamicMcpConfig(prev => { - if (prev?.ide) { - return prev; - } - return { - ...prev, - ide: { - type: ide.url.startsWith("ws:") ? "ws-ide" : "sse-ide", - url: ide.url, - ideName: ide.name, - authToken: ide.authToken, - ideRunningInWindows: ide.ideRunningInWindows, - scope: "dynamic" as const - } - }; - }); - }; - initializeIdeIntegration(addIde, ideToInstallExtension, () => setShowIdeOnboarding(true), status => setIDEInstallationState(status)); - }; - t2 = [autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState]; - $[0] = autoConnectIdeFlag; - $[1] = ideToInstallExtension; - $[2] = setDynamicMcpConfig; - $[3] = setIDEInstallationState; - $[4] = setShowIdeOnboarding; - $[5] = t1; - $[6] = t2; - } else { - t1 = $[5]; - t2 = $[6]; - } - useEffect(t1, t2); + setIDEInstallationState, + ]) } diff --git a/src/hooks/useLspPluginRecommendation.tsx b/src/hooks/useLspPluginRecommendation.tsx index aaffb43a2..610431a63 100644 --- a/src/hooks/useLspPluginRecommendation.tsx +++ b/src/hooks/useLspPluginRecommendation.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Hook for LSP plugin recommendations * @@ -11,183 +10,170 @@ import { c as _c } from "react/compiler-runtime"; * Only shows one recommendation per session. */ -import { extname, join } from 'path'; -import * as React from 'react'; -import { hasShownLspRecommendationThisSession, setLspRecommendationShownThisSession } from '../bootstrap/state.js'; -import { useNotifications } from '../context/notifications.js'; -import { useAppState } from '../state/AppState.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { logError } from '../utils/log.js'; -import { addToNeverSuggest, getMatchingLspPlugins, incrementIgnoredCount } from '../utils/plugins/lspRecommendation.js'; -import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js'; -import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; -import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; +import { extname, join } from 'path' +import * as React from 'react' +import { + hasShownLspRecommendationThisSession, + setLspRecommendationShownThisSession, +} from '../bootstrap/state.js' +import { useNotifications } from '../context/notifications.js' +import { useAppState } from '../state/AppState.js' +import { saveGlobalConfig } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { logError } from '../utils/log.js' +import { + addToNeverSuggest, + getMatchingLspPlugins, + incrementIgnoredCount, +} from '../utils/plugins/lspRecommendation.js' +import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js' +import { + getSettingsForSource, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { + installPluginAndNotify, + usePluginRecommendationBase, +} from './usePluginRecommendationBase.js' // Threshold for detecting timeout vs explicit dismiss (ms) // Menu auto-dismisses at 30s, so anything over 28s is likely timeout -const TIMEOUT_THRESHOLD_MS = 28_000; +const TIMEOUT_THRESHOLD_MS = 28_000 + export type LspRecommendationState = { - pluginId: string; - pluginName: string; - pluginDescription?: string; - fileExtension: string; - shownAt: number; // Timestamp for timeout detection -} | null; + pluginId: string + pluginName: string + pluginDescription?: string + fileExtension: string + shownAt: number // Timestamp for timeout detection +} | null + type UseLspPluginRecommendationResult = { - recommendation: LspRecommendationState; - handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void; -}; -export function useLspPluginRecommendation() { - const $ = _c(12); - const trackedFiles = useAppState(_temp); - const { - addNotification - } = useNotifications(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = new Set(); - $[0] = t0; - } else { - t0 = $[0]; - } - const checkedFilesRef = React.useRef(t0); - const { - recommendation, - clearRecommendation, - tryResolve - } = usePluginRecommendationBase(); - let t1; - let t2; - if ($[1] !== trackedFiles || $[2] !== tryResolve) { - t1 = () => { - tryResolve(async () => { - if (hasShownLspRecommendationThisSession()) { - return null; - } - const newFiles = []; - for (const file of trackedFiles) { - if (!checkedFilesRef.current.has(file)) { - checkedFilesRef.current.add(file); - newFiles.push(file); - } + recommendation: LspRecommendationState + handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void +} + +export function useLspPluginRecommendation(): UseLspPluginRecommendationResult { + const trackedFiles = useAppState(s => s.fileHistory.trackedFiles) + const { addNotification } = useNotifications() + const checkedFilesRef = React.useRef>(new Set()) + const { recommendation, clearRecommendation, tryResolve } = + usePluginRecommendationBase>() + + React.useEffect(() => { + tryResolve(async () => { + if (hasShownLspRecommendationThisSession()) return null + + const newFiles: string[] = [] + for (const file of trackedFiles) { + if (!checkedFilesRef.current.has(file)) { + checkedFilesRef.current.add(file) + newFiles.push(file) } - for (const filePath of newFiles) { - ; - try { - const matches = await getMatchingLspPlugins(filePath); - const match = matches[0]; - if (match) { - logForDebugging(`[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`); - setLspRecommendationShownThisSession(true); - return { - pluginId: match.pluginId, - pluginName: match.pluginName, - pluginDescription: match.description, - fileExtension: extname(filePath), - shownAt: Date.now() - }; + } + + for (const filePath of newFiles) { + try { + const matches = await getMatchingLspPlugins(filePath) + const match = matches[0] // official plugins prioritized + if (match) { + logForDebugging( + `[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`, + ) + setLspRecommendationShownThisSession(true) + return { + pluginId: match.pluginId, + pluginName: match.pluginName, + pluginDescription: match.description, + fileExtension: extname(filePath), + shownAt: Date.now(), } - } catch (t3) { - const error = t3; - logError(error); } + } catch (error) { + logError(error) } - return null; - }); - }; - t2 = [trackedFiles, tryResolve]; - $[1] = trackedFiles; - $[2] = tryResolve; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - React.useEffect(t1, t2); - let t3; - if ($[5] !== addNotification || $[6] !== clearRecommendation || $[7] !== recommendation) { - t3 = response => { - if (!recommendation) { - return; } - const { - pluginId, - pluginName, - shownAt - } = recommendation; - logForDebugging(`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`); - bb60: switch (response) { - case "yes": - { - installPluginAndNotify(pluginId, pluginName, "lsp-plugin", addNotification, async pluginData => { - logForDebugging(`[useLspPluginRecommendation] Installing plugin: ${pluginId}`); - const localSourcePath = typeof pluginData.entry.source === "string" ? join(pluginData.marketplaceInstallLocation, pluginData.entry.source) : undefined; - await cacheAndRegisterPlugin(pluginId, pluginData.entry, "user", undefined, localSourcePath); - const settings = getSettingsForSource("userSettings"); - updateSettingsForSource("userSettings", { + return null + }) + }, [trackedFiles, tryResolve]) + + const handleResponse = React.useCallback( + (response: 'yes' | 'no' | 'never' | 'disable') => { + if (!recommendation) return + + const { pluginId, pluginName, shownAt } = recommendation + + logForDebugging( + `[useLspPluginRecommendation] User response: ${response} for ${pluginName}`, + ) + + switch (response) { + case 'yes': + void installPluginAndNotify( + pluginId, + pluginName, + 'lsp-plugin', + addNotification, + async pluginData => { + logForDebugging( + `[useLspPluginRecommendation] Installing plugin: ${pluginId}`, + ) + const localSourcePath = + typeof pluginData.entry.source === 'string' + ? join( + pluginData.marketplaceInstallLocation, + pluginData.entry.source, + ) + : undefined + await cacheAndRegisterPlugin( + pluginId, + pluginData.entry, + 'user', + undefined, // projectPath - not needed for user scope + localSourcePath, + ) + // Enable in user settings so it loads on restart + const settings = getSettingsForSource('userSettings') + updateSettingsForSource('userSettings', { enabledPlugins: { ...settings?.enabledPlugins, - [pluginId]: true - } - }); - logForDebugging(`[useLspPluginRecommendation] Plugin installed: ${pluginId}`); - }); - break bb60; - } - case "no": - { - const elapsed = Date.now() - shownAt; - if (elapsed >= TIMEOUT_THRESHOLD_MS) { - logForDebugging(`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`); - incrementIgnoredCount(); - } - break bb60; - } - case "never": - { - addToNeverSuggest(pluginId); - break bb60; - } - case "disable": - { - saveGlobalConfig(_temp2); + [pluginId]: true, + }, + }) + logForDebugging( + `[useLspPluginRecommendation] Plugin installed: ${pluginId}`, + ) + }, + ) + break + + case 'no': { + const elapsed = Date.now() - shownAt + if (elapsed >= TIMEOUT_THRESHOLD_MS) { + logForDebugging( + `[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`, + ) + incrementIgnoredCount() } + break + } + + case 'never': + addToNeverSuggest(pluginId) + break + + case 'disable': + saveGlobalConfig(current => { + if (current.lspRecommendationDisabled) return current + return { ...current, lspRecommendationDisabled: true } + }) + break } - clearRecommendation(); - }; - $[5] = addNotification; - $[6] = clearRecommendation; - $[7] = recommendation; - $[8] = t3; - } else { - t3 = $[8]; - } - const handleResponse = t3; - let t4; - if ($[9] !== handleResponse || $[10] !== recommendation) { - t4 = { - recommendation, - handleResponse - }; - $[9] = handleResponse; - $[10] = recommendation; - $[11] = t4; - } else { - t4 = $[11]; - } - return t4; -} -function _temp2(current) { - if (current.lspRecommendationDisabled) { - return current; - } - return { - ...current, - lspRecommendationDisabled: true - }; -} -function _temp(s) { - return s.fileHistory.trackedFiles; + + clearRecommendation() + }, + [recommendation, addNotification, clearRecommendation], + ) + + return { recommendation, handleResponse } } diff --git a/src/hooks/useOfficialMarketplaceNotification.tsx b/src/hooks/useOfficialMarketplaceNotification.tsx index 7784c23d6..25cf62254 100644 --- a/src/hooks/useOfficialMarketplaceNotification.tsx +++ b/src/hooks/useOfficialMarketplaceNotification.tsx @@ -1,47 +1,67 @@ -import * as React from 'react'; -import type { Notification } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { logForDebugging } from '../utils/debug.js'; -import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js'; -import { useStartupNotification } from './notifs/useStartupNotification.js'; +import * as React from 'react' +import type { Notification } from '../context/notifications.js' +import { Text } from '../ink.js' +import { logForDebugging } from '../utils/debug.js' +import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js' +import { useStartupNotification } from './notifs/useStartupNotification.js' /** * Hook that handles official marketplace auto-installation and shows * notifications for success/failure in the bottom right of the REPL. */ -export function useOfficialMarketplaceNotification() { - useStartupNotification(_temp); -} -async function _temp() { - const result = await checkAndInstallOfficialMarketplace(); - const notifs = []; - if (result.configSaveFailed) { - logForDebugging("Showing marketplace config save failure notification"); - notifs.push({ - key: "marketplace-config-save-failed", - jsx: Failed to save marketplace retry info · Check ~/.claude.json permissions, - priority: "immediate", - timeoutMs: 10000 - }); - } - if (result.installed) { - logForDebugging("Showing marketplace installation success notification"); - notifs.push({ - key: "marketplace-installed", - jsx: ✓ Anthropic marketplace installed · /plugin to see available plugins, - priority: "immediate", - timeoutMs: 7000 - }); - } else { - if (result.skipped && result.reason === "unknown") { - logForDebugging("Showing marketplace installation failure notification"); +export function useOfficialMarketplaceNotification(): void { + useStartupNotification(async () => { + const result = await checkAndInstallOfficialMarketplace() + const notifs: Notification[] = [] + + // Check for config save failure first - this is critical + if (result.configSaveFailed) { + logForDebugging('Showing marketplace config save failure notification') + notifs.push({ + key: 'marketplace-config-save-failed', + jsx: ( + + Failed to save marketplace retry info · Check ~/.claude.json + permissions + + ), + priority: 'immediate', + timeoutMs: 10000, + }) + } + + if (result.installed) { + logForDebugging('Showing marketplace installation success notification') + notifs.push({ + key: 'marketplace-installed', + jsx: ( + + ✓ Anthropic marketplace installed · /plugin to see available plugins + + ), + priority: 'immediate', + timeoutMs: 7000, + }) + } else if (result.skipped && result.reason === 'unknown') { + logForDebugging('Showing marketplace installation failure notification') notifs.push({ - key: "marketplace-install-failed", - jsx: Failed to install Anthropic marketplace · Will retry on next startup, - priority: "immediate", - timeoutMs: 8000 - }); + key: 'marketplace-install-failed', + jsx: ( + + Failed to install Anthropic marketplace · Will retry on next startup + + ), + priority: 'immediate', + timeoutMs: 8000, + }) } - } - return notifs; + // Don't show notifications for: + // - already_installed (user already has it) + // - policy_blocked (enterprise policy, don't nag) + // - already_attempted (handled by retry logic now) + // - git_unavailable (marketplace is a nice-to-have; if git is missing + // or is a non-functional macOS xcrun shim, retry silently on backoff + // rather than nagging — the user will sort git out for other reasons) + return notifs + }) } diff --git a/src/hooks/usePluginRecommendationBase.tsx b/src/hooks/usePluginRecommendationBase.tsx index db0167ccf..23930fba4 100644 --- a/src/hooks/usePluginRecommendationBase.tsx +++ b/src/hooks/usePluginRecommendationBase.tsx @@ -1,19 +1,19 @@ -import { c as _c } from "react/compiler-runtime"; /** * Shared state machine + install helper for plugin-recommendation hooks * (LSP, claude-code-hint). Centralizes the gate chain, async-guard, * and success/failure notification JSX so new sources stay small. */ -import figures from 'figures'; -import * as React from 'react'; -import { getIsRemoteMode } from '../bootstrap/state.js'; -import type { useNotifications } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { logError } from '../utils/log.js'; -import { getPluginById } from '../utils/plugins/marketplaceManager.js'; -type AddNotification = ReturnType['addNotification']; -type PluginData = NonNullable>>; +import figures from 'figures' +import * as React from 'react' +import { getIsRemoteMode } from '../bootstrap/state.js' +import type { useNotifications } from '../context/notifications.js' +import { Text } from '../ink.js' +import { logError } from '../utils/log.js' +import { getPluginById } from '../utils/plugins/marketplaceManager.js' + +type AddNotification = ReturnType['addNotification'] +type PluginData = NonNullable>> /** * Call tryResolve inside a useEffect; it applies standard gates (remote @@ -21,84 +21,72 @@ type PluginData = NonNullable>>; * becomes the recommendation. Include tryResolve in effect deps — its * identity tracks recommendation, so clearing re-triggers resolution. */ -export function usePluginRecommendationBase() { - const $ = _c(6); - const [recommendation, setRecommendation] = React.useState(null); - const isCheckingRef = React.useRef(false); - let t0; - if ($[0] !== recommendation) { - t0 = resolve => { - if (getIsRemoteMode()) { - return; - } - if (recommendation) { - return; - } - if (isCheckingRef.current) { - return; - } - isCheckingRef.current = true; - resolve().then(rec => { - if (rec) { - setRecommendation(rec); - } - }).catch(logError).finally(() => { - isCheckingRef.current = false; - }); - }; - $[0] = recommendation; - $[1] = t0; - } else { - t0 = $[1]; - } - const tryResolve = t0; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setRecommendation(null); - $[2] = t1; - } else { - t1 = $[2]; - } - const clearRecommendation = t1; - let t2; - if ($[3] !== recommendation || $[4] !== tryResolve) { - t2 = { - recommendation, - clearRecommendation, - tryResolve - }; - $[3] = recommendation; - $[4] = tryResolve; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; +export function usePluginRecommendationBase(): { + recommendation: T | null + clearRecommendation: () => void + tryResolve: (resolve: () => Promise) => void +} { + const [recommendation, setRecommendation] = React.useState(null) + const isCheckingRef = React.useRef(false) + + const tryResolve = React.useCallback( + (resolve: () => Promise) => { + if (getIsRemoteMode()) return + if (recommendation) return + if (isCheckingRef.current) return + + isCheckingRef.current = true + void resolve() + .then(rec => { + if (rec) setRecommendation(rec) + }) + .catch(logError) + .finally(() => { + isCheckingRef.current = false + }) + }, + [recommendation], + ) + + const clearRecommendation = React.useCallback( + () => setRecommendation(null), + [], + ) + + return { recommendation, clearRecommendation, tryResolve } } /** Look up plugin, run install(), emit standard success/failure notification. */ -export async function installPluginAndNotify(pluginId: string, pluginName: string, keyPrefix: string, addNotification: AddNotification, install: (pluginData: PluginData) => Promise): Promise { +export async function installPluginAndNotify( + pluginId: string, + pluginName: string, + keyPrefix: string, + addNotification: AddNotification, + install: (pluginData: PluginData) => Promise, +): Promise { try { - const pluginData = await getPluginById(pluginId); + const pluginData = await getPluginById(pluginId) if (!pluginData) { - throw new Error(`Plugin ${pluginId} not found in marketplace`); + throw new Error(`Plugin ${pluginId} not found in marketplace`) } - await install(pluginData); + await install(pluginData) addNotification({ key: `${keyPrefix}-installed`, - jsx: + jsx: ( + {figures.tick} {pluginName} installed · restart to apply - , + + ), priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } catch (error) { - logError(error); + logError(error) addNotification({ key: `${keyPrefix}-install-failed`, jsx: Failed to install {pluginName}, priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } } diff --git a/src/hooks/usePromptsFromClaudeInChrome.tsx b/src/hooks/usePromptsFromClaudeInChrome.tsx index d71f08353..be7fa8363 100644 --- a/src/hooks/usePromptsFromClaudeInChrome.tsx +++ b/src/hooks/usePromptsFromClaudeInChrome.tsx @@ -1,70 +1,129 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; -import { useEffect, useRef } from 'react'; -import { logError } from 'src/utils/log.js'; -import { z } from 'zod/v4'; -import { callIdeRpc } from '../services/mcp/client.js'; -import type { ConnectedMCPServer, MCPServerConnection } from '../services/mcp/types.js'; -import type { PermissionMode } from '../types/permissions.js'; -import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isTrackedClaudeInChromeTabId } from '../utils/claudeInChrome/common.js'; -import { lazySchema } from '../utils/lazySchema.js'; -import { enqueuePendingNotification } from '../utils/messageQueueManager.js'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' +import { useEffect, useRef } from 'react' +import { logError } from 'src/utils/log.js' +import { z } from 'zod/v4' +import { callIdeRpc } from '../services/mcp/client.js' +import type { + ConnectedMCPServer, + MCPServerConnection, +} from '../services/mcp/types.js' +import type { PermissionMode } from '../types/permissions.js' +import { + CLAUDE_IN_CHROME_MCP_SERVER_NAME, + isTrackedClaudeInChromeTabId, +} from '../utils/claudeInChrome/common.js' +import { lazySchema } from '../utils/lazySchema.js' +import { enqueuePendingNotification } from '../utils/messageQueueManager.js' // Schema for the prompt notification from Chrome extension (JSON-RPC 2.0 format) -const ClaudeInChromePromptNotificationSchema = lazySchema(() => z.object({ - method: z.literal('notifications/message'), - params: z.object({ - prompt: z.string(), - image: z.object({ - type: z.literal('base64'), - media_type: z.enum(['image/jpeg', 'image/png', 'image/gif', 'image/webp']), - data: z.string() - }).optional(), - tabId: z.number().optional() - }) -})); +const ClaudeInChromePromptNotificationSchema = lazySchema(() => + z.object({ + method: z.literal('notifications/message'), + params: z.object({ + prompt: z.string(), + image: z + .object({ + type: z.literal('base64'), + media_type: z.enum([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]), + data: z.string(), + }) + .optional(), + tabId: z.number().optional(), + }), + }), +) /** * A hook that listens for prompt notifications from the Claude for Chrome extension, * enqueues them as user prompts, and syncs permission mode changes to the extension. */ -export function usePromptsFromClaudeInChrome(mcpClients, toolPermissionMode) { - const $ = _c(6); - useRef(undefined); - let t0; - if ($[0] !== mcpClients) { - t0 = [mcpClients]; - $[0] = mcpClients; - $[1] = t0; - } else { - t0 = $[1]; - } - useEffect(_temp, t0); - let t1; - let t2; - if ($[2] !== mcpClients || $[3] !== toolPermissionMode) { - t1 = () => { - const chromeClient = findChromeClient(mcpClients); - if (!chromeClient) { - return; - } - const chromeMode = toolPermissionMode === "bypassPermissions" ? "skip_all_permission_checks" : "ask"; - callIdeRpc("set_permission_mode", { - mode: chromeMode - }, chromeClient); - }; - t2 = [mcpClients, toolPermissionMode]; - $[2] = mcpClients; - $[3] = toolPermissionMode; - $[4] = t1; - $[5] = t2; - } else { - t1 = $[4]; - t2 = $[5]; - } - useEffect(t1, t2); +export function usePromptsFromClaudeInChrome( + mcpClients: MCPServerConnection[], + toolPermissionMode: PermissionMode, +): void { + const mcpClientRef = useRef(undefined) + + useEffect(() => { + if ("external" !== 'ant') { + return + } + + const mcpClient = findChromeClient(mcpClients) + if (mcpClientRef.current !== mcpClient) { + mcpClientRef.current = mcpClient + } + + if (mcpClient) { + mcpClient.client.setNotificationHandler( + ClaudeInChromePromptNotificationSchema(), + notification => { + if (mcpClientRef.current !== mcpClient) { + return + } + const { tabId, prompt, image } = notification.params + + // Process notifications from tabs we're tracking since notifications are broadcasted + if ( + typeof tabId !== 'number' || + !isTrackedClaudeInChromeTabId(tabId) + ) { + return + } + + try { + // Build content blocks if there's an image, otherwise just use the prompt string + if (image) { + const contentBlocks: ContentBlockParam[] = [ + { type: 'text', text: prompt }, + { + type: 'image', + source: { + type: image.type, + media_type: image.media_type, + data: image.data, + }, + }, + ] + enqueuePendingNotification({ + value: contentBlocks, + mode: 'prompt', + }) + } else { + enqueuePendingNotification({ value: prompt, mode: 'prompt' }) + } + } catch (error) { + logError(error as Error) + } + }, + ) + } + }, [mcpClients]) + + // Sync permission mode with Chrome extension whenever it changes + useEffect(() => { + const chromeClient = findChromeClient(mcpClients) + if (!chromeClient) return + + const chromeMode = + toolPermissionMode === 'bypassPermissions' + ? 'skip_all_permission_checks' + : 'ask' + + void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient) + }, [mcpClients, toolPermissionMode]) } -function _temp() {} -function findChromeClient(clients: MCPServerConnection[]): ConnectedMCPServer | undefined { - return clients.find((client): client is ConnectedMCPServer => client.type === 'connected' && client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME); + +function findChromeClient( + clients: MCPServerConnection[], +): ConnectedMCPServer | undefined { + return clients.find( + (client): client is ConnectedMCPServer => + client.type === 'connected' && + client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, + ) } diff --git a/src/hooks/useReplBridge.tsx b/src/hooks/useReplBridge.tsx index 6a767a1cd..522202891 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -1,32 +1,52 @@ -import { feature } from 'bun:bundle'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { setMainLoopModelOverride } from '../bootstrap/state.js'; -import { type BridgePermissionCallbacks, type BridgePermissionResponse, isBridgePermissionResponse } from '../bridge/bridgePermissionCallbacks.js'; -import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js'; -import { extractInboundMessageFields } from '../bridge/inboundMessages.js'; -import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js'; -import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js'; -import type { Command } from '../commands.js'; -import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js'; -import { getRemoteSessionUrl } from '../constants/product.js'; -import { useNotifications } from '../context/notifications.js'; -import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js'; -import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'; -import { Text } from '../ink.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js'; -import type { Message } from '../types/message.js'; -import { getCwd } from '../utils/cwd.js'; -import { logForDebugging } from '../utils/debug.js'; -import { errorMessage } from '../utils/errors.js'; -import { enqueue } from '../utils/messageQueueManager.js'; -import { buildSystemInitMessage } from '../utils/messages/systemInit.js'; -import { createBridgeStatusMessage, createSystemMessage } from '../utils/messages.js'; -import { getAutoModeUnavailableNotification, getAutoModeUnavailableReason, isAutoModeGateEnabled, isBypassPermissionsModeDisabled, transitionPermissionMode } from '../utils/permissions/permissionSetup.js'; -import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'; +import { feature } from 'bun:bundle' +import React, { useCallback, useEffect, useRef } from 'react' +import { setMainLoopModelOverride } from '../bootstrap/state.js' +import { + type BridgePermissionCallbacks, + type BridgePermissionResponse, + isBridgePermissionResponse, +} from '../bridge/bridgePermissionCallbacks.js' +import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js' +import { extractInboundMessageFields } from '../bridge/inboundMessages.js' +import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js' +import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js' +import type { Command } from '../commands.js' +import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js' +import { getRemoteSessionUrl } from '../constants/product.js' +import { useNotifications } from '../context/notifications.js' +import type { + PermissionMode, + SDKMessage, +} from '../entrypoints/agentSdkTypes.js' +import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js' +import { Text } from '../ink.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { + useAppState, + useAppStateStore, + useSetAppState, +} from '../state/AppState.js' +import type { Message } from '../types/message.js' +import { getCwd } from '../utils/cwd.js' +import { logForDebugging } from '../utils/debug.js' +import { errorMessage } from '../utils/errors.js' +import { enqueue } from '../utils/messageQueueManager.js' +import { buildSystemInitMessage } from '../utils/messages/systemInit.js' +import { + createBridgeStatusMessage, + createSystemMessage, +} from '../utils/messages.js' +import { + getAutoModeUnavailableNotification, + getAutoModeUnavailableReason, + isAutoModeGateEnabled, + isBypassPermissionsModeDisabled, + transitionPermissionMode, +} from '../utils/permissions/permissionSetup.js' +import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js' /** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */ -export const BRIDGE_FAILURE_DISMISS_MS = 10_000; +export const BRIDGE_FAILURE_DISMISS_MS = 10_000 /** * Max consecutive initReplBridge failures before the hook stops re-attempting @@ -37,7 +57,7 @@ export const BRIDGE_FAILURE_DISMISS_MS = 10_000; * top stuck client generated 2,879 × 401/day alone (17% of all 401s on the * route). */ -const MAX_CONSECUTIVE_INIT_FAILURES = 3; +const MAX_CONSECUTIVE_INIT_FAILURES = 3 /** * Hook that initializes an always-on bridge connection in the background @@ -50,44 +70,52 @@ const MAX_CONSECUTIVE_INIT_FAILURES = 3; * * Inbound messages from claude.ai are injected into the REPL via queuedCommands. */ -export function useReplBridge(messages: Message[], setMessages: (action: React.SetStateAction) => void, abortControllerRef: React.RefObject, commands: readonly Command[], mainLoopModel: string): { - sendBridgeResult: () => void; -} { - const handleRef = useRef(null); - const teardownPromiseRef = useRef | undefined>(undefined); - const lastWrittenIndexRef = useRef(0); +export function useReplBridge( + messages: Message[], + setMessages: (action: React.SetStateAction) => void, + abortControllerRef: React.RefObject, + commands: readonly Command[], + mainLoopModel: string, +): { sendBridgeResult: () => void } { + const handleRef = useRef(null) + const teardownPromiseRef = useRef | undefined>(undefined) + const lastWrittenIndexRef = useRef(0) // Tracks UUIDs already flushed as initial messages. Persists across // bridge reconnections so Bridge #2+ only sends new messages — sending // duplicate UUIDs causes the server to kill the WebSocket. - const flushedUUIDsRef = useRef(new Set()); - const failureTimeoutRef = useRef | undefined>(undefined); + const flushedUUIDsRef = useRef(new Set()) + const failureTimeoutRef = useRef | undefined>( + undefined, + ) // Persists across effect re-runs (unlike the effect's local state). Reset // only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown // for the session, regardless of replBridgeEnabled re-toggling. - const consecutiveFailuresRef = useRef(0); - const setAppState = useSetAppState(); - const commandsRef = useRef(commands); - commandsRef.current = commands; - const mainLoopModelRef = useRef(mainLoopModel); - mainLoopModelRef.current = mainLoopModel; - const messagesRef = useRef(messages); - messagesRef.current = messages; - const store = useAppStateStore(); - const { - addNotification - } = useNotifications(); - const replBridgeEnabled = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.replBridgeEnabled) : false; - const replBridgeConnected = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.replBridgeConnected) : false; - const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_1 => s_1.replBridgeOutboundOnly) : false; - const replBridgeInitialName = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_2 => s_2.replBridgeInitialName) : undefined; + const consecutiveFailuresRef = useRef(0) + const setAppState = useSetAppState() + const commandsRef = useRef(commands) + commandsRef.current = commands + const mainLoopModelRef = useRef(mainLoopModel) + mainLoopModelRef.current = mainLoopModel + const messagesRef = useRef(messages) + messagesRef.current = messages + const store = useAppStateStore() + const { addNotification } = useNotifications() + const replBridgeEnabled = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeEnabled) + : false + const replBridgeConnected = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeConnected) + : false + const replBridgeOutboundOnly = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeOutboundOnly) + : false + const replBridgeInitialName = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeInitialName) + : undefined // Initialize/teardown bridge when enabled state changes. // Passes current messages as initialMessages so the remote session @@ -97,39 +125,48 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // negative pattern (if (!feature(...)) return) does NOT eliminate // dynamic imports below. if (feature('BRIDGE_MODE')) { - if (!replBridgeEnabled) return; - const outboundOnly = replBridgeOutboundOnly; + if (!replBridgeEnabled) return + + const outboundOnly = replBridgeOutboundOnly function notifyBridgeFailed(detail?: string): void { - if (outboundOnly) return; + if (outboundOnly) return addNotification({ key: 'bridge-failed', - jsx: <> + jsx: ( + <> Remote Control failed {detail && · {detail}} - , - priority: 'immediate' - }); + + ), + priority: 'immediate', + }) } + if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_INIT_FAILURES) { - logForDebugging(`[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`); + logForDebugging( + `[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`, + ) // Clear replBridgeEnabled so /remote-control doesn't mistakenly show // BridgeDisconnectDialog for a bridge that never connected. - const fuseHint = 'disabled after repeated failures · restart to retry'; - notifyBridgeFailed(fuseHint); + const fuseHint = 'disabled after repeated failures · restart to retry' + notifyBridgeFailed(fuseHint) setAppState(prev => { - if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) return prev; + if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) + return prev return { ...prev, replBridgeError: fuseHint, - replBridgeEnabled: false - }; - }); - return; + replBridgeEnabled: false, + } + }) + return } - let cancelled = false; + + let cancelled = false // Capture messages.length now so we don't re-send initial messages // through writeMessages after the bridge connects. - const initialMessageCount = messages.length; + const initialMessageCount = messages.length + void (async () => { try { // Wait for any in-progress teardown to complete before registering @@ -137,20 +174,22 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // the previous teardown races with the new register call, and the // server may tear down the freshly-created environment. if (teardownPromiseRef.current) { - logForDebugging('[bridge:repl] Hook: waiting for previous teardown to complete before re-init'); - await teardownPromiseRef.current; - teardownPromiseRef.current = undefined; - logForDebugging('[bridge:repl] Hook: previous teardown complete, proceeding with re-init'); + logForDebugging( + '[bridge:repl] Hook: waiting for previous teardown to complete before re-init', + ) + await teardownPromiseRef.current + teardownPromiseRef.current = undefined + logForDebugging( + '[bridge:repl] Hook: previous teardown complete, proceeding with re-init', + ) } - if (cancelled) return; + if (cancelled) return // Dynamic import so the module is tree-shaken in external builds - const { - initReplBridge - } = await import('../bridge/initReplBridge.js'); - const { - shouldShowAppUpgradeMessage - } = await import('../bridge/envLessBridgeConfig.js'); + const { initReplBridge } = await import('../bridge/initReplBridge.js') + const { shouldShowAppUpgradeMessage } = await import( + '../bridge/envLessBridgeConfig.js' + ) // Assistant mode: perpetual bridge session — claude.ai shows one // continuous conversation across CLI restarts instead of a new @@ -161,12 +200,10 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // pointer-clear so the session survives clean exits, not just // crashes. Non-assistant bridges clear the pointer on teardown // (crash-recovery only). - let perpetual = false; + let perpetual = false if (feature('KAIROS')) { - const { - isAssistantMode - } = await import('../assistant/index.js'); - perpetual = isAssistantMode(); + const { isAssistantMode } = await import('../assistant/index.js') + perpetual = isAssistantMode() } // When a user message arrives from claude.ai, inject it into the REPL. @@ -179,30 +216,32 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // later, which is fine (web messages aren't rapid-fire). async function handleInboundMessage(msg: SDKMessage): Promise { try { - const fields = extractInboundMessageFields(msg); - if (!fields) return; - const { - uuid - } = fields; + const fields = extractInboundMessageFields(msg) + if (!fields) return + + const { uuid } = fields // Dynamic import keeps the bridge code out of non-BRIDGE_MODE builds. - const { - resolveAndPrepend - } = await import('../bridge/inboundAttachments.js'); - let sanitized = fields.content; + const { resolveAndPrepend } = await import( + '../bridge/inboundAttachments.js' + ) + let sanitized = fields.content if (feature('KAIROS_GITHUB_WEBHOOKS')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - sanitizeInboundWebhookContent - } = require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js'); + const { sanitizeInboundWebhookContent } = + require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js') /* eslint-enable @typescript-eslint/no-require-imports */ - if (typeof fields.content === 'string') { - sanitized = sanitizeInboundWebhookContent(fields.content); - } + sanitized = sanitizeInboundWebhookContent(fields.content) } - const content = await resolveAndPrepend(msg, sanitized); - const preview = typeof content === 'string' ? content.slice(0, 80) : `[${content.length} content blocks]`; - logForDebugging(`[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`); + const content = await resolveAndPrepend(msg, sanitized) + + const preview = + typeof content === 'string' + ? content.slice(0, 80) + : `[${content.length} content blocks]` + logForDebugging( + `[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`, + ) enqueue({ value: content, mode: 'prompt' as const, @@ -213,54 +252,73 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // This keeps exit-word suppression and immediate-command blocks // intact for any code path that checks skipSlashCommands directly. skipSlashCommands: true, - bridgeOrigin: true - }); + bridgeOrigin: true, + }) } catch (e) { - logForDebugging(`[bridge:repl] handleInboundMessage failed: ${e}`, { - level: 'error' - }); + logForDebugging( + `[bridge:repl] handleInboundMessage failed: ${e}`, + { level: 'error' }, + ) } } // State change callback — maps bridge lifecycle events to AppState. - function handleStateChange(state: BridgeState, detail_0?: string): void { - if (cancelled) return; + function handleStateChange( + state: BridgeState, + detail?: string, + ): void { + if (cancelled) return if (outboundOnly) { - logForDebugging(`[bridge:repl] Mirror state=${state}${detail_0 ? ` detail=${detail_0}` : ''}`); + logForDebugging( + `[bridge:repl] Mirror state=${state}${detail ? ` detail=${detail}` : ''}`, + ) // Sync replBridgeConnected so the forwarding effect starts/stops // writing as the transport comes up or dies. if (state === 'failed') { - setAppState(prev_3 => { - if (!prev_3.replBridgeConnected) return prev_3; - return { - ...prev_3, - replBridgeConnected: false - }; - }); + setAppState(prev => { + if (!prev.replBridgeConnected) return prev + return { ...prev, replBridgeConnected: false } + }) } else if (state === 'ready' || state === 'connected') { - setAppState(prev_4 => { - if (prev_4.replBridgeConnected) return prev_4; - return { - ...prev_4, - replBridgeConnected: true - }; - }); + setAppState(prev => { + if (prev.replBridgeConnected) return prev + return { ...prev, replBridgeConnected: true } + }) } - return; + return } - const handle = handleRef.current; + const handle = handleRef.current switch (state) { case 'ready': - setAppState(prev_9 => { - const connectUrl = handle && handle.environmentId !== '' ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) : prev_9.replBridgeConnectUrl; - const sessionUrl = handle ? getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl) : prev_9.replBridgeSessionUrl; - const envId = handle?.environmentId; - const sessionId = handle?.bridgeSessionId; - if (prev_9.replBridgeConnected && !prev_9.replBridgeSessionActive && !prev_9.replBridgeReconnecting && prev_9.replBridgeConnectUrl === connectUrl && prev_9.replBridgeSessionUrl === sessionUrl && prev_9.replBridgeEnvironmentId === envId && prev_9.replBridgeSessionId === sessionId) { - return prev_9; + setAppState(prev => { + const connectUrl = + handle && handle.environmentId !== '' + ? buildBridgeConnectUrl( + handle.environmentId, + handle.sessionIngressUrl, + ) + : prev.replBridgeConnectUrl + const sessionUrl = handle + ? getRemoteSessionUrl( + handle.bridgeSessionId, + handle.sessionIngressUrl, + ) + : prev.replBridgeSessionUrl + const envId = handle?.environmentId + const sessionId = handle?.bridgeSessionId + if ( + prev.replBridgeConnected && + !prev.replBridgeSessionActive && + !prev.replBridgeReconnecting && + prev.replBridgeConnectUrl === connectUrl && + prev.replBridgeSessionUrl === sessionUrl && + prev.replBridgeEnvironmentId === envId && + prev.replBridgeSessionId === sessionId + ) { + return prev } return { - ...prev_9, + ...prev, replBridgeConnected: true, replBridgeSessionActive: false, replBridgeReconnecting: false, @@ -268,35 +326,40 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S replBridgeSessionUrl: sessionUrl, replBridgeEnvironmentId: envId, replBridgeSessionId: sessionId, - replBridgeError: undefined - }; - }); - break; - case 'connected': - { - setAppState(prev_8 => { - if (prev_8.replBridgeSessionActive) return prev_8; - return { - ...prev_8, - replBridgeConnected: true, - replBridgeSessionActive: true, - replBridgeReconnecting: false, - replBridgeError: undefined - }; - }); - // Send system/init so remote clients (web/iOS/Android) get - // session metadata. REPL uses query() directly — never hits - // QueryEngine's SDKMessage layer — so this is the only path - // to put system/init on the REPL-bridge wire. Skills load is - // async (memoized, cheap after REPL startup); fire-and-forget - // so the connected-state transition isn't blocked. - if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', true)) { - void (async () => { - try { - const skills = await getSlashCommandToolSkills(getCwd()); - if (cancelled) return; - const state_0 = store.getState(); - handleRef.current?.writeSdkMessages([buildSystemInitMessage({ + replBridgeError: undefined, + } + }) + break + case 'connected': { + setAppState(prev => { + if (prev.replBridgeSessionActive) return prev + return { + ...prev, + replBridgeConnected: true, + replBridgeSessionActive: true, + replBridgeReconnecting: false, + replBridgeError: undefined, + } + }) + // Send system/init so remote clients (web/iOS/Android) get + // session metadata. REPL uses query() directly — never hits + // QueryEngine's SDKMessage layer — so this is the only path + // to put system/init on the REPL-bridge wire. Skills load is + // async (memoized, cheap after REPL startup); fire-and-forget + // so the connected-state transition isn't blocked. + if ( + getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_bridge_system_init', + false, + ) + ) { + void (async () => { + try { + const skills = await getSlashCommandToolSkills(getCwd()) + if (cancelled) return + const state = store.getState() + handleRef.current?.writeSdkMessages([ + buildSystemInitMessage({ // tools/mcpClients/plugins redacted for REPL-bridge: // MCP-prefixed tool names and server names leak which // integrations the user has wired up; plugin paths leak @@ -308,112 +371,119 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S tools: [], mcpClients: [], model: mainLoopModelRef.current, - permissionMode: state_0.toolPermissionContext.mode as PermissionMode, - // TODO: avoid the cast + permissionMode: state.toolPermissionContext + .mode as PermissionMode, // TODO: avoid the cast // Remote clients can only invoke bridge-safe commands — // advertising unsafe ones (local-jsx, unallowed local) // would let mobile/web attempt them and hit errors. - commands: commandsRef.current.filter(isBridgeSafeCommand), - agents: state_0.agentDefinitions.activeAgents, + commands: + commandsRef.current.filter(isBridgeSafeCommand), + agents: state.agentDefinitions.activeAgents, skills, plugins: [], - fastMode: state_0.fastMode - })]); - } catch (err_0) { - logForDebugging(`[bridge:repl] Failed to send system/init: ${errorMessage(err_0)}`, { - level: 'error' - }); - } - })(); - } - break; + fastMode: state.fastMode, + }), + ]) + } catch (err) { + logForDebugging( + `[bridge:repl] Failed to send system/init: ${errorMessage(err)}`, + { level: 'error' }, + ) + } + })() } + break + } case 'reconnecting': - setAppState(prev_7 => { - if (prev_7.replBridgeReconnecting) return prev_7; + setAppState(prev => { + if (prev.replBridgeReconnecting) return prev return { - ...prev_7, + ...prev, replBridgeReconnecting: true, - replBridgeSessionActive: false - }; - }); - break; + replBridgeSessionActive: false, + } + }) + break case 'failed': // Clear any previous failure dismiss timer - clearTimeout(failureTimeoutRef.current); - notifyBridgeFailed(detail_0); - setAppState(prev_5 => ({ - ...prev_5, - replBridgeError: detail_0, + clearTimeout(failureTimeoutRef.current) + notifyBridgeFailed(detail) + setAppState(prev => ({ + ...prev, + replBridgeError: detail, replBridgeReconnecting: false, replBridgeSessionActive: false, - replBridgeConnected: false - })); + replBridgeConnected: false, + })) // Auto-disable after timeout so the hook stops retrying. failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_6 => { - if (!prev_6.replBridgeError) return prev_6; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_6, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); - break; + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) + break } } // Map of pending bridge permission response handlers, keyed by request_id. // Each entry is an onResponse handler waiting for CCR to reply. - const pendingPermissionHandlers = new Map void>(); + const pendingPermissionHandlers = new Map< + string, + (response: BridgePermissionResponse) => void + >() // Dispatch incoming control_response messages to registered handlers - function handlePermissionResponse(msg_0: SDKControlResponse): void { - const requestId = (msg_0 as any).response?.request_id; - if (!requestId) return; - const handler = pendingPermissionHandlers.get(requestId); + function handlePermissionResponse(msg: SDKControlResponse): void { + const requestId = msg.response?.request_id + if (!requestId) return + const handler = pendingPermissionHandlers.get(requestId) if (!handler) { - logForDebugging(`[bridge:repl] No handler for control_response request_id=${requestId}`); - return; + logForDebugging( + `[bridge:repl] No handler for control_response request_id=${requestId}`, + ) + return } - pendingPermissionHandlers.delete(requestId); + pendingPermissionHandlers.delete(requestId) // Extract the permission decision from the control_response payload - const inner = (msg_0 as any).response; - if (inner.subtype === 'success' && inner.response && isBridgePermissionResponse(inner.response)) { - handler(inner.response); + const inner = msg.response + if ( + inner.subtype === 'success' && + inner.response && + isBridgePermissionResponse(inner.response) + ) { + handler(inner.response) } } - const handle_0 = await initReplBridge({ + + const handle = await initReplBridge({ outboundOnly, tags: outboundOnly ? ['ccr-mirror'] : undefined, onInboundMessage: handleInboundMessage, onPermissionResponse: handlePermissionResponse, onInterrupt() { - abortControllerRef.current?.abort(); + abortControllerRef.current?.abort() }, onSetModel(model) { - const resolved = model === 'default' ? null : model ?? null; - setMainLoopModelOverride(resolved); - setAppState(prev_10 => { - if (prev_10.mainLoopModelForSession === resolved) return prev_10; - return { - ...prev_10, - mainLoopModelForSession: resolved - }; - }); + const resolved = model === 'default' ? null : (model ?? null) + setMainLoopModelOverride(resolved) + setAppState(prev => { + if (prev.mainLoopModelForSession === resolved) return prev + return { ...prev, mainLoopModelForSession: resolved } + }) }, onSetMaxThinkingTokens(maxTokens) { - const enabled = maxTokens !== null; - setAppState(prev_11 => { - if (prev_11.thinkingEnabled === enabled) return prev_11; - return { - ...prev_11, - thinkingEnabled: enabled - }; - }); + const enabled = maxTokens !== null + setAppState(prev => { + if (prev.thinkingEnabled === enabled) return prev + return { ...prev, thinkingEnabled: enabled } + }) }, onSetPermissionMode(mode) { // Policy guards MUST fire before transitionPermissionMode — @@ -430,190 +500,240 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S if (isBypassPermissionsModeDisabled()) { return { ok: false, - error: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration' - }; + error: + 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration', + } } - if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) { + if ( + !store.getState().toolPermissionContext + .isBypassPermissionsModeAvailable + ) { return { ok: false, - error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions' - }; + error: + 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions', + } } } - if (feature('TRANSCRIPT_CLASSIFIER') && mode === 'auto' && !isAutoModeGateEnabled()) { - const reason = getAutoModeUnavailableReason(); + if ( + feature('TRANSCRIPT_CLASSIFIER') && + mode === 'auto' && + !isAutoModeGateEnabled() + ) { + const reason = getAutoModeUnavailableReason() return { ok: false, - error: reason ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` : 'Cannot set permission mode to auto' - }; + error: reason + ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` + : 'Cannot set permission mode to auto', + } } // Guards passed — apply via the centralized transition so // prePlanMode stashing and auto-mode state sync all fire. - setAppState(prev_12 => { - const current = prev_12.toolPermissionContext.mode; - if (current === mode) return prev_12; - const next = transitionPermissionMode(current, mode, prev_12.toolPermissionContext); + setAppState(prev => { + const current = prev.toolPermissionContext.mode + if (current === mode) return prev + const next = transitionPermissionMode( + current, + mode, + prev.toolPermissionContext, + ) return { - ...prev_12, - toolPermissionContext: { - ...next, - mode - } - }; - }); + ...prev, + toolPermissionContext: { ...next, mode }, + } + }) // Recheck queued permission prompts now that mode changed. setImmediate(() => { getLeaderToolUseConfirmQueue()?.(currentQueue => { currentQueue.forEach(item => { - void item.recheckPermission(); - }); - return currentQueue; - }); - }); - return { - ok: true - }; + void item.recheckPermission() + }) + return currentQueue + }) + }) + return { ok: true } }, onStateChange: handleStateChange, initialMessages: messages.length > 0 ? messages : undefined, getMessages: () => messagesRef.current, previouslyFlushedUUIDs: flushedUUIDsRef.current, initialName: replBridgeInitialName, - perpetual - }); + perpetual, + }) if (cancelled) { // Effect was cancelled while initReplBridge was in flight. // Tear down the handle to avoid leaking resources (poll loop, // WebSocket, registered environment, cleanup callback). - logForDebugging(`[bridge:repl] Hook: init cancelled during flight, tearing down${handle_0 ? ` env=${handle_0.environmentId}` : ''}`); - if (handle_0) { - void handle_0.teardown(); + logForDebugging( + `[bridge:repl] Hook: init cancelled during flight, tearing down${handle ? ` env=${handle.environmentId}` : ''}`, + ) + if (handle) { + void handle.teardown() } - return; + return } - if (!handle_0) { + if (!handle) { // initReplBridge returned null — a precondition failed. For most // cases (no_oauth, policy_denied, etc.) onStateChange('failed') // already fired with a specific hint. The GrowthBook-gate-off case // is intentionally silent — not a failure, just not rolled out. - consecutiveFailuresRef.current++; - logForDebugging(`[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`); - clearTimeout(failureTimeoutRef.current); - setAppState(prev_13 => ({ - ...prev_13, - replBridgeError: prev_13.replBridgeError ?? 'check debug logs for details' - })); + consecutiveFailuresRef.current++ + logForDebugging( + `[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`, + ) + clearTimeout(failureTimeoutRef.current) + setAppState(prev => ({ + ...prev, + replBridgeError: + prev.replBridgeError ?? 'check debug logs for details', + })) failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_14 => { - if (!prev_14.replBridgeError) return prev_14; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_14, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); - return; + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) + return } - handleRef.current = handle_0; - setReplBridgeHandle(handle_0); - consecutiveFailuresRef.current = 0; + handleRef.current = handle + setReplBridgeHandle(handle) + consecutiveFailuresRef.current = 0 // Skip initial messages in the forwarding effect — they were // already loaded as session events during creation. - lastWrittenIndexRef.current = initialMessageCount; + lastWrittenIndexRef.current = initialMessageCount + if (outboundOnly) { - setAppState(prev_15 => { - if (prev_15.replBridgeConnected && prev_15.replBridgeSessionId === handle_0.bridgeSessionId) return prev_15; + setAppState(prev => { + if ( + prev.replBridgeConnected && + prev.replBridgeSessionId === handle.bridgeSessionId + ) + return prev return { - ...prev_15, + ...prev, replBridgeConnected: true, - replBridgeSessionId: handle_0.bridgeSessionId, + replBridgeSessionId: handle.bridgeSessionId, replBridgeSessionUrl: undefined, replBridgeConnectUrl: undefined, - replBridgeError: undefined - }; - }); - logForDebugging(`[bridge:repl] Mirror initialized, session=${handle_0.bridgeSessionId}`); + replBridgeError: undefined, + } + }) + logForDebugging( + `[bridge:repl] Mirror initialized, session=${handle.bridgeSessionId}`, + ) } else { // Build bridge permission callbacks so the interactive permission // handler can race bridge responses against local user interaction. const permissionCallbacks: BridgePermissionCallbacks = { - sendRequest(requestId_0, toolName, input, toolUseId, description, permissionSuggestions, blockedPath) { - handle_0.sendControlRequest({ + sendRequest( + requestId, + toolName, + input, + toolUseId, + description, + permissionSuggestions, + blockedPath, + ) { + handle.sendControlRequest({ type: 'control_request', - request_id: requestId_0, + request_id: requestId, request: { subtype: 'can_use_tool', tool_name: toolName, input, tool_use_id: toolUseId, description, - ...(permissionSuggestions ? { - permission_suggestions: permissionSuggestions - } : {}), - ...(blockedPath ? { - blocked_path: blockedPath - } : {}) - } - }); + ...(permissionSuggestions + ? { permission_suggestions: permissionSuggestions } + : {}), + ...(blockedPath ? { blocked_path: blockedPath } : {}), + }, + }) }, - sendResponse(requestId_1, response) { - const payload: Record = { - ...response - }; - handle_0.sendControlResponse({ + sendResponse(requestId, response) { + const payload: Record = { ...response } + handle.sendControlResponse({ type: 'control_response', response: { subtype: 'success', - request_id: requestId_1, - response: payload - } - }); + request_id: requestId, + response: payload, + }, + }) }, - cancelRequest(requestId_2) { - handle_0.sendControlCancelRequest(requestId_2); + cancelRequest(requestId) { + handle.sendControlCancelRequest(requestId) }, - onResponse(requestId_3, handler_0) { - pendingPermissionHandlers.set(requestId_3, handler_0); + onResponse(requestId, handler) { + pendingPermissionHandlers.set(requestId, handler) return () => { - pendingPermissionHandlers.delete(requestId_3); - }; - } - }; - setAppState(prev_16 => ({ - ...prev_16, - replBridgePermissionCallbacks: permissionCallbacks - })); - const url = getRemoteSessionUrl(handle_0.bridgeSessionId, handle_0.sessionIngressUrl); + pendingPermissionHandlers.delete(requestId) + } + }, + } + setAppState(prev => ({ + ...prev, + replBridgePermissionCallbacks: permissionCallbacks, + })) + const url = getRemoteSessionUrl( + handle.bridgeSessionId, + handle.sessionIngressUrl, + ) // environmentId === '' signals the v2 env-less path. buildBridgeConnectUrl // builds an env-specific connect URL, which doesn't exist without an env. - const hasEnv = handle_0.environmentId !== ''; - const connectUrl_0 = hasEnv ? buildBridgeConnectUrl(handle_0.environmentId, handle_0.sessionIngressUrl) : undefined; - setAppState(prev_17 => { - if (prev_17.replBridgeConnected && prev_17.replBridgeSessionUrl === url) { - return prev_17; + const hasEnv = handle.environmentId !== '' + const connectUrl = hasEnv + ? buildBridgeConnectUrl( + handle.environmentId, + handle.sessionIngressUrl, + ) + : undefined + setAppState(prev => { + if ( + prev.replBridgeConnected && + prev.replBridgeSessionUrl === url + ) { + return prev } return { - ...prev_17, + ...prev, replBridgeConnected: true, replBridgeSessionUrl: url, - replBridgeConnectUrl: connectUrl_0 ?? prev_17.replBridgeConnectUrl, - replBridgeEnvironmentId: handle_0.environmentId, - replBridgeSessionId: handle_0.bridgeSessionId, - replBridgeError: undefined - }; - }); + replBridgeConnectUrl: connectUrl ?? prev.replBridgeConnectUrl, + replBridgeEnvironmentId: handle.environmentId, + replBridgeSessionId: handle.bridgeSessionId, + replBridgeError: undefined, + } + }) // Show bridge status with URL in the transcript. perpetual (KAIROS // assistant mode) falls back to v1 at initReplBridge.ts — skip the // v2-only upgrade nudge for them. Own try/catch so a cosmetic // GrowthBook hiccup doesn't hit the outer init-failure handler. - const upgradeNudge = !perpetual ? await shouldShowAppUpgradeMessage().catch(() => false) : false; - if (cancelled) return; - setMessages(prev_18 => [...prev_18, createBridgeStatusMessage(url, upgradeNudge ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' : undefined)]); - logForDebugging(`[bridge:repl] Hook initialized, session=${handle_0.bridgeSessionId}`); + const upgradeNudge = !perpetual + ? await shouldShowAppUpgradeMessage().catch(() => false) + : false + if (cancelled) return + setMessages(prev => [ + ...prev, + createBridgeStatusMessage( + url, + upgradeNudge + ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' + : undefined, + ), + ]) + + logForDebugging( + `[bridge:repl] Hook initialized, session=${handle.bridgeSessionId}`, + ) } } catch (err) { // Never crash the REPL — surface the error in the UI. @@ -622,49 +742,64 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // error), don't count that toward the fuse or spam a stale error // into the UI. Also fixes pre-existing spurious setAppState/ // setMessages on cancelled throws. - if (cancelled) return; - consecutiveFailuresRef.current++; - const errMsg = errorMessage(err); - logForDebugging(`[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`); - clearTimeout(failureTimeoutRef.current); - notifyBridgeFailed(errMsg); - setAppState(prev_0 => ({ - ...prev_0, - replBridgeError: errMsg - })); + if (cancelled) return + consecutiveFailuresRef.current++ + const errMsg = errorMessage(err) + logForDebugging( + `[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`, + ) + clearTimeout(failureTimeoutRef.current) + notifyBridgeFailed(errMsg) + setAppState(prev => ({ + ...prev, + replBridgeError: errMsg, + })) failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_1 => { - if (!prev_1.replBridgeError) return prev_1; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_1, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) if (!outboundOnly) { - setMessages(prev_2 => [...prev_2, createSystemMessage(`Remote Control failed to connect: ${errMsg}`, 'warning')]); + setMessages(prev => [ + ...prev, + createSystemMessage( + `Remote Control failed to connect: ${errMsg}`, + 'warning', + ), + ]) } } - })(); + })() + return () => { - cancelled = true; - clearTimeout(failureTimeoutRef.current); - failureTimeoutRef.current = undefined; + cancelled = true + clearTimeout(failureTimeoutRef.current) + failureTimeoutRef.current = undefined if (handleRef.current) { - logForDebugging(`[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`); - teardownPromiseRef.current = handleRef.current.teardown(); - handleRef.current = null; - setReplBridgeHandle(null); + logForDebugging( + `[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`, + ) + teardownPromiseRef.current = handleRef.current.teardown() + handleRef.current = null + setReplBridgeHandle(null) } - setAppState(prev_19 => { - if (!prev_19.replBridgeConnected && !prev_19.replBridgeSessionActive && !prev_19.replBridgeError) { - return prev_19; + setAppState(prev => { + if ( + !prev.replBridgeConnected && + !prev.replBridgeSessionActive && + !prev.replBridgeError + ) { + return prev } return { - ...prev_19, + ...prev, replBridgeConnected: false, replBridgeSessionActive: false, replBridgeReconnecting: false, @@ -673,13 +808,19 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S replBridgeEnvironmentId: undefined, replBridgeSessionId: undefined, replBridgeError: undefined, - replBridgePermissionCallbacks: undefined - }; - }); - lastWrittenIndexRef.current = 0; - }; + replBridgePermissionCallbacks: undefined, + } + }) + lastWrittenIndexRef.current = 0 + } } - }, [replBridgeEnabled, replBridgeOutboundOnly, setAppState, setMessages, addNotification]); + }, [ + replBridgeEnabled, + replBridgeOutboundOnly, + setAppState, + setMessages, + addNotification, + ]) // Write new messages as they appear. // Also re-runs when replBridgeConnected changes (bridge finishes init), @@ -687,38 +828,47 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S useEffect(() => { // Positive feature() guard — see first useEffect comment if (feature('BRIDGE_MODE')) { - if (!replBridgeConnected) return; - const handle_1 = handleRef.current; - if (!handle_1) return; + if (!replBridgeConnected) return + + const handle = handleRef.current + if (!handle) return // Clamp the index in case messages were compacted (array shortened). // After compaction the ref could exceed messages.length, and without // clamping no new messages would be forwarded. if (lastWrittenIndexRef.current > messages.length) { - logForDebugging(`[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`); + logForDebugging( + `[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`, + ) } - const startIndex = Math.min(lastWrittenIndexRef.current, messages.length); + const startIndex = Math.min(lastWrittenIndexRef.current, messages.length) // Collect new messages since last write - const newMessages: Message[] = []; + const newMessages: Message[] = [] for (let i = startIndex; i < messages.length; i++) { - const msg_1 = messages[i]; - if (msg_1 && (msg_1.type === 'user' || msg_1.type === 'assistant' || msg_1.type === 'system' && msg_1.subtype === 'local_command')) { - newMessages.push(msg_1); + const msg = messages[i] + if ( + msg && + (msg.type === 'user' || + msg.type === 'assistant' || + (msg.type === 'system' && msg.subtype === 'local_command')) + ) { + newMessages.push(msg) } } - lastWrittenIndexRef.current = messages.length; + lastWrittenIndexRef.current = messages.length + if (newMessages.length > 0) { - handle_1.writeMessages(newMessages); + handle.writeMessages(newMessages) } } - }, [messages, replBridgeConnected]); + }, [messages, replBridgeConnected]) + const sendBridgeResult = useCallback(() => { if (feature('BRIDGE_MODE')) { - handleRef.current?.sendResult(); + handleRef.current?.sendResult() } - }, []); - return { - sendBridgeResult - }; + }, []) + + return { sendBridgeResult } } diff --git a/src/hooks/useTeleportResume.tsx b/src/hooks/useTeleportResume.tsx index 24265fbc8..bc0e1fb0e 100644 --- a/src/hooks/useTeleportResume.tsx +++ b/src/hooks/useTeleportResume.tsx @@ -1,84 +1,78 @@ -import { c as _c } from "react/compiler-runtime"; -import { useCallback, useState } from 'react'; -import { setTeleportedSessionInfo } from 'src/bootstrap/state.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js'; -import type { CodeSession } from 'src/utils/teleport/api.js'; -import { errorMessage, TeleportOperationError } from '../utils/errors.js'; -import { teleportResumeCodeSession } from '../utils/teleport.js'; +import { useCallback, useState } from 'react' +import { setTeleportedSessionInfo } from 'src/bootstrap/state.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js' +import type { CodeSession } from 'src/utils/teleport/api.js' +import { errorMessage, TeleportOperationError } from '../utils/errors.js' +import { teleportResumeCodeSession } from '../utils/teleport.js' + export type TeleportResumeError = { - message: string; - formattedMessage?: string; - isOperationError: boolean; -}; -export type TeleportSource = 'cliArg' | 'localCommand'; -export function useTeleportResume(source) { - const $ = _c(8); - const [isResuming, setIsResuming] = useState(false); - const [error, setError] = useState(null); - const [selectedSession, setSelectedSession] = useState(null); - let t0; - if ($[0] !== source) { - t0 = async session => { - setIsResuming(true); - setError(null); - setSelectedSession(session); - logEvent("tengu_teleport_resume_session", { - source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_id: session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - ; + message: string + formattedMessage?: string + isOperationError: boolean +} + +export type TeleportSource = 'cliArg' | 'localCommand' + +export function useTeleportResume(source: TeleportSource) { + const [isResuming, setIsResuming] = useState(false) + const [error, setError] = useState(null) + const [selectedSession, setSelectedSession] = useState( + null, + ) + + const resumeSession = useCallback( + async (session: CodeSession): Promise => { + setIsResuming(true) + setError(null) + setSelectedSession(session) + + // Log teleport session selection + logEvent('tengu_teleport_resume_session', { + source: + source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_id: + session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + try { - const result = await teleportResumeCodeSession(session.id); - setTeleportedSessionInfo({ - sessionId: session.id - }); - setIsResuming(false); - return result; - } catch (t1) { - const err = t1; - const teleportError = { - message: err instanceof TeleportOperationError ? err.message : errorMessage(err), - formattedMessage: err instanceof TeleportOperationError ? err.formattedMessage : undefined, - isOperationError: err instanceof TeleportOperationError - }; - setError(teleportError); - setIsResuming(false); - return null; + const result = await teleportResumeCodeSession(session.id) + // Track teleported session for reliability logging + setTeleportedSessionInfo({ sessionId: session.id }) + setIsResuming(false) + return result + } catch (err) { + const teleportError: TeleportResumeError = { + message: + err instanceof TeleportOperationError + ? err.message + : errorMessage(err), + formattedMessage: + err instanceof TeleportOperationError + ? err.formattedMessage + : undefined, + isOperationError: err instanceof TeleportOperationError, + } + setError(teleportError) + setIsResuming(false) + return null } - }; - $[0] = source; - $[1] = t0; - } else { - t0 = $[1]; - } - const resumeSession = t0; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - setError(null); - }; - $[2] = t1; - } else { - t1 = $[2]; - } - const clearError = t1; - let t2; - if ($[3] !== error || $[4] !== isResuming || $[5] !== resumeSession || $[6] !== selectedSession) { - t2 = { - resumeSession, - isResuming, - error, - selectedSession, - clearError - }; - $[3] = error; - $[4] = isResuming; - $[5] = resumeSession; - $[6] = selectedSession; - $[7] = t2; - } else { - t2 = $[7]; + }, + [source], + ) + + const clearError = useCallback(() => { + setError(null) + }, []) + + return { + resumeSession, + isResuming, + error, + selectedSession, + clearError, } - return t2; } diff --git a/src/hooks/useTypeahead.tsx b/src/hooks/useTypeahead.tsx index 13333171e..3e2dbd220 100644 --- a/src/hooks/useTypeahead.tsx +++ b/src/hooks/useTypeahead.tsx @@ -1,119 +1,178 @@ -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { Text } from 'src/ink.js'; -import { logEvent } from 'src/services/analytics/index.js'; -import { useDebounceCallback } from 'usehooks-ts'; -import { type Command, getCommandName } from '../commands.js'; -import { getModeFromInput, getValueFromInput } from '../components/PromptInput/inputModes.js'; -import type { SuggestionItem, SuggestionType } from '../components/PromptInput/PromptInputFooterSuggestions.js'; -import { useIsModalOverlayActive, useRegisterOverlay } from '../context/overlayContext.js'; -import { KeyboardEvent } from '../ink/events/keyboard-event.js'; +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { Text } from 'src/ink.js' +import { logEvent } from 'src/services/analytics/index.js' +import { useDebounceCallback } from 'usehooks-ts' +import { type Command, getCommandName } from '../commands.js' +import { + getModeFromInput, + getValueFromInput, +} from '../components/PromptInput/inputModes.js' +import type { + SuggestionItem, + SuggestionType, +} from '../components/PromptInput/PromptInputFooterSuggestions.js' +import { + useIsModalOverlayActive, + useRegisterOverlay, +} from '../context/overlayContext.js' +import { KeyboardEvent } from '../ink/events/keyboard-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to -import { useInput } from '../ink.js'; -import { useOptionalKeybindingContext, useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { useAppState, useAppStateStore } from '../state/AppState.js'; -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; -import type { InlineGhostText, PromptInputMode } from '../types/textInputTypes.js'; -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; -import { generateProgressiveArgumentHint, parseArguments } from '../utils/argumentSubstitution.js'; -import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js'; -import { formatLogMetadata } from '../utils/format.js'; -import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js'; -import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, isCommandInput } from '../utils/suggestions/commandSuggestions.js'; -import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '../utils/suggestions/directoryCompletion.js'; -import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js'; -import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js'; -import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'; -import { applyFileSuggestion, findLongestCommonPrefix, onIndexBuildComplete, startBackgroundCacheRefresh } from './fileSuggestions.js'; -import { generateUnifiedSuggestions } from './unifiedSuggestions.js'; +import { useInput } from '../ink.js' +import { + useOptionalKeybindingContext, + useRegisterKeybindingContext, +} from '../keybindings/KeybindingContext.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { useAppState, useAppStateStore } from '../state/AppState.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import type { + InlineGhostText, + PromptInputMode, +} from '../types/textInputTypes.js' +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' +import { + generateProgressiveArgumentHint, + parseArguments, +} from '../utils/argumentSubstitution.js' +import { + getShellCompletions, + type ShellCompletionType, +} from '../utils/bash/shellCompletion.js' +import { formatLogMetadata } from '../utils/format.js' +import { + getSessionIdFromLog, + searchSessionsByCustomTitle, +} from '../utils/sessionStorage.js' +import { + applyCommandSuggestion, + findMidInputSlashCommand, + generateCommandSuggestions, + getBestCommandMatch, + isCommandInput, +} from '../utils/suggestions/commandSuggestions.js' +import { + getDirectoryCompletions, + getPathCompletions, + isPathLikeToken, +} from '../utils/suggestions/directoryCompletion.js' +import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js' +import { + getSlackChannelSuggestions, + hasSlackMcpServer, +} from '../utils/suggestions/slackChannelSuggestions.js' +import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' +import { + applyFileSuggestion, + findLongestCommonPrefix, + onIndexBuildComplete, + startBackgroundCacheRefresh, +} from './fileSuggestions.js' +import { generateUnifiedSuggestions } from './unifiedSuggestions.js' // Unicode-aware character class for file path tokens: // \p{L} = letters (CJK, Latin, Cyrillic, etc.) // \p{N} = numbers (incl. fullwidth) // \p{M} = combining marks (macOS NFD accents, Devanagari vowel signs) -const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u; -const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u; -const TOKEN_WITH_AT_RE = /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u; -const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u; -const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u; -const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/; +const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u +const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u +const TOKEN_WITH_AT_RE = + /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u +const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u +const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u +const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/ // Type guard for path completion metadata -function isPathMetadata(metadata: unknown): metadata is { - type: 'directory' | 'file'; -} { - return typeof metadata === 'object' && metadata !== null && 'type' in metadata && (metadata.type === 'directory' || metadata.type === 'file'); +function isPathMetadata( + metadata: unknown, +): metadata is { type: 'directory' | 'file' } { + return ( + typeof metadata === 'object' && + metadata !== null && + 'type' in metadata && + (metadata.type === 'directory' || metadata.type === 'file') + ) } // Helper to determine selectedSuggestion when updating suggestions -function getPreservedSelection(prevSuggestions: SuggestionItem[], prevSelection: number, newSuggestions: SuggestionItem[]): number { +function getPreservedSelection( + prevSuggestions: SuggestionItem[], + prevSelection: number, + newSuggestions: SuggestionItem[], +): number { // No new suggestions if (newSuggestions.length === 0) { - return -1; + return -1 } // No previous selection if (prevSelection < 0) { - return 0; + return 0 } // Get the previously selected item - const prevSelectedItem = prevSuggestions[prevSelection]; + const prevSelectedItem = prevSuggestions[prevSelection] if (!prevSelectedItem) { - return 0; + return 0 } // Try to find the same item in the new list by ID - const newIndex = newSuggestions.findIndex(item => item.id === prevSelectedItem.id); + const newIndex = newSuggestions.findIndex( + item => item.id === prevSelectedItem.id, + ) // Return the new index if found, otherwise default to 0 - return newIndex >= 0 ? newIndex : 0; + return newIndex >= 0 ? newIndex : 0 } + function buildResumeInputFromSuggestion(suggestion: SuggestionItem): string { - const metadata = suggestion.metadata as { - sessionId: string; - } | undefined; - return metadata?.sessionId ? `/resume ${metadata.sessionId}` : `/resume ${suggestion.displayText}`; + const metadata = suggestion.metadata as { sessionId: string } | undefined + return metadata?.sessionId + ? `/resume ${metadata.sessionId}` + : `/resume ${suggestion.displayText}` } + type Props = { - onInputChange: (value: string) => void; - onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void; - setCursorOffset: (offset: number) => void; - input: string; - cursorOffset: number; - commands: Command[]; - mode: string; - agents: AgentDefinition[]; - setSuggestionsState: (f: (previousSuggestionsState: { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }) => { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }) => void; + onInputChange: (value: string) => void + onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void + setCursorOffset: (offset: number) => void + input: string + cursorOffset: number + commands: Command[] + mode: string + agents: AgentDefinition[] + setSuggestionsState: ( + f: (previousSuggestionsState: { + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + }) => { + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + }, + ) => void suggestionsState: { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }; - suppressSuggestions?: boolean; - markAccepted: () => void; - onModeChange?: (mode: PromptInputMode) => void; -}; + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + } + suppressSuggestions?: boolean + markAccepted: () => void + onModeChange?: (mode: PromptInputMode) => void +} + type UseTypeaheadResult = { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - suggestionType: SuggestionType; - maxColumnWidth?: number; - commandArgumentHint?: string; - inlineGhostText?: InlineGhostText; - handleKeyDown: (e: KeyboardEvent) => void; -}; + suggestions: SuggestionItem[] + selectedSuggestion: number + suggestionType: SuggestionType + maxColumnWidth?: number + commandArgumentHint?: string + inlineGhostText?: InlineGhostText + handleKeyDown: (e: KeyboardEvent) => void +} /** * Extract search token from a completion token by removing @ prefix and quotes @@ -121,16 +180,16 @@ type UseTypeaheadResult = { * @returns The search token with @ and quotes removed */ export function extractSearchToken(completionToken: { - token: string; - isQuoted?: boolean; + token: string + isQuoted?: boolean }): string { if (completionToken.isQuoted) { // Remove @" prefix and optional closing " - return completionToken.token.slice(2).replace(/"$/, ''); + return completionToken.token.slice(2).replace(/"$/, '') } else if (completionToken.token.startsWith('@')) { - return completionToken.token.substring(1); + return completionToken.token.substring(1) } else { - return completionToken.token; + return completionToken.token } } @@ -146,80 +205,109 @@ export function extractSearchToken(completionToken: { * @returns The formatted replacement value */ export function formatReplacementValue(options: { - displayText: string; - mode: string; - hasAtPrefix: boolean; - needsQuotes: boolean; - isQuoted?: boolean; - isComplete: boolean; + displayText: string + mode: string + hasAtPrefix: boolean + needsQuotes: boolean + isQuoted?: boolean + isComplete: boolean }): string { - const { - displayText, - mode, - hasAtPrefix, - needsQuotes, - isQuoted, - isComplete - } = options; - const space = isComplete ? ' ' : ''; + const { displayText, mode, hasAtPrefix, needsQuotes, isQuoted, isComplete } = + options + const space = isComplete ? ' ' : '' + if (isQuoted || needsQuotes) { // Use quoted format - return mode === 'bash' ? `"${displayText}"${space}` : `@"${displayText}"${space}`; + return mode === 'bash' + ? `"${displayText}"${space}` + : `@"${displayText}"${space}` } else if (hasAtPrefix) { - return mode === 'bash' ? `${displayText}${space}` : `@${displayText}${space}`; + return mode === 'bash' + ? `${displayText}${space}` + : `@${displayText}${space}` } else { - return displayText; + return displayText } } /** * Apply a shell completion suggestion by replacing the current word */ -export function applyShellSuggestion(suggestion: SuggestionItem, input: string, cursorOffset: number, onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void, completionType: ShellCompletionType | undefined): void { - const beforeCursor = input.slice(0, cursorOffset); - const lastSpaceIndex = beforeCursor.lastIndexOf(' '); - const wordStart = lastSpaceIndex + 1; +export function applyShellSuggestion( + suggestion: SuggestionItem, + input: string, + cursorOffset: number, + onInputChange: (value: string) => void, + setCursorOffset: (offset: number) => void, + completionType: ShellCompletionType | undefined, +): void { + const beforeCursor = input.slice(0, cursorOffset) + const lastSpaceIndex = beforeCursor.lastIndexOf(' ') + const wordStart = lastSpaceIndex + 1 // Prepare the replacement text based on completion type - let replacementText: string; + let replacementText: string if (completionType === 'variable') { - replacementText = '$' + suggestion.displayText + ' '; + replacementText = '$' + suggestion.displayText + ' ' } else if (completionType === 'command') { - replacementText = suggestion.displayText + ' '; + replacementText = suggestion.displayText + ' ' } else { - replacementText = suggestion.displayText; + replacementText = suggestion.displayText } - const newInput = input.slice(0, wordStart) + replacementText + input.slice(cursorOffset); - onInputChange(newInput); - setCursorOffset(wordStart + replacementText.length); + + const newInput = + input.slice(0, wordStart) + replacementText + input.slice(cursorOffset) + + onInputChange(newInput) + setCursorOffset(wordStart + replacementText.length) } -const DM_MEMBER_RE = /(^|\s)@[\w-]*$/; -function applyTriggerSuggestion(suggestion: SuggestionItem, input: string, cursorOffset: number, triggerRe: RegExp, onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void): void { - const m = input.slice(0, cursorOffset).match(triggerRe); - if (!m || m.index === undefined) return; - const prefixStart = m.index + (m[1]?.length ?? 0); - const before = input.slice(0, prefixStart); - const newInput = before + suggestion.displayText + ' ' + input.slice(cursorOffset); - onInputChange(newInput); - setCursorOffset(before.length + suggestion.displayText.length + 1); + +const DM_MEMBER_RE = /(^|\s)@[\w-]*$/ + +function applyTriggerSuggestion( + suggestion: SuggestionItem, + input: string, + cursorOffset: number, + triggerRe: RegExp, + onInputChange: (value: string) => void, + setCursorOffset: (offset: number) => void, +): void { + const m = input.slice(0, cursorOffset).match(triggerRe) + if (!m || m.index === undefined) return + const prefixStart = m.index + (m[1]?.length ?? 0) + const before = input.slice(0, prefixStart) + const newInput = + before + suggestion.displayText + ' ' + input.slice(cursorOffset) + onInputChange(newInput) + setCursorOffset(before.length + suggestion.displayText.length + 1) } -let currentShellCompletionAbortController: AbortController | null = null; + +let currentShellCompletionAbortController: AbortController | null = null /** * Generate bash shell completion suggestions */ -async function generateBashSuggestions(input: string, cursorOffset: number): Promise { +async function generateBashSuggestions( + input: string, + cursorOffset: number, +): Promise { try { if (currentShellCompletionAbortController) { - currentShellCompletionAbortController.abort(); + currentShellCompletionAbortController.abort() } - currentShellCompletionAbortController = new AbortController(); - const suggestions = await getShellCompletions(input, cursorOffset, currentShellCompletionAbortController.signal); - return suggestions; + + currentShellCompletionAbortController = new AbortController() + const suggestions = await getShellCompletions( + input, + cursorOffset, + currentShellCompletionAbortController.signal, + ) + + return suggestions } catch { // Silent failure - don't break UX - logEvent('tengu_shell_completion_failed', {}); - return []; + logEvent('tengu_shell_completion_failed', {}) + return [] } } @@ -234,21 +322,25 @@ async function generateBashSuggestions(input: string, cursorOffset: number): Pro * @param isDirectory Whether the suggestion is a directory (adds / suffix) or file (adds space) * @returns Object with the new input text and cursor position */ -export function applyDirectorySuggestion(input: string, suggestionId: string, tokenStartPos: number, tokenLength: number, isDirectory: boolean): { - newInput: string; - cursorPos: number; -} { - const suffix = isDirectory ? '/' : ' '; - const before = input.slice(0, tokenStartPos); - const after = input.slice(tokenStartPos + tokenLength); +export function applyDirectorySuggestion( + input: string, + suggestionId: string, + tokenStartPos: number, + tokenLength: number, + isDirectory: boolean, +): { newInput: string; cursorPos: number } { + const suffix = isDirectory ? '/' : ' ' + const before = input.slice(0, tokenStartPos) + const after = input.slice(tokenStartPos + tokenLength) // Always add @ prefix - if token already has it, we're replacing // the whole token (including @) with @suggestion.id - const replacement = '@' + suggestionId + suffix; - const newInput = before + replacement + after; + const replacement = '@' + suggestionId + suffix + const newInput = before + replacement + after + return { newInput, - cursorPos: before.length + replacement.length - }; + cursorPos: before.length + replacement.length, + } } /** @@ -258,93 +350,104 @@ export function applyDirectorySuggestion(input: string, suggestionId: string, to * @param includeAtSymbol Whether to consider @ symbol as part of the token * @returns The completable token and its start position, or null if not found */ -export function extractCompletionToken(text: string, cursorPos: number, includeAtSymbol = false): { - token: string; - startPos: number; - isQuoted?: boolean; -} | null { +export function extractCompletionToken( + text: string, + cursorPos: number, + includeAtSymbol = false, +): { token: string; startPos: number; isQuoted?: boolean } | null { // Empty input check - if (!text) return null; + if (!text) return null // Get text up to cursor - const textBeforeCursor = text.substring(0, cursorPos); + const textBeforeCursor = text.substring(0, cursorPos) // Check for quoted @ mention first (e.g., @"my file with spaces") if (includeAtSymbol) { - const quotedAtRegex = /@"([^"]*)"?$/; - const quotedMatch = textBeforeCursor.match(quotedAtRegex); + const quotedAtRegex = /@"([^"]*)"?$/ + const quotedMatch = textBeforeCursor.match(quotedAtRegex) if (quotedMatch && quotedMatch.index !== undefined) { // Include any remaining quoted content after cursor until closing quote or end - const textAfterCursor = text.substring(cursorPos); - const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/); - const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/) + const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : '' + return { token: quotedMatch[0] + quotedSuffix, startPos: quotedMatch.index, - isQuoted: true - }; + isQuoted: true, + } } } // Fast path for @ tokens: use lastIndexOf to avoid expensive $ anchor scan if (includeAtSymbol) { - const atIdx = textBeforeCursor.lastIndexOf('@'); - if (atIdx >= 0 && (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!))) { - const fromAt = textBeforeCursor.substring(atIdx); - const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE); + const atIdx = textBeforeCursor.lastIndexOf('@') + if ( + atIdx >= 0 && + (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!)) + ) { + const fromAt = textBeforeCursor.substring(atIdx) + const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE) if (atHeadMatch && atHeadMatch[0].length === fromAt.length) { - const textAfterCursor = text.substring(cursorPos); - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); - const tokenSuffix = afterMatch ? afterMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) + const tokenSuffix = afterMatch ? afterMatch[0] : '' return { token: atHeadMatch[0] + tokenSuffix, startPos: atIdx, - isQuoted: false - }; + isQuoted: false, + } } } } // Non-@ token or cursor outside @ token — use $ anchor on (short) tail - const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE; - const match = textBeforeCursor.match(tokenRegex); + const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE + const match = textBeforeCursor.match(tokenRegex) if (!match || match.index === undefined) { - return null; + return null } // Check if cursor is in the MIDDLE of a token (more word characters after cursor) // If so, extend the token to include all characters until whitespace or end of string - const textAfterCursor = text.substring(cursorPos); - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); - const tokenSuffix = afterMatch ? afterMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) + const tokenSuffix = afterMatch ? afterMatch[0] : '' + return { token: match[0] + tokenSuffix, startPos: match.index, - isQuoted: false - }; + isQuoted: false, + } } + function extractCommandNameAndArgs(value: string): { - commandName: string; - args: string; + commandName: string + args: string } | null { if (isCommandInput(value)) { - const spaceIndex = value.indexOf(' '); - if (spaceIndex === -1) return { - commandName: value.slice(1), - args: '' - }; + const spaceIndex = value.indexOf(' ') + if (spaceIndex === -1) + return { + commandName: value.slice(1), + args: '', + } return { commandName: value.slice(1, spaceIndex), - args: value.slice(spaceIndex + 1) - }; + args: value.slice(spaceIndex + 1), + } } - return null; + return null } -function hasCommandWithArguments(isAtEndWithWhitespace: boolean, value: string) { + +function hasCommandWithArguments( + isAtEndWithWhitespace: boolean, + value: string, +) { // If value.endsWith(' ') but the user is not at the end, then the user has // potentially gone back to the command in an effort to edit the command name // (but preserve the arguments). - return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' '); + return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' ') } /** @@ -360,122 +463,150 @@ export function useTypeahead({ mode, agents, setSuggestionsState, - suggestionsState: { - suggestions, - selectedSuggestion, - commandArgumentHint - }, + suggestionsState: { suggestions, selectedSuggestion, commandArgumentHint }, suppressSuggestions = false, markAccepted, - onModeChange + onModeChange, }: Props): UseTypeaheadResult { - const { - addNotification - } = useNotifications(); - const thinkingToggleShortcut = useShortcutDisplay('chat:thinkingToggle', 'Chat', 'alt+t'); - const [suggestionType, setSuggestionType] = useState('none'); + const { addNotification } = useNotifications() + const thinkingToggleShortcut = useShortcutDisplay( + 'chat:thinkingToggle', + 'Chat', + 'alt+t', + ) + const [suggestionType, setSuggestionType] = useState('none') // Compute max column width from ALL commands once (not filtered results) // This prevents layout shift when filtering const allCommandsMaxWidth = useMemo(() => { - const visibleCommands = commands.filter(cmd => !cmd.isHidden); - if (visibleCommands.length === 0) return undefined; - const maxLen = Math.max(...visibleCommands.map(cmd => getCommandName(cmd).length)); - return maxLen + 6; // +1 for "/" prefix, +5 for padding - }, [commands]); - const [maxColumnWidth, setMaxColumnWidth] = useState(undefined); - const mcpResources = useAppState(s => s.mcp.resources); - const store = useAppStateStore(); - const promptSuggestion = useAppState(s => s.promptSuggestion); + const visibleCommands = commands.filter(cmd => !cmd.isHidden) + if (visibleCommands.length === 0) return undefined + const maxLen = Math.max( + ...visibleCommands.map(cmd => getCommandName(cmd).length), + ) + return maxLen + 6 // +1 for "/" prefix, +5 for padding + }, [commands]) + + const [maxColumnWidth, setMaxColumnWidth] = useState( + undefined, + ) + const mcpResources = useAppState(s => s.mcp.resources) + const store = useAppStateStore() + const promptSuggestion = useAppState(s => s.promptSuggestion) // PromptInput hides suggestion ghost text in teammate view — mirror that // gate here so Tab/rightArrow can't accept what isn't displayed. - const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId); + const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId) // Access keybinding context to check for pending chord sequences - const keybindingContext = useOptionalKeybindingContext(); + const keybindingContext = useOptionalKeybindingContext() // State for inline ghost text (bash history completion - async) - const [inlineGhostText, setInlineGhostText] = useState(undefined); + const [inlineGhostText, setInlineGhostText] = useState< + InlineGhostText | undefined + >(undefined) // Synchronous ghost text for prompt mode mid-input slash commands. // Computed during render via useMemo to eliminate the one-frame flicker // that occurs when using useState + useEffect (effect runs after render). const syncPromptGhostText = useMemo((): InlineGhostText | undefined => { - if (mode !== 'prompt' || suppressSuggestions) return undefined; - const midInputCommand = findMidInputSlashCommand(input, cursorOffset); - if (!midInputCommand) return undefined; - const match = getBestCommandMatch(midInputCommand.partialCommand, commands); - if (!match) return undefined; + if (mode !== 'prompt' || suppressSuggestions) return undefined + const midInputCommand = findMidInputSlashCommand(input, cursorOffset) + if (!midInputCommand) return undefined + const match = getBestCommandMatch(midInputCommand.partialCommand, commands) + if (!match) return undefined return { text: match.suffix, fullCommand: match.fullCommand, - insertPosition: midInputCommand.startPos + 1 + midInputCommand.partialCommand.length - }; - }, [input, cursorOffset, mode, commands, suppressSuggestions]); + insertPosition: + midInputCommand.startPos + 1 + midInputCommand.partialCommand.length, + } + }, [input, cursorOffset, mode, commands, suppressSuggestions]) // Merged ghost text: prompt mode uses synchronous useMemo, bash mode uses async useState - const effectiveGhostText = suppressSuggestions ? undefined : mode === 'prompt' ? syncPromptGhostText : inlineGhostText; + const effectiveGhostText = suppressSuggestions + ? undefined + : mode === 'prompt' + ? syncPromptGhostText + : inlineGhostText // Use a ref for cursorOffset to avoid re-triggering suggestions on cursor movement alone // We only want to re-fetch suggestions when the actual search token changes - const cursorOffsetRef = useRef(cursorOffset); - cursorOffsetRef.current = cursorOffset; + const cursorOffsetRef = useRef(cursorOffset) + cursorOffsetRef.current = cursorOffset // Track the latest search token to discard stale results from slow async operations - const latestSearchTokenRef = useRef(null); + const latestSearchTokenRef = useRef(null) // Track previous input to detect actual text changes vs. callback recreations - const prevInputRef = useRef(''); + const prevInputRef = useRef('') // Track the latest path token to discard stale results from path completion - const latestPathTokenRef = useRef(''); + const latestPathTokenRef = useRef('') // Track the latest bash input to discard stale results from history completion - const latestBashInputRef = useRef(''); + const latestBashInputRef = useRef('') // Track the latest slack channel token to discard stale results from MCP - const latestSlackTokenRef = useRef(''); + const latestSlackTokenRef = useRef('') // Track suggestions via ref to avoid updateSuggestions being recreated on selection changes - const suggestionsRef = useRef(suggestions); - suggestionsRef.current = suggestions; + const suggestionsRef = useRef(suggestions) + suggestionsRef.current = suggestions // Track the input value when suggestions were manually dismissed to prevent re-triggering - const dismissedForInputRef = useRef(null); + const dismissedForInputRef = useRef(null) // Clear all suggestions const clearSuggestions = useCallback(() => { setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - setInlineGhostText(undefined); - }, [setSuggestionsState]); + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + setInlineGhostText(undefined) + }, [setSuggestionsState]) // Expensive async operation to fetch file/resource suggestions - const fetchFileSuggestions = useCallback(async (searchToken: string, isAtSymbol = false): Promise => { - latestSearchTokenRef.current = searchToken; - const combinedItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); - // Discard stale results if a newer query was initiated while waiting - if (latestSearchTokenRef.current !== searchToken) { - return; - } - if (combinedItems.length === 0) { - // Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions - setSuggestionsState(() => ({ + const fetchFileSuggestions = useCallback( + async (searchToken: string, isAtSymbol = false): Promise => { + latestSearchTokenRef.current = searchToken + const combinedItems = await generateUnifiedSuggestions( + searchToken, + mcpResources, + agents, + isAtSymbol, + ) + // Discard stale results if a newer query was initiated while waiting + if (latestSearchTokenRef.current !== searchToken) { + return + } + if (combinedItems.length === 0) { + // Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions + setSuggestionsState(() => ({ + commandArgumentHint: undefined, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } + setSuggestionsState(prev => ({ commandArgumentHint: undefined, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; - } - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: combinedItems, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, combinedItems) - })); - setSuggestionType(combinedItems.length > 0 ? 'file' : 'none'); - setMaxColumnWidth(undefined); // No fixed width for file suggestions - }, [mcpResources, setSuggestionsState, setSuggestionType, setMaxColumnWidth, agents]); + suggestions: combinedItems, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + combinedItems, + ), + })) + setSuggestionType(combinedItems.length > 0 ? 'file' : 'none') + setMaxColumnWidth(undefined) // No fixed width for file suggestions + }, + [ + mcpResources, + setSuggestionsState, + setSuggestionType, + setMaxColumnWidth, + agents, + ], + ) // Pre-warm the file index on mount so the first @-mention doesn't block. // The build runs in background with ~4ms event-loop yields, so it doesn't @@ -492,399 +623,515 @@ export function useTypeahead({ // subsequent tests in the shard. The subscriber still registers so // fileSuggestions tests that trigger a refresh directly work correctly. useEffect(() => { - if (("production" as string) !== 'test') { - startBackgroundCacheRefresh(); + if ("production" !== 'test') { + startBackgroundCacheRefresh() } return onIndexBuildComplete(() => { - const token = latestSearchTokenRef.current; + const token = latestSearchTokenRef.current if (token !== null) { - latestSearchTokenRef.current = null; - void fetchFileSuggestions(token, token === ''); + latestSearchTokenRef.current = null + void fetchFileSuggestions(token, token === '') } - }); - }, [fetchFileSuggestions]); + }) + }, [fetchFileSuggestions]) // Debounce the file fetch operation. 50ms sits just above macOS default // key-repeat (~33ms) so held-delete/backspace coalesces into one search // instead of stuttering on each repeated key. The search itself is ~8–15ms // on a 270k-file index. - const debouncedFetchFileSuggestions = useDebounceCallback(fetchFileSuggestions, 50); - const fetchSlackChannels = useCallback(async (partial: string): Promise => { - latestSlackTokenRef.current = partial; - const channels = await getSlackChannelSuggestions(store.getState().mcp.clients, partial); - if (latestSlackTokenRef.current !== partial) return; - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: channels, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, channels) - })); - setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none'); - setMaxColumnWidth(undefined); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref - [setSuggestionsState]); + const debouncedFetchFileSuggestions = useDebounceCallback( + fetchFileSuggestions, + 50, + ) + + const fetchSlackChannels = useCallback( + async (partial: string): Promise => { + latestSlackTokenRef.current = partial + const channels = await getSlackChannelSuggestions( + store.getState().mcp.clients, + partial, + ) + if (latestSlackTokenRef.current !== partial) return + setSuggestionsState(prev => ({ + commandArgumentHint: undefined, + suggestions: channels, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + channels, + ), + })) + setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none') + setMaxColumnWidth(undefined) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref + [setSuggestionsState], + ) // First keystroke after # needs the MCP round-trip; subsequent keystrokes // that share the same first-word segment hit the cache synchronously. - const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150); + const debouncedFetchSlackChannels = useDebounceCallback( + fetchSlackChannels, + 150, + ) // Handle immediate suggestion logic (cheap operations) // biome-ignore lint/correctness/useExhaustiveDependencies: store is a stable context ref, read imperatively at call-time - const updateSuggestions = useCallback(async (value: string, inputCursorOffset?: number): Promise => { - // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) - const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current; - if (suppressSuggestions) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; - } + const updateSuggestions = useCallback( + async (value: string, inputCursorOffset?: number): Promise => { + // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) + const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current + if (suppressSuggestions) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return + } - // Check for mid-input slash command (e.g., "help me /com") - // Only in prompt mode, not when input starts with "/" (handled separately) - // Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo. - // We only need to clear dropdown suggestions here when ghost text is active. - if (mode === 'prompt') { - const midInputCommand = findMidInputSlashCommand(value, effectiveCursorOffset); - if (midInputCommand) { - const match = getBestCommandMatch(midInputCommand.partialCommand, commands); - if (match) { + // Check for mid-input slash command (e.g., "help me /com") + // Only in prompt mode, not when input starts with "/" (handled separately) + // Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo. + // We only need to clear dropdown suggestions here when ghost text is active. + if (mode === 'prompt') { + const midInputCommand = findMidInputSlashCommand( + value, + effectiveCursorOffset, + ) + if (midInputCommand) { + const match = getBestCommandMatch( + midInputCommand.partialCommand, + commands, + ) + if (match) { + // Clear dropdown suggestions when showing ghost text + setSuggestionsState(() => ({ + commandArgumentHint: undefined, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } + } + } + + // Bash mode: check for history-based ghost text completion + if (mode === 'bash' && value.trim()) { + latestBashInputRef.current = value + const historyMatch = await getShellHistoryCompletion(value) + // Discard stale results if input changed while waiting + if (latestBashInputRef.current !== value) { + return + } + if (historyMatch) { + setInlineGhostText({ + text: historyMatch.suffix, + fullCommand: historyMatch.fullCommand, + insertPosition: value.length, + }) // Clear dropdown suggestions when showing ghost text setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } else { + // No history match, clear ghost text + setInlineGhostText(undefined) } } - } - // Bash mode: check for history-based ghost text completion - if (mode === 'bash' && value.trim()) { - latestBashInputRef.current = value; - const historyMatch = await getShellHistoryCompletion(value); - // Discard stale results if input changed while waiting - if (latestBashInputRef.current !== value) { - return; - } - if (historyMatch) { - setInlineGhostText({ - text: historyMatch.suffix, - fullCommand: historyMatch.fullCommand, - insertPosition: value.length - }); - // Clear dropdown suggestions when showing ghost text - setSuggestionsState(() => ({ - commandArgumentHint: undefined, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; - } else { - // No history match, clear ghost text - setInlineGhostText(undefined); - } - } + // Check for @ to trigger team member / named subagent suggestions + // Must check before @ file symbol to prevent conflict + // Skip in bash mode - @ has no special meaning in shell commands + const atMatch = + mode !== 'bash' + ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) + : null + if (atMatch) { + const partialName = (atMatch[2] ?? '').toLowerCase() + // Imperative read — reading at call-time fixes staleness for + // teammates/subagents added mid-session. + const state = store.getState() + const members: SuggestionItem[] = [] + const seen = new Set() + + if (isAgentSwarmsEnabled() && state.teamContext) { + for (const t of Object.values(state.teamContext.teammates ?? {})) { + if (t.name === TEAM_LEAD_NAME) continue + if (!t.name.toLowerCase().startsWith(partialName)) continue + seen.add(t.name) + members.push({ + id: `dm-${t.name}`, + displayText: `@${t.name}`, + description: 'send message', + }) + } + } - // Check for @ to trigger team member / named subagent suggestions - // Must check before @ file symbol to prevent conflict - // Skip in bash mode - @ has no special meaning in shell commands - const atMatch = mode !== 'bash' ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) : null; - if (atMatch) { - const partialName = (atMatch[2] ?? '').toLowerCase(); - // Imperative read — reading at call-time fixes staleness for - // teammates/subagents added mid-session. - const state = store.getState(); - const members: SuggestionItem[] = []; - const seen = new Set(); - if (isAgentSwarmsEnabled() && state.teamContext) { - for (const t of Object.values(state.teamContext.teammates ?? {})) { - if (t.name === TEAM_LEAD_NAME) continue; - if (!t.name.toLowerCase().startsWith(partialName)) continue; - seen.add(t.name); + for (const [name, agentId] of state.agentNameRegistry) { + if (seen.has(name)) continue + if (!name.toLowerCase().startsWith(partialName)) continue + const status = state.tasks[agentId]?.status members.push({ - id: `dm-${t.name}`, - displayText: `@${t.name}`, - description: 'send message' - }); + id: `dm-${name}`, + displayText: `@${name}`, + description: status ? `send message · ${status}` : 'send message', + }) } - } - for (const [name, agentId] of state.agentNameRegistry) { - if (seen.has(name)) continue; - if (!name.toLowerCase().startsWith(partialName)) continue; - const status = state.tasks[agentId]?.status; - members.push({ - id: `dm-${name}`, - displayText: `@${name}`, - description: status ? `send message · ${status}` : 'send message' - }); - } - if (members.length > 0) { - debouncedFetchFileSuggestions.cancel(); - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: members, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, members) - })); - setSuggestionType('agent'); - setMaxColumnWidth(undefined); - return; - } - } - // Check for # to trigger Slack channel suggestions (requires Slack MCP server) - if (mode === 'prompt') { - const hashMatch = value.substring(0, effectiveCursorOffset).match(HASH_CHANNEL_RE); - if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) { - debouncedFetchSlackChannels(hashMatch[2]!); - return; - } else if (suggestionType === 'slack-channel') { - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); - } - } - - // Check for @ symbol to trigger file suggestions (including quoted paths) - // Includes colon for MCP resources (e.g., server:resource/path) - const hasAtSymbol = value.substring(0, effectiveCursorOffset).match(HAS_AT_SYMBOL_RE); - - // First, check for slash command suggestions (higher priority than @ symbol) - // Only show slash command selector if cursor is not on the "/" character itself - // Also don't show if cursor is at end of line with whitespace before it - // Don't show slash commands in bash mode - const isAtEndWithWhitespace = effectiveCursorOffset === value.length && effectiveCursorOffset > 0 && value.length > 0 && value[effectiveCursorOffset - 1] === ' '; - - // Handle directory completion for commands - if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0) { - const parsedCommand = extractCommandNameAndArgs(value); - if (parsedCommand && parsedCommand.commandName === 'add-dir' && parsedCommand.args) { - const { - args - } = parsedCommand; - - // Clear suggestions if args end with whitespace (user is done with path) - if (args.match(/\s+$/)) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; - } - const dirSuggestions = await getDirectoryCompletions(args); - if (dirSuggestions.length > 0) { + if (members.length > 0) { + debouncedFetchFileSuggestions.cancel() setSuggestionsState(prev => ({ - suggestions: dirSuggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, dirSuggestions), - commandArgumentHint: undefined - })); - setSuggestionType('directory'); - return; + commandArgumentHint: undefined, + suggestions: members, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + members, + ), + })) + setSuggestionType('agent') + setMaxColumnWidth(undefined) + return } + } - // No suggestions found - clear and return - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; + // Check for # to trigger Slack channel suggestions (requires Slack MCP server) + if (mode === 'prompt') { + const hashMatch = value + .substring(0, effectiveCursorOffset) + .match(HASH_CHANNEL_RE) + if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) { + debouncedFetchSlackChannels(hashMatch[2]!) + return + } else if (suggestionType === 'slack-channel') { + debouncedFetchSlackChannels.cancel() + clearSuggestions() + } } - // Handle custom title completion for /resume command - if (parsedCommand && parsedCommand.commandName === 'resume' && parsedCommand.args !== undefined && value.includes(' ')) { - const { - args - } = parsedCommand; - - // Get custom title suggestions using partial match - const matches = await searchSessionsByCustomTitle(args, { - limit: 10 - }); - const suggestions = matches.map(log => { - const sessionId = getSessionIdFromLog(log); - return { - id: `resume-title-${sessionId}`, - displayText: log.customTitle!, - description: formatLogMetadata(log), - metadata: { - sessionId - } - }; - }); - if (suggestions.length > 0) { - setSuggestionsState(prev => ({ - suggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestions), - commandArgumentHint: undefined - })); - setSuggestionType('custom-title'); - return; + // Check for @ symbol to trigger file suggestions (including quoted paths) + // Includes colon for MCP resources (e.g., server:resource/path) + const hasAtSymbol = value + .substring(0, effectiveCursorOffset) + .match(HAS_AT_SYMBOL_RE) + + // First, check for slash command suggestions (higher priority than @ symbol) + // Only show slash command selector if cursor is not on the "/" character itself + // Also don't show if cursor is at end of line with whitespace before it + // Don't show slash commands in bash mode + const isAtEndWithWhitespace = + effectiveCursorOffset === value.length && + effectiveCursorOffset > 0 && + value.length > 0 && + value[effectiveCursorOffset - 1] === ' ' + + // Handle directory completion for commands + if ( + mode === 'prompt' && + isCommandInput(value) && + effectiveCursorOffset > 0 + ) { + const parsedCommand = extractCommandNameAndArgs(value) + + if ( + parsedCommand && + parsedCommand.commandName === 'add-dir' && + parsedCommand.args + ) { + const { args } = parsedCommand + + // Clear suggestions if args end with whitespace (user is done with path) + if (args.match(/\s+$/)) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return + } + + const dirSuggestions = await getDirectoryCompletions(args) + if (dirSuggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions: dirSuggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + dirSuggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('directory') + return + } + + // No suggestions found - clear and return + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return } - // No suggestions found - clear and return - clearSuggestions(); - return; + // Handle custom title completion for /resume command + if ( + parsedCommand && + parsedCommand.commandName === 'resume' && + parsedCommand.args !== undefined && + value.includes(' ') + ) { + const { args } = parsedCommand + + // Get custom title suggestions using partial match + const matches = await searchSessionsByCustomTitle(args, { + limit: 10, + }) + + const suggestions = matches.map(log => { + const sessionId = getSessionIdFromLog(log) + return { + id: `resume-title-${sessionId}`, + displayText: log.customTitle!, + description: formatLogMetadata(log), + metadata: { sessionId }, + } + }) + + if (suggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + suggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('custom-title') + return + } + + // No suggestions found - clear and return + clearSuggestions() + return + } } - } - // Determine whether to display the argument hint and command suggestions. - if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0 && !hasCommandWithArguments(isAtEndWithWhitespace, value)) { - let commandArgumentHint: string | undefined = undefined; - if (value.length > 1) { - // We have a partial or complete command without arguments - // Check if it matches a command exactly and has an argument hint - - // Extract command name: everything after / until the first space (or end) - const spaceIndex = value.indexOf(' '); - const commandName = spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex); - - // Check if there are real arguments (non-whitespace after the command) - const hasRealArguments = spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0; - - // Check if input is exactly "command + single space" (ready for arguments) - const hasExactlyOneTrailingSpace = spaceIndex !== -1 && value.length === spaceIndex + 1; - - // If input has a space after the command, don't show suggestions - // This prevents Enter from selecting a different command after Tab completion - if (spaceIndex !== -1) { - const exactMatch = commands.find(cmd => getCommandName(cmd) === commandName); - if (exactMatch || hasRealArguments) { - // Priority 1: Static argumentHint (only on first trailing space for backwards compat) - if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) { - commandArgumentHint = exactMatch.argumentHint; - } - // Priority 2: Progressive hint from argNames (show when trailing space) - else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && value.endsWith(' ')) { - const argsText = value.slice(spaceIndex + 1); - const typedArgs = parseArguments(argsText); - commandArgumentHint = generateProgressiveArgumentHint(exactMatch.argNames, typedArgs); + // Determine whether to display the argument hint and command suggestions. + if ( + mode === 'prompt' && + isCommandInput(value) && + effectiveCursorOffset > 0 && + !hasCommandWithArguments(isAtEndWithWhitespace, value) + ) { + let commandArgumentHint: string | undefined = undefined + if (value.length > 1) { + // We have a partial or complete command without arguments + // Check if it matches a command exactly and has an argument hint + + // Extract command name: everything after / until the first space (or end) + const spaceIndex = value.indexOf(' ') + const commandName = + spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex) + + // Check if there are real arguments (non-whitespace after the command) + const hasRealArguments = + spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0 + + // Check if input is exactly "command + single space" (ready for arguments) + const hasExactlyOneTrailingSpace = + spaceIndex !== -1 && value.length === spaceIndex + 1 + + // If input has a space after the command, don't show suggestions + // This prevents Enter from selecting a different command after Tab completion + if (spaceIndex !== -1) { + const exactMatch = commands.find( + cmd => getCommandName(cmd) === commandName, + ) + if (exactMatch || hasRealArguments) { + // Priority 1: Static argumentHint (only on first trailing space for backwards compat) + if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) { + commandArgumentHint = exactMatch.argumentHint + } + // Priority 2: Progressive hint from argNames (show when trailing space) + else if ( + exactMatch?.type === 'prompt' && + exactMatch.argNames?.length && + value.endsWith(' ') + ) { + const argsText = value.slice(spaceIndex + 1) + const typedArgs = parseArguments(argsText) + commandArgumentHint = generateProgressiveArgumentHint( + exactMatch.argNames, + typedArgs, + ) + } + setSuggestionsState(() => ({ + commandArgumentHint, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return } - setSuggestionsState(() => ({ - commandArgumentHint, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; } + + // Note: argument hint is only shown when there's exactly one trailing space + // (set above when hasExactlyOneTrailingSpace is true) } - // Note: argument hint is only shown when there's exactly one trailing space - // (set above when hasExactlyOneTrailingSpace is true) + const commandItems = generateCommandSuggestions(value, commands) + setSuggestionsState(() => ({ + commandArgumentHint, + suggestions: commandItems, + selectedSuggestion: commandItems.length > 0 ? 0 : -1, + })) + setSuggestionType(commandItems.length > 0 ? 'command' : 'none') + + // Use stable width from all commands (prevents layout shift when filtering) + if (commandItems.length > 0) { + setMaxColumnWidth(allCommandsMaxWidth) + } + return } - const commandItems = generateCommandSuggestions(value, commands); - setSuggestionsState(() => ({ - commandArgumentHint, - suggestions: commandItems, - selectedSuggestion: commandItems.length > 0 ? 0 : -1 - })); - setSuggestionType(commandItems.length > 0 ? 'command' : 'none'); - - // Use stable width from all commands (prevents layout shift when filtering) - if (commandItems.length > 0) { - setMaxColumnWidth(allCommandsMaxWidth); + + if (suggestionType === 'command') { + // If we had command suggestions but the input no longer starts with '/' + // we need to clear the suggestions. However, we should not return + // because there may be relevant @ symbol and file suggestions. + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } else if ( + isCommandInput(value) && + hasCommandWithArguments(isAtEndWithWhitespace, value) + ) { + // If we have a command with arguments (no trailing space), clear any stale hint + // This prevents the hint from flashing when transitioning between states + setSuggestionsState(prev => + prev.commandArgumentHint + ? { ...prev, commandArgumentHint: undefined } + : prev, + ) } - return; - } - if (suggestionType === 'command') { - // If we had command suggestions but the input no longer starts with '/' - // we need to clear the suggestions. However, we should not return - // because there may be relevant @ symbol and file suggestions. - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - } else if (isCommandInput(value) && hasCommandWithArguments(isAtEndWithWhitespace, value)) { - // If we have a command with arguments (no trailing space), clear any stale hint - // This prevents the hint from flashing when transitioning between states - setSuggestionsState(prev => prev.commandArgumentHint ? { - ...prev, - commandArgumentHint: undefined - } : prev); - } - if (suggestionType === 'custom-title') { - // If we had custom-title suggestions but the input is no longer /resume - // we need to clear the suggestions. - clearSuggestions(); - } - if (suggestionType === 'agent' && suggestionsRef.current.some((s: SuggestionItem) => s.id?.startsWith('dm-'))) { - // If we had team member suggestions but the input no longer has @ - // we need to clear the suggestions. - const hasAt = value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/); - if (!hasAt) { - clearSuggestions(); + + if (suggestionType === 'custom-title') { + // If we had custom-title suggestions but the input is no longer /resume + // we need to clear the suggestions. + clearSuggestions() } - } - // Check for @ symbol to trigger file and MCP resource suggestions - // Skip @ autocomplete in bash mode - @ has no special meaning in shell commands - if (hasAtSymbol && mode !== 'bash') { - // Get the @ token (including the @ symbol) - const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); - if (completionToken && completionToken.token.startsWith('@')) { - const searchToken = extractSearchToken(completionToken); - - // If the token after @ is path-like, use path completion instead of fuzzy search - // This handles cases like @~/path, @./path, @/path for directory traversal - if (isPathLikeToken(searchToken)) { - latestPathTokenRef.current = searchToken; - const pathSuggestions = await getPathCompletions(searchToken, { - maxResults: 10 - }); - // Discard stale results if a newer query was initiated while waiting - if (latestPathTokenRef.current !== searchToken) { - return; - } - if (pathSuggestions.length > 0) { - setSuggestionsState(prev => ({ - suggestions: pathSuggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, pathSuggestions), - commandArgumentHint: undefined - })); - setSuggestionType('directory'); - return; - } + if ( + suggestionType === 'agent' && + suggestionsRef.current.some((s: SuggestionItem) => + s.id?.startsWith('dm-'), + ) + ) { + // If we had team member suggestions but the input no longer has @ + // we need to clear the suggestions. + const hasAt = value + .substring(0, effectiveCursorOffset) + .match(/(^|\s)@([\w-]*)$/) + if (!hasAt) { + clearSuggestions() } + } - // Skip if we already fetched for this exact token (prevents loop from - // suggestions dependency causing updateSuggestions to be recreated) - if (latestSearchTokenRef.current === searchToken) { - return; + // Check for @ symbol to trigger file and MCP resource suggestions + // Skip @ autocomplete in bash mode - @ has no special meaning in shell commands + if (hasAtSymbol && mode !== 'bash') { + // Get the @ token (including the @ symbol) + const completionToken = extractCompletionToken( + value, + effectiveCursorOffset, + true, + ) + if (completionToken && completionToken.token.startsWith('@')) { + const searchToken = extractSearchToken(completionToken) + + // If the token after @ is path-like, use path completion instead of fuzzy search + // This handles cases like @~/path, @./path, @/path for directory traversal + if (isPathLikeToken(searchToken)) { + latestPathTokenRef.current = searchToken + const pathSuggestions = await getPathCompletions(searchToken, { + maxResults: 10, + }) + // Discard stale results if a newer query was initiated while waiting + if (latestPathTokenRef.current !== searchToken) { + return + } + if (pathSuggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions: pathSuggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + pathSuggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('directory') + return + } + } + + // Skip if we already fetched for this exact token (prevents loop from + // suggestions dependency causing updateSuggestions to be recreated) + if (latestSearchTokenRef.current === searchToken) { + return + } + void debouncedFetchFileSuggestions(searchToken, true) + return } - void debouncedFetchFileSuggestions(searchToken, true); - return; } - } - // If we have active file suggestions or the input changed, check for file suggestions - if (suggestionType === 'file') { - const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); - if (completionToken) { - const searchToken = extractSearchToken(completionToken); - // Skip if we already fetched for this exact token - if (latestSearchTokenRef.current === searchToken) { - return; + // If we have active file suggestions or the input changed, check for file suggestions + if (suggestionType === 'file') { + const completionToken = extractCompletionToken( + value, + effectiveCursorOffset, + true, + ) + if (completionToken) { + const searchToken = extractSearchToken(completionToken) + // Skip if we already fetched for this exact token + if (latestSearchTokenRef.current === searchToken) { + return + } + void debouncedFetchFileSuggestions(searchToken, false) + } else { + // If we had file suggestions but now there's no completion token + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - void debouncedFetchFileSuggestions(searchToken, false); - } else { - // If we had file suggestions but now there's no completion token - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); } - } - // Clear shell suggestions if not in bash mode OR if input has changed - if (suggestionType === 'shell') { - const inputSnapshot = (suggestionsRef.current[0]?.metadata as { - inputSnapshot?: string; - })?.inputSnapshot; - if (mode !== 'bash' || value !== inputSnapshot) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + // Clear shell suggestions if not in bash mode OR if input has changed + if (suggestionType === 'shell') { + const inputSnapshot = ( + suggestionsRef.current[0]?.metadata as { inputSnapshot?: string } + )?.inputSnapshot + + if (mode !== 'bash' || value !== inputSnapshot) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } } - } - }, [suggestionType, commands, setSuggestionsState, clearSuggestions, debouncedFetchFileSuggestions, debouncedFetchSlackChannels, mode, suppressSuggestions, - // Note: using suggestionsRef instead of suggestions to avoid recreating - // this callback when only selectedSuggestion changes (not the suggestions list) - allCommandsMaxWidth]); + }, + [ + suggestionType, + commands, + setSuggestionsState, + clearSuggestions, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + mode, + suppressSuggestions, + // Note: using suggestionsRef instead of suggestions to avoid recreating + // this callback when only selectedSuggestion changes (not the suggestions list) + allCommandsMaxWidth, + ], + ) // Update suggestions when input changes // Note: We intentionally don't depend on cursorOffset here - cursor movement alone @@ -893,19 +1140,19 @@ export function useTypeahead({ useEffect(() => { // If suggestions were dismissed for this exact input, don't re-trigger if (dismissedForInputRef.current === input) { - return; + return } // When the actual input text changes (not just updateSuggestions being recreated), // reset the search token ref so the same query can be re-fetched. // This fixes: type @readme.md, clear, retype @readme.md → no suggestions. if (prevInputRef.current !== input) { - prevInputRef.current = input; - latestSearchTokenRef.current = null; + prevInputRef.current = input + latestSearchTokenRef.current = null } // Clear the dismissed state when input changes - dismissedForInputRef.current = null; - void updateSuggestions(input); - }, [input, updateSuggestions]); + dismissedForInputRef.current = null + void updateSuggestions(input) + }, [input, updateSuggestions]) // Handle tab key press - complete suggestions or trigger file suggestions const handleTab = useCallback(async () => { @@ -914,143 +1161,216 @@ export function useTypeahead({ // Check for bash mode history completion first if (mode === 'bash') { // Replace the input with the full command from history - onInputChange(effectiveGhostText.fullCommand); - setCursorOffset(effectiveGhostText.fullCommand.length); - setInlineGhostText(undefined); - return; + onInputChange(effectiveGhostText.fullCommand) + setCursorOffset(effectiveGhostText.fullCommand.length) + setInlineGhostText(undefined) + return } // Find the mid-input command to get its position (for prompt mode) - const midInputCommand = findMidInputSlashCommand(input, cursorOffset); + const midInputCommand = findMidInputSlashCommand(input, cursorOffset) if (midInputCommand) { // Replace the partial command with the full command + space - const before = input.slice(0, midInputCommand.startPos); - const after = input.slice(midInputCommand.startPos + midInputCommand.token.length); - const newInput = before + '/' + effectiveGhostText.fullCommand + ' ' + after; - const newCursorOffset = midInputCommand.startPos + 1 + effectiveGhostText.fullCommand.length + 1; - onInputChange(newInput); - setCursorOffset(newCursorOffset); - return; + const before = input.slice(0, midInputCommand.startPos) + const after = input.slice( + midInputCommand.startPos + midInputCommand.token.length, + ) + const newInput = + before + '/' + effectiveGhostText.fullCommand + ' ' + after + const newCursorOffset = + midInputCommand.startPos + + 1 + + effectiveGhostText.fullCommand.length + + 1 + + onInputChange(newInput) + setCursorOffset(newCursorOffset) + return } } // If we have active suggestions, select one if (suggestions.length > 0) { // Cancel any pending debounced fetches to prevent flicker when accepting - debouncedFetchFileSuggestions.cancel(); - debouncedFetchSlackChannels.cancel(); - const index = selectedSuggestion === -1 ? 0 : selectedSuggestion; - const suggestion = suggestions[index]; + debouncedFetchFileSuggestions.cancel() + debouncedFetchSlackChannels.cancel() + + const index = selectedSuggestion === -1 ? 0 : selectedSuggestion + const suggestion = suggestions[index] + if (suggestionType === 'command' && index < suggestions.length) { if (suggestion) { - applyCommandSuggestion(suggestion, false, - // don't execute on tab - commands, onInputChange, setCursorOffset, onSubmit); - clearSuggestions(); + applyCommandSuggestion( + suggestion, + false, // don't execute on tab + commands, + onInputChange, + setCursorOffset, + onSubmit, + ) + clearSuggestions() } } else if (suggestionType === 'custom-title' && suggestions.length > 0) { // Apply custom title to /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion); - onInputChange(newInput); - setCursorOffset(newInput.length); - clearSuggestions(); + const newInput = buildResumeInputFromSuggestion(suggestion) + onInputChange(newInput) + setCursorOffset(newInput.length) + clearSuggestions() } } else if (suggestionType === 'directory' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { // Check if this is a command context (e.g., /add-dir) or general path completion - const isInCommandContext = isCommandInput(input); - let newInput: string; + const isInCommandContext = isCommandInput(input) + + let newInput: string if (isInCommandContext) { // Command context: replace just the argument portion - const spaceIndex = input.indexOf(' '); - const commandPart = input.slice(0, spaceIndex + 1); // Include the space - const cmdSuffix = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory' ? '/' : ' '; - newInput = commandPart + suggestion.id + cmdSuffix; - onInputChange(newInput); - setCursorOffset(newInput.length); - if (isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory') { + const spaceIndex = input.indexOf(' ') + const commandPart = input.slice(0, spaceIndex + 1) // Include the space + const cmdSuffix = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + ? '/' + : ' ' + newInput = commandPart + suggestion.id + cmdSuffix + + onInputChange(newInput) + setCursorOffset(newInput.length) + + if ( + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + ) { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, - commandArgumentHint: undefined - })); - void updateSuggestions(newInput, newInput.length); + commandArgumentHint: undefined, + })) + void updateSuggestions(newInput, newInput.length) } else { - clearSuggestions(); + clearSuggestions() } } else { // General path completion: replace the path token in input with @-prefixed path // Try to get token with @ prefix first to check if already prefixed - const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); - const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); + const completionTokenWithAt = extractCompletionToken( + input, + cursorOffset, + true, + ) + const completionToken = + completionTokenWithAt ?? + extractCompletionToken(input, cursorOffset, false) + if (completionToken) { - const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; - const result = applyDirectorySuggestion(input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir); - newInput = result.newInput; - onInputChange(newInput); - setCursorOffset(result.cursorPos); + const isDir = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + const result = applyDirectorySuggestion( + input, + suggestion.id, + completionToken.startPos, + completionToken.token.length, + isDir, + ) + newInput = result.newInput + + onInputChange(newInput) + setCursorOffset(result.cursorPos) + if (isDir) { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, - commandArgumentHint: undefined - })); - void updateSuggestions(newInput, result.cursorPos); + commandArgumentHint: undefined, + })) + void updateSuggestions(newInput, result.cursorPos) } else { // For files, clear suggestions - clearSuggestions(); + clearSuggestions() } } else { // No completion token found (e.g., cursor after space) - just clear suggestions // without modifying input to avoid data loss - clearSuggestions(); + clearSuggestions() } } } } else if (suggestionType === 'shell' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); - clearSuggestions(); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) + clearSuggestions() } - } else if (suggestionType === 'agent' && suggestions.length > 0 && suggestions[index]?.id?.startsWith('dm-')) { - const suggestion = suggestions[index]; + } else if ( + suggestionType === 'agent' && + suggestions.length > 0 && + suggestions[index]?.id?.startsWith('dm-') + ) { + const suggestion = suggestions[index] if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + DM_MEMBER_RE, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } else if (suggestionType === 'slack-channel' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + HASH_CHANNEL_RE, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } else if (suggestionType === 'file' && suggestions.length > 0) { - const completionToken = extractCompletionToken(input, cursorOffset, true); + const completionToken = extractCompletionToken( + input, + cursorOffset, + true, + ) if (!completionToken) { - clearSuggestions(); - return; + clearSuggestions() + return } // Check if all suggestions share a common prefix longer than the current input - const commonPrefix = findLongestCommonPrefix(suggestions); + const commonPrefix = findLongestCommonPrefix(suggestions) // Determine if token starts with @ to preserve it during replacement - const hasAtPrefix = completionToken.token.startsWith('@'); + const hasAtPrefix = completionToken.token.startsWith('@') // The effective token length excludes the @ and quotes if present - let effectiveTokenLength: number; + let effectiveTokenLength: number if (completionToken.isQuoted) { // Remove @" prefix and optional closing " to get effective length - effectiveTokenLength = completionToken.token.slice(2).replace(/"$/, '').length; + effectiveTokenLength = completionToken.token + .slice(2) + .replace(/"$/, '').length } else if (hasAtPrefix) { - effectiveTokenLength = completionToken.token.length - 1; + effectiveTokenLength = completionToken.token.length - 1 } else { - effectiveTokenLength = completionToken.token.length; + effectiveTokenLength = completionToken.token.length } // If there's a common prefix longer than what the user has typed, @@ -1060,233 +1380,401 @@ export function useTypeahead({ displayText: commonPrefix, mode, hasAtPrefix, - needsQuotes: false, - // common prefix doesn't need quotes unless already quoted + needsQuotes: false, // common prefix doesn't need quotes unless already quoted isQuoted: completionToken.isQuoted, - isComplete: false // partial completion - }); - applyFileSuggestion(replacementValue, input, completionToken.token, completionToken.startPos, onInputChange, setCursorOffset); + isComplete: false, // partial completion + }) + + applyFileSuggestion( + replacementValue, + input, + completionToken.token, + completionToken.startPos, + onInputChange, + setCursorOffset, + ) // Don't clear suggestions so user can continue typing or select a specific option // Instead, update for the new prefix - void updateSuggestions(input.replace(completionToken.token, replacementValue), cursorOffset); + void updateSuggestions( + input.replace(completionToken.token, replacementValue), + cursorOffset, + ) } else if (index < suggestions.length) { // Otherwise, apply the selected suggestion - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - const needsQuotes = suggestion.displayText.includes(' '); + const needsQuotes = suggestion.displayText.includes(' ') const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, hasAtPrefix, needsQuotes, isQuoted: completionToken.isQuoted, - isComplete: true // complete suggestion - }); - applyFileSuggestion(replacementValue, input, completionToken.token, completionToken.startPos, onInputChange, setCursorOffset); - clearSuggestions(); + isComplete: true, // complete suggestion + }) + + applyFileSuggestion( + replacementValue, + input, + completionToken.token, + completionToken.startPos, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } } } else if (input.trim() !== '') { - let suggestionType: SuggestionType; - let suggestionItems: SuggestionItem[]; + let suggestionType: SuggestionType + let suggestionItems: SuggestionItem[] + if (mode === 'bash') { - suggestionType = 'shell'; + suggestionType = 'shell' // This should be very fast, taking <10ms - const bashSuggestions = await generateBashSuggestions(input, cursorOffset); + const bashSuggestions = await generateBashSuggestions( + input, + cursorOffset, + ) if (bashSuggestions.length === 1) { // If single suggestion, apply it immediately - const suggestion = bashSuggestions[0]; + const suggestion = bashSuggestions[0] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) } - suggestionItems = []; + suggestionItems = [] } else { - suggestionItems = bashSuggestions; + suggestionItems = bashSuggestions } } else { - suggestionType = 'file'; + suggestionType = 'file' // If no suggestions, fetch file and MCP resource suggestions - const completionInfo = extractCompletionToken(input, cursorOffset, true); + const completionInfo = extractCompletionToken(input, cursorOffset, true) if (completionInfo) { // If token starts with @, search without the @ prefix - const isAtSymbol = completionInfo.token.startsWith('@'); - const searchToken = isAtSymbol ? completionInfo.token.substring(1) : completionInfo.token; - suggestionItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); + const isAtSymbol = completionInfo.token.startsWith('@') + const searchToken = isAtSymbol + ? completionInfo.token.substring(1) + : completionInfo.token + + suggestionItems = await generateUnifiedSuggestions( + searchToken, + mcpResources, + agents, + isAtSymbol, + ) } else { - suggestionItems = []; + suggestionItems = [] } } + if (suggestionItems.length > 0) { // Multiple suggestions or not bash mode: show list setSuggestionsState(prev => ({ commandArgumentHint: undefined, suggestions: suggestionItems, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestionItems) - })); - setSuggestionType(suggestionType); - setMaxColumnWidth(undefined); + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + suggestionItems, + ), + })) + setSuggestionType(suggestionType) + setMaxColumnWidth(undefined) } } - }, [suggestions, selectedSuggestion, input, suggestionType, commands, mode, onInputChange, setCursorOffset, onSubmit, clearSuggestions, cursorOffset, updateSuggestions, mcpResources, setSuggestionsState, agents, debouncedFetchFileSuggestions, debouncedFetchSlackChannels, effectiveGhostText]); + }, [ + suggestions, + selectedSuggestion, + input, + suggestionType, + commands, + mode, + onInputChange, + setCursorOffset, + onSubmit, + clearSuggestions, + cursorOffset, + updateSuggestions, + mcpResources, + setSuggestionsState, + agents, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + effectiveGhostText, + ]) // Handle enter key press - apply and execute suggestions const handleEnter = useCallback(() => { - if (selectedSuggestion < 0 || suggestions.length === 0) return; - const suggestion = suggestions[selectedSuggestion]; - if (suggestionType === 'command' && selectedSuggestion < suggestions.length) { + if (selectedSuggestion < 0 || suggestions.length === 0) return + + const suggestion = suggestions[selectedSuggestion] + + if ( + suggestionType === 'command' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { - applyCommandSuggestion(suggestion, true, - // execute on return - commands, onInputChange, setCursorOffset, onSubmit); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + applyCommandSuggestion( + suggestion, + true, // execute on return + commands, + onInputChange, + setCursorOffset, + onSubmit, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'custom-title' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'custom-title' && + selectedSuggestion < suggestions.length + ) { // Apply custom title and execute /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion); - onInputChange(newInput); - setCursorOffset(newInput.length); - onSubmit(newInput, /* isSubmittingSlashCommand */true); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + const newInput = buildResumeInputFromSuggestion(suggestion) + onInputChange(newInput) + setCursorOffset(newInput.length) + onSubmit(newInput, /* isSubmittingSlashCommand */ true) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'shell' && selectedSuggestion < suggestions.length) { - const suggestion = suggestions[selectedSuggestion]; + } else if ( + suggestionType === 'shell' && + selectedSuggestion < suggestions.length + ) { + const suggestion = suggestions[selectedSuggestion] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'agent' && selectedSuggestion < suggestions.length && suggestion?.id?.startsWith('dm-')) { - applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - } else if (suggestionType === 'slack-channel' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'agent' && + selectedSuggestion < suggestions.length && + suggestion?.id?.startsWith('dm-') + ) { + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + DM_MEMBER_RE, + onInputChange, + setCursorOffset, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } else if ( + suggestionType === 'slack-channel' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + HASH_CHANNEL_RE, + onInputChange, + setCursorOffset, + ) + debouncedFetchSlackChannels.cancel() + clearSuggestions() } - } else if (suggestionType === 'file' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'file' && + selectedSuggestion < suggestions.length + ) { // Extract completion token directly when needed - const completionInfo = extractCompletionToken(input, cursorOffset, true); + const completionInfo = extractCompletionToken(input, cursorOffset, true) if (completionInfo) { if (suggestion) { - const hasAtPrefix = completionInfo.token.startsWith('@'); - const needsQuotes = suggestion.displayText.includes(' '); + const hasAtPrefix = completionInfo.token.startsWith('@') + const needsQuotes = suggestion.displayText.includes(' ') const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, hasAtPrefix, needsQuotes, isQuoted: completionInfo.isQuoted, - isComplete: true // complete suggestion - }); - applyFileSuggestion(replacementValue, input, completionInfo.token, completionInfo.startPos, onInputChange, setCursorOffset); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + isComplete: true, // complete suggestion + }) + + applyFileSuggestion( + replacementValue, + input, + completionInfo.token, + completionInfo.startPos, + onInputChange, + setCursorOffset, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } } - } else if (suggestionType === 'directory' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'directory' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { // In command context (e.g., /add-dir), Enter submits the command // rather than applying the directory suggestion. Just clear // suggestions and let the submit handler process the current input. if (isCommandInput(input)) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return } // General path completion: replace the path token - const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); - const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); + const completionTokenWithAt = extractCompletionToken( + input, + cursorOffset, + true, + ) + const completionToken = + completionTokenWithAt ?? + extractCompletionToken(input, cursorOffset, false) + if (completionToken) { - const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; - const result = applyDirectorySuggestion(input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir); - onInputChange(result.newInput); - setCursorOffset(result.cursorPos); + const isDir = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + const result = applyDirectorySuggestion( + input, + suggestion.id, + completionToken.startPos, + completionToken.token.length, + isDir, + ) + onInputChange(result.newInput) + setCursorOffset(result.cursorPos) } // If no completion token found (e.g., cursor after space), don't modify input // to avoid data loss - just clear suggestions - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } } - }, [suggestions, selectedSuggestion, suggestionType, commands, input, cursorOffset, mode, onInputChange, setCursorOffset, onSubmit, clearSuggestions, debouncedFetchFileSuggestions, debouncedFetchSlackChannels]); + }, [ + suggestions, + selectedSuggestion, + suggestionType, + commands, + input, + cursorOffset, + mode, + onInputChange, + setCursorOffset, + onSubmit, + clearSuggestions, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + ]) // Handler for autocomplete:accept - accepts current suggestion via Tab or Right Arrow const handleAutocompleteAccept = useCallback(() => { - void handleTab(); - }, [handleTab]); + void handleTab() + }, [handleTab]) // Handler for autocomplete:dismiss - clears suggestions and prevents re-triggering const handleAutocompleteDismiss = useCallback(() => { - debouncedFetchFileSuggestions.cancel(); - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); + debouncedFetchFileSuggestions.cancel() + debouncedFetchSlackChannels.cancel() + clearSuggestions() // Remember the input when dismissed to prevent immediate re-triggering - dismissedForInputRef.current = input; - }, [debouncedFetchFileSuggestions, debouncedFetchSlackChannels, clearSuggestions, input]); + dismissedForInputRef.current = input + }, [ + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + clearSuggestions, + input, + ]) // Handler for autocomplete:previous - selects previous suggestion const handleAutocompletePrevious = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: prev.selectedSuggestion <= 0 ? suggestions.length - 1 : prev.selectedSuggestion - 1 - })); - }, [suggestions.length, setSuggestionsState]); + selectedSuggestion: + prev.selectedSuggestion <= 0 + ? suggestions.length - 1 + : prev.selectedSuggestion - 1, + })) + }, [suggestions.length, setSuggestionsState]) // Handler for autocomplete:next - selects next suggestion const handleAutocompleteNext = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: prev.selectedSuggestion >= suggestions.length - 1 ? 0 : prev.selectedSuggestion + 1 - })); - }, [suggestions.length, setSuggestionsState]); + selectedSuggestion: + prev.selectedSuggestion >= suggestions.length - 1 + ? 0 + : prev.selectedSuggestion + 1, + })) + }, [suggestions.length, setSuggestionsState]) // Autocomplete context keybindings - only active when suggestions are visible - const autocompleteHandlers = useMemo(() => ({ - 'autocomplete:accept': handleAutocompleteAccept, - 'autocomplete:dismiss': handleAutocompleteDismiss, - 'autocomplete:previous': handleAutocompletePrevious, - 'autocomplete:next': handleAutocompleteNext - }), [handleAutocompleteAccept, handleAutocompleteDismiss, handleAutocompletePrevious, handleAutocompleteNext]); + const autocompleteHandlers = useMemo( + () => ({ + 'autocomplete:accept': handleAutocompleteAccept, + 'autocomplete:dismiss': handleAutocompleteDismiss, + 'autocomplete:previous': handleAutocompletePrevious, + 'autocomplete:next': handleAutocompleteNext, + }), + [ + handleAutocompleteAccept, + handleAutocompleteDismiss, + handleAutocompletePrevious, + handleAutocompleteNext, + ], + ) // Register autocomplete as an overlay so CancelRequestHandler defers ESC handling // This ensures ESC dismisses autocomplete before canceling running tasks - const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText; - const isModalOverlayActive = useIsModalOverlayActive(); - useRegisterOverlay('autocomplete', isAutocompleteActive); + const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText + const isModalOverlayActive = useIsModalOverlayActive() + useRegisterOverlay('autocomplete', isAutocompleteActive) // Register Autocomplete context so it appears in activeContexts for other handlers. // This allows Chat's resolver to see Autocomplete and defer to its bindings for up/down. - useRegisterKeybindingContext('Autocomplete', isAutocompleteActive); + useRegisterKeybindingContext('Autocomplete', isAutocompleteActive) // Disable autocomplete keybindings when a modal overlay (e.g., DiffDialog) is active, // so escape reaches the overlay's handler instead of dismissing autocomplete useKeybindings(autocompleteHandlers, { context: 'Autocomplete', - isActive: isAutocompleteActive && !isModalOverlayActive - }); + isActive: isAutocompleteActive && !isModalOverlayActive, + }) + function acceptSuggestionText(text: string): void { - const detectedMode = getModeFromInput(text); + const detectedMode = getModeFromInput(text) if (detectedMode !== 'prompt' && onModeChange) { - onModeChange(detectedMode); - const stripped = getValueFromInput(text); - onInputChange(stripped); - setCursorOffset(stripped.length); + onModeChange(detectedMode) + const stripped = getValueFromInput(text) + onInputChange(stripped) + setCursorOffset(stripped.length) } else { - onInputChange(text); - setCursorOffset(text.length); + onInputChange(text) + setCursorOffset(text.length) } } @@ -1294,13 +1782,13 @@ export function useTypeahead({ const handleKeyDown = (e: KeyboardEvent): void => { // Handle right arrow to accept prompt suggestion ghost text if (e.key === 'right' && !isViewingTeammate) { - const suggestionText = promptSuggestion.text; - const suggestionShownAt = promptSuggestion.shownAt; + const suggestionText = promptSuggestion.text + const suggestionShownAt = promptSuggestion.shownAt if (suggestionText && suggestionShownAt > 0 && input === '') { - markAccepted(); - acceptSuggestionText(suggestionText); - e.stopImmediatePropagation(); - return; + markAccepted() + acceptSuggestionText(suggestionText) + e.stopImmediatePropagation() + return } } @@ -1309,69 +1797,78 @@ export function useTypeahead({ if (e.key === 'tab' && !e.shift) { // Skip if autocomplete is handling this (suggestions or ghost text exist) if (suggestions.length > 0 || effectiveGhostText) { - return; + return } // Accept prompt suggestion if it exists in AppState - const suggestionText = promptSuggestion.text; - const suggestionShownAt = promptSuggestion.shownAt; - if (suggestionText && suggestionShownAt > 0 && input === '' && !isViewingTeammate) { - e.preventDefault(); - markAccepted(); - acceptSuggestionText(suggestionText); - return; + const suggestionText = promptSuggestion.text + const suggestionShownAt = promptSuggestion.shownAt + if ( + suggestionText && + suggestionShownAt > 0 && + input === '' && + !isViewingTeammate + ) { + e.preventDefault() + markAccepted() + acceptSuggestionText(suggestionText) + return } // Remind user about thinking toggle shortcut if empty input if (input.trim() === '') { - e.preventDefault(); + e.preventDefault() addNotification({ key: 'thinking-toggle-hint', - jsx: + jsx: ( + Use {thinkingToggleShortcut} to toggle thinking - , + + ), priority: 'immediate', - timeoutMs: 3000 - }); + timeoutMs: 3000, + }) } - return; + return } // Only continue with navigation if we have suggestions - if (suggestions.length === 0) return; + if (suggestions.length === 0) return // Handle Ctrl-N/P for navigation (arrows handled by keybindings) // Skip if we're in the middle of a chord sequence to allow chords like ctrl+f n - const hasPendingChord = keybindingContext?.pendingChord != null; + const hasPendingChord = keybindingContext?.pendingChord != null if (e.ctrl && e.key === 'n' && !hasPendingChord) { - e.preventDefault(); - handleAutocompleteNext(); - return; + e.preventDefault() + handleAutocompleteNext() + return } + if (e.ctrl && e.key === 'p' && !hasPendingChord) { - e.preventDefault(); - handleAutocompletePrevious(); - return; + e.preventDefault() + handleAutocompletePrevious() + return } // Handle selection and execution via return/enter // Shift+Enter and Meta+Enter insert newlines (handled by useTextInput), // so don't accept the suggestion for those. if (e.key === 'return' && !e.shift && !e.meta) { - e.preventDefault(); - handleEnter(); + e.preventDefault() + handleEnter() } - }; + } // Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown. useInput((_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress); - handleKeyDown(kbEvent); + const kbEvent = new KeyboardEvent(event.keypress) + handleKeyDown(kbEvent) if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation(); + event.stopImmediatePropagation() } - }); + }) + return { suggestions, selectedSuggestion, @@ -1379,6 +1876,6 @@ export function useTypeahead({ maxColumnWidth, commandArgumentHint, inlineGhostText: effectiveGhostText, - handleKeyDown - }; + handleKeyDown, + } } diff --git a/src/hooks/useVoiceIntegration.tsx b/src/hooks/useVoiceIntegration.tsx index 47de37c93..7cedb1c0f 100644 --- a/src/hooks/useVoiceIntegration.tsx +++ b/src/hooks/useVoiceIntegration.tsx @@ -1,75 +1,85 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { useIsModalOverlayActive } from '../context/overlayContext.js'; -import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js'; -import { KeyboardEvent } from '../ink/events/keyboard-event.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useNotifications } from '../context/notifications.js' +import { useIsModalOverlayActive } from '../context/overlayContext.js' +import { + useGetVoiceState, + useSetVoiceState, + useVoiceState, +} from '../context/voice.js' +import { KeyboardEvent } from '../ink/events/keyboard-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until REPL wires handleKeyDown to -import { useInput } from '../ink.js'; -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { keystrokesEqual } from '../keybindings/resolver.js'; -import type { ParsedKeystroke } from '../keybindings/types.js'; -import { normalizeFullWidthSpace } from '../utils/stringUtils.js'; -import { useVoiceEnabled } from './useVoiceEnabled.js'; +import { useInput } from '../ink.js' +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' +import { keystrokesEqual } from '../keybindings/resolver.js' +import type { ParsedKeystroke } from '../keybindings/types.js' +import { normalizeFullWidthSpace } from '../utils/stringUtils.js' +import { useVoiceEnabled } from './useVoiceEnabled.js' // Dead code elimination: conditional import for voice input hook. /* eslint-disable @typescript-eslint/no-require-imports */ // Capture the module namespace, not the function: spyOn() mutates the module // object, so `voiceNs.useVoice(...)` resolves to the spy even if this module // was loaded before the spy was installed (test ordering independence). -const voiceNs: { - useVoice: typeof import('./useVoice.js').useVoice; -} = feature('VOICE_MODE') ? require('./useVoice.js') : { - useVoice: ({ - enabled: _e - }: { - onTranscript: (t: string) => void; - enabled: boolean; - }) => ({ - state: 'idle' as const, - handleKeyEvent: (_fallbackMs?: number) => {} - }) -}; +const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature( + 'VOICE_MODE', +) + ? require('./useVoice.js') + : { + useVoice: ({ + enabled: _e, + }: { + onTranscript: (t: string) => void + enabled: boolean + }) => ({ + state: 'idle' as const, + handleKeyEvent: (_fallbackMs?: number) => {}, + }), + } /* eslint-enable @typescript-eslint/no-require-imports */ // Maximum gap (ms) between key presses to count as held (auto-repeat). // Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while // excluding normal typing speed (100-300ms between keystrokes). -const RAPID_KEY_GAP_MS = 120; +const RAPID_KEY_GAP_MS = 120 // Fallback (ms) for modifier-combo first-press activation. Must match // FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial // key-repeat delay (~2s on macOS with slider at "Long") so holding a // modifier combo doesn't fragment into two sessions when the first // auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS. -const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000; +const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000 // Number of rapid consecutive key events required to activate voice. // Only applies to bare-char bindings (space, v, etc.) where a single press // could be normal typing. Modifier combos activate on the first press. -const HOLD_THRESHOLD = 5; +const HOLD_THRESHOLD = 5 // Number of rapid key events to start showing warmup feedback. -const WARMUP_THRESHOLD = 2; +const WARMUP_THRESHOLD = 2 // Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy // matchesKeystroke(input, Key, ...) path which assumed useInput's raw // `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space', // 'f9') that getKeyName() didn't handle, so modifier combos and f-keys // silently failed to match after the onKeyDown migration (#23524). -function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean { +function matchesKeyboardEvent( + e: KeyboardEvent, + target: ParsedKeystroke, +): boolean { // KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space // and 'enter' for return (see parser.ts case 'space'/'return'). - const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase(); - if (key !== target.key) return false; - if (e.ctrl !== target.ctrl) return false; - if (e.shift !== target.shift) return false; + const key = + e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase() + if (key !== target.key) return false + if (e.ctrl !== target.ctrl) return false + if (e.shift !== target.shift) return false // KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix); // ParsedKeystroke has both alt and meta as aliases for the same thing. - if (e.meta !== (target.alt || target.meta)) return false; - if (e.superKey !== target.super) return false; - return true; + if (e.meta !== (target.alt || target.meta)) return false + if (e.superKey !== target.super) return false + return true } // Hardcoded default for when there's no KeybindingProvider at all (e.g. @@ -82,60 +92,61 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = { alt: false, shift: false, meta: false, - super: false -}; + super: false, +} + type InsertTextHandle = { - insert: (text: string) => void; - setInputWithCursor: (value: string, cursor: number) => void; - cursorOffset: number; -}; + insert: (text: string) => void + setInputWithCursor: (value: string, cursor: number) => void + cursorOffset: number +} + type UseVoiceIntegrationArgs = { - setInputValueRaw: React.Dispatch>; - inputValueRef: React.RefObject; - insertTextRef: React.RefObject; -}; -type InterimRange = { - start: number; - end: number; -}; + setInputValueRaw: React.Dispatch> + inputValueRef: React.RefObject + insertTextRef: React.RefObject +} + +type InterimRange = { start: number; end: number } + type StripOpts = { // Which char to strip (the configured hold key). Defaults to space. - char?: string; + char?: string // Capture the voice prefix/suffix anchor at the stripped position. - anchor?: boolean; + anchor?: boolean // Minimum trailing count to leave behind — prevents stripping the // intentional warmup chars when defensively cleaning up leaks. - floor?: number; -}; + floor?: number +} + type UseVoiceIntegrationResult = { // Returns the number of trailing chars remaining after stripping. - stripTrailing: (maxStrip: number, opts?: StripOpts) => number; + stripTrailing: (maxStrip: number, opts?: StripOpts) => number // Undo the gap space and reset anchor refs after a failed voice activation. - resetAnchor: () => void; - handleKeyEvent: (fallbackMs?: number) => void; - interimRange: InterimRange | null; -}; + resetAnchor: () => void + handleKeyEvent: (fallbackMs?: number) => void + interimRange: InterimRange | null +} + export function useVoiceIntegration({ setInputValueRaw, inputValueRef, - insertTextRef + insertTextRef, }: UseVoiceIntegrationArgs): UseVoiceIntegrationResult { - const { - addNotification - } = useNotifications(); + const { addNotification } = useNotifications() // Tracks the input content before/after the cursor when voice starts, // so interim transcripts can be inserted at the cursor position without // clobbering surrounding user text. - const voicePrefixRef = useRef(null); - const voiceSuffixRef = useRef(''); + const voicePrefixRef = useRef(null) + const voiceSuffixRef = useRef('') // Tracks the last input value this hook wrote (via anchor, interim effect, // or handleVoiceTranscript). If inputValueRef.current diverges, the user // submitted or edited — both write paths bail to avoid clobbering. This is // the only guard that correctly handles empty-prefix-empty-suffix: a // startsWith('')/endsWith('') check vacuously passes, and a length check // can't distinguish a cleared input from a never-set one. - const lastSetInputRef = useRef(null); + const lastSetInputRef = useRef(null) // Strip trailing hold-key chars (and optionally capture the voice // anchor). Called during warmup (to clean up chars that leaked past @@ -149,53 +160,59 @@ export function useVoiceIntegration({ // defensive cleanup only removes leaks). Returns the number of // trailing chars remaining after stripping. When nothing changes, no // state update is performed. - const stripTrailing = useCallback((maxStrip: number, { - char = ' ', - anchor = false, - floor = 0 - }: StripOpts = {}) => { - const prev = inputValueRef.current; - const offset = insertTextRef.current?.cursorOffset ?? prev.length; - const beforeCursor = prev.slice(0, offset); - const afterCursor = prev.slice(offset); - // When the hold key is space, also count full-width spaces (U+3000) - // that a CJK IME may have inserted for the same physical key. - // U+3000 is BMP single-code-unit so indices align with beforeCursor. - const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor; - let trailing = 0; - while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) { - trailing++; - } - const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)); - const remaining = trailing - stripCount; - const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount); - // When anchoring with a non-space suffix, insert a gap space so the - // waveform cursor sits on the gap instead of covering the first - // suffix letter. The interim transcript effect maintains this same - // structure (prefix + leading + interim + trailing + suffix), so - // the gap is seamless once transcript text arrives. - // Always overwrite on anchor — if a prior activation failed to start - // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and - // the old anchor is stale. anchor=true is only passed on the single - // activation call, never during recording, so overwrite is safe. - let gap = ''; - if (anchor) { - voicePrefixRef.current = stripped; - voiceSuffixRef.current = afterCursor; - if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { - gap = ' '; + const stripTrailing = useCallback( + ( + maxStrip: number, + { char = ' ', anchor = false, floor = 0 }: StripOpts = {}, + ) => { + const prev = inputValueRef.current + const offset = insertTextRef.current?.cursorOffset ?? prev.length + const beforeCursor = prev.slice(0, offset) + const afterCursor = prev.slice(offset) + // When the hold key is space, also count full-width spaces (U+3000) + // that a CJK IME may have inserted for the same physical key. + // U+3000 is BMP single-code-unit so indices align with beforeCursor. + const scan = + char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor + let trailing = 0 + while ( + trailing < scan.length && + scan[scan.length - 1 - trailing] === char + ) { + trailing++ } - } - const newValue = stripped + gap + afterCursor; - if (anchor) lastSetInputRef.current = newValue; - if (newValue === prev && stripCount === 0) return remaining; - if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue, stripped.length); - } else { - setInputValueRaw(newValue); - } - return remaining; - }, [setInputValueRaw, inputValueRef, insertTextRef]); + const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)) + const remaining = trailing - stripCount + const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount) + // When anchoring with a non-space suffix, insert a gap space so the + // waveform cursor sits on the gap instead of covering the first + // suffix letter. The interim transcript effect maintains this same + // structure (prefix + leading + interim + trailing + suffix), so + // the gap is seamless once transcript text arrives. + // Always overwrite on anchor — if a prior activation failed to start + // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and + // the old anchor is stale. anchor=true is only passed on the single + // activation call, never during recording, so overwrite is safe. + let gap = '' + if (anchor) { + voicePrefixRef.current = stripped + voiceSuffixRef.current = afterCursor + if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { + gap = ' ' + } + } + const newValue = stripped + gap + afterCursor + if (anchor) lastSetInputRef.current = newValue + if (newValue === prev && stripCount === 0) return remaining + if (insertTextRef.current) { + insertTextRef.current.setInputWithCursor(newValue, stripped.length) + } else { + setInputValueRaw(newValue) + } + return remaining + }, + [setInputValueRaw, inputValueRef, insertTextRef], + ) // Undo the gap space inserted by stripTrailing(..., {anchor:true}) and // reset the voice prefix/suffix refs. Called when voice activation fails @@ -204,110 +221,124 @@ export function useVoiceIntegration({ // reach the stale anchor. Without this, the gap space and stale refs // persist in the input. const resetAnchor = useCallback(() => { - const prefix = voicePrefixRef.current; - if (prefix === null) return; - const suffix = voiceSuffixRef.current; - voicePrefixRef.current = null; - voiceSuffixRef.current = ''; - const restored = prefix + suffix; + const prefix = voicePrefixRef.current + if (prefix === null) return + const suffix = voiceSuffixRef.current + voicePrefixRef.current = null + voiceSuffixRef.current = '' + const restored = prefix + suffix if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(restored, prefix.length); + insertTextRef.current.setInputWithCursor(restored, prefix.length) } else { - setInputValueRaw(restored); + setInputValueRaw(restored) } - }, [setInputValueRaw, insertTextRef]); + }, [setInputValueRaw, insertTextRef]) // Voice state selectors. useVoiceEnabled = user intent (settings) + // auth + GB kill-switch, with the auth half memoized on authVersion so // render loops never hit a cold keychain spawn. // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle' as const; - const voiceInterimTranscript: string = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_0 => s_0.voiceInterimTranscript) as string : ''; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) + const voiceInterimTranscript = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceInterimTranscript) + : '' // Set the voice anchor for focus mode (where recording starts via terminal // focus, not key hold). Key-hold sets the anchor in stripTrailing. useEffect(() => { - if (!feature('VOICE_MODE')) return; + if (!feature('VOICE_MODE')) return if (voiceState === 'recording' && voicePrefixRef.current === null) { - const input = inputValueRef.current; - const offset_0 = insertTextRef.current?.cursorOffset ?? input.length; - voicePrefixRef.current = input.slice(0, offset_0); - voiceSuffixRef.current = input.slice(offset_0); - lastSetInputRef.current = input; + const input = inputValueRef.current + const offset = insertTextRef.current?.cursorOffset ?? input.length + voicePrefixRef.current = input.slice(0, offset) + voiceSuffixRef.current = input.slice(offset) + lastSetInputRef.current = input } if (voiceState === 'idle') { - voicePrefixRef.current = null; - voiceSuffixRef.current = ''; - lastSetInputRef.current = null; + voicePrefixRef.current = null + voiceSuffixRef.current = '' + lastSetInputRef.current = null } - }, [voiceState, inputValueRef, insertTextRef]); + }, [voiceState, inputValueRef, insertTextRef]) // Live-update the prompt input with the interim transcript as voice // transcribes speech. The prefix (user-typed text before the cursor) is // preserved and the transcript is inserted between prefix and suffix. useEffect(() => { - if (!feature('VOICE_MODE')) return; - if (voicePrefixRef.current === null) return; - const prefix_0 = voicePrefixRef.current; - const suffix_0 = voiceSuffixRef.current; + if (!feature('VOICE_MODE')) return + if (voicePrefixRef.current === null) return + const prefix = voicePrefixRef.current + const suffix = voiceSuffixRef.current // Submit race: if the input isn't what this hook last set it to, the // user submitted (clearing it) or edited it. voicePrefixRef is only // cleared on voiceState→idle, so it's still set during the 'processing' // window between CloseStream and WS close — this catches refined // TranscriptText arriving then and re-filling a cleared input. - if (inputValueRef.current !== lastSetInputRef.current) return; - const needsSpace = prefix_0.length > 0 && !/\s$/.test(prefix_0) && voiceInterimTranscript.length > 0; + if (inputValueRef.current !== lastSetInputRef.current) return + const needsSpace = + prefix.length > 0 && + !/\s$/.test(prefix) && + voiceInterimTranscript.length > 0 // Don't gate on voiceInterimTranscript.length -- when interim clears to '' // after handleVoiceTranscript sets the final text, the trailing space // between prefix and suffix must still be preserved. - const needsTrailingSpace = suffix_0.length > 0 && !/^\s/.test(suffix_0); - const leadingSpace = needsSpace ? ' ' : ''; - const trailingSpace = needsTrailingSpace ? ' ' : ''; - const newValue_0 = prefix_0 + leadingSpace + voiceInterimTranscript + trailingSpace + suffix_0; + const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) + const leadingSpace = needsSpace ? ' ' : '' + const trailingSpace = needsTrailingSpace ? ' ' : '' + const newValue = + prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix // Position cursor after the transcribed text (before suffix) - const cursorPos = prefix_0.length + leadingSpace.length + voiceInterimTranscript.length; + const cursorPos = + prefix.length + leadingSpace.length + voiceInterimTranscript.length if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue_0, cursorPos); + insertTextRef.current.setInputWithCursor(newValue, cursorPos) } else { - setInputValueRaw(newValue_0); + setInputValueRaw(newValue) } - lastSetInputRef.current = newValue_0; - }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]); - const handleVoiceTranscript = useCallback((text: string) => { - if (!feature('VOICE_MODE')) return; - const prefix_1 = voicePrefixRef.current; - // No voice anchor — voice was reset (or never started). Nothing to do. - if (prefix_1 === null) return; - const suffix_1 = voiceSuffixRef.current; - // Submit race: finishRecording() → user presses Enter (input cleared) - // → WebSocket close → this callback fires with stale prefix/suffix. - // If the input isn't what this hook last set (via the interim effect - // or anchor), the user submitted or edited — don't re-fill. Comparing - // against `text.length` would false-positive when the final is longer - // than the interim (ASR routinely adds punctuation/corrections). - if (inputValueRef.current !== lastSetInputRef.current) return; - const needsSpace_0 = prefix_1.length > 0 && !/\s$/.test(prefix_1) && text.length > 0; - const needsTrailingSpace_0 = suffix_1.length > 0 && !/^\s/.test(suffix_1) && text.length > 0; - const leadingSpace_0 = needsSpace_0 ? ' ' : ''; - const trailingSpace_0 = needsTrailingSpace_0 ? ' ' : ''; - const newInput = prefix_1 + leadingSpace_0 + text + trailingSpace_0 + suffix_1; - // Position cursor after the transcribed text (before suffix) - const cursorPos_0 = prefix_1.length + leadingSpace_0.length + text.length; - if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newInput, cursorPos_0); - } else { - setInputValueRaw(newInput); - } - lastSetInputRef.current = newInput; - // Update the prefix to include this chunk so focus mode can continue - // appending subsequent transcripts after it. - voicePrefixRef.current = prefix_1 + leadingSpace_0 + text; - }, [setInputValueRaw, inputValueRef, insertTextRef]); + lastSetInputRef.current = newValue + }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]) + + const handleVoiceTranscript = useCallback( + (text: string) => { + if (!feature('VOICE_MODE')) return + const prefix = voicePrefixRef.current + // No voice anchor — voice was reset (or never started). Nothing to do. + if (prefix === null) return + const suffix = voiceSuffixRef.current + // Submit race: finishRecording() → user presses Enter (input cleared) + // → WebSocket close → this callback fires with stale prefix/suffix. + // If the input isn't what this hook last set (via the interim effect + // or anchor), the user submitted or edited — don't re-fill. Comparing + // against `text.length` would false-positive when the final is longer + // than the interim (ASR routinely adds punctuation/corrections). + if (inputValueRef.current !== lastSetInputRef.current) return + const needsSpace = + prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0 + const needsTrailingSpace = + suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0 + const leadingSpace = needsSpace ? ' ' : '' + const trailingSpace = needsTrailingSpace ? ' ' : '' + const newInput = prefix + leadingSpace + text + trailingSpace + suffix + // Position cursor after the transcribed text (before suffix) + const cursorPos = prefix.length + leadingSpace.length + text.length + if (insertTextRef.current) { + insertTextRef.current.setInputWithCursor(newInput, cursorPos) + } else { + setInputValueRaw(newInput) + } + lastSetInputRef.current = newInput + // Update the prefix to include this chunk so focus mode can continue + // appending subsequent transcripts after it. + voicePrefixRef.current = prefix + leadingSpace + text + }, + [setInputValueRaw, inputValueRef, insertTextRef], + ) + const voice = voiceNs.useVoice({ onTranscript: handleVoiceTranscript, onError: (message: string) => { @@ -316,34 +347,35 @@ export function useVoiceIntegration({ text: message, color: 'error', priority: 'immediate', - timeoutMs: 10_000 - }); + timeoutMs: 10_000, + }) }, enabled: voiceEnabled, - focusMode: false - }); + focusMode: false, + }) // Compute the character range of interim (not-yet-finalized) transcript // text in the input value, so the UI can dim it. const interimRange = useMemo((): InterimRange | null => { - if (!feature('VOICE_MODE')) return null; - if (voicePrefixRef.current === null) return null; - if (voiceInterimTranscript.length === 0) return null; - const prefix_2 = voicePrefixRef.current; - const needsSpace_1 = prefix_2.length > 0 && !/\s$/.test(prefix_2) && voiceInterimTranscript.length > 0; - const start = prefix_2.length + (needsSpace_1 ? 1 : 0); - const end = start + voiceInterimTranscript.length; - return { - start, - end - }; - }, [voiceInterimTranscript]); + if (!feature('VOICE_MODE')) return null + if (voicePrefixRef.current === null) return null + if (voiceInterimTranscript.length === 0) return null + const prefix = voicePrefixRef.current + const needsSpace = + prefix.length > 0 && + !/\s$/.test(prefix) && + voiceInterimTranscript.length > 0 + const start = prefix.length + (needsSpace ? 1 : 0) + const end = start + voiceInterimTranscript.length + return { start, end } + }, [voiceInterimTranscript]) + return { stripTrailing, resetAnchor, handleKeyEvent: voice.handleKeyEvent, - interimRange - }; + interimRange, + } } /** @@ -374,24 +406,23 @@ export function useVoiceKeybindingHandler({ voiceHandleKeyEvent, stripTrailing, resetAnchor, - isActive + isActive, }: { - voiceHandleKeyEvent: (fallbackMs?: number) => void; - stripTrailing: (maxStrip: number, opts?: StripOpts) => number; - resetAnchor: () => void; - isActive: boolean; -}): { - handleKeyDown: (e: KeyboardEvent) => void; -} { - const getVoiceState = useGetVoiceState(); - const setVoiceState = useSetVoiceState(); - const keybindingContext = useOptionalKeybindingContext(); - const isModalOverlayActive = useIsModalOverlayActive(); - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? + voiceHandleKeyEvent: (fallbackMs?: number) => void + stripTrailing: (maxStrip: number, opts?: StripOpts) => number + resetAnchor: () => void + isActive: boolean +}): { handleKeyDown: (e: KeyboardEvent) => void } { + const getVoiceState = useGetVoiceState() + const setVoiceState = useSetVoiceState() + const keybindingContext = useOptionalKeybindingContext() + const isModalOverlayActive = useIsModalOverlayActive() // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle'; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : 'idle' // Find the configured key for voice:pushToTalk from keybinding context. // Forward iteration with last-wins (matching the resolver): if a later @@ -403,22 +434,22 @@ export function useVoiceKeybindingHandler({ // is also bound in Settings/Confirmation/Plugin (select:accept etc.); // without the filter those would null out the default. const voiceKeystroke = useMemo((): ParsedKeystroke | null => { - if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE; - let result: ParsedKeystroke | null = null; + if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE + let result: ParsedKeystroke | null = null for (const binding of keybindingContext.bindings) { - if (binding.context !== 'Chat') continue; - if (binding.chord.length !== 1) continue; - const ks = binding.chord[0]; - if (!ks) continue; + if (binding.context !== 'Chat') continue + if (binding.chord.length !== 1) continue + const ks = binding.chord[0] + if (!ks) continue if (binding.action === 'voice:pushToTalk') { - result = ks; + result = ks } else if (result !== null && keystrokesEqual(ks, result)) { // A later binding overrides this chord (null unbind or reassignment) - result = null; + result = null } } - return result; - }, [keybindingContext]); + return result + }, [keybindingContext]) // If the binding is a bare (unmodified) single printable char, terminal // auto-repeat may batch N keystrokes into one input event (e.g. "vvv"), @@ -426,8 +457,18 @@ export function useVoiceKeybindingHandler({ // Modifier combos (meta+k, ctrl+x) also auto-repeat (the letter part // repeats) but don't insert text, so they're swallowed from the first // press with no stripping needed. matchesKeyboardEvent handles those. - const bareChar = voiceKeystroke !== null && voiceKeystroke.key.length === 1 && !voiceKeystroke.ctrl && !voiceKeystroke.alt && !voiceKeystroke.shift && !voiceKeystroke.meta && !voiceKeystroke.super ? voiceKeystroke.key : null; - const rapidCountRef = useRef(0); + const bareChar = + voiceKeystroke !== null && + voiceKeystroke.key.length === 1 && + !voiceKeystroke.ctrl && + !voiceKeystroke.alt && + !voiceKeystroke.shift && + !voiceKeystroke.meta && + !voiceKeystroke.super + ? voiceKeystroke.key + : null + + const rapidCountRef = useRef(0) // How many rapid chars we intentionally let through to the text // input (the first WARMUP_THRESHOLD). The activation strip removes // up to this many + the activation event's potential leak. For the @@ -436,15 +477,15 @@ export function useVoiceKeybindingHandler({ // one pre-existing char if the input already ended in the bound // letter (e.g. "hav" + hold "v" → "ha"). We don't track that // boundary — it's best-effort and the warning says so. - const charsInInputRef = useRef(0); + const charsInInputRef = useRef(0) // Trailing-char count remaining after the activation strip — these // belong to the user's anchored prefix and must be preserved during // recording's defensive leak cleanup. - const recordingFloorRef = useRef(0); + const recordingFloorRef = useRef(0) // True when the current recording was started by key-hold (not focus). // Used to avoid swallowing keypresses during focus-mode recording. - const isHoldActiveRef = useRef(false); - const resetTimerRef = useRef | null>(null); + const isHoldActiveRef = useRef(false) + const resetTimerRef = useRef | null>(null) // Reset hold state as soon as we leave 'recording'. The physical hold // ends when key-repeat stops (state → 'processing'); keeping the ref @@ -452,21 +493,19 @@ export function useVoiceKeybindingHandler({ // while the transcript finalizes. useEffect(() => { if (voiceState !== 'recording') { - isHoldActiveRef.current = false; - rapidCountRef.current = 0; - charsInInputRef.current = 0; - recordingFloorRef.current = 0; + isHoldActiveRef.current = false + rapidCountRef.current = 0 + charsInInputRef.current = 0 + recordingFloorRef.current = 0 setVoiceState(prev => { - if (!prev.voiceWarmingUp) return prev; - return { - ...prev, - voiceWarmingUp: false - }; - }); + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) } - }, [voiceState, setVoiceState]); + }, [voiceState, setVoiceState]) + const handleKeyDown = (e: KeyboardEvent): void => { - if (!voiceEnabled) return; + if (!voiceEnabled) return // PromptInput is not a valid transcript target — let the hold key // flow through instead of swallowing it into stale refs (#33556). @@ -476,32 +515,37 @@ export function useVoiceKeybindingHandler({ // /plugin. Mirrors CommandKeybindingHandlers' isActive gate. // - isModalOverlayActive: overlay (permission dialog, Select with // onCancel) has focus; PromptInput is mounted but focus=false. - if (!isActive || isModalOverlayActive) return; + if (!isActive || isModalOverlayActive) return // null means the user overrode the default (null-unbind/reassign) — // hold-to-talk is disabled via binding. To toggle the feature // itself, use /voice. - if (voiceKeystroke === null) return; + if (voiceKeystroke === null) return // Match the configured key. Bare chars match by content (handles // batched auto-repeat like "vvv") with a modifier reject so e.g. // ctrl+v doesn't trip a "v" binding. Modifier combos go through // matchesKeyboardEvent (one event per repeat, no batching). - let repeatCount: number; + let repeatCount: number if (bareChar !== null) { - if (e.ctrl || e.meta || e.shift) return; + if (e.ctrl || e.meta || e.shift) return // When bound to space, also accept U+3000 (full-width space) — // CJK IMEs emit it for the same physical key. - const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key; + const normalized = + bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key // Fast-path: normal typing (any char that isn't the bound one) // bails here without allocating. The repeat() check only matters // for batched auto-repeat (input.length > 1) which is rare. - if (normalized[0] !== bareChar) return; - if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return; - repeatCount = normalized.length; + if (normalized[0] !== bareChar) return + if ( + normalized.length > 1 && + normalized !== bareChar.repeat(normalized.length) + ) + return + repeatCount = normalized.length } else { - if (!matchesKeyboardEvent(e, voiceKeystroke)) return; - repeatCount = 1; + if (!matchesKeyboardEvent(e, voiceKeystroke)) return + repeatCount = 1 } // Guard: only swallow keypresses when recording was triggered by @@ -511,22 +555,22 @@ export function useVoiceKeybindingHandler({ // from the store so that if voiceHandleKeyEvent() fails to transition // state (module not loaded, stream unavailable) we don't permanently // swallow keypresses. - const currentVoiceState = getVoiceState().voiceState; + const currentVoiceState = getVoiceState().voiceState if (isHoldActiveRef.current && currentVoiceState !== 'idle') { // Already recording — swallow continued keypresses and forward // to voice for release detection. For bare chars, defensively // strip in case the text input handler fired before this one // (listener order is not guaranteed). Modifier combos don't // insert text, so nothing to strip. - e.stopImmediatePropagation(); + e.stopImmediatePropagation() if (bareChar !== null) { stripTrailing(repeatCount, { char: bareChar, - floor: recordingFloorRef.current - }); + floor: recordingFloorRef.current, + }) } - voiceHandleKeyEvent(); - return; + voiceHandleKeyEvent() + return } // Non-hold recording (focus-mode) or processing is active. @@ -536,11 +580,12 @@ export function useVoiceKeybindingHandler({ // hit the warmup else-branch (swallow only). Bare chars flow through // unconditionally — user may be typing during focus-recording. if (currentVoiceState !== 'idle') { - if (bareChar === null) e.stopImmediatePropagation(); - return; + if (bareChar === null) e.stopImmediatePropagation() + return } - const countBefore = rapidCountRef.current; - rapidCountRef.current += repeatCount; + + const countBefore = rapidCountRef.current + rapidCountRef.current += repeatCount // ── Activation ──────────────────────────────────────────── // Handled first so the warmup branch below does NOT also run @@ -550,42 +595,37 @@ export function useVoiceKeybindingHandler({ // typed accidentally, so the hold threshold (which exists to // distinguish typing a space from holding space) doesn't apply. if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) { - e.stopImmediatePropagation(); + e.stopImmediatePropagation() if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current); - resetTimerRef.current = null; + clearTimeout(resetTimerRef.current) + resetTimerRef.current = null } - rapidCountRef.current = 0; - isHoldActiveRef.current = true; - setVoiceState(prev_0 => { - if (!prev_0.voiceWarmingUp) return prev_0; - return { - ...prev_0, - voiceWarmingUp: false - }; - }); + rapidCountRef.current = 0 + isHoldActiveRef.current = true + setVoiceState(prev => { + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) if (bareChar !== null) { // Strip the intentional warmup chars plus this event's leak // (if text input fired first). Cap covers both; min(trailing) // handles the no-leak case. Anchor the voice prefix here. // The return value (remaining) becomes the floor for // recording-time leak cleanup. - recordingFloorRef.current = stripTrailing(charsInInputRef.current + repeatCount, { - char: bareChar, - anchor: true - }); - charsInInputRef.current = 0; - voiceHandleKeyEvent(); + recordingFloorRef.current = stripTrailing( + charsInInputRef.current + repeatCount, + { char: bareChar, anchor: true }, + ) + charsInInputRef.current = 0 + voiceHandleKeyEvent() } else { // Modifier combo: nothing inserted, nothing to strip. Just // anchor the voice prefix at the current cursor position. // Longer fallback: this call is at t=0 (before auto-repeat), // so the gap to the next keypress is the OS initial repeat // *delay* (up to ~2s), not the repeat *rate* (~30-80ms). - stripTrailing(0, { - anchor: true - }); - voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS); + stripTrailing(0, { anchor: true }) + voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS) } // If voice failed to transition (module not loaded, stream // unavailable, stale enabled), clear the ref so a later @@ -594,10 +634,10 @@ export function useVoiceKeybindingHandler({ // immediate. The anchor set by stripTrailing above will // be overwritten on retry (anchor always overwrites now). if (getVoiceState().voiceState === 'idle') { - isHoldActiveRef.current = false; - resetAnchor(); + isHoldActiveRef.current = false + resetAnchor() } - return; + return } // ── Warmup (bare-char only; modifier combos activated above) ── @@ -610,67 +650,74 @@ export function useVoiceKeybindingHandler({ // no-op when nothing leaked. Check countBefore so the event that // crosses the threshold still flows through (terminal batching). if (countBefore >= WARMUP_THRESHOLD) { - e.stopImmediatePropagation(); + e.stopImmediatePropagation() stripTrailing(repeatCount, { char: bareChar, - floor: charsInInputRef.current - }); + floor: charsInInputRef.current, + }) } else { - charsInInputRef.current += repeatCount; + charsInInputRef.current += repeatCount } // Show warmup feedback once we detect a hold pattern if (rapidCountRef.current >= WARMUP_THRESHOLD) { - setVoiceState(prev_1 => { - if (prev_1.voiceWarmingUp) return prev_1; - return { - ...prev_1, - voiceWarmingUp: true - }; - }); + setVoiceState(prev => { + if (prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: true } + }) } + if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current); + clearTimeout(resetTimerRef.current) } - resetTimerRef.current = setTimeout((resetTimerRef_0, rapidCountRef_0, charsInInputRef_0, setVoiceState_0) => { - resetTimerRef_0.current = null; - rapidCountRef_0.current = 0; - charsInInputRef_0.current = 0; - setVoiceState_0(prev_2 => { - if (!prev_2.voiceWarmingUp) return prev_2; - return { - ...prev_2, - voiceWarmingUp: false - }; - }); - }, RAPID_KEY_GAP_MS, resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState); - }; + resetTimerRef.current = setTimeout( + (resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => { + resetTimerRef.current = null + rapidCountRef.current = 0 + charsInInputRef.current = 0 + setVoiceState(prev => { + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) + }, + RAPID_KEY_GAP_MS, + resetTimerRef, + rapidCountRef, + charsInInputRef, + setVoiceState, + ) + } // Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once REPL passes handleKeyDown. - useInput((_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress); - handleKeyDown(kbEvent); - // handleKeyDown stopped the adapter event, not the InputEvent the - // emitter actually checks — forward it so the text input's useInput - // listener is skipped and held spaces don't leak into the prompt. - if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation(); - } - }, { - isActive - }); - return { - handleKeyDown - }; + useInput( + (_input, _key, event) => { + const kbEvent = new KeyboardEvent(event.keypress) + handleKeyDown(kbEvent) + // handleKeyDown stopped the adapter event, not the InputEvent the + // emitter actually checks — forward it so the text input's useInput + // listener is skipped and held spaces don't leak into the prompt. + if (kbEvent.didStopImmediatePropagation()) { + event.stopImmediatePropagation() + } + }, + { isActive }, + ) + + return { handleKeyDown } } // TODO(onKeyDown-migration): temporary shim so existing JSX callers // () keep compiling. Remove once REPL.tsx // wires handleKeyDown directly. -export function VoiceKeybindingHandler(props) { - useVoiceKeybindingHandler(props); - return null; +export function VoiceKeybindingHandler(props: { + voiceHandleKeyEvent: (fallbackMs?: number) => void + stripTrailing: (maxStrip: number, opts?: StripOpts) => number + resetAnchor: () => void + isActive: boolean +}): null { + useVoiceKeybindingHandler(props) + return null } diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 709f31e66..7fbed68a4 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -1,105 +1,235 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js'; -import type { AssistantMessage, Message as MessageType, NormalizedUserMessage } from 'src/types/message.js'; -import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'; -import { z } from 'zod/v4'; -import { clearInvokedSkillsForAgent, getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; -import { enhanceSystemPromptWithEnvDetails, getSystemPrompt } from '../../constants/prompts.js'; -import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'; -import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { clearDumpState } from '../../services/api/dumpPrompts.js'; -import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { assembleToolPool } from '../../tools.js'; -import { asAgentId } from '../../types/ids.js'; -import { type SubagentContext, runWithAgentContext } from '../../utils/agentContext.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { AbortError, errorMessage, toError } from '../../utils/errors.js'; -import type { CacheSafeParams } from '../../utils/forkedAgent.js'; -import { lazySchema } from '../../utils/lazySchema.js'; -import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from '../../utils/messages.js'; -import { getAgentModel } from '../../utils/model/agent.js'; -import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js'; -import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'; -import { filterDeniedAgents, getDenyRuleForAgent } from '../../utils/permissions/permissions.js'; -import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js'; -import { writeAgentMetadata } from '../../utils/sessionStorage.js'; -import { sleep } from '../../utils/sleep.js'; -import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'; -import { asSystemPrompt } from '../../utils/systemPromptType.js'; -import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { getParentSessionId, isTeammate } from '../../utils/teammate.js'; -import { isInProcessTeammate } from '../../utils/teammateContext.js'; -import { teleportToRemote } from '../../utils/teleport.js'; -import { getAssistantMessageContentLength } from '../../utils/tokens.js'; -import { createAgentId } from '../../utils/uuid.js'; -import { createAgentWorktree, hasWorktreeChanges, removeAgentWorktree } from '../../utils/worktree.js'; -import { BASH_TOOL_NAME } from '../BashTool/toolName.js'; -import { BackgroundHint } from '../BashTool/UI.js'; -import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'; -import { spawnTeammate } from '../shared/spawnMultiAgent.js'; -import { setAgentColor } from './agentColorManager.js'; -import { agentToolResultSchema, classifyHandoffIfNeeded, emitTaskProgress, extractPartialResult, finalizeAgentTool, getLastToolUseName, runAsyncAgentLifecycle } from './agentToolUtils.js'; -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; -import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME, ONE_SHOT_BUILTIN_AGENT_TYPES } from './constants.js'; -import { buildForkedMessages, buildWorktreeNotice, FORK_AGENT, isForkSubagentEnabled, isInForkChild } from './forkSubagent.js'; -import type { AgentDefinition } from './loadAgentsDir.js'; -import { filterAgentsByMcpRequirements, hasRequiredMcpServers, isBuiltInAgent } from './loadAgentsDir.js'; -import { getPrompt } from './prompt.js'; -import { runAgent } from './runAgent.js'; -import { renderGroupedAgentToolUse, renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseTag, userFacingName, userFacingNameBackgroundColor } from './UI.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js' +import type { + Message as MessageType, + NormalizedUserMessage, +} from 'src/types/message.js' +import { getQuerySourceForAgent } from 'src/utils/promptCategory.js' +import { z } from 'zod/v4' +import { + clearInvokedSkillsForAgent, + getSdkAgentProgressSummariesEnabled, +} from '../../bootstrap/state.js' +import { + enhanceSystemPromptWithEnvDetails, + getSystemPrompt, +} from '../../constants/prompts.js' +import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js' +import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { clearDumpState } from '../../services/api/dumpPrompts.js' +import { + completeAgentTask as completeAsyncAgent, + createActivityDescriptionResolver, + createProgressTracker, + enqueueAgentNotification, + failAgentTask as failAsyncAgent, + getProgressUpdate, + getTokenCountFromTracker, + isLocalAgentTask, + killAsyncAgent, + registerAgentForeground, + registerAsyncAgent, + unregisterAgentForeground, + updateAgentProgress as updateAsyncAgentProgress, + updateProgressFromMessage, +} from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { + checkRemoteAgentEligibility, + formatPreconditionError, + getRemoteTaskSessionUrl, + registerRemoteAgentTask, +} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { assembleToolPool } from '../../tools.js' +import { asAgentId } from '../../types/ids.js' +import { runWithAgentContext } from '../../utils/agentContext.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { getCwd, runWithCwdOverride } from '../../utils/cwd.js' +import { logForDebugging } from '../../utils/debug.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { AbortError, errorMessage, toError } from '../../utils/errors.js' +import type { CacheSafeParams } from '../../utils/forkedAgent.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { + createUserMessage, + extractTextContent, + isSyntheticMessage, + normalizeMessages, +} from '../../utils/messages.js' +import { getAgentModel } from '../../utils/model/agent.js' +import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js' +import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' +import { + filterDeniedAgents, + getDenyRuleForAgent, +} from '../../utils/permissions/permissions.js' +import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js' +import { writeAgentMetadata } from '../../utils/sessionStorage.js' +import { sleep } from '../../utils/sleep.js' +import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js' +import { asSystemPrompt } from '../../utils/systemPromptType.js' +import { getTaskOutputPath } from '../../utils/task/diskOutput.js' +import { getParentSessionId, isTeammate } from '../../utils/teammate.js' +import { isInProcessTeammate } from '../../utils/teammateContext.js' +import { teleportToRemote } from '../../utils/teleport.js' +import { getAssistantMessageContentLength } from '../../utils/tokens.js' +import { createAgentId } from '../../utils/uuid.js' +import { + createAgentWorktree, + hasWorktreeChanges, + removeAgentWorktree, +} from '../../utils/worktree.js' +import { BASH_TOOL_NAME } from '../BashTool/toolName.js' +import { BackgroundHint } from '../BashTool/UI.js' +import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' +import { spawnTeammate } from '../shared/spawnMultiAgent.js' +import { setAgentColor } from './agentColorManager.js' +import { + agentToolResultSchema, + classifyHandoffIfNeeded, + emitTaskProgress, + extractPartialResult, + finalizeAgentTool, + getLastToolUseName, + runAsyncAgentLifecycle, +} from './agentToolUtils.js' +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' +import { + AGENT_TOOL_NAME, + LEGACY_AGENT_TOOL_NAME, + ONE_SHOT_BUILTIN_AGENT_TYPES, +} from './constants.js' +import { + buildForkedMessages, + buildWorktreeNotice, + FORK_AGENT, + isForkSubagentEnabled, + isInForkChild, +} from './forkSubagent.js' +import type { AgentDefinition } from './loadAgentsDir.js' +import { + filterAgentsByMcpRequirements, + hasRequiredMcpServers, + isBuiltInAgent, +} from './loadAgentsDir.js' +import { getPrompt } from './prompt.js' +import { runAgent } from './runAgent.js' +import { + renderGroupedAgentToolUse, + renderToolResultMessage, + renderToolUseErrorMessage, + renderToolUseMessage, + renderToolUseProgressMessage, + renderToolUseRejectedMessage, + renderToolUseTag, + userFacingName, + userFacingNameBackgroundColor, +} from './UI.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') as typeof import('../../proactive/index.js') : null; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? (require('../../proactive/index.js') as typeof import('../../proactive/index.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Progress display constants (for showing background hint) -const PROGRESS_THRESHOLD_MS = 2000; // Show background hint after 2 seconds +const PROGRESS_THRESHOLD_MS = 2000 // Show background hint after 2 seconds // Check if background tasks are disabled at module load time const isBackgroundTasksDisabled = -// eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load -isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); + // eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load + isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) // Auto-background agent tasks after this many ms (0 = disabled) // Enabled by env var OR GrowthBook gate (checked lazily since GB may not be ready at module load) function getAutoBackgroundMs(): number { - if (isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)) { - return 120_000; + if ( + isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || + getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false) + ) { + return 120_000 } - return 0; + return 0 } // Multi-agent type constants are defined inline inside gated blocks to enable dead code elimination // Base input schema without multi-agent parameters -const baseInputSchema = lazySchema(() => z.object({ - description: z.string().describe('A short (3-5 word) description of the task'), - prompt: z.string().describe('The task for the agent to perform'), - subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'), - model: z.enum(['sonnet', 'opus', 'haiku']).optional().describe("Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent."), - run_in_background: z.boolean().optional().describe('Set to true to run this agent in the background. You will be notified when it completes.') -})); +const baseInputSchema = lazySchema(() => + z.object({ + description: z + .string() + .describe('A short (3-5 word) description of the task'), + prompt: z.string().describe('The task for the agent to perform'), + subagent_type: z + .string() + .optional() + .describe('The type of specialized agent to use for this task'), + model: z + .enum(['sonnet', 'opus', 'haiku']) + .optional() + .describe( + "Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent.", + ), + run_in_background: z + .boolean() + .optional() + .describe( + 'Set to true to run this agent in the background. You will be notified when it completes.', + ), + }), +) // Full schema combining base + multi-agent params + isolation const fullInputSchema = lazySchema(() => { // Multi-agent parameters const multiAgentInputSchema = z.object({ - name: z.string().optional().describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), - team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), - mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).') - }); - return baseInputSchema().merge(multiAgentInputSchema).extend({ - isolation: ((process.env.USER_TYPE) === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional().describe((process.env.USER_TYPE) === 'ant' ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'), - cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".') - }); -}); + name: z + .string() + .optional() + .describe( + 'Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.', + ), + team_name: z + .string() + .optional() + .describe( + 'Team name for spawning. Uses current team context if omitted.', + ), + mode: permissionModeSchema() + .optional() + .describe( + 'Permission mode for spawned teammate (e.g., "plan" to require plan approval).', + ), + }) + + return baseInputSchema() + .merge(multiAgentInputSchema) + .extend({ + isolation: (process.env.USER_TYPE === 'ant' + ? z.enum(['worktree', 'remote']) + : z.enum(['worktree']) + ) + .optional() + .describe( + process.env.USER_TYPE === 'ant' + ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' + : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.', + ), + cwd: z + .string() + .optional() + .describe( + 'Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".', + ), + }) +}) // Strip optional fields from the schema when the backing feature is off so // the model never sees them. Done via .omit() rather than conditional spread @@ -108,9 +238,9 @@ const fullInputSchema = lazySchema(() => { // type, but call() destructures via the explicit AgentToolInput type below // which always includes all optional fields. export const inputSchema = lazySchema(() => { - const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ - cwd: true - }); + const schema = feature('KAIROS') + ? fullInputSchema() + : fullInputSchema().omit({ cwd: true }) // GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which // was removed in 906da6c723): the divergence window is one-session-per- @@ -119,61 +249,70 @@ export const inputSchema = lazySchema(() => { // by forceAsync) or "schema hides a param that would've worked" (gate // flips off mid-session: everything still runs async via memoized // forceAsync). No Zod rejection, no crash — unlike required→optional. - return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ - run_in_background: true - }) : schema; -}); -type InputSchema = ReturnType; + return isBackgroundTasksDisabled || isForkSubagentEnabled() + ? schema.omit({ run_in_background: true }) + : schema +}) +type InputSchema = ReturnType // Explicit type widens the schema inference to always include all optional // fields even when .omit() strips them for gating (cwd, run_in_background). // subagent_type is optional; call() defaults it to general-purpose when the // fork gate is off, or routes to the fork path when the gate is on. type AgentToolInput = z.infer> & { - name?: string; - team_name?: string; - mode?: z.infer>; - isolation?: 'worktree' | 'remote'; - cwd?: string; -}; + name?: string + team_name?: string + mode?: z.infer> + isolation?: 'worktree' | 'remote' + cwd?: string +} // Output schema - multi-agent spawned schema added dynamically at runtime when enabled export const outputSchema = lazySchema(() => { const syncOutputSchema = agentToolResultSchema().extend({ status: z.literal('completed'), - prompt: z.string() - }); + prompt: z.string(), + }) + const asyncOutputSchema = z.object({ status: z.literal('async_launched'), agentId: z.string().describe('The ID of the async agent'), description: z.string().describe('The description of the task'), prompt: z.string().describe('The prompt for the agent'), - outputFile: z.string().describe('Path to the output file for checking agent progress'), - canReadOutputFile: z.boolean().optional().describe('Whether the calling agent has Read/Bash tools to check progress') - }); - return z.union([syncOutputSchema, asyncOutputSchema]); -}); -type OutputSchema = ReturnType; -type Output = z.input; + outputFile: z + .string() + .describe('Path to the output file for checking agent progress'), + canReadOutputFile: z + .boolean() + .optional() + .describe( + 'Whether the calling agent has Read/Bash tools to check progress', + ), + }) + + return z.union([syncOutputSchema, asyncOutputSchema]) +}) +type OutputSchema = ReturnType +type Output = z.input // Private type for teammate spawn results - excluded from exported schema for dead code elimination // The 'teammate_spawned' status string is only included when ENABLE_AGENT_SWARMS is true type TeammateSpawnedOutput = { - status: 'teammate_spawned'; - prompt: string; - teammate_id: string; - agent_id: string; - agent_type?: string; - model?: string; - name: string; - color?: string; - tmux_session_name: string; - tmux_window_name: string; - tmux_pane_id: string; - team_name?: string; - is_splitpane?: boolean; - plan_mode_required?: boolean; -}; + status: 'teammate_spawned' + prompt: string + teammate_id: string + agent_id: string + agent_type?: string + model?: string + name: string + color?: string + tmux_session_name: string + tmux_window_name: string + tmux_pane_id: string + team_name?: string + is_splitpane?: boolean + plan_mode_required?: boolean +} // Combined output type including both public and internal types // Note: TeammateSpawnedOutput type is fine - TypeScript types are erased at compile time @@ -181,123 +320,146 @@ type TeammateSpawnedOutput = { // like TeammateSpawnedOutput for dead code elimination purposes. Exported // for UI.tsx to do proper discriminated-union narrowing instead of ad-hoc casts. export type RemoteLaunchedOutput = { - status: 'remote_launched'; - taskId: string; - sessionUrl: string; - description: string; - prompt: string; - outputFile: string; -}; -type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput; -import type { AgentToolProgress, ShellProgress } from '../../types/tools.js'; + status: 'remote_launched' + taskId: string + sessionUrl: string + description: string + prompt: string + outputFile: string +} + +type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput + +import type { AgentToolProgress, ShellProgress } from '../../types/tools.js' // AgentTool forwards both its own progress events and shell progress // events from the sub-agent so the SDK receives tool_progress updates during bash/powershell runs. -export type Progress = AgentToolProgress | ShellProgress; +export type Progress = AgentToolProgress | ShellProgress + export const AgentTool = buildTool({ - async prompt({ - agents, - tools, - getToolPermissionContext, - allowedAgentTypes - }) { - const toolPermissionContext = await getToolPermissionContext(); + async prompt({ agents, tools, getToolPermissionContext, allowedAgentTypes }) { + const toolPermissionContext = await getToolPermissionContext() // Get MCP servers that have tools available - const mcpServersWithTools: string[] = []; + const mcpServersWithTools: string[] = [] for (const tool of tools) { if (tool.name?.startsWith('mcp__')) { - const parts = tool.name.split('__'); - const serverName = parts[1]; + const parts = tool.name.split('__') + const serverName = parts[1] if (serverName && !mcpServersWithTools.includes(serverName)) { - mcpServersWithTools.push(serverName); + mcpServersWithTools.push(serverName) } } } // Filter agents: first by MCP requirements, then by permission rules - const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements(agents, mcpServersWithTools); - const filteredAgents = filterDeniedAgents(agentsWithMcpRequirementsMet, toolPermissionContext, AGENT_TOOL_NAME); + const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements( + agents, + mcpServersWithTools, + ) + const filteredAgents = filterDeniedAgents( + agentsWithMcpRequirementsMet, + toolPermissionContext, + AGENT_TOOL_NAME, + ) // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; - return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes); + const isCoordinator = feature('COORDINATOR_MODE') + ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + : false + return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes) }, name: AGENT_TOOL_NAME, searchHint: 'delegate work to a subagent', aliases: [LEGACY_AGENT_TOOL_NAME], maxResultSizeChars: 100_000, async description() { - return 'Launch a new agent'; + return 'Launch a new agent' }, get inputSchema(): InputSchema { - return inputSchema(); + return inputSchema() }, get outputSchema(): OutputSchema { - return outputSchema(); + return outputSchema() }, - async call({ - prompt, - subagent_type, - description, - model: modelParam, - run_in_background, - name, - team_name, - mode: spawnMode, - isolation, - cwd - }: AgentToolInput, toolUseContext, canUseTool, assistantMessage, onProgress?) { - const startTime = Date.now(); - const model = isCoordinatorMode() ? undefined : modelParam; + async call( + { + prompt, + subagent_type, + description, + model: modelParam, + run_in_background, + name, + team_name, + mode: spawnMode, + isolation, + cwd, + }: AgentToolInput, + toolUseContext, + canUseTool, + assistantMessage, + onProgress?, + ) { + const startTime = Date.now() + const model = isCoordinatorMode() ? undefined : modelParam // Get app state for permission mode and agent filtering - const appState = toolUseContext.getAppState(); - const permissionMode = appState.toolPermissionContext.mode; + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode // In-process teammates get a no-op setAppState; setAppStateForTasks // reaches the root store so task registration/progress/kill stay visible. - const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState; + const rootSetAppState = + toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState // Check if user is trying to use agent teams without access if (team_name && !isAgentSwarmsEnabled()) { - throw new Error('Agent Teams is not yet available on your plan.'); + throw new Error('Agent Teams is not yet available on your plan.') } // Teammates (in-process or tmux) passing `name` would trigger spawnTeammate() // below, but TeamFile.members is a flat array with one leadAgentId — nested // teammates land in the roster with no provenance and confuse the lead. - const teamName = resolveTeamName({ - team_name - }, appState); + const teamName = resolveTeamName({ team_name }, appState) if (isTeammate() && teamName && name) { - throw new Error('Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.'); + throw new Error( + 'Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.', + ) } // In-process teammates cannot spawn background agents (their lifecycle is // tied to the leader's process). Tmux teammates are separate processes and // can manage their own background agents. if (isInProcessTeammate() && teamName && run_in_background === true) { - throw new Error('In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.'); + throw new Error( + 'In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.', + ) } // Check if this is a multi-agent spawn request // Spawn is triggered when team_name is set (from param or context) and name is provided if (teamName && name) { // Set agent definition color for grouped UI display before spawning - const agentDef = subagent_type ? toolUseContext.options.agentDefinitions.activeAgents.find(a => a.agentType === subagent_type) : undefined; + const agentDef = subagent_type + ? toolUseContext.options.agentDefinitions.activeAgents.find( + a => a.agentType === subagent_type, + ) + : undefined if (agentDef?.color) { - setAgentColor(subagent_type!, agentDef.color); + setAgentColor(subagent_type!, agentDef.color) } - const result = await spawnTeammate({ - name, - prompt, - description, - team_name: teamName, - use_splitpane: true, - plan_mode_required: spawnMode === 'plan', - model: model ?? agentDef?.model, - agent_type: subagent_type, - invokingRequestId: assistantMessage?.requestId as string | undefined - }, toolUseContext); + const result = await spawnTeammate( + { + name, + prompt, + description, + team_name: teamName, + use_splitpane: true, + plan_mode_required: spawnMode === 'plan', + model: model ?? agentDef?.model, + agent_type: subagent_type, + invokingRequestId: assistantMessage?.requestId, + }, + toolUseContext, + ) // Type assertion uses TeammateSpawnedOutput (defined above) instead of any. // This type is excluded from the exported outputSchema for dead code elimination. @@ -306,22 +468,21 @@ export const AgentTool = buildTool({ const spawnResult: TeammateSpawnedOutput = { status: 'teammate_spawned' as const, prompt, - ...result.data - }; - return { - data: spawnResult - } as unknown as { - data: Output; - }; + ...result.data, + } + return { data: spawnResult } as unknown as { data: Output } } // Fork subagent experiment routing: // - subagent_type set: use it (explicit wins) // - subagent_type omitted, gate on: fork path (undefined) // - subagent_type omitted, gate off: default general-purpose - const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType); - const isForkPath = effectiveType === undefined; - let selectedAgent: AgentDefinition; + const effectiveType = + subagent_type ?? + (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType) + const isForkPath = effectiveType === undefined + + let selectedAgent: AgentDefinition if (isForkPath) { // Recursive fork guard: fork children keep the Agent tool in their // pool for cache-identical tool defs, so reject fork attempts at call @@ -329,42 +490,70 @@ export const AgentTool = buildTool({ // context.options at spawn time, survives autocompact's message // rewrite). Message-scan fallback catches any path where querySource // wasn't threaded. - if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages)) { - throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.'); + if ( + toolUseContext.options.querySource === + `agent:builtin:${FORK_AGENT.agentType}` || + isInForkChild(toolUseContext.messages) + ) { + throw new Error( + 'Fork is not available inside a forked worker. Complete your task directly using your tools.', + ) } - selectedAgent = FORK_AGENT; + selectedAgent = FORK_AGENT } else { // Filter agents to exclude those denied via Agent(AgentName) syntax - const allAgents = toolUseContext.options.agentDefinitions.activeAgents; - const { - allowedAgentTypes - } = toolUseContext.options.agentDefinitions; + const allAgents = toolUseContext.options.agentDefinitions.activeAgents + const { allowedAgentTypes } = toolUseContext.options.agentDefinitions const agents = filterDeniedAgents( - // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types - allowedAgentTypes ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) : allAgents, appState.toolPermissionContext, AGENT_TOOL_NAME); - const found = agents.find(agent => agent.agentType === effectiveType); + // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types + allowedAgentTypes + ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) + : allAgents, + appState.toolPermissionContext, + AGENT_TOOL_NAME, + ) + + const found = agents.find(agent => agent.agentType === effectiveType) if (!found) { // Check if the agent exists but is denied by permission rules - const agentExistsButDenied = allAgents.find(agent => agent.agentType === effectiveType); + const agentExistsButDenied = allAgents.find( + agent => agent.agentType === effectiveType, + ) if (agentExistsButDenied) { - const denyRule = getDenyRuleForAgent(appState.toolPermissionContext, AGENT_TOOL_NAME, effectiveType); - throw new Error(`Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`); + const denyRule = getDenyRuleForAgent( + appState.toolPermissionContext, + AGENT_TOOL_NAME, + effectiveType, + ) + throw new Error( + `Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`, + ) } - throw new Error(`Agent type '${effectiveType}' not found. Available agents: ${agents.map(a => a.agentType).join(', ')}`); + throw new Error( + `Agent type '${effectiveType}' not found. Available agents: ${agents + .map(a => a.agentType) + .join(', ')}`, + ) } - selectedAgent = found; + selectedAgent = found } // Same lifecycle constraint as the run_in_background guard above, but for // agent definitions that force background via `background: true`. Checked // here because selectedAgent is only now resolved. - if (isInProcessTeammate() && teamName && selectedAgent.background === true) { - throw new Error(`In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`); + if ( + isInProcessTeammate() && + teamName && + selectedAgent.background === true + ) { + throw new Error( + `In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`, + ) } // Capture for type narrowing — `let selectedAgent` prevents TS from // narrowing property types across the if-else assignment above. - const requiredMcpServers = selectedAgent.requiredMcpServers; + const requiredMcpServers = selectedAgent.requiredMcpServers // Check if required MCP servers have tools available // A server that's connected but not authenticated won't have any tools @@ -372,113 +561,153 @@ export const AgentTool = buildTool({ // If any required servers are still pending (connecting), wait for them // before checking tool availability. This avoids a race condition where // the agent is invoked before MCP servers finish connecting. - const hasPendingRequiredServers = appState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - let currentAppState = appState; + const hasPendingRequiredServers = appState.mcp.clients.some( + c => + c.type === 'pending' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + + let currentAppState = appState if (hasPendingRequiredServers) { - const MAX_WAIT_MS = 30_000; - const POLL_INTERVAL_MS = 500; - const deadline = Date.now() + MAX_WAIT_MS; + const MAX_WAIT_MS = 30_000 + const POLL_INTERVAL_MS = 500 + const deadline = Date.now() + MAX_WAIT_MS + while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS); - currentAppState = toolUseContext.getAppState(); + await sleep(POLL_INTERVAL_MS) + currentAppState = toolUseContext.getAppState() // Early exit: if any required server has already failed, no point // waiting for other pending servers — the check will fail regardless. - const hasFailedRequiredServer = currentAppState.mcp.clients.some(c => c.type === 'failed' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - if (hasFailedRequiredServer) break; - const stillPending = currentAppState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - if (!stillPending) break; + const hasFailedRequiredServer = currentAppState.mcp.clients.some( + c => + c.type === 'failed' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + if (hasFailedRequiredServer) break + + const stillPending = currentAppState.mcp.clients.some( + c => + c.type === 'pending' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + if (!stillPending) break } } // Get servers that actually have tools (meaning they're connected AND authenticated) - const serversWithTools: string[] = []; + const serversWithTools: string[] = [] for (const tool of currentAppState.mcp.tools) { if (tool.name?.startsWith('mcp__')) { // Extract server name from tool name (format: mcp__serverName__toolName) - const parts = tool.name.split('__'); - const serverName = parts[1]; + const parts = tool.name.split('__') + const serverName = parts[1] if (serverName && !serversWithTools.includes(serverName)) { - serversWithTools.push(serverName); + serversWithTools.push(serverName) } } } + if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) { - const missing = requiredMcpServers.filter(pattern => !serversWithTools.some(server => server.toLowerCase().includes(pattern.toLowerCase()))); - throw new Error(`Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + `Use /mcp to configure and authenticate the required MCP servers.`); + const missing = requiredMcpServers.filter( + pattern => + !serversWithTools.some(server => + server.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + throw new Error( + `Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + + `Use /mcp to configure and authenticate the required MCP servers.`, + ) } } // Initialize the color for this agent if it has a predefined one if (selectedAgent.color) { - setAgentColor(selectedAgent.agentType, selectedAgent.color); + setAgentColor(selectedAgent.agentType, selectedAgent.color) } // Resolve agent params for logging (these are already resolved in runAgent) - const resolvedAgentModel = getAgentModel(selectedAgent.model, toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode); + const resolvedAgentModel = getAgentModel( + selectedAgent.model, + toolUseContext.options.mainLoopModel, + isForkPath ? undefined : model, + permissionMode, + ) + logEvent('tengu_agent_tool_selected', { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - color: selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + color: + selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_built_in_agent: isBuiltInAgent(selectedAgent), is_resume: false, - is_async: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, - is_fork: isForkPath - }); + is_async: + (run_in_background === true || selectedAgent.background === true) && + !isBackgroundTasksDisabled, + is_fork: isForkPath, + }) // Resolve effective isolation mode (explicit param overrides agent def) - const effectiveIsolation = isolation ?? selectedAgent.isolation; + const effectiveIsolation = isolation ?? selectedAgent.isolation // Remote isolation: delegate to CCR. Gated ant-only — the guard enables // dead code elimination of the entire block for external builds. - if ((process.env.USER_TYPE) === 'ant' && effectiveIsolation === 'remote') { - const eligibility = await checkRemoteAgentEligibility(); + if (process.env.USER_TYPE === 'ant' && effectiveIsolation === 'remote') { + const eligibility = await checkRemoteAgentEligibility() if (!eligibility.eligible) { - const reasons = (eligibility as { eligible: false; errors: Parameters[0][] }).errors.map(formatPreconditionError).join('\n'); - throw new Error(`Cannot launch remote agent:\n${reasons}`); + const reasons = eligibility.errors + .map(formatPreconditionError) + .join('\n') + throw new Error(`Cannot launch remote agent:\n${reasons}`) } - let bundleFailHint: string | undefined; + + let bundleFailHint: string | undefined const session = await teleportToRemote({ initialMessage: prompt, description, signal: toolUseContext.abortController.signal, onBundleFail: msg => { - bundleFailHint = msg; - } - }); + bundleFailHint = msg + }, + }) if (!session) { - throw new Error(bundleFailHint ?? 'Failed to create remote session'); + throw new Error(bundleFailHint ?? 'Failed to create remote session') } - const { - taskId, - sessionId - } = registerRemoteAgentTask({ + + const { taskId, sessionId } = registerRemoteAgentTask({ remoteTaskType: 'remote-agent', - session: { - id: session.id, - title: session.title || description - }, + session: { id: session.id, title: session.title || description }, command: prompt, context: toolUseContext, - toolUseId: toolUseContext.toolUseId - }); + toolUseId: toolUseContext.toolUseId, + }) + logEvent('tengu_agent_tool_remote_launched', { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const remoteResult: RemoteLaunchedOutput = { status: 'remote_launched', taskId, sessionUrl: getRemoteTaskSessionUrl(sessionId), description, prompt, - outputFile: getTaskOutputPath(taskId) - }; - return { - data: remoteResult - } as unknown as { - data: Output; - }; + outputFile: getTaskOutputPath(taskId), + } + return { data: remoteResult } as unknown as { data: Output } } // System prompt + prompt messages: branch on fork path. // @@ -489,72 +718,98 @@ export const AgentTool = buildTool({ // // Normal path: build the selected agent's own system prompt with env // details, and use a simple user message for the prompt. - let enhancedSystemPrompt: string[] | undefined; - let forkParentSystemPrompt: ReturnType | undefined; - let promptMessages: MessageType[]; + let enhancedSystemPrompt: string[] | undefined + let forkParentSystemPrompt: + | ReturnType + | undefined + let promptMessages: MessageType[] + if (isForkPath) { if (toolUseContext.renderedSystemPrompt) { - forkParentSystemPrompt = toolUseContext.renderedSystemPrompt; + forkParentSystemPrompt = toolUseContext.renderedSystemPrompt } else { // Fallback: recompute. May diverge from parent's cached bytes if // GrowthBook state changed between parent turn-start and fork spawn. - const mainThreadAgentDefinition = appState.agent ? appState.agentDefinitions.activeAgents.find(a => a.agentType === appState.agent) : undefined; - const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); - const defaultSystemPrompt = await getSystemPrompt(toolUseContext.options.tools, toolUseContext.options.mainLoopModel, additionalWorkingDirectories, toolUseContext.options.mcpClients); + const mainThreadAgentDefinition = appState.agent + ? appState.agentDefinitions.activeAgents.find( + a => a.agentType === appState.agent, + ) + : undefined + const additionalWorkingDirectories = Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ) + const defaultSystemPrompt = await getSystemPrompt( + toolUseContext.options.tools, + toolUseContext.options.mainLoopModel, + additionalWorkingDirectories, + toolUseContext.options.mcpClients, + ) forkParentSystemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, toolUseContext, customSystemPrompt: toolUseContext.options.customSystemPrompt, defaultSystemPrompt, - appendSystemPrompt: toolUseContext.options.appendSystemPrompt - }); + appendSystemPrompt: toolUseContext.options.appendSystemPrompt, + }) } - promptMessages = buildForkedMessages(prompt, assistantMessage); + promptMessages = buildForkedMessages(prompt, assistantMessage) } else { try { - const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); + const additionalWorkingDirectories = Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ) // All agents have getSystemPrompt - pass toolUseContext to all - const agentPrompt = selectedAgent.getSystemPrompt({ - toolUseContext - }); + const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext }) // Log agent memory loaded event for subagents if (selectedAgent.memory) { logEvent('tengu_agent_memory_loaded', { - ...((process.env.USER_TYPE) === 'ant' && { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ...(process.env.USER_TYPE === 'ant' && { + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), - scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + scope: + selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } // Apply environment details enhancement - enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails([agentPrompt], resolvedAgentModel, additionalWorkingDirectories); + enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails( + [agentPrompt], + resolvedAgentModel, + additionalWorkingDirectories, + ) } catch (error) { - logForDebugging(`Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`); + logForDebugging( + `Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`, + ) } - promptMessages = [createUserMessage({ - content: prompt - })]; + promptMessages = [createUserMessage({ content: prompt })] } + const metadata = { prompt, resolvedAgentModel, isBuiltInAgent: isBuiltInAgent(selectedAgent), startTime, agentType: selectedAgent.agentType, - isAsync: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled - }; + isAsync: + (run_in_background === true || selectedAgent.background === true) && + !isBackgroundTasksDisabled, + } // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; + const isCoordinator = feature('COORDINATOR_MODE') + ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + : false // Fork subagent experiment: force ALL spawns async for a unified // interaction model (not just fork spawns — all of them). - const forceAsync = isForkSubagentEnabled(); + const forceAsync = isForkSubagentEnabled() // Assistant mode: force all agents async. Synchronous subagents hold the // main loop's turn open until they complete — the daemon's inputQueue @@ -563,8 +818,18 @@ export const AgentTool = buildTool({ // executeForkedSlashCommand's fire-and-forget path; the // re-entry there is handled by the else branch // below (registerAsyncAgentTask + notifyOnCompletion). - const assistantForceAsync = feature('KAIROS') ? appState.kairosEnabled : false; - const shouldRunAsync = (run_in_background === true || selectedAgent.background === true || isCoordinator || forceAsync || assistantForceAsync || (proactiveModule?.isProactiveActive() ?? false)) && !isBackgroundTasksDisabled; + const assistantForceAsync = feature('KAIROS') + ? appState.kairosEnabled + : false + + const shouldRunAsync = + (run_in_background === true || + selectedAgent.background === true || + isCoordinator || + forceAsync || + assistantForceAsync || + (proactiveModule?.isProactiveActive() ?? false)) && + !isBackgroundTasksDisabled // Assemble the worker's tool pool independently of the parent's. // Workers always get their tools from assembleToolPool with their own // permission mode, so they aren't affected by the parent's tool @@ -572,41 +837,53 @@ export const AgentTool = buildTool({ // import from tools.ts (which would create a circular dependency). const workerPermissionContext = { ...appState.toolPermissionContext, - mode: selectedAgent.permissionMode ?? 'acceptEdits' - }; - const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools); + mode: selectedAgent.permissionMode ?? 'acceptEdits', + } + const workerTools = assembleToolPool( + workerPermissionContext, + appState.mcp.tools, + ) // Create a stable agent ID early so it can be used for worktree slug - const earlyAgentId = createAgentId(); + const earlyAgentId = createAgentId() // Set up worktree isolation if requested let worktreeInfo: { - worktreePath: string; - worktreeBranch?: string; - headCommit?: string; - gitRoot?: string; - hookBased?: boolean; - } | null = null; + worktreePath: string + worktreeBranch?: string + headCommit?: string + gitRoot?: string + hookBased?: boolean + } | null = null + if (effectiveIsolation === 'worktree') { - const slug = `agent-${earlyAgentId.slice(0, 8)}`; - worktreeInfo = await createAgentWorktree(slug); + const slug = `agent-${earlyAgentId.slice(0, 8)}` + worktreeInfo = await createAgentWorktree(slug) } // Fork + worktree: inject a notice telling the child to translate paths // and re-read potentially stale files. Appended after the fork directive // so it appears as the most recent guidance the child sees. if (isForkPath && worktreeInfo) { - promptMessages.push(createUserMessage({ - content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath) - })); + promptMessages.push( + createUserMessage({ + content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath), + }), + ) } + const runAgentParams: Parameters[0] = { agentDefinition: selectedAgent, promptMessages, toolUseContext, canUseTool, isAsync: shouldRunAsync, - querySource: toolUseContext.options.querySource ?? getQuerySourceForAgent(selectedAgent.agentType, isBuiltInAgent(selectedAgent)), + querySource: + toolUseContext.options.querySource ?? + getQuerySourceForAgent( + selectedAgent.agentType, + isBuiltInAgent(selectedAgent), + ), model: isForkPath ? undefined : model, // Fork path: pass parent's system prompt AND parent's exact tool // array (cache-identical prefix). workerTools is rebuilt under @@ -619,72 +896,64 @@ export const AgentTool = buildTool({ // or explicit cwd), skip the pre-built system prompt so runAgent's // buildAgentSystemPrompt() runs inside wrapWithCwd where getCwd() // returns the override path. - override: isForkPath ? { - systemPrompt: forkParentSystemPrompt - } : enhancedSystemPrompt && !worktreeInfo && !cwd ? { - systemPrompt: asSystemPrompt(enhancedSystemPrompt) - } : undefined, + override: isForkPath + ? { systemPrompt: forkParentSystemPrompt } + : enhancedSystemPrompt && !worktreeInfo && !cwd + ? { systemPrompt: asSystemPrompt(enhancedSystemPrompt) } + : undefined, availableTools: isForkPath ? toolUseContext.options.tools : workerTools, // Pass parent conversation when the fork-subagent path needs full // context. useExactTools inherits thinkingConfig (runAgent.ts:624). forkContextMessages: isForkPath ? toolUseContext.messages : undefined, - ...(isForkPath && { - useExactTools: true - }), + ...(isForkPath && { useExactTools: true }), worktreePath: worktreeInfo?.worktreePath, - description - }; + description, + } // Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS) // takes precedence over worktree isolation path. - const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath; - const wrapWithCwd = (fn: () => T): T => cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn(); + const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath + const wrapWithCwd = (fn: () => T): T => + cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn() // Helper to clean up worktree after agent completes const cleanupWorktreeIfNeeded = async (): Promise<{ - worktreePath?: string; - worktreeBranch?: string; + worktreePath?: string + worktreeBranch?: string }> => { - if (!worktreeInfo) return {}; - const { - worktreePath, - worktreeBranch, - headCommit, - gitRoot, - hookBased - } = worktreeInfo; + if (!worktreeInfo) return {} + const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = + worktreeInfo // Null out to make idempotent — guards against double-call if code // between cleanup and end of try throws into catch - worktreeInfo = null; + worktreeInfo = null if (hookBased) { // Hook-based worktrees are always kept since we can't detect VCS changes - logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`); - return { - worktreePath - }; + logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`) + return { worktreePath } } if (headCommit) { - const changed = await hasWorktreeChanges(worktreePath, headCommit); + const changed = await hasWorktreeChanges(worktreePath, headCommit) if (!changed) { - await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot); + await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot) // Clear worktreePath from metadata so resume doesn't try to use // a deleted directory. Fire-and-forget to match runAgent's // writeAgentMetadata handling. void writeAgentMetadata(asAgentId(earlyAgentId), { agentType: selectedAgent.agentType, - description - }).catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`)); - return {}; + description, + }).catch(_err => + logForDebugging(`Failed to clear worktree metadata: ${_err}`), + ) + return {} } } - logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`); - return { - worktreePath, - worktreeBranch - }; - }; + logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`) + return { worktreePath, worktreeBranch } + } + if (shouldRunAsync) { - const asyncAgentId = earlyAgentId; + const asyncAgentId = earlyAgentId const agentBackgroundTask = registerAsyncAgent({ agentId: asyncAgentId, description, @@ -694,25 +963,22 @@ export const AgentTool = buildTool({ // Don't link to parent's abort controller -- background agents should // survive when the user presses ESC to cancel the main thread. // They are killed explicitly via chat:killAgents. - toolUseId: toolUseContext.toolUseId - }); + toolUseId: toolUseContext.toolUseId, + }) // Register name → agentId for SendMessage routing. Post-registerAsyncAgent // so we don't leave a stale entry if spawn fails. Sync agents skipped — // coordinator is blocked, so SendMessage routing doesn't apply. if (name) { rootSetAppState(prev => { - const next = new Map(prev.agentNameRegistry); - next.set(name, asAgentId(asyncAgentId)); - return { - ...prev, - agentNameRegistry: next - }; - }); + const next = new Map(prev.agentNameRegistry) + next.set(name, asAgentId(asyncAgentId)) + return { ...prev, agentNameRegistry: next } + }) } // Wrap async agent execution in agent context for analytics attribution - const asyncAgentContext: SubagentContext = { + const asyncAgentContext = { agentId: asyncAgentId, // For subagents from teammates: use team lead's session // For subagents from main REPL: undefined (no parent session) @@ -720,37 +986,50 @@ export const AgentTool = buildTool({ agentType: 'subagent' as const, subagentName: selectedAgent.agentType, isBuiltIn: isBuiltInAgent(selectedAgent), - invokingRequestId: assistantMessage?.requestId as string | undefined, + invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, - invocationEmitted: false - }; + invocationEmitted: false, + } // Workload propagation: handlePromptSubmit wraps the entire turn in // runWithWorkload (AsyncLocalStorage). ALS context is captured at // invocation time — when this `void` fires — and survives every await // inside. No capture/restore needed; the detached closure sees the // parent turn's workload automatically, isolated from its finally. - void runWithAgentContext(asyncAgentContext, () => wrapWithCwd(() => runAsyncAgentLifecycle({ - taskId: agentBackgroundTask.agentId, - abortController: agentBackgroundTask.abortController!, - makeStream: onCacheSafeParams => runAgent({ - ...runAgentParams, - override: { - ...runAgentParams.override, - agentId: asAgentId(agentBackgroundTask.agentId), - abortController: agentBackgroundTask.abortController! - }, - onCacheSafeParams - }), - metadata, - description, - toolUseContext, - rootSetAppState, - agentIdForCleanup: asyncAgentId, - enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(), - getWorktreeResult: cleanupWorktreeIfNeeded - }))); - const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); + void runWithAgentContext(asyncAgentContext, () => + wrapWithCwd(() => + runAsyncAgentLifecycle({ + taskId: agentBackgroundTask.agentId, + abortController: agentBackgroundTask.abortController!, + makeStream: onCacheSafeParams => + runAgent({ + ...runAgentParams, + override: { + ...runAgentParams.override, + agentId: asAgentId(agentBackgroundTask.agentId), + abortController: agentBackgroundTask.abortController!, + }, + onCacheSafeParams, + }), + metadata, + description, + toolUseContext, + rootSetAppState, + agentIdForCleanup: asyncAgentId, + enableSummarization: + isCoordinator || + isForkSubagentEnabled() || + getSdkAgentProgressSummariesEnabled(), + getWorktreeResult: cleanupWorktreeIfNeeded, + }), + ), + ) + + const canReadOutputFile = toolUseContext.options.tools.some( + t => + toolMatchesName(t, FILE_READ_TOOL_NAME) || + toolMatchesName(t, BASH_TOOL_NAME), + ) return { data: { isAsync: true as const, @@ -759,15 +1038,15 @@ export const AgentTool = buildTool({ description: description, prompt: prompt, outputFile: getTaskOutputPath(agentBackgroundTask.agentId), - canReadOutputFile - } - }; + canReadOutputFile, + }, + } } else { // Create an explicit agentId for sync agents - const syncAgentId = asAgentId(earlyAgentId); + const syncAgentId = asAgentId(earlyAgentId) // Set up agent context for sync execution (for analytics attribution) - const syncAgentContext: SubagentContext = { + const syncAgentContext = { agentId: syncAgentId, // For subagents from teammates: use team lead's session // For subagents from main REPL: undefined (no parent session) @@ -775,607 +1054,767 @@ export const AgentTool = buildTool({ agentType: 'subagent' as const, subagentName: selectedAgent.agentType, isBuiltIn: isBuiltInAgent(selectedAgent), - invokingRequestId: assistantMessage?.requestId as string | undefined, + invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, - invocationEmitted: false - }; + invocationEmitted: false, + } // Wrap entire sync agent execution in context for analytics attribution // and optionally in a worktree cwd override for filesystem isolation - return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => { - const agentMessages: MessageType[] = []; - const agentStartTime = Date.now(); - const syncTracker = createProgressTracker(); - const syncResolveActivity = createActivityDescriptionResolver(toolUseContext.options.tools); - - // Yield initial progress message to carry metadata (prompt) - if (promptMessages.length > 0) { - const normalizedPromptMessages = normalizeMessages(promptMessages); - const normalizedFirstMessage = normalizedPromptMessages.find((m): m is NormalizedUserMessage => m.type === 'user'); - if (normalizedFirstMessage && normalizedFirstMessage.type === 'user' && onProgress) { - onProgress({ - toolUseID: `agent_${assistantMessage.message.id}`, - data: { - message: normalizedFirstMessage, - type: 'agent_progress', - prompt, - agentId: syncAgentId - } - }); + return runWithAgentContext(syncAgentContext, () => + wrapWithCwd(async () => { + const agentMessages: MessageType[] = [] + const agentStartTime = Date.now() + const syncTracker = createProgressTracker() + const syncResolveActivity = createActivityDescriptionResolver( + toolUseContext.options.tools, + ) + + // Yield initial progress message to carry metadata (prompt) + if (promptMessages.length > 0) { + const normalizedPromptMessages = normalizeMessages(promptMessages) + const normalizedFirstMessage = normalizedPromptMessages.find( + (m): m is NormalizedUserMessage => m.type === 'user', + ) + if ( + normalizedFirstMessage && + normalizedFirstMessage.type === 'user' && + onProgress + ) { + onProgress({ + toolUseID: `agent_${assistantMessage.message.id}`, + data: { + message: normalizedFirstMessage, + type: 'agent_progress', + prompt, + agentId: syncAgentId, + }, + }) + } } - } - // Register as foreground task immediately so it can be backgrounded at any time - // Skip registration if background tasks are disabled - let foregroundTaskId: string | undefined; - // Create the background race promise once outside the loop — otherwise - // each iteration adds a new .then() reaction to the same pending - // promise, accumulating callbacks for the lifetime of the agent. - let backgroundPromise: Promise<{ - type: 'background'; - }> | undefined; - let cancelAutoBackground: (() => void) | undefined; - if (!isBackgroundTasksDisabled) { - const registration = registerAgentForeground({ - agentId: syncAgentId, - description, - prompt, - selectedAgent, - setAppState: rootSetAppState, - toolUseId: toolUseContext.toolUseId, - autoBackgroundMs: getAutoBackgroundMs() || undefined - }); - foregroundTaskId = registration.taskId; - backgroundPromise = registration.backgroundSignal.then(() => ({ - type: 'background' as const - })); - cancelAutoBackground = registration.cancelAutoBackground; - } + // Register as foreground task immediately so it can be backgrounded at any time + // Skip registration if background tasks are disabled + let foregroundTaskId: string | undefined + // Create the background race promise once outside the loop — otherwise + // each iteration adds a new .then() reaction to the same pending + // promise, accumulating callbacks for the lifetime of the agent. + let backgroundPromise: Promise<{ type: 'background' }> | undefined + let cancelAutoBackground: (() => void) | undefined + if (!isBackgroundTasksDisabled) { + const registration = registerAgentForeground({ + agentId: syncAgentId, + description, + prompt, + selectedAgent, + setAppState: rootSetAppState, + toolUseId: toolUseContext.toolUseId, + autoBackgroundMs: getAutoBackgroundMs() || undefined, + }) + foregroundTaskId = registration.taskId + backgroundPromise = registration.backgroundSignal.then(() => ({ + type: 'background' as const, + })) + cancelAutoBackground = registration.cancelAutoBackground + } - // Track if we've shown the background hint UI - let backgroundHintShown = false; - // Track if the agent was backgrounded (cleanup handled by backgrounded finally) - let wasBackgrounded = false; - // Per-scope stop function — NOT shared with the backgrounded closure. - // idempotent: startAgentSummarization's stop() checks `stopped` flag. - let stopForegroundSummarization: (() => void) | undefined; - // const capture for sound type narrowing inside the callback below - const summaryTaskId = foregroundTaskId; - - // Get async iterator for the agent - const agentIterator = runAgent({ - ...runAgentParams, - override: { - ...runAgentParams.override, - agentId: syncAgentId - }, - onCacheSafeParams: summaryTaskId && getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { - stop - } = startAgentSummarization(summaryTaskId, syncAgentId, params, rootSetAppState); - stopForegroundSummarization = stop; - } : undefined - })[Symbol.asyncIterator](); - - // Track if an error occurred during iteration - let syncAgentError: Error | undefined; - let wasAborted = false; - let worktreeResult: { - worktreePath?: string; - worktreeBranch?: string; - } = {}; - try { - while (true) { - const elapsed = Date.now() - agentStartTime; - - // Show background hint after threshold (but task is already registered) - // Skip if background tasks are disabled - if (!isBackgroundTasksDisabled && !backgroundHintShown && elapsed >= PROGRESS_THRESHOLD_MS && toolUseContext.setToolJSX) { - backgroundHintShown = true; - toolUseContext.setToolJSX({ - jsx: , - shouldHidePromptInput: false, - shouldContinueAnimation: true, - showSpinner: true - }); - } + // Track if we've shown the background hint UI + let backgroundHintShown = false + // Track if the agent was backgrounded (cleanup handled by backgrounded finally) + let wasBackgrounded = false + // Per-scope stop function — NOT shared with the backgrounded closure. + // idempotent: startAgentSummarization's stop() checks `stopped` flag. + let stopForegroundSummarization: (() => void) | undefined + // const capture for sound type narrowing inside the callback below + const summaryTaskId = foregroundTaskId + + // Get async iterator for the agent + const agentIterator = runAgent({ + ...runAgentParams, + override: { + ...runAgentParams.override, + agentId: syncAgentId, + }, + onCacheSafeParams: + summaryTaskId && getSdkAgentProgressSummariesEnabled() + ? (params: CacheSafeParams) => { + const { stop } = startAgentSummarization( + summaryTaskId, + syncAgentId, + params, + rootSetAppState, + ) + stopForegroundSummarization = stop + } + : undefined, + })[Symbol.asyncIterator]() + + // Track if an error occurred during iteration + let syncAgentError: Error | undefined + let wasAborted = false + let worktreeResult: { + worktreePath?: string + worktreeBranch?: string + } = {} + + try { + while (true) { + const elapsed = Date.now() - agentStartTime + + // Show background hint after threshold (but task is already registered) + // Skip if background tasks are disabled + if ( + !isBackgroundTasksDisabled && + !backgroundHintShown && + elapsed >= PROGRESS_THRESHOLD_MS && + toolUseContext.setToolJSX + ) { + backgroundHintShown = true + toolUseContext.setToolJSX({ + jsx: , + shouldHidePromptInput: false, + shouldContinueAnimation: true, + showSpinner: true, + }) + } - // Race between next message and background signal - // If background tasks are disabled, just await the next message directly - const nextMessagePromise = agentIterator.next(); - const raceResult = backgroundPromise ? await Promise.race([nextMessagePromise.then(r => ({ - type: 'message' as const, - result: r - })), backgroundPromise]) : { - type: 'message' as const, - result: await nextMessagePromise - }; - - // Check if we were backgrounded via backgroundAll() - // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' - // because backgroundPromise is only defined when foregroundTaskId is defined - if (raceResult.type === 'background' && foregroundTaskId) { - const appState = toolUseContext.getAppState(); - const task = appState.tasks[foregroundTaskId]; - if (isLocalAgentTask(task) && task.isBackgrounded) { - // Capture the taskId for use in the async callback - const backgroundedTaskId = foregroundTaskId; - wasBackgrounded = true; - // Stop foreground summarization; the backgrounded closure - // below owns its own independent stop function. - stopForegroundSummarization?.(); - - // Workload: inherited via ALS at `void` invocation time, - // same as the async-from-start path above. - // Continue agent in background and return async result - void runWithAgentContext(syncAgentContext, async () => { - let stopBackgroundedSummarization: (() => void) | undefined; - try { - // Clean up the foreground iterator so its finally block runs - // (releases MCP connections, session hooks, prompt cache tracking, etc.) - // Timeout prevents blocking if MCP server cleanup hangs. - // .catch() prevents unhandled rejection if timeout wins the race. - await Promise.race([agentIterator.return(undefined).catch(() => {}), sleep(1000)]); - // Initialize progress tracking from existing messages - const tracker = createProgressTracker(); - const resolveActivity2 = createActivityDescriptionResolver(toolUseContext.options.tools); - for (const existingMsg of agentMessages) { - updateProgressFromMessage(tracker, existingMsg, resolveActivity2, toolUseContext.options.tools); - } - for await (const msg of runAgent({ - ...runAgentParams, - isAsync: true, - // Agent is now running in background - override: { - ...runAgentParams.override, - agentId: asAgentId(backgroundedTaskId), - abortController: task.abortController - }, - onCacheSafeParams: getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { - stop - } = startAgentSummarization(backgroundedTaskId, asAgentId(backgroundedTaskId), params, rootSetAppState); - stopBackgroundedSummarization = stop; - } : undefined - })) { - agentMessages.push(msg); - - // Track progress for backgrounded agents - updateProgressFromMessage(tracker, msg, resolveActivity2, toolUseContext.options.tools); - updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState); - const lastToolName = getLastToolUseName(msg); - if (lastToolName) { - emitTaskProgress(tracker, backgroundedTaskId, toolUseContext.toolUseId, description, startTime, lastToolName); + // Race between next message and background signal + // If background tasks are disabled, just await the next message directly + const nextMessagePromise = agentIterator.next() + const raceResult = backgroundPromise + ? await Promise.race([ + nextMessagePromise.then(r => ({ + type: 'message' as const, + result: r, + })), + backgroundPromise, + ]) + : { + type: 'message' as const, + result: await nextMessagePromise, + } + + // Check if we were backgrounded via backgroundAll() + // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' + // because backgroundPromise is only defined when foregroundTaskId is defined + if (raceResult.type === 'background' && foregroundTaskId) { + const appState = toolUseContext.getAppState() + const task = appState.tasks[foregroundTaskId] + if (isLocalAgentTask(task) && task.isBackgrounded) { + // Capture the taskId for use in the async callback + const backgroundedTaskId = foregroundTaskId + wasBackgrounded = true + // Stop foreground summarization; the backgrounded closure + // below owns its own independent stop function. + stopForegroundSummarization?.() + + // Workload: inherited via ALS at `void` invocation time, + // same as the async-from-start path above. + // Continue agent in background and return async result + void runWithAgentContext(syncAgentContext, async () => { + let stopBackgroundedSummarization: (() => void) | undefined + try { + // Clean up the foreground iterator so its finally block runs + // (releases MCP connections, session hooks, prompt cache tracking, etc.) + // Timeout prevents blocking if MCP server cleanup hangs. + // .catch() prevents unhandled rejection if timeout wins the race. + await Promise.race([ + agentIterator.return(undefined).catch(() => {}), + sleep(1000), + ]) + // Initialize progress tracking from existing messages + const tracker = createProgressTracker() + const resolveActivity2 = + createActivityDescriptionResolver( + toolUseContext.options.tools, + ) + for (const existingMsg of agentMessages) { + updateProgressFromMessage( + tracker, + existingMsg, + resolveActivity2, + toolUseContext.options.tools, + ) } - } - const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); - - // Mark task completed FIRST so TaskOutput(block=true) - // unblocks immediately. classifyHandoffIfNeeded and - // cleanupWorktreeIfNeeded can hang — they must not gate - // the status transition (gh-20236). - completeAsyncAgent(agentResult, rootSetAppState); - - // Extract text from agent result content for the notification - let finalMessage = extractTextContent(agentResult.content, '\n'); - if (feature('TRANSCRIPT_CLASSIFIER')) { - const backgroundedAppState = toolUseContext.getAppState(); - const handoffWarning = await classifyHandoffIfNeeded({ + for await (const msg of runAgent({ + ...runAgentParams, + isAsync: true, // Agent is now running in background + override: { + ...runAgentParams.override, + agentId: asAgentId(backgroundedTaskId), + abortController: task.abortController, + }, + onCacheSafeParams: getSdkAgentProgressSummariesEnabled() + ? (params: CacheSafeParams) => { + const { stop } = startAgentSummarization( + backgroundedTaskId, + asAgentId(backgroundedTaskId), + params, + rootSetAppState, + ) + stopBackgroundedSummarization = stop + } + : undefined, + })) { + agentMessages.push(msg) + + // Track progress for backgrounded agents + updateProgressFromMessage( + tracker, + msg, + resolveActivity2, + toolUseContext.options.tools, + ) + updateAsyncAgentProgress( + backgroundedTaskId, + getProgressUpdate(tracker), + rootSetAppState, + ) + + const lastToolName = getLastToolUseName(msg) + if (lastToolName) { + emitTaskProgress( + tracker, + backgroundedTaskId, + toolUseContext.toolUseId, + description, + startTime, + lastToolName, + ) + } + } + const agentResult = finalizeAgentTool( agentMessages, - tools: toolUseContext.options.tools, - toolPermissionContext: backgroundedAppState.toolPermissionContext, - abortSignal: task.abortController!.signal, - subagentType: selectedAgent.agentType, - totalToolUseCount: agentResult.totalToolUseCount - }); - if (handoffWarning) { - finalMessage = `${handoffWarning}\n\n${finalMessage}`; + backgroundedTaskId, + metadata, + ) + + // Mark task completed FIRST so TaskOutput(block=true) + // unblocks immediately. classifyHandoffIfNeeded and + // cleanupWorktreeIfNeeded can hang — they must not gate + // the status transition (gh-20236). + completeAsyncAgent(agentResult, rootSetAppState) + + // Extract text from agent result content for the notification + let finalMessage = extractTextContent( + agentResult.content, + '\n', + ) + + if (feature('TRANSCRIPT_CLASSIFIER')) { + const backgroundedAppState = + toolUseContext.getAppState() + const handoffWarning = await classifyHandoffIfNeeded({ + agentMessages, + tools: toolUseContext.options.tools, + toolPermissionContext: + backgroundedAppState.toolPermissionContext, + abortSignal: task.abortController!.signal, + subagentType: selectedAgent.agentType, + totalToolUseCount: agentResult.totalToolUseCount, + }) + if (handoffWarning) { + finalMessage = `${handoffWarning}\n\n${finalMessage}` + } } - } - // Clean up worktree before notification so we can include it - const worktreeResult = await cleanupWorktreeIfNeeded(); - enqueueAgentNotification({ - taskId: backgroundedTaskId, - description, - status: 'completed', - setAppState: rootSetAppState, - finalMessage, - usage: { - totalTokens: getTokenCountFromTracker(tracker), - toolUses: agentResult.totalToolUseCount, - durationMs: agentResult.totalDurationMs - }, - toolUseId: toolUseContext.toolUseId, - ...worktreeResult - }); - } catch (error) { - if (error instanceof AbortError) { - // Transition status BEFORE worktree cleanup so - // TaskOutput unblocks even if git hangs (gh-20236). - killAsyncAgent(backgroundedTaskId, rootSetAppState); - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: true, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const worktreeResult = await cleanupWorktreeIfNeeded(); - const partialResult = extractPartialResult(agentMessages); + // Clean up worktree before notification so we can include it + const worktreeResult = await cleanupWorktreeIfNeeded() + + enqueueAgentNotification({ + taskId: backgroundedTaskId, + description, + status: 'completed', + setAppState: rootSetAppState, + finalMessage, + usage: { + totalTokens: getTokenCountFromTracker(tracker), + toolUses: agentResult.totalToolUseCount, + durationMs: agentResult.totalDurationMs, + }, + toolUseId: toolUseContext.toolUseId, + ...worktreeResult, + }) + } catch (error) { + if (error instanceof AbortError) { + // Transition status BEFORE worktree cleanup so + // TaskOutput unblocks even if git hangs (gh-20236). + killAsyncAgent(backgroundedTaskId, rootSetAppState) + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: true, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const worktreeResult = await cleanupWorktreeIfNeeded() + const partialResult = + extractPartialResult(agentMessages) + enqueueAgentNotification({ + taskId: backgroundedTaskId, + description, + status: 'killed', + setAppState: rootSetAppState, + toolUseId: toolUseContext.toolUseId, + finalMessage: partialResult, + ...worktreeResult, + }) + return + } + const errMsg = errorMessage(error) + failAsyncAgent( + backgroundedTaskId, + errMsg, + rootSetAppState, + ) + const worktreeResult = await cleanupWorktreeIfNeeded() enqueueAgentNotification({ taskId: backgroundedTaskId, description, - status: 'killed', + status: 'failed', + error: errMsg, setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, - finalMessage: partialResult, - ...worktreeResult - }); - return; + ...worktreeResult, + }) + } finally { + stopBackgroundedSummarization?.() + clearInvokedSkillsForAgent(syncAgentId) + clearDumpState(syncAgentId) + // Note: worktree cleanup is done before enqueueAgentNotification + // in both try and catch paths so we can include worktree info } - const errMsg = errorMessage(error); - failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState); - const worktreeResult = await cleanupWorktreeIfNeeded(); - enqueueAgentNotification({ - taskId: backgroundedTaskId, - description, - status: 'failed', - error: errMsg, - setAppState: rootSetAppState, - toolUseId: toolUseContext.toolUseId, - ...worktreeResult - }); - } finally { - stopBackgroundedSummarization?.(); - clearInvokedSkillsForAgent(syncAgentId); - clearDumpState(syncAgentId); - // Note: worktree cleanup is done before enqueueAgentNotification - // in both try and catch paths so we can include worktree info + }) + + // Return async_launched result immediately + const canReadOutputFile = toolUseContext.options.tools.some( + t => + toolMatchesName(t, FILE_READ_TOOL_NAME) || + toolMatchesName(t, BASH_TOOL_NAME), + ) + return { + data: { + isAsync: true as const, + status: 'async_launched' as const, + agentId: backgroundedTaskId, + description: description, + prompt: prompt, + outputFile: getTaskOutputPath(backgroundedTaskId), + canReadOutputFile, + }, } - }); - - // Return async_launched result immediately - const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); - return { - data: { - isAsync: true as const, - status: 'async_launched' as const, - agentId: backgroundedTaskId, - description: description, - prompt: prompt, - outputFile: getTaskOutputPath(backgroundedTaskId), - canReadOutputFile + } + } + + // Process the message from the race result + if (raceResult.type !== 'message') { + // This shouldn't happen - background case handled above + continue + } + const { result } = raceResult + if (result.done) break + const message = result.value + + agentMessages.push(message) + + // Emit task_progress for the VS Code subagent panel + updateProgressFromMessage( + syncTracker, + message, + syncResolveActivity, + toolUseContext.options.tools, + ) + if (foregroundTaskId) { + const lastToolName = getLastToolUseName(message) + if (lastToolName) { + emitTaskProgress( + syncTracker, + foregroundTaskId, + toolUseContext.toolUseId, + description, + agentStartTime, + lastToolName, + ) + // Keep AppState task.progress in sync when SDK summaries are + // enabled, so updateAgentSummary reads correct token/tool counts + // instead of zeros. + if (getSdkAgentProgressSummariesEnabled()) { + updateAsyncAgentProgress( + foregroundTaskId, + getProgressUpdate(syncTracker), + rootSetAppState, + ) } - }; + } } - } - // Process the message from the race result - if (raceResult.type !== 'message') { - // This shouldn't happen - background case handled above - continue; - } - const { - result - } = raceResult; - if (result.done) break; - const message = result.value as MessageType; - agentMessages.push(message); - - // Emit task_progress for the VS Code subagent panel - updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools); - if (foregroundTaskId) { - const lastToolName = getLastToolUseName(message); - if (lastToolName) { - emitTaskProgress(syncTracker, foregroundTaskId, toolUseContext.toolUseId, description, agentStartTime, lastToolName); - // Keep AppState task.progress in sync when SDK summaries are - // enabled, so updateAgentSummary reads correct token/tool counts - // instead of zeros. - if (getSdkAgentProgressSummariesEnabled()) { - updateAsyncAgentProgress(foregroundTaskId, getProgressUpdate(syncTracker), rootSetAppState); + // Forward bash_progress events from sub-agent to parent so the SDK + // receives tool_progress events just as it does for the main agent. + if ( + message.type === 'progress' && + (message.data.type === 'bash_progress' || + message.data.type === 'powershell_progress') && + onProgress + ) { + onProgress({ + toolUseID: message.toolUseID, + data: message.data, + }) + } + + if (message.type !== 'assistant' && message.type !== 'user') { + continue + } + + // Increment token count in spinner for assistant messages + // Subagent streaming events are filtered out in runAgent.ts, so we + // need to count tokens from completed messages here + if (message.type === 'assistant') { + const contentLength = getAssistantMessageContentLength(message) + if (contentLength > 0) { + toolUseContext.setResponseLength(len => len + contentLength) } } - } - // Forward bash_progress events from sub-agent to parent so the SDK - // receives tool_progress events just as it does for the main agent. - if (message.type === 'progress' && ((message.data as { type?: string })?.type === 'bash_progress' || (message.data as { type?: string })?.type === 'powershell_progress') && onProgress) { - onProgress({ - toolUseID: message.toolUseID as string, - data: message.data - }); + const normalizedNew = normalizeMessages([message]) + for (const m of normalizedNew) { + for (const content of m.message.content) { + if ( + content.type !== 'tool_use' && + content.type !== 'tool_result' + ) { + continue + } + + // Forward progress updates + if (onProgress) { + onProgress({ + toolUseID: `agent_${assistantMessage.message.id}`, + data: { + message: m, + type: 'agent_progress', + // prompt only needed on first progress message (UI.tsx:624 + // reads progressMessages[0]). Omit here to avoid duplication. + prompt: '', + agentId: syncAgentId, + }, + }) + } + } + } } - if (message.type !== 'assistant' && message.type !== 'user') { - continue; + } catch (error) { + // Handle errors from the sync agent loop + // AbortError should be re-thrown for proper interruption handling + if (error instanceof AbortError) { + wasAborted = true + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: false, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw error } - // Increment token count in spinner for assistant messages - // Subagent streaming events are filtered out in runAgent.ts, so we - // need to count tokens from completed messages here - if (message.type === 'assistant') { - const contentLength = getAssistantMessageContentLength(message as AssistantMessage); - if (contentLength > 0) { - toolUseContext.setResponseLength(len => len + contentLength); - } + // Log the error for debugging + logForDebugging(`Sync agent error: ${errorMessage(error)}`, { + level: 'error', + }) + + // Store the error to handle after cleanup + syncAgentError = toError(error) + } finally { + // Clear the background hint UI + if (toolUseContext.setToolJSX) { + toolUseContext.setToolJSX(null) } - const normalizedNew = normalizeMessages([message]); - for (const m of normalizedNew) { - for (const content of (m.message.content as unknown as Array<{ type: string; [key: string]: unknown }>)) { - if (content.type !== 'tool_use' && content.type !== 'tool_result') { - continue; - } - // Forward progress updates - if (onProgress) { - onProgress({ - toolUseID: `agent_${assistantMessage.message.id}`, - data: { - message: m, - type: 'agent_progress', - // prompt only needed on first progress message (UI.tsx:624 - // reads progressMessages[0]). Omit here to avoid duplication. - prompt: '', - agentId: syncAgentId - } - }); - } + // Stop foreground summarization. Idempotent — if already stopped at + // the backgrounding transition, this is a no-op. The backgrounded + // closure owns a separate stop function (stopBackgroundedSummarization). + stopForegroundSummarization?.() + + // Unregister foreground task if agent completed without being backgrounded + if (foregroundTaskId) { + unregisterAgentForeground(foregroundTaskId, rootSetAppState) + // Notify SDK consumers (e.g. VS Code subagent panel) that this + // foreground agent is done. Goes through drainSdkEvents() — does + // NOT trigger the print.ts XML task_notification parser or the LLM loop. + if (!wasBackgrounded) { + const progress = getProgressUpdate(syncTracker) + enqueueSdkEvent({ + type: 'system', + subtype: 'task_notification', + task_id: foregroundTaskId, + tool_use_id: toolUseContext.toolUseId, + status: syncAgentError + ? 'failed' + : wasAborted + ? 'stopped' + : 'completed', + output_file: '', + summary: description, + usage: { + total_tokens: progress.tokenCount, + tool_uses: progress.toolUseCount, + duration_ms: Date.now() - agentStartTime, + }, + }) } } - } - } catch (error) { - // Handle errors from the sync agent loop - // AbortError should be re-thrown for proper interruption handling - if (error instanceof AbortError) { - wasAborted = true; - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: false, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw error; - } - // Log the error for debugging - logForDebugging(`Sync agent error: ${errorMessage(error)}`, { - level: 'error' - }); - - // Store the error to handle after cleanup - syncAgentError = toError(error); - } finally { - // Clear the background hint UI - if (toolUseContext.setToolJSX) { - toolUseContext.setToolJSX(null); - } + // Clean up scoped skills so they don't accumulate in the global map + clearInvokedSkillsForAgent(syncAgentId) - // Stop foreground summarization. Idempotent — if already stopped at - // the backgrounding transition, this is a no-op. The backgrounded - // closure owns a separate stop function (stopBackgroundedSummarization). - stopForegroundSummarization?.(); - - // Unregister foreground task if agent completed without being backgrounded - if (foregroundTaskId) { - unregisterAgentForeground(foregroundTaskId, rootSetAppState); - // Notify SDK consumers (e.g. VS Code subagent panel) that this - // foreground agent is done. Goes through drainSdkEvents() — does - // NOT trigger the print.ts XML task_notification parser or the LLM loop. + // Clean up dumpState entry for this agent to prevent unbounded growth + // Skip if backgrounded — the backgrounded agent's finally handles cleanup if (!wasBackgrounded) { - const progress = getProgressUpdate(syncTracker); - enqueueSdkEvent({ - type: 'system', - subtype: 'task_notification', - task_id: foregroundTaskId, - tool_use_id: toolUseContext.toolUseId, - status: syncAgentError ? 'failed' : wasAborted ? 'stopped' : 'completed', - output_file: '', - summary: description, - usage: { - total_tokens: progress.tokenCount, - tool_uses: progress.toolUseCount, - duration_ms: Date.now() - agentStartTime - } - }); + clearDumpState(syncAgentId) } - } - // Clean up scoped skills so they don't accumulate in the global map - clearInvokedSkillsForAgent(syncAgentId); + // Cancel auto-background timer if agent completed before it fired + cancelAutoBackground?.() - // Clean up dumpState entry for this agent to prevent unbounded growth - // Skip if backgrounded — the backgrounded agent's finally handles cleanup - if (!wasBackgrounded) { - clearDumpState(syncAgentId); + // Clean up worktree if applicable (in finally to handle abort/error paths) + // Skip if backgrounded — the background continuation is still running in it + if (!wasBackgrounded) { + worktreeResult = await cleanupWorktreeIfNeeded() + } } - // Cancel auto-background timer if agent completed before it fired - cancelAutoBackground?.(); - - // Clean up worktree if applicable (in finally to handle abort/error paths) - // Skip if backgrounded — the background continuation is still running in it - if (!wasBackgrounded) { - worktreeResult = await cleanupWorktreeIfNeeded(); + // Re-throw abort errors + // TODO: Find a cleaner way to express this + const lastMessage = agentMessages.findLast( + _ => _.type !== 'system' && _.type !== 'progress', + ) + if (lastMessage && isSyntheticMessage(lastMessage)) { + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: false, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw new AbortError() } - } - // Re-throw abort errors - // TODO: Find a cleaner way to express this - const lastMessage = agentMessages.findLast(_ => _.type !== 'system' && _.type !== 'progress'); - if (lastMessage && isSyntheticMessage(lastMessage)) { - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: false, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw new AbortError(); - } + // If an error occurred during iteration, try to return a result with + // whatever messages we have. If we have no assistant messages, + // re-throw the error so it's properly handled by the tool framework. + if (syncAgentError) { + // Check if we have any assistant messages to return + const hasAssistantMessages = agentMessages.some( + msg => msg.type === 'assistant', + ) + + if (!hasAssistantMessages) { + // No messages collected, re-throw the error + throw syncAgentError + } - // If an error occurred during iteration, try to return a result with - // whatever messages we have. If we have no assistant messages, - // re-throw the error so it's properly handled by the tool framework. - if (syncAgentError) { - // Check if we have any assistant messages to return - const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant'); - if (!hasAssistantMessages) { - // No messages collected, re-throw the error - throw syncAgentError; + // We have some messages, try to finalize and return them + // This allows the parent agent to see partial progress even after an error + logForDebugging( + `Sync agent recovering from error with ${agentMessages.length} messages`, + ) } - // We have some messages, try to finalize and return them - // This allows the parent agent to see partial progress even after an error - logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`); - } - const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata); - if (feature('TRANSCRIPT_CLASSIFIER')) { - const currentAppState = toolUseContext.getAppState(); - const handoffWarning = await classifyHandoffIfNeeded({ + const agentResult = finalizeAgentTool( agentMessages, - tools: toolUseContext.options.tools, - toolPermissionContext: currentAppState.toolPermissionContext, - abortSignal: toolUseContext.abortController.signal, - subagentType: selectedAgent.agentType, - totalToolUseCount: agentResult.totalToolUseCount - }); - if (handoffWarning) { - agentResult.content = [{ - type: 'text' as const, - text: handoffWarning - }, ...agentResult.content]; + syncAgentId, + metadata, + ) + + if (feature('TRANSCRIPT_CLASSIFIER')) { + const currentAppState = toolUseContext.getAppState() + const handoffWarning = await classifyHandoffIfNeeded({ + agentMessages, + tools: toolUseContext.options.tools, + toolPermissionContext: currentAppState.toolPermissionContext, + abortSignal: toolUseContext.abortController.signal, + subagentType: selectedAgent.agentType, + totalToolUseCount: agentResult.totalToolUseCount, + }) + if (handoffWarning) { + agentResult.content = [ + { type: 'text' as const, text: handoffWarning }, + ...agentResult.content, + ] + } } - } - return { - data: { - status: 'completed' as const, - prompt, - ...agentResult, - ...worktreeResult + + return { + data: { + status: 'completed' as const, + prompt, + ...agentResult, + ...worktreeResult, + }, } - }; - })); + }), + ) } }, isReadOnly() { - return true; // delegates permission checks to its underlying tools + return true // delegates permission checks to its underlying tools }, toAutoClassifierInput(input) { - const i = input as AgentToolInput; - const tags = [i.subagent_type, i.mode ? `mode=${i.mode}` : undefined].filter((t): t is string => t !== undefined); - const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': '; - return `${prefix}${i.prompt}`; + const i = input as AgentToolInput + const tags = [ + i.subagent_type, + i.mode ? `mode=${i.mode}` : undefined, + ].filter((t): t is string => t !== undefined) + const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': ' + return `${prefix}${i.prompt}` }, isConcurrencySafe() { - return true; + return true }, userFacingName, userFacingNameBackgroundColor, getActivityDescription(input) { - return input?.description ?? 'Running task'; + return input?.description ?? 'Running task' }, async checkPermissions(input, context): Promise { - const appState = context.getAppState(); + const appState = context.getAppState() // Only route through auto mode classifier when in auto mode // In all other modes, auto-approve sub-agent generation - // Note: "external" === 'ant' guard enables dead code elimination for external builds - if ((process.env.USER_TYPE) === 'ant' && appState.toolPermissionContext.mode === 'auto') { + // Note: process.env.USER_TYPE === 'ant' guard enables dead code elimination for external builds + if ( + process.env.USER_TYPE === 'ant' && + appState.toolPermissionContext.mode === 'auto' + ) { return { behavior: 'passthrough', - message: 'Agent tool requires permission to spawn sub-agents.' - }; + message: 'Agent tool requires permission to spawn sub-agents.', + } } - return { - behavior: 'allow', - updatedInput: input - }; + + return { behavior: 'allow', updatedInput: input } }, mapToolResultToToolResultBlockParam(data, toolUseID) { // Multi-agent spawn result - const internalData = data as InternalOutput; - if (typeof internalData === 'object' && internalData !== null && 'status' in internalData && internalData.status === 'teammate_spawned') { - const spawnData = internalData as TeammateSpawnedOutput; + const internalData = data as InternalOutput + if ( + typeof internalData === 'object' && + internalData !== null && + 'status' in internalData && + internalData.status === 'teammate_spawned' + ) { + const spawnData = internalData as TeammateSpawnedOutput return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text: `Spawned successfully. + content: [ + { + type: 'text', + text: `Spawned successfully. agent_id: ${spawnData.teammate_id} name: ${spawnData.name} team_name: ${spawnData.team_name} -The agent is now running and will receive instructions via mailbox.` - }] - }; +The agent is now running and will receive instructions via mailbox.`, + }, + ], + } } if ('status' in internalData && internalData.status === 'remote_launched') { - const r = internalData; + const r = internalData return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.` - }] - }; + content: [ + { + type: 'text', + text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.`, + }, + ], + } } if (data.status === 'async_launched') { - const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`; - const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`; - const text = `${prefix}\n${instructions}`; + const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.` + const instructions = data.canReadOutputFile + ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` + : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.` + const text = `${prefix}\n${instructions}` return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text - }] - }; + content: [ + { + type: 'text', + text, + }, + ], + } } if (data.status === 'completed') { - const worktreeData = data as Record; - const worktreeInfoText = worktreeData.worktreePath ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` : ''; + const worktreeData = data as Record + const worktreeInfoText = worktreeData.worktreePath + ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` + : '' // If the subagent completes with no content, the tool_result is just the // agentId/usage trailer below — a metadata-only block at the prompt tail. // Some models read that as "nothing to act on" and end their turn // immediately. Say so explicitly so the parent has something to react to. - const contentOrMarker = data.content.length > 0 ? data.content : [{ - type: 'text' as const, - text: '(Subagent completed but returned no output.)' - }]; + const contentOrMarker = + data.content.length > 0 + ? data.content + : [ + { + type: 'text' as const, + text: '(Subagent completed but returned no output.)', + }, + ] // One-shot built-ins (Explore, Plan) are never continued via SendMessage // — the agentId hint and block are dead weight (~135 chars × // 34M Explore runs/week ≈ 1-2 Gtok/week). Telemetry doesn't parse this // block (it uses logEvent in finalizeAgentTool), so dropping is safe. // agentType is optional for resume compat — missing means show trailer. - if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText) { + if ( + data.agentType && + ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && + !worktreeInfoText + ) { return { tool_use_id: toolUseID, type: 'tool_result', - content: contentOrMarker - }; + content: contentOrMarker, + } } return { tool_use_id: toolUseID, type: 'tool_result', - content: [...contentOrMarker, { - type: 'text', - text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText} + content: [ + ...contentOrMarker, + { + type: 'text', + text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText} total_tokens: ${data.totalTokens} tool_uses: ${data.totalToolUseCount} -duration_ms: ${data.totalDurationMs}` - }] - }; +duration_ms: ${data.totalDurationMs}`, + }, + ], + } } - data satisfies never; - throw new Error(`Unexpected agent tool result status: ${(data as { - status: string; - }).status}`); + data satisfies never + throw new Error( + `Unexpected agent tool result status: ${(data as { status: string }).status}`, + ) }, renderToolResultMessage, renderToolUseMessage, @@ -1383,15 +1822,13 @@ duration_ms: ${data.totalDurationMs}` renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseErrorMessage, - renderGroupedToolUse: renderGroupedAgentToolUse -} satisfies ToolDef); -function resolveTeamName(input: { - team_name?: string; -}, appState: { - teamContext?: { - teamName: string; - }; -}): string | undefined { - if (!isAgentSwarmsEnabled()) return undefined; - return input.team_name || appState.teamContext?.teamName; + renderGroupedToolUse: renderGroupedAgentToolUse, +} satisfies ToolDef) + +function resolveTeamName( + input: { team_name?: string }, + appState: { teamContext?: { teamName: string } }, +): string | undefined { + if (!isAgentSwarmsEnabled()) return undefined + return input.team_name || appState.teamContext?.teamName } diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index ff0eb632f..aaa312e20 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -1,36 +1,57 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; -import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; -import { Byline } from 'src/components/design-system/Byline.js'; -import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js'; -import type { z } from 'zod/v4'; -import { AgentProgressLine } from '../../components/AgentProgressLine.js'; -import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; -import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js'; -import { Markdown } from '../../components/Markdown.js'; -import { Message as MessageComponent } from '../../components/Message.js'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { ToolUseLoader } from '../../components/ToolUseLoader.js'; -import { Box, Text } from '../../ink.js'; -import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; -import { findToolByName, type Tools } from '../../Tool.js'; -import type { Message, ProgressMessage } from '../../types/message.js'; -import type { AgentToolProgress } from '../../types/tools.js'; -import { count } from '../../utils/array.js'; -import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatDuration, formatNumber } from '../../utils/format.js'; -import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js'; -import type { ModelAlias } from '../../utils/model/aliases.js'; -import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js'; -import type { Theme, ThemeName } from '../../utils/theme.js'; -import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; -import { inputSchema } from './AgentTool.js'; -import { getAgentColor } from './agentColorManager.js'; -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; -const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; +import type { + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js' +import { + CtrlOToExpand, + SubAgentProvider, +} from 'src/components/CtrlOToExpand.js' +import { Byline } from 'src/components/design-system/Byline.js' +import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js' +import type { z } from 'zod/v4' +import { AgentProgressLine } from '../../components/AgentProgressLine.js' +import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js' +import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js' +import { Markdown } from '../../components/Markdown.js' +import { Message as MessageComponent } from '../../components/Message.js' +import { MessageResponse } from '../../components/MessageResponse.js' +import { ToolUseLoader } from '../../components/ToolUseLoader.js' +import { Box, Text } from '../../ink.js' +import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' +import { findToolByName, type Tools } from '../../Tool.js' +import type { Message, ProgressMessage } from '../../types/message.js' +import type { AgentToolProgress } from '../../types/tools.js' +import { count } from '../../utils/array.js' +import { + getSearchOrReadFromContent, + getSearchReadSummaryText, +} from '../../utils/collapseReadSearch.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatDuration, formatNumber } from '../../utils/format.js' +import { + buildSubagentLookups, + createAssistantMessage, + EMPTY_LOOKUPS, +} from '../../utils/messages.js' +import type { ModelAlias } from '../../utils/model/aliases.js' +import { + getMainLoopModel, + parseUserSpecifiedModel, + renderModelName, +} from '../../utils/model/model.js' +import type { Theme, ThemeName } from '../../utils/theme.js' +import type { + outputSchema, + Progress, + RemoteLaunchedOutput, +} from './AgentTool.js' +import { inputSchema } from './AgentTool.js' +import { getAgentColor } from './agentColorManager.js' +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' + +const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 /** * Guard: checks if progress data has a `message` field (agent_progress or @@ -39,10 +60,10 @@ const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; */ function hasProgressMessage(data: Progress): data is AgentToolProgress { if (!('message' in data)) { - return false; + return false } - const msg = (data as AgentToolProgress).message; - return msg != null && typeof msg === 'object' && 'type' in msg; + const msg = (data as AgentToolProgress).message + return msg != null && typeof msg === 'object' && 'type' in msg } /** @@ -52,93 +73,112 @@ function hasProgressMessage(data: Progress): data is AgentToolProgress { * For tool_result messages, uses the provided `toolUseByID` map to find the * corresponding tool_use block instead of relying on `normalizedMessages`. */ -function getSearchOrReadInfo(progressMessage: ProgressMessage, tools: Tools, toolUseByID: Map): { - isSearch: boolean; - isRead: boolean; - isREPL: boolean; -} | null { +function getSearchOrReadInfo( + progressMessage: ProgressMessage, + tools: Tools, + toolUseByID: Map, +): { isSearch: boolean; isRead: boolean; isREPL: boolean } | null { if (!hasProgressMessage(progressMessage.data)) { - return null; + return null } - const message = progressMessage.data.message; + const message = progressMessage.data.message // Check tool_use (assistant message) if (message.type === 'assistant') { - return getSearchOrReadFromContent(message.message.content[0], tools); + return getSearchOrReadFromContent(message.message.content[0], tools) } // Check tool_result (user message) - find corresponding tool use from the map if (message.type === 'user') { - const content = message.message.content[0]; + const content = message.message.content[0] if (content?.type === 'tool_result') { - const toolUse = toolUseByID.get(content.tool_use_id); + const toolUse = toolUseByID.get(content.tool_use_id) if (toolUse) { - return getSearchOrReadFromContent(toolUse, tools); + return getSearchOrReadFromContent(toolUse, tools) } } } - return null; + + return null } + type SummaryMessage = { - type: 'summary'; - searchCount: number; - readCount: number; - replCount: number; - uuid: string; - isActive: boolean; // true if still in progress (last message was tool_use, not tool_result) -}; -type ProcessedMessage = { - type: 'original'; - message: ProgressMessage; -} | SummaryMessage; + type: 'summary' + searchCount: number + readCount: number + replCount: number + uuid: string + isActive: boolean // true if still in progress (last message was tool_use, not tool_result) +} + +type ProcessedMessage = + | { type: 'original'; message: ProgressMessage } + | SummaryMessage /** * Process progress messages to group consecutive search/read operations into summaries. * For ants only - returns original messages for non-ants. * @param isAgentRunning - If true, the last group is always marked as active (in progress) */ -function processProgressMessages(messages: ProgressMessage[], tools: Tools, isAgentRunning: boolean): ProcessedMessage[] { +function processProgressMessages( + messages: ProgressMessage[], + tools: Tools, + isAgentRunning: boolean, +): ProcessedMessage[] { // Only process for ants - if ((process.env.USER_TYPE) !== 'ant') { - return messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data) && m.data.message.type !== 'user').map(m => ({ - type: 'original', - message: m - })); + if ("external" !== 'ant') { + return messages + .filter( + (m): m is ProgressMessage => + hasProgressMessage(m.data) && m.data.message.type !== 'user', + ) + .map(m => ({ type: 'original', message: m })) } - const result: ProcessedMessage[] = []; + + const result: ProcessedMessage[] = [] let currentGroup: { - searchCount: number; - readCount: number; - replCount: number; - startUuid: string; - } | null = null; + searchCount: number + readCount: number + replCount: number + startUuid: string + } | null = null + function flushGroup(isActive: boolean): void { - if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { + if ( + currentGroup && + (currentGroup.searchCount > 0 || + currentGroup.readCount > 0 || + currentGroup.replCount > 0) + ) { result.push({ type: 'summary', searchCount: currentGroup.searchCount, readCount: currentGroup.readCount, replCount: currentGroup.replCount, uuid: `summary-${currentGroup.startUuid}`, - isActive - }); + isActive, + }) } - currentGroup = null; + currentGroup = null } - const agentMessages = messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data)); + + const agentMessages = messages.filter( + (m): m is ProgressMessage => hasProgressMessage(m.data), + ) // Build tool_use lookup incrementally as we iterate - const toolUseByID = new Map(); + const toolUseByID = new Map() for (const msg of agentMessages) { // Track tool_use blocks as we see them if (msg.data.message.type === 'assistant') { for (const c of msg.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam); + toolUseByID.set(c.id, c as ToolUseBlockParam) } } } - const info = getSearchOrReadInfo(msg, tools, toolUseByID); + const info = getSearchOrReadInfo(msg, tools, toolUseByID) + if (info && (info.isSearch || info.isRead || info.isREPL)) { // This is a search/read/REPL operation - add to current group if (!currentGroup) { @@ -146,188 +186,163 @@ function processProgressMessages(messages: ProgressMessage[], tools: T searchCount: 0, readCount: 0, replCount: 0, - startUuid: msg.uuid - }; + startUuid: msg.uuid, + } } // Only count tool_result messages (not tool_use) to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - currentGroup.searchCount++; + currentGroup.searchCount++ } else if (info.isREPL) { - currentGroup.replCount++; + currentGroup.replCount++ } else if (info.isRead) { - currentGroup.readCount++; + currentGroup.readCount++ } } } else { // Non-search/read/REPL message - flush current group (completed) and add this message - flushGroup(false); + flushGroup(false) // Skip user tool_result messages — subagent progress messages lack // toolUseResult, so UserToolSuccessMessage returns null and the // height=1 Box in renderToolUseProgressMessage shows as a blank line. if (msg.data.message.type !== 'user') { - result.push({ - type: 'original', - message: msg - }); + result.push({ type: 'original', message: msg }) } } } // Flush any remaining group - it's active if the agent is still running - flushGroup(isAgentRunning); - return result; -} -const ESTIMATED_LINES_PER_TOOL = 9; -const TERMINAL_BUFFER_LINES = 7; -type Output = z.input>; -export function AgentPromptDisplay(t0) { - const $ = _c(3); - const { - prompt, - dim: t1 - } = t0; - t1 === undefined ? false : t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Prompt:; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== prompt) { - t3 = {t2}{prompt}; - $[1] = prompt; - $[2] = t3; - } else { - t3 = $[2]; - } - return t3; + flushGroup(isAgentRunning) + + return result } -export function AgentResponseDisplay(t0) { - const $ = _c(5); - const { - content - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Response:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== content) { - t2 = content.map(_temp); - $[1] = content; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t1}{t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + +const ESTIMATED_LINES_PER_TOOL = 9 +const TERMINAL_BUFFER_LINES = 7 + +type Output = z.input> + +export function AgentPromptDisplay({ + prompt, + dim: _dim = false, +}: { + prompt: string + theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally + dim?: boolean // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) +}): React.ReactNode { + return ( + + + Prompt: + + + {prompt} + + + ) } -function _temp(block, index) { - return {block.text}; + +export function AgentResponseDisplay({ + content, +}: { + content: { type: string; text: string }[] + theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally +}): React.ReactNode { + return ( + + + Response: + + {content.map((block: { type: string; text: string }, index: number) => ( + + {block.text} + + ))} + + ) } + type VerboseAgentTranscriptProps = { - progressMessages: ProgressMessage[]; - tools: Tools; - verbose: boolean; -}; -function VerboseAgentTranscript(t0) { - const $ = _c(15); - const { - progressMessages, - tools, - verbose - } = t0; - let t1; - if ($[0] !== progressMessages) { - t1 = buildSubagentLookups(progressMessages.filter(_temp2).map(_temp3)); - $[0] = progressMessages; - $[1] = t1; - } else { - t1 = $[1]; - } - const { - lookups: agentLookups, - inProgressToolUseIDs - } = t1; - let t2; - if ($[2] !== agentLookups || $[3] !== inProgressToolUseIDs || $[4] !== progressMessages || $[5] !== tools || $[6] !== verbose) { - const filteredMessages = progressMessages.filter(_temp4); - let t3; - if ($[8] !== agentLookups || $[9] !== inProgressToolUseIDs || $[10] !== tools || $[11] !== verbose) { - t3 = progressMessage => ; - $[8] = agentLookups; - $[9] = inProgressToolUseIDs; - $[10] = tools; - $[11] = verbose; - $[12] = t3; - } else { - t3 = $[12]; - } - t2 = filteredMessages.map(t3); - $[2] = agentLookups; - $[3] = inProgressToolUseIDs; - $[4] = progressMessages; - $[5] = tools; - $[6] = verbose; - $[7] = t2; - } else { - t2 = $[7]; - } - let t3; - if ($[13] !== t2) { - t3 = <>{t2}; - $[13] = t2; - $[14] = t3; - } else { - t3 = $[14]; - } - return t3; -} -function _temp4(pm_1) { - if (!hasProgressMessage(pm_1.data)) { - return false; - } - const msg = pm_1.data.message; - if (msg.type === "user" && msg.toolUseResult === undefined) { - return false; - } - return true; -} -function _temp3(pm_0) { - return pm_0.data; + progressMessages: ProgressMessage[] + tools: Tools + verbose: boolean } -function _temp2(pm) { - return hasProgressMessage(pm.data); -} -export function renderToolResultMessage(data: Output, progressMessagesForMessage: ProgressMessage[], { + +function VerboseAgentTranscript({ + progressMessages, tools, verbose, - theme, - isTranscriptMode = false -}: { - tools: Tools; - verbose: boolean; - theme: ThemeName; - isTranscriptMode?: boolean; -}): React.ReactNode { +}: VerboseAgentTranscriptProps): React.ReactNode { + const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups( + progressMessages + .filter((pm): pm is ProgressMessage => + hasProgressMessage(pm.data), + ) + .map(pm => pm.data), + ) + + // Filter out user tool_result messages that lack toolUseResult. + // Subagent progress messages don't carry the parsed tool output, + // so UserToolSuccessMessage returns null and MessageResponse renders + // a bare ⎿ with no content. + const filteredMessages = progressMessages.filter( + (pm): pm is ProgressMessage => { + if (!hasProgressMessage(pm.data)) { + return false + } + const msg = pm.data.message + if (msg.type === 'user' && msg.toolUseResult === undefined) { + return false + } + return true + }, + ) + + return ( + <> + {filteredMessages.map(progressMessage => ( + + + + ))} + + ) +} + +export function renderToolResultMessage( + data: Output, + progressMessagesForMessage: ProgressMessage[], + { + tools, + verbose, + theme, + isTranscriptMode = false, + }: { + tools: Tools + verbose: boolean + theme: ThemeName + isTranscriptMode?: boolean + }, +): React.ReactNode { // Remote-launched agents (ant-only) use a private output type not in the // public schema. Narrow via the internal discriminant. - const internal = data as Output | RemoteLaunchedOutput; + const internal = data as Output | RemoteLaunchedOutput if (internal.status === 'remote_launched') { - return + return ( + Remote agent launched{' '} @@ -336,34 +351,48 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage - ; + + ) } if (data.status === 'async_launched') { - const { - prompt - } = data; - return + const { prompt } = data + return ( + Backgrounded agent - {!isTranscriptMode && + {!isTranscriptMode && ( + {' ('} - {prompt && } + {prompt && ( + + )} {')'} - } + + )} - {isTranscriptMode && prompt && + {isTranscriptMode && prompt && ( + - } - ; + + )} + + ) } + if (data.status !== 'completed') { - return null; + return null } + const { agentId, totalDurationMs, @@ -371,501 +400,737 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage totalTokens, usage, content, - prompt - } = data; - const result = [totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs)]; - const completionMessage = `Done (${result.join(' · ')})`; + prompt, + } = data + const result = [ + totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, + formatNumber(totalTokens) + ' tokens', + formatDuration(totalDurationMs), + ] + + const completionMessage = `Done (${result.join(' · ')})` + const finalAssistantMessage = createAssistantMessage({ content: completionMessage, - usage: { - ...usage, - inference_geo: null, - iterations: null, - speed: null - } as import('@anthropic-ai/sdk/resources/beta/messages/messages.mjs').BetaUsage - }); - return - {(process.env.USER_TYPE) === 'ant' && + usage: { ...usage, inference_geo: null, iterations: null, speed: null }, + }) + + return ( + + {process.env.USER_TYPE === 'ant' && ( + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - } - {isTranscriptMode && prompt && + + )} + {isTranscriptMode && prompt && ( + - } - {isTranscriptMode ? - - : null} - {isTranscriptMode && content && content.length > 0 && + + )} + {isTranscriptMode ? ( + + + + ) : null} + {isTranscriptMode && content && content.length > 0 && ( + - } + + )} - + - {!isTranscriptMode && + {!isTranscriptMode && ( + {' '} - } - ; + + )} + + ) } + export function renderToolUseMessage({ description, - prompt + prompt, }: Partial<{ - description: string; - prompt: string; + description: string + prompt: string }>): React.ReactNode { if (!description || !prompt) { - return null; + return null } - return description; + return description } -export function renderToolUseTag(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; - model?: ModelAlias; -}>): React.ReactNode { - const tags: React.ReactNode[] = []; + +export function renderToolUseTag( + input: Partial<{ + description: string + prompt: string + subagent_type: string + model?: ModelAlias + }>, +): React.ReactNode { + const tags: React.ReactNode[] = [] + if (input.model) { - const mainModel = getMainLoopModel(); - const agentModel = parseUserSpecifiedModel(input.model); + const mainModel = getMainLoopModel() + const agentModel = parseUserSpecifiedModel(input.model) if (agentModel !== mainModel) { - tags.push( + tags.push( + {renderModelName(agentModel)} - ); + , + ) } } + if (tags.length === 0) { - return null; + return null } - return <>{tags}; + + return <>{tags} } -const INITIALIZING_TEXT = 'Initializing…'; -export function renderToolUseProgressMessage(progressMessages: ProgressMessage[], { - tools, - verbose, - terminalSize, - inProgressToolCallCount, - isTranscriptMode = false -}: { - tools: Tools; - verbose: boolean; - terminalSize?: { - columns: number; - rows: number; - }; - inProgressToolCallCount?: number; - isTranscriptMode?: boolean; -}): React.ReactNode { + +const INITIALIZING_TEXT = 'Initializing…' + +export function renderToolUseProgressMessage( + progressMessages: ProgressMessage[], + { + tools, + verbose, + terminalSize, + inProgressToolCallCount, + isTranscriptMode = false, + }: { + tools: Tools + verbose: boolean + terminalSize?: { columns: number; rows: number } + inProgressToolCallCount?: number + isTranscriptMode?: boolean + }, +): React.ReactNode { if (!progressMessages.length) { - return + return ( + {INITIALIZING_TEXT} - ; + + ) } // Checks to see if we should show a super condensed progress message summary. // This prevents flickers when the terminal size is too small to render all the dynamic content - const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; - const shouldUseCondensedMode = !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; + const toolToolRenderLinesEstimate = + (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + + TERMINAL_BUFFER_LINES + const shouldUseCondensedMode = + !isTranscriptMode && + terminalSize && + terminalSize.rows && + terminalSize.rows < toolToolRenderLinesEstimate + const getProgressStats = () => { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false; + return false } - const message = msg.data.message; - return message.message.content.some(content => content.type === 'tool_use'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; + const message = msg.data.message + return message.message.content.some( + content => content.type === 'tool_use', + ) + }) + + const latestAssistant = progressMessages.findLast( + (msg): msg is ProgressMessage => + hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', + ) + + let tokens = null if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; + const usage = latestAssistant.data.message.message.usage + tokens = + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + usage.input_tokens + + usage.output_tokens } - return { - toolUseCount, - tokens - }; - }; + + return { toolUseCount, tokens } + } + if (shouldUseCondensedMode) { - const { - toolUseCount, - tokens - } = getProgressStats(); - return + const { toolUseCount, tokens } = getProgressStats() + + return ( + In progress… · {toolUseCount} tool{' '} {toolUseCount === 1 ? 'use' : 'uses'} {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} - + - ; + + ) } // Process messages to group consecutive search/read operations into summaries (ants only) // isAgentRunning=true since this is the progress view while the agent is still running - const processedMessages = processProgressMessages(progressMessages, tools, true); + const processedMessages = processProgressMessages( + progressMessages, + tools, + true, + ) // For display, take the last few processed messages - const displayedMessages = isTranscriptMode ? processedMessages : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); + const displayedMessages = isTranscriptMode + ? processedMessages + : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW) // Count hidden tool uses specifically (not all messages) to match the // final "Done (N tool uses)" count. Each tool use generates multiple // progress messages (tool_use + tool_result + text), so counting all // hidden messages inflates the number shown to the user. - const hiddenMessages = isTranscriptMode ? [] : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); + const hiddenMessages = isTranscriptMode + ? [] + : processedMessages.slice( + 0, + Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW), + ) const hiddenToolUseCount = count(hiddenMessages, m => { if (m.type === 'summary') { - return m.searchCount + m.readCount + m.replCount > 0; + return m.searchCount + m.readCount + m.replCount > 0 } - const data = m.message.data; + const data = m.message.data if (!hasProgressMessage(data)) { - return false; + return false } - return data.message.message.content.some(content => content.type === 'tool_use'); - }); - const firstData = progressMessages[0]?.data; - const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; + return data.message.message.content.some( + content => content.type === 'tool_use', + ) + }) + + const firstData = progressMessages[0]?.data + const prompt = + firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined // After grouping, displayedMessages can be empty when the only progress so // far is an assistant tool_use for a search/read op (grouped but not yet // counted, since counts increment on tool_result). Fall back to the // initializing text so MessageResponse doesn't render a bare ⎿. if (displayedMessages.length === 0 && !(isTranscriptMode && prompt)) { - return + return ( + {INITIALIZING_TEXT} - ; + + ) } + const { lookups: subagentLookups, - inProgressToolUseIDs: collapsedInProgressIDs - } = buildSubagentLookups(progressMessages.filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)).map(pm => pm.data)); - return + inProgressToolUseIDs: collapsedInProgressIDs, + } = buildSubagentLookups( + progressMessages + .filter((pm): pm is ProgressMessage => + hasProgressMessage(pm.data), + ) + .map(pm => pm.data), + ) + + return ( + - {isTranscriptMode && prompt && + {isTranscriptMode && prompt && ( + - } + + )} {displayedMessages.map(processed => { - if (processed.type === 'summary') { - // Render summary for grouped search/read/REPL operations using shared formatting - const summaryText = getSearchReadSummaryText(processed.searchCount, processed.readCount, processed.isActive, processed.replCount); - return + if (processed.type === 'summary') { + // Render summary for grouped search/read/REPL operations using shared formatting + const summaryText = getSearchReadSummaryText( + processed.searchCount, + processed.readCount, + processed.isActive, + processed.replCount, + ) + return ( + {summaryText} - ; - } - // Render original message without height=1 wrapper so null - // content (tool not found, renderToolUseMessage returns null) - // doesn't leave a blank line. Tool call headers are single-line - // anyway so truncation isn't needed. - return ; - })} + + ) + } + // Render original message without height=1 wrapper so null + // content (tool not found, renderToolUseMessage returns null) + // doesn't leave a blank line. Tool call headers are single-line + // anyway so truncation isn't needed. + return ( + + ) + })} - {hiddenToolUseCount > 0 && + {hiddenToolUseCount > 0 && ( + +{hiddenToolUseCount} more tool{' '} {hiddenToolUseCount === 1 ? 'use' : 'uses'} - } + + )} - ; + + ) } -export function renderToolUseRejectedMessage(_input: { - description: string; - prompt: string; - subagent_type: string; -}, { - progressMessagesForMessage, - tools, - verbose, - isTranscriptMode -}: { - columns: number; - messages: Message[]; - style?: 'condensed'; - theme: ThemeName; - progressMessagesForMessage: ProgressMessage[]; - tools: Tools; - verbose: boolean; - isTranscriptMode?: boolean; -}): React.ReactNode { + +export function renderToolUseRejectedMessage( + _input: { description: string; prompt: string; subagent_type: string }, + { + progressMessagesForMessage, + tools, + verbose, + isTranscriptMode, + }: { + columns: number + messages: Message[] + style?: 'condensed' + theme: ThemeName + progressMessagesForMessage: ProgressMessage[] + tools: Tools + verbose: boolean + isTranscriptMode?: boolean + }, +): React.ReactNode { // Get agentId from progress messages if available (agent was running before rejection) - const firstData = progressMessagesForMessage[0]?.data; - const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; - return <> - {(process.env.USER_TYPE) === 'ant' && agentId && + const firstData = progressMessagesForMessage[0]?.data + const agentId = + firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined + + return ( + <> + {process.env.USER_TYPE === 'ant' && agentId && ( + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - } + + )} {renderToolUseProgressMessage(progressMessagesForMessage, { - tools, - verbose, - isTranscriptMode - })} + tools, + verbose, + isTranscriptMode, + })} - ; + + ) } -export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { - progressMessagesForMessage, - tools, - verbose, - isTranscriptMode -}: { - progressMessagesForMessage: ProgressMessage[]; - tools: Tools; - verbose: boolean; - isTranscriptMode?: boolean; -}): React.ReactNode { - return <> + +export function renderToolUseErrorMessage( + result: ToolResultBlockParam['content'], + { + progressMessagesForMessage, + tools, + verbose, + isTranscriptMode, + }: { + progressMessagesForMessage: ProgressMessage[] + tools: Tools + verbose: boolean + isTranscriptMode?: boolean + }, +): React.ReactNode { + return ( + <> {renderToolUseProgressMessage(progressMessagesForMessage, { - tools, - verbose, - isTranscriptMode - })} + tools, + verbose, + isTranscriptMode, + })} - ; + + ) } + function calculateAgentStats(progressMessages: ProgressMessage[]): { - toolUseCount: number; - tokens: number | null; + toolUseCount: number + tokens: number | null } { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false; + return false } - const message = msg.data.message; - return message.type === 'user' && message.message.content.some(content => content.type === 'tool_result'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; + const message = msg.data.message + return ( + message.type === 'user' && + message.message.content.some(content => content.type === 'tool_result') + ) + }) + + const latestAssistant = progressMessages.findLast( + (msg): msg is ProgressMessage => + hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', + ) + + let tokens = null if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; + const usage = latestAssistant.data.message.message.usage + tokens = + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + usage.input_tokens + + usage.output_tokens } - return { - toolUseCount, - tokens - }; + + return { toolUseCount, tokens } } -export function renderGroupedAgentToolUse(toolUses: Array<{ - param: ToolUseBlockParam; - isResolved: boolean; - isError: boolean; - isInProgress: boolean; - progressMessages: ProgressMessage[]; - result?: { - param: ToolResultBlockParam; - output: Output; - }; -}>, options: { - shouldAnimate: boolean; - tools: Tools; -}): React.ReactNode | null { - const { - shouldAnimate, - tools - } = options; - // Calculate stats for each agent - const agentStats = toolUses.map(({ - param, - isResolved, - isError, - progressMessages, - result - }) => { - const stats = calculateAgentStats(progressMessages); - const lastToolInfo = extractLastToolInfo(progressMessages, tools); - const parsedInput = inputSchema().safeParse(param.input); - - // teammate_spawned is not part of the exported Output type (cast through unknown - // for dead code elimination), so check via string comparison on the raw value - const isTeammateSpawn = result?.output?.status as string === 'teammate_spawned'; - - // For teammate spawns, show @name with type in parens and description as status - let agentType: string; - let description: string | undefined; - let color: keyof Theme | undefined; - let descriptionColor: keyof Theme | undefined; - let taskDescription: string | undefined; - if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { - agentType = `@${parsedInput.data.name}`; - const subagentType = parsedInput.data.subagent_type; - description = isCustomSubagentType(subagentType) ? subagentType : undefined; - taskDescription = parsedInput.data.description; - // Use the custom agent definition's color on the type, not the name - descriptionColor = isCustomSubagentType(subagentType) ? getAgentColor(subagentType) as keyof Theme | undefined : undefined; - } else { - agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; - description = parsedInput.success ? parsedInput.data.description : undefined; - color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; - taskDescription = undefined; +export function renderGroupedAgentToolUse( + toolUses: Array<{ + param: ToolUseBlockParam + isResolved: boolean + isError: boolean + isInProgress: boolean + progressMessages: ProgressMessage[] + result?: { + param: ToolResultBlockParam + output: Output } + }>, + options: { + shouldAnimate: boolean + tools: Tools + }, +): React.ReactNode | null { + const { shouldAnimate, tools } = options + + // Calculate stats for each agent + const agentStats = toolUses.map( + ({ param, isResolved, isError, progressMessages, result }) => { + const stats = calculateAgentStats(progressMessages) + const lastToolInfo = extractLastToolInfo(progressMessages, tools) + const parsedInput = inputSchema().safeParse(param.input) + + // teammate_spawned is not part of the exported Output type (cast through unknown + // for dead code elimination), so check via string comparison on the raw value + const isTeammateSpawn = + (result?.output?.status as string) === 'teammate_spawned' + + // For teammate spawns, show @name with type in parens and description as status + let agentType: string + let description: string | undefined + let color: keyof Theme | undefined + let descriptionColor: keyof Theme | undefined + let taskDescription: string | undefined + if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { + agentType = `@${parsedInput.data.name}` + const subagentType = parsedInput.data.subagent_type + description = isCustomSubagentType(subagentType) + ? subagentType + : undefined + taskDescription = parsedInput.data.description + // Use the custom agent definition's color on the type, not the name + descriptionColor = isCustomSubagentType(subagentType) + ? (getAgentColor(subagentType) as keyof Theme | undefined) + : undefined + } else { + agentType = parsedInput.success + ? userFacingName(parsedInput.data) + : 'Agent' + description = parsedInput.success + ? parsedInput.data.description + : undefined + color = parsedInput.success + ? userFacingNameBackgroundColor(parsedInput.data) + : undefined + taskDescription = undefined + } + + // Check if this was launched as a background agent OR backgrounded mid-execution + const launchedAsAsync = + parsedInput.success && + 'run_in_background' in parsedInput.data && + parsedInput.data.run_in_background === true + const outputStatus = (result?.output as { status?: string } | undefined) + ?.status + const backgroundedMidExecution = + outputStatus === 'async_launched' || outputStatus === 'remote_launched' + const isAsync = + launchedAsAsync || backgroundedMidExecution || isTeammateSpawn - // Check if this was launched as a background agent OR backgrounded mid-execution - const launchedAsAsync = parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; - const outputStatus = (result?.output as { - status?: string; - } | undefined)?.status; - const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; - const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; - const name = parsedInput.success ? parsedInput.data.name : undefined; - return { - id: param.id, - agentType, - description, - toolUseCount: stats.toolUseCount, - tokens: stats.tokens, - isResolved, - isError, - isAsync, - color, - descriptionColor, - lastToolInfo, - taskDescription, - name - }; - }); - const anyUnresolved = toolUses.some(t => !t.isResolved); - const anyError = toolUses.some(t => t.isError); - const allComplete = !anyUnresolved; + const name = parsedInput.success ? parsedInput.data.name : undefined + + return { + id: param.id, + agentType, + description, + toolUseCount: stats.toolUseCount, + tokens: stats.tokens, + isResolved, + isError, + isAsync, + color, + descriptionColor, + lastToolInfo, + taskDescription, + name, + } + }, + ) + + const anyUnresolved = toolUses.some(t => !t.isResolved) + const anyError = toolUses.some(t => t.isError) + const allComplete = !anyUnresolved // Check if all agents are the same type - const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); - const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; + const allSameType = + agentStats.length > 0 && + agentStats.every(stat => stat.agentType === agentStats[0]?.agentType) + const commonType = + allSameType && agentStats[0]?.agentType !== 'Agent' + ? agentStats[0]?.agentType + : null // Check if all resolved agents are async (background) - const allAsync = agentStats.every(stat => stat.isAsync); - return + const allAsync = agentStats.every(stat => stat.isAsync) + + return ( + - + - {allComplete ? allAsync ? <> + {allComplete ? ( + allAsync ? ( + <> {toolUses.length} background agents launched{' '} - : <> + + ) : ( + <> {toolUses.length}{' '} {commonType ? `${commonType} agents` : 'agents'} finished - : <> + + ) + ) : ( + <> Running {toolUses.length}{' '} {commonType ? `${commonType} agents` : 'agents'}… - }{' '} + + )}{' '} {!allAsync && } - {agentStats.map((stat, index) => )} - ; + {agentStats.map((stat, index) => ( + + ))} + + ) } -export function userFacingName(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; - name: string; - team_name: string; -}> | undefined): string { - if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { + +export function userFacingName( + input: + | Partial<{ + description: string + prompt: string + subagent_type: string + name: string + team_name: string + }> + | undefined, +): string { + if ( + input?.subagent_type && + input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType + ) { // Display "worker" agents as "Agent" for cleaner UI if (input.subagent_type === 'worker') { - return 'Agent'; + return 'Agent' } - return input.subagent_type; + return input.subagent_type } - return 'Agent'; + return 'Agent' } -export function userFacingNameBackgroundColor(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; -}> | undefined): keyof Theme | undefined { + +export function userFacingNameBackgroundColor( + input: + | Partial<{ description: string; prompt: string; subagent_type: string }> + | undefined, +): keyof Theme | undefined { if (!input?.subagent_type) { - return undefined; + return undefined } // Get the color for this agent - return getAgentColor(input.subagent_type) as keyof Theme | undefined; + return getAgentColor(input.subagent_type) as keyof Theme | undefined } -export function extractLastToolInfo(progressMessages: ProgressMessage[], tools: Tools): string | null { + +export function extractLastToolInfo( + progressMessages: ProgressMessage[], + tools: Tools, +): string | null { // Build tool_use lookup from all progress messages (needed for reverse iteration) - const toolUseByID = new Map(); + const toolUseByID = new Map() for (const pm of progressMessages) { if (!hasProgressMessage(pm.data)) { - continue; + continue } if (pm.data.message.type === 'assistant') { for (const c of pm.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam); + toolUseByID.set(c.id, c as ToolUseBlockParam) } } } } // Count trailing consecutive search/read operations from the end - let searchCount = 0; - let readCount = 0; + let searchCount = 0 + let readCount = 0 for (let i = progressMessages.length - 1; i >= 0; i--) { - const msg = progressMessages[i]!; + const msg = progressMessages[i]! if (!hasProgressMessage(msg.data)) { - continue; + continue } - const info = getSearchOrReadInfo(msg, tools, toolUseByID); + const info = getSearchOrReadInfo(msg, tools, toolUseByID) if (info && (info.isSearch || info.isRead)) { // Only count tool_result messages to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - searchCount++; + searchCount++ } else if (info.isRead) { - readCount++; + readCount++ } } } else { - break; + break } } + if (searchCount + readCount >= 2) { - return getSearchReadSummaryText(searchCount, readCount, true); + return getSearchReadSummaryText(searchCount, readCount, true) } // Find the last tool_result message - const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage => { - if (!hasProgressMessage(msg.data)) { - return false; - } - const message = msg.data.message; - return message.type === 'user' && message.message.content.some(c => c.type === 'tool_result'); - }); + const lastToolResult = progressMessages.findLast( + (msg): msg is ProgressMessage => { + if (!hasProgressMessage(msg.data)) { + return false + } + const message = msg.data.message + return ( + message.type === 'user' && + message.message.content.some(c => c.type === 'tool_result') + ) + }, + ) + if (lastToolResult?.data.message.type === 'user') { - const toolResultBlock = lastToolResult.data.message.message.content.find(c => c.type === 'tool_result'); + const toolResultBlock = lastToolResult.data.message.message.content.find( + c => c.type === 'tool_result', + ) + if (toolResultBlock?.type === 'tool_result') { // Look up the corresponding tool_use — already indexed above - const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); + const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id) + if (toolUseBlock) { - const tool = findToolByName(tools, toolUseBlock.name); + const tool = findToolByName(tools, toolUseBlock.name) if (!tool) { - return toolUseBlock.name; // Fallback to raw name + return toolUseBlock.name // Fallback to raw name } - const input = toolUseBlock.input as Record; - const parsedInput = tool.inputSchema.safeParse(input); + + const input = toolUseBlock.input as Record + const parsedInput = tool.inputSchema.safeParse(input) // Get user-facing tool name - const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); + const userFacingToolName = tool.userFacingName( + parsedInput.success ? parsedInput.data : undefined, + ) // Try to get summary from the tool itself if (tool.getToolUseSummary) { - const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); + const summary = tool.getToolUseSummary( + parsedInput.success ? parsedInput.data : undefined, + ) if (summary) { - return `${userFacingToolName}: ${summary}`; + return `${userFacingToolName}: ${summary}` } } // Default: just show user-facing tool name - return userFacingToolName; + return userFacingToolName } } } - return null; + + return null } -function isCustomSubagentType(subagentType: string | undefined): subagentType is string { - return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; + +function isCustomSubagentType( + subagentType: string | undefined, +): subagentType is string { + return ( + !!subagentType && + subagentType !== GENERAL_PURPOSE_AGENT.agentType && + subagentType !== 'worker' + ) } diff --git a/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx index 64da4dcc4..e71a5c665 100644 --- a/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +++ b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx @@ -1,136 +1,226 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { getAllowedChannels, getQuestionPreviewFormat } from 'src/bootstrap/state.js'; -import { MessageResponse } from 'src/components/MessageResponse.js'; -import { BLACK_CIRCLE } from 'src/constants/figures.js'; -import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; -import { z } from 'zod/v4'; -import { Box, Text } from '../../ink.js'; -import type { Tool } from '../../Tool.js'; -import { buildTool, type ToolDef } from '../../Tool.js'; -import { lazySchema } from '../../utils/lazySchema.js'; -import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT } from './prompt.js'; -const questionOptionSchema = lazySchema(() => z.object({ - label: z.string().describe('The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.'), - description: z.string().describe('Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.'), - preview: z.string().optional().describe('Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.') -})); -const questionSchema = lazySchema(() => z.object({ - question: z.string().describe('The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'), - header: z.string().describe(`Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`), - options: z.array(questionOptionSchema()).min(2).max(4).describe(`The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`), - multiSelect: z.boolean().default(false).describe('Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.') -})); +import { feature } from 'bun:bundle' +import * as React from 'react' +import { + getAllowedChannels, + getQuestionPreviewFormat, +} from 'src/bootstrap/state.js' +import { MessageResponse } from 'src/components/MessageResponse.js' +import { BLACK_CIRCLE } from 'src/constants/figures.js' +import { getModeColor } from 'src/utils/permissions/PermissionMode.js' +import { z } from 'zod/v4' +import { Box, Text } from '../../ink.js' +import type { Tool } from '../../Tool.js' +import { buildTool, type ToolDef } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { + ASK_USER_QUESTION_TOOL_CHIP_WIDTH, + ASK_USER_QUESTION_TOOL_NAME, + ASK_USER_QUESTION_TOOL_PROMPT, + DESCRIPTION, + PREVIEW_FEATURE_PROMPT, +} from './prompt.js' + +const questionOptionSchema = lazySchema(() => + z.object({ + label: z + .string() + .describe( + 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.', + ), + description: z + .string() + .describe( + 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.', + ), + preview: z + .string() + .optional() + .describe( + 'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.', + ), + }), +) + +const questionSchema = lazySchema(() => + z.object({ + question: z + .string() + .describe( + 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"', + ), + header: z + .string() + .describe( + `Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`, + ), + options: z + .array(questionOptionSchema()) + .min(2) + .max(4) + .describe( + `The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`, + ), + multiSelect: z + .boolean() + .default(false) + .describe( + 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', + ), + }), +) + const annotationsSchema = lazySchema(() => { const annotationSchema = z.object({ - preview: z.string().optional().describe('The preview content of the selected option, if the question used previews.'), - notes: z.string().optional().describe('Free-text notes the user added to their selection.') - }); - return z.record(z.string(), annotationSchema).optional().describe('Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.'); -}); + preview: z + .string() + .optional() + .describe( + 'The preview content of the selected option, if the question used previews.', + ), + notes: z + .string() + .optional() + .describe('Free-text notes the user added to their selection.'), + }) + + return z + .record(z.string(), annotationSchema) + .optional() + .describe( + 'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.', + ) +}) + const UNIQUENESS_REFINE = { check: (data: { - questions: { - question: string; - options: { - label: string; - }[]; - }[]; + questions: { question: string; options: { label: string }[] }[] }) => { - const questions = data.questions.map(q => q.question); + const questions = data.questions.map(q => q.question) if (questions.length !== new Set(questions).size) { - return false; + return false } for (const question of data.questions) { - const labels = question.options.map(opt => opt.label); + const labels = question.options.map(opt => opt.label) if (labels.length !== new Set(labels).size) { - return false; + return false } } - return true; + return true }, - message: 'Question texts must be unique, option labels must be unique within each question' -} as const; + message: + 'Question texts must be unique, option labels must be unique within each question', +} as const + const commonFields = lazySchema(() => ({ - answers: z.record(z.string(), z.string()).optional().describe('User answers collected by the permission component'), + answers: z + .record(z.string(), z.string()) + .optional() + .describe('User answers collected by the permission component'), annotations: annotationsSchema(), - metadata: z.object({ - source: z.string().optional().describe('Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.') - }).optional().describe('Optional metadata for tracking and analytics purposes. Not displayed to user.') -})); -const inputSchema = lazySchema(() => z.strictObject({ - questions: z.array(questionSchema()).min(1).max(4).describe('Questions to ask the user (1-4 questions)'), - ...commonFields() -}).refine(UNIQUENESS_REFINE.check, { - message: UNIQUENESS_REFINE.message -})); -type InputSchema = ReturnType; -const outputSchema = lazySchema(() => z.object({ - questions: z.array(questionSchema()).describe('The questions that were asked'), - answers: z.record(z.string(), z.string()).describe('The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)'), - annotations: annotationsSchema() -})); -type OutputSchema = ReturnType; + metadata: z + .object({ + source: z + .string() + .optional() + .describe( + 'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.', + ), + }) + .optional() + .describe( + 'Optional metadata for tracking and analytics purposes. Not displayed to user.', + ), +})) + +const inputSchema = lazySchema(() => + z + .strictObject({ + questions: z + .array(questionSchema()) + .min(1) + .max(4) + .describe('Questions to ask the user (1-4 questions)'), + ...commonFields(), + }) + .refine(UNIQUENESS_REFINE.check, { + message: UNIQUENESS_REFINE.message, + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + questions: z + .array(questionSchema()) + .describe('The questions that were asked'), + answers: z + .record(z.string(), z.string()) + .describe( + 'The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)', + ), + annotations: annotationsSchema(), + }), +) +type OutputSchema = ReturnType // SDK schemas are identical to internal schemas now that `preview` and // `annotations` are public (configurable via `toolConfig.askUserQuestion`). -export const _sdkInputSchema = inputSchema; -export const _sdkOutputSchema = outputSchema; -export type Question = z.infer>; -export type QuestionOption = z.infer>; -export type Output = z.infer; -function AskUserQuestionResultMessage(t0) { - const $ = _c(3); - const { - answers - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {BLACK_CIRCLE} User answered Claude's questions:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== answers) { - t2 = {t1}{Object.entries(answers).map(_temp)}; - $[1] = answers; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; -} -function _temp(t0) { - const [questionText, answer] = t0; - return · {questionText} → {answer}; +export const _sdkInputSchema = inputSchema +export const _sdkOutputSchema = outputSchema + +export type Question = z.infer> +export type QuestionOption = z.infer> +export type Output = z.infer + +function AskUserQuestionResultMessage({ + answers, +}: { + answers: Output['answers'] +}): React.ReactNode { + return ( + + + {BLACK_CIRCLE}  + User answered Claude's questions: + + + + {Object.entries(answers).map(([questionText, answer]) => ( + + · {questionText} → {answer} + + ))} + + + + ) } + export const AskUserQuestionTool: Tool = buildTool({ name: ASK_USER_QUESTION_TOOL_NAME, searchHint: 'prompt the user with a multiple-choice question', maxResultSizeChars: 100_000, shouldDefer: true, async description() { - return DESCRIPTION; + return DESCRIPTION }, async prompt() { - const format = getQuestionPreviewFormat(); + const format = getQuestionPreviewFormat() if (format === undefined) { // SDK consumer that hasn't opted into a preview format — omit preview // guidance (they may not render the field at all). - return ASK_USER_QUESTION_TOOL_PROMPT; + return ASK_USER_QUESTION_TOOL_PROMPT } - return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]; + return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format] }, get inputSchema(): InputSchema { - return inputSchema(); + return inputSchema() }, get outputSchema(): OutputSchema { - return outputSchema(); + return outputSchema() }, userFacingName() { - return ''; + return '' }, isEnabled() { // When --channels is active the user is likely on Telegram/Discord, not @@ -138,128 +228,115 @@ export const AskUserQuestionTool: Tool = buildTool({ // the keyboard. Channel permission relay already skips // requiresUserInteraction() tools (interactiveHandler.ts) so there's // no alternate approval path. - if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) { - return false; + if ( + (feature('KAIROS') || feature('KAIROS_CHANNELS')) && + getAllowedChannels().length > 0 + ) { + return false } - return true; + return true }, isConcurrencySafe() { - return true; + return true }, isReadOnly() { - return true; + return true }, toAutoClassifierInput(input) { - return input.questions.map(q => q.question).join(' | '); + return input.questions.map(q => q.question).join(' | ') }, requiresUserInteraction() { - return true; + return true }, - async validateInput({ - questions - }) { + async validateInput({ questions }) { if (getQuestionPreviewFormat() !== 'html') { - return { - result: true - }; + return { result: true } } for (const q of questions) { for (const opt of q.options) { - const err = validateHtmlPreview(opt.preview); + const err = validateHtmlPreview(opt.preview) if (err) { return { result: false, message: `Option "${opt.label}" in question "${q.question}": ${err}`, - errorCode: 1 - }; + errorCode: 1, + } } } } - return { - result: true - }; + return { result: true } }, async checkPermissions(input) { return { behavior: 'ask' as const, message: 'Answer questions?', - updatedInput: input - }; + updatedInput: input, + } }, renderToolUseMessage() { - return null; + return null }, renderToolUseProgressMessage() { - return null; + return null }, - renderToolResultMessage({ - answers - }, _toolUseID) { - return ; + renderToolResultMessage({ answers }, _toolUseID) { + return }, renderToolUseRejectedMessage() { - return + return ( + {BLACK_CIRCLE}  User declined to answer questions - ; + + ) }, renderToolUseErrorMessage() { - return null; + return null }, - async call({ - questions, - answers = {}, - annotations - }, _context) { + async call({ questions, answers = {}, annotations }, _context) { return { - data: { - questions, - answers, - ...(annotations && { - annotations - }) - } - }; + data: { questions, answers, ...(annotations && { annotations }) }, + } }, - mapToolResultToToolResultBlockParam({ - answers, - annotations - }, toolUseID) { - const answersText = Object.entries(answers).map(([questionText, answer]) => { - const annotation = annotations?.[questionText]; - const parts = [`"${questionText}"="${answer}"`]; - if (annotation?.preview) { - parts.push(`selected preview:\n${annotation.preview}`); - } - if (annotation?.notes) { - parts.push(`user notes: ${annotation.notes}`); - } - return parts.join(' '); - }).join(', '); + mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) { + const answersText = Object.entries(answers) + .map(([questionText, answer]) => { + const annotation = annotations?.[questionText] + const parts = [`"${questionText}"="${answer}"`] + if (annotation?.preview) { + parts.push(`selected preview:\n${annotation.preview}`) + } + if (annotation?.notes) { + parts.push(`user notes: ${annotation.notes}`) + } + return parts.join(' ') + }) + .join(', ') + return { type: 'tool_result', content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`, - tool_use_id: toolUseID - }; - } -} satisfies ToolDef); + tool_use_id: toolUseID, + } + }, +} satisfies ToolDef) // Lightweight HTML fragment check. Not a parser — HTML5 parsers are // error-recovering by spec and accept anything. We're checking model intent // (did it emit HTML?) and catching the specific things we told it not to do. function validateHtmlPreview(preview: string | undefined): string | null { - if (preview === undefined) return null; + if (preview === undefined) return null if (/<\s*(html|body|!doctype)\b/i.test(preview)) { - return 'preview must be an HTML fragment, not a full document (no , , or )'; + return 'preview must be an HTML fragment, not a full document (no , , or )' } // SDK consumers typically set this via innerHTML — disallow executable/style // tags so a preview can't run code or restyle the host page. Inline event // handlers (onclick etc.) are still possible; consumers should sanitize. if (/<\s*(script|style)\b/i.test(preview)) { - return 'preview must not contain