style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-04 21:56:11 +08:00
parent 02694918b5
commit db1f531691
43 changed files with 12311 additions and 9559 deletions

View File

@@ -1,162 +1,114 @@
import { c as _c } from "react/compiler-runtime"; import { feature } from 'bun:bundle'
import { feature } from 'bun:bundle'; import figures from 'figures'
import figures from 'figures'; import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'; import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { stringWidth } from '../ink/stringWidth.js'
import { stringWidth } from '../ink/stringWidth.js'; import { Box, Text } from '../ink.js'
import { Box, Text } from '../ink.js'; import { useAppState, useSetAppState } from '../state/AppState.js'
import { useAppState, useSetAppState } from '../state/AppState.js'; import type { AppState } from '../state/AppStateStore.js'
import type { AppState } from '../state/AppStateStore.js'; import { getGlobalConfig } from '../utils/config.js'
import { getGlobalConfig } from '../utils/config.js'; import { isFullscreenActive } from '../utils/fullscreen.js'
import { isFullscreenActive } from '../utils/fullscreen.js'; import type { Theme } from '../utils/theme.js'
import type { Theme } from '../utils/theme.js'; import { getCompanion } from './companion.js'
import { getCompanion } from './companion.js'; import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'; import { RARITY_COLORS } from './types.js'
import { RARITY_COLORS } from './types.js';
const TICK_MS = 500; const TICK_MS = 500
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms 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 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 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. // 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". // 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. // Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
const H = figures.heart; const H = figures.heart
const PET_HEARTS = [` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · ']; const PET_HEARTS = [
` ${H} ${H} `,
` ${H} ${H} ${H} `,
` ${H} ${H} ${H} `,
`${H} ${H} ${H} `,
'· · · ',
]
function wrap(text: string, width: number): string[] { function wrap(text: string, width: number): string[] {
const words = text.split(' '); const words = text.split(' ')
const lines: string[] = []; const lines: string[] = []
let cur = ''; let cur = ''
for (const w of words) { for (const w of words) {
if (cur.length + w.length + 1 > width && cur) { if (cur.length + w.length + 1 > width && cur) {
lines.push(cur); lines.push(cur)
cur = w; cur = w
} else { } else {
cur = cur ? `${cur} ${w}` : w; cur = cur ? `${cur} ${w}` : w
} }
} }
if (cur) lines.push(cur); if (cur) lines.push(cur)
return lines; return lines
} }
function SpeechBubble(t0) {
const $ = _c(31); function SpeechBubble({
const { text,
text, color,
color, fading,
fading, tail,
tail }: {
} = t0; text: string
let T0; color: keyof Theme
let borderColor; fading: boolean
let t1; tail: 'down' | 'right'
let t2; }): React.ReactNode {
let t3; const lines = wrap(text, 30)
let t4; const borderColor = fading ? 'inactive' : color
let t5; const bubble = (
let t6; <Box
if ($[0] !== color || $[1] !== fading || $[2] !== text) { flexDirection="column"
const lines = wrap(text, 30); borderStyle="round"
borderColor = fading ? "inactive" : color; borderColor={borderColor}
T0 = Box; paddingX={1}
t1 = "column"; width={34}
t2 = "round"; >
t3 = borderColor; {lines.map((l, i) => (
t4 = 1; <Text
t5 = 34; key={i}
let t7; italic
if ($[11] !== fading) { dimColor={!fading}
t7 = (l, i) => <Text key={i} italic={true} dimColor={!fading} color={fading ? "inactive" : undefined}>{l}</Text>; color={fading ? 'inactive' : undefined}
$[11] = fading; >
$[12] = t7; {l}
} else { </Text>
t7 = $[12]; ))}
} </Box>
t6 = lines.map(t7); )
$[0] = color; if (tail === 'right') {
$[1] = fading; return (
$[2] = text; <Box flexDirection="row" alignItems="center">
$[3] = T0; {bubble}
$[4] = borderColor; <Text color={borderColor}></Text>
$[5] = t1; </Box>
$[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; return (
if ($[13] !== T0 || $[14] !== t1 || $[15] !== t2 || $[16] !== t3 || $[17] !== t4 || $[18] !== t5 || $[19] !== t6) { <Box flexDirection="column" alignItems="flex-end" marginRight={1}>
t7 = <T0 flexDirection={t1} borderStyle={t2} borderColor={t3} paddingX={t4} width={t5}>{t6}</T0>; {bubble}
$[13] = T0; <Box flexDirection="column" alignItems="flex-end" paddingRight={6}>
$[14] = t1; <Text color={borderColor}> </Text>
$[15] = t2; <Text color={borderColor}></Text>
$[16] = t3; </Box>
$[17] = t4; </Box>
$[18] = t5; )
$[19] = t6;
$[20] = t7;
} else {
t7 = $[20];
}
const bubble = t7;
if (tail === "right") {
let t8;
if ($[21] !== borderColor) {
t8 = <Text color={borderColor}></Text>;
$[21] = borderColor;
$[22] = t8;
} else {
t8 = $[22];
}
let t9;
if ($[23] !== bubble || $[24] !== t8) {
t9 = <Box flexDirection="row" alignItems="center">{bubble}{t8}</Box>;
$[23] = bubble;
$[24] = t8;
$[25] = t9;
} else {
t9 = $[25];
}
return t9;
}
let t8;
if ($[26] !== borderColor) {
t8 = <Box flexDirection="column" alignItems="flex-end" paddingRight={6}><Text color={borderColor}> </Text><Text color={borderColor}></Text></Box>;
$[26] = borderColor;
$[27] = t8;
} else {
t8 = $[27];
}
let t9;
if ($[28] !== bubble || $[29] !== t8) {
t9 = <Box flexDirection="column" alignItems="flex-end" marginRight={1}>{bubble}{t8}</Box>;
$[28] = bubble;
$[29] = t8;
$[30] = t9;
} else {
t9 = $[30];
}
return t9;
} }
export const MIN_COLS_FOR_FULL_SPRITE = 100;
const SPRITE_BODY_WIDTH = 12; export const MIN_COLS_FOR_FULL_SPRITE = 100
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name ` const SPRITE_BODY_WIDTH = 12
const SPRITE_PADDING_X = 2; const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column const SPRITE_PADDING_X = 2
const NARROW_QUIP_CAP = 24; const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
const NARROW_QUIP_CAP = 24
function spriteColWidth(nameWidth: number): number { 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 // 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. // 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 // Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
// (above input in fullscreen, below in scrollback), so no reservation. // (above input in fullscreen, below in scrollback), so no reservation.
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { export function companionReservedColumns(
if (!feature('BUDDY')) return 0; terminalColumns: number,
const companion = getCompanion(); speaking: boolean,
if (!companion || getGlobalConfig().companionMuted) return 0; ): number {
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; if (!feature('BUDDY')) return 0
const nameWidth = stringWidth(companion.name); const companion = getCompanion()
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; if (!companion || getGlobalConfig().companionMuted) return 0
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; 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 { export function CompanionSprite(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction); const reaction = useAppState(s => s.companionReaction)
const petAt = useAppState(s => s.companionPetAt); const petAt = useAppState(s => s.companionPetAt)
const focused = useAppState(s => s.footerSelection === 'companion'); const focused = useAppState(s => s.footerSelection === 'companion')
const setAppState = useSetAppState(); const setAppState = useSetAppState()
const { const { columns } = useTerminalSize()
columns const [tick, setTick] = useState(0)
} = useTerminalSize(); const lastSpokeTick = useRef(0)
const [tick, setTick] = useState(0);
const lastSpokeTick = useRef(0);
// Sync-during-render (not useEffect) so the first post-pet render already // Sync-during-render (not useEffect) so the first post-pet render already
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped. // has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
const [{ const [{ petStartTick, forPetAt }, setPetStart] = useState({
petStartTick,
forPetAt
}, setPetStart] = useState({
petStartTick: 0, petStartTick: 0,
forPetAt: petAt forPetAt: petAt,
}); })
if (petAt !== forPetAt) { if (petAt !== forPetAt) {
setPetStart({ setPetStart({ petStartTick: tick, forPetAt: petAt })
petStartTick: tick,
forPetAt: petAt
});
} }
useEffect(() => { useEffect(() => {
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick); const timer = setInterval(
return () => clearInterval(timer); setT => setT((t: number) => t + 1),
}, []); TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [])
useEffect(() => { useEffect(() => {
if (!reaction) return; if (!reaction) return
lastSpokeTick.current = tick; lastSpokeTick.current = tick
const timer = setTimeout(setA => setA((prev: AppState) => prev.companionReaction === undefined ? prev : { const timer = setTimeout(
...prev, setA =>
companionReaction: undefined setA((prev: AppState) =>
}), BUBBLE_SHOW * TICK_MS, setAppState); prev.companionReaction === undefined
return () => clearTimeout(timer); ? 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 // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
}, [reaction, setAppState]); }, [reaction, setAppState])
if (!feature('BUDDY')) return null;
const companion = getCompanion(); if (!feature('BUDDY')) return null
if (!companion || getGlobalConfig().companionMuted) return null; const companion = getCompanion()
const color = RARITY_COLORS[companion.rarity]; if (!companion || getGlobalConfig().companionMuted) return null
const colWidth = spriteColWidth(stringWidth(companion.name));
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; const color = RARITY_COLORS[companion.rarity]
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; const colWidth = spriteColWidth(stringWidth(companion.name))
const petAge = petAt ? tick - petStartTick : Infinity;
const petting = petAge * TICK_MS < PET_BURST_MS; 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 // Narrow terminals: collapse to one-line face. When speaking, the quip
// replaces the name beside the face (no room for a bubble). // replaces the name beside the face (no room for a bubble).
if (columns < MIN_COLS_FOR_FULL_SPRITE) { if (columns < MIN_COLS_FOR_FULL_SPRITE) {
const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; const quip =
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name; reaction && reaction.length > NARROW_QUIP_CAP
return <Box paddingX={1} alignSelf="flex-end"> ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
: reaction
const label = quip
? `"${quip}"`
: focused
? ` ${companion.name} `
: companion.name
return (
<Box paddingX={1} alignSelf="flex-end">
<Text> <Text>
{petting && <Text color="autoAccept">{figures.heart} </Text>} {petting && <Text color="autoAccept">{figures.heart} </Text>}
<Text bold color={color}> <Text bold color={color}>
{renderFace(companion)} {renderFace(companion)}
</Text>{' '} </Text>{' '}
<Text italic dimColor={!focused && !reaction} bold={focused} inverse={focused && !reaction} color={reaction ? fading ? 'inactive' : color : focused ? color : undefined}> <Text
italic
dimColor={!focused && !reaction}
bold={focused}
inverse={focused && !reaction}
color={
reaction
? fading
? 'inactive'
: color
: focused
? color
: undefined
}
>
{label} {label}
</Text> </Text>
</Text> </Text>
</Box>; </Box>
)
} }
const frameCount = spriteFrameCount(companion.species); const frameCount = spriteFrameCount(companion.species)
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
let spriteFrame: number;
let blink = false; let spriteFrame: number
let blink = false
if (reaction || petting) { if (reaction || petting) {
// Excited: cycle all fidget frames fast // Excited: cycle all fidget frames fast
spriteFrame = tick % frameCount; spriteFrame = tick % frameCount
} else { } else {
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!; const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
if (step === -1) { if (step === -1) {
spriteFrame = 0; spriteFrame = 0
blink = true; blink = true
} else { } 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, // Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
// focused shows inverse name. The enter-to-open hint lives in // focused shows inverse name. The enter-to-open hint lives in
// PromptInputFooter's right column so this row stays one line and the // PromptInputFooter's right column so this row stays one line and the
// sprite doesn't jump up when selected. flexShrink=0 stops the // sprite doesn't jump up when selected. flexShrink=0 stops the
// inline-bubble row wrapper from squeezing the sprite to fit. // inline-bubble row wrapper from squeezing the sprite to fit.
const spriteColumn = <Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}> const spriteColumn = (
{sprite.map((line, i) => <Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}> <Box
flexDirection="column"
flexShrink={0}
alignItems="center"
width={colWidth}
>
{sprite.map((line, i) => (
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
{line} {line}
</Text>)} </Text>
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}> ))}
<Text
italic
bold={focused}
dimColor={!focused}
color={focused ? color : undefined}
inverse={focused}
>
{focused ? ` ${companion.name} ` : companion.name} {focused ? ` ${companion.name} ` : companion.name}
</Text> </Text>
</Box>; </Box>
)
if (!reaction) { if (!reaction) {
return <Box paddingX={1}>{spriteColumn}</Box>; return <Box paddingX={1}>{spriteColumn}</Box>
} }
// Fullscreen: bubble renders separately via CompanionFloatingBubble in // 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) // Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
// because floating into Static scrollback can't be cleared. // because floating into Static scrollback can't be cleared.
if (isFullscreenActive()) { if (isFullscreenActive()) {
return <Box paddingX={1}>{spriteColumn}</Box>; return <Box paddingX={1}>{spriteColumn}</Box>
} }
return <Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}> return (
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" /> <Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
<SpeechBubble
text={reaction}
color={color}
fading={fading}
tail="right"
/>
{spriteColumn} {spriteColumn}
</Box>; </Box>
)
} }
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's // Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
// bottomFloat slot (outside the overflowY:hidden clip) so it can extend into // bottomFloat slot (outside the overflowY:hidden clip) so it can extend into
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this // the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
// just reads companionReaction and renders the fade. // just reads companionReaction and renders the fade.
export function CompanionFloatingBubble() { export function CompanionFloatingBubble(): React.ReactNode {
const $ = _c(8); const reaction = useAppState(s => s.companionReaction)
const reaction = useAppState(_temp); const [{ tick, forReaction }, setTick] = useState({
let t0; tick: 0,
if ($[0] !== reaction) { forReaction: reaction,
t0 = { })
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
$[0] = reaction; // reaction the tick is counting FOR alongside the tick itself means the
$[1] = t0; // fade computation never sees a tick from a previous reaction.
} else {
t0 = $[1];
}
const [t1, setTick] = useState(t0);
const {
tick,
forReaction
} = t1;
if (reaction !== forReaction) { if (reaction !== forReaction) {
setTick({ setTick({ tick: 0, forReaction: reaction })
tick: 0,
forReaction: reaction
});
} }
let t2;
let t3; useEffect(() => {
if ($[2] !== reaction) { if (!reaction) return
t2 = () => { const timer = setInterval(
if (!reaction) { set => set(s => ({ ...s, tick: s.tick + 1 })),
return; TICK_MS,
} setTick,
const timer = setInterval(_temp3, TICK_MS, setTick); )
return () => clearInterval(timer); return () => clearInterval(timer)
}; }, [reaction])
t3 = [reaction];
$[2] = reaction; if (!feature('BUDDY') || !reaction) return null
$[3] = t2; const companion = getCompanion()
$[4] = t3; if (!companion || getGlobalConfig().companionMuted) return null
} else {
t2 = $[3]; return (
t3 = $[4]; <SpeechBubble
} text={reaction}
useEffect(t2, t3); color={RARITY_COLORS[companion.rarity]}
if (!feature("BUDDY") || !reaction) { fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
return null; tail="down"
} />
const companion = getCompanion(); )
if (!companion || getGlobalConfig().companionMuted) {
return null;
}
const t4 = tick >= BUBBLE_SHOW - FADE_WINDOW;
let t5;
if ($[5] !== reaction || $[6] !== t4) {
t5 = <SpeechBubble text={reaction} color={RARITY_COLORS[companion.rarity]} fading={t4} tail="down" />;
$[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;
} }

View File

@@ -1,97 +1,67 @@
import { c as _c } from "react/compiler-runtime"; import { feature } from 'bun:bundle'
import { feature } from 'bun:bundle'; import React, { useEffect } from 'react'
import React, { useEffect } from 'react'; import { useNotifications } from '../context/notifications.js'
import { useNotifications } from '../context/notifications.js'; import { Text } from '../ink.js'
import { Text } from '../ink.js'; import { getGlobalConfig } from '../utils/config.js'
import { getGlobalConfig } from '../utils/config.js'; import { getRainbowColor } from '../utils/thinking.js'
import { getRainbowColor } from '../utils/thinking.js';
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter // Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load. // 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. // Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean { export function isBuddyTeaserWindow(): boolean {
if ((process.env.USER_TYPE) === 'ant') return true; if (process.env.USER_TYPE === 'ant') return true
const d = new Date(); const d = new Date()
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
} }
export function isBuddyLive(): boolean { export function isBuddyLive(): boolean {
if ((process.env.USER_TYPE) === 'ant') return true; if (process.env.USER_TYPE === 'ant') return true
const d = new Date(); const d = new Date()
return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3; return (
d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)
)
} }
function RainbowText(t0) {
const $ = _c(2); function RainbowText({ text }: { text: string }): React.ReactNode {
const { return (
text <>
} = t0; {[...text].map((ch, i) => (
let t1; <Text key={i} color={getRainbowColor(i)}>
if ($[0] !== text) { {ch}
t1 = <>{[...text].map(_temp)}</>; </Text>
$[0] = text; ))}
$[1] = t1; </>
} else { )
t1 = $[1];
}
return t1;
} }
// Rainbow /buddy teaser shown on startup when no companion hatched yet. // Rainbow /buddy teaser shown on startup when no companion hatched yet.
// Idle presence and reactions are handled by CompanionSprite directly. // Idle presence and reactions are handled by CompanionSprite directly.
function _temp(ch, i) { export function useBuddyNotification(): void {
return <Text key={i} color={getRainbowColor(i)}>{ch}</Text>; const { addNotification, removeNotification } = useNotifications()
useEffect(() => {
if (!feature('BUDDY')) return
const config = getGlobalConfig()
if (config.companion || !isBuddyTeaserWindow()) return
addNotification({
key: 'buddy-teaser',
jsx: <RainbowText text="/buddy" />,
priority: 'immediate',
timeoutMs: 15_000,
})
return () => removeNotification('buddy-teaser')
}, [addNotification, removeNotification])
} }
export function useBuddyNotification() {
const $ = _c(4); export function findBuddyTriggerPositions(
const { text: string,
addNotification, ): Array<{ start: number; end: number }> {
removeNotification if (!feature('BUDDY')) return []
} = useNotifications(); const triggers: Array<{ start: number; end: number }> = []
let t0; const re = /\/buddy\b/g
let t1; let m: RegExpExecArray | null
if ($[0] !== addNotification || $[1] !== removeNotification) {
t0 = () => {
if (!feature("BUDDY")) {
return;
}
const config = getGlobalConfig();
if (config.companion || !isBuddyTeaserWindow()) {
return;
}
addNotification({
key: "buddy-teaser",
jsx: <RainbowText text="/buddy" />,
priority: "immediate",
timeoutMs: 15000
});
return () => removeNotification("buddy-teaser");
};
t1 = [addNotification, removeNotification];
$[0] = addNotification;
$[1] = removeNotification;
$[2] = t0;
$[3] = t1;
} else {
t0 = $[2];
t1 = $[3];
}
useEffect(t0, t1);
}
export function findBuddyTriggerPositions(text: string): Array<{
start: number;
end: number;
}> {
if (!feature('BUDDY')) return [];
const triggers: Array<{
start: number;
end: number;
}> = [];
const re = /\/buddy\b/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) { while ((m = re.exec(text)) !== null) {
triggers.push({ triggers.push({ start: m.index, end: m.index + m[0].length })
start: m.index,
end: m.index + m[0].length
});
} }
return triggers; return triggers
} }

View File

@@ -3,359 +3,453 @@
* These are dynamically imported only when the corresponding `claude mcp *` command runs. * These are dynamically imported only when the corresponding `claude mcp *` command runs.
*/ */
import { stat } from 'fs/promises'; import { stat } from 'fs/promises'
import pMap from 'p-map'; import pMap from 'p-map'
import { cwd } from 'process'; import { cwd } from 'process'
import React from 'react'; import React from 'react'
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'; import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'
import { render } from '../../ink.js'; import { render } from '../../ink.js'
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.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 {
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js'; type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; logEvent,
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js'; } from '../../services/analytics/index.js'
import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js'; import {
import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js'; clearMcpClientConfig,
import { AppStateProvider } from '../../state/AppState.js'; clearServerTokensFromLocalStorage,
import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js'; getMcpClientConfig,
import { isFsInaccessible } from '../../utils/errors.js'; readClientSecret,
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; saveMcpClientSecret,
import { safeParseJSON } from '../../utils/json.js'; } from '../../services/mcp/auth.js'
import { getPlatform } from '../../utils/platform.js'; import {
import { cliError, cliOk } from '../exit.js'; connectToServer,
async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise<string> { 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<string> {
try { try {
const result = await connectToServer(name, server); const result = await connectToServer(name, server)
if (result.type === 'connected') { if (result.type === 'connected') {
return '✓ Connected'; return '✓ Connected'
} else if (result.type === 'needs-auth') { } else if (result.type === 'needs-auth') {
return '! Needs authentication'; return '! Needs authentication'
} else { } else {
return '✗ Failed to connect'; return '✗ Failed to connect'
} }
} catch (_error) { } catch (_error) {
return '✗ Connection error'; return '✗ Connection error'
} }
} }
// mcp serve (lines 45124532) // mcp serve (lines 45124532)
export async function mcpServeHandler({ export async function mcpServeHandler({
debug, debug,
verbose verbose,
}: { }: {
debug?: boolean; debug?: boolean
verbose?: boolean; verbose?: boolean
}): Promise<void> { }): Promise<void> {
const providedCwd = cwd(); const providedCwd = cwd()
logEvent('tengu_mcp_start', {}); logEvent('tengu_mcp_start', {})
try { try {
await stat(providedCwd); await stat(providedCwd)
} catch (error) { } catch (error) {
if (isFsInaccessible(error)) { if (isFsInaccessible(error)) {
cliError(`Error: Directory ${providedCwd} does not exist`); cliError(`Error: Directory ${providedCwd} does not exist`)
} }
throw error; throw error
} }
try { try {
const { const { setup } = await import('../../setup.js')
setup await setup(providedCwd, 'default', false, false, undefined, false)
} = await import('../../setup.js'); const { startMCPServer } = await import('../../entrypoints/mcp.js')
await setup(providedCwd, 'default', false, false, undefined, false); await startMCPServer(providedCwd, debug ?? false, verbose ?? false)
const {
startMCPServer
} = await import('../../entrypoints/mcp.js');
await startMCPServer(providedCwd, debug ?? false, verbose ?? false);
} catch (error) { } catch (error) {
cliError(`Error: Failed to start MCP server: ${error}`); cliError(`Error: Failed to start MCP server: ${error}`)
} }
} }
// mcp remove (lines 45454635) // mcp remove (lines 45454635)
export async function mcpRemoveHandler(name: string, options: { export async function mcpRemoveHandler(
scope?: string; name: string,
}): Promise<void> { options: { scope?: string },
): Promise<void> {
// Look up config before removing so we can clean up secure storage // Look up config before removing so we can clean up secure storage
const serverBeforeRemoval = getMcpConfigByName(name); const serverBeforeRemoval = getMcpConfigByName(name)
const cleanupSecureStorage = () => { const cleanupSecureStorage = () => {
if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) { if (
clearServerTokensFromLocalStorage(name, serverBeforeRemoval); serverBeforeRemoval &&
clearMcpClientConfig(name, serverBeforeRemoval); (serverBeforeRemoval.type === 'sse' ||
serverBeforeRemoval.type === 'http')
) {
clearServerTokensFromLocalStorage(name, serverBeforeRemoval)
clearMcpClientConfig(name, serverBeforeRemoval)
} }
}; }
try { try {
if (options.scope) { if (options.scope) {
const scope = ensureConfigScope(options.scope); const scope = ensureConfigScope(options.scope)
logEvent('tengu_mcp_delete', { logEvent('tengu_mcp_delete', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope: scope 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`); await removeMcpConfig(name, scope)
cliOk(`File modified: ${describeMcpConfigFilePath(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 // If no scope specified, check where the server exists
const projectConfig = getCurrentProjectConfig(); const projectConfig = getCurrentProjectConfig()
const globalConfig = getGlobalConfig(); const globalConfig = getGlobalConfig()
// Check if server exists in project scope (.mcp.json) // Check if server exists in project scope (.mcp.json)
const { const { servers: projectServers } = getMcpConfigsByScope('project')
servers: projectServers const mcpJsonExists = !!projectServers[name]
} = getMcpConfigsByScope('project');
const mcpJsonExists = !!projectServers[name];
// Count how many scopes contain this server // Count how many scopes contain this server
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []; const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []
if (projectConfig.mcpServers?.[name]) scopes.push('local'); if (projectConfig.mcpServers?.[name]) scopes.push('local')
if (mcpJsonExists) scopes.push('project'); if (mcpJsonExists) scopes.push('project')
if (globalConfig.mcpServers?.[name]) scopes.push('user'); if (globalConfig.mcpServers?.[name]) scopes.push('user')
if (scopes.length === 0) { 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) { } else if (scopes.length === 1) {
// Server exists in only one scope, remove it // Server exists in only one scope, remove it
const scope = scopes[0]!; const scope = scopes[0]!
logEvent('tengu_mcp_delete', { logEvent('tengu_mcp_delete', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope: scope 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`); await removeMcpConfig(name, scope)
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); cleanupSecureStorage()
process.stdout.write(
`Removed MCP server "${name}" from ${scope} config\n`,
)
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
} else { } else {
// Server exists in multiple scopes // 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 => { scopes.forEach(scope => {
process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`); process.stderr.write(
}); ` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`,
process.stderr.write('\nTo remove from a specific scope, use:\n'); )
})
process.stderr.write('\nTo remove from a specific scope, use:\n')
scopes.forEach(scope => { scopes.forEach(scope => {
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`); process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`)
}); })
cliError(); cliError()
} }
} catch (error) { } catch (error) {
cliError((error as Error).message); cliError((error as Error).message)
} }
} }
// mcp list (lines 46414688) // mcp list (lines 46414688)
export async function mcpListHandler(): Promise<void> { export async function mcpListHandler(): Promise<void> {
logEvent('tengu_mcp_list', {}); logEvent('tengu_mcp_list', {})
const { const { servers: configs } = await getAllMcpConfigs()
servers: configs
} = await getAllMcpConfigs();
if (Object.keys(configs).length === 0) { if (Object.keys(configs).length === 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output // 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 { } else {
// biome-ignore lint/suspicious/noConsole:: intentional console output // 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 // Check servers concurrently
const entries = Object.entries(configs); const entries = Object.entries(configs)
const results = await pMap(entries, async ([name, server]) => ({ const results = await pMap(
name, entries,
server, async ([name, server]) => ({
status: await checkMcpServerHealth(name, server) name,
}), { server,
concurrency: getMcpServerConnectionBatchSize() status: await checkMcpServerHealth(name, server),
}); }),
for (const { { concurrency: getMcpServerConnectionBatchSize() },
name, )
server,
status for (const { name, server, status } of results) {
} of results) {
// Intentionally excluding sse-ide servers here since they're internal // Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') { if (server.type === 'sse') {
// biome-ignore lint/suspicious/noConsole:: intentional console output // 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') { } else if (server.type === 'http') {
// biome-ignore lint/suspicious/noConsole:: intentional console output // 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') { } else if (server.type === 'claudeai-proxy') {
// biome-ignore lint/suspicious/noConsole:: intentional console output // 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') { } 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 // 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 // Use gracefulShutdown to properly clean up MCP server connections
// (process.exit bypasses cleanup handlers, leaving child processes orphaned) // (process.exit bypasses cleanup handlers, leaving child processes orphaned)
await gracefulShutdown(0); await gracefulShutdown(0)
} }
// mcp get (lines 46944786) // mcp get (lines 46944786)
export async function mcpGetHandler(name: string): Promise<void> { export async function mcpGetHandler(name: string): Promise<void> {
logEvent('tengu_mcp_get', { logEvent('tengu_mcp_get', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}); })
const server = getMcpConfigByName(name); const server = getMcpConfigByName(name)
if (!server) { 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 // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}:`); console.log(`${name}:`)
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Scope: ${getScopeLabel(server.scope)}`); console.log(` Scope: ${getScopeLabel(server.scope)}`)
// Check server health // Check server health
const status = await checkMcpServerHealth(name, server); const status = await checkMcpServerHealth(name, server)
// biome-ignore lint/suspicious/noConsole:: intentional console output // 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 // Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') { if (server.type === 'sse') {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Type: sse`); console.log(` Type: sse`)
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`); console.log(` URL: ${server.url}`)
if (server.headers) { if (server.headers) {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Headers:'); console.log(' Headers:')
for (const [key, value] of Object.entries(server.headers)) { for (const [key, value] of Object.entries(server.headers)) {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}: ${value}`); console.log(` ${key}: ${value}`)
} }
} }
if (server.oauth?.clientId || server.oauth?.callbackPort) { if (server.oauth?.clientId || server.oauth?.callbackPort) {
const parts: string[] = []; const parts: string[] = []
if (server.oauth.clientId) { if (server.oauth.clientId) {
parts.push('client_id configured'); parts.push('client_id configured')
const clientConfig = getMcpClientConfig(name, server); const clientConfig = getMcpClientConfig(name, server)
if (clientConfig?.clientSecret) parts.push('client_secret configured'); 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 // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` OAuth: ${parts.join(', ')}`); console.log(` OAuth: ${parts.join(', ')}`)
} }
} else if (server.type === 'http') { } else if (server.type === 'http') {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Type: http`); console.log(` Type: http`)
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`); console.log(` URL: ${server.url}`)
if (server.headers) { if (server.headers) {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Headers:'); console.log(' Headers:')
for (const [key, value] of Object.entries(server.headers)) { for (const [key, value] of Object.entries(server.headers)) {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}: ${value}`); console.log(` ${key}: ${value}`)
} }
} }
if (server.oauth?.clientId || server.oauth?.callbackPort) { if (server.oauth?.clientId || server.oauth?.callbackPort) {
const parts: string[] = []; const parts: string[] = []
if (server.oauth.clientId) { if (server.oauth.clientId) {
parts.push('client_id configured'); parts.push('client_id configured')
const clientConfig = getMcpClientConfig(name, server); const clientConfig = getMcpClientConfig(name, server)
if (clientConfig?.clientSecret) parts.push('client_secret configured'); 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 // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` OAuth: ${parts.join(', ')}`); console.log(` OAuth: ${parts.join(', ')}`)
} }
} else if (server.type === 'stdio') { } else if (server.type === 'stdio') {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Type: stdio`); console.log(` Type: stdio`)
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Command: ${server.command}`); console.log(` Command: ${server.command}`)
const args = Array.isArray(server.args) ? server.args : []; const args = Array.isArray(server.args) ? server.args : []
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Args: ${args.join(' ')}`); console.log(` Args: ${args.join(' ')}`)
if (server.env) { if (server.env) {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Environment:'); console.log(' Environment:')
for (const [key, value] of Object.entries(server.env)) { for (const [key, value] of Object.entries(server.env)) {
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}=${value}`); console.log(` ${key}=${value}`)
} }
} }
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output // 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 // Use gracefulShutdown to properly clean up MCP server connections
// (process.exit bypasses cleanup handlers, leaving child processes orphaned) // (process.exit bypasses cleanup handlers, leaving child processes orphaned)
await gracefulShutdown(0); await gracefulShutdown(0)
} }
// mcp add-json (lines 48014870) // mcp add-json (lines 48014870)
export async function mcpAddJsonHandler(name: string, json: string, options: { export async function mcpAddJsonHandler(
scope?: string; name: string,
clientSecret?: true; json: string,
}): Promise<void> { options: { scope?: string; clientSecret?: true },
): Promise<void> {
try { try {
const scope = ensureConfigScope(options.scope); const scope = ensureConfigScope(options.scope)
const parsedJson = safeParseJSON(json); const parsedJson = safeParseJSON(json)
// Read secret before writing config so cancellation doesn't leave partial state // 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 needsSecret =
const clientSecret = needsSecret ? await readClientSecret() : undefined; options.clientSecret &&
await addMcpConfig(name, parsedJson, scope); parsedJson &&
const transportType = parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson ? String(parsedJson.type || 'stdio') : 'stdio'; typeof parsedJson === 'object' &&
if (clientSecret && parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson && (parsedJson.type === 'sse' || parsedJson.type === 'http') && 'url' in parsedJson && typeof parsedJson.url === 'string') { 'type' in parsedJson &&
saveMcpClientSecret(name, { (parsedJson.type === 'sse' || parsedJson.type === 'http') &&
type: parsedJson.type, 'url' in parsedJson &&
url: parsedJson.url typeof parsedJson.url === 'string' &&
}, clientSecret); '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', { logEvent('tengu_mcp_add', {
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope:
source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS source:
}); 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`); type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`)
} catch (error) { } catch (error) {
cliError((error as Error).message); cliError((error as Error).message)
} }
} }
// mcp add-from-claude-desktop (lines 48814927) // mcp add-from-claude-desktop (lines 48814927)
export async function mcpAddFromDesktopHandler(options: { export async function mcpAddFromDesktopHandler(options: {
scope?: string; scope?: string
}): Promise<void> { }): Promise<void> {
try { try {
const scope = ensureConfigScope(options.scope); const scope = ensureConfigScope(options.scope)
const platform = getPlatform(); const platform = getPlatform()
logEvent('tengu_mcp_add', { logEvent('tengu_mcp_add', {
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope:
platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS platform:
}); platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
const { source:
readClaudeDesktopMcpServers 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
} = await import('../../utils/claudeDesktop.js'); })
const servers = await readClaudeDesktopMcpServers();
const { readClaudeDesktopMcpServers } = await import(
'../../utils/claudeDesktop.js'
)
const servers = await readClaudeDesktopMcpServers()
if (Object.keys(servers).length === 0) { 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 const { unmount } = await render(
} = await render(<AppStateProvider> <AppStateProvider>
<KeybindingSetup> <KeybindingSetup>
<MCPServerDesktopImportDialog servers={servers} scope={scope} onDone={() => { <MCPServerDesktopImportDialog
unmount(); servers={servers}
}} /> scope={scope}
onDone={() => {
unmount()
}}
/>
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>, { </AppStateProvider>,
exitOnCtrlC: true { exitOnCtrlC: true },
}); )
} catch (error) { } catch (error) {
cliError((error as Error).message); cliError((error as Error).message)
} }
} }
// mcp reset-project-choices (lines 49354952) // mcp reset-project-choices (lines 49354952)
export async function mcpResetChoicesHandler(): Promise<void> { export async function mcpResetChoicesHandler(): Promise<void> {
logEvent('tengu_mcp_reset_mcpjson_choices', {}); logEvent('tengu_mcp_reset_mcpjson_choices', {})
saveCurrentProjectConfig(current => ({ saveCurrentProjectConfig(current => ({
...current, ...current,
enabledMcpjsonServers: [], enabledMcpjsonServers: [],
disabledMcpjsonServers: [], disabledMcpjsonServers: [],
enableAllProjectMcpServers: false 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.'); 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.',
)
} }

View File

@@ -1,34 +1,37 @@
import { c as _c } from "react/compiler-runtime";
/** /**
* Miscellaneous subcommand handlers — extracted from main.tsx for lazy loading. * Miscellaneous subcommand handlers — extracted from main.tsx for lazy loading.
* setup-token, doctor, install * setup-token, doctor, install
*/ */
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */ /* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
import { cwd } from 'process'; import { cwd } from 'process'
import React from 'react'; import React from 'react'
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'; import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'
import { useManagePlugins } from '../../hooks/useManagePlugins.js'; import { useManagePlugins } from '../../hooks/useManagePlugins.js'
import type { Root } from '../../ink.js'; import type { Root } from '../../ink.js'
import { Box, Text } from '../../ink.js'; import { Box, Text } from '../../ink.js'
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
import { logEvent } from '../../services/analytics/index.js'; import { logEvent } from '../../services/analytics/index.js'
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'; import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'
import { AppStateProvider } from '../../state/AppState.js'; import { AppStateProvider } from '../../state/AppState.js'
import { onChangeAppState } from '../../state/onChangeAppState.js'; import { onChangeAppState } from '../../state/onChangeAppState.js'
import { isAnthropicAuthEnabled } from '../../utils/auth.js'; import { isAnthropicAuthEnabled } from '../../utils/auth.js'
export async function setupTokenHandler(root: Root): Promise<void> { export async function setupTokenHandler(root: Root): Promise<void> {
logEvent('tengu_setup_token_command', {}); logEvent('tengu_setup_token_command', {})
const showAuthWarning = !isAnthropicAuthEnabled();
const { const showAuthWarning = !isAnthropicAuthEnabled()
ConsoleOAuthFlow const { ConsoleOAuthFlow } = await import(
} = await import('../../components/ConsoleOAuthFlow.js'); '../../components/ConsoleOAuthFlow.js'
)
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
root.render(<AppStateProvider onChangeAppState={onChangeAppState}> root.render(
<AppStateProvider onChangeAppState={onChangeAppState}>
<KeybindingSetup> <KeybindingSetup>
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<WelcomeV2 /> <WelcomeV2 />
{showAuthWarning && <Box flexDirection="column"> {showAuthWarning && (
<Box flexDirection="column">
<Text color="warning"> <Text color="warning">
Warning: You already have authentication configured via Warning: You already have authentication configured via
environment variable or API key helper. environment variable or API key helper.
@@ -37,73 +40,87 @@ export async function setupTokenHandler(root: Root): Promise<void> {
The setup-token command will create a new OAuth token which The setup-token command will create a new OAuth token which
you can use instead. you can use instead.
</Text> </Text>
</Box>} </Box>
<ConsoleOAuthFlow onDone={() => { )}
void resolve(); <ConsoleOAuthFlow
}} mode="setup-token" startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." /> onDone={() => {
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."
/>
</Box> </Box>
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>); </AppStateProvider>,
}); )
root.unmount(); })
process.exit(0); root.unmount()
process.exit(0)
} }
// DoctorWithPlugins wrapper + doctor handler // DoctorWithPlugins wrapper + doctor handler
const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ const DoctorLazy = React.lazy(() =>
default: m.Doctor import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })),
}))); )
function DoctorWithPlugins(t0) {
const $ = _c(2); function DoctorWithPlugins({
const { onDone,
onDone }: {
} = t0; onDone: () => void
useManagePlugins(); }): React.ReactNode {
let t1; useManagePlugins()
if ($[0] !== onDone) { return (
t1 = <React.Suspense fallback={null}><DoctorLazy onDone={onDone} /></React.Suspense>; <React.Suspense fallback={null}>
$[0] = onDone; <DoctorLazy onDone={onDone} />
$[1] = t1; </React.Suspense>
} else { )
t1 = $[1];
}
return t1;
} }
export async function doctorHandler(root: Root): Promise<void> { export async function doctorHandler(root: Root): Promise<void> {
logEvent('tengu_doctor_command', {}); logEvent('tengu_doctor_command', {})
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
root.render(<AppStateProvider> root.render(
<AppStateProvider>
<KeybindingSetup> <KeybindingSetup>
<MCPConnectionManager dynamicMcpConfig={undefined} isStrictMcpConfig={false}> <MCPConnectionManager
<DoctorWithPlugins onDone={() => { dynamicMcpConfig={undefined}
void resolve(); isStrictMcpConfig={false}
}} /> >
<DoctorWithPlugins
onDone={() => {
void resolve()
}}
/>
</MCPConnectionManager> </MCPConnectionManager>
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>); </AppStateProvider>,
}); )
root.unmount(); })
process.exit(0); root.unmount()
process.exit(0)
} }
// install handler // install handler
export async function installHandler(target: string | undefined, options: { export async function installHandler(
force?: boolean; target: string | undefined,
}): Promise<void> { options: { force?: boolean },
const { ): Promise<void> {
setup const { setup } = await import('../../setup.js')
} = await import('../../setup.js'); await setup(cwd(), 'default', false, false, undefined, false)
await setup(cwd(), 'default', false, false, undefined, false); const { install } = await import('../../commands/install.js')
const {
install
} = await import('../../commands/install.js');
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
const args: string[] = []; const args: string[] = []
if (target) args.push(target); if (target) args.push(target)
if (options.force) args.push('--force'); if (options.force) args.push('--force')
void install.call(result => {
void resolve(); void install.call(
process.exit(result.includes('failed') ? 1 : 0); result => {
}, {}, args); void resolve()
}); process.exit(result.includes('failed') ? 1 : 0)
},
{},
args,
)
})
} }

View File

@@ -1,62 +1,45 @@
import { c as _c } from "react/compiler-runtime"; import * as React from 'react'
import * as React from 'react'; import { Box } from '../ink.js'
import { Box } from '../ink.js';
type QueuedMessageContextValue = { type QueuedMessageContextValue = {
isQueued: boolean; isQueued: boolean
isFirst: boolean; isFirst: boolean
/** Width reduction for container padding (e.g., 4 for paddingX={2}) */ /** Width reduction for container padding (e.g., 4 for paddingX={2}) */
paddingWidth: number; paddingWidth: number
};
const QueuedMessageContext = React.createContext<QueuedMessageContextValue | undefined>(undefined);
export function useQueuedMessage() {
return React.useContext(QueuedMessageContext);
} }
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 = { type Props = {
isFirst: boolean; isFirst: boolean
useBriefLayout?: boolean; useBriefLayout?: boolean
children: React.ReactNode; children: React.ReactNode
}; }
export function QueuedMessageProvider(t0) {
const $ = _c(9); export function QueuedMessageProvider({
const { isFirst,
isFirst, useBriefLayout,
useBriefLayout, children,
children }: Props): React.ReactNode {
} = t0; // Brief mode already indents via paddingLeft in HighlightedThinkingText /
const padding = useBriefLayout ? 0 : PADDING_X; // BriefTool UI — adding paddingX here would double-indent the queue.
const t1 = padding * 2; const padding = useBriefLayout ? 0 : PADDING_X
let t2; const value = React.useMemo(
if ($[0] !== isFirst || $[1] !== t1) { () => ({ isQueued: true, isFirst, paddingWidth: padding * 2 }),
t2 = { [isFirst, padding],
isQueued: true, )
isFirst,
paddingWidth: t1 return (
}; <QueuedMessageContext.Provider value={value}>
$[0] = isFirst; <Box paddingX={padding}>{children}</Box>
$[1] = t1; </QueuedMessageContext.Provider>
$[2] = t2; )
} else {
t2 = $[2];
}
const value = t2;
let t3;
if ($[3] !== children || $[4] !== padding) {
t3 = <Box paddingX={padding}>{children}</Box>;
$[3] = children;
$[4] = padding;
$[5] = t3;
} else {
t3 = $[5];
}
let t4;
if ($[6] !== t3 || $[7] !== value) {
t4 = <QueuedMessageContext.Provider value={value}>{t3}</QueuedMessageContext.Provider>;
$[6] = t3;
$[7] = value;
$[8] = t4;
} else {
t4 = $[8];
}
return t4;
} }

View File

@@ -1,29 +1,26 @@
import { c as _c } from "react/compiler-runtime"; import React, { createContext, useContext } from 'react'
import React, { createContext, useContext } from 'react'; import type { FpsMetrics } from '../utils/fpsTracker.js'
import type { FpsMetrics } from '../utils/fpsTracker.js';
type FpsMetricsGetter = () => FpsMetrics | undefined; type FpsMetricsGetter = () => FpsMetrics | undefined
const FpsMetricsContext = createContext<FpsMetricsGetter | undefined>(undefined);
const FpsMetricsContext = createContext<FpsMetricsGetter | undefined>(undefined)
type Props = { type Props = {
getFpsMetrics: FpsMetricsGetter; getFpsMetrics: FpsMetricsGetter
children: React.ReactNode; children: React.ReactNode
};
export function FpsMetricsProvider(t0) {
const $ = _c(3);
const {
getFpsMetrics,
children
} = t0;
let t1;
if ($[0] !== children || $[1] !== getFpsMetrics) {
t1 = <FpsMetricsContext.Provider value={getFpsMetrics}>{children}</FpsMetricsContext.Provider>;
$[0] = children;
$[1] = getFpsMetrics;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
} }
export function useFpsMetrics() {
return useContext(FpsMetricsContext); export function FpsMetricsProvider({
getFpsMetrics,
children,
}: Props): React.ReactNode {
return (
<FpsMetricsContext.Provider value={getFpsMetrics}>
{children}
</FpsMetricsContext.Provider>
)
}
export function useFpsMetrics(): FpsMetricsGetter | undefined {
return useContext(FpsMetricsContext)
} }

View File

@@ -1,37 +1,25 @@
import { c as _c } from "react/compiler-runtime"; import React, { createContext, useContext, useMemo } from 'react'
import React, { createContext, useContext, useMemo } from 'react'; import { Mailbox } from '../utils/mailbox.js'
import { Mailbox } from '../utils/mailbox.js';
const MailboxContext = createContext<Mailbox | undefined>(undefined); const MailboxContext = createContext<Mailbox | undefined>(undefined)
type Props = { type Props = {
children: React.ReactNode; 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 = <MailboxContext.Provider value={mailbox}>{children}</MailboxContext.Provider>;
$[1] = children;
$[2] = t2;
} else {
t2 = $[2];
}
return t2;
} }
export function useMailbox() {
const mailbox = useContext(MailboxContext); export function MailboxProvider({ children }: Props): React.ReactNode {
const mailbox = useMemo(() => new Mailbox(), [])
return (
<MailboxContext.Provider value={mailbox}>
{children}
</MailboxContext.Provider>
)
}
export function useMailbox(): Mailbox {
const mailbox = useContext(MailboxContext)
if (!mailbox) { 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
} }

View File

@@ -1,6 +1,5 @@
import { c as _c } from "react/compiler-runtime"; import { createContext, type RefObject, useContext } from 'react'
import { createContext, type RefObject, useContext } from 'react'; import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'
import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js';
/** /**
* Set by FullscreenLayout when rendering content in its `modal` slot — * 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. * null = not inside the modal slot.
*/ */
type ModalCtx = { type ModalCtx = {
rows: number; rows: number
columns: number; columns: number
scrollRef: RefObject<ScrollBoxHandle | null> | null; scrollRef: RefObject<ScrollBoxHandle | null> | null
}; }
export const ModalContext = createContext<ModalCtx | null>(null); export const ModalContext = createContext<ModalCtx | null>(null)
export function useIsInsideModal() {
return useContext(ModalContext) !== 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 * component caps its visible content height — the modal's inner area is
* smaller than the terminal. * smaller than the terminal.
*/ */
export function useModalOrTerminalSize(fallback) { export function useModalOrTerminalSize(fallback: {
const $ = _c(3); rows: number
const ctx = useContext(ModalContext); columns: number
let t0; }): { rows: number; columns: number } {
if ($[0] !== ctx || $[1] !== fallback) { const ctx = useContext(ModalContext)
t0 = ctx ? { return ctx ? { rows: ctx.rows, columns: ctx.columns } : fallback
rows: ctx.rows,
columns: ctx.columns
} : fallback;
$[0] = ctx;
$[1] = fallback;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
} }
export function useModalScrollRef() {
return useContext(ModalContext)?.scrollRef ?? null; export function useModalScrollRef(): RefObject<ScrollBoxHandle | null> | null {
return useContext(ModalContext)?.scrollRef ?? null
} }

View File

@@ -1,216 +1,288 @@
import type * as React from 'react'; import type * as React from 'react'
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react'
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'; import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'
import type { Theme } from '../utils/theme.js'; import type { Theme } from '../utils/theme.js'
type Priority = 'low' | 'medium' | 'high' | 'immediate';
type Priority = 'low' | 'medium' | 'high' | 'immediate'
type BaseNotification = { type BaseNotification = {
key: string; key: string
/** /**
* Keys of notifications that this notification invalidates. * Keys of notifications that this notification invalidates.
* If a notification is invalidated, it will be removed from the queue * If a notification is invalidated, it will be removed from the queue
* and, if currently displayed, cleared immediately. * and, if currently displayed, cleared immediately.
*/ */
invalidates?: string[]; invalidates?: string[]
priority: Priority; priority: Priority
timeoutMs?: number; timeoutMs?: number
/** /**
* Combine notifications with the same key, like Array.reduce(). * Combine notifications with the same key, like Array.reduce().
* Called as fold(accumulator, incoming) when a notification with a matching * Called as fold(accumulator, incoming) when a notification with a matching
* key already exists in the queue or is currently displayed. * key already exists in the queue or is currently displayed.
* Returns the merged notification (should carry fold forward for future merges). * 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 & { type TextNotification = BaseNotification & {
text: string; text: string
color?: keyof Theme; color?: keyof Theme
}; }
type JSXNotification = BaseNotification & { type JSXNotification = BaseNotification & {
jsx: React.ReactNode; jsx: React.ReactNode
}; }
type AddNotificationFn = (content: Notification) => void;
type RemoveNotificationFn = (key: string) => void; type AddNotificationFn = (content: Notification) => void
export type Notification = TextNotification | JSXNotification; type RemoveNotificationFn = (key: string) => void
const DEFAULT_TIMEOUT_MS = 8000;
export type Notification = TextNotification | JSXNotification
const DEFAULT_TIMEOUT_MS = 8000
// Track current timeout to clear it when immediate notifications arrive // 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(): { export function useNotifications(): {
addNotification: AddNotificationFn; addNotification: AddNotificationFn
removeNotification: RemoveNotificationFn; removeNotification: RemoveNotificationFn
} { } {
const store = useAppStateStore(); const store = useAppStateStore()
const setAppState = useSetAppState(); const setAppState = useSetAppState()
// Process queue when current notification finishes or queue changes // Process queue when current notification finishes or queue changes
const processQueue = useCallback(() => { const processQueue = useCallback(() => {
setAppState(prev => { setAppState(prev => {
const next = getNext(prev.notifications.queue); const next = getNext(prev.notifications.queue)
if (prev.notifications.current !== null || !next) { if (prev.notifications.current !== null || !next) {
return prev; return prev
} }
currentTimeoutId = setTimeout((setAppState, nextKey, processQueue) => {
currentTimeoutId = null; currentTimeoutId = setTimeout(
setAppState(prev => { (setAppState, nextKey, processQueue) => {
// Compare by key instead of reference to handle re-created notifications currentTimeoutId = null
if (prev.notifications.current?.key !== nextKey) { setAppState(prev => {
return prev; // Compare by key instead of reference to handle re-created notifications
} if (prev.notifications.current?.key !== nextKey) {
return { return prev
...prev,
notifications: {
queue: prev.notifications.queue,
current: null
} }
}; return {
}); ...prev,
processQueue(); notifications: {
}, next.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, next.key, processQueue); queue: prev.notifications.queue,
current: null,
},
}
})
processQueue()
},
next.timeoutMs ?? DEFAULT_TIMEOUT_MS,
setAppState,
next.key,
processQueue,
)
return { return {
...prev, ...prev,
notifications: { notifications: {
queue: prev.notifications.queue.filter(_ => _ !== next), queue: prev.notifications.queue.filter(_ => _ !== next),
current: next current: next,
} },
};
});
}, [setAppState]);
const addNotification = useCallback<AddNotificationFn>((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;
} }
})
}, [setAppState])
// Set up timeout for the immediate notification const addNotification = useCallback<AddNotificationFn>(
currentTimeoutId = setTimeout((setAppState, notif, processQueue) => { (notif: Notification) => {
currentTimeoutId = null; // Handle immediate priority notifications
setAppState(prev => { if (notif.priority === 'immediate') {
// Compare by key instead of reference to handle re-created notifications // Clear any existing timeout since we're showing a new immediate notification
if (prev.notifications.current?.key !== notif.key) { if (currentTimeoutId) {
return prev; clearTimeout(currentTimeoutId)
} currentTimeoutId = null
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))
} }
}));
return; // IMPORTANT: Exit addNotification for immediate notifications
}
// Handle non-immediate notifications // Set up timeout for the immediate notification
setAppState(prev => { currentTimeoutId = setTimeout(
// Check if we can fold into an existing notification with the same key (setAppState, notif, processQueue) => {
if (notif.fold) { currentTimeoutId = null
// Fold into current notification if keys match setAppState(prev => {
if (prev.notifications.current?.key === notif.key) { // Compare by key instead of reference to handle re-created notifications
const folded = notif.fold(prev.notifications.current, notif); if (prev.notifications.current?.key !== notif.key) {
// Reset timeout for the folded notification return prev
if (currentTimeoutId) {
clearTimeout(currentTimeoutId);
currentTimeoutId = null;
}
currentTimeoutId = setTimeout((setAppState, foldedKey, processQueue) => {
currentTimeoutId = null;
setAppState(p => {
if (p.notifications.current?.key !== foldedKey) {
return p;
} }
return { return {
...p, ...prev,
notifications: { notifications: {
queue: p.notifications.queue, queue: prev.notifications.queue.filter(
current: null _ => !notif.invalidates?.includes(_.key),
} ),
}; current: null,
}); },
processQueue(); }
}, folded.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, folded.key, processQueue); })
return { processQueue()
...prev, },
notifications: { notif.timeoutMs ?? DEFAULT_TIMEOUT_MS,
current: folded, setAppState,
queue: prev.notifications.queue 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 return {
const queueIdx = prev.notifications.queue.findIndex(_ => _.key === notif.key); ...prev,
if (queueIdx !== -1) { notifications: {
const folded = notif.fold(prev.notifications.queue[queueIdx]!, notif); current: folded,
const newQueue = [...prev.notifications.queue]; queue: 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) // Fold into queued notification if keys match
const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key)); const queueIdx = prev.notifications.queue.findIndex(
const shouldAdd = !queuedKeys.has(notif.key) && prev.notifications.current?.key !== notif.key; _ => _.key === notif.key,
if (!shouldAdd) return prev; )
const invalidatesCurrent = prev.notifications.current !== null && notif.invalidates?.includes(prev.notifications.current.key); if (queueIdx !== -1) {
if (invalidatesCurrent && currentTimeoutId) { const folded = notif.fold(
clearTimeout(currentTimeoutId); prev.notifications.queue[queueIdx]!,
currentTimeoutId = null; notif,
} )
return { const newQueue = [...prev.notifications.queue]
...prev, newQueue[queueIdx] = folded
notifications: { return {
current: invalidatesCurrent ? null : prev.notifications.current, ...prev,
queue: [...prev.notifications.queue.filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key)), notif] notifications: {
current: prev.notifications.current,
queue: newQueue,
},
}
}
} }
};
});
// Process queue after adding the notification // Only add to queue if not already present (prevent duplicates)
processQueue(); const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key))
}, [setAppState, processQueue]); const shouldAdd =
const removeNotification = useCallback<RemoveNotificationFn>((key: string) => { !queuedKeys.has(notif.key) &&
setAppState(prev => { prev.notifications.current?.key !== notif.key
const isCurrent = prev.notifications.current?.key === key;
const inQueue = prev.notifications.queue.some(n => n.key === key); if (!shouldAdd) return prev
if (!isCurrent && !inQueue) {
return prev; const invalidatesCurrent =
} prev.notifications.current !== null &&
if (isCurrent && currentTimeoutId) { notif.invalidates?.includes(prev.notifications.current.key)
clearTimeout(currentTimeoutId);
currentTimeoutId = null; if (invalidatesCurrent && currentTimeoutId) {
} clearTimeout(currentTimeoutId)
return { currentTimeoutId = null
...prev,
notifications: {
current: isCurrent ? null : prev.notifications.current,
queue: prev.notifications.queue.filter(n => n.key !== key)
} }
};
}); return {
processQueue(); ...prev,
}, [setAppState, processQueue]); 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<RemoveNotificationFn>(
(key: string) => {
setAppState(prev => {
const isCurrent = prev.notifications.current?.key === key
const inQueue = prev.notifications.queue.some(n => n.key === key)
if (!isCurrent && !inQueue) {
return prev
}
if (isCurrent && currentTimeoutId) {
clearTimeout(currentTimeoutId)
currentTimeoutId = null
}
return {
...prev,
notifications: {
current: isCurrent ? null : prev.notifications.current,
queue: prev.notifications.queue.filter(n => n.key !== key),
},
}
})
processQueue()
},
[setAppState, processQueue],
)
// Process queue on mount if there are notifications in the initial state. // Process queue on mount if there are notifications in the initial state.
// Imperative read (not useAppState) — a subscription in a mount-only // 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 // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref
useEffect(() => { useEffect(() => {
if (store.getState().notifications.queue.length > 0) { if (store.getState().notifications.queue.length > 0) {
processQueue(); processQueue()
} }
}, []); }, [])
return {
addNotification, return { addNotification, removeNotification }
removeNotification
};
} }
const PRIORITIES: Record<Priority, number> = { const PRIORITIES: Record<Priority, number> = {
immediate: 0, immediate: 0,
high: 1, high: 1,
medium: 2, medium: 2,
low: 3 low: 3,
}; }
export function getNext(queue: Notification[]): Notification | undefined { export function getNext(queue: Notification[]): Notification | undefined {
if (queue.length === 0) return undefined; if (queue.length === 0) return undefined
return queue.reduce((min, n) => PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min); return queue.reduce((min, n) =>
PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min,
)
} }

View File

@@ -1,4 +1,3 @@
import { c as _c } from "react/compiler-runtime";
/** /**
* Overlay tracking for Escape key coordination. * 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, * The hook automatically registers on mount and unregisters on unmount,
* so no manual cleanup or state management is needed. * so no manual cleanup or state management is needed.
*/ */
import { useContext, useEffect, useLayoutEffect } from 'react'; import { useContext, useEffect, useLayoutEffect } from 'react'
import instances from '../ink/instances.js'; import instances from '../ink/instances.js'
import { AppStoreContext, useAppState } from '../state/AppState.js'; import { AppStoreContext, useAppState } from '../state/AppState.js'
// Non-modal overlays that shouldn't disable TextInput focus // 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. * 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) { export function useRegisterOverlay(id: string, enabled = true): void {
const $ = _c(8); // Use context directly so this is a no-op when rendered outside AppStateProvider
const enabled = t0 === undefined ? true : t0; // (e.g., in isolated component tests that don't need the full app state tree).
const store = useContext(AppStoreContext); const store = useContext(AppStoreContext)
const setAppState = store?.setState; const setAppState = store?.setState
let t1; useEffect(() => {
let t2; if (!enabled || !setAppState) return
if ($[0] !== enabled || $[1] !== id || $[2] !== setAppState) { setAppState(prev => {
t1 = () => { if (prev.activeOverlays.has(id)) return prev
if (!enabled || !setAppState) { const next = new Set(prev.activeOverlays)
return; next.add(id)
} return { ...prev, activeOverlays: next }
})
return () => {
setAppState(prev => { setAppState(prev => {
if (prev.activeOverlays.has(id)) { if (!prev.activeOverlays.has(id)) return prev
return prev; const next = new Set(prev.activeOverlays)
} next.delete(id)
const next = new Set(prev.activeOverlays); return { ...prev, activeOverlays: next }
next.add(id); })
return { }
...prev, }, [id, enabled, setAppState])
activeOverlays: next
}; // 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)
return () => { // shrinks the Ink-managed region on unmount; the blit fast path can
setAppState(prev_0 => { // copy stale cells from the overlay's previous frame into rows the
if (!prev_0.activeOverlays.has(id)) { // shorter layout no longer reaches, leaving a ghost title/divider.
return prev_0; // useLayoutEffect so cleanup runs synchronously before the microtask-
} // deferred onRender (scheduleRender queues a microtask from
const next_0 = new Set(prev_0.activeOverlays); // resetAfterCommit; passive-effect cleanup would land after it).
next_0.delete(id); useLayoutEffect(() => {
return { if (!enabled) return
...prev_0, return () => instances.get(process.stdout)?.invalidatePrevFrame()
activeOverlays: next_0 }, [enabled])
};
});
};
};
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);
} }
/** /**
@@ -116,11 +84,8 @@ export function useRegisterOverlay(id, t0) {
* useKeybinding('chat:cancel', handleCancel, { isActive }) * useKeybinding('chat:cancel', handleCancel, { isActive })
* } * }
*/ */
function _temp() { export function useIsOverlayActive(): boolean {
return instances.get(process.stdout)?.invalidatePrevFrame(); return useAppState(s => s.activeOverlays.size > 0)
}
export function useIsOverlayActive() {
return useAppState(_temp2);
} }
/** /**
@@ -134,17 +99,11 @@ export function useIsOverlayActive() {
* // Use for TextInput focus - allows typing during autocomplete * // Use for TextInput focus - allows typing during autocomplete
* focus: !isSearchingHistory && !isModalOverlayActive * focus: !isSearchingHistory && !isModalOverlayActive
*/ */
function _temp2(s) { export function useIsModalOverlayActive(): boolean {
return s.activeOverlays.size > 0; return useAppState(s => {
} for (const id of s.activeOverlays) {
export function useIsModalOverlayActive() { if (!NON_MODAL_OVERLAYS.has(id)) return true
return useAppState(_temp3);
}
function _temp3(s) {
for (const id of s.activeOverlays) {
if (!NON_MODAL_OVERLAYS.has(id)) {
return true;
} }
} return false
return false; })
} }

View File

@@ -1,4 +1,3 @@
import { c as _c } from "react/compiler-runtime";
/** /**
* Portal for content that floats above the prompt so it escapes * Portal for content that floats above the prompt so it escapes
* FullscreenLayout's bottom-slot `overflowY:hidden` clip. * 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 * Split into data/setter context pairs so writers never re-render on
* their own writes — the setter contexts are stable. * their own writes — the setter contexts are stable.
*/ */
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react'; import React, {
import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js'; createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js'
export type PromptOverlayData = { export type PromptOverlayData = {
suggestions: SuggestionItem[]; suggestions: SuggestionItem[]
selectedSuggestion: number; selectedSuggestion: number
maxColumnWidth?: number; maxColumnWidth?: number
};
type Setter<T> = (d: T | null) => void;
const DataContext = createContext<PromptOverlayData | null>(null);
const SetContext = createContext<Setter<PromptOverlayData> | null>(null);
const DialogContext = createContext<ReactNode>(null);
const SetDialogContext = createContext<Setter<ReactNode> | 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 = <DialogContext.Provider value={dialog}>{children}</DialogContext.Provider>;
$[0] = children;
$[1] = dialog;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
if ($[3] !== data || $[4] !== t1) {
t2 = <SetContext.Provider value={setData}><SetDialogContext.Provider value={setDialog}><DataContext.Provider value={data}>{t1}</DataContext.Provider></SetDialogContext.Provider></SetContext.Provider>;
$[3] = data;
$[4] = t1;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
} }
export function usePromptOverlay() {
return useContext(DataContext); type Setter<T> = (d: T | null) => void
const DataContext = createContext<PromptOverlayData | null>(null)
const SetContext = createContext<Setter<PromptOverlayData> | null>(null)
const DialogContext = createContext<ReactNode>(null)
const SetDialogContext = createContext<Setter<ReactNode> | null>(null)
export function PromptOverlayProvider({
children,
}: {
children: ReactNode
}): ReactNode {
const [data, setData] = useState<PromptOverlayData | null>(null)
const [dialog, setDialog] = useState<ReactNode>(null)
return (
<SetContext.Provider value={setData}>
<SetDialogContext.Provider value={setDialog}>
<DataContext.Provider value={data}>
<DialogContext.Provider value={dialog}>
{children}
</DialogContext.Provider>
</DataContext.Provider>
</SetDialogContext.Provider>
</SetContext.Provider>
)
} }
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. * Register suggestion data for the floating overlay. Clears on unmount.
* No-op outside the provider (non-fullscreen renders inline instead). * No-op outside the provider (non-fullscreen renders inline instead).
*/ */
export function useSetPromptOverlay(data) { export function useSetPromptOverlay(data: PromptOverlayData | null): void {
const $ = _c(4); const set = useContext(SetContext)
const set = useContext(SetContext); useEffect(() => {
let t0; if (!set) return
let t1; set(data)
if ($[0] !== data || $[1] !== set) { return () => set(null)
t0 = () => { }, [set, data])
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);
} }
/** /**
* Register a dialog node to float above the prompt. Clears on unmount. * Register a dialog node to float above the prompt. Clears on unmount.
* No-op outside the provider (non-fullscreen renders inline instead). * No-op outside the provider (non-fullscreen renders inline instead).
*/ */
export function useSetPromptOverlayDialog(node) { export function useSetPromptOverlayDialog(node: ReactNode): void {
const $ = _c(4); const set = useContext(SetDialogContext)
const set = useContext(SetDialogContext); useEffect(() => {
let t0; if (!set) return
let t1; set(node)
if ($[0] !== node || $[1] !== set) { return () => set(null)
t0 = () => { }, [set, node])
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);
} }

View File

@@ -1,219 +1,173 @@
import { c as _c } from "react/compiler-runtime"; import React, {
import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; createContext,
import { saveCurrentProjectConfig } from '../utils/config.js'; useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import { saveCurrentProjectConfig } from '../utils/config.js'
export type StatsStore = { export type StatsStore = {
increment(name: string, value?: number): void; increment(name: string, value?: number): void
set(name: string, value: number): void; set(name: string, value: number): void
observe(name: string, value: number): void; observe(name: string, value: number): void
add(name: string, value: string): void; add(name: string, value: string): void
getAll(): Record<string, number>; getAll(): Record<string, number>
};
function percentile(sorted: number[], p: number): number {
const index = p / 100 * (sorted.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) {
return sorted[lower]!;
}
return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower);
} }
const RESERVOIR_SIZE = 1024;
function percentile(sorted: number[], p: number): number {
const index = (p / 100) * (sorted.length - 1)
const lower = Math.floor(index)
const upper = Math.ceil(index)
if (lower === upper) {
return sorted[lower]!
}
return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower)
}
const RESERVOIR_SIZE = 1024
type Histogram = { type Histogram = {
reservoir: number[]; reservoir: number[]
count: number; count: number
sum: number; sum: number
min: number; min: number
max: number; max: number
}; }
export function createStatsStore(): StatsStore { export function createStatsStore(): StatsStore {
const metrics = new Map<string, number>(); const metrics = new Map<string, number>()
const histograms = new Map<string, Histogram>(); const histograms = new Map<string, Histogram>()
const sets = new Map<string, Set<string>>(); const sets = new Map<string, Set<string>>()
return { return {
increment(name: string, value = 1) { 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) { set(name: string, value: number) {
metrics.set(name, value); metrics.set(name, value)
}, },
observe(name: string, value: number) { observe(name: string, value: number) {
let h = histograms.get(name); let h = histograms.get(name)
if (!h) { if (!h) {
h = { h = { reservoir: [], count: 0, sum: 0, min: value, max: value }
reservoir: [], histograms.set(name, h)
count: 0,
sum: 0,
min: value,
max: value
};
histograms.set(name, h);
} }
h.count++; h.count++
h.sum += value; h.sum += value
if (value < h.min) { if (value < h.min) {
h.min = value; h.min = value
} }
if (value > h.max) { if (value > h.max) {
h.max = value; h.max = value
} }
// Reservoir sampling (Algorithm R) // Reservoir sampling (Algorithm R)
if (h.reservoir.length < RESERVOIR_SIZE) { if (h.reservoir.length < RESERVOIR_SIZE) {
h.reservoir.push(value); h.reservoir.push(value)
} else { } else {
const j = Math.floor(Math.random() * h.count); const j = Math.floor(Math.random() * h.count)
if (j < RESERVOIR_SIZE) { if (j < RESERVOIR_SIZE) {
h.reservoir[j] = value; h.reservoir[j] = value
} }
} }
}, },
add(name: string, value: string) { add(name: string, value: string) {
let s = sets.get(name); let s = sets.get(name)
if (!s) { if (!s) {
s = new Set(); s = new Set()
sets.set(name, s); sets.set(name, s)
} }
s.add(value); s.add(value)
}, },
getAll() { getAll() {
const result: Record<string, number> = Object.fromEntries(metrics); const result: Record<string, number> = Object.fromEntries(metrics)
for (const [name, h] of histograms) { for (const [name, h] of histograms) {
if (h.count === 0) { if (h.count === 0) {
continue; continue
} }
result[`${name}_count`] = h.count; result[`${name}_count`] = h.count
result[`${name}_min`] = h.min; result[`${name}_min`] = h.min
result[`${name}_max`] = h.max; result[`${name}_max`] = h.max
result[`${name}_avg`] = h.sum / h.count; result[`${name}_avg`] = h.sum / h.count
const sorted = [...h.reservoir].sort((a, b) => a - b); const sorted = [...h.reservoir].sort((a, b) => a - b)
result[`${name}_p50`] = percentile(sorted, 50); result[`${name}_p50`] = percentile(sorted, 50)
result[`${name}_p95`] = percentile(sorted, 95); result[`${name}_p95`] = percentile(sorted, 95)
result[`${name}_p99`] = percentile(sorted, 99); result[`${name}_p99`] = percentile(sorted, 99)
} }
for (const [name, s] of sets) { for (const [name, s] of sets) {
result[name] = s.size; result[name] = s.size
} }
return result;
} return result
}; },
}
} }
export const StatsContext = createContext<StatsStore | null>(null);
export const StatsContext = createContext<StatsStore | null>(null)
type Props = { type Props = {
store?: StatsStore; store?: StatsStore
children: React.ReactNode; 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 = <StatsContext.Provider value={store}>{children}</StatsContext.Provider>;
$[4] = children;
$[5] = store;
$[6] = t4;
} else {
t4 = $[6];
}
return t4;
} }
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 <StatsContext.Provider value={store}>{children}</StatsContext.Provider>
}
export function useStats(): StatsStore {
const store = useContext(StatsContext)
if (!store) { 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); export function useCounter(name: string): (value?: number) => void {
const store = useStats(); const store = useStats()
let t0; return useCallback(
if ($[0] !== name || $[1] !== store) { (value?: number) => store.increment(name, value),
t0 = value => store.increment(name, value); [store, name],
$[0] = name; )
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
} }
export function useGauge(name) {
const $ = _c(3); export function useGauge(name: string): (value: number) => void {
const store = useStats(); const store = useStats()
let t0; return useCallback((value: number) => store.set(name, value), [store, name])
if ($[0] !== name || $[1] !== store) {
t0 = value => store.set(name, value);
$[0] = name;
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
} }
export function useTimer(name) {
const $ = _c(3); export function useTimer(name: string): (value: number) => void {
const store = useStats(); const store = useStats()
let t0; return useCallback(
if ($[0] !== name || $[1] !== store) { (value: number) => store.observe(name, value),
t0 = value => store.observe(name, value); [store, name],
$[0] = name; )
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
} }
export function useSet(name) {
const $ = _c(3); export function useSet(name: string): (value: string) => void {
const store = useStats(); const store = useStats()
let t0; return useCallback((value: string) => store.add(name, value), [store, name])
if ($[0] !== name || $[1] !== store) {
t0 = value => store.add(name, value);
$[0] = name;
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
} }

View File

@@ -1,71 +1,58 @@
import { c as _c } from "react/compiler-runtime"; import React, {
import React, { createContext, useContext, useState, useSyncExternalStore } from 'react'; createContext,
import { createStore, type Store } from '../state/store.js'; useContext,
useState,
useSyncExternalStore,
} from 'react'
import { createStore, type Store } from '../state/store.js'
export type VoiceState = { export type VoiceState = {
voiceState: 'idle' | 'recording' | 'processing'; voiceState: 'idle' | 'recording' | 'processing'
voiceError: string | null; voiceError: string | null
voiceInterimTranscript: string; voiceInterimTranscript: string
voiceAudioLevels: number[]; voiceAudioLevels: number[]
voiceWarmingUp: boolean; voiceWarmingUp: boolean
}; }
const DEFAULT_STATE: VoiceState = { const DEFAULT_STATE: VoiceState = {
voiceState: 'idle', voiceState: 'idle',
voiceError: null, voiceError: null,
voiceInterimTranscript: '', voiceInterimTranscript: '',
voiceAudioLevels: [], voiceAudioLevels: [],
voiceWarmingUp: false voiceWarmingUp: false,
}; }
type VoiceStore = Store<VoiceState>;
const VoiceContext = createContext<VoiceStore | null>(null); type VoiceStore = Store<VoiceState>
const VoiceContext = createContext<VoiceStore | null>(null)
type Props = { type Props = {
children: React.ReactNode; 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 = <VoiceContext.Provider value={store}>{children}</VoiceContext.Provider>;
$[0] = children;
$[1] = store;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
} }
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<VoiceState>(DEFAULT_STATE))
return <VoiceContext.Provider value={store}>{children}</VoiceContext.Provider>
} }
function useVoiceStore() {
const store = useContext(VoiceContext); function useVoiceStore(): VoiceStore {
const store = useContext(VoiceContext)
if (!store) { 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 * Subscribe to a slice of voice state. Only re-renders when the selected
* value changes (compared via Object.is). * value changes (compared via Object.is).
*/ */
export function useVoiceState(selector) { export function useVoiceState<T>(selector: (state: VoiceState) => T): T {
const $ = _c(3); const store = useVoiceStore()
const store = useVoiceStore(); const get = () => selector(store.getState())
let t0; return useSyncExternalStore(store.subscribe, get, get)
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);
} }
/** /**
@@ -73,8 +60,10 @@ export function useVoiceState(selector) {
* store.setState is synchronous: callers can read getVoiceState() immediately * store.setState is synchronous: callers can read getVoiceState() immediately
* after to observe the new value (VoiceKeybindingHandler relies on this). * after to observe the new value (VoiceKeybindingHandler relies on this).
*/ */
export function useSetVoiceState() { export function useSetVoiceState(): (
return useVoiceStore().setState; 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 * useVoiceState (which subscribes), this doesn't cause re-renders — use
* inside event handlers that need to read state set earlier in the same tick. * inside event handlers that need to read state set earlier in the same tick.
*/ */
export function useGetVoiceState() { export function useGetVoiceState(): () => VoiceState {
return useVoiceStore().getState; return useVoiceStore().getState
} }

View File

@@ -1,25 +1,31 @@
import { c as _c } from "react/compiler-runtime"; import React from 'react'
import React from 'react'; import Link from './components/Link.js'
import Link from './components/Link.js'; import Text from './components/Text.js'
import Text from './components/Text.js'; import type { Color } from './styles.js'
import type { Color } from './styles.js'; import {
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js'; type NamedColor,
Parser,
type Color as TermioColor,
type TextStyle,
} from './termio.js'
type Props = { type Props = {
children: string; children: string
/** When true, force all text to be rendered with dim styling */ /** When true, force all text to be rendered with dim styling */
dimColor?: boolean; dimColor?: boolean
}; }
type SpanProps = { type SpanProps = {
color?: Color; color?: Color
backgroundColor?: Color; backgroundColor?: Color
dim?: boolean; dim?: boolean
bold?: boolean; bold?: boolean
italic?: boolean; italic?: boolean
underline?: boolean; underline?: boolean
strikethrough?: boolean; strikethrough?: boolean
inverse?: boolean; inverse?: boolean
hyperlink?: string; hyperlink?: string
}; }
/** /**
* Component that parses ANSI escape codes and renders them using Text components. * 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. * 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 }) { export const Ansi = React.memo(function Ansi({
const $ = _c(12); children,
const { dimColor,
children, }: Props): React.ReactNode {
dimColor if (typeof children !== 'string') {
} = t0; return dimColor ? (
if (typeof children !== "string") { <Text dim>{String(children)}</Text>
let t1; ) : (
if ($[0] !== children || $[1] !== dimColor) { <Text>{String(children)}</Text>
t1 = dimColor ? <Text dim={true}>{String(children)}</Text> : <Text>{String(children)}</Text>; )
$[0] = children; }
$[1] = dimColor;
$[2] = t1; if (children === '') {
} else { return null
t1 = $[2]; }
const spans = parseToSpans(children)
if (spans.length === 0) {
return null
}
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
return dimColor ? (
<Text dim>{spans[0]!.text}</Text>
) : (
<Text>{spans[0]!.text}</Text>
)
}
const content = spans.map((span, i) => {
const hyperlink = span.props.hyperlink
// When dimColor is forced, override the span's dim prop
if (dimColor) {
span.props.dim = true
} }
return t1; const hasTextProps = hasAnyTextProps(span.props)
}
if (children === "") { if (hyperlink) {
return null; return hasTextProps ? (
} <Link key={i} url={hyperlink}>
let t1; <StyledText
let t2; color={span.props.color}
if ($[3] !== children || $[4] !== dimColor) { backgroundColor={span.props.backgroundColor}
t2 = Symbol.for("react.early_return_sentinel"); dim={span.props.dim}
bb0: { bold={span.props.bold}
const spans = parseToSpans(children); italic={span.props.italic}
if (spans.length === 0) { underline={span.props.underline}
t2 = null; strikethrough={span.props.strikethrough}
break bb0; inverse={span.props.inverse}
} >
if (spans.length === 1 && !hasAnyProps(spans[0].props)) { {span.text}
t2 = dimColor ? <Text dim={true}>{spans[0].text}</Text> : <Text>{spans[0].text}</Text>; </StyledText>
break bb0; </Link>
} ) : (
let t3; <Link key={i} url={hyperlink}>
if ($[7] !== dimColor) { {span.text}
t3 = (span, i) => { </Link>
const hyperlink = span.props.hyperlink; )
if (dimColor) {
span.props.dim = true;
}
const hasTextProps = hasAnyTextProps(span.props);
if (hyperlink) {
return hasTextProps ? <Link key={i} url={hyperlink}><StyledText color={span.props.color} backgroundColor={span.props.backgroundColor} dim={span.props.dim} bold={span.props.bold} italic={span.props.italic} underline={span.props.underline} strikethrough={span.props.strikethrough} inverse={span.props.inverse}>{span.text}</StyledText></Link> : <Link key={i} url={hyperlink}>{span.text}</Link>;
}
return hasTextProps ? <StyledText key={i} color={span.props.color} backgroundColor={span.props.backgroundColor} dim={span.props.dim} bold={span.props.bold} italic={span.props.italic} underline={span.props.underline} strikethrough={span.props.strikethrough} inverse={span.props.inverse}>{span.text}</StyledText> : span.text;
};
$[7] = dimColor;
$[8] = t3;
} else {
t3 = $[8];
}
t1 = spans.map(t3);
} }
$[3] = children;
$[4] = dimColor; return hasTextProps ? (
$[5] = t1; <StyledText
$[6] = t2; key={i}
} else { color={span.props.color}
t1 = $[5]; backgroundColor={span.props.backgroundColor}
t2 = $[6]; dim={span.props.dim}
} bold={span.props.bold}
if (t2 !== Symbol.for("react.early_return_sentinel")) { italic={span.props.italic}
return t2; underline={span.props.underline}
} strikethrough={span.props.strikethrough}
const content = t1; inverse={span.props.inverse}
let t3; >
if ($[9] !== content || $[10] !== dimColor) { {span.text}
t3 = dimColor ? <Text dim={true}>{content}</Text> : <Text>{content}</Text>; </StyledText>
$[9] = content; ) : (
$[10] = dimColor; span.text
$[11] = t3; )
} else { })
t3 = $[11];
} return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>
return t3; })
});
type Span = { type Span = {
text: string; text: string
props: SpanProps; props: SpanProps
}; }
/** /**
* Parse an ANSI string into spans using the termio parser. * Parse an ANSI string into spans using the termio parser.
*/ */
function parseToSpans(input: string): Span[] { function parseToSpans(input: string): Span[] {
const parser = new Parser(); const parser = new Parser()
const actions = parser.feed(input); const actions = parser.feed(input)
const spans: Span[] = []; const spans: Span[] = []
let currentHyperlink: string | undefined;
let currentHyperlink: string | undefined
for (const action of actions) { for (const action of actions) {
if (action.type === 'link') { if (action.type === 'link') {
if (action.action.type === 'start') { if (action.action.type === 'start') {
currentHyperlink = action.action.url; currentHyperlink = action.action.url
} else { } else {
currentHyperlink = undefined; currentHyperlink = undefined
} }
continue; continue
} }
if (action.type === 'text') { if (action.type === 'text') {
const text = action.graphemes.map(g => g.value).join(''); const text = action.graphemes.map(g => g.value).join('')
if (!text) continue; if (!text) continue
const props = textStyleToSpanProps(action.style);
const props = textStyleToSpanProps(action.style)
if (currentHyperlink) { if (currentHyperlink) {
props.hyperlink = currentHyperlink; props.hyperlink = currentHyperlink
} }
// Try to merge with previous span if props match // 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)) { if (lastSpan && propsEqual(lastSpan.props, props)) {
lastSpan.text += text; lastSpan.text += text
} else { } else {
spans.push({ spans.push({ text, props })
text,
props
});
} }
} }
} }
return spans;
return spans
} }
/** /**
* Convert termio's TextStyle to SpanProps. * Convert termio's TextStyle to SpanProps.
*/ */
function textStyleToSpanProps(style: TextStyle): SpanProps { function textStyleToSpanProps(style: TextStyle): SpanProps {
const props: SpanProps = {}; const props: SpanProps = {}
if (style.bold) props.bold = true;
if (style.dim) props.dim = true; if (style.bold) props.bold = true
if (style.italic) props.italic = true; if (style.dim) props.dim = true
if (style.underline !== 'none') props.underline = true; if (style.italic) props.italic = true
if (style.strikethrough) props.strikethrough = true; if (style.underline !== 'none') props.underline = true
if (style.inverse) props.inverse = true; if (style.strikethrough) props.strikethrough = true
const fgColor = colorToString(style.fg); if (style.inverse) props.inverse = true
if (fgColor) props.color = fgColor;
const bgColor = colorToString(style.bg); const fgColor = colorToString(style.fg)
if (bgColor) props.backgroundColor = bgColor; if (fgColor) props.color = fgColor
return props;
const bgColor = colorToString(style.bg)
if (bgColor) props.backgroundColor = bgColor
return props
} }
// Map termio named colors to the ansi: format // Map termio named colors to the ansi: format
@@ -187,8 +204,8 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
brightBlue: 'ansi:blueBright', brightBlue: 'ansi:blueBright',
brightMagenta: 'ansi:magentaBright', brightMagenta: 'ansi:magentaBright',
brightCyan: 'ansi:cyanBright', brightCyan: 'ansi:cyanBright',
brightWhite: 'ansi:whiteBright' brightWhite: 'ansi:whiteBright',
}; }
/** /**
* Convert termio's Color to the string format used by Ink. * Convert termio's Color to the string format used by Ink.
@@ -196,13 +213,13 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
function colorToString(color: TermioColor): Color | undefined { function colorToString(color: TermioColor): Color | undefined {
switch (color.type) { switch (color.type) {
case 'named': case 'named':
return NAMED_COLOR_MAP[color.name] as Color; return NAMED_COLOR_MAP[color.name] as Color
case 'indexed': case 'indexed':
return `ansi256(${color.index})` as Color; return `ansi256(${color.index})` as Color
case 'rgb': case 'rgb':
return `rgb(${color.r},${color.g},${color.b})` as Color; return `rgb(${color.r},${color.g},${color.b})` as Color
case 'default': case 'default':
return undefined; return undefined
} }
} }
@@ -210,82 +227,81 @@ function colorToString(color: TermioColor): Color | undefined {
* Check if two SpanProps are equal for merging. * Check if two SpanProps are equal for merging.
*/ */
function propsEqual(a: SpanProps, b: SpanProps): boolean { 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 { 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 { 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 // Text style props without weight (bold/dim) - these are handled separately
type BaseTextStyleProps = { type BaseTextStyleProps = {
color?: Color; color?: Color
backgroundColor?: Color; backgroundColor?: Color
italic?: boolean; italic?: boolean
underline?: boolean; underline?: boolean
strikethrough?: boolean; strikethrough?: boolean
inverse?: boolean; inverse?: boolean
}; }
// Wrapper component that handles bold/dim mutual exclusivity for Text // Wrapper component that handles bold/dim mutual exclusivity for Text
function StyledText(t0) { function StyledText({
const $ = _c(14); bold,
let bold; dim,
let children; children,
let dim; ...rest
let rest; }: BaseTextStyleProps & {
if ($[0] !== t0) { bold?: boolean
({ dim?: boolean
bold, children: string
dim, }): React.ReactNode {
children, // dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
...rest
} = t0);
$[0] = t0;
$[1] = bold;
$[2] = children;
$[3] = dim;
$[4] = rest;
} else {
bold = $[1];
children = $[2];
dim = $[3];
rest = $[4];
}
if (dim) { if (dim) {
let t1; return (
if ($[5] !== children || $[6] !== rest) { <Text {...rest} dim>
t1 = <Text {...rest} dim={true}>{children}</Text>; {children}
$[5] = children; </Text>
$[6] = rest; )
$[7] = t1;
} else {
t1 = $[7];
}
return t1;
} }
if (bold) { if (bold) {
let t1; return (
if ($[8] !== children || $[9] !== rest) { <Text {...rest} bold>
t1 = <Text {...rest} bold={true}>{children}</Text>; {children}
$[8] = children; </Text>
$[9] = rest; )
$[10] = t1;
} else {
t1 = $[10];
}
return t1;
} }
let t1; return <Text {...rest}>{children}</Text>
if ($[11] !== children || $[12] !== rest) {
t1 = <Text {...rest}>{children}</Text>;
$[11] = children;
$[12] = rest;
$[13] = t1;
} else {
t1 = $[13];
}
return t1;
} }

View File

@@ -1,14 +1,23 @@
import { c as _c } from "react/compiler-runtime"; import React, {
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react'; type PropsWithChildren,
import instances from '../instances.js'; useContext,
import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'; useInsertionEffect,
import { TerminalWriteContext } from '../useTerminalNotification.js'; } from 'react'
import Box from './Box.js'; import instances from '../instances.js'
import { TerminalSizeContext } from './TerminalSizeContext.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<{ type Props = PropsWithChildren<{
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */ /** Enable SGR mouse tracking (wheel + click/drag). Default true. */
mouseTracking?: boolean; mouseTracking?: boolean
}>; }>
/** /**
* Run children in the terminal's alternate screen buffer, constrained to * 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 * from scrolling content) and so signal-exit cleanup can exit the alt
* screen if the component's own unmount doesn't run. * screen if the component's own unmount doesn't run.
*/ */
export function AlternateScreen(t0) { export function AlternateScreen({
const $ = _c(7); children,
const { mouseTracking = true,
children, }: Props): React.ReactNode {
mouseTracking: t1 const size = useContext(TerminalSizeContext)
} = t0; const writeRaw = useContext(TerminalWriteContext)
const mouseTracking = t1 === undefined ? true : t1;
const size = useContext(TerminalSizeContext); // useInsertionEffect (not useLayoutEffect): react-reconciler calls
const writeRaw = useContext(TerminalWriteContext); // resetAfterCommit between the mutation and layout commit phases, and
let t2; // Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that
let t3; // first onRender fires BEFORE this effect — writing a full frame to the
if ($[0] !== mouseTracking || $[1] !== writeRaw) { // main screen with altScreen=false. That frame is preserved when we
t2 = () => { // enter alt screen and revealed on exit as a broken view. Insertion
const ink = instances.get(process.stdout); // effects fire during the mutation phase, before resetAfterCommit, so
if (!writeRaw) { // ENTER_ALT_SCREEN reaches the terminal before the first frame does.
return; // Cleanup timing is unchanged: both insertion and layout effect cleanup
} // run in the mutation phase on unmount, before resetAfterCommit.
writeRaw(ENTER_ALT_SCREEN + "\x1B[2J\x1B[H" + (mouseTracking ? ENABLE_MOUSE_TRACKING : "")); useInsertionEffect(() => {
ink?.setAltScreenActive(true, mouseTracking); const ink = instances.get(process.stdout)
return () => { if (!writeRaw) return
ink?.setAltScreenActive(false);
ink?.clearTextSelection(); writeRaw(
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : "") + EXIT_ALT_SCREEN); ENTER_ALT_SCREEN +
}; '\x1b[2J\x1b[H' +
}; (mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
t3 = [writeRaw, mouseTracking]; )
$[0] = mouseTracking; ink?.setAltScreenActive(true, mouseTracking)
$[1] = writeRaw;
$[2] = t2; return () => {
$[3] = t3; ink?.setAltScreenActive(false)
} else { ink?.clearTextSelection()
t2 = $[2]; writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
t3 = $[3]; }
} }, [writeRaw, mouseTracking])
useInsertionEffect(t2, t3);
const t4 = size?.rows ?? 24; return (
let t5; <Box
if ($[4] !== children || $[5] !== t4) { flexDirection="column"
t5 = <Box flexDirection="column" height={t4} width="100%" flexShrink={0}>{children}</Box>; height={size?.rows ?? 24}
$[4] = children; width="100%"
$[5] = t4; flexShrink={0}
$[6] = t5; >
} else { {children}
t5 = $[6]; </Box>
} )
return t5;
} }

View File

@@ -1,223 +1,290 @@
import React, { PureComponent, type ReactNode } from 'react'; import React, { PureComponent, type ReactNode } from 'react'
import { updateLastInteractionTime } from '../../bootstrap/state.js'; import { updateLastInteractionTime } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.js'; import { logForDebugging } from '../../utils/debug.js'
import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'; import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'
import { isEnvTruthy } from '../../utils/envUtils.js'; import { isEnvTruthy } from '../../utils/envUtils.js'
import { isMouseClicksDisabled } from '../../utils/fullscreen.js'; import { isMouseClicksDisabled } from '../../utils/fullscreen.js'
import { logError } from '../../utils/log.js'; import { logError } from '../../utils/log.js'
import { EventEmitter } from '../events/emitter.js'; import { EventEmitter } from '../events/emitter.js'
import { InputEvent } from '../events/input-event.js'; import { InputEvent } from '../events/input-event.js'
import { TerminalFocusEvent } from '../events/terminal-focus-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 {
import reconciler from '../reconciler.js'; INITIAL_STATE,
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'; type ParsedInput,
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'; type ParsedKey,
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'; type ParsedMouse,
import { TerminalQuerier, xtversion } from '../terminal-querier.js'; parseMultipleKeypresses,
import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT } from '../termio/csi.js'; } from '../parse-keypress.js'
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js'; import reconciler from '../reconciler.js'
import AppContext from './AppContext.js'; import {
import { ClockProvider } from './ClockContext.js'; finishSelection,
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js'; hasSelection,
import ErrorOverview from './ErrorOverview.js'; type SelectionState,
import StdinContext from './StdinContext.js'; startSelection,
import { TerminalFocusProvider } from './TerminalFocusContext.js'; } from '../selection.js'
import { TerminalSizeContext } from './TerminalSizeContext.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) // 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 // After this many milliseconds of stdin silence, the next chunk triggers
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach, // a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
// ssh reconnect, and laptop wake — the terminal resets DEC private modes // 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 no signal reaches us. 5s is well above normal inter-keystroke gaps
// but short enough that the first scroll after reattach works. // but short enough that the first scroll after reattach works.
const STDIN_RESUME_GAP_MS = 5000; const STDIN_RESUME_GAP_MS = 5000
type Props = { type Props = {
readonly children: ReactNode; readonly children: ReactNode
readonly stdin: NodeJS.ReadStream; readonly stdin: NodeJS.ReadStream
readonly stdout: NodeJS.WriteStream; readonly stdout: NodeJS.WriteStream
readonly stderr: NodeJS.WriteStream; readonly stderr: NodeJS.WriteStream
readonly exitOnCtrlC: boolean; readonly exitOnCtrlC: boolean
readonly onExit: (error?: Error) => void; readonly onExit: (error?: Error) => void
readonly terminalColumns: number; readonly terminalColumns: number
readonly terminalRows: number; readonly terminalRows: number
// Text selection state. App mutates this directly from mouse events // Text selection state. App mutates this directly from mouse events
// and calls onSelectionChange to trigger a repaint. Mouse events only // and calls onSelectionChange to trigger a repaint. Mouse events only
// arrive when <AlternateScreen> (or similar) enables mouse tracking, // arrive when <AlternateScreen> (or similar) enables mouse tracking,
// so the handler is always wired but dormant until tracking is on. // so the handler is always wired but dormant until tracking is on.
readonly selection: SelectionState; readonly selection: SelectionState
readonly onSelectionChange: () => void; readonly onSelectionChange: () => void
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles // Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
// onClick handlers. Returns true if a DOM handler consumed the click. // onClick handlers. Returns true if a DOM handler consumed the click.
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick // No-op (returns false) outside fullscreen mode (Ink.dispatchClick
// gates on altScreenActive). // 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 // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
// DOM elements. Called for mode-1003 motion events with no button held. // DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). // 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 // Look up the OSC 8 hyperlink at (col, row) synchronously at click
// time. Returns the URL or undefined. The browser-open is deferred by // time. Returns the URL or undefined. The browser-open is deferred by
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it. // 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. // 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 // 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 // the word under the cursor; count=3 selects the line. Ink reads the
// screen buffer to find word/line boundaries and mutates selection, // screen buffer to find word/line boundaries and mutates selection,
// setting isDragging=true so a subsequent drag extends by word/line. // 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 // Called on drag-motion. Mode-aware: char mode updates focus to the
// exact cell; word/line mode snaps to word/line boundaries. Needs // exact cell; word/line mode snaps to word/line boundaries. Needs
// screen-buffer access (word boundaries) so lives on Ink, not here. // 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. // Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap.
// Ink re-asserts terminal modes: extended key reporting, and (when in // Ink re-asserts terminal modes: extended key reporting, and (when in
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the // fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
// terminal side. Optional so testing.tsx doesn't need to stub it. // 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 // Receives the declared native-cursor position from useDeclaredCursor
// so ink.tsx can park the terminal cursor there after each frame. // so ink.tsx can park the terminal cursor there after each frame.
// Enables IME composition at the input caret and lets screen readers / // Enables IME composition at the input caret and lets screen readers /
// magnifiers track the input. Optional so testing.tsx doesn't stub it. // 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 // Dispatch a keyboard event through the DOM tree. Called for each
// parsed key alongside the legacy EventEmitter path. // 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 // Multi-click detection thresholds. 500ms is the macOS default; a small
// position tolerance allows for trackpad jitter between clicks. // position tolerance allows for trackpad jitter between clicks.
const MULTI_CLICK_TIMEOUT_MS = 500; const MULTI_CLICK_TIMEOUT_MS = 500
const MULTI_CLICK_DISTANCE = 1; const MULTI_CLICK_DISTANCE = 1
type State = { type State = {
readonly error?: Error; readonly error?: Error
}; }
// Root component for all Ink apps // Root component for all Ink apps
// It renders stdin and stdout contexts, so that children can access them if needed // It renders stdin and stdout contexts, so that children can access them if needed
// It also handles Ctrl+C exiting and cursor visibility // It also handles Ctrl+C exiting and cursor visibility
export default class App extends PureComponent<Props, State> { export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp'; static displayName = 'InternalApp'
static getDerivedStateFromError(error: Error) { static getDerivedStateFromError(error: Error) {
return { return { error }
error
};
} }
override state = { override state = {
error: undefined error: undefined,
}; }
// Count how many components enabled raw mode to avoid disabling // Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore // raw mode until all components don't need it anymore
rawModeEnabledCount = 0; rawModeEnabledCount = 0
internal_eventEmitter = new EventEmitter();
keyParseState = INITIAL_STATE; internal_eventEmitter = new EventEmitter()
keyParseState = INITIAL_STATE
// Timer for flushing incomplete escape sequences // Timer for flushing incomplete escape sequences
incompleteEscapeTimer: NodeJS.Timeout | null = null; incompleteEscapeTimer: NodeJS.Timeout | null = null
// Timeout durations for incomplete sequences (ms) // Timeout durations for incomplete sequences (ms)
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations
// Terminal query/response dispatch. Responses arrive on stdin (parsed // Terminal query/response dispatch. Responses arrive on stdin (parsed
// out by parse-keypress) and are routed to pending promise resolvers. // 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 // Multi-click tracking for double/triple-click text selection. A click
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous // within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
// click increments clickCount; otherwise it resets to 1. // click increments clickCount; otherwise it resets to 1.
lastClickTime = 0; lastClickTime = 0
lastClickCol = -1; lastClickCol = -1
lastClickRow = -1; lastClickRow = -1
clickCount = 0; clickCount = 0
// Deferred hyperlink-open timer — cancelled if a second click arrives // Deferred hyperlink-open timer — cancelled if a second click arrives
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects // within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
// the word without also opening the browser). DOM onClick dispatch is // the word without also opening the browser). DOM onClick dispatch is
// NOT deferred — it returns true from onClickAt and skips this timer. // NOT deferred — it returns true from onClickAt and skips this timer.
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null; pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null
// Last mode-1003 motion position. Terminals already dedupe to cell // Last mode-1003 motion position. Terminals already dedupe to cell
// granularity but this also lets us skip dispatchHover entirely on // granularity but this also lets us skip dispatchHover entirely on
// repeat events (drag-then-release at same cell, etc.). // repeat events (drag-then-release at same cell, etc.).
lastHoverCol = -1; lastHoverCol = -1
lastHoverRow = -1; lastHoverRow = -1
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
// ssh reconnect, laptop wake) and trigger terminal mode re-assert. // ssh reconnect, laptop wake) and trigger terminal mode re-assert.
// Initialized to now so startup doesn't false-trigger. // Initialized to now so startup doesn't false-trigger.
lastStdinTime = Date.now(); lastStdinTime = Date.now()
// Determines if TTY is supported on the provided stdin // Determines if TTY is supported on the provided stdin
isRawModeSupported(): boolean { isRawModeSupported(): boolean {
return this.props.stdin.isTTY; return this.props.stdin.isTTY
} }
override render() { override render() {
return <TerminalSizeContext.Provider value={{ return (
columns: this.props.terminalColumns, <TerminalSizeContext.Provider
rows: this.props.terminalRows value={{
}}> columns: this.props.terminalColumns,
<AppContext.Provider value={{ rows: this.props.terminalRows,
exit: this.handleExit }}
}}> >
<StdinContext.Provider value={{ <AppContext.Provider
stdin: this.props.stdin, value={{
setRawMode: this.handleSetRawMode, exit: this.handleExit,
isRawModeSupported: this.isRawModeSupported(), }}
internal_exitOnCtrlC: this.props.exitOnCtrlC, >
internal_eventEmitter: this.internal_eventEmitter, <StdinContext.Provider
internal_querier: this.querier value={{
}}> stdin: this.props.stdin,
setRawMode: this.handleSetRawMode,
isRawModeSupported: this.isRawModeSupported(),
internal_exitOnCtrlC: this.props.exitOnCtrlC,
internal_eventEmitter: this.internal_eventEmitter,
internal_querier: this.querier,
}}
>
<TerminalFocusProvider> <TerminalFocusProvider>
<ClockProvider> <ClockProvider>
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}> <CursorDeclarationContext.Provider
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children} value={this.props.onCursorDeclaration ?? (() => {})}
>
{this.state.error ? (
<ErrorOverview error={this.state.error as Error} />
) : (
this.props.children
)}
</CursorDeclarationContext.Provider> </CursorDeclarationContext.Provider>
</ClockProvider> </ClockProvider>
</TerminalFocusProvider> </TerminalFocusProvider>
</StdinContext.Provider> </StdinContext.Provider>
</AppContext.Provider> </AppContext.Provider>
</TerminalSizeContext.Provider>; </TerminalSizeContext.Provider>
)
} }
override componentDidMount() { override componentDidMount() {
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools // 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)) { if (
this.props.stdout.write(HIDE_CURSOR); this.props.stdout.isTTY &&
!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
) {
this.props.stdout.write(HIDE_CURSOR)
} }
} }
override componentWillUnmount() { override componentWillUnmount() {
if (this.props.stdout.isTTY) { if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR); this.props.stdout.write(SHOW_CURSOR)
} }
// Clear any pending timers // Clear any pending timers
if (this.incompleteEscapeTimer) { if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer); clearTimeout(this.incompleteEscapeTimer)
this.incompleteEscapeTimer = null; this.incompleteEscapeTimer = null
} }
if (this.pendingHyperlinkTimer) { if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer); clearTimeout(this.pendingHyperlinkTimer)
this.pendingHyperlinkTimer = null; this.pendingHyperlinkTimer = null
} }
// ignore calling setRawMode on an handle stdin it cannot be called // ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) { if (this.isRawModeSupported()) {
this.handleSetRawMode(false); this.handleSetRawMode(false)
} }
} }
override componentDidCatch(error: Error) { override componentDidCatch(error: Error) {
this.handleExit(error); this.handleExit(error)
} }
handleSetRawMode = (isEnabled: boolean): void => { handleSetRawMode = (isEnabled: boolean): void => {
const { const { stdin } = this.props
stdin
} = this.props;
if (!this.isRawModeSupported()) { if (!this.isRawModeSupported()) {
if (stdin === process.stdin) { 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 { } 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) { if (isEnabled) {
// Ensure raw mode is enabled only once // Ensure raw mode is enabled only once
if (this.rawModeEnabledCount === 0) { if (this.rawModeEnabledCount === 0) {
@@ -225,22 +292,22 @@ export default class App extends PureComponent<Props, State> {
// Both use the same stdin 'readable' + read() pattern, so they can't // Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- our handler would drain stdin before Ink's can see it. // coexist -- our handler would drain stdin before Ink's can see it.
// The buffered text is preserved for REPL.tsx via consumeEarlyInput(). // The buffered text is preserved for REPL.tsx via consumeEarlyInput().
stopCapturingEarlyInput(); stopCapturingEarlyInput()
stdin.ref(); stdin.ref()
stdin.setRawMode(true); stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable); stdin.addListener('readable', this.handleReadable)
// Enable bracketed paste mode // Enable bracketed paste mode
this.props.stdout.write(EBP); this.props.stdout.write(EBP)
// Enable terminal focus reporting (DECSET 1004) // Enable terminal focus reporting (DECSET 1004)
this.props.stdout.write(EFE); this.props.stdout.write(EFE)
// Enable extended key reporting so ctrl+shift+<letter> is // Enable extended key reporting so ctrl+shift+<letter> is
// distinguishable from ctrl+<letter>. We write both the kitty stack // distinguishable from ctrl+<letter>. We write both the kitty stack
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
// terminals honor whichever they implement (tmux only accepts the // terminals honor whichever they implement (tmux only accepts the
// latter). // latter).
if (supportsExtendedKeys()) { if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD); this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS); this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
} }
// Probe terminal identity. XTVERSION survives SSH (query/reply goes // Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
@@ -251,41 +318,45 @@ export default class App extends PureComponent<Props, State> {
// init sequence completes — avoids interleaving with alt-screen/mouse // init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle. // tracking enable writes that may happen in the same render cycle.
setImmediate(() => { 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) { if (r) {
setXtversionName(r.name); setXtversionName(r.name)
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`); logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else { } 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 // Disable raw mode only when no components left that are using it
if (--this.rawModeEnabledCount === 0) { if (--this.rawModeEnabledCount === 0) {
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS); this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
this.props.stdout.write(DISABLE_KITTY_KEYBOARD); this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
// Disable terminal focus reporting (DECSET 1004) // Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE); this.props.stdout.write(DFE)
// Disable bracketed paste mode // Disable bracketed paste mode
this.props.stdout.write(DBP); this.props.stdout.write(DBP)
stdin.setRawMode(false); stdin.setRawMode(false)
stdin.removeListener('readable', this.handleReadable); stdin.removeListener('readable', this.handleReadable)
stdin.unref(); stdin.unref()
} }
}; }
// Helper to flush incomplete escape sequences // Helper to flush incomplete escape sequences
flushIncomplete = (): void => { flushIncomplete = (): void => {
// Clear the timer reference // Clear the timer reference
this.incompleteEscapeTimer = null; this.incompleteEscapeTimer = null
// Only proceed if we have incomplete sequences // 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 // Fullscreen: if stdin has data waiting, it's almost certainly the
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a // continuation of the buffered sequence (e.g. `[<64;74;16M` after a
@@ -296,20 +367,23 @@ export default class App extends PureComponent<Props, State> {
// drain stdin next and clear this timer. Prevents both the spurious // drain stdin next and clear this timer. Prevents both the spurious
// Escape key and the lost scroll event. // Escape key and the lost scroll event.
if (this.props.stdin.readableLength > 0) { if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT); this.incompleteEscapeTimer = setTimeout(
return; this.flushIncomplete,
this.NORMAL_TIMEOUT,
)
return
} }
// Process incomplete as a flush operation (input=null) // Process incomplete as a flush operation (input=null)
// This reuses all existing parsing logic // This reuses all existing parsing logic
this.processInput(null); this.processInput(null)
}; }
// Process input through the parser and handle the results // Process input through the parser and handle the results
processInput = (input: string | Buffer | null): void => { processInput = (input: string | Buffer | null): void => {
// Parse input using our state machine // Parse input using our state machine
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input); const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input)
this.keyParseState = newState; this.keyParseState = newState
// Process ALL keys in a SINGLE discreteUpdates call to prevent // Process ALL keys in a SINGLE discreteUpdates call to prevent
// "Maximum update depth exceeded" error when many keys arrive at once // "Maximum update depth exceeded" error when many keys arrive at once
@@ -317,87 +391,106 @@ export default class App extends PureComponent<Props, State> {
// This batches all state updates from handleInput and all useInput // This batches all state updates from handleInput and all useInput
// listeners together within one high-priority update context. // listeners together within one high-priority update context.
if (keys.length > 0) { 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 we have incomplete escape sequences, set a timer to flush them
if (this.keyParseState.incomplete) { if (this.keyParseState.incomplete) {
// Cancel any existing timer first // Cancel any existing timer first
if (this.incompleteEscapeTimer) { 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 => { handleReadable = (): void => {
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake). // Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
// The terminal may have reset DEC private modes; re-assert mouse // The terminal may have reset DEC private modes; re-assert mouse
// tracking. Checked before the read loop so one Date.now() covers // tracking. Checked before the read loop so one Date.now() covers
// all chunks in this readable event. // all chunks in this readable event.
const now = Date.now(); const now = Date.now()
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) { if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.(); this.props.onStdinResume?.()
} }
this.lastStdinTime = now; this.lastStdinTime = now
try { try {
let chunk; let chunk
while ((chunk = this.props.stdin.read() as string | null) !== null) { while ((chunk = this.props.stdin.read() as string | null) !== null) {
// Process the input chunk // Process the input chunk
this.processInput(chunk); this.processInput(chunk)
} }
} catch (error) { } catch (error) {
// In Bun, an uncaught throw inside a stream 'readable' handler can // In Bun, an uncaught throw inside a stream 'readable' handler can
// permanently wedge the stream: data stays buffered and 'readable' // permanently wedge the stream: data stays buffered and 'readable'
// never re-emits. Catching here ensures the stream stays healthy so // never re-emits. Catching here ensures the stream stays healthy so
// subsequent keystrokes are still delivered. // subsequent keystrokes are still delivered.
logError(error); logError(error)
// Re-attach the listener in case the exception detached it. // Re-attach the listener in case the exception detached it.
// Bun may remove the listener after an error; without this, // Bun may remove the listener after an error; without this,
// the session freezes permanently (stdin reader dead, event loop alive). // the session freezes permanently (stdin reader dead, event loop alive).
const { const { stdin } = this.props
stdin if (
} = this.props; this.rawModeEnabledCount > 0 &&
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) { !stdin.listeners('readable').includes(this.handleReadable)
logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', { ) {
level: 'warn' logForDebugging(
}); 'handleReadable: re-attaching stdin readable listener after error recovery',
stdin.addListener('readable', this.handleReadable); { level: 'warn' },
)
stdin.addListener('readable', this.handleReadable)
} }
} }
}; }
handleInput = (input: string | undefined): void => { handleInput = (input: string | undefined): void => {
// Exit on Ctrl+C // Exit on Ctrl+C
if (input === '\x03' && this.props.exitOnCtrlC) { if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit(); this.handleExit()
} }
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the // Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
// parsed key to support both raw (\x1a) and CSI u format from Kitty // parsed key to support both raw (\x1a) and CSI u format from Kitty
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm) // keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
}; }
handleExit = (error?: Error): void => { handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) { if (this.isRawModeSupported()) {
this.handleSetRawMode(false); this.handleSetRawMode(false)
} }
this.props.onExit(error);
}; this.props.onExit(error)
}
handleTerminalFocus = (isFocused: boolean): void => { handleTerminalFocus = (isFocused: boolean): void => {
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context) // setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
// and Clock (interval speed) — no App setState needed. // and Clock (interval speed) — no App setState needed.
setTerminalFocused(isFocused); setTerminalFocused(isFocused)
}; }
handleSuspend = (): void => { handleSuspend = (): void => {
if (!this.isRawModeSupported()) { if (!this.isRawModeSupported()) {
return; return
} }
// Store the exact raw mode count to restore it properly // Store the exact raw mode count to restore it properly
const rawModeCountBeforeSuspend = this.rawModeEnabledCount; const rawModeCountBeforeSuspend = this.rawModeEnabledCount
// Completely disable raw mode before suspending // Completely disable raw mode before suspending
while (this.rawModeEnabledCount > 0) { while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false); this.handleSetRawMode(false)
} }
// Show cursor, disable focus reporting, and disable mouse tracking // Show cursor, disable focus reporting, and disable mouse tracking
@@ -406,108 +499,125 @@ export default class App extends PureComponent<Props, State> {
// it, SGR mouse sequences would appear as garbled text at the // it, SGR mouse sequences would appear as garbled text at the
// shell prompt while suspended. // shell prompt while suspended.
if (this.props.stdout.isTTY) { 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 // 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 // Set up resume handler
const resumeHandler = () => { const resumeHandler = () => {
// Restore raw mode to exact previous state // Restore raw mode to exact previous state
for (let i = 0; i < rawModeCountBeforeSuspend; i++) { for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
if (this.isRawModeSupported()) { if (this.isRawModeSupported()) {
this.handleSetRawMode(true); this.handleSetRawMode(true)
} }
} }
// Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming // Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming
if (this.props.stdout.isTTY) { if (this.props.stdout.isTTY) {
if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { 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 // 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 // Emit resume event for Claude Code to handle
this.internal_eventEmitter.emit('resume'); this.internal_eventEmitter.emit('resume')
process.removeListener('SIGCONT', resumeHandler);
}; process.removeListener('SIGCONT', resumeHandler)
process.on('SIGCONT', resumeHandler); }
process.kill(process.pid, 'SIGSTOP');
}; process.on('SIGCONT', resumeHandler)
process.kill(process.pid, 'SIGSTOP')
}
} }
// Helper to process all keys within a single discrete update context. // Helper to process all keys within a single discrete update context.
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d) // 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. // Update interaction time for notification timeout tracking.
// This is called from the central input handler to avoid having multiple // This is called from the central input handler to avoid having multiple
// stdin listeners that can cause race conditions and dropped input. // stdin listeners that can cause race conditions and dropped input.
// Terminal responses (kind: 'response') are automated, not user input. // Terminal responses (kind: 'response') are automated, not user input.
// Mode-1003 no-button motion is also excluded — passive cursor drift is // Mode-1003 no-button motion is also excluded — passive cursor drift is
// not engagement (would suppress idle notifications + defer housekeeping). // 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))) { if (
updateLastInteractionTime(); items.some(
i =>
i.kind === 'key' ||
(i.kind === 'mouse' &&
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
)
) {
updateLastInteractionTime()
} }
for (const item of items) { for (const item of items) {
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user // Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
// input — route them to the querier to resolve pending promises. // input — route them to the querier to resolve pending promises.
if (item.kind === 'response') { if (item.kind === 'response') {
app.querier.onResponse(item.response); app.querier.onResponse(item.response)
continue; continue
} }
// Mouse click/drag events update selection state (fullscreen only). // Mouse click/drag events update selection state (fullscreen only).
// Terminal sends 1-indexed col/row; convert to 0-indexed for the // Terminal sends 1-indexed col/row; convert to 0-indexed for the
// screen buffer. Button bit 0x20 = drag (motion while button held). // screen buffer. Button bit 0x20 = drag (motion while button held).
if (item.kind === 'mouse') { if (item.kind === 'mouse') {
handleMouseEvent(app, item); handleMouseEvent(app, item)
continue; continue
} }
const sequence = item.sequence;
const sequence = item.sequence
// Handle terminal focus events (DECSET 1004) // Handle terminal focus events (DECSET 1004)
if (sequence === FOCUS_IN) { if (sequence === FOCUS_IN) {
app.handleTerminalFocus(true); app.handleTerminalFocus(true)
const event = new TerminalFocusEvent('terminalfocus'); const event = new TerminalFocusEvent('terminalfocus')
app.internal_eventEmitter.emit('terminalfocus', event); app.internal_eventEmitter.emit('terminalfocus', event)
continue; continue
} }
if (sequence === FOCUS_OUT) { if (sequence === FOCUS_OUT) {
app.handleTerminalFocus(false); app.handleTerminalFocus(false)
// Defensive: if we lost the release event (mouse released outside // Defensive: if we lost the release event (mouse released outside
// terminal window — some emulators drop it rather than capturing the // terminal window — some emulators drop it rather than capturing the
// pointer), focus-out is the next observable signal that the drag is // pointer), focus-out is the next observable signal that the drag is
// over. Without this, drag-to-scroll's timer runs until the scroll // over. Without this, drag-to-scroll's timer runs until the scroll
// boundary is hit. // boundary is hit.
if (app.props.selection.isDragging) { if (app.props.selection.isDragging) {
finishSelection(app.props.selection); finishSelection(app.props.selection)
app.props.onSelectionChange(); app.props.onSelectionChange()
} }
const event = new TerminalFocusEvent('terminalblur'); const event = new TerminalFocusEvent('terminalblur')
app.internal_eventEmitter.emit('terminalblur', event); app.internal_eventEmitter.emit('terminalblur', event)
continue; continue
} }
// Failsafe: if we receive input, the terminal must be focused // Failsafe: if we receive input, the terminal must be focused
if (!getTerminalFocused()) { if (!getTerminalFocused()) {
setTerminalFocused(true); setTerminalFocused(true)
} }
// Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and // Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and
// CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals // CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) { if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
app.handleSuspend(); app.handleSuspend()
continue; continue
} }
app.handleInput(sequence);
const event = new InputEvent(item); app.handleInput(sequence)
app.internal_eventEmitter.emit('input', event); const event = new InputEvent(item)
app.internal_eventEmitter.emit('input', event)
// Also dispatch through the DOM tree so onKeyDown handlers fire. // 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 { export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling while keeping wheel scroll (which goes // Allow disabling click handling while keeping wheel scroll (which goes
// through the keybinding system as 'wheelup'/'wheeldown', not here). // through the keybinding system as 'wheelup'/'wheeldown', not here).
if (isMouseClicksDisabled()) return; if (isMouseClicksDisabled()) return
const sel = app.props.selection;
const sel = app.props.selection
// Terminal coords are 1-indexed; screen buffer is 0-indexed // Terminal coords are 1-indexed; screen buffer is 0-indexed
const col = m.col - 1; const col = m.col - 1
const row = m.row - 1; const row = m.row - 1
const baseButton = m.button & 0x03; const baseButton = m.button & 0x03
if (m.action === 'press') { if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) { if ((m.button & 0x20) !== 0 && baseButton === 3) {
// Mode-1003 motion with no button held. Dispatch hover; skip the // 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 // past the edge, came back" — and tmux drops focus events unless
// `focus-events on` is set, so this is the more reliable signal. // `focus-events on` is set, so this is the more reliable signal.
if (sel.isDragging) { if (sel.isDragging) {
finishSelection(sel); finishSelection(sel)
app.props.onSelectionChange(); app.props.onSelectionChange()
} }
if (col === app.lastHoverCol && row === app.lastHoverRow) return; if (col === app.lastHoverCol && row === app.lastHoverRow) return
app.lastHoverCol = col; app.lastHoverCol = col
app.lastHoverRow = row; app.lastHoverRow = row
app.props.onHoverAt(col, row); app.props.onHoverAt(col, row)
return; return
} }
if (baseButton !== 0) { if (baseButton !== 0) {
// Non-left press breaks the multi-click chain. // Non-left press breaks the multi-click chain.
app.clickCount = 0; app.clickCount = 0
return; return
} }
if ((m.button & 0x20) !== 0) { if ((m.button & 0x20) !== 0) {
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag // Drag motion: mode-aware extension (char/word/line). onSelectionDrag
// calls notifySelectionChange internally — no extra onSelectionChange. // calls notifySelectionChange internally — no extra onSelectionChange.
app.props.onSelectionDrag(col, row); app.props.onSelectionDrag(col, row)
return; return
} }
// Lost-release fallback for mode-1002-only terminals: a fresh press // Lost-release fallback for mode-1002-only terminals: a fresh press
// while isDragging=true means the previous release was dropped (cursor // 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 // before startSelection/onMultiClick clobbers it. Mode-1003 terminals
// hit the no-button-motion recovery above instead, so this is rare. // hit the no-button-motion recovery above instead, so this is rare.
if (sel.isDragging) { if (sel.isDragging) {
finishSelection(sel); finishSelection(sel)
app.props.onSelectionChange(); app.props.onSelectionChange()
} }
// Fresh left press. Detect multi-click HERE (not on release) so the // Fresh left press. Detect multi-click HERE (not on release) so the
// word/line highlight appears immediately and a subsequent drag can // word/line highlight appears immediately and a subsequent drag can
// extend by word/line like native macOS. Previously detected on // extend by word/line like native macOS. Previously detected on
// release, which meant (a) visible latency before the word highlights // release, which meant (a) visible latency before the word highlights
// and (b) double-click+drag fell through to char-mode selection. // and (b) double-click+drag fell through to char-mode selection.
const now = Date.now(); 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; const nearLast =
app.clickCount = nearLast ? app.clickCount + 1 : 1; now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
app.lastClickTime = now; Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
app.lastClickCol = col; Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE
app.lastClickRow = row; app.clickCount = nearLast ? app.clickCount + 1 : 1
app.lastClickTime = now
app.lastClickCol = col
app.lastClickRow = row
if (app.clickCount >= 2) { if (app.clickCount >= 2) {
// Cancel any pending hyperlink-open from the first click — this is // Cancel any pending hyperlink-open from the first click — this is
// a double-click, not a single-click on a link. // a double-click, not a single-click on a link.
if (app.pendingHyperlinkTimer) { if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer); clearTimeout(app.pendingHyperlinkTimer)
app.pendingHyperlinkTimer = null; app.pendingHyperlinkTimer = null
} }
// Cap at 3 (line select) for quadruple+ clicks. // Cap at 3 (line select) for quadruple+ clicks.
const count = app.clickCount === 2 ? 2 : 3; const count = app.clickCount === 2 ? 2 : 3
app.props.onMultiClick(col, row, count); app.props.onMultiClick(col, row, count)
return; return
} }
startSelection(sel, col, row); startSelection(sel, col, row)
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see // SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
// comment at the hyperlink-open guard below). On macOS xterm.js, // comment at the hyperlink-open guard below). On macOS xterm.js,
// receiving alt means macOptionClickForcesSelection is OFF (otherwise // receiving alt means macOptionClickForcesSelection is OFF (otherwise
// xterm.js would have consumed the event for native selection). // xterm.js would have consumed the event for native selection).
sel.lastPressHadAlt = (m.button & 0x08) !== 0; sel.lastPressHadAlt = (m.button & 0x08) !== 0
app.props.onSelectionChange(); app.props.onSelectionChange()
return; return
} }
// Release: end the drag even for non-zero button codes. Some terminals // 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 // scroll boundary. Only act on non-left releases when we ARE dragging
// (so an unrelated middle/right click-release doesn't touch selection). // (so an unrelated middle/right click-release doesn't touch selection).
if (baseButton !== 0) { if (baseButton !== 0) {
if (!sel.isDragging) return; if (!sel.isDragging) return
finishSelection(sel); finishSelection(sel)
app.props.onSelectionChange(); app.props.onSelectionChange()
return; return
} }
finishSelection(sel); finishSelection(sel)
// NOTE: unlike the old release-based detection we do NOT reset clickCount // NOTE: unlike the old release-based detection we do NOT reset clickCount
// on release-after-drag. This aligns with NSEvent.clickCount semantics: // on release-after-drag. This aligns with NSEvent.clickCount semantics:
// an intervening drag doesn't break the click chain. Practical upside: // 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 // Resolve the hyperlink URL synchronously while the screen buffer
// still reflects what the user clicked — deferring only the // still reflects what the user clicked — deferring only the
// browser-open so double-click can cancel it. // 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 // xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link
// handler that fires on Cmd+click *without consuming the mouse event* // handler that fires on Cmd+click *without consuming the mouse event*
// (Linkifier._handleMouseUp calls link.activate() but never // (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 // Clear any prior pending timer — clicking a second link
// supersedes the first (only the latest click opens). // supersedes the first (only the latest click opens).
if (app.pendingHyperlinkTimer) { if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer); clearTimeout(app.pendingHyperlinkTimer)
} }
app.pendingHyperlinkTimer = setTimeout((app, url) => { app.pendingHyperlinkTimer = setTimeout(
app.pendingHyperlinkTimer = null; (app, url) => {
app.props.onOpenHyperlink(url); app.pendingHyperlinkTimer = null
}, MULTI_CLICK_TIMEOUT_MS, app, url); app.props.onOpenHyperlink(url)
},
MULTI_CLICK_TIMEOUT_MS,
app,
url,
)
} }
} }
} }
app.props.onSelectionChange(); app.props.onSelectionChange()
} }

