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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,26 @@
import { c as _c } from "react/compiler-runtime";
import React, { createContext, useContext } from 'react';
import type { FpsMetrics } from '../utils/fpsTracker.js';
type FpsMetricsGetter = () => FpsMetrics | undefined;
const FpsMetricsContext = createContext<FpsMetricsGetter | undefined>(undefined);
import React, { createContext, useContext } from 'react'
import type { FpsMetrics } from '../utils/fpsTracker.js'
type FpsMetricsGetter = () => FpsMetrics | undefined
const FpsMetricsContext = createContext<FpsMetricsGetter | undefined>(undefined)
type Props = {
getFpsMetrics: FpsMetricsGetter;
children: React.ReactNode;
};
export function FpsMetricsProvider(t0) {
const $ = _c(3);
const {
getFpsMetrics,
children
} = t0;
let t1;
if ($[0] !== children || $[1] !== getFpsMetrics) {
t1 = <FpsMetricsContext.Provider value={getFpsMetrics}>{children}</FpsMetricsContext.Provider>;
$[0] = children;
$[1] = getFpsMetrics;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
getFpsMetrics: FpsMetricsGetter
children: React.ReactNode
}
export function useFpsMetrics() {
return useContext(FpsMetricsContext);
export function FpsMetricsProvider({
getFpsMetrics,
children,
}: Props): React.ReactNode {
return (
<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 { Mailbox } from '../utils/mailbox.js';
const MailboxContext = createContext<Mailbox | undefined>(undefined);
import React, { createContext, useContext, useMemo } from 'react'
import { Mailbox } from '../utils/mailbox.js'
const MailboxContext = createContext<Mailbox | undefined>(undefined)
type Props = {
children: React.ReactNode;
};
export function MailboxProvider(t0) {
const $ = _c(3);
const {
children
} = t0;
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = new Mailbox();
$[0] = t1;
} else {
t1 = $[0];
}
const mailbox = t1;
let t2;
if ($[1] !== children) {
t2 = <MailboxContext.Provider value={mailbox}>{children}</MailboxContext.Provider>;
$[1] = children;
$[2] = t2;
} else {
t2 = $[2];
}
return t2;
children: React.ReactNode
}
export function useMailbox() {
const mailbox = useContext(MailboxContext);
export function MailboxProvider({ children }: Props): React.ReactNode {
const mailbox = useMemo(() => new Mailbox(), [])
return (
<MailboxContext.Provider value={mailbox}>
{children}
</MailboxContext.Provider>
)
}
export function useMailbox(): Mailbox {
const mailbox = useContext(MailboxContext)
if (!mailbox) {
throw new Error("useMailbox must be used within a MailboxProvider");
throw new Error('useMailbox must be used within a MailboxProvider')
}
return mailbox;
return mailbox
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,39 @@
import { c as _c } from "react/compiler-runtime";
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../dom.js';
import type { ClickEvent } from '../events/click-event.js';
import type { FocusEvent } from '../events/focus-event.js';
import type { KeyboardEvent } from '../events/keyboard-event.js';
import type { Styles } from '../styles.js';
import Box from './Box.js';
import React, {
type Ref,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../dom.js'
import type { ClickEvent } from '../events/click-event.js'
import type { FocusEvent } from '../events/focus-event.js'
import type { KeyboardEvent } from '../events/keyboard-event.js'
import type { Styles } from '../styles.js'
import Box from './Box.js'
type ButtonState = {
focused: boolean;
hovered: boolean;
active: boolean;
};
focused: boolean
hovered: boolean
active: boolean
}
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>;
ref?: Ref<DOMElement>
/**
* Called when the button is activated via Enter, Space, or click.
*/
onAction: () => void;
onAction: () => void
/**
* Tab order index. Defaults to 0 (in tab order).
* Set to -1 for programmatically focusable only.
*/
tabIndex?: number;
tabIndex?: number
/**
* Focus this button when it mounts.
*/
autoFocus?: boolean;
autoFocus?: boolean
/**
* Render prop receiving the interactive state. Use this to
* style children based on focus/hover/active — Button itself
@@ -34,158 +41,82 @@ export type Props = Except<Styles, 'textWrap'> & {
*
* If not provided, children render as-is (no state-dependent styling).
*/
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode;
};
function Button(t0) {
const $ = _c(30);
let autoFocus;
let children;
let onAction;
let ref;
let style;
let t1;
if ($[0] !== t0) {
({
onAction,
tabIndex: t1,
autoFocus,
children,
ref,
...style
} = t0);
$[0] = t0;
$[1] = autoFocus;
$[2] = children;
$[3] = onAction;
$[4] = ref;
$[5] = style;
$[6] = t1;
} else {
autoFocus = $[1];
children = $[2];
onAction = $[3];
ref = $[4];
style = $[5];
t1 = $[6];
}
const tabIndex = t1 === undefined ? 0 : t1;
const [isFocused, setIsFocused] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isActive, setIsActive] = useState(false);
const activeTimer = useRef(null);
let t2;
let t3;
if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
t2 = () => () => {
if (activeTimer.current) {
clearTimeout(activeTimer.current);
}
};
t3 = [];
$[7] = t2;
$[8] = t3;
} else {
t2 = $[7];
t3 = $[8];
}
useEffect(t2, t3);
let t4;
if ($[9] !== onAction) {
t4 = e => {
if (e.key === "return" || e.key === " ") {
e.preventDefault();
setIsActive(true);
onAction();
if (activeTimer.current) {
clearTimeout(activeTimer.current);
}
activeTimer.current = setTimeout(_temp, 100, setIsActive);
}
};
$[9] = onAction;
$[10] = t4;
} else {
t4 = $[10];
}
const handleKeyDown = t4;
let t5;
if ($[11] !== onAction) {
t5 = _e => {
onAction();
};
$[11] = onAction;
$[12] = t5;
} else {
t5 = $[12];
}
const handleClick = t5;
let t6;
if ($[13] === Symbol.for("react.memo_cache_sentinel")) {
t6 = _e_0 => setIsFocused(true);
$[13] = t6;
} else {
t6 = $[13];
}
const handleFocus = t6;
let t7;
if ($[14] === Symbol.for("react.memo_cache_sentinel")) {
t7 = _e_1 => setIsFocused(false);
$[14] = t7;
} else {
t7 = $[14];
}
const handleBlur = t7;
let t8;
if ($[15] === Symbol.for("react.memo_cache_sentinel")) {
t8 = () => setIsHovered(true);
$[15] = t8;
} else {
t8 = $[15];
}
const handleMouseEnter = t8;
let t9;
if ($[16] === Symbol.for("react.memo_cache_sentinel")) {
t9 = () => setIsHovered(false);
$[16] = t9;
} else {
t9 = $[16];
}
const handleMouseLeave = t9;
let t10;
if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) {
const state = {
focused: isFocused,
hovered: isHovered,
active: isActive
};
t10 = typeof children === "function" ? children(state) : children;
$[17] = children;
$[18] = isActive;
$[19] = isFocused;
$[20] = isHovered;
$[21] = t10;
} else {
t10 = $[21];
}
const content = t10;
let t11;
if ($[22] !== autoFocus || $[23] !== content || $[24] !== handleClick || $[25] !== handleKeyDown || $[26] !== ref || $[27] !== style || $[28] !== tabIndex) {
t11 = <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;
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
}
function _temp(setter) {
return setter(false);
function Button({
onAction,
tabIndex = 0,
autoFocus,
children,
ref,
...style
}: Props): React.ReactNode {
const [isFocused, setIsFocused] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
const activeTimer = useRef<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 { FRAME_INTERVAL_MS } from '../constants.js';
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
import React, { createContext, useEffect, useState } from 'react'
import { FRAME_INTERVAL_MS } from '../constants.js'
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
export type Clock = {
subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
now: () => number;
setTickInterval: (ms: number) => void;
};
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
now: () => number
setTickInterval: (ms: number) => void
}
export function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>();
let interval: ReturnType<typeof setInterval> | null = null;
let currentTickIntervalMs = tickIntervalMs;
let startTime = 0;
const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | null = null
let currentTickIntervalMs = tickIntervalMs
let startTime = 0
// Snapshot of the current tick's time, ensuring all subscribers in the same
// tick see the same value (keeps animations synchronized)
let tickTime = 0;
let tickTime = 0
function tick(): void {
tickTime = Date.now() - startTime;
tickTime = Date.now() - startTime
for (const onChange of subscribers.keys()) {
onChange();
onChange()
}
}
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean);
const anyKeepAlive = [...subscribers.values()].some(Boolean)
if (anyKeepAlive) {
if (interval) {
clearInterval(interval);
interval = null;
clearInterval(interval)
interval = null
}
if (startTime === 0) {
startTime = Date.now();
startTime = Date.now()
}
interval = setInterval(tick, currentTickIntervalMs);
interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) {
clearInterval(interval);
interval = null;
clearInterval(interval)
interval = null
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive);
updateInterval();
subscribers.set(onChange, keepAlive)
updateInterval()
return () => {
subscribers.delete(onChange);
updateInterval();
};
subscribers.delete(onChange)
updateInterval()
}
},
now() {
if (startTime === 0) {
startTime = Date.now();
startTime = Date.now()
}
// When the clock interval is running, return the synchronized tickTime
// so all subscribers in the same tick see the same value.
// When paused (no keepAlive subscribers), return real-time to avoid
// returning a stale tickTime from the last tick before the pause.
if (interval && tickTime) {
return tickTime;
return tickTime
}
return Date.now() - startTime;
return Date.now() - startTime
},
setTickInterval(ms) {
if (ms === currentTickIntervalMs) return;
currentTickIntervalMs = ms;
updateInterval();
}
};
if (ms === currentTickIntervalMs) return
currentTickIntervalMs = ms
updateInterval()
},
}
}
export const ClockContext = createContext<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.
// The clock value is stable (created once via useState), so the provider
// never causes consumer re-renders on its own.
export function ClockProvider(t0) {
const $ = _c(7);
const {
children
} = t0;
const [clock] = useState(_temp);
const focused = useTerminalFocus();
let t1;
let t2;
if ($[0] !== clock || $[1] !== focused) {
t1 = () => {
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
};
t2 = [clock, focused];
$[0] = clock;
$[1] = focused;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
let t3;
if ($[4] !== children || $[5] !== clock) {
t3 = <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);
export function ClockProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
const focused = useTerminalFocus()
useEffect(() => {
clock.setTickInterval(
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
)
}, [clock, focused])
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
}

View File

@@ -1,55 +1,57 @@
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
import { readFileSync } from 'fs';
import React from 'react';
import StackUtils from 'stack-utils';
import Box from './Box.js';
import Text from './Text.js';
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
import { readFileSync } from 'fs'
import React from 'react'
import StackUtils from 'stack-utils'
import Box from './Box.js'
import Text from './Text.js'
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
// Error's source file is reported as file:///home/user/file.js
// This function removes the file://[cwd] part
const cleanupPath = (path: string | undefined): string | undefined => {
return path?.replace(`file://${process.cwd()}/`, '');
};
let stackUtils: StackUtils | undefined;
return path?.replace(`file://${process.cwd()}/`, '')
}
let stackUtils: StackUtils | undefined
function getStackUtils(): StackUtils {
return stackUtils ??= new StackUtils({
return (stackUtils ??= new StackUtils({
cwd: process.cwd(),
internals: StackUtils.nodeInternals()
});
internals: StackUtils.nodeInternals(),
}))
}
/* eslint-enable custom-rules/no-process-cwd */
type Props = {
readonly error: Error;
};
export default function ErrorOverview({
error
}: Props) {
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined;
const filePath = cleanupPath(origin?.file);
let excerpt: CodeExcerpt[] | undefined;
let lineWidth = 0;
readonly error: Error
}
export default function ErrorOverview({ error }: Props) {
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
const filePath = cleanupPath(origin?.file)
let excerpt: CodeExcerpt[] | undefined
let lineWidth = 0
if (filePath && origin?.line) {
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
const sourceCode = readFileSync(filePath, 'utf8');
excerpt = codeExcerpt(sourceCode, origin.line);
const sourceCode = readFileSync(filePath, 'utf8')
excerpt = codeExcerpt(sourceCode, origin.line)
if (excerpt) {
for (const {
line
} of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length);
for (const { line } of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length)
}
}
} catch {
// file not readable — skip source context
}
}
return <Box flexDirection="column" padding={1}>
return (
<Box flexDirection="column" padding={1}>
<Box>
<Text backgroundColor="ansi:red" color="ansi:white">
{' '}
@@ -59,41 +61,62 @@ export default function ErrorOverview({
<Text> {error.message}</Text>
</Box>
{origin && filePath && <Box marginTop={1}>
{origin && filePath && (
<Box marginTop={1}>
<Text dim>
{filePath}:{origin.line}:{origin.column}
</Text>
</Box>}
</Box>
)}
{origin && excerpt && <Box marginTop={1} flexDirection="column">
{excerpt.map(({
line: line_0,
value
}) => <Box key={line_0}>
{origin && excerpt && (
<Box marginTop={1} flexDirection="column">
{excerpt.map(({ line, value }) => (
<Box key={line}>
<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}>
{String(line_0).padStart(lineWidth, ' ')}:
<Text
dim={line !== origin.line}
backgroundColor={
line === origin.line ? 'ansi:red' : undefined
}
color={line === origin.line ? 'ansi:white' : undefined}
>
{String(line).padStart(lineWidth, ' ')}:
</Text>
</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}
</Text>
</Box>)}
</Box>}
</Box>
))}
</Box>
)}
{error.stack && <Box marginTop={1} flexDirection="column">
{error.stack.split('\n').slice(1).map(line_1 => {
const parsedLine = getStackUtils().parseLine(line_1);
{error.stack && (
<Box marginTop={1} flexDirection="column">
{error.stack
.split('\n')
.slice(1)
.map(line => {
const parsedLine = getStackUtils().parseLine(line)
// If the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) {
return <Box key={line_1}>
// If the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) {
return (
<Box key={line}>
<Text dim>- </Text>
<Text bold>{line_1}</Text>
</Box>;
}
return <Box key={line_1}>
<Text bold>{line}</Text>
</Box>
)
}
return (
<Box key={line}>
<Text dim>- </Text>
<Text bold>{parsedLine.function}</Text>
<Text dim>
@@ -101,8 +124,11 @@ export default function ErrorOverview({
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
</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 React from 'react';
import { supportsHyperlinks } from '../supports-hyperlinks.js';
import Text from './Text.js';
import type { ReactNode } from 'react'
import React from 'react'
import { supportsHyperlinks } from '../supports-hyperlinks.js'
import Text from './Text.js'
export type Props = {
readonly children?: ReactNode;
readonly url: string;
readonly fallback?: ReactNode;
};
export default function Link(t0) {
const $ = _c(5);
const {
children,
url,
fallback
} = t0;
const content = children ?? url;
if (supportsHyperlinks()) {
let t1;
if ($[0] !== content || $[1] !== url) {
t1 = <Text><ink-link href={url}>{content}</ink-link></Text>;
$[0] = content;
$[1] = url;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}
const t1 = fallback ?? content;
let t2;
if ($[3] !== t1) {
t2 = <Text>{t1}</Text>;
$[3] = t1;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
readonly children?: ReactNode
readonly url: string
readonly fallback?: ReactNode
}
export default function Link({
children,
url,
fallback,
}: Props): React.ReactNode {
// Use children if provided, otherwise display the URL
const content = children ?? url
if (supportsHyperlinks()) {
// Wrap in Text to ensure we're in a text context
// (ink-link is a text element like ink-text)
return (
<Text>
<ink-link href={url}>{content}</ink-link>
</Text>
)
}
return <Text>{fallback ?? content}</Text>
}

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 = {
/**
* Number of newlines to insert.
*
* @default 1
*/
readonly count?: number;
};
readonly count?: number
}
/**
* Adds one or more newline (\n) characters. Must be used within <Text> components.
*/
export default function Newline(t0) {
const $ = _c(4);
const {
count: t1
} = t0;
const count = t1 === undefined ? 1 : t1;
let t2;
if ($[0] !== count) {
t2 = "\n".repeat(count);
$[0] = count;
$[1] = t2;
} else {
t2 = $[1];
}
let t3;
if ($[2] !== t2) {
t3 = <ink-text>{t2}</ink-text>;
$[2] = t2;
$[3] = t3;
} else {
t3 = $[3];
}
return t3;
export default function Newline({ count = 1 }: Props) {
return <ink-text>{'\n'.repeat(count)}</ink-text>
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,14 +9,19 @@
* 4. Can be idle (waiting for work) or active (processing)
*/
import { isTerminalTaskStatus, type SetAppState, type Task, type TaskStateBase } from '../../Task.js';
import type { Message } from '../../types/message.js';
import { logForDebugging } from '../../utils/debug.js';
import { createUserMessage } from '../../utils/messages.js';
import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js';
import { updateTaskState } from '../../utils/task/framework.js';
import type { InProcessTeammateTaskState } from './types.js';
import { appendCappedMessage, isInProcessTeammateTask } from './types.js';
import {
isTerminalTaskStatus,
type SetAppState,
type Task,
type TaskStateBase,
} from '../../Task.js'
import type { Message } from '../../types/message.js'
import { logForDebugging } from '../../utils/debug.js'
import { createUserMessage } from '../../utils/messages.js'
import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js'
import { updateTaskState } from '../../utils/task/framework.js'
import type { InProcessTeammateTaskState } from './types.js'
import { appendCappedMessage, isInProcessTeammateTask } from './types.js'
/**
* InProcessTeammateTask - Handles in-process teammate execution.
@@ -25,39 +30,48 @@ export const InProcessTeammateTask: Task = {
name: 'InProcessTeammateTask',
type: 'in_process_teammate',
async kill(taskId, setAppState) {
killInProcessTeammate(taskId, setAppState);
}
};
killInProcessTeammate(taskId, setAppState)
},
}
/**
* Request shutdown for a teammate.
*/
export function requestTeammateShutdown(taskId: string, setAppState: SetAppState): void {
export function requestTeammateShutdown(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
if (task.status !== 'running' || task.shutdownRequested) {
return task;
return task
}
return {
...task,
shutdownRequested: true
};
});
shutdownRequested: true,
}
})
}
/**
* Append a message to a teammate's conversation history.
* Used for zoomed view to show the teammate's conversation.
*/
export function appendTeammateMessage(taskId: string, message: Message, setAppState: SetAppState): void {
export function appendTeammateMessage(
taskId: string,
message: Message,
setAppState: SetAppState,
): void {
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') {
return task;
return task
}
return {
...task,
messages: appendCappedMessage(task.messages, message)
};
});
messages: appendCappedMessage(task.messages, message),
}
})
}
/**
@@ -65,22 +79,30 @@ export function appendTeammateMessage(taskId: string, message: Message, setAppSt
* Used when viewing a teammate's transcript to send typed messages to them.
* Also adds the message to task.messages so it appears immediately in the transcript.
*/
export function injectUserMessageToTeammate(taskId: string, message: string, setAppState: SetAppState): void {
export function injectUserMessageToTeammate(
taskId: string,
message: string,
setAppState: SetAppState,
): void {
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
// Allow message injection when teammate is running or idle (waiting for input)
// Only reject if teammate is in a terminal state
if (isTerminalTaskStatus(task.status)) {
logForDebugging(`Dropping message for teammate task ${taskId}: task status is "${task.status}"`);
return task;
logForDebugging(
`Dropping message for teammate task ${taskId}: task status is "${task.status}"`,
)
return task
}
return {
...task,
pendingUserMessages: [...task.pendingUserMessages, message],
messages: appendCappedMessage(task.messages, createUserMessage({
content: message
}))
};
});
messages: appendCappedMessage(
task.messages,
createUserMessage({ content: message }),
),
}
})
}
/**
@@ -89,29 +111,34 @@ export function injectUserMessageToTeammate(taskId: string, message: string, set
* with the same agentId exist.
* Returns undefined if not found.
*/
export function findTeammateTaskByAgentId(agentId: string, tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState | undefined {
let fallback: InProcessTeammateTaskState | undefined;
export function findTeammateTaskByAgentId(
agentId: string,
tasks: Record<string, TaskStateBase>,
): InProcessTeammateTaskState | undefined {
let fallback: InProcessTeammateTaskState | undefined
for (const task of Object.values(tasks)) {
if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) {
// Prefer running tasks in case old killed tasks still exist in AppState
// alongside new running ones with the same agentId
if (task.status === 'running') {
return task;
return task
}
// Keep first match as fallback in case no running task exists
if (!fallback) {
fallback = task;
fallback = task
}
}
}
return fallback;
return fallback
}
/**
* Get all in-process teammate tasks from AppState.
*/
export function getAllInProcessTeammateTasks(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] {
return Object.values(tasks).filter(isInProcessTeammateTask);
export function getAllInProcessTeammateTasks(
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
* array, so all three must agree on sort order.
*/
export function getRunningTeammatesSorted(tasks: Record<string, TaskStateBase>): InProcessTeammateTaskState[] {
return getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running').sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName));
export function getRunningTeammatesSorted(
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 { stat } from 'fs/promises';
import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG } from '../../constants/xml.js';
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js';
import type { AppState } from '../../state/AppState.js';
import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js';
import { createTaskStateBase } from '../../Task.js';
import type { AgentId } from '../../types/ids.js';
import { registerCleanup } from '../../utils/cleanupRegistry.js';
import { tailFile } from '../../utils/fsOperations.js';
import { logError } from '../../utils/log.js';
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js';
import type { ShellCommand } from '../../utils/ShellCommand.js';
import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js';
import { registerTask, updateTaskState } from '../../utils/task/framework.js';
import { escapeXml } from '../../utils/xml.js';
import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js';
import { isMainSessionTask } from '../LocalMainSessionTask.js';
import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js';
import { killTask } from './killShellTasks.js';
import { feature } from 'bun:bundle'
import { stat } from 'fs/promises'
import {
OUTPUT_FILE_TAG,
STATUS_TAG,
SUMMARY_TAG,
TASK_ID_TAG,
TASK_NOTIFICATION_TAG,
TOOL_USE_ID_TAG,
} from '../../constants/xml.js'
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'
import type { AppState } from '../../state/AppState.js'
import type {
LocalShellSpawnInput,
SetAppState,
Task,
TaskContext,
TaskHandle,
} from '../../Task.js'
import { createTaskStateBase } from '../../Task.js'
import type { AgentId } from '../../types/ids.js'
import { registerCleanup } from '../../utils/cleanupRegistry.js'
import { tailFile } from '../../utils/fsOperations.js'
import { logError } from '../../utils/log.js'
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'
import type { ShellCommand } from '../../utils/ShellCommand.js'
import {
evictTaskOutput,
getTaskOutputPath,
} from '../../utils/task/diskOutput.js'
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
import { escapeXml } from '../../utils/xml.js'
import {
backgroundAgentTask,
isLocalAgentTask,
} from '../LocalAgentTask/LocalAgentTask.js'
import { isMainSessionTask } from '../LocalMainSessionTask.js'
import {
type BashTaskKind,
isLocalShellTask,
type LocalShellTaskState,
} from './guards.js'
import { killTask } from './killShellTasks.js'
/** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */
export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command ';
const STALL_CHECK_INTERVAL_MS = 5_000;
const STALL_THRESHOLD_MS = 45_000;
const STALL_TAIL_BYTES = 1024;
export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '
const STALL_CHECK_INTERVAL_MS = 5_000
const STALL_THRESHOLD_MS = 45_000
const STALL_TAIL_BYTES = 1024
// Last-line patterns that suggest a command is blocked waiting for keyboard
// input. Used to gate the stall notification — we stay silent on commands that
// are merely slow (git log -S, long builds) and only notify when the tail
// looks like an interactive prompt the model can act on. See CC-1175.
const PROMPT_PATTERNS = [/\(y\/n\)/i,
// (Y/n), (y/N)
/\[y\/n\]/i,
// [Y/n], [y/N]
/\(yes\/no\)/i, /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i,
// directed questions
/Press (any key|Enter)/i, /Continue\?/i, /Overwrite\?/i];
const PROMPT_PATTERNS = [
/\(y\/n\)/i, // (Y/n), (y/N)
/\[y\/n\]/i, // [Y/n], [y/N]
/\(yes\/no\)/i,
/\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, // directed questions
/Press (any key|Enter)/i,
/Continue\?/i,
/Overwrite\?/i,
]
export function looksLikePrompt(tail: string): boolean {
const lastLine = tail.trimEnd().split('\n').pop() ?? '';
return PROMPT_PATTERNS.some(p => p.test(lastLine));
const lastLine = tail.trimEnd().split('\n').pop() ?? ''
return PROMPT_PATTERNS.some(p => p.test(lastLine))
}
// Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot
// notification if output stops growing and the tail looks like a prompt.
function startStallWatchdog(taskId: string, description: string, kind: BashTaskKind | undefined, toolUseId?: string, agentId?: AgentId): () => void {
if (kind === 'monitor') return () => {};
const outputPath = getTaskOutputPath(taskId);
let lastSize = 0;
let lastGrowth = Date.now();
let cancelled = false;
function startStallWatchdog(
taskId: string,
description: string,
kind: BashTaskKind | undefined,
toolUseId?: string,
agentId?: AgentId,
): () => void {
if (kind === 'monitor') return () => {}
const outputPath = getTaskOutputPath(taskId)
let lastSize = 0
let lastGrowth = Date.now()
let cancelled = false
const timer = setInterval(() => {
void stat(outputPath).then(s => {
if (s.size > lastSize) {
lastSize = s.size;
lastGrowth = Date.now();
return;
}
if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return;
void tailFile(outputPath, STALL_TAIL_BYTES).then(({
content
}) => {
if (cancelled) return;
if (!looksLikePrompt(content)) {
// Not a prompt — keep watching. Reset so the next check is
// 45s out instead of re-reading the tail on every tick.
lastGrowth = Date.now();
return;
void stat(outputPath).then(
s => {
if (s.size > lastSize) {
lastSize = s.size
lastGrowth = Date.now()
return
}
// Latch before the async-boundary-visible side effects so an
// overlapping tick's callback sees cancelled=true and bails.
cancelled = true;
clearInterval(timer);
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${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}>
if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return
void tailFile(outputPath, STALL_TAIL_BYTES).then(
({ content }) => {
if (cancelled) return
if (!looksLikePrompt(content)) {
// Not a prompt — keep watching. Reset so the next check is
// 45s out instead of re-reading the tail on every tick.
lastGrowth = Date.now()
return
}
// Latch before the async-boundary-visible side effects so an
// overlapping tick's callback sees cancelled=true and bails.
cancelled = true
clearInterval(timer)
const toolUseIdLine = toolUseId
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${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}
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
@@ -85,47 +121,60 @@ function startStallWatchdog(taskId: string, description: string, kind: BashTaskK
Last output:
${content.trimEnd()}
The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`;
enqueuePendingNotification({
value: message,
mode: 'task-notification',
priority: 'next',
agentId
});
}, () => {});
}, () => {} // File may not exist yet
);
}, STALL_CHECK_INTERVAL_MS);
timer.unref();
The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`
enqueuePendingNotification({
value: message,
mode: 'task-notification',
priority: 'next',
agentId,
})
},
() => {},
)
},
() => {}, // File may not exist yet
)
}, STALL_CHECK_INTERVAL_MS)
timer.unref()
return () => {
cancelled = true;
clearInterval(timer);
};
cancelled = true
clearInterval(timer)
}
}
function enqueueShellNotification(taskId: string, description: string, status: 'completed' | 'failed' | 'killed', exitCode: number | undefined, setAppState: SetAppState, toolUseId?: string, kind: BashTaskKind = 'bash', agentId?: AgentId): void {
function enqueueShellNotification(
taskId: string,
description: string,
status: 'completed' | 'failed' | 'killed',
exitCode: number | undefined,
setAppState: SetAppState,
toolUseId?: string,
kind: BashTaskKind = 'bash',
agentId?: AgentId,
): void {
// Atomically check and set notified flag to prevent duplicate notifications.
// If the task was already marked as notified (e.g., by TaskStopTool), skip
// enqueueing to avoid sending redundant messages to the model.
let shouldEnqueue = false;
updateTaskState<LocalShellTaskState>(taskId, setAppState, task => {
let shouldEnqueue = false
updateTaskState(taskId, setAppState, task => {
if (task.notified) {
return task;
return task
}
shouldEnqueue = true;
return {
...task,
notified: true
};
});
shouldEnqueue = true
return { ...task, notified: true }
})
if (!shouldEnqueue) {
return;
return
}
// Abort any active speculation — background task state changed, so speculated
// results may reference stale task output. The prompt suggestion text is
// preserved; only the pre-computed response is discarded.
abortSpeculation(setAppState);
let summary: string;
abortSpeculation(setAppState)
let summary: string
if (feature('MONITOR_TOOL') && kind === 'monitor') {
// Monitor is streaming-only (post-#22764) — the script exiting means
// the stream ended, not "condition met". Distinct from the bash prefix
@@ -133,73 +182,71 @@ function enqueueShellNotification(taskId: string, description: string, status: '
// completed" collapse.
switch (status) {
case 'completed':
summary = `Monitor "${description}" stream ended`;
break;
summary = `Monitor "${description}" stream ended`
break
case 'failed':
summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`;
break;
summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`
break
case 'killed':
summary = `Monitor "${description}" stopped`;
break;
summary = `Monitor "${description}" stopped`
break
}
} else {
switch (status) {
case 'completed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`;
break;
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`
break
case 'failed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`;
break;
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`
break
case 'killed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`;
break;
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`
break
}
}
const outputPath = getTaskOutputPath(taskId);
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${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}>
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
<${STATUS_TAG}>${status}</${STATUS_TAG}>
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
</${TASK_NOTIFICATION_TAG}>`;
</${TASK_NOTIFICATION_TAG}>`
enqueuePendingNotification({
value: message,
mode: 'task-notification',
priority: feature('MONITOR_TOOL') ? 'next' : 'later',
agentId
});
agentId,
})
}
export const LocalShellTask: Task = {
name: 'LocalShellTask',
type: 'local_bash',
async kill(taskId, setAppState) {
killTask(taskId, setAppState);
}
};
export async function spawnShellTask(input: LocalShellSpawnInput & {
shellCommand: ShellCommand;
}, context: TaskContext): Promise<TaskHandle> {
const {
command,
description,
shellCommand,
toolUseId,
agentId,
kind
} = input;
const {
setAppState
} = context;
killTask(taskId, setAppState)
},
}
export async function spawnShellTask(
input: LocalShellSpawnInput & { shellCommand: ShellCommand },
context: TaskContext,
): Promise<TaskHandle> {
const { command, description, shellCommand, toolUseId, agentId, kind } = input
const { setAppState } = context
// TaskOutput owns the data — use its taskId so disk writes are consistent
const {
taskOutput
} = shellCommand;
const taskId = taskOutput.taskId;
const { taskOutput } = shellCommand
const taskId = taskOutput.taskId
const unregisterCleanup = registerCleanup(async () => {
killTask(taskId, setAppState);
});
killTask(taskId, setAppState)
})
const taskState: LocalShellTaskState = {
...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
type: 'local_bash',
@@ -211,44 +258,64 @@ export async function spawnShellTask(input: LocalShellSpawnInput & {
lastReportedTotalLines: 0,
isBackgrounded: true,
agentId,
kind
};
registerTask(taskState, setAppState);
kind,
}
registerTask(taskState, setAppState)
// Data flows through TaskOutput automatically — no stream listeners needed.
// Just transition to backgrounded state so the process keeps running.
shellCommand.background(taskId);
const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId);
shellCommand.background(taskId)
const cancelStallWatchdog = startStallWatchdog(
taskId,
description,
kind,
toolUseId,
agentId,
)
void shellCommand.result.then(async result => {
cancelStallWatchdog();
await flushAndCleanup(shellCommand);
let wasKilled = false;
cancelStallWatchdog()
await flushAndCleanup(shellCommand)
let wasKilled = false
updateTaskState<LocalShellTaskState>(taskId, setAppState, task => {
if (task.status === 'killed') {
wasKilled = true;
return task;
wasKilled = true
return task
}
return {
...task,
status: result.code === 0 ? 'completed' : 'failed',
result: {
code: result.code,
interrupted: result.interrupted
},
result: { code: result.code, interrupted: result.interrupted },
shellCommand: null,
unregisterCleanup: undefined,
endTime: Date.now()
};
});
enqueueShellNotification(taskId, description, wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', result.code, setAppState, toolUseId, kind, agentId);
void evictTaskOutput(taskId);
});
endTime: Date.now(),
}
})
enqueueShellNotification(
taskId,
description,
wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed',
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
void evictTaskOutput(taskId)
})
return {
taskId,
cleanup: () => {
unregisterCleanup();
}
};
unregisterCleanup()
},
}
}
/**
@@ -256,19 +323,19 @@ export async function spawnShellTask(input: LocalShellSpawnInput & {
* Called when a bash command has been running long enough to show the BackgroundHint.
* @returns taskId for the registered task
*/
export function registerForeground(input: LocalShellSpawnInput & {
shellCommand: ShellCommand;
}, setAppState: SetAppState, toolUseId?: string): string {
const {
command,
description,
shellCommand,
agentId
} = input;
const taskId = shellCommand.taskOutput.taskId;
export function registerForeground(
input: LocalShellSpawnInput & { shellCommand: ShellCommand },
setAppState: SetAppState,
toolUseId?: string,
): string {
const { command, description, shellCommand, agentId } = input
const taskId = shellCommand.taskOutput.taskId
const unregisterCleanup = registerCleanup(async () => {
killTask(taskId, setAppState);
});
killTask(taskId, setAppState)
})
const taskState: LocalShellTaskState = {
...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
type: 'local_bash',
@@ -278,93 +345,119 @@ export function registerForeground(input: LocalShellSpawnInput & {
shellCommand,
unregisterCleanup,
lastReportedTotalLines: 0,
isBackgrounded: false,
// Not yet backgrounded - running in foreground
agentId
};
registerTask(taskState, setAppState);
return taskId;
isBackgrounded: false, // Not yet backgrounded - running in foreground
agentId,
}
registerTask(taskState, setAppState)
return taskId
}
/**
* Background a specific foreground task.
* @returns true if backgrounded successfully, false otherwise
*/
function backgroundTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean {
function backgroundTask(
taskId: string,
getAppState: () => AppState,
setAppState: SetAppState,
): boolean {
// Step 1: Get the task and shell command from current state
const state = getAppState();
const task = state.tasks[taskId];
const state = getAppState()
const task = state.tasks[taskId]
if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) {
return false;
return false
}
const shellCommand = task.shellCommand;
const description = task.description;
const {
toolUseId,
kind,
agentId
} = task;
const shellCommand = task.shellCommand
const description = task.description
const { toolUseId, kind, agentId } = task
// Transition to backgrounded — TaskOutput continues receiving data automatically
if (!shellCommand.background(taskId)) {
return false;
return false
}
setAppState(prev => {
const prevTask = prev.tasks[taskId];
const prevTask = prev.tasks[taskId]
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
return prev;
return prev
}
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: {
...prevTask,
isBackgrounded: true
}
}
};
});
const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId);
[taskId]: { ...prevTask, isBackgrounded: true },
},
}
})
const cancelStallWatchdog = startStallWatchdog(
taskId,
description,
kind,
toolUseId,
agentId,
)
// Set up result handler
void shellCommand.result.then(async result => {
cancelStallWatchdog();
await flushAndCleanup(shellCommand);
let wasKilled = false;
let cleanupFn: (() => void) | undefined;
cancelStallWatchdog()
await flushAndCleanup(shellCommand)
let wasKilled = false
let cleanupFn: (() => void) | undefined
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
if (t.status === 'killed') {
wasKilled = true;
return t;
wasKilled = true
return t
}
// Capture cleanup function to call outside of updater
cleanupFn = t.unregisterCleanup;
cleanupFn = t.unregisterCleanup
return {
...t,
status: result.code === 0 ? 'completed' : 'failed',
result: {
code: result.code,
interrupted: result.interrupted
},
result: { code: result.code, interrupted: result.interrupted },
shellCommand: null,
unregisterCleanup: undefined,
endTime: Date.now()
};
});
endTime: Date.now(),
}
})
// Call cleanup outside of the state updater (avoid side effects in updater)
cleanupFn?.();
cleanupFn?.()
if (wasKilled) {
enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId);
enqueueShellNotification(
taskId,
description,
'killed',
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
} else {
const finalStatus = result.code === 0 ? 'completed' : 'failed';
enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId);
const finalStatus = result.code === 0 ? 'completed' : 'failed'
enqueueShellNotification(
taskId,
description,
finalStatus,
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
}
void evictTaskOutput(taskId);
});
return true;
void evictTaskOutput(taskId)
})
return true
}
/**
@@ -378,34 +471,42 @@ function backgroundTask(taskId: string, getAppState: () => AppState, setAppState
export function hasForegroundTasks(state: AppState): boolean {
return Object.values(state.tasks).some(task => {
if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) {
return true;
return true
}
// Exclude main session tasks - they display in the main view, not as foreground tasks
if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) {
return true;
if (
isLocalAgentTask(task) &&
!task.isBackgrounded &&
!isMainSessionTask(task)
) {
return true
}
return false;
});
return false
})
}
export function backgroundAll(getAppState: () => AppState, setAppState: SetAppState): void {
const state = getAppState();
export function backgroundAll(
getAppState: () => AppState,
setAppState: SetAppState,
): void {
const state = getAppState()
// Background all foreground bash tasks
const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => {
const task = state.tasks[id];
return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand;
});
const task = state.tasks[id]
return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand
})
for (const taskId of foregroundBashTaskIds) {
backgroundTask(taskId, getAppState, setAppState);
backgroundTask(taskId, getAppState, setAppState)
}
// Background all foreground agent tasks
const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => {
const task = state.tasks[id];
return isLocalAgentTask(task) && !task.isBackgrounded;
});
const task = state.tasks[id]
return isLocalAgentTask(task) && !task.isBackgrounded
})
for (const taskId of foregroundAgentTaskIds) {
backgroundAgentTask(taskId, getAppState, setAppState);
backgroundAgentTask(taskId, getAppState, setAppState)
}
}
@@ -417,60 +518,86 @@ export function backgroundAll(getAppState: () => AppState, setAppState: SetAppSt
* already registered the task (avoiding duplicate task_started SDK events
* and leaked cleanup callbacks).
*/
export function backgroundExistingForegroundTask(taskId: string, shellCommand: ShellCommand, description: string, setAppState: SetAppState, toolUseId?: string): boolean {
export function backgroundExistingForegroundTask(
taskId: string,
shellCommand: ShellCommand,
description: string,
setAppState: SetAppState,
toolUseId?: string,
): boolean {
if (!shellCommand.background(taskId)) {
return false;
return false
}
let agentId: AgentId | undefined;
let agentId: AgentId | undefined
setAppState(prev => {
const prevTask = prev.tasks[taskId];
const prevTask = prev.tasks[taskId]
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
return prev;
return prev
}
agentId = prevTask.agentId;
agentId = prevTask.agentId
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: {
...prevTask,
isBackgrounded: true
}
}
};
});
const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId);
[taskId]: { ...prevTask, isBackgrounded: true },
},
}
})
const cancelStallWatchdog = startStallWatchdog(
taskId,
description,
undefined,
toolUseId,
agentId,
)
// Set up result handler (mirrors backgroundTask's handler)
void shellCommand.result.then(async result => {
cancelStallWatchdog();
await flushAndCleanup(shellCommand);
let wasKilled = false;
let cleanupFn: (() => void) | undefined;
cancelStallWatchdog()
await flushAndCleanup(shellCommand)
let wasKilled = false
let cleanupFn: (() => void) | undefined
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
if (t.status === 'killed') {
wasKilled = true;
return t;
wasKilled = true
return t
}
cleanupFn = t.unregisterCleanup;
cleanupFn = t.unregisterCleanup
return {
...t,
status: result.code === 0 ? 'completed' : 'failed',
result: {
code: result.code,
interrupted: result.interrupted
},
result: { code: result.code, interrupted: result.interrupted },
shellCommand: null,
unregisterCleanup: undefined,
endTime: Date.now()
};
});
cleanupFn?.();
const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed';
enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId);
void evictTaskOutput(taskId);
});
return true;
endTime: Date.now(),
}
})
cleanupFn?.()
const finalStatus = wasKilled
? 'killed'
: result.code === 0
? 'completed'
: 'failed'
enqueueShellNotification(
taskId,
description,
finalStatus,
result.code,
setAppState,
toolUseId,
undefined,
agentId,
)
void evictTaskOutput(taskId)
})
return true
}
/**
@@ -478,45 +605,47 @@ export function backgroundExistingForegroundTask(taskId: string, shellCommand: S
* Used when backgrounding raced with completion — the tool result already
* carries the full output, so the <task_notification> would be redundant.
*/
export function markTaskNotified(taskId: string, setAppState: SetAppState): void {
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => t.notified ? t : {
...t,
notified: true
});
export function markTaskNotified(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState(taskId, setAppState, t =>
t.notified ? t : { ...t, notified: true },
)
}
/**
* Unregister a foreground task when the command completes without being backgrounded.
*/
export function unregisterForeground(taskId: string, setAppState: SetAppState): void {
let cleanupFn: (() => void) | undefined;
export function unregisterForeground(
taskId: string,
setAppState: SetAppState,
): void {
let cleanupFn: (() => void) | undefined
setAppState(prev => {
const task = prev.tasks[taskId];
const task = prev.tasks[taskId]
// Only remove if it's a foreground task (not backgrounded)
if (!isLocalShellTask(task) || task.isBackgrounded) {
return prev;
return prev
}
// Capture cleanup function to call outside of updater
cleanupFn = task.unregisterCleanup;
const {
[taskId]: removed,
...rest
} = prev.tasks;
return {
...prev,
tasks: rest
};
});
cleanupFn = task.unregisterCleanup
const { [taskId]: removed, ...rest } = prev.tasks
return { ...prev, tasks: rest }
})
// Call cleanup outside of the state updater (avoid side effects in updater)
cleanupFn?.();
cleanupFn?.()
}
async function flushAndCleanup(shellCommand: ShellCommand): Promise<void> {
try {
await shellCommand.taskOutput.flush();
shellCommand.cleanup();
await shellCommand.taskOutput.flush()
shellCommand.cleanup()
} catch (error) {
logError(error);
logError(error)
}
}

File diff suppressed because it is too large Load Diff