mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
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:
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 4512–4532)
|
||||
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 4545–4635)
|
||||
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 4641–4688)
|
||||
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 4694–4786)
|
||||
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 4801–4870)
|
||||
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 4881–4927)
|
||||
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 4935–4952)
|
||||
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.',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
412
src/ink/Ansi.tsx
412
src/ink/Ansi.tsx
@@ -1,25 +1,31 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import Link from './components/Link.js';
|
||||
import Text from './components/Text.js';
|
||||
import type { Color } from './styles.js';
|
||||
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js';
|
||||
import React from 'react'
|
||||
import Link from './components/Link.js'
|
||||
import Text from './components/Text.js'
|
||||
import type { Color } from './styles.js'
|
||||
import {
|
||||
type NamedColor,
|
||||
Parser,
|
||||
type Color as TermioColor,
|
||||
type TextStyle,
|
||||
} from './termio.js'
|
||||
|
||||
type Props = {
|
||||
children: string;
|
||||
children: string
|
||||
/** When true, force all text to be rendered with dim styling */
|
||||
dimColor?: boolean;
|
||||
};
|
||||
dimColor?: boolean
|
||||
}
|
||||
|
||||
type SpanProps = {
|
||||
color?: Color;
|
||||
backgroundColor?: Color;
|
||||
dim?: boolean;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
inverse?: boolean;
|
||||
hyperlink?: string;
|
||||
};
|
||||
color?: Color
|
||||
backgroundColor?: Color
|
||||
dim?: boolean
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
inverse?: boolean
|
||||
hyperlink?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that parses ANSI escape codes and renders them using Text components.
|
||||
@@ -29,145 +35,156 @@ type SpanProps = {
|
||||
*
|
||||
* Memoized to prevent re-renders when parent changes but children string is the same.
|
||||
*/
|
||||
export const Ansi = React.memo(function Ansi(t0: { children: React.ReactNode; dimColor?: boolean }) {
|
||||
const $ = _c(12);
|
||||
const {
|
||||
children,
|
||||
dimColor
|
||||
} = t0;
|
||||
if (typeof children !== "string") {
|
||||
let t1;
|
||||
if ($[0] !== children || $[1] !== dimColor) {
|
||||
t1 = dimColor ? <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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
1662
src/ink/ink.tsx
1662
src/ink/ink.tsx
File diff suppressed because it is too large
Load Diff
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
9084
src/screens/REPL.tsx
9084
src/screens/REPL.tsx
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user