View File

@@ -1,212 +1,118 @@
import { c as _c } from "react/compiler-runtime"; import React, { type PropsWithChildren, type Ref } from 'react'
import React, { type PropsWithChildren, type Ref } from 'react'; import type { Except } from 'type-fest'
import type { Except } from 'type-fest'; import type { DOMElement } from '../dom.js'
import type { DOMElement } from '../dom.js'; import type { ClickEvent } from '../events/click-event.js'
import type { ClickEvent } from '../events/click-event.js'; import type { FocusEvent } from '../events/focus-event.js'
import type { FocusEvent } from '../events/focus-event.js'; import type { KeyboardEvent } from '../events/keyboard-event.js'
import type { KeyboardEvent } from '../events/keyboard-event.js'; import type { Styles } from '../styles.js'
import type { Styles } from '../styles.js'; import * as warn from '../warn.js'
import * as warn from '../warn.js';
export type Props = Except<Styles, 'textWrap'> & { export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>; ref?: Ref<DOMElement>
/** /**
* Tab order index. Nodes with `tabIndex >= 0` participate in * Tab order index. Nodes with `tabIndex >= 0` participate in
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only. * Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
*/ */
tabIndex?: number; tabIndex?: number
/** /**
* Focus this element when it mounts. Like the HTML `autofocus` * Focus this element when it mounts. Like the HTML `autofocus`
* attribute — the FocusManager calls `focus(node)` during the * attribute — the FocusManager calls `focus(node)` during the
* reconciler's `commitMount` phase. * reconciler's `commitMount` phase.
*/ */
autoFocus?: boolean; autoFocus?: boolean
/** /**
* Fired on left-button click (press + release without drag). Only works * Fired on left-button click (press + release without drag). Only works
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op * inside `<AlternateScreen>` where mouse tracking is enabled — no-op
* otherwise. The event bubbles from the deepest hit Box up through * otherwise. The event bubbles from the deepest hit Box up through
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling. * ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
*/ */
onClick?: (event: ClickEvent) => void; onClick?: (event: ClickEvent) => void
onFocus?: (event: FocusEvent) => void; onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void; onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void; onBlur?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void; onBlurCapture?: (event: FocusEvent) => void
onKeyDown?: (event: KeyboardEvent) => void; onKeyDown?: (event: KeyboardEvent) => void
onKeyDownCapture?: (event: KeyboardEvent) => void; onKeyDownCapture?: (event: KeyboardEvent) => void
/** /**
* Fired when the mouse moves into this Box's rendered rect. Like DOM * Fired when the mouse moves into this Box's rendered rect. Like DOM
* `mouseenter`, does NOT bubble — moving between children does not * `mouseenter`, does NOT bubble — moving between children does not
* re-fire on the parent. Only works inside `<AlternateScreen>` where * re-fire on the parent. Only works inside `<AlternateScreen>` where
* mode-1003 mouse tracking is enabled. * mode-1003 mouse tracking is enabled.
*/ */
onMouseEnter?: () => void; onMouseEnter?: () => void
/** Fired when the mouse moves out of this Box's rendered rect. */ /** Fired when the mouse moves out of this Box's rendered rect. */
onMouseLeave?: () => void; onMouseLeave?: () => void
}; }
/** /**
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser. * `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
*/ */
function Box(t0) { function Box({
const $ = _c(42); children,
let autoFocus; flexWrap = 'nowrap',
let children; flexDirection = 'row',
let flexDirection; flexGrow = 0,
let flexGrow; flexShrink = 1,
let flexShrink; ref,
let flexWrap; tabIndex,
let onBlur; autoFocus,
let onBlurCapture; onClick,
let onClick; onFocus,
let onFocus; onFocusCapture,
let onFocusCapture; onBlur,
let onKeyDown; onBlurCapture,
let onKeyDownCapture; onMouseEnter,
let onMouseEnter; onMouseLeave,
let onMouseLeave; onKeyDown,
let ref; onKeyDownCapture,
let style; ...style
let tabIndex; }: PropsWithChildren<Props>): React.ReactNode {
if ($[0] !== t0) { // Warn if spacing values are not integers to prevent fractional layout dimensions
const { warn.ifNotInteger(style.margin, 'margin')
children: t1, warn.ifNotInteger(style.marginX, 'marginX')
flexWrap: t2, warn.ifNotInteger(style.marginY, 'marginY')
flexDirection: t3, warn.ifNotInteger(style.marginTop, 'marginTop')
flexGrow: t4, warn.ifNotInteger(style.marginBottom, 'marginBottom')
flexShrink: t5, warn.ifNotInteger(style.marginLeft, 'marginLeft')
ref: t6, warn.ifNotInteger(style.marginRight, 'marginRight')
tabIndex: t7, warn.ifNotInteger(style.padding, 'padding')
autoFocus: t8, warn.ifNotInteger(style.paddingX, 'paddingX')
onClick: t9, warn.ifNotInteger(style.paddingY, 'paddingY')
onFocus: t10, warn.ifNotInteger(style.paddingTop, 'paddingTop')
onFocusCapture: t11, warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
onBlur: t12, warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
onBlurCapture: t13, warn.ifNotInteger(style.paddingRight, 'paddingRight')
onMouseEnter: t14, warn.ifNotInteger(style.gap, 'gap')
onMouseLeave: t15, warn.ifNotInteger(style.columnGap, 'columnGap')
onKeyDown: t16, warn.ifNotInteger(style.rowGap, 'rowGap')
onKeyDownCapture: t17,
...t18 return (
} = t0; <ink-box
children = t1; ref={ref}
ref = t6; tabIndex={tabIndex}
tabIndex = t7; autoFocus={autoFocus}
autoFocus = t8; onClick={onClick}
onClick = t9; onFocus={onFocus}
onFocus = t10; onFocusCapture={onFocusCapture}
onFocusCapture = t11; onBlur={onBlur}
onBlur = t12; onBlurCapture={onBlurCapture}
onBlurCapture = t13; onMouseEnter={onMouseEnter}
onMouseEnter = t14; onMouseLeave={onMouseLeave}
onMouseLeave = t15; onKeyDown={onKeyDown}
onKeyDown = t16; onKeyDownCapture={onKeyDownCapture}
onKeyDownCapture = t17; style={{
style = t18; flexWrap,
flexWrap = t2 === undefined ? "nowrap" : t2; flexDirection,
flexDirection = t3 === undefined ? "row" : t3; flexGrow,
flexGrow = t4 === undefined ? 0 : t4; flexShrink,
flexShrink = t5 === undefined ? 1 : t5; ...style,
warn.ifNotInteger(style.margin, "margin"); overflowX: style.overflowX ?? style.overflow ?? 'visible',
warn.ifNotInteger(style.marginX, "marginX"); overflowY: style.overflowY ?? style.overflow ?? 'visible',
warn.ifNotInteger(style.marginY, "marginY"); }}
warn.ifNotInteger(style.marginTop, "marginTop"); >
warn.ifNotInteger(style.marginBottom, "marginBottom"); {children}
warn.ifNotInteger(style.marginLeft, "marginLeft"); </ink-box>
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 = <ink-box ref={ref} tabIndex={tabIndex} autoFocus={autoFocus} onClick={onClick} onFocus={onFocus} onFocusCapture={onFocusCapture} onBlur={onBlur} onBlurCapture={onBlurCapture} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onKeyDown={onKeyDown} onKeyDownCapture={onKeyDownCapture} style={t3}>{children}</ink-box>;
$[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;
} }
export default Box;
export default Box

View File

@@ -1,32 +1,39 @@
import { c as _c } from "react/compiler-runtime"; import React, {
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react'; type Ref,
import type { Except } from 'type-fest'; useCallback,
import type { DOMElement } from '../dom.js'; useEffect,
import type { ClickEvent } from '../events/click-event.js'; useRef,
import type { FocusEvent } from '../events/focus-event.js'; useState,
import type { KeyboardEvent } from '../events/keyboard-event.js'; } from 'react'
import type { Styles } from '../styles.js'; import type { Except } from 'type-fest'
import Box from './Box.js'; 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 = { type ButtonState = {
focused: boolean; focused: boolean
hovered: boolean; hovered: boolean
active: boolean; active: boolean
}; }
export type Props = Except<Styles, 'textWrap'> & { export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>; ref?: Ref<DOMElement>
/** /**
* Called when the button is activated via Enter, Space, or click. * Called when the button is activated via Enter, Space, or click.
*/ */
onAction: () => void; onAction: () => void
/** /**
* Tab order index. Defaults to 0 (in tab order). * Tab order index. Defaults to 0 (in tab order).
* Set to -1 for programmatically focusable only. * Set to -1 for programmatically focusable only.
*/ */
tabIndex?: number; tabIndex?: number
/** /**
* Focus this button when it mounts. * Focus this button when it mounts.
*/ */
autoFocus?: boolean; autoFocus?: boolean
/** /**
* Render prop receiving the interactive state. Use this to * Render prop receiving the interactive state. Use this to
* style children based on focus/hover/active — Button itself * style children based on focus/hover/active — Button itself
@@ -34,158 +41,82 @@ export type Props = Except<Styles, 'textWrap'> & {
* *
* If not provided, children render as-is (no state-dependent styling). * If not provided, children render as-is (no state-dependent styling).
*/ */
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode; children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
};
function Button(t0) {
const $ = _c(30);
let autoFocus;
let children;
let onAction;
let ref;
let style;
let t1;
if ($[0] !== t0) {
({
onAction,
tabIndex: t1,
autoFocus,
children,
ref,
...style
} = t0);
$[0] = t0;
$[1] = autoFocus;
$[2] = children;
$[3] = onAction;
$[4] = ref;
$[5] = style;
$[6] = t1;
} else {
autoFocus = $[1];
children = $[2];
onAction = $[3];
ref = $[4];
style = $[5];
t1 = $[6];
}
const tabIndex = t1 === undefined ? 0 : t1;
const [isFocused, setIsFocused] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isActive, setIsActive] = useState(false);
const activeTimer = useRef(null);
let t2;
let t3;
if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
t2 = () => () => {
if (activeTimer.current) {
clearTimeout(activeTimer.current);
}
};
t3 = [];
$[7] = t2;
$[8] = t3;
} else {
t2 = $[7];
t3 = $[8];
}
useEffect(t2, t3);
let t4;
if ($[9] !== onAction) {
t4 = e => {
if (e.key === "return" || e.key === " ") {
e.preventDefault();
setIsActive(true);
onAction();
if (activeTimer.current) {
clearTimeout(activeTimer.current);
}
activeTimer.current = setTimeout(_temp, 100, setIsActive);
}
};
$[9] = onAction;
$[10] = t4;
} else {
t4 = $[10];
}
const handleKeyDown = t4;
let t5;
if ($[11] !== onAction) {
t5 = _e => {
onAction();
};
$[11] = onAction;
$[12] = t5;
} else {
t5 = $[12];
}
const handleClick = t5;
let t6;
if ($[13] === Symbol.for("react.memo_cache_sentinel")) {
t6 = _e_0 => setIsFocused(true);
$[13] = t6;
} else {
t6 = $[13];
}
const handleFocus = t6;
let t7;
if ($[14] === Symbol.for("react.memo_cache_sentinel")) {
t7 = _e_1 => setIsFocused(false);
$[14] = t7;
} else {
t7 = $[14];
}
const handleBlur = t7;
let t8;
if ($[15] === Symbol.for("react.memo_cache_sentinel")) {
t8 = () => setIsHovered(true);
$[15] = t8;
} else {
t8 = $[15];
}
const handleMouseEnter = t8;
let t9;
if ($[16] === Symbol.for("react.memo_cache_sentinel")) {
t9 = () => setIsHovered(false);
$[16] = t9;
} else {
t9 = $[16];
}
const handleMouseLeave = t9;
let t10;
if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) {
const state = {
focused: isFocused,
hovered: isHovered,
active: isActive
};
t10 = typeof children === "function" ? children(state) : children;
$[17] = children;
$[18] = isActive;
$[19] = isFocused;
$[20] = isHovered;
$[21] = t10;
} else {
t10 = $[21];
}
const content = t10;
let t11;
if ($[22] !== autoFocus || $[23] !== content || $[24] !== handleClick || $[25] !== handleKeyDown || $[26] !== ref || $[27] !== style || $[28] !== tabIndex) {
t11 = <Box ref={ref} tabIndex={tabIndex} autoFocus={autoFocus} onKeyDown={handleKeyDown} onClick={handleClick} onFocus={handleFocus} onBlur={handleBlur} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...style}>{content}</Box>;
$[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); 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<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (activeTimer.current) clearTimeout(activeTimer.current)
}
}, [])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'return' || e.key === ' ') {
e.preventDefault()
setIsActive(true)
onAction()
if (activeTimer.current) clearTimeout(activeTimer.current)
activeTimer.current = setTimeout(
setter => setter(false),
100,
setIsActive,
)
}
},
[onAction],
)
const handleClick = useCallback(
(_e: ClickEvent) => {
onAction()
},
[onAction],
)
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
const state: ButtonState = {
focused: isFocused,
hovered: isHovered,
active: isActive,
}
const content = typeof children === 'function' ? children(state) : children
return (
<Box
ref={ref}
tabIndex={tabIndex}
autoFocus={autoFocus}
onKeyDown={handleKeyDown}
onClick={handleClick}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...style}
>
{content}
</Box>
)
} }
export default Button;
export type { ButtonState }; export default Button
export type { ButtonState }

View File

@@ -1,111 +1,99 @@
import { c as _c } from "react/compiler-runtime"; import React, { createContext, useEffect, useState } from 'react'
import React, { createContext, useEffect, useState } from 'react'; import { FRAME_INTERVAL_MS } from '../constants.js'
import { FRAME_INTERVAL_MS } from '../constants.js'; import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
export type Clock = { export type Clock = {
subscribe: (onChange: () => void, keepAlive: boolean) => () => void; subscribe: (onChange: () => void, keepAlive: boolean) => () => void
now: () => number; now: () => number
setTickInterval: (ms: number) => void; setTickInterval: (ms: number) => void
}; }
export function createClock(tickIntervalMs: number): Clock { export function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>(); const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | null = null; let interval: ReturnType<typeof setInterval> | null = null
let currentTickIntervalMs = tickIntervalMs; let currentTickIntervalMs = tickIntervalMs
let startTime = 0; let startTime = 0
// Snapshot of the current tick's time, ensuring all subscribers in the same // Snapshot of the current tick's time, ensuring all subscribers in the same
// tick see the same value (keeps animations synchronized) // tick see the same value (keeps animations synchronized)
let tickTime = 0; let tickTime = 0
function tick(): void { function tick(): void {
tickTime = Date.now() - startTime; tickTime = Date.now() - startTime
for (const onChange of subscribers.keys()) { for (const onChange of subscribers.keys()) {
onChange(); onChange()
} }
} }
function updateInterval(): void { function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean); const anyKeepAlive = [...subscribers.values()].some(Boolean)
if (anyKeepAlive) { if (anyKeepAlive) {
if (interval) { if (interval) {
clearInterval(interval); clearInterval(interval)
interval = null; interval = null
} }
if (startTime === 0) { if (startTime === 0) {
startTime = Date.now(); startTime = Date.now()
} }
interval = setInterval(tick, currentTickIntervalMs); interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) { } else if (interval) {
clearInterval(interval); clearInterval(interval)
interval = null; interval = null
} }
} }
return { return {
subscribe(onChange, keepAlive) { subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive); subscribers.set(onChange, keepAlive)
updateInterval(); updateInterval()
return () => { return () => {
subscribers.delete(onChange); subscribers.delete(onChange)
updateInterval(); updateInterval()
}; }
}, },
now() { now() {
if (startTime === 0) { if (startTime === 0) {
startTime = Date.now(); startTime = Date.now()
} }
// When the clock interval is running, return the synchronized tickTime // When the clock interval is running, return the synchronized tickTime
// so all subscribers in the same tick see the same value. // so all subscribers in the same tick see the same value.
// When paused (no keepAlive subscribers), return real-time to avoid // When paused (no keepAlive subscribers), return real-time to avoid
// returning a stale tickTime from the last tick before the pause. // returning a stale tickTime from the last tick before the pause.
if (interval && tickTime) { if (interval && tickTime) {
return tickTime; return tickTime
} }
return Date.now() - startTime; return Date.now() - startTime
}, },
setTickInterval(ms) { setTickInterval(ms) {
if (ms === currentTickIntervalMs) return; if (ms === currentTickIntervalMs) return
currentTickIntervalMs = ms; currentTickIntervalMs = ms
updateInterval(); updateInterval()
} },
}; }
} }
export const ClockContext = createContext<Clock | null>(null);
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2; export const ClockContext = createContext<Clock | null>(null)
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
// Own component so App.tsx doesn't re-render when the clock is created. // 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 // The clock value is stable (created once via useState), so the provider
// never causes consumer re-renders on its own. // never causes consumer re-renders on its own.
export function ClockProvider(t0) { export function ClockProvider({
const $ = _c(7); children,
const { }: {
children children: React.ReactNode
} = t0; }): React.ReactNode {
const [clock] = useState(_temp); const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
const focused = useTerminalFocus(); const focused = useTerminalFocus()
let t1;
let t2; useEffect(() => {
if ($[0] !== clock || $[1] !== focused) { clock.setTickInterval(
t1 = () => { focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS); )
}; }, [clock, focused])
t2 = [clock, focused];
$[0] = clock; return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
$[1] = focused;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
let t3;
if ($[4] !== children || $[5] !== clock) {
t3 = <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
$[4] = children;
$[5] = clock;
$[6] = t3;
} else {
t3 = $[6];
}
return t3;
}
function _temp() {
return createClock(FRAME_INTERVAL_MS);
} }

View File

@@ -1,55 +1,57 @@
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'; import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
import { readFileSync } from 'fs'; import { readFileSync } from 'fs'
import React from 'react'; import React from 'react'
import StackUtils from 'stack-utils'; import StackUtils from 'stack-utils'
import Box from './Box.js'; import Box from './Box.js'
import Text from './Text.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 */ /* 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 // Error's source file is reported as file:///home/user/file.js
// This function removes the file://[cwd] part // This function removes the file://[cwd] part
const cleanupPath = (path: string | undefined): string | undefined => { const cleanupPath = (path: string | undefined): string | undefined => {
return path?.replace(`file://${process.cwd()}/`, ''); return path?.replace(`file://${process.cwd()}/`, '')
}; }
let stackUtils: StackUtils | undefined;
let stackUtils: StackUtils | undefined
function getStackUtils(): StackUtils { function getStackUtils(): StackUtils {
return stackUtils ??= new StackUtils({ return (stackUtils ??= new StackUtils({
cwd: process.cwd(), cwd: process.cwd(),
internals: StackUtils.nodeInternals() internals: StackUtils.nodeInternals(),
}); }))
} }
/* eslint-enable custom-rules/no-process-cwd */ /* eslint-enable custom-rules/no-process-cwd */
type Props = { type Props = {
readonly error: Error; readonly error: Error
}; }
export default function ErrorOverview({
error export default function ErrorOverview({ error }: Props) {
}: Props) { const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined; const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined; const filePath = cleanupPath(origin?.file)
const filePath = cleanupPath(origin?.file); let excerpt: CodeExcerpt[] | undefined
let excerpt: CodeExcerpt[] | undefined; let lineWidth = 0
let lineWidth = 0;
if (filePath && origin?.line) { if (filePath && origin?.line) {
try { try {
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring // 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'); const sourceCode = readFileSync(filePath, 'utf8')
excerpt = codeExcerpt(sourceCode, origin.line); excerpt = codeExcerpt(sourceCode, origin.line)
if (excerpt) { if (excerpt) {
for (const { for (const { line } of excerpt) {
line lineWidth = Math.max(lineWidth, String(line).length)
} of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length);
} }
} }
} catch { } catch {
// file not readable — skip source context // file not readable — skip source context
} }
} }
return <Box flexDirection="column" padding={1}>
return (
<Box flexDirection="column" padding={1}>
<Box> <Box>
<Text backgroundColor="ansi:red" color="ansi:white"> <Text backgroundColor="ansi:red" color="ansi:white">
{' '} {' '}
@@ -59,41 +61,62 @@ export default function ErrorOverview({
<Text> {error.message}</Text> <Text> {error.message}</Text>
</Box> </Box>
{origin && filePath && <Box marginTop={1}> {origin && filePath && (
<Box marginTop={1}>
<Text dim> <Text dim>
{filePath}:{origin.line}:{origin.column} {filePath}:{origin.line}:{origin.column}
</Text> </Text>
</Box>} </Box>
)}
{origin && excerpt && <Box marginTop={1} flexDirection="column"> {origin && excerpt && (
{excerpt.map(({ <Box marginTop={1} flexDirection="column">
line: line_0, {excerpt.map(({ line, value }) => (
value <Box key={line}>
}) => <Box key={line_0}>
<Box width={lineWidth + 1}> <Box width={lineWidth + 1}>
<Text dim={line_0 !== origin.line} backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined} color={line_0 === origin.line ? 'ansi:white' : undefined}> <Text
{String(line_0).padStart(lineWidth, ' ')}: dim={line !== origin.line}
backgroundColor={
line === origin.line ? 'ansi:red' : undefined
}
color={line === origin.line ? 'ansi:white' : undefined}
>
{String(line).padStart(lineWidth, ' ')}:
</Text> </Text>
</Box> </Box>
<Text key={line_0} backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined} color={line_0 === origin.line ? 'ansi:white' : undefined}> <Text
key={line}
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
color={line === origin.line ? 'ansi:white' : undefined}
>
{' ' + value} {' ' + value}
</Text> </Text>
</Box>)} </Box>
</Box>} ))}
</Box>
)}
{error.stack && <Box marginTop={1} flexDirection="column"> {error.stack && (
{error.stack.split('\n').slice(1).map(line_1 => { <Box marginTop={1} flexDirection="column">
const parsedLine = getStackUtils().parseLine(line_1); {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 the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) { if (!parsedLine) {
return <Box key={line_1}> return (
<Box key={line}>
<Text dim>- </Text> <Text dim>- </Text>
<Text bold>{line_1}</Text> <Text bold>{line}</Text>
</Box>; </Box>
} )
return <Box key={line_1}> }
return (
<Box key={line}>
<Text dim>- </Text> <Text dim>- </Text>
<Text bold>{parsedLine.function}</Text> <Text bold>{parsedLine.function}</Text>
<Text dim> <Text dim>
@@ -101,8 +124,11 @@ export default function ErrorOverview({
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}: ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column}) {parsedLine.column})
</Text> </Text>
</Box>; </Box>
})} )
</Box>} })}
</Box>; </Box>
)}
</Box>
)
} }

View File

@@ -1,41 +1,31 @@
import { c as _c } from "react/compiler-runtime"; import type { ReactNode } from 'react'
import type { ReactNode } from 'react'; import React from 'react'
import React from 'react'; import { supportsHyperlinks } from '../supports-hyperlinks.js'
import { supportsHyperlinks } from '../supports-hyperlinks.js'; import Text from './Text.js'
import Text from './Text.js';
export type Props = { export type Props = {
readonly children?: ReactNode; readonly children?: ReactNode
readonly url: string; readonly url: string
readonly fallback?: ReactNode; readonly fallback?: ReactNode
}; }
export default function Link(t0) {
const $ = _c(5); export default function Link({
const { children,
children, url,
url, fallback,
fallback }: Props): React.ReactNode {
} = t0; // Use children if provided, otherwise display the URL
const content = children ?? url; const content = children ?? url
if (supportsHyperlinks()) {
let t1; if (supportsHyperlinks()) {
if ($[0] !== content || $[1] !== url) { // Wrap in Text to ensure we're in a text context
t1 = <Text><ink-link href={url}>{content}</ink-link></Text>; // (ink-link is a text element like ink-text)
$[0] = content; return (
$[1] = url; <Text>
$[2] = t1; <ink-link href={url}>{content}</ink-link>
} else { </Text>
t1 = $[2]; )
} }
return t1;
} return <Text>{fallback ?? content}</Text>
const t1 = fallback ?? content;
let t2;
if ($[3] !== t1) {
t2 = <Text>{t1}</Text>;
$[3] = t1;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
} }

View File

@@ -1,38 +1,17 @@
import { c as _c } from "react/compiler-runtime"; import React from 'react'
import React from 'react';
export type Props = { export type Props = {
/** /**
* Number of newlines to insert. * Number of newlines to insert.
* *
* @default 1 * @default 1
*/ */
readonly count?: number; readonly count?: number
}; }
/** /**
* Adds one or more newline (\n) characters. Must be used within <Text> components. * Adds one or more newline (\n) characters. Must be used within <Text> components.
*/ */
export default function Newline(t0) { export default function Newline({ count = 1 }: Props) {
const $ = _c(4); return <ink-text>{'\n'.repeat(count)}</ink-text>
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 = <ink-text>{t2}</ink-text>;
$[2] = t2;
$[3] = t3;
} else {
t3 = $[3];
}
return t3;
} }

View File

@@ -1,6 +1,6 @@
import { c as _c } from "react/compiler-runtime"; import React, { type PropsWithChildren } from 'react'
import React, { type PropsWithChildren } from 'react'; import Box, { type Props as BoxProps } from './Box.js'
import Box, { type Props as BoxProps } from './Box.js';
type Props = Omit<BoxProps, 'noSelect'> & { type Props = Omit<BoxProps, 'noSelect'> & {
/** /**
* Extend the exclusion zone from column 0 to this box's right edge, * Extend the exclusion zone from column 0 to this box's right edge,
@@ -11,8 +11,8 @@ type Props = Omit<BoxProps, 'noSelect'> & {
* *
* @default false * @default false
*/ */
fromLeftEdge?: boolean; fromLeftEdge?: boolean
}; }
/** /**
* Marks its contents as non-selectable in fullscreen text selection. * Marks its contents as non-selectable in fullscreen text selection.
@@ -32,36 +32,14 @@ type Props = Omit<BoxProps, 'noSelect'> & {
* tracking). No-op in the main-screen scrollback render where the * tracking). No-op in the main-screen scrollback render where the
* terminal's native selection is used instead. * terminal's native selection is used instead.
*/ */
export function NoSelect(t0) { export function NoSelect({
const $ = _c(8); children,
let boxProps; fromLeftEdge,
let children; ...boxProps
let fromLeftEdge; }: PropsWithChildren<Props>): React.ReactNode {
if ($[0] !== t0) { return (
({ <Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
children, {children}
fromLeftEdge, </Box>
...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 = <Box {...boxProps} noSelect={t1}>{children}</Box>;
$[4] = boxProps;
$[5] = children;
$[6] = t1;
$[7] = t2;
} else {
t2 = $[7];
}
return t2;
} }

View File

@@ -1,14 +1,14 @@
import { c as _c } from "react/compiler-runtime"; import React from 'react'
import React from 'react';
type Props = { type Props = {
/** /**
* Pre-rendered ANSI lines. Each element must be exactly one terminal row * Pre-rendered ANSI lines. Each element must be exactly one terminal row
* (already wrapped to `width` by the producer) with ANSI escape codes inline. * (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. */ /** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
width: number; width: number
}; }
/** /**
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for * Bypass the <Ansi> → 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(), * (width × lines.length) and hands the joined string straight to output.write(),
* which already splits on '\n' and parses ANSI into the screen buffer. * which already splits on '\n' and parses ANSI into the screen buffer.
*/ */
export function RawAnsi(t0) { export function RawAnsi({ lines, width }: Props): React.ReactNode {
const $ = _c(6);
const {
lines,
width
} = t0;
if (lines.length === 0) { if (lines.length === 0) {
return null; return null
} }
let t1; return (
if ($[0] !== lines) { <ink-raw-ansi
t1 = lines.join("\n"); rawText={lines.join('\n')}
$[0] = lines; rawWidth={width}
$[1] = t1; rawHeight={lines.length}
} else { />
t1 = $[1]; )
}
let t2;
if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
t2 = <ink-raw-ansi rawText={t1} rawWidth={width} rawHeight={lines.length} />;
$[2] = lines.length;
$[3] = t1;
$[4] = width;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
} }

View File

@@ -1,14 +1,21 @@
import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react'; import React, {
import type { Except } from 'type-fest'; type PropsWithChildren,
import { markScrollActivity } from '../../bootstrap/state.js'; type Ref,
import type { DOMElement } from '../dom.js'; useImperativeHandle,
import { markDirty, scheduleRenderFrom } from '../dom.js'; useRef,
import { markCommitStart } from '../reconciler.js'; useState,
import type { Styles } from '../styles.js'; } from 'react'
import Box from './Box.js'; 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 = { export type ScrollBoxHandle = {
scrollTo: (y: number) => void; scrollTo: (y: number) => void
scrollBy: (dy: number) => void; scrollBy: (dy: number) => void
/** /**
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike * 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 * 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 * render-node-to-output reads `el.yogaNode.getComputedTop()` in the
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot. * SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
*/ */
scrollToElement: (el: DOMElement, offset?: number) => void; scrollToElement: (el: DOMElement, offset?: number) => void
scrollToBottom: () => void; scrollToBottom: () => void
getScrollTop: () => number; getScrollTop: () => number
getPendingDelta: () => number; getPendingDelta: () => number
getScrollHeight: () => number; getScrollHeight: () => number
/** /**
* Like getScrollHeight, but reads Yoga directly instead of the cached * Like getScrollHeight, but reads Yoga directly instead of the cached
* value written by render-node-to-output (throttled, up to 16ms stale). * 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 * Use when you need a fresh value in useLayoutEffect after a React commit
* that grew content. Slightly more expensive (native Yoga call). * that grew content. Slightly more expensive (native Yoga call).
*/ */
getFreshScrollHeight: () => number; getFreshScrollHeight: () => number
getViewportHeight: () => number; getViewportHeight: () => number
/** /**
* Absolute screen-buffer row of the first visible content line (inside * Absolute screen-buffer row of the first visible content line (inside
* padding). Used for drag-to-scroll edge detection. * padding). Used for drag-to-scroll edge detection.
*/ */
getViewportTop: () => number; getViewportTop: () => number
/** /**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the * True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional * 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 * scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
* layout values (unlike scrollTop+viewportH >= scrollHeight). * layout values (unlike scrollTop+viewportH >= scrollHeight).
*/ */
isSticky: () => boolean; isSticky: () => boolean
/** /**
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom). * Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
* Does NOT fire for stickyScroll updates done by the Ink renderer — those * Does NOT fire for stickyScroll updates done by the Ink renderer — those
* happen during Ink's render phase after React has committed. Callers that * happen during Ink's render phase after React has committed. Callers that
* care about the sticky case should treat "at bottom" as a fallback. * 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 * Set the render-time scrollTop clamp to the currently-mounted children's
* coverage span. Called by useVirtualScroll after computing its range; * 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, * content instead of blank spacer. Pass undefined to disable (sticky,
* cold start). * cold start).
*/ */
setClampBounds: (min: number | undefined, max: number | undefined) => void; setClampBounds: (min: number | undefined, max: number | undefined) => void
}; }
export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'> & {
ref?: Ref<ScrollBoxHandle>; export type ScrollBoxProps = Except<
Styles,
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
> & {
ref?: Ref<ScrollBoxHandle>
/** /**
* When true, automatically pins scroll position to the bottom when content * When true, automatically pins scroll position to the bottom when content
* grows. Unset manually via scrollTo/scrollBy to break the stickiness. * grows. Unset manually via scrollTo/scrollBy to break the stickiness.
*/ */
stickyScroll?: boolean; stickyScroll?: boolean
}; }
/** /**
* A Box with `overflow: scroll` and an imperative scroll API. * A Box with `overflow: scroll` and an imperative scroll API.
@@ -84,7 +95,7 @@ function ScrollBox({
stickyScroll, stickyScroll,
...style ...style
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode { }: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null); const domRef = useRef<DOMElement>(null)
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node, // scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
// mark it dirty, and call the root's throttled scheduleRender directly. // mark it dirty, and call the root's throttled scheduleRender directly.
// The Ink renderer reads scrollTop from the node — no React state needed, // 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 // render — otherwise scheduleRender's leading edge fires on the FIRST
// event before subsequent events mutate scrollTop. scrollToBottom still // event before subsequent events mutate scrollTop. scrollToBottom still
// forces a React render: sticky is attribute-observed, no DOM-only path. // forces a React render: sticky is attribute-observed, no DOM-only path.
const [, forceRender] = useState(0); const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>()); const listenersRef = useRef(new Set<() => void>())
const renderQueuedRef = useRef(false); const renderQueuedRef = useRef(false)
const notify = () => { const notify = () => {
for (const l of listenersRef.current) l(); for (const l of listenersRef.current) l()
}; }
function scrollMutated(el: DOMElement): void { function scrollMutated(el: DOMElement): void {
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan // Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
// check) to skip their next tick — they compete for the event loop and // check) to skip their next tick — they compete for the event loop and
// contributed to 1402ms max frame gaps during scroll drain. // contributed to 1402ms max frame gaps during scroll drain.
markScrollActivity(); markScrollActivity()
markDirty(el); markDirty(el)
markCommitStart(); markCommitStart()
notify(); notify()
if (renderQueuedRef.current) return; if (renderQueuedRef.current) return
renderQueuedRef.current = true; renderQueuedRef.current = true
queueMicrotask(() => { queueMicrotask(() => {
renderQueuedRef.current = false; renderQueuedRef.current = false
scheduleRenderFrom(el); scheduleRenderFrom(el)
}); })
} }
useImperativeHandle(ref, (): ScrollBoxHandle => ({
scrollTo(y: number) { useImperativeHandle(
const el = domRef.current; ref,
if (!el) return; (): ScrollBoxHandle => ({
// Explicit false overrides the DOM attribute so manual scroll scrollTo(y: number) {
// breaks stickiness. Render code checks ?? precedence. const el = domRef.current
el.stickyScroll = false; if (!el) return
el.pendingScrollDelta = undefined; // Explicit false overrides the DOM attribute so manual scroll
el.scrollAnchor = undefined; // breaks stickiness. Render code checks ?? precedence.
el.scrollTop = Math.max(0, Math.floor(y)); el.stickyScroll = false
scrollMutated(el); el.pendingScrollDelta = undefined
}, el.scrollAnchor = undefined
scrollToElement(el: DOMElement, offset = 0) { el.scrollTop = Math.max(0, Math.floor(y))
const box = domRef.current; scrollMutated(el)
if (!box) return; },
box.stickyScroll = false; scrollToElement(el: DOMElement, offset = 0) {
box.pendingScrollDelta = undefined; const box = domRef.current
box.scrollAnchor = { if (!box) return
el, box.stickyScroll = false
offset box.pendingScrollDelta = undefined
}; box.scrollAnchor = { el, offset }
scrollMutated(box); scrollMutated(box)
}, },
scrollBy(dy: number) { scrollBy(dy: number) {
const el = domRef.current; const el = domRef.current
if (!el) return; if (!el) return
el.stickyScroll = false; el.stickyScroll = false
// Wheel input cancels any in-flight anchor seek — user override. // Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined; el.scrollAnchor = undefined
// Accumulate in pendingScrollDelta; renderer drains it at a capped // Accumulate in pendingScrollDelta; renderer drains it at a capped
// rate so fast flicks show intermediate frames. Pure accumulator: // rate so fast flicks show intermediate frames. Pure accumulator:
// scroll-up followed by scroll-down naturally cancels. // scroll-up followed by scroll-down naturally cancels.
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy); el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el); scrollMutated(el)
}, },
scrollToBottom() { scrollToBottom() {
const el = domRef.current; const el = domRef.current
if (!el) return; if (!el) return
el.pendingScrollDelta = undefined; el.pendingScrollDelta = undefined
el.stickyScroll = true; el.stickyScroll = true
markDirty(el); markDirty(el)
notify(); notify()
forceRender(n => n + 1); forceRender(n => n + 1)
}, },
getScrollTop() { getScrollTop() {
return domRef.current?.scrollTop ?? 0; return domRef.current?.scrollTop ?? 0
}, },
getPendingDelta() { getPendingDelta() {
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs // Accumulated-but-not-yet-drained delta. useVirtualScroll needs
// this to mount the union [committed, committed+pending] range — // this to mount the union [committed, committed+pending] range —
// otherwise intermediate drain frames find no children (blank). // otherwise intermediate drain frames find no children (blank).
return domRef.current?.pendingScrollDelta ?? 0; return domRef.current?.pendingScrollDelta ?? 0
}, },
getScrollHeight() { getScrollHeight() {
return domRef.current?.scrollHeight ?? 0; return domRef.current?.scrollHeight ?? 0
}, },
getFreshScrollHeight() { getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined; const content = domRef.current?.childNodes[0] as DOMElement | undefined
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0; return (
}, content?.yogaNode?.getComputedHeight() ??
getViewportHeight() { domRef.current?.scrollHeight ??
return domRef.current?.scrollViewportHeight ?? 0; 0
}, )
getViewportTop() { },
return domRef.current?.scrollViewportTop ?? 0; getViewportHeight() {
}, return domRef.current?.scrollViewportHeight ?? 0
isSticky() { },
const el = domRef.current; getViewportTop() {
if (!el) return false; return domRef.current?.scrollViewportTop ?? 0
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']); },
}, isSticky() {
subscribe(listener: () => void) { const el = domRef.current
listenersRef.current.add(listener); if (!el) return false
return () => listenersRef.current.delete(listener); return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
}, },
setClampBounds(min, max) { subscribe(listener: () => void) {
const el = domRef.current; listenersRef.current.add(listener)
if (!el) return; return () => listenersRef.current.delete(listener)
el.scrollClampMin = min; },
el.scrollClampMax = max; setClampBounds(min, max) {
} const el = domRef.current
}), if (!el) return
// notify/scrollMutated are inline (no useCallback) but only close over el.scrollClampMin = min
// refs + imports — stable. Empty deps avoids rebuilding the handle on el.scrollClampMax = max
// every render (which re-registers the ref = churn). },
// eslint-disable-next-line react-hooks/exhaustive-deps }),
[]); // 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) > // Structure: outer viewport (overflow:scroll, constrained height) >
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport // 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 // 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 // available on the first render — ref callbacks fire after the initial
// commit, which is too late for the first frame. // commit, which is too late for the first frame.
return <ink-box ref={el => { return (
domRef.current = el; <ink-box
if (el) el.scrollTop ??= 0; ref={el => {
}} style={{ domRef.current = el
flexWrap: 'nowrap', if (el) el.scrollTop ??= 0
flexDirection: style.flexDirection ?? 'row', }}
flexGrow: style.flexGrow ?? 0, style={{
flexShrink: style.flexShrink ?? 1, flexWrap: 'nowrap',
...style, flexDirection: style.flexDirection ?? 'row',
overflowX: 'scroll', flexGrow: style.flexGrow ?? 0,
overflowY: 'scroll' flexShrink: style.flexShrink ?? 1,
}} {...stickyScroll ? { ...style,
stickyScroll: true overflowX: 'scroll',
} : {}}> overflowY: 'scroll',
}}
{...(stickyScroll ? { stickyScroll: true } : {})}
>
<Box flexDirection="column" flexGrow={1} flexShrink={0} width="100%"> <Box flexDirection="column" flexGrow={1} flexShrink={0} width="100%">
{children} {children}
</Box> </Box>
</ink-box>; </ink-box>
)
} }
export default ScrollBox;
export default ScrollBox

View File

@@ -1,19 +1,10 @@
import { c as _c } from "react/compiler-runtime"; import React from 'react'
import React from 'react'; import Box from './Box.js'
import Box from './Box.js';
/** /**
* A flexible space that expands along the major axis of its containing layout. * 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. * It's useful as a shortcut for filling all the available spaces between elements.
*/ */
export default function Spacer() { export default function Spacer() {
const $ = _c(1); return <Box flexGrow={1} />
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <Box flexGrow={1} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
} }

View File

@@ -1,51 +1,53 @@
import { c as _c } from "react/compiler-runtime"; import React, { createContext, useMemo, useSyncExternalStore } from 'react'
import React, { createContext, useMemo, useSyncExternalStore } from 'react'; import {
import { getTerminalFocused, getTerminalFocusState, subscribeTerminalFocus, type TerminalFocusState } from '../terminal-focus-state.js'; getTerminalFocused,
export type { TerminalFocusState }; getTerminalFocusState,
subscribeTerminalFocus,
type TerminalFocusState,
} from '../terminal-focus-state.js'
export type { TerminalFocusState }
export type TerminalFocusContextProps = { export type TerminalFocusContextProps = {
readonly isTerminalFocused: boolean; readonly isTerminalFocused: boolean
readonly terminalFocusState: TerminalFocusState; readonly terminalFocusState: TerminalFocusState
}; }
const TerminalFocusContext = createContext<TerminalFocusContextProps>({ const TerminalFocusContext = createContext<TerminalFocusContextProps>({
isTerminalFocused: true, isTerminalFocused: true,
terminalFocusState: 'unknown' terminalFocusState: 'unknown',
}); })
// eslint-disable-next-line custom-rules/no-top-level-side-effects // 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. // 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 — // Children are a stable prop reference, so they don't re-render either —
// only components that consume the context will re-render. // only components that consume the context will re-render.
export function TerminalFocusProvider(t0) { export function TerminalFocusProvider({
const $ = _c(6); children,
const { }: {
children children: React.ReactNode
} = t0; }): React.ReactNode {
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused); const isTerminalFocused = useSyncExternalStore(
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState); subscribeTerminalFocus,
let t1; getTerminalFocused,
if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) { )
t1 = { const terminalFocusState = useSyncExternalStore(
isTerminalFocused, subscribeTerminalFocus,
terminalFocusState getTerminalFocusState,
}; )
$[0] = isTerminalFocused;
$[1] = terminalFocusState; const value = useMemo(
$[2] = t1; () => ({ isTerminalFocused, terminalFocusState }),
} else { [isTerminalFocused, terminalFocusState],
t1 = $[2]; )
}
const value = t1; return (
let t2; <TerminalFocusContext.Provider value={value}>
if ($[3] !== children || $[4] !== value) { {children}
t2 = <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>; </TerminalFocusContext.Provider>
$[3] = children; )
$[4] = value;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
} }
export default TerminalFocusContext;
export default TerminalFocusContext

View File

@@ -1,6 +1,8 @@
import { createContext } from 'react'; import { createContext } from 'react'
export type TerminalSize = { export type TerminalSize = {
columns: number; columns: number
rows: number; rows: number
}; }
export const TerminalSizeContext = createContext<TerminalSize | null>(null);
export const TerminalSizeContext = createContext<TerminalSize | null>(null)

View File

@@ -1,253 +1,144 @@
import { c as _c } from "react/compiler-runtime"; import type { ReactNode } from 'react'
import type { ReactNode } from 'react'; import React from 'react'
import React from 'react'; import type { Color, Styles, TextStyles } from '../styles.js'
import type { Color, Styles, TextStyles } from '../styles.js';
type BaseProps = { type BaseProps = {
/** /**
* Change text color. Accepts a raw color value (rgb, hex, ansi). * Change text color. Accepts a raw color value (rgb, hex, ansi).
*/ */
readonly color?: Color; readonly color?: Color
/** /**
* Same as `color`, but for background. * Same as `color`, but for background.
*/ */
readonly backgroundColor?: Color; readonly backgroundColor?: Color
/** /**
* Make the text italic. * Make the text italic.
*/ */
readonly italic?: boolean; readonly italic?: boolean
/** /**
* Make the text underlined. * Make the text underlined.
*/ */
readonly underline?: boolean; readonly underline?: boolean
/** /**
* Make the text crossed with a line. * Make the text crossed with a line.
*/ */
readonly strikethrough?: boolean; readonly strikethrough?: boolean
/** /**
* Inverse background and foreground colors. * 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. * 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 `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. * 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 wrap?: Styles['textWrap']
readonly children?: ReactNode;
}; readonly children?: ReactNode
}
/** /**
* Bold and dim are mutually exclusive in terminals. * Bold and dim are mutually exclusive in terminals.
* This type ensures you can use one or the other, but not both. * This type ensures you can use one or the other, but not both.
*/ */
type WeightProps = { type WeightProps =
bold?: never; | { bold?: never; dim?: never }
dim?: never; | { bold: boolean; dim?: never }
} | { | { dim: boolean; bold?: never }
bold: boolean;
dim?: never; export type Props = BaseProps & WeightProps
} | {
dim: boolean;
bold?: never;
};
export type Props = BaseProps & WeightProps;
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = { const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
wrap: { wrap: {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'wrap' textWrap: 'wrap',
}, },
'wrap-trim': { 'wrap-trim': {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'wrap-trim' textWrap: 'wrap-trim',
}, },
end: { end: {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'end' textWrap: 'end',
}, },
middle: { middle: {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'middle' textWrap: 'middle',
}, },
'truncate-end': { 'truncate-end': {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'truncate-end' textWrap: 'truncate-end',
}, },
truncate: { truncate: {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'truncate' textWrap: 'truncate',
}, },
'truncate-middle': { 'truncate-middle': {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'truncate-middle' textWrap: 'truncate-middle',
}, },
'truncate-start': { 'truncate-start': {
flexGrow: 0, flexGrow: 0,
flexShrink: 1, flexShrink: 1,
flexDirection: 'row', flexDirection: 'row',
textWrap: 'truncate-start' textWrap: 'truncate-start',
} },
} as const; } as const
/** /**
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough. * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
*/ */
export default function Text(t0) { export default function Text({
const $ = _c(29); color,
const { backgroundColor,
color, bold,
backgroundColor, dim,
bold, italic = false,
dim, underline = false,
italic: t1, strikethrough = false,
underline: t2, inverse = false,
strikethrough: t3, wrap = 'wrap',
inverse: t4, children,
wrap: t5, }: Props): React.ReactNode {
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;
if (children === undefined || children === null) { if (children === undefined || children === null) {
return null; return null
} }
let t6;
if ($[0] !== color) { // Build textStyles object with only the properties that are set
t6 = color && { const textStyles: TextStyles = {
color ...(color && { color }),
}; ...(backgroundColor && { backgroundColor }),
$[0] = color; ...(dim && { dim }),
$[1] = t6; ...(bold && { bold }),
} else { ...(italic && { italic }),
t6 = $[1]; ...(underline && { underline }),
...(strikethrough && { strikethrough }),
...(inverse && { inverse }),
} }
let t7;
if ($[2] !== backgroundColor) { return (
t7 = backgroundColor && { <ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
backgroundColor {children}
}; </ink-text>
$[2] = backgroundColor; )
$[3] = t7;
} else {
t7 = $[3];
}
let t8;
if ($[4] !== dim) {
t8 = dim && {
dim
};
$[4] = dim;
$[5] = t8;
} else {
t8 = $[5];
}
let t9;
if ($[6] !== bold) {
t9 = bold && {
bold
};
$[6] = bold;
$[7] = t9;
} else {
t9 = $[7];
}
let t10;
if ($[8] !== italic) {
t10 = italic && {
italic
};
$[8] = italic;
$[9] = t10;
} else {
t10 = $[9];
}
let t11;
if ($[10] !== underline) {
t11 = underline && {
underline
};
$[10] = underline;
$[11] = t11;
} else {
t11 = $[11];
}
let t12;
if ($[12] !== strikethrough) {
t12 = strikethrough && {
strikethrough
};
$[12] = strikethrough;
$[13] = t12;
} else {
t12 = $[13];
}
let t13;
if ($[14] !== inverse) {
t13 = inverse && {
inverse
};
$[14] = inverse;
$[15] = t13;
} else {
t13 = $[15];
}
let t14;
if ($[16] !== t10 || $[17] !== t11 || $[18] !== t12 || $[19] !== t13 || $[20] !== t6 || $[21] !== t7 || $[22] !== t8 || $[23] !== t9) {
t14 = {
...t6,
...t7,
...t8,
...t9,
...t10,
...t11,
...t12,
...t13
};
$[16] = t10;
$[17] = t11;
$[18] = t12;
$[19] = t13;
$[20] = t6;
$[21] = t7;
$[22] = t8;
$[23] = t9;
$[24] = t14;
} else {
t14 = $[24];
}
const textStyles = t14;
const t15 = memoizedStylesForWrap[wrap];
let t16;
if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) {
t16 = <ink-text style={t15} textStyles={textStyles}>{children}</ink-text>;
$[25] = children;
$[26] = t15;
$[27] = textStyles;
$[28] = t16;
} else {
t16 = $[28];
}
return t16;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,149 +1,152 @@
import { c as _c } from "react/compiler-runtime"; import React, {
import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react'; createContext,
import type { Key } from '../ink.js'; type RefObject,
import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js'; useContext,
import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; 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 */ /** Handler registration for action callbacks */
type HandlerRegistration = { type HandlerRegistration = {
action: string; action: string
context: KeybindingContextName; context: KeybindingContextName
handler: () => void; handler: () => void
}; }
type KeybindingContextValue = { type KeybindingContextValue = {
/** Resolve a key input to an action name (with chord support) */ /** 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 */ /** 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") */ /** 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) */ /** All parsed bindings (for help display) */
bindings: ParsedBinding[]; bindings: ParsedBinding[]
/** Current pending chord keystrokes (null if not in a chord) */ /** Current pending chord keystrokes (null if not in a chord) */
pendingChord: ParsedKeystroke[] | null; pendingChord: ParsedKeystroke[] | null
/** Currently active keybinding contexts (for priority resolution) */ /** Currently active keybinding contexts (for priority resolution) */
activeContexts: Set<KeybindingContextName>; activeContexts: Set<KeybindingContextName>
/** Register a context as active (call on mount) */ /** Register a context as active (call on mount) */
registerActiveContext: (context: KeybindingContextName) => void; registerActiveContext: (context: KeybindingContextName) => void
/** Unregister a context (call on unmount) */ /** Unregister a context (call on unmount) */
unregisterActiveContext: (context: KeybindingContextName) => void; unregisterActiveContext: (context: KeybindingContextName) => void
/** Register a handler for an action (used by useKeybinding) */ /** 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) */ /** Invoke all handlers for an action (used by ChordInterceptor) */
invokeAction: (action: string) => boolean; invokeAction: (action: string) => boolean
}; }
const KeybindingContext = createContext<KeybindingContextValue | null>(null);
const KeybindingContext = createContext<KeybindingContextValue | null>(null)
type ProviderProps = { type ProviderProps = {
bindings: ParsedBinding[]; bindings: ParsedBinding[]
/** Ref for immediate access to pending chord (avoids React state delay) */ /** Ref for immediate access to pending chord (avoids React state delay) */
pendingChordRef: RefObject<ParsedKeystroke[] | null>; pendingChordRef: RefObject<ParsedKeystroke[] | null>
/** State value for re-renders (UI updates) */ /** State value for re-renders (UI updates) */
pendingChord: ParsedKeystroke[] | null; pendingChord: ParsedKeystroke[] | null
setPendingChord: (pending: ParsedKeystroke[] | null) => void; setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>; activeContexts: Set<KeybindingContextName>
registerActiveContext: (context: KeybindingContextName) => void; registerActiveContext: (context: KeybindingContextName) => void
unregisterActiveContext: (context: KeybindingContextName) => void; unregisterActiveContext: (context: KeybindingContextName) => void
/** Ref to handler registry (used by ChordInterceptor) */ /** Ref to handler registry (used by ChordInterceptor) */
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>; handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
children: React.ReactNode; children: React.ReactNode
}; }
export function KeybindingProvider(t0) {
const $ = _c(24); export function KeybindingProvider({
const { bindings,
bindings, pendingChordRef,
pendingChordRef, pendingChord,
pendingChord, setPendingChord,
setPendingChord, activeContexts,
activeContexts, registerActiveContext,
registerActiveContext, unregisterActiveContext,
unregisterActiveContext, handlerRegistryRef,
handlerRegistryRef, children,
children }: ProviderProps): React.ReactNode {
} = t0; const value = useMemo<KeybindingContextValue>(() => {
let t1; const getDisplay = (action: string, context: KeybindingContextName) =>
if ($[0] !== bindings) { getBindingDisplayText(action, context, bindings)
t1 = (action, context) => getBindingDisplayText(action, context, bindings);
$[0] = bindings; // Register a handler for an action
$[1] = t1; const registerHandler = (registration: HandlerRegistration) => {
} else { const registry = handlerRegistryRef.current
t1 = $[1]; if (!registry) return () => {}
}
const getDisplay = t1;
let t2;
if ($[2] !== handlerRegistryRef) {
t2 = registration => {
const registry = handlerRegistryRef.current;
if (!registry) {
return _temp;
}
if (!registry.has(registration.action)) { 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 () => { return () => {
const handlers = registry.get(registration.action); const handlers = registry.get(registration.action)
if (handlers) { if (handlers) {
handlers.delete(registration); handlers.delete(registration)
if (handlers.size === 0) { 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; // Invoke all handlers for an action
} const invokeAction = (action: string): boolean => {
for (const registration_0 of handlers_0) { const registry = handlerRegistryRef.current
if (activeContexts.has(registration_0.context)) { if (!registry) return false
registration_0.handler();
return true; 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; return false
}; }
$[4] = activeContexts;
$[5] = handlerRegistryRef; return {
$[6] = t3; // Use ref for immediate access to pending chord, avoiding React state delay
} else { // This is critical for chord sequences where the second key might be pressed
t3 = $[6]; // before React re-renders with the updated pendingChord state
} resolve: (input, key, contexts) =>
const invokeAction = t3; resolveKeyWithChordState(
let t4; input,
if ($[7] !== bindings || $[8] !== pendingChordRef) { key,
t4 = (input, key, contexts) => resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); contexts,
$[7] = bindings; bindings,
$[8] = pendingChordRef; pendingChordRef.current,
$[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,
setPendingChord, setPendingChord,
getDisplayText: getDisplay, getDisplayText: getDisplay,
bindings, bindings,
@@ -152,49 +155,42 @@ export function KeybindingProvider(t0) {
registerActiveContext, registerActiveContext,
unregisterActiveContext, unregisterActiveContext,
registerHandler, registerHandler,
invokeAction invokeAction,
}; }
$[10] = activeContexts; }, [
$[11] = bindings; bindings,
$[12] = getDisplay; pendingChordRef,
$[13] = invokeAction; pendingChord,
$[14] = pendingChord; setPendingChord,
$[15] = registerActiveContext; activeContexts,
$[16] = registerHandler; registerActiveContext,
$[17] = setPendingChord; unregisterActiveContext,
$[18] = t4; handlerRegistryRef,
$[19] = unregisterActiveContext; ])
$[20] = t5;
} else { return (
t5 = $[20]; <KeybindingContext.Provider value={value}>
} {children}
const value = t5; </KeybindingContext.Provider>
let t6; )
if ($[21] !== children || $[22] !== value) {
t6 = <KeybindingContext.Provider value={value}>{children}</KeybindingContext.Provider>;
$[21] = children;
$[22] = value;
$[23] = t6;
} else {
t6 = $[23];
}
return t6;
} }
function _temp() {}
export function useKeybindingContext() { export function useKeybindingContext(): KeybindingContextValue {
const ctx = useContext(KeybindingContext); const ctx = useContext(KeybindingContext)
if (!ctx) { 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. * Optional hook that returns undefined outside of KeybindingProvider.
* Useful for components that may render before provider is available. * Useful for components that may render before provider is available.
*/ */
export function useOptionalKeybindingContext() { export function useOptionalKeybindingContext(): KeybindingContextValue | null {
return useContext(KeybindingContext); return useContext(KeybindingContext)
} }
/** /**
@@ -212,31 +208,18 @@ export function useOptionalKeybindingContext() {
* } * }
* ``` * ```
*/ */
export function useRegisterKeybindingContext(context, t0) { export function useRegisterKeybindingContext(
const $ = _c(5); context: KeybindingContextName,
const isActive = t0 === undefined ? true : t0; isActive: boolean = true,
const keybindingContext = useOptionalKeybindingContext(); ): void {
let t1; const keybindingContext = useOptionalKeybindingContext()
let t2;
if ($[0] !== context || $[1] !== isActive || $[2] !== keybindingContext) { useLayoutEffect(() => {
t1 = () => { if (!keybindingContext || !isActive) return
if (!keybindingContext || !isActive) {
return; keybindingContext.registerActiveContext(context)
} return () => {
keybindingContext.registerActiveContext(context); keybindingContext.unregisterActiveContext(context)
return () => { }
keybindingContext.unregisterActiveContext(context); }, [context, keybindingContext, isActive])
};
};
t2 = [context, keybindingContext, isActive];
$[0] = context;
$[1] = isActive;
$[2] = keybindingContext;
$[3] = t1;
$[4] = t2;
} else {
t1 = $[3];
t2 = $[4];
}
useLayoutEffect(t1, t2);
} }

View File

@@ -1,4 +1,3 @@
import { c as _c } from "react/compiler-runtime";
/** /**
* Setup utilities for integrating KeybindingProvider into the app. * 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 * user-defined bindings from ~/.claude/keybindings.json, with hot-reload
* support when the file changes. * support when the file changes.
*/ */
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useNotifications } from '../context/notifications.js'; import { useNotifications } from '../context/notifications.js'
import type { InputEvent } from '../ink/events/input-event.js'; import type { InputEvent } from '../ink/events/input-event.js'
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before // ChordInterceptor intentionally uses useInput to intercept all keystrokes before
// other handlers process them - this is required for chord sequence support // other handlers process them - this is required for chord sequence support
// eslint-disable-next-line custom-rules/prefer-use-keybindings // eslint-disable-next-line custom-rules/prefer-use-keybindings
import { type Key, useInput } from '../ink.js'; import { type Key, useInput } from '../ink.js'
import { count } from '../utils/array.js'; import { count } from '../utils/array.js'
import { logForDebugging } from '../utils/debug.js'; import { logForDebugging } from '../utils/debug.js'
import { plural } from '../utils/stringUtils.js'; import { plural } from '../utils/stringUtils.js'
import { KeybindingProvider } from './KeybindingContext.js'; import { KeybindingProvider } from './KeybindingContext.js'
import { initializeKeybindingWatcher, type KeybindingsLoadResult, loadKeybindingsSyncWithWarnings, subscribeToKeybindingChanges } from './loadUserBindings.js'; import {
import { resolveKeyWithChordState } from './resolver.js'; initializeKeybindingWatcher,
import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; type KeybindingsLoadResult,
import type { KeybindingWarning } from './validate.js'; 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. * Timeout for chord sequences in milliseconds.
* If the user doesn't complete the chord within this time, it's cancelled. * 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 = { type Props = {
children: React.ReactNode; children: React.ReactNode
}; }
/** /**
* Keybinding provider with default + user bindings and hot-reload support. * Keybinding provider with default + user bindings and hot-reload support.
@@ -56,156 +65,179 @@ type Props = {
* Display keybinding warnings to the user via notifications. * Display keybinding warnings to the user via notifications.
* Shows a brief message pointing to /doctor for details. * Shows a brief message pointing to /doctor for details.
*/ */
function useKeybindingWarnings(warnings, isReload) { function useKeybindingWarnings(
const $ = _c(9); warnings: KeybindingWarning[],
const { isReload: boolean,
addNotification, ): void {
removeNotification const { addNotification, removeNotification } = useNotifications()
} = useNotifications();
let t0; useEffect(() => {
if ($[0] !== addNotification || $[1] !== removeNotification || $[2] !== warnings) { const notificationKey = 'keybinding-config-warning'
t0 = () => {
if (warnings.length === 0) { if (warnings.length === 0) {
removeNotification("keybinding-config-warning"); removeNotification(notificationKey)
return; return
} }
const errorCount = count(warnings, _temp);
const warnCount = count(warnings, _temp2); const errorCount = count(warnings, w => w.severity === 'error')
let message; const warnCount = count(warnings, w => w.severity === 'warning')
if (errorCount > 0 && warnCount > 0) {
message = `Found ${errorCount} keybinding ${plural(errorCount, "error")} and ${warnCount} ${plural(warnCount, "warning")}`; let message: string
} else { if (errorCount > 0 && warnCount > 0) {
if (errorCount > 0) { message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')} and ${warnCount} ${plural(warnCount, 'warning')}`
message = `Found ${errorCount} keybinding ${plural(errorCount, "error")}`; } else if (errorCount > 0) {
} else { message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')}`
message = `Found ${warnCount} keybinding ${plural(warnCount, "warning")}`; } else {
} message = `Found ${warnCount} keybinding ${plural(warnCount, 'warning')}`
} }
message = message + " \xB7 /doctor for details"; message += ' · /doctor for details'
addNotification({
key: "keybinding-config-warning", addNotification({
text: message, key: notificationKey,
color: errorCount > 0 ? "error" : "warning", text: message,
priority: errorCount > 0 ? "immediate" : "high", color: errorCount > 0 ? 'error' : 'warning',
timeoutMs: 60000 priority: errorCount > 0 ? 'immediate' : 'high',
}); // Keep visible for 60 seconds like settings errors
}; timeoutMs: 60000,
$[0] = addNotification; })
$[1] = removeNotification; }, [warnings, isReload, addNotification, 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"; export function KeybindingSetup({ children }: Props): React.ReactNode {
}
function _temp(w) {
return w.severity === "error";
}
export function KeybindingSetup({
children
}: Props): React.ReactNode {
// Load bindings synchronously for initial render // Load bindings synchronously for initial render
const [{ const [{ bindings, warnings }, setLoadResult] =
bindings, useState<KeybindingsLoadResult>(() => {
warnings const result = loadKeybindingsSyncWithWarnings()
}, setLoadResult] = useState<KeybindingsLoadResult>(() => { logForDebugging(
const result = loadKeybindingsSyncWithWarnings(); `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
logForDebugging(`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`); )
return result; return result
}); })
// Track if this is a reload (not initial load) // Track if this is a reload (not initial load)
const [isReload, setIsReload] = useState(false); const [isReload, setIsReload] = useState(false)
// Display warnings via notifications // Display warnings via notifications
useKeybindingWarnings(warnings, isReload); useKeybindingWarnings(warnings, isReload)
// Chord state management - use ref for immediate access, state for re-renders // 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 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) // The state is used to trigger re-renders when needed (e.g., for UI updates)
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null); const pendingChordRef = useRef<ParsedKeystroke[] | null>(null)
const [pendingChord, setPendingChordState] = useState<ParsedKeystroke[] | null>(null); const [pendingChord, setPendingChordState] = useState<
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null); ParsedKeystroke[] | null
>(null)
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Handler registry for action callbacks (used by ChordInterceptor to invoke handlers) // Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
const handlerRegistryRef = useRef(new Map<string, Set<{ const handlerRegistryRef = useRef(
action: string; new Map<
context: KeybindingContextName; string,
handler: () => void; Set<{
}>>()); action: string
context: KeybindingContextName
handler: () => void
}>
>(),
)
// Active context tracking for keybinding priority resolution // Active context tracking for keybinding priority resolution
// Using a ref instead of state for synchronous updates - input handlers need // Using a ref instead of state for synchronous updates - input handlers need
// to see the current value immediately, not after a React render cycle. // to see the current value immediately, not after a React render cycle.
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set()); const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
const registerActiveContext = useCallback((context: KeybindingContextName) => {
activeContextsRef.current.add(context); const registerActiveContext = useCallback(
}, []); (context: KeybindingContextName) => {
const unregisterActiveContext = useCallback((context_0: KeybindingContextName) => { activeContextsRef.current.add(context)
activeContextsRef.current.delete(context_0); },
}, []); [],
)
const unregisterActiveContext = useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.delete(context)
},
[],
)
// Clear chord timeout when component unmounts or chord changes // Clear chord timeout when component unmounts or chord changes
const clearChordTimeout = useCallback(() => { const clearChordTimeout = useCallback(() => {
if (chordTimeoutRef.current) { if (chordTimeoutRef.current) {
clearTimeout(chordTimeoutRef.current); clearTimeout(chordTimeoutRef.current)
chordTimeoutRef.current = null; chordTimeoutRef.current = null
} }
}, []); }, [])
// Wrapper for setPendingChord that manages timeout and syncs ref+state // Wrapper for setPendingChord that manages timeout and syncs ref+state
const setPendingChord = useCallback((pending: ParsedKeystroke[] | null) => { const setPendingChord = useCallback(
clearChordTimeout(); (pending: ParsedKeystroke[] | null) => {
if (pending !== null) { clearChordTimeout()
// Set timeout to cancel chord if not completed
chordTimeoutRef.current = setTimeout((pendingChordRef_0, setPendingChordState_0) => { if (pending !== null) {
logForDebugging('[keybindings] Chord timeout - cancelling'); // Set timeout to cancel chord if not completed
pendingChordRef_0.current = null; chordTimeoutRef.current = setTimeout(
setPendingChordState_0(null); (pendingChordRef, setPendingChordState) => {
}, CHORD_TIMEOUT_MS, 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(() => { useEffect(() => {
// Initialize file watcher (idempotent - only runs once) // Initialize file watcher (idempotent - only runs once)
void initializeKeybindingWatcher(); void initializeKeybindingWatcher()
// Subscribe to changes // Subscribe to changes
const unsubscribe = subscribeToKeybindingChanges(result_0 => { const unsubscribe = subscribeToKeybindingChanges(result => {
// Any callback invocation is a reload since initial load happens // Any callback invocation is a reload since initial load happens
// synchronously in useState, not via this subscription // synchronously in useState, not via this subscription
setIsReload(true); setIsReload(true)
setLoadResult(result_0);
logForDebugging(`[keybindings] Reloaded: ${result_0.bindings.length} bindings, ${result_0.warnings.length} warnings`); setLoadResult(result)
}); logForDebugging(
`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
)
})
return () => { return () => {
unsubscribe(); unsubscribe()
clearChordTimeout(); clearChordTimeout()
}; }
}, [clearChordTimeout]); }, [clearChordTimeout])
return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={pendingChord} setPendingChord={setPendingChord} activeContexts={activeContextsRef.current} registerActiveContext={registerActiveContext} unregisterActiveContext={unregisterActiveContext} handlerRegistryRef={handlerRegistryRef}>
<ChordInterceptor bindings={bindings} pendingChordRef={pendingChordRef} setPendingChord={setPendingChord} activeContexts={activeContextsRef.current} handlerRegistryRef={handlerRegistryRef} /> return (
<KeybindingProvider
bindings={bindings}
pendingChordRef={pendingChordRef}
pendingChord={pendingChord}
setPendingChord={setPendingChord}
activeContexts={activeContextsRef.current}
registerActiveContext={registerActiveContext}
unregisterActiveContext={unregisterActiveContext}
handlerRegistryRef={handlerRegistryRef}
>
<ChordInterceptor
bindings={bindings}
pendingChordRef={pendingChordRef}
setPendingChord={setPendingChord}
activeContexts={activeContextsRef.current}
handlerRegistryRef={handlerRegistryRef}
/>
{children} {children}
</KeybindingProvider>; </KeybindingProvider>
)
} }
/** /**
@@ -219,89 +251,131 @@ export function KeybindingSetup({
* system could recognize it as completing a chord. * system could recognize it as completing a chord.
*/ */
type HandlerRegistration = { type HandlerRegistration = {
action: string; action: string
context: KeybindingContextName; context: KeybindingContextName
handler: () => void; handler: () => void
}; }
function ChordInterceptor(t0) {
const $ = _c(6); function ChordInterceptor({
const { bindings,
bindings, pendingChordRef,
pendingChordRef, setPendingChord,
setPendingChord, activeContexts,
activeContexts, handlerRegistryRef,
handlerRegistryRef }: {
} = t0; bindings: ParsedBinding[]
let t1; pendingChordRef: React.RefObject<ParsedKeystroke[] | null>
if ($[0] !== activeContexts || $[1] !== bindings || $[2] !== handlerRegistryRef || $[3] !== pendingChordRef || $[4] !== setPendingChord) { setPendingChord: (pending: ParsedKeystroke[] | null) => void
t1 = (input, key, event) => { activeContexts: Set<KeybindingContextName>
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
}): 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) { 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<KeybindingContextName>()
if (registry) { if (registry) {
for (const handlers of registry.values()) { for (const handlers of registry.values()) {
for (const registration of handlers) { for (const registration of handlers) {
handlerContexts.add(registration.context); handlerContexts.add(registration.context)
} }
} }
} }
const contexts = [...handlerContexts, ...activeContexts, "Global"]; const contexts: KeybindingContextName[] = [
const wasInChord = pendingChordRef.current !== null; ...handlerContexts,
const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); ...activeContexts,
bb23: switch (result.type) { 'Global',
case "chord_started": ]
{
setPendingChord(result.pending); // Track whether we're completing a chord (pending was non-null)
event.stopImmediatePropagation(); const wasInChord = pendingChordRef.current !== null
break bb23;
} // Check if this keystroke is part of a chord sequence
case "match": const result = resolveKeyWithChordState(
{ input,
setPendingChord(null); key,
if (wasInChord) { contexts,
const contextsSet = new Set(contexts); bindings,
if (registry) { pendingChordRef.current,
const handlers_0 = registry.get(result.action); )
if (handlers_0 && handlers_0.size > 0) {
for (const registration_0 of handlers_0) { switch (result.type) {
if (contextsSet.has(registration_0.context)) { case 'chord_started':
registration_0.handler(); // This key starts a chord - store pending state and stop propagation
event.stopImmediatePropagation(); setPendingChord(result.pending)
break; 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": break
{ }
setPendingChord(null);
event.stopImmediatePropagation(); case 'chord_cancelled':
break bb23; // Invalid key during chord - clear pending state and swallow the
} // keystroke so it doesn't propagate as a standalone action
case "unbound": // (e.g., ctrl+x ctrl+c should not fire app:interrupt).
{ setPendingChord(null)
setPendingChord(null); event.stopImmediatePropagation()
event.stopImmediatePropagation(); break
break bb23;
} case 'unbound':
case "none": // 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; bindings,
$[2] = handlerRegistryRef; pendingChordRef,
$[3] = pendingChordRef; setPendingChord,
$[4] = setPendingChord; activeContexts,
$[5] = t1; handlerRegistryRef,
} else { ],
t1 = $[5]; )
}
const handleInput = t1; useInput(handleInput)
useInput(handleInput);
return null; return null
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +1,91 @@
import { c as _c } from "react/compiler-runtime"; import { feature } from 'bun:bundle'
import { feature } from 'bun:bundle'; import { dirname } from 'path'
import { dirname } from 'path'; import React from 'react'
import React from 'react'; import { useTerminalSize } from 'src/hooks/useTerminalSize.js'
import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; import { getOriginalCwd, switchSession } from '../bootstrap/state.js'
import { getOriginalCwd, switchSession } from '../bootstrap/state.js'; import type { Command } from '../commands.js'
import type { Command } from '../commands.js'; import { LogSelector } from '../components/LogSelector.js'
import { LogSelector } from '../components/LogSelector.js'; import { Spinner } from '../components/Spinner.js'
import { Spinner } from '../components/Spinner.js'; import { restoreCostStateForSession } from '../cost-tracker.js'
import { restoreCostStateForSession } from '../cost-tracker.js'; import { setClipboard } from '../ink/termio/osc.js'
import { setClipboard } from '../ink/termio/osc.js'; import { Box, Text } from '../ink.js'
import { Box, Text } from '../ink.js'; import { useKeybinding } from '../keybindings/useKeybinding.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'; import {
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
import type { MCPServerConnection, ScopedMcpServerConfig } from '../services/mcp/types.js'; logEvent,
import { useAppState, useSetAppState } from '../state/AppState.js'; } from '../services/analytics/index.js'
import type { Tool } from '../Tool.js'; import type {
import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; MCPServerConnection,
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; ScopedMcpServerConfig,
import { asSessionId } from '../types/ids.js'; } from '../services/mcp/types.js'
import type { LogOption } from '../types/logs.js'; import { useAppState, useSetAppState } from '../state/AppState.js'
import type { Message } from '../types/message.js'; import type { Tool } from '../Tool.js'
import { agenticSessionSearch } from '../utils/agenticSessionSearch.js'; import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
import { renameRecordingForSession } from '../utils/asciicast.js'; import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import { updateSessionName } from '../utils/concurrentSessions.js'; import { asSessionId } from '../types/ids.js'
import { loadConversationForResume } from '../utils/conversationRecovery.js'; import type { LogOption } from '../types/logs.js'
import { checkCrossProjectResume } from '../utils/crossProjectResume.js'; import type { Message } from '../types/message.js'
import type { FileHistorySnapshot } from '../utils/fileHistory.js'; import { agenticSessionSearch } from '../utils/agenticSessionSearch.js'
import { logError } from '../utils/log.js'; import { renameRecordingForSession } from '../utils/asciicast.js'
import { createSystemMessage } from '../utils/messages.js'; import { updateSessionName } from '../utils/concurrentSessions.js'
import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; import { loadConversationForResume } from '../utils/conversationRecovery.js'
import { adoptResumedSessionFile, enrichLogs, isCustomTitleEnabled, loadAllProjectsMessageLogsProgressive, loadSameRepoMessageLogsProgressive, recordContentReplacement, resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult } from '../utils/sessionStorage.js'; import { checkCrossProjectResume } from '../utils/crossProjectResume.js'
import type { ThinkingConfig } from '../utils/thinking.js'; import type { FileHistorySnapshot } from '../utils/fileHistory.js'
import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'; import { logError } from '../utils/log.js'
import { REPL } from './REPL.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 { function parsePrIdentifier(value: string): number | null {
const directNumber = parseInt(value, 10); const directNumber = parseInt(value, 10)
if (!isNaN(directNumber) && directNumber > 0) { 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]) { if (urlMatch?.[1]) {
return parseInt(urlMatch[1], 10); return parseInt(urlMatch[1], 10)
} }
return null; return null
} }
type Props = { type Props = {
commands: Command[]; commands: Command[]
worktreePaths: string[]; worktreePaths: string[]
initialTools: Tool[]; initialTools: Tool[]
mcpClients?: MCPServerConnection[]; mcpClients?: MCPServerConnection[]
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>; dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>
debug: boolean; debug: boolean
mainThreadAgentDefinition?: AgentDefinition; mainThreadAgentDefinition?: AgentDefinition
autoConnectIdeFlag?: boolean; autoConnectIdeFlag?: boolean
strictMcpConfig?: boolean; strictMcpConfig?: boolean
systemPrompt?: string; systemPrompt?: string
appendSystemPrompt?: string; appendSystemPrompt?: string
initialSearchQuery?: string; initialSearchQuery?: string
disableSlashCommands?: boolean; disableSlashCommands?: boolean
forkSession?: boolean; forkSession?: boolean
taskListId?: string; taskListId?: string
filterByPr?: boolean | number | string; filterByPr?: boolean | number | string
thinkingConfig: ThinkingConfig; thinkingConfig: ThinkingConfig
onTurnComplete?: (messages: Message[]) => void | Promise<void>; onTurnComplete?: (messages: Message[]) => void | Promise<void>
}; }
export function ResumeConversation({ export function ResumeConversation({
commands, commands,
worktreePaths, worktreePaths,
@@ -82,317 +104,365 @@ export function ResumeConversation({
taskListId, taskListId,
filterByPr, filterByPr,
thinkingConfig, thinkingConfig,
onTurnComplete onTurnComplete,
}: Props): React.ReactNode { }: Props): React.ReactNode {
const { const { rows } = useTerminalSize()
rows const agentDefinitions = useAppState(s => s.agentDefinitions)
} = useTerminalSize(); const setAppState = useSetAppState()
const agentDefinitions = useAppState(s => s.agentDefinitions); const [logs, setLogs] = React.useState<LogOption[]>([])
const setAppState = useSetAppState(); const [loading, setLoading] = React.useState(true)
const [logs, setLogs] = React.useState<LogOption[]>([]); const [resuming, setResuming] = React.useState(false)
const [loading, setLoading] = React.useState(true); const [showAllProjects, setShowAllProjects] = React.useState(false)
const [resuming, setResuming] = React.useState(false);
const [showAllProjects, setShowAllProjects] = React.useState(false);
const [resumeData, setResumeData] = React.useState<{ const [resumeData, setResumeData] = React.useState<{
messages: Message[]; messages: Message[]
fileHistorySnapshots?: FileHistorySnapshot[]; fileHistorySnapshots?: FileHistorySnapshot[]
contentReplacements?: ContentReplacementRecord[]; contentReplacements?: ContentReplacementRecord[]
agentName?: string; agentName?: string
agentColor?: AgentColorName; agentColor?: AgentColorName
mainThreadAgentDefinition?: AgentDefinition; mainThreadAgentDefinition?: AgentDefinition
} | null>(null); } | null>(null)
const [crossProjectCommand, setCrossProjectCommand] = React.useState<string | null>(null); const [crossProjectCommand, setCrossProjectCommand] = React.useState<
const sessionLogResultRef = React.useRef<SessionLogResult | null>(null); string | null
>(null)
const sessionLogResultRef = React.useRef<SessionLogResult | null>(null)
// Mirror of logs.length so loadMoreLogs can compute value indices outside // Mirror of logs.length so loadMoreLogs can compute value indices outside
// the setLogs updater (keeping it pure per React's contract). // the setLogs updater (keeping it pure per React's contract).
const logCountRef = React.useRef(0); const logCountRef = React.useRef(0)
const filteredLogs = React.useMemo(() => { const filteredLogs = React.useMemo(() => {
let result = logs.filter(l => !l.isSidechain); let result = logs.filter(l => !l.isSidechain)
if (filterByPr !== undefined) { if (filterByPr !== undefined) {
if (filterByPr === true) { if (filterByPr === true) {
result = result.filter(l_0 => l_0.prNumber !== undefined); result = result.filter(l => l.prNumber !== undefined)
} else if (typeof filterByPr === 'number') { } 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') { } else if (typeof filterByPr === 'string') {
const prNumber = parsePrIdentifier(filterByPr); const prNumber = parsePrIdentifier(filterByPr)
if (prNumber !== null) { if (prNumber !== null) {
result = result.filter(l_2 => l_2.prNumber === prNumber); result = result.filter(l => l.prNumber === prNumber)
} }
} }
} }
return result; return result
}, [logs, filterByPr]); }, [logs, filterByPr])
const isResumeWithRenameEnabled = isCustomTitleEnabled(); const isResumeWithRenameEnabled = isCustomTitleEnabled()
React.useEffect(() => { React.useEffect(() => {
loadSameRepoMessageLogsProgressive(worktreePaths).then(result_0 => { loadSameRepoMessageLogsProgressive(worktreePaths)
sessionLogResultRef.current = result_0; .then(result => {
logCountRef.current = result_0.logs.length; sessionLogResultRef.current = result
setLogs(result_0.logs); logCountRef.current = result.logs.length
setLoading(false); setLogs(result.logs)
}).catch(error => { setLoading(false)
logError(error); })
setLoading(false); .catch(error => {
}); logError(error)
}, [worktreePaths]); setLoading(false)
})
}, [worktreePaths])
const loadMoreLogs = React.useCallback((count: number) => { const loadMoreLogs = React.useCallback((count: number) => {
const ref = sessionLogResultRef.current; const ref = sessionLogResultRef.current
if (!ref || ref.nextIndex >= ref.allStatLogs.length) return; if (!ref || ref.nextIndex >= ref.allStatLogs.length) return
void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result_1 => {
ref.nextIndex = result_1.nextIndex; void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => {
if (result_1.logs.length > 0) { ref.nextIndex = result.nextIndex
if (result.logs.length > 0) {
// enrichLogs returns fresh unshared objects — safe to mutate in place. // enrichLogs returns fresh unshared objects — safe to mutate in place.
// Offset comes from logCountRef so the setLogs updater stays pure. // Offset comes from logCountRef so the setLogs updater stays pure.
const offset = logCountRef.current; const offset = logCountRef.current
result_1.logs.forEach((log, i) => { result.logs.forEach((log, i) => {
log.value = offset + i; log.value = offset + i
}); })
setLogs(prev => prev.concat(result_1.logs)); setLogs(prev => prev.concat(result.logs))
logCountRef.current += result_1.logs.length; logCountRef.current += result.logs.length
} else if (ref.nextIndex < ref.allStatLogs.length) { } else if (ref.nextIndex < ref.allStatLogs.length) {
loadMoreLogs(count); loadMoreLogs(count)
} }
}); })
}, []); }, [])
const loadLogs = React.useCallback((allProjects: boolean) => {
setLoading(true); const loadLogs = React.useCallback(
const promise = allProjects ? loadAllProjectsMessageLogsProgressive() : loadSameRepoMessageLogsProgressive(worktreePaths); (allProjects: boolean) => {
promise.then(result_2 => { setLoading(true)
sessionLogResultRef.current = result_2; const promise = allProjects
logCountRef.current = result_2.logs.length; ? loadAllProjectsMessageLogsProgressive()
setLogs(result_2.logs); : loadSameRepoMessageLogsProgressive(worktreePaths)
}).catch(error_0 => { promise
logError(error_0); .then(result => {
}).finally(() => { sessionLogResultRef.current = result
setLoading(false); logCountRef.current = result.logs.length
}); setLogs(result.logs)
}, [worktreePaths]); })
.catch(error => {
logError(error)
})
.finally(() => {
setLoading(false)
})
},
[worktreePaths],
)
const handleToggleAllProjects = React.useCallback(() => { const handleToggleAllProjects = React.useCallback(() => {
const newValue = !showAllProjects; const newValue = !showAllProjects
setShowAllProjects(newValue); setShowAllProjects(newValue)
loadLogs(newValue); loadLogs(newValue)
}, [showAllProjects, loadLogs]); }, [showAllProjects, loadLogs])
function onCancel() { function onCancel() {
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(1); process.exit(1)
} }
async function onSelect(log_0: LogOption) {
setResuming(true); async function onSelect(log: LogOption) {
const resumeStart = performance.now(); setResuming(true)
const crossProjectCheck = checkCrossProjectResume(log_0, showAllProjects, worktreePaths); const resumeStart = performance.now()
const crossProjectCheck = checkCrossProjectResume(
log,
showAllProjects,
worktreePaths,
)
if (crossProjectCheck.isCrossProject) { if (crossProjectCheck.isCrossProject) {
if (!crossProjectCheck.isSameRepoWorktree) { if (!crossProjectCheck.isSameRepoWorktree) {
const raw = await setClipboard((crossProjectCheck as any).command); const raw = await setClipboard(crossProjectCheck.command)
if (raw) process.stdout.write(raw); if (raw) process.stdout.write(raw)
setCrossProjectCommand((crossProjectCheck as any).command); setCrossProjectCommand(crossProjectCheck.command)
return; return
} }
} }
try { try {
const result_3 = await loadConversationForResume(log_0, undefined); const result = await loadConversationForResume(log, undefined)
if (!result_3) { if (!result) {
throw new Error('Failed to load conversation'); throw new Error('Failed to load conversation')
} }
if (feature('COORDINATOR_MODE')) { if (feature('COORDINATOR_MODE')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* 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 */ /* eslint-enable @typescript-eslint/no-require-imports */
const warning = coordinatorModule.matchSessionMode(result_3.mode); const warning = coordinatorModule.matchSessionMode(result.mode)
if (warning) { if (warning) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const { const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } =
getAgentDefinitionsWithOverrides, require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js')
getActiveAgentsFromList
} = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js');
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
getAgentDefinitionsWithOverrides.cache.clear?.(); getAgentDefinitionsWithOverrides.cache.clear?.()
const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd()); const freshAgentDefs = await getAgentDefinitionsWithOverrides(
setAppState(prev_0 => ({ getOriginalCwd(),
...prev_0, )
setAppState(prev => ({
...prev,
agentDefinitions: { agentDefinitions: {
...freshAgentDefs, ...freshAgentDefs,
allAgents: freshAgentDefs.allAgents, allAgents: freshAgentDefs.allAgents,
activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents) activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents),
} },
})); }))
result_3.messages.push(createSystemMessage(warning, 'warning')); result.messages.push(createSystemMessage(warning, 'warning'))
} }
} }
if (result_3.sessionId && !forkSession) {
switchSession(asSessionId(result_3.sessionId), log_0.fullPath ? dirname(log_0.fullPath) : null); if (result.sessionId && !forkSession) {
await renameRecordingForSession(); switchSession(
await resetSessionFilePointer(); asSessionId(result.sessionId),
restoreCostStateForSession(result_3.sessionId); log.fullPath ? dirname(log.fullPath) : null,
} else if (forkSession && result_3.contentReplacements?.length) { )
await recordContentReplacement(result_3.contentReplacements); await renameRecordingForSession()
await resetSessionFilePointer()
restoreCostStateForSession(result.sessionId)
} else if (forkSession && result.contentReplacements?.length) {
await recordContentReplacement(result.contentReplacements)
} }
const {
agentDefinition: resolvedAgentDef const { agentDefinition: resolvedAgentDef } = restoreAgentFromSession(
} = restoreAgentFromSession(result_3.agentSetting, mainThreadAgentDefinition, agentDefinitions); result.agentSetting,
setAppState(prev_1 => ({ mainThreadAgentDefinition,
...prev_1, agentDefinitions,
agent: resolvedAgentDef?.agentType )
})); setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType }))
if (feature('COORDINATOR_MODE')) { if (feature('COORDINATOR_MODE')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const { const { saveMode } = require('../utils/sessionStorage.js')
saveMode const { isCoordinatorMode } =
} = require('../utils/sessionStorage.js'); require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
const {
isCoordinatorMode
} = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js');
/* eslint-enable @typescript-eslint/no-require-imports */ /* 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) { if (standaloneAgentContext) {
setAppState(prev_2 => ({ setAppState(prev => ({ ...prev, standaloneAgentContext }))
...prev_2,
standaloneAgentContext
}));
} }
void updateSessionName(result_3.agentName); void updateSessionName(result.agentName)
restoreSessionMetadata(forkSession ? {
...result_3, restoreSessionMetadata(
worktreeSession: undefined forkSession ? { ...result, worktreeSession: undefined } : result,
} : result_3); )
if (!forkSession) { if (!forkSession) {
restoreWorktreeForResume(result_3.worktreeSession); restoreWorktreeForResume(result.worktreeSession)
if (result_3.sessionId) { if (result.sessionId) {
adoptResumedSessionFile(); adoptResumedSessionFile()
} }
} }
if (feature('CONTEXT_COLLAPSE')) { if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* 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 */ /* eslint-enable @typescript-eslint/no-require-imports */
} }
logEvent('tengu_session_resumed', { 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, success: true,
resume_duration_ms: Math.round(performance.now() - resumeStart) resume_duration_ms: Math.round(performance.now() - resumeStart),
}); })
setLogs([]);
setLogs([])
setResumeData({ setResumeData({
messages: result_3.messages, messages: result.messages,
fileHistorySnapshots: result_3.fileHistorySnapshots, fileHistorySnapshots: result.fileHistorySnapshots,
contentReplacements: result_3.contentReplacements, contentReplacements: result.contentReplacements,
agentName: result_3.agentName, agentName: result.agentName,
agentColor: (result_3.agentColor === 'default' ? undefined : result_3.agentColor) as AgentColorName | undefined, agentColor: (result.agentColor === 'default'
mainThreadAgentDefinition: resolvedAgentDef ? undefined
}); : result.agentColor) as AgentColorName | undefined,
mainThreadAgentDefinition: resolvedAgentDef,
})
} catch (e) { } catch (e) {
logEvent('tengu_session_resumed', { logEvent('tengu_session_resumed', {
entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, entrypoint:
success: false 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}); success: false,
logError(e as Error); })
throw e; logError(e as Error)
throw e
} }
} }
if (crossProjectCommand) { if (crossProjectCommand) {
return <CrossProjectMessage command={crossProjectCommand} />; return <CrossProjectMessage command={crossProjectCommand} />
} }
if (resumeData) { if (resumeData) {
return <REPL debug={debug} commands={commands} initialTools={initialTools} initialMessages={resumeData.messages} initialFileHistorySnapshots={resumeData.fileHistorySnapshots} initialContentReplacements={resumeData.contentReplacements} initialAgentName={resumeData.agentName} initialAgentColor={resumeData.agentColor} mcpClients={mcpClients} dynamicMcpConfig={dynamicMcpConfig} strictMcpConfig={strictMcpConfig} systemPrompt={systemPrompt} appendSystemPrompt={appendSystemPrompt} mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition} autoConnectIdeFlag={autoConnectIdeFlag} disableSlashCommands={disableSlashCommands} taskListId={taskListId} thinkingConfig={thinkingConfig} onTurnComplete={onTurnComplete} />; return (
<REPL
debug={debug}
commands={commands}
initialTools={initialTools}
initialMessages={resumeData.messages}
initialFileHistorySnapshots={resumeData.fileHistorySnapshots}
initialContentReplacements={resumeData.contentReplacements}
initialAgentName={resumeData.agentName}
initialAgentColor={resumeData.agentColor}
mcpClients={mcpClients}
dynamicMcpConfig={dynamicMcpConfig}
strictMcpConfig={strictMcpConfig}
systemPrompt={systemPrompt}
appendSystemPrompt={appendSystemPrompt}
mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition}
autoConnectIdeFlag={autoConnectIdeFlag}
disableSlashCommands={disableSlashCommands}
taskListId={taskListId}
thinkingConfig={thinkingConfig}
onTurnComplete={onTurnComplete}
/>
)
} }
if (loading) { if (loading) {
return <Box> return (
<Box>
<Spinner /> <Spinner />
<Text> Loading conversations</Text> <Text> Loading conversations</Text>
</Box>; </Box>
)
} }
if (resuming) { if (resuming) {
return <Box> return (
<Box>
<Spinner /> <Spinner />
<Text> Resuming conversation</Text> <Text> Resuming conversation</Text>
</Box>; </Box>
)
} }
if (filteredLogs.length === 0) { if (filteredLogs.length === 0) {
return <NoConversationsMessage />; return <NoConversationsMessage />
} }
return <LogSelector logs={filteredLogs} maxHeight={rows} onCancel={onCancel} onSelect={onSelect} onLogsChanged={isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />;
return (
<LogSelector
logs={filteredLogs}
maxHeight={rows}
onCancel={onCancel}
onSelect={onSelect}
onLogsChanged={
isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined
}
onLoadMore={loadMoreLogs}
initialSearchQuery={initialSearchQuery}
showAllProjects={showAllProjects}
onToggleAllProjects={handleToggleAllProjects}
onAgenticSearch={agenticSessionSearch}
/>
)
} }
function NoConversationsMessage() {
const $ = _c(2); function NoConversationsMessage(): React.ReactNode {
let t0; useKeybinding(
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 'app:interrupt',
t0 = { () => {
context: "Global" // eslint-disable-next-line custom-rules/no-process-exit
}; process.exit(1)
$[0] = t0; },
} else { { context: 'Global' },
t0 = $[0]; )
}
useKeybinding("app:interrupt", _temp, t0); return (
let t1; <Box flexDirection="column">
if ($[1] === Symbol.for("react.memo_cache_sentinel")) { <Text>No conversations found to resume.</Text>
t1 = <Box flexDirection="column"><Text>No conversations found to resume.</Text><Text dimColor={true}>Press Ctrl+C to exit and start a new conversation.</Text></Box>; <Text dimColor>Press Ctrl+C to exit and start a new conversation.</Text>
$[1] = t1; </Box>
} else { )
t1 = $[1];
}
return t1;
} }
function _temp() {
process.exit(1); function CrossProjectMessage({
} command,
function CrossProjectMessage(t0) { }: {
const $ = _c(8); command: string
const { }): React.ReactNode {
command React.useEffect(() => {
} = t0; const timeout = setTimeout(() => {
let t1; // eslint-disable-next-line custom-rules/no-process-exit
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { process.exit(0)
t1 = []; }, 100)
$[0] = t1; return () => clearTimeout(timeout)
} else { }, [])
t1 = $[0];
} return (
React.useEffect(_temp3, t1); <Box flexDirection="column" gap={1}>
let t2; <Text>This conversation is from a different directory.</Text>
if ($[1] === Symbol.for("react.memo_cache_sentinel")) { <Box flexDirection="column">
t2 = <Text>This conversation is from a different directory.</Text>; <Text>To resume, run:</Text>
$[1] = t2; <Text> {command}</Text>
} else { </Box>
t2 = $[1]; <Text dimColor>(Command copied to clipboard)</Text>
} </Box>
let t3; )
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t3 = <Text>To resume, run:</Text>;
$[2] = t3;
} else {
t3 = $[2];
}
let t4;
if ($[3] !== command) {
t4 = <Box flexDirection="column">{t3}<Text> {command}</Text></Box>;
$[3] = command;
$[4] = t4;
} else {
t4 = $[4];
}
let t5;
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
t5 = <Text dimColor={true}>(Command copied to clipboard)</Text>;
$[5] = t5;
} else {
t5 = $[5];
}
let t6;
if ($[6] !== t4) {
t6 = <Box flexDirection="column" gap={1}>{t2}{t4}{t5}</Box>;
$[6] = t4;
$[7] = t6;
} else {
t6 = $[7];
}
return t6;
}
function _temp3() {
const timeout = setTimeout(_temp2, 100);
return () => clearTimeout(timeout);
}
function _temp2() {
process.exit(0);
} }

View File

@@ -1,72 +1,74 @@
import { c as _c } from "react/compiler-runtime"; import React, {
import React, { createContext, type ReactNode, useContext, useMemo } from 'react'; createContext,
import type { Command } from '../../commands.js'; type ReactNode,
import type { Tool } from '../../Tool.js'; useContext,
import type { MCPServerConnection, ScopedMcpServerConfig, ServerResource } from './types.js'; useMemo,
import { useManageMCPConnections } from './useManageMCPConnections.js'; } 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 { interface MCPConnectionContextValue {
reconnectMcpServer: (serverName: string) => Promise<{ reconnectMcpServer: (serverName: string) => Promise<{
client: MCPServerConnection; client: MCPServerConnection
tools: Tool[]; tools: Tool[]
commands: Command[]; commands: Command[]
resources?: ServerResource[]; resources?: ServerResource[]
}>; }>
toggleMcpServer: (serverName: string) => Promise<void>; toggleMcpServer: (serverName: string) => Promise<void>
} }
const MCPConnectionContext = createContext<MCPConnectionContextValue | null>(null);
const MCPConnectionContext = createContext<MCPConnectionContextValue | null>(
null,
)
export function useMcpReconnect() { export function useMcpReconnect() {
const context = useContext(MCPConnectionContext); const context = useContext(MCPConnectionContext)
if (!context) { 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() { export function useMcpToggleEnabled() {
const context = useContext(MCPConnectionContext); const context = useContext(MCPConnectionContext)
if (!context) { 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 { interface MCPConnectionManagerProps {
children: ReactNode; children: ReactNode
dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined; dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined
isStrictMcpConfig: boolean; isStrictMcpConfig: boolean
} }
// TODO (ollie): We may be able to get rid of this context by putting these function on app state // TODO (ollie): We may be able to get rid of this context by putting these function on app state
export function MCPConnectionManager(t0) { export function MCPConnectionManager({
const $ = _c(6); children,
const { dynamicMcpConfig,
children, isStrictMcpConfig,
}: MCPConnectionManagerProps): React.ReactNode {
const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections(
dynamicMcpConfig, dynamicMcpConfig,
isStrictMcpConfig isStrictMcpConfig,
} = t0; )
const { const value = useMemo(
reconnectMcpServer, () => ({ reconnectMcpServer, toggleMcpServer }),
toggleMcpServer [reconnectMcpServer, toggleMcpServer],
} = useManageMCPConnections(dynamicMcpConfig, isStrictMcpConfig); )
let t1;
if ($[0] !== reconnectMcpServer || $[1] !== toggleMcpServer) { return (
t1 = { <MCPConnectionContext.Provider value={value}>
reconnectMcpServer, {children}
toggleMcpServer </MCPConnectionContext.Provider>
}; )
$[0] = reconnectMcpServer;
$[1] = toggleMcpServer;
$[2] = t1;
} else {
t1 = $[2];
}
const value = t1;
let t2;
if ($[3] !== children || $[4] !== value) {
t2 = <MCPConnectionContext.Provider value={value}>{children}</MCPConnectionContext.Provider>;
$[3] = children;
$[4] = value;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
} }

View File

@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react'
import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js'; import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js'
import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js'; import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js'
import type { Root } from '../ink.js'; import type { Root } from '../ink.js'
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
import { AppStateProvider } from '../state/AppState.js'; import { AppStateProvider } from '../state/AppState.js'
import { getMcpConfigsByScope } from './mcp/config.js'; import { getMcpConfigsByScope } from './mcp/config.js'
import { getProjectMcpServerStatus } from './mcp/utils.js'; import { getProjectMcpServerStatus } from './mcp/utils.js'
/** /**
* Show MCP server approval dialogs for pending project servers. * 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). * from main.tsx instead of creating a separate one).
*/ */
export async function handleMcpjsonServerApprovals(root: Root): Promise<void> { export async function handleMcpjsonServerApprovals(root: Root): Promise<void> {
const { const { servers: projectServers } = getMcpConfigsByScope('project')
servers: projectServers const pendingServers = Object.keys(projectServers).filter(
} = getMcpConfigsByScope('project'); serverName => getProjectMcpServerStatus(serverName) === 'pending',
const pendingServers = Object.keys(projectServers).filter(serverName => getProjectMcpServerStatus(serverName) === 'pending'); )
if (pendingServers.length === 0) { if (pendingServers.length === 0) {
return; return
} }
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
const done = (): void => void resolve(); const done = (): void => void resolve()
if (pendingServers.length === 1 && pendingServers[0] !== undefined) { if (pendingServers.length === 1 && pendingServers[0] !== undefined) {
const serverName = pendingServers[0]; const serverName = pendingServers[0]
root.render(<AppStateProvider> root.render(
<AppStateProvider>
<KeybindingSetup> <KeybindingSetup>
<MCPServerApprovalDialog serverName={serverName} onDone={done} /> <MCPServerApprovalDialog serverName={serverName} onDone={done} />
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>); </AppStateProvider>,
)
} else { } else {
root.render(<AppStateProvider> root.render(
<AppStateProvider>
<KeybindingSetup> <KeybindingSetup>
<MCPServerMultiselectDialog serverNames={pendingServers} onDone={done} /> <MCPServerMultiselectDialog
serverNames={pendingServers}
onDone={done}
/>
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>); </AppStateProvider>,
)
} }
}); })
} }

View File

@@ -1,15 +1,20 @@
import React from 'react'; import React from 'react'
import { getIsInteractive } from '../../bootstrap/state.js'; import { getIsInteractive } from '../../bootstrap/state.js'
import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js'; import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js'
import { extractDangerousSettings, hasDangerousSettings, hasDangerousSettingsChanged } from '../../components/ManagedSettingsSecurityDialog/utils.js'; import {
import { render } from '../../ink.js'; extractDangerousSettings,
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; hasDangerousSettings,
import { AppStateProvider } from '../../state/AppState.js'; hasDangerousSettingsChanged,
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; } from '../../components/ManagedSettingsSecurityDialog/utils.js'
import { getBaseRenderOptions } from '../../utils/renderOptions.js'; import { render } from '../../ink.js'
import type { SettingsJson } from '../../utils/settings/types.js'; import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
import { logEvent } from '../analytics/index.js'; import { AppStateProvider } from '../../state/AppState.js'
export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'; 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. * 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 * @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 * @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<SecurityCheckResult> { export async function checkManagedSettingsSecurity(
cachedSettings: SettingsJson | null,
newSettings: SettingsJson | null,
): Promise<SecurityCheckResult> {
// If new settings don't have dangerous settings, no check needed // If new settings don't have dangerous settings, no check needed
if (!newSettings || !hasDangerousSettings(extractDangerousSettings(newSettings))) { if (
return 'no_check_needed'; !newSettings ||
!hasDangerousSettings(extractDangerousSettings(newSettings))
) {
return 'no_check_needed'
} }
// If dangerous settings haven't changed, no check needed // If dangerous settings haven't changed, no check needed
if (!hasDangerousSettingsChanged(cachedSettings, newSettings)) { if (!hasDangerousSettingsChanged(cachedSettings, newSettings)) {
return 'no_check_needed'; return 'no_check_needed'
} }
// Skip dialog in non-interactive mode (consistent with trust dialog behavior) // Skip dialog in non-interactive mode (consistent with trust dialog behavior)
if (!getIsInteractive()) { if (!getIsInteractive()) {
return 'no_check_needed'; return 'no_check_needed'
} }
// Log that dialog is being shown // Log that dialog is being shown
logEvent('tengu_managed_settings_security_dialog_shown', {}); logEvent('tengu_managed_settings_security_dialog_shown', {})
// Show blocking dialog // Show blocking dialog
return new Promise<SecurityCheckResult>(resolve => { return new Promise<SecurityCheckResult>(resolve => {
void (async () => { void (async () => {
const { const { unmount } = await render(
unmount <AppStateProvider>
} = await render(<AppStateProvider>
<KeybindingSetup> <KeybindingSetup>
<ManagedSettingsSecurityDialog settings={newSettings} onAccept={() => { <ManagedSettingsSecurityDialog
logEvent('tengu_managed_settings_security_dialog_accepted', {}); settings={newSettings}
unmount(); onAccept={() => {
void resolve('approved'); logEvent('tengu_managed_settings_security_dialog_accepted', {})
}} onReject={() => { unmount()
logEvent('tengu_managed_settings_security_dialog_rejected', {}); void resolve('approved')
unmount(); }}
void resolve('rejected'); onReject={() => {
}} /> logEvent('tengu_managed_settings_security_dialog_rejected', {})
unmount()
void resolve('rejected')
}}
/>
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>, getBaseRenderOptions(false)); </AppStateProvider>,
})(); getBaseRenderOptions(false),
}); )
})()
})
} }
/** /**
* Handle the security check result by exiting if rejected * Handle the security check result by exiting if rejected
* Returns true if we should continue, false if we should stop * 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') { if (result === 'rejected') {
gracefulShutdownSync(1); gracefulShutdownSync(1)
return false; return false
} }
return true; return true
} }

View File

@@ -1,126 +1,134 @@
import { c as _c } from "react/compiler-runtime"; import { feature } from 'bun:bundle'
import { feature } from 'bun:bundle'; import React, {
import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react'; useContext,
import { MailboxProvider } from '../context/mailbox.js'; useEffect,
import { useSettingsChange } from '../hooks/useSettingsChange.js'; useEffectEvent,
import { logForDebugging } from '../utils/debug.js'; useState,
import { createDisabledBypassPermissionsContext, isBypassPermissionsModeDisabled } from '../utils/permissions/permissionSetup.js'; useSyncExternalStore,
import { applySettingsChange } from '../utils/settings/applySettingsChange.js'; } from 'react'
import type { SettingSource } from '../utils/settings/constants.js'; import { MailboxProvider } from '../context/mailbox.js'
import { createStore } from './store.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. // DCE: voice context is ant-only. External builds get a passthrough.
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const VoiceProvider: (props: { const VoiceProvider: (props: { children: React.ReactNode }) => React.ReactNode =
children: React.ReactNode; feature('VOICE_MODE')
}) => React.ReactNode = feature('VOICE_MODE') ? require('../context/voice.js').VoiceProvider : ({ ? require('../context/voice.js').VoiceProvider
children : ({ children }) => children
}) => children;
/* eslint-enable @typescript-eslint/no-require-imports */ /* 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 // TODO: Remove these re-exports once all callers import directly from
// ./AppStateStore.js. Kept for back-compat during migration so .ts callers // ./AppStateStore.js. Kept for back-compat during migration so .ts callers
// can incrementally move off the .tsx import and stop pulling React. // 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 {
export const AppStoreContext = React.createContext<AppStateStore | null>(null); type AppState,
type AppStateStore,
type CompletionBoundary,
getDefaultAppState,
IDLE_SPECULATION_STATE,
type SpeculationResult,
type SpeculationState,
} from './AppStateStore.js'
export const AppStoreContext = React.createContext<AppStateStore | null>(null)
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode
initialState?: AppState; initialState?: AppState
onChangeAppState?: (args: { onChangeAppState?: (args: { newState: AppState; oldState: AppState }) => void
newState: AppState; }
oldState: AppState;
}) => void; const HasAppStateContext = React.createContext<boolean>(false)
};
const HasAppStateContext = React.createContext<boolean>(false); export function AppStateProvider({
export function AppStateProvider(t0) { children,
const $ = _c(13); initialState,
const { onChangeAppState,
children, }: Props): React.ReactNode {
initialState, // Don't allow nested AppStateProviders.
onChangeAppState const hasAppStateContext = useContext(HasAppStateContext)
} = t0;
const hasAppStateContext = useContext(HasAppStateContext);
if (hasAppStateContext) { if (hasAppStateContext) {
throw new Error("AppStateProvider can not be nested within another AppStateProvider"); throw new Error(
'AppStateProvider can not be nested within another AppStateProvider',
)
} }
let t1;
if ($[0] !== initialState || $[1] !== onChangeAppState) { // Store is created once and never changes -- stable context value means
t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState); // the provider never triggers re-renders. Consumers subscribe to slices
$[0] = initialState; // via useSyncExternalStore in useAppState(selector).
$[1] = onChangeAppState; const [store] = useState(() =>
$[2] = t1; createStore<AppState>(
} else { initialState ?? getDefaultAppState(),
t1 = $[2]; onChangeAppState,
} ),
const [store] = useState(t1); )
let t2;
if ($[3] !== store) { // Check on mount if bypass mode should be disabled
t2 = () => { // This handles the race condition where remote settings load BEFORE this component mounts,
const { // meaning the settings change notification was sent when no listeners were subscribed.
toolPermissionContext // On subsequent sessions, the cached remote-settings.json is read during initial setup,
} = store.getState(); // but on the first session the remote fetch may complete before React mounts.
if (toolPermissionContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) { useEffect(() => {
logForDebugging("Disabling bypass permissions mode on mount (remote settings loaded before mount)"); const { toolPermissionContext } = store.getState()
store.setState(_temp); if (
} toolPermissionContext.isBypassPermissionsModeAvailable &&
}; isBypassPermissionsModeDisabled()
$[3] = store; ) {
$[4] = t2; logForDebugging(
} else { 'Disabling bypass permissions mode on mount (remote settings loaded before mount)',
t2 = $[4]; )
} store.setState(prev => ({
let t3; ...prev,
if ($[5] === Symbol.for("react.memo_cache_sentinel")) { toolPermissionContext: createDisabledBypassPermissionsContext(
t3 = []; prev.toolPermissionContext,
$[5] = t3; ),
} else { }))
t3 = $[5]; }
} // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect
useEffect(t2, t3); }, [])
let t4;
if ($[6] !== store.setState) { // Listen for external settings changes and sync to AppState.
t4 = source => applySettingsChange(source, store.setState); // This ensures file watcher changes propagate through the app --
$[6] = store.setState; // shared with the headless/SDK path via applySettingsChange.
$[7] = t4; const onSettingsChange = useEffectEvent((source: SettingSource) =>
} else { applySettingsChange(source, store.setState),
t4 = $[7]; )
} useSettingsChange(onSettingsChange)
const onSettingsChange = useEffectEvent(t4);
useSettingsChange(onSettingsChange); return (
let t5; <HasAppStateContext.Provider value={true}>
if ($[8] !== children) { <AppStoreContext.Provider value={store}>
t5 = <MailboxProvider><VoiceProvider>{children}</VoiceProvider></MailboxProvider>; <MailboxProvider>
$[8] = children; <VoiceProvider>{children}</VoiceProvider>
$[9] = t5; </MailboxProvider>
} else { </AppStoreContext.Provider>
t5 = $[9]; </HasAppStateContext.Provider>
} )
let t6;
if ($[10] !== store || $[11] !== t5) {
t6 = <HasAppStateContext.Provider value={true}><AppStoreContext.Provider value={store}>{t5}</AppStoreContext.Provider></HasAppStateContext.Provider>;
$[10] = store;
$[11] = t5;
$[12] = t6;
} else {
t6 = $[12];
}
return t6;
}
function _temp(prev) {
return {
...prev,
toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext)
};
} }
function useAppStore(): AppStateStore { function useAppStore(): AppStateStore {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const store = useContext(AppStoreContext); const store = useContext(AppStoreContext)
if (!store) { if (!store) {
throw new ReferenceError('useAppState/useSetAppState cannot be called outside of an <AppStateProvider />'); throw new ReferenceError(
'useAppState/useSetAppState cannot be called outside of an <AppStateProvider />',
)
} }
return store; return store
} }
/** /**
@@ -139,27 +147,23 @@ function useAppStore(): AppStateStore {
* const { text, promptId } = useAppState(s => s.promptSuggestion) // good * const { text, promptId } = useAppState(s => s.promptSuggestion) // good
* ``` * ```
*/ */
export function useAppState<R>(selector: (state: AppState) => R): R { export function useAppState<T>(selector: (state: AppState) => T): T {
const $ = _c(3); const store = useAppStore()
const store = useAppStore();
let t0; const get = () => {
if ($[0] !== selector || $[1] !== store) { const state = store.getState()
t0 = () => { const selected = selector(state)
const state = store.getState();
const selected = selector(state); if (process.env.USER_TYPE === 'ant' && state === selected) {
if (false && state === selected) { throw new Error(
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.`); `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; return selected
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
} }
const get = t0;
return useSyncExternalStore(store.subscribe, get, get); return useSyncExternalStore(store.subscribe, get, get)
} }
/** /**
@@ -167,33 +171,30 @@ export function useAppState<R>(selector: (state: AppState) => R): R {
* Returns a stable reference that never changes -- components using only * Returns a stable reference that never changes -- components using only
* this hook will never re-render from state changes. * this hook will never re-render from state changes.
*/ */
export function useSetAppState() { export function useSetAppState(): (
return useAppStore().setState; updater: (prev: AppState) => AppState,
) => void {
return useAppStore().setState
} }
/** /**
* Get the store directly (for passing getState/setState to non-React code). * Get the store directly (for passing getState/setState to non-React code).
*/ */
export function useAppStateStore() { export function useAppStateStore(): AppStateStore {
return useAppStore(); return useAppStore()
} }
const NOOP_SUBSCRIBE = () => () => {};
const NOOP_SUBSCRIBE = () => () => {}
/** /**
* Safe version of useAppState that returns undefined if called outside of AppStateProvider. * 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. * Useful for components that may be rendered in contexts where AppStateProvider isn't available.
*/ */
export function useAppStateMaybeOutsideOfProvider<R>(selector: (state: AppState) => R): R | undefined { export function useAppStateMaybeOutsideOfProvider<T>(
const $ = _c(3); selector: (state: AppState) => T,
const store = useContext(AppStoreContext); ): T | undefined {
let t0; const store = useContext(AppStoreContext)
if ($[0] !== selector || $[1] !== store) { return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, () =>
t0 = () => store ? selector(store.getState()) : undefined; store ? selector(store.getState()) : undefined,
$[0] = selector; )
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
}
return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, t0);
} }

View File

@@ -9,14 +9,19 @@
* 4. Can be idle (waiting for work) or active (processing) * 4. Can be idle (waiting for work) or active (processing)
*/ */
import { isTerminalTaskStatus, type SetAppState, type Task, type TaskStateBase } from '../../Task.js'; import {
import type { Message } from '../../types/message.js'; isTerminalTaskStatus,
import { logForDebugging } from '../../utils/debug.js'; type SetAppState,
import { createUserMessage } from '../../utils/messages.js'; type Task,
import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js'; type TaskStateBase,
import { updateTaskState } from '../../utils/task/framework.js'; } from '../../Task.js'
import type { InProcessTeammateTaskState } from './types.js'; import type { Message } from '../../types/message.js'
import { appendCappedMessage, isInProcessTeammateTask } from './types.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. * InProcessTeammateTask - Handles in-process teammate execution.
@@ -25,39 +30,48 @@ export const InProcessTeammateTask: Task = {
name: 'InProcessTeammateTask', name: 'InProcessTeammateTask',
type: 'in_process_teammate', type: 'in_process_teammate',
async kill(taskId, setAppState) { async kill(taskId, setAppState) {
killInProcessTeammate(taskId, setAppState); killInProcessTeammate(taskId, setAppState)
} },
}; }
/** /**
* Request shutdown for a teammate. * Request shutdown for a teammate.
*/ */
export function requestTeammateShutdown(taskId: string, setAppState: SetAppState): void { export function requestTeammateShutdown(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => { updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
if (task.status !== 'running' || task.shutdownRequested) { if (task.status !== 'running' || task.shutdownRequested) {
return task; return task
} }
return { return {
...task, ...task,
shutdownRequested: true shutdownRequested: true,
}; }
}); })
} }
/** /**
* Append a message to a teammate's conversation history. * Append a message to a teammate's conversation history.
* Used for zoomed view to show the teammate's conversation. * 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<InProcessTeammateTaskState>(taskId, setAppState, task => { updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') { if (task.status !== 'running') {
return task; return task
} }
return { return {
...task, ...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. * 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. * 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<InProcessTeammateTaskState>(taskId, setAppState, task => { updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
// Allow message injection when teammate is running or idle (waiting for input) // Allow message injection when teammate is running or idle (waiting for input)
// Only reject if teammate is in a terminal state // Only reject if teammate is in a terminal state
if (isTerminalTaskStatus(task.status)) { if (isTerminalTaskStatus(task.status)) {
logForDebugging(`Dropping message for teammate task ${taskId}: task status is "${task.status}"`); logForDebugging(
return task; `Dropping message for teammate task ${taskId}: task status is "${task.status}"`,
)
return task
} }
return { return {
...task, ...task,
pendingUserMessages: [...task.pendingUserMessages, message], pendingUserMessages: [...task.pendingUserMessages, message],
messages: appendCappedMessage(task.messages, createUserMessage({ messages: appendCappedMessage(
content: message task.messages,
})) createUserMessage({ content: message }),
}; ),
}); }
})
} }
/** /**
@@ -89,29 +111,34 @@ export function injectUserMessageToTeammate(taskId: string, message: string, set
* with the same agentId exist. * with the same agentId exist.
* Returns undefined if not found. * Returns undefined if not found.
*/ */
export function findTeammateTaskByAgentId(agentId: string, tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState | undefined { export function findTeammateTaskByAgentId(
let fallback: InProcessTeammateTaskState | undefined; agentId: string,
tasks: Record<string, TaskStateBase>,
): InProcessTeammateTaskState | undefined {
let fallback: InProcessTeammateTaskState | undefined
for (const task of Object.values(tasks)) { for (const task of Object.values(tasks)) {
if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) { if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) {
// Prefer running tasks in case old killed tasks still exist in AppState // Prefer running tasks in case old killed tasks still exist in AppState
// alongside new running ones with the same agentId // alongside new running ones with the same agentId
if (task.status === 'running') { if (task.status === 'running') {
return task; return task
} }
// Keep first match as fallback in case no running task exists // Keep first match as fallback in case no running task exists
if (!fallback) { if (!fallback) {
fallback = task; fallback = task
} }
} }
} }
return fallback; return fallback
} }
/** /**
* Get all in-process teammate tasks from AppState. * Get all in-process teammate tasks from AppState.
*/ */
export function getAllInProcessTeammateTasks(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] { export function getAllInProcessTeammateTasks(
return Object.values(tasks).filter(isInProcessTeammateTask); tasks: Record<string, TaskStateBase>,
): InProcessTeammateTaskState[] {
return Object.values(tasks).filter(isInProcessTeammateTask)
} }
/** /**
@@ -120,6 +147,10 @@ export function getAllInProcessTeammateTasks(tasks: Record<string, TaskStateBase
* and useBackgroundTaskNavigation — selectedIPAgentIndex maps into this * and useBackgroundTaskNavigation — selectedIPAgentIndex maps into this
* array, so all three must agree on sort order. * array, so all three must agree on sort order.
*/ */
export function getRunningTeammatesSorted(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] { export function getRunningTeammatesSorted(
return getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running').sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)); tasks: Record<string, TaskStateBase>,
): InProcessTeammateTaskState[] {
return getAllInProcessTeammateTasks(tasks)
.filter(t => t.status === 'running')
.sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName))
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,83 +1,119 @@
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle'
import { stat } from 'fs/promises'; 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 {
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; OUTPUT_FILE_TAG,
import type { AppState } from '../../state/AppState.js'; STATUS_TAG,
import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js'; SUMMARY_TAG,
import { createTaskStateBase } from '../../Task.js'; TASK_ID_TAG,
import type { AgentId } from '../../types/ids.js'; TASK_NOTIFICATION_TAG,
import { registerCleanup } from '../../utils/cleanupRegistry.js'; TOOL_USE_ID_TAG,
import { tailFile } from '../../utils/fsOperations.js'; } from '../../constants/xml.js'
import { logError } from '../../utils/log.js'; import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; import type { AppState } from '../../state/AppState.js'
import type { ShellCommand } from '../../utils/ShellCommand.js'; import type {
import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js'; LocalShellSpawnInput,
import { registerTask, updateTaskState } from '../../utils/task/framework.js'; SetAppState,
import { escapeXml } from '../../utils/xml.js'; Task,
import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js'; TaskContext,
import { isMainSessionTask } from '../LocalMainSessionTask.js'; TaskHandle,
import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js'; } from '../../Task.js'
import { killTask } from './killShellTasks.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. */ /** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */
export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '; export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '
const STALL_CHECK_INTERVAL_MS = 5_000;
const STALL_THRESHOLD_MS = 45_000; const STALL_CHECK_INTERVAL_MS = 5_000
const STALL_TAIL_BYTES = 1024; const STALL_THRESHOLD_MS = 45_000
const STALL_TAIL_BYTES = 1024
// Last-line patterns that suggest a command is blocked waiting for keyboard // 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 // 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 // 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. // looks like an interactive prompt the model can act on. See CC-1175.
const PROMPT_PATTERNS = [/\(y\/n\)/i, const PROMPT_PATTERNS = [
// (Y/n), (y/N) /\(y\/n\)/i, // (Y/n), (y/N)
/\[y\/n\]/i, /\[y\/n\]/i, // [Y/n], [y/N]
// [Y/n], [y/N] /\(yes\/no\)/i,
/\(yes\/no\)/i, /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, // directed questions
// directed questions /Press (any key|Enter)/i,
/Press (any key|Enter)/i, /Continue\?/i, /Overwrite\?/i]; /Continue\?/i,
/Overwrite\?/i,
]
export function looksLikePrompt(tail: string): boolean { export function looksLikePrompt(tail: string): boolean {
const lastLine = tail.trimEnd().split('\n').pop() ?? ''; const lastLine = tail.trimEnd().split('\n').pop() ?? ''
return PROMPT_PATTERNS.some(p => p.test(lastLine)); return PROMPT_PATTERNS.some(p => p.test(lastLine))
} }
// Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot // Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot
// notification if output stops growing and the tail looks like a prompt. // 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 { function startStallWatchdog(
if (kind === 'monitor') return () => {}; taskId: string,
const outputPath = getTaskOutputPath(taskId); description: string,
let lastSize = 0; kind: BashTaskKind | undefined,
let lastGrowth = Date.now(); toolUseId?: string,
let cancelled = false; agentId?: AgentId,
): () => void {
if (kind === 'monitor') return () => {}
const outputPath = getTaskOutputPath(taskId)
let lastSize = 0
let lastGrowth = Date.now()
let cancelled = false
const timer = setInterval(() => { const timer = setInterval(() => {
void stat(outputPath).then(s => { void stat(outputPath).then(
if (s.size > lastSize) { s => {
lastSize = s.size; if (s.size > lastSize) {
lastGrowth = Date.now(); lastSize = s.size
return; 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;
} }
// Latch before the async-boundary-visible side effects so an if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return
// overlapping tick's callback sees cancelled=true and bails. void tailFile(outputPath, STALL_TAIL_BYTES).then(
cancelled = true; ({ content }) => {
clearInterval(timer); if (cancelled) return
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : ''; if (!looksLikePrompt(content)) {
const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`; // Not a prompt — keep watching. Reset so the next check is
// No <status> tag — print.ts treats <status> as a terminal // 45s out instead of re-reading the tail on every tick.
// signal and an unknown value falls through to 'completed', lastGrowth = Date.now()
// falsely closing the task for SDK consumers. Statusless return
// notifications are skipped by the SDK emitter (progress ping). }
const message = `<${TASK_NOTIFICATION_TAG}> // 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}</${TOOL_USE_ID_TAG}>`
: ''
const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`
// No <status> tag — print.ts treats <status> 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}</${TASK_ID_TAG}>${toolUseIdLine} <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}> <${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
@@ -85,47 +121,60 @@ function startStallWatchdog(taskId: string, description: string, kind: BashTaskK
Last output: Last output:
${content.trimEnd()} ${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.`; 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({ enqueuePendingNotification({
value: message, value: message,
mode: 'task-notification', mode: 'task-notification',
priority: 'next', priority: 'next',
agentId agentId,
}); })
}, () => {}); },
}, () => {} // File may not exist yet () => {},
); )
}, STALL_CHECK_INTERVAL_MS); },
timer.unref(); () => {}, // File may not exist yet
)
}, STALL_CHECK_INTERVAL_MS)
timer.unref()
return () => { return () => {
cancelled = true; cancelled = true
clearInterval(timer); 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. // Atomically check and set notified flag to prevent duplicate notifications.
// If the task was already marked as notified (e.g., by TaskStopTool), skip // If the task was already marked as notified (e.g., by TaskStopTool), skip
// enqueueing to avoid sending redundant messages to the model. // enqueueing to avoid sending redundant messages to the model.
let shouldEnqueue = false; let shouldEnqueue = false
updateTaskState<LocalShellTaskState>(taskId, setAppState, task => { updateTaskState(taskId, setAppState, task => {
if (task.notified) { if (task.notified) {
return task; return task
} }
shouldEnqueue = true; shouldEnqueue = true
return { return { ...task, notified: true }
...task, })
notified: true
};
});
if (!shouldEnqueue) { if (!shouldEnqueue) {
return; return
} }
// Abort any active speculation — background task state changed, so speculated // Abort any active speculation — background task state changed, so speculated
// results may reference stale task output. The prompt suggestion text is // results may reference stale task output. The prompt suggestion text is
// preserved; only the pre-computed response is discarded. // preserved; only the pre-computed response is discarded.
abortSpeculation(setAppState); abortSpeculation(setAppState)
let summary: string;
let summary: string
if (feature('MONITOR_TOOL') && kind === 'monitor') { if (feature('MONITOR_TOOL') && kind === 'monitor') {
// Monitor is streaming-only (post-#22764) — the script exiting means // Monitor is streaming-only (post-#22764) — the script exiting means
// the stream ended, not "condition met". Distinct from the bash prefix // the stream ended, not "condition met". Distinct from the bash prefix
@@ -133,73 +182,71 @@ function enqueueShellNotification(taskId: string, description: string, status: '
// completed" collapse. // completed" collapse.
switch (status) { switch (status) {
case 'completed': case 'completed':
summary = `Monitor "${description}" stream ended`; summary = `Monitor "${description}" stream ended`
break; break
case 'failed': case 'failed':
summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`; summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`
break; break
case 'killed': case 'killed':
summary = `Monitor "${description}" stopped`; summary = `Monitor "${description}" stopped`
break; break
} }
} else { } else {
switch (status) { switch (status) {
case 'completed': case 'completed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`; summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`
break; break
case 'failed': case 'failed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`; summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`
break; break
case 'killed': case 'killed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`; summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`
break; break
} }
} }
const outputPath = getTaskOutputPath(taskId);
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : ''; const outputPath = getTaskOutputPath(taskId)
const toolUseIdLine = toolUseId
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
: ''
const message = `<${TASK_NOTIFICATION_TAG}> const message = `<${TASK_NOTIFICATION_TAG}>
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine} <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
<${STATUS_TAG}>${status}</${STATUS_TAG}> <${STATUS_TAG}>${status}</${STATUS_TAG}>
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}> <${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
</${TASK_NOTIFICATION_TAG}>`; </${TASK_NOTIFICATION_TAG}>`
enqueuePendingNotification({ enqueuePendingNotification({
value: message, value: message,
mode: 'task-notification', mode: 'task-notification',
priority: feature('MONITOR_TOOL') ? 'next' : 'later', priority: feature('MONITOR_TOOL') ? 'next' : 'later',
agentId agentId,
}); })
} }
export const LocalShellTask: Task = { export const LocalShellTask: Task = {
name: 'LocalShellTask', name: 'LocalShellTask',
type: 'local_bash', type: 'local_bash',
async kill(taskId, setAppState) { async kill(taskId, setAppState) {
killTask(taskId, setAppState); killTask(taskId, setAppState)
} },
}; }
export async function spawnShellTask(input: LocalShellSpawnInput & {
shellCommand: ShellCommand; export async function spawnShellTask(
}, context: TaskContext): Promise<TaskHandle> { input: LocalShellSpawnInput & { shellCommand: ShellCommand },
const { context: TaskContext,
command, ): Promise<TaskHandle> {
description, const { command, description, shellCommand, toolUseId, agentId, kind } = input
shellCommand, const { setAppState } = context
toolUseId,
agentId,
kind
} = input;
const {
setAppState
} = context;
// TaskOutput owns the data — use its taskId so disk writes are consistent // TaskOutput owns the data — use its taskId so disk writes are consistent
const { const { taskOutput } = shellCommand
taskOutput const taskId = taskOutput.taskId
} = shellCommand;
const taskId = taskOutput.taskId;
const unregisterCleanup = registerCleanup(async () => { const unregisterCleanup = registerCleanup(async () => {
killTask(taskId, setAppState); killTask(taskId, setAppState)
}); })
const taskState: LocalShellTaskState = { const taskState: LocalShellTaskState = {
...createTaskStateBase(taskId, 'local_bash', description, toolUseId), ...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
type: 'local_bash', type: 'local_bash',
@@ -211,44 +258,64 @@ export async function spawnShellTask(input: LocalShellSpawnInput & {
lastReportedTotalLines: 0, lastReportedTotalLines: 0,
isBackgrounded: true, isBackgrounded: true,
agentId, agentId,
kind kind,
}; }
registerTask(taskState, setAppState);
registerTask(taskState, setAppState)
// Data flows through TaskOutput automatically — no stream listeners needed. // Data flows through TaskOutput automatically — no stream listeners needed.
// Just transition to backgrounded state so the process keeps running. // Just transition to backgrounded state so the process keeps running.
shellCommand.background(taskId); shellCommand.background(taskId)
const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId);
const cancelStallWatchdog = startStallWatchdog(
taskId,
description,
kind,
toolUseId,
agentId,
)
void shellCommand.result.then(async result => { void shellCommand.result.then(async result => {
cancelStallWatchdog(); cancelStallWatchdog()
await flushAndCleanup(shellCommand); await flushAndCleanup(shellCommand)
let wasKilled = false; let wasKilled = false
updateTaskState<LocalShellTaskState>(taskId, setAppState, task => { updateTaskState<LocalShellTaskState>(taskId, setAppState, task => {
if (task.status === 'killed') { if (task.status === 'killed') {
wasKilled = true; wasKilled = true
return task; return task
} }
return { return {
...task, ...task,
status: result.code === 0 ? 'completed' : 'failed', status: result.code === 0 ? 'completed' : 'failed',
result: { result: { code: result.code, interrupted: result.interrupted },
code: result.code,
interrupted: result.interrupted
},
shellCommand: null, shellCommand: null,
unregisterCleanup: undefined, unregisterCleanup: undefined,
endTime: Date.now() endTime: Date.now(),
}; }
}); })
enqueueShellNotification(taskId, description, wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', result.code, setAppState, toolUseId, kind, agentId);
void evictTaskOutput(taskId); enqueueShellNotification(
}); taskId,
description,
wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed',
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
void evictTaskOutput(taskId)
})
return { return {
taskId, taskId,
cleanup: () => { 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. * Called when a bash command has been running long enough to show the BackgroundHint.
* @returns taskId for the registered task * @returns taskId for the registered task
*/ */
export function registerForeground(input: LocalShellSpawnInput & { export function registerForeground(
shellCommand: ShellCommand; input: LocalShellSpawnInput & { shellCommand: ShellCommand },
}, setAppState: SetAppState, toolUseId?: string): string { setAppState: SetAppState,
const { toolUseId?: string,
command, ): string {
description, const { command, description, shellCommand, agentId } = input
shellCommand,
agentId const taskId = shellCommand.taskOutput.taskId
} = input;
const taskId = shellCommand.taskOutput.taskId;
const unregisterCleanup = registerCleanup(async () => { const unregisterCleanup = registerCleanup(async () => {
killTask(taskId, setAppState); killTask(taskId, setAppState)
}); })
const taskState: LocalShellTaskState = { const taskState: LocalShellTaskState = {
...createTaskStateBase(taskId, 'local_bash', description, toolUseId), ...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
type: 'local_bash', type: 'local_bash',
@@ -278,93 +345,119 @@ export function registerForeground(input: LocalShellSpawnInput & {
shellCommand, shellCommand,
unregisterCleanup, unregisterCleanup,
lastReportedTotalLines: 0, lastReportedTotalLines: 0,
isBackgrounded: false, isBackgrounded: false, // Not yet backgrounded - running in foreground
// Not yet backgrounded - running in foreground agentId,
agentId }
};
registerTask(taskState, setAppState); registerTask(taskState, setAppState)
return taskId; return taskId
} }
/** /**
* Background a specific foreground task. * Background a specific foreground task.
* @returns true if backgrounded successfully, false otherwise * @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 // Step 1: Get the task and shell command from current state
const state = getAppState(); const state = getAppState()
const task = state.tasks[taskId]; const task = state.tasks[taskId]
if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) { if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) {
return false; return false
} }
const shellCommand = task.shellCommand;
const description = task.description; const shellCommand = task.shellCommand
const { const description = task.description
toolUseId, const { toolUseId, kind, agentId } = task
kind,
agentId
} = task;
// Transition to backgrounded — TaskOutput continues receiving data automatically // Transition to backgrounded — TaskOutput continues receiving data automatically
if (!shellCommand.background(taskId)) { if (!shellCommand.background(taskId)) {
return false; return false
} }
setAppState(prev => { setAppState(prev => {
const prevTask = prev.tasks[taskId]; const prevTask = prev.tasks[taskId]
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
return prev; return prev
} }
return { return {
...prev, ...prev,
tasks: { tasks: {
...prev.tasks, ...prev.tasks,
[taskId]: { [taskId]: { ...prevTask, isBackgrounded: true },
...prevTask, },
isBackgrounded: true }
} })
}
}; const cancelStallWatchdog = startStallWatchdog(
}); taskId,
const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); description,
kind,
toolUseId,
agentId,
)
// Set up result handler // Set up result handler
void shellCommand.result.then(async result => { void shellCommand.result.then(async result => {
cancelStallWatchdog(); cancelStallWatchdog()
await flushAndCleanup(shellCommand); await flushAndCleanup(shellCommand)
let wasKilled = false; let wasKilled = false
let cleanupFn: (() => void) | undefined; let cleanupFn: (() => void) | undefined
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => { updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
if (t.status === 'killed') { if (t.status === 'killed') {
wasKilled = true; wasKilled = true
return t; return t
} }
// Capture cleanup function to call outside of updater // Capture cleanup function to call outside of updater
cleanupFn = t.unregisterCleanup; cleanupFn = t.unregisterCleanup
return { return {
...t, ...t,
status: result.code === 0 ? 'completed' : 'failed', status: result.code === 0 ? 'completed' : 'failed',
result: { result: { code: result.code, interrupted: result.interrupted },
code: result.code,
interrupted: result.interrupted
},
shellCommand: null, shellCommand: null,
unregisterCleanup: undefined, unregisterCleanup: undefined,
endTime: Date.now() endTime: Date.now(),
}; }
}); })
// Call cleanup outside of the state updater (avoid side effects in updater) // Call cleanup outside of the state updater (avoid side effects in updater)
cleanupFn?.(); cleanupFn?.()
if (wasKilled) { if (wasKilled) {
enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId); enqueueShellNotification(
taskId,
description,
'killed',
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
} else { } else {
const finalStatus = result.code === 0 ? 'completed' : 'failed'; const finalStatus = result.code === 0 ? 'completed' : 'failed'
enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId); enqueueShellNotification(
taskId,
description,
finalStatus,
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
} }
void evictTaskOutput(taskId);
}); void evictTaskOutput(taskId)
return true; })
return true
} }
/** /**
@@ -378,34 +471,42 @@ function backgroundTask(taskId: string, getAppState: () => AppState, setAppState
export function hasForegroundTasks(state: AppState): boolean { export function hasForegroundTasks(state: AppState): boolean {
return Object.values(state.tasks).some(task => { return Object.values(state.tasks).some(task => {
if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) { 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 // Exclude main session tasks - they display in the main view, not as foreground tasks
if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) { if (
return true; 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 // Background all foreground bash tasks
const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => { const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => {
const task = state.tasks[id]; const task = state.tasks[id]
return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand; return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand
}); })
for (const taskId of foregroundBashTaskIds) { for (const taskId of foregroundBashTaskIds) {
backgroundTask(taskId, getAppState, setAppState); backgroundTask(taskId, getAppState, setAppState)
} }
// Background all foreground agent tasks // Background all foreground agent tasks
const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => { const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => {
const task = state.tasks[id]; const task = state.tasks[id]
return isLocalAgentTask(task) && !task.isBackgrounded; return isLocalAgentTask(task) && !task.isBackgrounded
}); })
for (const taskId of foregroundAgentTaskIds) { 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 * already registered the task (avoiding duplicate task_started SDK events
* and leaked cleanup callbacks). * 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)) { if (!shellCommand.background(taskId)) {
return false; return false
} }
let agentId: AgentId | undefined;
let agentId: AgentId | undefined
setAppState(prev => { setAppState(prev => {
const prevTask = prev.tasks[taskId]; const prevTask = prev.tasks[taskId]
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
return prev; return prev
} }
agentId = prevTask.agentId; agentId = prevTask.agentId
return { return {
...prev, ...prev,
tasks: { tasks: {
...prev.tasks, ...prev.tasks,
[taskId]: { [taskId]: { ...prevTask, isBackgrounded: true },
...prevTask, },
isBackgrounded: true }
} })
}
}; const cancelStallWatchdog = startStallWatchdog(
}); taskId,
const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId); description,
undefined,
toolUseId,
agentId,
)
// Set up result handler (mirrors backgroundTask's handler) // Set up result handler (mirrors backgroundTask's handler)
void shellCommand.result.then(async result => { void shellCommand.result.then(async result => {
cancelStallWatchdog(); cancelStallWatchdog()
await flushAndCleanup(shellCommand); await flushAndCleanup(shellCommand)
let wasKilled = false; let wasKilled = false
let cleanupFn: (() => void) | undefined; let cleanupFn: (() => void) | undefined
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => { updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
if (t.status === 'killed') { if (t.status === 'killed') {
wasKilled = true; wasKilled = true
return t; return t
} }
cleanupFn = t.unregisterCleanup; cleanupFn = t.unregisterCleanup
return { return {
...t, ...t,
status: result.code === 0 ? 'completed' : 'failed', status: result.code === 0 ? 'completed' : 'failed',
result: { result: { code: result.code, interrupted: result.interrupted },
code: result.code,
interrupted: result.interrupted
},
shellCommand: null, shellCommand: null,
unregisterCleanup: undefined, unregisterCleanup: undefined,
endTime: Date.now() endTime: Date.now(),
}; }
}); })
cleanupFn?.();
const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed'; cleanupFn?.()
enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId);
void evictTaskOutput(taskId); const finalStatus = wasKilled
}); ? 'killed'
return true; : 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 * Used when backgrounding raced with completion — the tool result already
* carries the full output, so the <task_notification> would be redundant. * carries the full output, so the <task_notification> would be redundant.
*/ */
export function markTaskNotified(taskId: string, setAppState: SetAppState): void { export function markTaskNotified(
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => t.notified ? t : { taskId: string,
...t, setAppState: SetAppState,
notified: true ): void {
}); updateTaskState(taskId, setAppState, t =>
t.notified ? t : { ...t, notified: true },
)
} }
/** /**
* Unregister a foreground task when the command completes without being backgrounded. * Unregister a foreground task when the command completes without being backgrounded.
*/ */
export function unregisterForeground(taskId: string, setAppState: SetAppState): void { export function unregisterForeground(
let cleanupFn: (() => void) | undefined; taskId: string,
setAppState: SetAppState,
): void {
let cleanupFn: (() => void) | undefined
setAppState(prev => { setAppState(prev => {
const task = prev.tasks[taskId]; const task = prev.tasks[taskId]
// Only remove if it's a foreground task (not backgrounded) // Only remove if it's a foreground task (not backgrounded)
if (!isLocalShellTask(task) || task.isBackgrounded) { if (!isLocalShellTask(task) || task.isBackgrounded) {
return prev; return prev
} }
// Capture cleanup function to call outside of updater // Capture cleanup function to call outside of updater
cleanupFn = task.unregisterCleanup; cleanupFn = task.unregisterCleanup
const {
[taskId]: removed, const { [taskId]: removed, ...rest } = prev.tasks
...rest return { ...prev, tasks: rest }
} = prev.tasks; })
return {
...prev,
tasks: rest
};
});
// Call cleanup outside of the state updater (avoid side effects in updater) // Call cleanup outside of the state updater (avoid side effects in updater)
cleanupFn?.(); cleanupFn?.()
} }
async function flushAndCleanup(shellCommand: ShellCommand): Promise<void> { async function flushAndCleanup(shellCommand: ShellCommand): Promise<void> {
try { try {
await shellCommand.taskOutput.flush(); await shellCommand.taskOutput.flush()
shellCommand.cleanup(); shellCommand.cleanup()
} catch (error) { } catch (error) {
logError(error); logError(error)
} }
} }

File diff suppressed because it is too large Load Diff