diff --git a/src/components/FeedbackSurvey/FeedbackSurvey.tsx b/src/components/FeedbackSurvey/FeedbackSurvey.tsx index 8a5ccbfcf..2f9c8e47d 100644 --- a/src/components/FeedbackSurvey/FeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey/FeedbackSurvey.tsx @@ -1,173 +1,167 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { Box, Text } from '../../ink.js'; -import { FeedbackSurveyView, isValidResponseInput } from './FeedbackSurveyView.js'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import { TranscriptSharePrompt } from './TranscriptSharePrompt.js'; -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; -import type { FeedbackSurveyResponse } from './utils.js'; +import React from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { Box, Text } from '../../ink.js' +import { + FeedbackSurveyView, + isValidResponseInput, +} from './FeedbackSurveyView.js' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import { TranscriptSharePrompt } from './TranscriptSharePrompt.js' +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' +import type { FeedbackSurveyResponse } from './utils.js' + type Props = { - state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; - lastResponse: FeedbackSurveyResponse | null; - handleSelect: (selected: FeedbackSurveyResponse) => void; - handleTranscriptSelect?: (selected: TranscriptShareResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; - onRequestFeedback?: () => void; - message?: string; -}; -export function FeedbackSurvey(t0) { - const $ = _c(16); - const { - state, - lastResponse, - handleSelect, - handleTranscriptSelect, - inputValue, - setInputValue, - onRequestFeedback, - message - } = t0; - if (state === "closed") { - return null; + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => void + handleTranscriptSelect?: (selected: TranscriptShareResponse) => void + inputValue: string + setInputValue: (value: string) => void + onRequestFeedback?: () => void + message?: string +} + +export function FeedbackSurvey({ + state, + lastResponse, + handleSelect, + handleTranscriptSelect, + inputValue, + setInputValue, + onRequestFeedback, + message, +}: Props): React.ReactNode { + if (state === 'closed') { + return null } - if (state === "thanks") { - let t1; - if ($[0] !== inputValue || $[1] !== lastResponse || $[2] !== onRequestFeedback || $[3] !== setInputValue) { - t1 = ; - $[0] = inputValue; - $[1] = lastResponse; - $[2] = onRequestFeedback; - $[3] = setInputValue; - $[4] = t1; - } else { - t1 = $[4]; - } - return t1; + + if (state === 'thanks') { + return ( + + ) } - if (state === "submitted") { - let t1; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {"\u2713"} Thanks for sharing your transcript!; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; + + if (state === 'submitted') { + return ( + + + {'\u2713'} Thanks for sharing your transcript! + + + ) } - if (state === "submitting") { - let t1; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sharing transcript{"\u2026"}; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + + if (state === 'submitting') { + return ( + + Sharing transcript{'\u2026'} + + ) } - if (state === "transcript_prompt") { + + if (state === 'transcript_prompt') { if (!handleTranscriptSelect) { - return null; + return null } - if (inputValue && !["1", "2", "3"].includes(inputValue)) { - return null; + // Hide prompt if user is typing non-response characters + if (inputValue && !['1', '2', '3'].includes(inputValue)) { + return null } - let t1; - if ($[7] !== handleTranscriptSelect || $[8] !== inputValue || $[9] !== setInputValue) { - t1 = ; - $[7] = handleTranscriptSelect; - $[8] = inputValue; - $[9] = setInputValue; - $[10] = t1; - } else { - t1 = $[10]; - } - return t1; + return ( + + ) } + + // state === 'open' + // Hide the survey if the user is typing anything other than a survey response. + // This prevents the survey from showing up when the user is typing a message, + // which can result in accidental survey submissions (e.g. "s3cmd"). if (inputValue && !isValidResponseInput(inputValue)) { - return null; + return null } - let t1; - if ($[11] !== handleSelect || $[12] !== inputValue || $[13] !== message || $[14] !== setInputValue) { - t1 = ; - $[11] = handleSelect; - $[12] = inputValue; - $[13] = message; - $[14] = setInputValue; - $[15] = t1; - } else { - t1 = $[15]; - } - return t1; + + return ( + + ) } + type ThanksProps = { - lastResponse: FeedbackSurveyResponse | null; - inputValue: string; - setInputValue: (value: string) => void; - onRequestFeedback?: () => void; -}; -const isFollowUpDigit = (char: string): char is '1' => char === '1'; -function FeedbackSurveyThanks(t0) { - const $ = _c(12); - const { - lastResponse, + lastResponse: FeedbackSurveyResponse | null + inputValue: string + setInputValue: (value: string) => void + onRequestFeedback?: () => void +} + +const isFollowUpDigit = (char: string): char is '1' => char === '1' + +function FeedbackSurveyThanks({ + lastResponse, + inputValue, + setInputValue, + onRequestFeedback, +}: ThanksProps): React.ReactNode { + const showFollowUp = onRequestFeedback && lastResponse === 'good' + + // Listen for "1" keypress to launch /feedback + useDebouncedDigitInput({ inputValue, setInputValue, - onRequestFeedback - } = t0; - const showFollowUp = onRequestFeedback && lastResponse === "good"; - const t1 = Boolean(showFollowUp); - let t2; - if ($[0] !== lastResponse || $[1] !== onRequestFeedback) { - t2 = () => { - logEvent("tengu_feedback_survey_event", { - event_type: "followup_accepted" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - onRequestFeedback?.(); - }; - $[0] = lastResponse; - $[1] = onRequestFeedback; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== inputValue || $[4] !== setInputValue || $[5] !== t1 || $[6] !== t2) { - t3 = { - inputValue, - setInputValue, - isValidDigit: isFollowUpDigit, - enabled: t1, - once: true, - onDigit: t2 - }; - $[3] = inputValue; - $[4] = setInputValue; - $[5] = t1; - $[6] = t2; - $[7] = t3; - } else { - t3 = $[7]; - } - useDebouncedDigitInput(t3); - const feedbackCommand = false ? "/issue" : "/feedback"; - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Thanks for the feedback!; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== lastResponse || $[10] !== showFollowUp) { - t5 = {t4}{showFollowUp ? (Optional) Press [1] to tell us what went well {" \xB7 "}{feedbackCommand} : lastResponse === "bad" ? Use /issue to report model behavior issues. : Use {feedbackCommand} to share detailed feedback anytime.}; - $[9] = lastResponse; - $[10] = showFollowUp; - $[11] = t5; - } else { - t5 = $[11]; - } - return t5; + isValidDigit: isFollowUpDigit, + enabled: Boolean(showFollowUp), + once: true, + onDigit: () => { + logEvent('tengu_feedback_survey_event', { + event_type: + 'followup_accepted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + onRequestFeedback?.() + }, + }) + + const feedbackCommand = + process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback' + + return ( + + Thanks for the feedback! + {showFollowUp ? ( + + (Optional) Press [1] to tell us what + went well {' \u00b7 '} + {feedbackCommand} + + ) : lastResponse === 'bad' ? ( + Use /issue to report model behavior issues. + ) : ( + + Use {feedbackCommand} to share detailed feedback anytime. + + )} + + ) } diff --git a/src/components/FeedbackSurvey/FeedbackSurveyView.tsx b/src/components/FeedbackSurvey/FeedbackSurveyView.tsx index 74a6f6bfa..a8eadf3ba 100644 --- a/src/components/FeedbackSurvey/FeedbackSurveyView.tsx +++ b/src/components/FeedbackSurvey/FeedbackSurveyView.tsx @@ -1,107 +1,72 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; -import type { FeedbackSurveyResponse } from './utils.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' +import type { FeedbackSurveyResponse } from './utils.js' + type Props = { - onSelect: (option: FeedbackSurveyResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; - message?: string; -}; -const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const; -type ResponseInput = (typeof RESPONSE_INPUTS)[number]; + onSelect: (option: FeedbackSurveyResponse) => void + inputValue: string + setInputValue: (value: string) => void + message?: string +} + +const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const +type ResponseInput = (typeof RESPONSE_INPUTS)[number] + const inputToResponse: Record = { '0': 'dismissed', '1': 'bad', '2': 'fine', - '3': 'good' -} as const; -export const isValidResponseInput = (input: string): input is ResponseInput => (RESPONSE_INPUTS as readonly string[]).includes(input); -const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)'; -export function FeedbackSurveyView(t0) { - const $ = _c(15); - const { - onSelect, + '3': 'good', +} as const + +export const isValidResponseInput = (input: string): input is ResponseInput => + (RESPONSE_INPUTS as readonly string[]).includes(input) + +const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)' + +export function FeedbackSurveyView({ + onSelect, + inputValue, + setInputValue, + message = DEFAULT_MESSAGE, +}: Props): React.ReactNode { + useDebouncedDigitInput({ inputValue, setInputValue, - message: t1 - } = t0; - const message = t1 === undefined ? DEFAULT_MESSAGE : t1; - let t2; - if ($[0] !== onSelect) { - t2 = digit => onSelect(inputToResponse[digit]); - $[0] = onSelect; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== inputValue || $[3] !== setInputValue || $[4] !== t2) { - t3 = { - inputValue, - setInputValue, - isValidDigit: isValidResponseInput, - onDigit: t2 - }; - $[2] = inputValue; - $[3] = setInputValue; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - useDebouncedDigitInput(t3); - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== message) { - t5 = {t4}{message}; - $[7] = message; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = 1: Bad; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = 2: Fine; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t8 = 3: Good; - $[11] = t8; - } else { - t8 = $[11]; - } - let t9; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t9 = {t6}{t7}{t8}0: Dismiss; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] !== t5) { - t10 = {t5}{t9}; - $[13] = t5; - $[14] = t10; - } else { - t10 = $[14]; - } - return t10; + isValidDigit: isValidResponseInput, + onDigit: digit => onSelect(inputToResponse[digit]), + }) + + return ( + + + + {message} + + + + + + 1: Bad + + + + + 2: Fine + + + + + 3: Good + + + + + 0: Dismiss + + + + + ) } diff --git a/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx b/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx index da3893a76..ec7a974f5 100644 --- a/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx +++ b/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx @@ -1,87 +1,74 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { BLACK_CIRCLE } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; -export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again'; +import React from 'react' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' + +export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again' + type Props = { - onSelect: (option: TranscriptShareResponse) => void; - inputValue: string; - setInputValue: (value: string) => void; -}; -const RESPONSE_INPUTS = ['1', '2', '3'] as const; -type ResponseInput = (typeof RESPONSE_INPUTS)[number]; + onSelect: (option: TranscriptShareResponse) => void + inputValue: string + setInputValue: (value: string) => void +} + +const RESPONSE_INPUTS = ['1', '2', '3'] as const +type ResponseInput = (typeof RESPONSE_INPUTS)[number] + const inputToResponse: Record = { '1': 'yes', '2': 'no', - '3': 'dont_ask_again' -} as const; -const isValidResponseInput = (input: string): input is ResponseInput => (RESPONSE_INPUTS as readonly string[]).includes(input); -export function TranscriptSharePrompt(t0) { - const $ = _c(11); - const { - onSelect, + '3': 'dont_ask_again', +} as const + +const isValidResponseInput = (input: string): input is ResponseInput => + (RESPONSE_INPUTS as readonly string[]).includes(input) + +export function TranscriptSharePrompt({ + onSelect, + inputValue, + setInputValue, +}: Props): React.ReactNode { + useDebouncedDigitInput({ inputValue, - setInputValue - } = t0; - let t1; - if ($[0] !== onSelect) { - t1 = digit => onSelect(inputToResponse[digit]); - $[0] = onSelect; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== inputValue || $[3] !== setInputValue || $[4] !== t1) { - t2 = { - inputValue, - setInputValue, - isValidDigit: isValidResponseInput, - onDigit: t1 - }; - $[2] = inputValue; - $[3] = setInputValue; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - useDebouncedDigitInput(t2); - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {BLACK_CIRCLE} Can Anthropic look at your session transcript to help us improve Claude Code?; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Learn more: https://code.claude.com/docs/en/data-usage#session-quality-surveys; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = 1: Yes; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = 2: No; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = {t3}{t4}{t5}{t6}3: Don't ask again; - $[10] = t7; - } else { - t7 = $[10]; - } - return t7; + setInputValue, + isValidDigit: isValidResponseInput, + onDigit: digit => onSelect(inputToResponse[digit]), + }) + + return ( + + + {BLACK_CIRCLE} + + Can Anthropic look at your session transcript to help us improve + Claude Code? + + + + + + Learn more: + https://code.claude.com/docs/en/data-usage#session-quality-surveys + + + + + + + 1: Yes + + + + + 2: No + + + + + 3: Don't ask again + + + + + ) } diff --git a/src/components/FeedbackSurvey/useFeedbackSurvey.tsx b/src/components/FeedbackSurvey/useFeedbackSurvey.tsx index d19cd0300..166c2dcdd 100644 --- a/src/components/FeedbackSurvey/useFeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey/useFeedbackSurvey.tsx @@ -1,32 +1,41 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js'; -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { isPolicyAllowed } from '../../services/policyLimits/index.js'; -import type { Message } from '../../types/message.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { getLastAssistantMessage } from '../../utils/messages.js'; -import { getMainLoopModel } from '../../utils/model/model.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { logOTelEvent } from '../../utils/telemetry/events.js'; -import { submitTranscriptShare, type TranscriptShareTrigger } from './submitTranscriptShare.js'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import { useSurveyState } from './useSurveyState.js'; -import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js' +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { isPolicyAllowed } from '../../services/policyLimits/index.js' +import type { Message } from '../../types/message.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { getLastAssistantMessage } from '../../utils/messages.js' +import { getMainLoopModel } from '../../utils/model/model.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { logOTelEvent } from '../../utils/telemetry/events.js' +import { + submitTranscriptShare, + type TranscriptShareTrigger, +} from './submitTranscriptShare.js' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import { useSurveyState } from './useSurveyState.js' +import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js' + type FeedbackSurveyConfig = { - minTimeBeforeFeedbackMs: number; - minTimeBetweenFeedbackMs: number; - minTimeBetweenGlobalFeedbackMs: number; - minUserTurnsBeforeFeedback: number; - minUserTurnsBetweenFeedback: number; - hideThanksAfterMs: number; - onForModels: string[]; - probability: number; -}; + minTimeBeforeFeedbackMs: number + minTimeBetweenFeedbackMs: number + minTimeBetweenGlobalFeedbackMs: number + minUserTurnsBeforeFeedback: number + minUserTurnsBetweenFeedback: number + hideThanksAfterMs: number + onForModels: string[] + probability: number +} + type TranscriptAskConfig = { - probability: number; -}; + probability: number +} + const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = { minTimeBeforeFeedbackMs: 600000, minTimeBetweenFeedbackMs: 3600000, @@ -35,261 +44,381 @@ const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = { minUserTurnsBetweenFeedback: 10, hideThanksAfterMs: 3000, onForModels: ['*'], - probability: 0.005 -}; + probability: 0.005, +} + const DEFAULT_TRANSCRIPT_ASK_CONFIG: TranscriptAskConfig = { - probability: 0 -}; -export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submitCount: number, surveyType: FeedbackSurveyType = 'session', hasActivePrompt: boolean = false): { - state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; - lastResponse: FeedbackSurveyResponse | null; - handleSelect: (selected: FeedbackSurveyResponse) => boolean; - handleTranscriptSelect: (selected: TranscriptShareResponse) => void; + probability: 0, +} + +export function useFeedbackSurvey( + messages: Message[], + isLoading: boolean, + submitCount: number, + surveyType: FeedbackSurveyType = 'session', + hasActivePrompt: boolean = false, +): { + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => boolean + handleTranscriptSelect: (selected: TranscriptShareResponse) => void } { - const lastAssistantMessageIdRef = useRef('unknown'); - lastAssistantMessageIdRef.current = getLastAssistantMessage(messages)?.message?.id || 'unknown'; + const lastAssistantMessageIdRef = useRef('unknown') + lastAssistantMessageIdRef.current = + getLastAssistantMessage(messages)?.message?.id || 'unknown' const [feedbackSurvey, setFeedbackSurvey] = useState<{ - timeLastShown: number | null; - submitCountAtLastAppearance: number | null; - }>(() => ({ - timeLastShown: null, - submitCountAtLastAppearance: null - })); - const config = useDynamicConfig('tengu_feedback_survey_config', DEFAULT_FEEDBACK_SURVEY_CONFIG); - const badTranscriptAskConfig = useDynamicConfig('tengu_bad_survey_transcript_ask_config', DEFAULT_TRANSCRIPT_ASK_CONFIG); - const goodTranscriptAskConfig = useDynamicConfig('tengu_good_survey_transcript_ask_config', DEFAULT_TRANSCRIPT_ASK_CONFIG); - const settingsRate = getInitialSettings().feedbackSurveyRate; - const sessionStartTime = useRef(Date.now()); - const submitCountAtSessionStart = useRef(submitCount); - const submitCountRef = useRef(submitCount); - submitCountRef.current = submitCount; - const messagesRef = useRef(messages); - messagesRef.current = messages; + timeLastShown: number | null + submitCountAtLastAppearance: number | null + }>(() => ({ timeLastShown: null, submitCountAtLastAppearance: null })) + const config = useDynamicConfig( + 'tengu_feedback_survey_config', + DEFAULT_FEEDBACK_SURVEY_CONFIG, + ) + const badTranscriptAskConfig = useDynamicConfig( + 'tengu_bad_survey_transcript_ask_config', + DEFAULT_TRANSCRIPT_ASK_CONFIG, + ) + const goodTranscriptAskConfig = useDynamicConfig( + 'tengu_good_survey_transcript_ask_config', + DEFAULT_TRANSCRIPT_ASK_CONFIG, + ) + const settingsRate = getInitialSettings().feedbackSurveyRate + const sessionStartTime = useRef(Date.now()) + const submitCountAtSessionStart = useRef(submitCount) + const submitCountRef = useRef(submitCount) + submitCountRef.current = submitCount + const messagesRef = useRef(messages) + messagesRef.current = messages // Probability gate: roll once when eligibility conditions are met, not on every // useMemo re-evaluation. Without this, each dependency change (submitCount, // isLoading toggle, etc.) re-rolls Math.random(), making the survey almost // certain to appear after enough renders. - const probabilityPassedRef = useRef(false); - const lastEligibleSubmitCountRef = useRef(null); - const updateLastShownTime = useCallback((timestamp: number, submitCountValue: number) => { - setFeedbackSurvey(prev => { - if (prev.timeLastShown === timestamp && prev.submitCountAtLastAppearance === submitCountValue) { - return prev; - } - return { - timeLastShown: timestamp, - submitCountAtLastAppearance: submitCountValue - }; - }); - // Persist cross-session pacing state (previously done by onChangeAppState observer) - if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) { - saveGlobalConfig(current => ({ - ...current, - feedbackSurveyState: { - lastShownTime: timestamp + const probabilityPassedRef = useRef(false) + const lastEligibleSubmitCountRef = useRef(null) + + const updateLastShownTime = useCallback( + (timestamp: number, submitCountValue: number) => { + setFeedbackSurvey(prev => { + if ( + prev.timeLastShown === timestamp && + prev.submitCountAtLastAppearance === submitCountValue + ) { + return prev } - })); - } - }, []); - const onOpen = useCallback((appearanceId: string) => { - updateLastShownTime(Date.now(), submitCountRef.current); - logEvent('tengu_feedback_survey_event', { - event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'appeared', - appearance_id: appearanceId, - survey_type: surveyType - }); - }, [updateLastShownTime, surveyType]); - const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => { - updateLastShownTime(Date.now(), submitCountRef.current); - logEvent('tengu_feedback_survey_event', { - event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'responded', - appearance_id: appearanceId_0, - response: selected, - survey_type: surveyType - }); - }, [updateLastShownTime, surveyType]); - const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => { - // Only bad and good ratings trigger the transcript ask - if (selected_0 !== 'bad' && selected_0 !== 'good') { - return false; - } + return { + timeLastShown: timestamp, + submitCountAtLastAppearance: submitCountValue, + } + }) + // Persist cross-session pacing state (previously done by onChangeAppState observer) + if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) { + saveGlobalConfig(current => ({ + ...current, + feedbackSurveyState: { + lastShownTime: timestamp, + }, + })) + } + }, + [], + ) - // Don't show if user previously chose "Don't ask again" - if (getGlobalConfig().transcriptShareDismissed) { - return false; - } - - // Don't show if product feedback is blocked by org policy (ZDR) - if (!isPolicyAllowed('allow_product_feedback')) { - return false; - } - - // Probability gate from GrowthBook config (separate per rating) - const probability = selected_0 === 'bad' ? badTranscriptAskConfig.probability : goodTranscriptAskConfig.probability; - return Math.random() <= probability; - }, [badTranscriptAskConfig.probability, goodTranscriptAskConfig.probability]); - const onTranscriptPromptShown = useCallback((appearanceId_1: string, surveyResponse: FeedbackSurveyResponse) => { - const trigger: TranscriptShareTrigger = surveyResponse === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey'; - logEvent('tengu_feedback_survey_event', { - event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'transcript_prompt_appeared', - appearance_id: appearanceId_1, - survey_type: surveyType - }); - }, [surveyType]); - const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse, surveyResponse_0: FeedbackSurveyResponse | null): Promise => { - const trigger_0: TranscriptShareTrigger = surveyResponse_0 === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey'; - logEvent('tengu_feedback_survey_event', { - event_type: `transcript_share_${selected_1}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: trigger_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - if (selected_1 === 'dont_ask_again') { - saveGlobalConfig(current_0 => ({ - ...current_0, - transcriptShareDismissed: true - })); - } - if (selected_1 === 'yes') { - const result = await submitTranscriptShare(messagesRef.current, trigger_0, appearanceId_2); + const onOpen = useCallback( + (appearanceId: string) => { + updateLastShownTime(Date.now(), submitCountRef.current) logEvent('tengu_feedback_survey_event', { - event_type: (result.success ? 'transcript_share_submitted' : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: trigger_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - return result.success; - } - return false; - }, [surveyType]); - const { - state, - lastResponse, - open, - handleSelect, - handleTranscriptSelect - } = useSurveyState({ - hideThanksAfterMs: config.hideThanksAfterMs, - onOpen, - onSelect, - shouldShowTranscriptPrompt, - onTranscriptPromptShown, - onTranscriptSelect - }); - const currentModel = getMainLoopModel(); + event_type: + 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'appeared', + appearance_id: appearanceId, + survey_type: surveyType, + }) + }, + [updateLastShownTime, surveyType], + ) + + const onSelect = useCallback( + (appearanceId: string, selected: FeedbackSurveyResponse) => { + updateLastShownTime(Date.now(), submitCountRef.current) + logEvent('tengu_feedback_survey_event', { + event_type: + 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: surveyType, + }) + }, + [updateLastShownTime, surveyType], + ) + + const shouldShowTranscriptPrompt = useCallback( + (selected: FeedbackSurveyResponse) => { + // Only bad and good ratings trigger the transcript ask + if (selected !== 'bad' && selected !== 'good') { + return false + } + + // Don't show if user previously chose "Don't ask again" + if (getGlobalConfig().transcriptShareDismissed) { + return false + } + + // Don't show if product feedback is blocked by org policy (ZDR) + if (!isPolicyAllowed('allow_product_feedback')) { + return false + } + + // Probability gate from GrowthBook config (separate per rating) + const probability = + selected === 'bad' + ? badTranscriptAskConfig.probability + : goodTranscriptAskConfig.probability + return Math.random() <= probability + }, + [badTranscriptAskConfig.probability, goodTranscriptAskConfig.probability], + ) + + const onTranscriptPromptShown = useCallback( + (appearanceId: string, surveyResponse: FeedbackSurveyResponse) => { + const trigger: TranscriptShareTrigger = + surveyResponse === 'good' + ? 'good_feedback_survey' + : 'bad_feedback_survey' + logEvent('tengu_feedback_survey_event', { + event_type: + 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'transcript_prompt_appeared', + appearance_id: appearanceId, + survey_type: surveyType, + }) + }, + [surveyType], + ) + + const onTranscriptSelect = useCallback( + async ( + appearanceId: string, + selected: TranscriptShareResponse, + surveyResponse: FeedbackSurveyResponse | null, + ): Promise => { + const trigger: TranscriptShareTrigger = + surveyResponse === 'good' + ? 'good_feedback_survey' + : 'bad_feedback_survey' + + logEvent('tengu_feedback_survey_event', { + event_type: + `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + survey_type: + surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + if (selected === 'dont_ask_again') { + saveGlobalConfig(current => ({ + ...current, + transcriptShareDismissed: true, + })) + } + + if (selected === 'yes') { + const result = await submitTranscriptShare( + messagesRef.current, + trigger, + appearanceId, + ) + logEvent('tengu_feedback_survey_event', { + event_type: (result.success + ? 'transcript_share_submitted' + : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return result.success + } + + return false + }, + [surveyType], + ) + + const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = + useSurveyState({ + hideThanksAfterMs: config.hideThanksAfterMs, + onOpen, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + onTranscriptSelect, + }) + + const currentModel = getMainLoopModel() const isModelAllowed = useMemo(() => { if (config.onForModels.length === 0) { - return false; + return false } if (config.onForModels.includes('*')) { - return true; + return true } - return config.onForModels.includes(currentModel); - }, [config.onForModels, currentModel]); + return config.onForModels.includes(currentModel) + }, [config.onForModels, currentModel]) + const shouldOpen = useMemo(() => { if (state !== 'closed') { - return false; + return false } + if (isLoading) { - return false; + return false } // Don't show survey when permission or ask question prompts are visible if (hasActivePrompt) { - return false; + return false } // Force display for testing - if (process.env.CLAUDE_FORCE_DISPLAY_SURVEY && !feedbackSurvey.timeLastShown) { - return true; + if ( + process.env.CLAUDE_FORCE_DISPLAY_SURVEY && + !feedbackSurvey.timeLastShown + ) { + return true } + if (!isModelAllowed) { - return false; + return false } + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return false; + return false } + if (isFeedbackSurveyDisabled()) { - return false; + return false } // Check if product feedback is allowed by org policy if (!isPolicyAllowed('allow_product_feedback')) { - return false; + return false } // Check session-local pacing if (feedbackSurvey.timeLastShown) { // Check time elapsed since last appearance in this session - const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown; + const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown if (timeSinceLastShown < config.minTimeBetweenFeedbackMs) { - return false; + return false } // Check user turn requirement for subsequent appearances - if (feedbackSurvey.submitCountAtLastAppearance !== null && submitCount < feedbackSurvey.submitCountAtLastAppearance + config.minUserTurnsBetweenFeedback) { - return false; + if ( + feedbackSurvey.submitCountAtLastAppearance !== null && + submitCount < + feedbackSurvey.submitCountAtLastAppearance + + config.minUserTurnsBetweenFeedback + ) { + return false } } else { // First appearance in this session - const timeSinceSessionStart = Date.now() - sessionStartTime.current; + const timeSinceSessionStart = Date.now() - sessionStartTime.current if (timeSinceSessionStart < config.minTimeBeforeFeedbackMs) { - return false; + return false } - if (submitCount < submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback) { - return false; + if ( + submitCount < + submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback + ) { + return false } } // Probability check: roll once per eligibility window to avoid re-rolling // on every useMemo re-evaluation (which would make triggering near-certain). if (lastEligibleSubmitCountRef.current !== submitCount) { - lastEligibleSubmitCountRef.current = submitCount; - probabilityPassedRef.current = Math.random() <= (settingsRate ?? config.probability); + lastEligibleSubmitCountRef.current = submitCount + probabilityPassedRef.current = + Math.random() <= (settingsRate ?? config.probability) } if (!probabilityPassedRef.current) { - return false; + return false } // Check global pacing (across all sessions) // Leave this till last because it reads from the filesystem which is expensive. - const globalFeedbackState = getGlobalConfig().feedbackSurveyState; + const globalFeedbackState = getGlobalConfig().feedbackSurveyState if (globalFeedbackState?.lastShownTime) { - const timeSinceGlobalLastShown = Date.now() - globalFeedbackState.lastShownTime; + const timeSinceGlobalLastShown = + Date.now() - globalFeedbackState.lastShownTime if (timeSinceGlobalLastShown < config.minTimeBetweenGlobalFeedbackMs) { - return false; + return false } } - return true; - }, [state, isLoading, hasActivePrompt, isModelAllowed, feedbackSurvey.timeLastShown, feedbackSurvey.submitCountAtLastAppearance, submitCount, config.minTimeBetweenFeedbackMs, config.minTimeBetweenGlobalFeedbackMs, config.minUserTurnsBetweenFeedback, config.minTimeBeforeFeedbackMs, config.minUserTurnsBeforeFeedback, config.probability, settingsRate]); + + return true + }, [ + state, + isLoading, + hasActivePrompt, + isModelAllowed, + feedbackSurvey.timeLastShown, + feedbackSurvey.submitCountAtLastAppearance, + submitCount, + config.minTimeBetweenFeedbackMs, + config.minTimeBetweenGlobalFeedbackMs, + config.minUserTurnsBetweenFeedback, + config.minTimeBeforeFeedbackMs, + config.minUserTurnsBeforeFeedback, + config.probability, + settingsRate, + ]) + useEffect(() => { if (shouldOpen) { - open(); + open() } - }, [shouldOpen, open]); - return { - state, - lastResponse, - handleSelect, - handleTranscriptSelect - }; + }, [shouldOpen, open]) + + return { state, lastResponse, handleSelect, handleTranscriptSelect } } diff --git a/src/components/FeedbackSurvey/useMemorySurvey.tsx b/src/components/FeedbackSurvey/useMemorySurvey.tsx index bd28ee699..2c96b2b9e 100644 --- a/src/components/FeedbackSurvey/useMemorySurvey.tsx +++ b/src/components/FeedbackSurvey/useMemorySurvey.tsx @@ -1,212 +1,283 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { isAutoMemoryEnabled } from '../../memdir/paths.js'; -import { isPolicyAllowed } from '../../services/policyLimits/index.js'; -import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'; -import type { Message } from '../../types/message.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js'; -import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js'; -import { logOTelEvent } from '../../utils/telemetry/events.js'; -import { submitTranscriptShare } from './submitTranscriptShare.js'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import { useSurveyState } from './useSurveyState.js'; -import type { FeedbackSurveyResponse } from './utils.js'; -const HIDE_THANKS_AFTER_MS = 3000; -const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell'; -const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event'; -const SURVEY_PROBABILITY = 0.2; -const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey'; -const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i; +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { isAutoMemoryEnabled } from '../../memdir/paths.js' +import { isPolicyAllowed } from '../../services/policyLimits/index.js' +import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' +import type { Message } from '../../types/message.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js' +import { + extractTextContent, + getLastAssistantMessage, +} from '../../utils/messages.js' +import { logOTelEvent } from '../../utils/telemetry/events.js' +import { submitTranscriptShare } from './submitTranscriptShare.js' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import { useSurveyState } from './useSurveyState.js' +import type { FeedbackSurveyResponse } from './utils.js' + +const HIDE_THANKS_AFTER_MS = 3000 +const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell' +const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event' +const SURVEY_PROBABILITY = 0.2 +const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey' + +const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i + function hasMemoryFileRead(messages: Message[]): boolean { for (const message of messages) { if (message.type !== 'assistant') { - continue; + continue } - const content = message.message.content; + const content = message.message.content if (!Array.isArray(content)) { - continue; + continue } for (const block of content) { if (block.type !== 'tool_use' || block.name !== FILE_READ_TOOL_NAME) { - continue; + continue } - const input = block.input as { - file_path?: unknown; - }; - if (typeof input.file_path === 'string' && isAutoManagedMemoryFile(input.file_path)) { - return true; + const input = block.input as { file_path?: unknown } + if ( + typeof input.file_path === 'string' && + isAutoManagedMemoryFile(input.file_path) + ) { + return true } } } - return false; + return false } -export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActivePrompt = false, { - enabled = true -}: { - enabled?: boolean; -} = {}): { - state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; - lastResponse: FeedbackSurveyResponse | null; - handleSelect: (selected: FeedbackSurveyResponse) => void; - handleTranscriptSelect: (selected: TranscriptShareResponse) => void; + +export function useMemorySurvey( + messages: Message[], + isLoading: boolean, + hasActivePrompt = false, + { enabled = true }: { enabled?: boolean } = {}, +): { + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => void + handleTranscriptSelect: (selected: TranscriptShareResponse) => void } { // Track assistant message UUIDs that were already evaluated so we don't // re-roll probability on re-renders or re-scan messages for the same turn. - const seenAssistantUuids = useRef>(new Set()); + const seenAssistantUuids = useRef>(new Set()) // Once a memory file read is observed it stays true for the session — // skip the O(n) scan on subsequent turns. - const memoryReadSeen = useRef(false); - const messagesRef = useRef(messages); - messagesRef.current = messages; + const memoryReadSeen = useRef(false) + const messagesRef = useRef(messages) + messagesRef.current = messages + const onOpen = useCallback((appearanceId: string) => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + event_type: + 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) void logOTelEvent('feedback_survey', { event_type: 'appeared', appearance_id: appearanceId, - survey_type: 'memory' - }); - }, []); - const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => { + survey_type: 'memory', + }) + }, []) + + const onSelect = useCallback( + (appearanceId: string, selected: FeedbackSurveyResponse) => { + logEvent(MEMORY_SURVEY_EVENT, { + event_type: + 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: 'memory', + }) + }, + [], + ) + + const shouldShowTranscriptPrompt = useCallback( + (selected: FeedbackSurveyResponse) => { + if (process.env.USER_TYPE !== 'ant') { + return false + } + if (selected !== 'bad' && selected !== 'good') { + return false + } + if (getGlobalConfig().transcriptShareDismissed) { + return false + } + if (!isPolicyAllowed('allow_product_feedback')) { + return false + } + return true + }, + [], + ) + + const onTranscriptPromptShown = useCallback((appearanceId: string) => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void logOTelEvent('feedback_survey', { - event_type: 'responded', - appearance_id: appearanceId_0, - response: selected, - survey_type: 'memory' - }); - }, []); - const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => { - if ((process.env.USER_TYPE) !== 'ant') { - return false; - } - if (selected_0 !== 'bad' && selected_0 !== 'good') { - return false; - } - if (getGlobalConfig().transcriptShareDismissed) { - return false; - } - if (!isPolicyAllowed('allow_product_feedback')) { - return false; - } - return true; - }, []); - const onTranscriptPromptShown = useCallback((appearanceId_1: string) => { - logEvent(MEMORY_SURVEY_EVENT, { - event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + event_type: + 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) void logOTelEvent('feedback_survey', { event_type: 'transcript_prompt_appeared', - appearance_id: appearanceId_1, - survey_type: 'memory' - }); - }, []); - const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse): Promise => { - logEvent(MEMORY_SURVEY_EVENT, { - event_type: `transcript_share_${selected_1}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - if (selected_1 === 'dont_ask_again') { - saveGlobalConfig(current => ({ - ...current, - transcriptShareDismissed: true - })); - } - if (selected_1 === 'yes') { - const result = await submitTranscriptShare(messagesRef.current, TRANSCRIPT_SHARE_TRIGGER, appearanceId_2); + appearance_id: appearanceId, + survey_type: 'memory', + }) + }, []) + + const onTranscriptSelect = useCallback( + async ( + appearanceId: string, + selected: TranscriptShareResponse, + ): Promise => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: (result.success ? 'transcript_share_submitted' : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - return result.success; - } - return false; - }, []); - const { - state, - lastResponse, - open, - handleSelect, - handleTranscriptSelect - } = useSurveyState({ - hideThanksAfterMs: HIDE_THANKS_AFTER_MS, - onOpen, - onSelect, - shouldShowTranscriptPrompt, - onTranscriptPromptShown, - onTranscriptSelect - }); - const lastAssistant = useMemo(() => getLastAssistantMessage(messages), [messages]); + event_type: + `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + if (selected === 'dont_ask_again') { + saveGlobalConfig(current => ({ + ...current, + transcriptShareDismissed: true, + })) + } + + if (selected === 'yes') { + const result = await submitTranscriptShare( + messagesRef.current, + TRANSCRIPT_SHARE_TRIGGER, + appearanceId, + ) + logEvent(MEMORY_SURVEY_EVENT, { + event_type: (result.success + ? 'transcript_share_submitted' + : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: + TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return result.success + } + + return false + }, + [], + ) + + const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = + useSurveyState({ + hideThanksAfterMs: HIDE_THANKS_AFTER_MS, + onOpen, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + onTranscriptSelect, + }) + + const lastAssistant = useMemo( + () => getLastAssistantMessage(messages), + [messages], + ) + useEffect(() => { - if (!enabled) return; + if (!enabled) return // /clear resets messages but REPL stays mounted — reset refs so a memory // read from the previous conversation doesn't leak into the new one. if (messages.length === 0) { - memoryReadSeen.current = false; - seenAssistantUuids.current.clear(); - return; + memoryReadSeen.current = false + seenAssistantUuids.current.clear() + return } + if (state !== 'closed' || isLoading || hasActivePrompt) { - return; + return } // 3P default: survey off (no GrowthBook on Bedrock/Vertex/Foundry). if (!getFeatureValue_CACHED_MAY_BE_STALE(MEMORY_SURVEY_GATE, false)) { - return; + return } + if (!isAutoMemoryEnabled()) { - return; + return } + if (isFeedbackSurveyDisabled()) { - return; + return } + if (!isPolicyAllowed('allow_product_feedback')) { - return; + return } + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return; + return } + if (!lastAssistant || seenAssistantUuids.current.has(lastAssistant.uuid)) { - return; + return } - const text = extractTextContent(Array.isArray(lastAssistant.message.content) ? lastAssistant.message.content : [], ' '); + + const text = extractTextContent(lastAssistant.message.content, ' ') if (!MEMORY_WORD_RE.test(text)) { - return; + return } // Mark as evaluated before the memory-read scan so a turn that mentions // "memory" but has no memory read doesn't trigger repeated O(n) scans // on subsequent renders with the same last assistant message. - seenAssistantUuids.current.add(lastAssistant.uuid); + seenAssistantUuids.current.add(lastAssistant.uuid) + if (!memoryReadSeen.current) { - memoryReadSeen.current = hasMemoryFileRead(messages); + memoryReadSeen.current = hasMemoryFileRead(messages) } if (!memoryReadSeen.current) { - return; + return } + if (Math.random() < SURVEY_PROBABILITY) { - open(); + open() } - }, [enabled, state, isLoading, hasActivePrompt, lastAssistant, messages, open]); - return { + }, [ + enabled, state, - lastResponse, - handleSelect, - handleTranscriptSelect - }; + isLoading, + hasActivePrompt, + lastAssistant, + messages, + open, + ]) + + return { state, lastResponse, handleSelect, handleTranscriptSelect } } diff --git a/src/components/FeedbackSurvey/usePostCompactSurvey.tsx b/src/components/FeedbackSurvey/usePostCompactSurvey.tsx index ee8e31e79..99e80dfed 100644 --- a/src/components/FeedbackSurvey/usePostCompactSurvey.tsx +++ b/src/components/FeedbackSurvey/usePostCompactSurvey.tsx @@ -1,205 +1,195 @@ -import { c as _c } from "react/compiler-runtime"; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; -import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'; -import type { Message } from '../../types/message.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { isCompactBoundaryMessage } from '../../utils/messages.js'; -import { logOTelEvent } from '../../utils/telemetry/events.js'; -import { useSurveyState } from './useSurveyState.js'; -import type { FeedbackSurveyResponse } from './utils.js'; -const HIDE_THANKS_AFTER_MS = 3000; -const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey'; -const SURVEY_PROBABILITY = 0.2; // Show survey 20% of the time after compaction +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' +import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js' +import type { Message } from '../../types/message.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { isCompactBoundaryMessage } from '../../utils/messages.js' +import { logOTelEvent } from '../../utils/telemetry/events.js' +import { useSurveyState } from './useSurveyState.js' +import type { FeedbackSurveyResponse } from './utils.js' -function hasMessageAfterBoundary(messages: Message[], boundaryUuid: string): boolean { - const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid); +const HIDE_THANKS_AFTER_MS = 3000 +const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey' +const SURVEY_PROBABILITY = 0.2 // Show survey 20% of the time after compaction + +function hasMessageAfterBoundary( + messages: Message[], + boundaryUuid: string, +): boolean { + const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid) if (boundaryIndex === -1) { - return false; + return false } // Check if there's a user or assistant message after the boundary for (let i = boundaryIndex + 1; i < messages.length; i++) { - const msg = messages[i]; + const msg = messages[i] if (msg && (msg.type === 'user' || msg.type === 'assistant')) { - return true; + return true } } - return false; + return false } -export function usePostCompactSurvey(messages, isLoading, t0, t1) { - const $ = _c(23); - const hasActivePrompt = t0 === undefined ? false : t0; - let t2; - if ($[0] !== t1) { - t2 = t1 === undefined ? {} : t1; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - const { - enabled: t3 - } = t2; - const enabled = t3 === undefined ? true : t3; - const [gateEnabled, setGateEnabled] = useState(null); - let t4; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t4 = new Set(); - $[2] = t4; - } else { - t4 = $[2]; - } - const seenCompactBoundaries = useRef(t4); - const pendingCompactBoundaryUuid = useRef(null); - const onOpen = _temp; - const onSelect = _temp2; - let t5; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - hideThanksAfterMs: HIDE_THANKS_AFTER_MS, - onOpen, - onSelect - }; - $[3] = t5; - } else { - t5 = $[3]; - } - const { - state, - lastResponse, - open, - handleSelect - } = useSurveyState(t5); - let t6; - let t7; - if ($[4] !== enabled) { - t6 = () => { - if (!enabled) { - return; - } - setGateEnabled(checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE)); - }; - t7 = [enabled]; - $[4] = enabled; - $[5] = t6; - $[6] = t7; - } else { - t6 = $[5]; - t7 = $[6]; - } - useEffect(t6, t7); - let t8; - if ($[7] !== messages) { - t8 = new Set(messages.filter(_temp3).map(_temp4)); - $[7] = messages; - $[8] = t8; - } else { - t8 = $[8]; - } - const currentCompactBoundaries = t8; - let t10; - let t9; - if ($[9] !== currentCompactBoundaries || $[10] !== enabled || $[11] !== gateEnabled || $[12] !== hasActivePrompt || $[13] !== isLoading || $[14] !== messages || $[15] !== open || $[16] !== state) { - t9 = () => { - if (!enabled) { - return; - } - if (state !== "closed" || isLoading) { - return; - } - if (hasActivePrompt) { - return; - } - if (gateEnabled !== true) { - return; - } - if (isFeedbackSurveyDisabled()) { - return; - } - if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return; - } - if (pendingCompactBoundaryUuid.current !== null) { - if (hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current)) { - pendingCompactBoundaryUuid.current = null; - if (Math.random() < SURVEY_PROBABILITY) { - open(); - } - return; + +export function usePostCompactSurvey( + messages: Message[], + isLoading: boolean, + hasActivePrompt = false, + { enabled = true }: { enabled?: boolean } = {}, +): { + state: + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + lastResponse: FeedbackSurveyResponse | null + handleSelect: (selected: FeedbackSurveyResponse) => void +} { + const [gateEnabled, setGateEnabled] = useState(null) + const seenCompactBoundaries = useRef>(new Set()) + // Track the compact boundary we're waiting on (to show survey after next message) + const pendingCompactBoundaryUuid = useRef(null) + + const onOpen = useCallback((appearanceId: string) => { + const smCompactionEnabled = shouldUseSessionMemoryCompaction() + logEvent('tengu_post_compact_survey_event', { + event_type: + 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_memory_compaction_enabled: + smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'appeared', + appearance_id: appearanceId, + survey_type: 'post_compact', + }) + }, []) + + const onSelect = useCallback( + (appearanceId: string, selected: FeedbackSurveyResponse) => { + const smCompactionEnabled = shouldUseSessionMemoryCompaction() + logEvent('tengu_post_compact_survey_event', { + event_type: + 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: + appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: + selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_memory_compaction_enabled: + smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: 'post_compact', + }) + }, + [], + ) + + const { state, lastResponse, open, handleSelect } = useSurveyState({ + hideThanksAfterMs: HIDE_THANKS_AFTER_MS, + onOpen, + onSelect, + }) + + // Check the feature gate on mount + useEffect(() => { + if (!enabled) return + setGateEnabled( + checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE), + ) + }, [enabled]) + + // Find compact boundary messages + const currentCompactBoundaries = useMemo( + () => + new Set( + messages + .filter(msg => isCompactBoundaryMessage(msg)) + .map(msg => msg.uuid), + ), + [messages], + ) + + // Detect new compact boundaries and defer showing survey until next message + useEffect(() => { + if (!enabled) return + + // Don't process if already showing + if (state !== 'closed' || isLoading) { + return + } + + // Don't show survey when permission or ask question prompts are visible + if (hasActivePrompt) { + return + } + + // Check if the gate is enabled + if (gateEnabled !== true) { + return + } + + if (isFeedbackSurveyDisabled()) { + return + } + + // Check if survey is explicitly disabled + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { + return + } + + // First, check if we have a pending compact and a new message has arrived + if (pendingCompactBoundaryUuid.current !== null) { + if ( + hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current) + ) { + // A new message arrived after the compact - decide whether to show survey + pendingCompactBoundaryUuid.current = null + + // Only show survey 20% of the time + if (Math.random() < SURVEY_PROBABILITY) { + open() } + return } - const newBoundaries = Array.from(currentCompactBoundaries).filter(uuid => !seenCompactBoundaries.current.has(uuid)); - if (newBoundaries.length > 0) { - seenCompactBoundaries.current = new Set(currentCompactBoundaries); - pendingCompactBoundaryUuid.current = newBoundaries[newBoundaries.length - 1]; - } - }; - t10 = [enabled, currentCompactBoundaries, state, isLoading, hasActivePrompt, gateEnabled, messages, open]; - $[9] = currentCompactBoundaries; - $[10] = enabled; - $[11] = gateEnabled; - $[12] = hasActivePrompt; - $[13] = isLoading; - $[14] = messages; - $[15] = open; - $[16] = state; - $[17] = t10; - $[18] = t9; - } else { - t10 = $[17]; - t9 = $[18]; - } - useEffect(t9, t10); - let t11; - if ($[19] !== handleSelect || $[20] !== lastResponse || $[21] !== state) { - t11 = { - state, - lastResponse, - handleSelect - }; - $[19] = handleSelect; - $[20] = lastResponse; - $[21] = state; - $[22] = t11; - } else { - t11 = $[22]; - } - return t11; -} -function _temp4(msg_0) { - return msg_0.uuid; -} -function _temp3(msg) { - return isCompactBoundaryMessage(msg); -} -function _temp2(appearanceId_0, selected) { - const smCompactionEnabled_0 = shouldUseSessionMemoryCompaction(); - logEvent("tengu_post_compact_survey_event", { - event_type: "responded" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_memory_compaction_enabled: smCompactionEnabled_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - logOTelEvent("feedback_survey", { - event_type: "responded", - appearance_id: appearanceId_0, - response: selected, - survey_type: "post_compact" - }); -} -function _temp(appearanceId) { - const smCompactionEnabled = shouldUseSessionMemoryCompaction(); - logEvent("tengu_post_compact_survey_event", { - event_type: "appeared" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_memory_compaction_enabled: smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - logOTelEvent("feedback_survey", { - event_type: "appeared", - appearance_id: appearanceId, - survey_type: "post_compact" - }); + } + + // Find new compact boundaries that we haven't seen yet + const newBoundaries = Array.from(currentCompactBoundaries).filter( + uuid => !seenCompactBoundaries.current.has(uuid), + ) + + if (newBoundaries.length > 0) { + // Mark these boundaries as seen + seenCompactBoundaries.current = new Set(currentCompactBoundaries) + + // Don't show survey immediately - wait for next message + // Store the most recent new boundary UUID + pendingCompactBoundaryUuid.current = + newBoundaries[newBoundaries.length - 1]! + } + }, [ + enabled, + currentCompactBoundaries, + state, + isLoading, + hasActivePrompt, + gateEnabled, + messages, + open, + ]) + + return { state, lastResponse, handleSelect } } diff --git a/src/components/FeedbackSurvey/useSurveyState.tsx b/src/components/FeedbackSurvey/useSurveyState.tsx index d98e6d655..e00c82c0d 100644 --- a/src/components/FeedbackSurvey/useSurveyState.tsx +++ b/src/components/FeedbackSurvey/useSurveyState.tsx @@ -1,99 +1,144 @@ -import { randomUUID } from 'crypto'; -import { useCallback, useRef, useState } from 'react'; -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; -import type { FeedbackSurveyResponse } from './utils.js'; -type SurveyState = 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; +import { randomUUID } from 'crypto' +import { useCallback, useRef, useState } from 'react' +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' +import type { FeedbackSurveyResponse } from './utils.js' + +type SurveyState = + | 'closed' + | 'open' + | 'thanks' + | 'transcript_prompt' + | 'submitting' + | 'submitted' + type UseSurveyStateOptions = { - hideThanksAfterMs: number; - onOpen: (appearanceId: string) => void | Promise; - onSelect: (appearanceId: string, selected: FeedbackSurveyResponse) => void | Promise; - shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean; - onTranscriptPromptShown?: (appearanceId: string, surveyResponse: FeedbackSurveyResponse) => void; - onTranscriptSelect?: (appearanceId: string, selected: TranscriptShareResponse, surveyResponse: FeedbackSurveyResponse | null) => boolean | Promise; -}; + hideThanksAfterMs: number + onOpen: (appearanceId: string) => void | Promise + onSelect: ( + appearanceId: string, + selected: FeedbackSurveyResponse, + ) => void | Promise + shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean + onTranscriptPromptShown?: ( + appearanceId: string, + surveyResponse: FeedbackSurveyResponse, + ) => void + onTranscriptSelect?: ( + appearanceId: string, + selected: TranscriptShareResponse, + surveyResponse: FeedbackSurveyResponse | null, + ) => boolean | Promise +} + export function useSurveyState({ hideThanksAfterMs, onOpen, onSelect, shouldShowTranscriptPrompt, onTranscriptPromptShown, - onTranscriptSelect + onTranscriptSelect, }: UseSurveyStateOptions): { - state: SurveyState; - lastResponse: FeedbackSurveyResponse | null; - open: () => void; - handleSelect: (selected: FeedbackSurveyResponse) => boolean; - handleTranscriptSelect: (selected: TranscriptShareResponse) => void; + state: SurveyState + lastResponse: FeedbackSurveyResponse | null + open: () => void + handleSelect: (selected: FeedbackSurveyResponse) => boolean + handleTranscriptSelect: (selected: TranscriptShareResponse) => void } { - const [state, setState] = useState('closed'); - const [lastResponse, setLastResponse] = useState(null); - const appearanceId = useRef(randomUUID()); - const lastResponseRef = useRef(null); + const [state, setState] = useState('closed') + const [lastResponse, setLastResponse] = + useState(null) + const appearanceId = useRef(randomUUID()) + const lastResponseRef = useRef(null) + const showThanksThenClose = useCallback(() => { - setState('thanks'); - setTimeout((setState_0, setLastResponse_0) => { - setState_0('closed'); - setLastResponse_0(null); - }, hideThanksAfterMs, setState, setLastResponse); - }, [hideThanksAfterMs]); + setState('thanks') + setTimeout( + (setState, setLastResponse) => { + setState('closed') + setLastResponse(null) + }, + hideThanksAfterMs, + setState, + setLastResponse, + ) + }, [hideThanksAfterMs]) + const showSubmittedThenClose = useCallback(() => { - setState('submitted'); - setTimeout(setState, hideThanksAfterMs, 'closed'); - }, [hideThanksAfterMs]); + setState('submitted') + setTimeout(setState, hideThanksAfterMs, 'closed') + }, [hideThanksAfterMs]) + const open = useCallback(() => { if (state !== 'closed') { - return; + return } - setState('open'); - appearanceId.current = randomUUID(); - void onOpen(appearanceId.current); - }, [state, onOpen]); - const handleSelect = useCallback((selected: FeedbackSurveyResponse): boolean => { - setLastResponse(selected); - lastResponseRef.current = selected; - // Always fire the survey response event first - void onSelect(appearanceId.current, selected); - if (selected === 'dismissed') { - setState('closed'); - setLastResponse(null); - } else if (shouldShowTranscriptPrompt?.(selected)) { - setState('transcript_prompt'); - onTranscriptPromptShown?.(appearanceId.current, selected); - return true; - } else { - showThanksThenClose(); - } - return false; - }, [showThanksThenClose, onSelect, shouldShowTranscriptPrompt, onTranscriptPromptShown]); - const handleTranscriptSelect = useCallback((selected_0: TranscriptShareResponse) => { - switch (selected_0) { - case 'yes': - setState('submitting'); - void (async () => { - try { - const success = await onTranscriptSelect?.(appearanceId.current, selected_0, lastResponseRef.current); - if (success) { - showSubmittedThenClose(); - } else { - showThanksThenClose(); + setState('open') + appearanceId.current = randomUUID() + void onOpen(appearanceId.current) + }, [state, onOpen]) + + const handleSelect = useCallback( + (selected: FeedbackSurveyResponse): boolean => { + setLastResponse(selected) + lastResponseRef.current = selected + // Always fire the survey response event first + void onSelect(appearanceId.current, selected) + + if (selected === 'dismissed') { + setState('closed') + setLastResponse(null) + } else if (shouldShowTranscriptPrompt?.(selected)) { + setState('transcript_prompt') + onTranscriptPromptShown?.(appearanceId.current, selected) + return true + } else { + showThanksThenClose() + } + return false + }, + [ + showThanksThenClose, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + ], + ) + + const handleTranscriptSelect = useCallback( + (selected: TranscriptShareResponse) => { + switch (selected) { + case 'yes': + setState('submitting') + void (async () => { + try { + const success = await onTranscriptSelect?.( + appearanceId.current, + selected, + lastResponseRef.current, + ) + if (success) { + showSubmittedThenClose() + } else { + showThanksThenClose() + } + } catch { + showThanksThenClose() } - } catch { - showThanksThenClose(); - } - })(); - break; - case 'no': - case 'dont_ask_again': - void onTranscriptSelect?.(appearanceId.current, selected_0, lastResponseRef.current); - showThanksThenClose(); - break; - } - }, [showThanksThenClose, showSubmittedThenClose, onTranscriptSelect]); - return { - state, - lastResponse, - open, - handleSelect, - handleTranscriptSelect - }; + })() + break + case 'no': + case 'dont_ask_again': + void onTranscriptSelect?.( + appearanceId.current, + selected, + lastResponseRef.current, + ) + showThanksThenClose() + break + } + }, + [showThanksThenClose, showSubmittedThenClose, onTranscriptSelect], + ) + + return { state, lastResponse, open, handleSelect, handleTranscriptSelect } } diff --git a/src/components/PromptInput/HistorySearchInput.tsx b/src/components/PromptInput/HistorySearchInput.tsx index dba1f4cda..22830119d 100644 --- a/src/components/PromptInput/HistorySearchInput.tsx +++ b/src/components/PromptInput/HistorySearchInput.tsx @@ -1,50 +1,38 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import TextInput from '../TextInput.js'; +import * as React from 'react' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import TextInput from '../TextInput.js' + type Props = { - value: string; - onChange: (value: string) => void; - historyFailedMatch: boolean; -}; -function HistorySearchInput(t0) { - const $ = _c(9); - const { - value, - onChange, - historyFailedMatch - } = t0; - const t1 = historyFailedMatch ? "no matching prompt:" : "search prompts:"; - let t2; - if ($[0] !== t1) { - t2 = {t1}; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - const t3 = stringWidth(value) + 1; - let t4; - if ($[2] !== onChange || $[3] !== t3 || $[4] !== value) { - t4 = ; - $[2] = onChange; - $[3] = t3; - $[4] = value; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t2 || $[7] !== t4) { - t5 = {t2}{t4}; - $[6] = t2; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - return t5; + value: string + onChange: (value: string) => void + historyFailedMatch: boolean } -function _temp() {} -export default HistorySearchInput; + +function HistorySearchInput({ + value, + onChange, + historyFailedMatch, +}: Props): React.ReactNode { + return ( + + + {historyFailedMatch ? 'no matching prompt:' : 'search prompts:'} + + {}} + columns={stringWidth(value) + 1} + focus={true} + showCursor={true} + multiline={false} + dimColor={true} + /> + + ) +} + +export default HistorySearchInput diff --git a/src/components/PromptInput/IssueFlagBanner.tsx b/src/components/PromptInput/IssueFlagBanner.tsx index bb5d6b5d8..723678eaf 100644 --- a/src/components/PromptInput/IssueFlagBanner.tsx +++ b/src/components/PromptInput/IssueFlagBanner.tsx @@ -1,11 +1,28 @@ -import * as React from 'react'; -import { FLAG_ICON } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; +import * as React from 'react' +import { FLAG_ICON } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' /** * ANT-ONLY: Banner shown in the transcript that prompts users to report * issues via /issue. Appears when friction is detected in the conversation. */ -export function IssueFlagBanner() { - return null; +export function IssueFlagBanner(): React.ReactNode { + if (process.env.USER_TYPE !== 'ant') { + return null + } + + return ( + + + {FLAG_ICON} + + + [ANT-ONLY] + + Something off with Claude? + + /issue to report it + + + ) } diff --git a/src/components/PromptInput/Notifications.tsx b/src/components/PromptInput/Notifications.tsx index 5203318e3..d89d596b3 100644 --- a/src/components/PromptInput/Notifications.tsx +++ b/src/components/PromptInput/Notifications.tsx @@ -1,218 +1,201 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { type ReactNode, useEffect, useMemo, useState } from 'react'; -import { type Notification, useNotifications } from 'src/context/notifications.js'; -import { logEvent } from 'src/services/analytics/index.js'; -import { useAppState } from 'src/state/AppState.js'; -import { useVoiceState } from '../../context/voice.js'; -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; -import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js'; -import type { IDESelection } from '../../hooks/useIdeSelection.js'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'; -import { Box, Text } from '../../ink.js'; -import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js'; -import { calculateTokenWarningState } from '../../services/compact/autoCompact.js'; -import type { MCPServerConnection } from '../../services/mcp/types.js'; -import type { Message } from '../../types/message.js'; -import { getApiKeyHelperElapsedMs, getConfiguredApiKeyHelper, getSubscriptionType } from '../../utils/auth.js'; -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; -import { getExternalEditor } from '../../utils/editor.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { formatDuration } from '../../utils/format.js'; -import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js'; -import { toIDEDisplayName } from '../../utils/ide.js'; -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; -import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js'; -import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { IdeStatusIndicator } from '../IdeStatusIndicator.js'; -import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js'; -import { SentryErrorBoundary } from '../SentryErrorBoundary.js'; -import { TokenWarning } from '../TokenWarning.js'; -import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { type ReactNode, useEffect, useMemo, useState } from 'react' +import { + type Notification, + useNotifications, +} from 'src/context/notifications.js' +import { logEvent } from 'src/services/analytics/index.js' +import { useAppState } from 'src/state/AppState.js' +import { useVoiceState } from '../../context/voice.js' +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' +import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js' +import type { IDESelection } from '../../hooks/useIdeSelection.js' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js' +import { Box, Text } from '../../ink.js' +import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js' +import { calculateTokenWarningState } from '../../services/compact/autoCompact.js' +import type { MCPServerConnection } from '../../services/mcp/types.js' +import type { Message } from '../../types/message.js' +import { + getApiKeyHelperElapsedMs, + getConfiguredApiKeyHelper, + getSubscriptionType, +} from '../../utils/auth.js' +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' +import { getExternalEditor } from '../../utils/editor.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { formatDuration } from '../../utils/format.js' +import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js' +import { toIDEDisplayName } from '../../utils/ide.js' +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' +import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js' +import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { IdeStatusIndicator } from '../IdeStatusIndicator.js' +import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js' +import { SentryErrorBoundary } from '../SentryErrorBoundary.js' +import { TokenWarning } from '../TokenWarning.js' +import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator = feature('VOICE_MODE') ? require('./VoiceIndicator.js').VoiceIndicator : () => null; +const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator = + feature('VOICE_MODE') + ? require('./VoiceIndicator.js').VoiceIndicator + : () => null /* eslint-enable @typescript-eslint/no-require-imports */ -export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000; +export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000 + type Props = { - apiKeyStatus: VerificationStatus; - autoUpdaterResult: AutoUpdaterResult | null; - isAutoUpdating: boolean; - debug: boolean; - verbose: boolean; - messages: Message[]; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - onChangeIsUpdating: (isUpdating: boolean) => void; - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; - isInputWrapped?: boolean; - isNarrow?: boolean; -}; -export function Notifications(t0) { - const $ = _c(34); - const { - apiKeyStatus, - autoUpdaterResult, - debug, - isAutoUpdating, - verbose, - messages, - onAutoUpdaterResult, - onChangeIsUpdating, - ideSelection, - mcpClients, - isInputWrapped: t1, - isNarrow: t2 - } = t0; - const isInputWrapped = t1 === undefined ? false : t1; - const isNarrow = t2 === undefined ? false : t2; - let t3; - if ($[0] !== messages) { - const messagesForTokenCount = getMessagesAfterCompactBoundary(messages); - t3 = tokenCountFromLastAPIResponse(messagesForTokenCount); - $[0] = messages; - $[1] = t3; - } else { - t3 = $[1]; - } - const tokenUsage = t3; - const mainLoopModel = useMainLoopModel(); - let t4; - if ($[2] !== mainLoopModel || $[3] !== tokenUsage) { - t4 = calculateTokenWarningState(tokenUsage, mainLoopModel); - $[2] = mainLoopModel; - $[3] = tokenUsage; - $[4] = t4; - } else { - t4 = $[4]; - } - const isShowingCompactMessage = t4.isAboveWarningThreshold; - const { - status: ideStatus - } = useIdeConnectionStatus(mcpClients); - const notifications = useAppState(_temp); - const { + apiKeyStatus: VerificationStatus + autoUpdaterResult: AutoUpdaterResult | null + isAutoUpdating: boolean + debug: boolean + verbose: boolean + messages: Message[] + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + onChangeIsUpdating: (isUpdating: boolean) => void + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] + isInputWrapped?: boolean + isNarrow?: boolean +} + +export function Notifications({ + apiKeyStatus, + autoUpdaterResult, + debug, + isAutoUpdating, + verbose, + messages, + onAutoUpdaterResult, + onChangeIsUpdating, + ideSelection, + mcpClients, + isInputWrapped = false, + isNarrow = false, +}: Props): ReactNode { + const tokenUsage = useMemo(() => { + const messagesForTokenCount = getMessagesAfterCompactBoundary(messages) + return tokenCountFromLastAPIResponse(messagesForTokenCount) + }, [messages]) + + // AppState-sourced model — same source as API requests. getMainLoopModel() + // re-reads settings.json on every call, so another session's /model write + // would leak into this session's display (anthropics/claude-code#37596). + const mainLoopModel = useMainLoopModel() + const isShowingCompactMessage = calculateTokenWarningState( + tokenUsage, + mainLoopModel, + ).isAboveWarningThreshold + const { status: ideStatus } = useIdeConnectionStatus(mcpClients) + const notifications = useAppState(s => s.notifications) + const { addNotification, removeNotification } = useNotifications() + const claudeAiLimits = useClaudeAiLimits() + + // Register env hook notifier for CwdChanged/FileChanged feedback + useEffect(() => { + setEnvHookNotifier((text, isError) => { + addNotification({ + key: 'env-hook', + text, + color: isError ? 'error' : undefined, + priority: isError ? 'medium' : 'low', + timeoutMs: isError ? 8000 : 5000, + }) + }) + return () => setEnvHookNotifier(null) + }, [addNotification]) + + // Check if we should show the IDE selection indicator + const shouldShowIdeSelection = + ideStatus === 'connected' && + (ideSelection?.filePath || + (ideSelection?.text && ideSelection.lineCount > 0)) + + // Hide update installed message when showing IDE selection + const shouldShowAutoUpdater = + !shouldShowIdeSelection || + isAutoUpdating || + autoUpdaterResult?.status !== 'success' + + // Check if we're in overage mode for UI indicators + const isInOverageMode = claudeAiLimits.isUsingOverage + const subscriptionType = getSubscriptionType() + const isTeamOrEnterprise = + subscriptionType === 'team' || subscriptionType === 'enterprise' + + // Check if the external editor hint should be shown + const editor = getExternalEditor() + const shouldShowExternalEditorHint = + isInputWrapped && + !isShowingCompactMessage && + apiKeyStatus !== 'invalid' && + apiKeyStatus !== 'missing' && + editor !== undefined + + // Show external editor hint as notification when input is wrapped + useEffect(() => { + if (shouldShowExternalEditorHint && editor) { + logEvent('tengu_external_editor_hint_shown', {}) + addNotification({ + key: 'external-editor-hint', + jsx: ( + + + + ), + priority: 'immediate', + timeoutMs: 5000, + }) + } else { + removeNotification('external-editor-hint') + } + }, [ + shouldShowExternalEditorHint, + editor, addNotification, - removeNotification - } = useNotifications(); - const claudeAiLimits = useClaudeAiLimits(); - let t5; - let t6; - if ($[5] !== addNotification) { - t5 = () => { - setEnvHookNotifier((text, isError) => { - addNotification({ - key: "env-hook", - text, - color: isError ? "error" : undefined, - priority: isError ? "medium" : "low", - timeoutMs: isError ? 8000 : 5000 - }); - }); - return _temp2; - }; - t6 = [addNotification]; - $[5] = addNotification; - $[6] = t5; - $[7] = t6; - } else { - t5 = $[6]; - t6 = $[7]; - } - useEffect(t5, t6); - const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0); - const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== "success"; - const isInOverageMode = claudeAiLimits.isUsingOverage; - let t7; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t7 = getSubscriptionType(); - $[8] = t7; - } else { - t7 = $[8]; - } - const subscriptionType = t7; - const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise"; - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = getExternalEditor(); - $[9] = t8; - } else { - t8 = $[9]; - } - const editor = t8; - const shouldShowExternalEditorHint = isInputWrapped && !isShowingCompactMessage && apiKeyStatus !== "invalid" && apiKeyStatus !== "missing" && editor !== undefined; - let t10; - let t9; - if ($[10] !== addNotification || $[11] !== removeNotification || $[12] !== shouldShowExternalEditorHint) { - t9 = () => { - if (shouldShowExternalEditorHint && editor) { - logEvent("tengu_external_editor_hint_shown", {}); - addNotification({ - key: "external-editor-hint", - jsx: , - priority: "immediate", - timeoutMs: 5000 - }); - } else { - removeNotification("external-editor-hint"); - } - }; - t10 = [shouldShowExternalEditorHint, editor, addNotification, removeNotification]; - $[10] = addNotification; - $[11] = removeNotification; - $[12] = shouldShowExternalEditorHint; - $[13] = t10; - $[14] = t9; - } else { - t10 = $[13]; - t9 = $[14]; - } - useEffect(t9, t10); - const t11 = isNarrow ? "flex-start" : "flex-end"; - const t12 = isInOverageMode ?? false; - let t13; - if ($[15] !== apiKeyStatus || $[16] !== autoUpdaterResult || $[17] !== debug || $[18] !== ideSelection || $[19] !== isAutoUpdating || $[20] !== isShowingCompactMessage || $[21] !== mainLoopModel || $[22] !== mcpClients || $[23] !== notifications || $[24] !== onAutoUpdaterResult || $[25] !== onChangeIsUpdating || $[26] !== shouldShowAutoUpdater || $[27] !== t12 || $[28] !== tokenUsage || $[29] !== verbose) { - t13 = ; - $[15] = apiKeyStatus; - $[16] = autoUpdaterResult; - $[17] = debug; - $[18] = ideSelection; - $[19] = isAutoUpdating; - $[20] = isShowingCompactMessage; - $[21] = mainLoopModel; - $[22] = mcpClients; - $[23] = notifications; - $[24] = onAutoUpdaterResult; - $[25] = onChangeIsUpdating; - $[26] = shouldShowAutoUpdater; - $[27] = t12; - $[28] = tokenUsage; - $[29] = verbose; - $[30] = t13; - } else { - t13 = $[30]; - } - let t14; - if ($[31] !== t11 || $[32] !== t13) { - t14 = {t13}; - $[31] = t11; - $[32] = t13; - $[33] = t14; - } else { - t14 = $[33]; - } - return t14; -} -function _temp2() { - return setEnvHookNotifier(null); -} -function _temp(s) { - return s.notifications; + removeNotification, + ]) + + return ( + + + + + + ) } + function NotificationContent({ ideSelection, mcpClients, @@ -229,103 +212,155 @@ function NotificationContent({ isAutoUpdating, isShowingCompactMessage, onAutoUpdaterResult, - onChangeIsUpdating + onChangeIsUpdating, }: { - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] notifications: { - current: Notification | null; - queue: Notification[]; - }; - isInOverageMode: boolean; - isTeamOrEnterprise: boolean; - apiKeyStatus: VerificationStatus; - debug: boolean; - verbose: boolean; - tokenUsage: number; - mainLoopModel: string; - shouldShowAutoUpdater: boolean; - autoUpdaterResult: AutoUpdaterResult | null; - isAutoUpdating: boolean; - isShowingCompactMessage: boolean; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - onChangeIsUpdating: (isUpdating: boolean) => void; + current: Notification | null + queue: Notification[] + } + isInOverageMode: boolean + isTeamOrEnterprise: boolean + apiKeyStatus: VerificationStatus + debug: boolean + verbose: boolean + tokenUsage: number + mainLoopModel: string + shouldShowAutoUpdater: boolean + autoUpdaterResult: AutoUpdaterResult | null + isAutoUpdating: boolean + isShowingCompactMessage: boolean + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + onChangeIsUpdating: (isUpdating: boolean) => void }): ReactNode { // Poll apiKeyHelper inflight state to show slow-helper notice. // Gated on configuration — most users never set apiKeyHelper, so the // effect is a no-op for them (no interval allocated). - const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState(null); + const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState(null) useEffect(() => { - if (!getConfiguredApiKeyHelper()) return; - const interval = setInterval((setSlow: React.Dispatch>) => { - const ms = getApiKeyHelperElapsedMs(); - const next = ms >= 10_000 ? formatDuration(ms) : null; - setSlow(prev => next === prev ? prev : next); - }, 1000, setApiKeyHelperSlow); - return () => clearInterval(interval); - }, []); + if (!getConfiguredApiKeyHelper()) return + const interval = setInterval( + (setSlow: React.Dispatch>) => { + const ms = getApiKeyHelperElapsedMs() + const next = ms >= 10_000 ? formatDuration(ms) : null + setSlow(prev => (next === prev ? prev : next)) + }, + 1000, + setApiKeyHelperSlow, + ) + return () => clearInterval(interval) + }, []) // Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook) - const voiceState = feature('VOICE_MODE') ? + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle' as const; - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceError = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_0 => s_0.voiceError) : null; - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_1 => s_1.isBriefOnly) : false; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceError = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceError) + : null + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false // When voice is actively recording or processing, replace all // notifications with just the voice indicator. - if (feature('VOICE_MODE') && voiceEnabled && (voiceState === 'recording' || voiceState === 'processing')) { - return ; + if ( + feature('VOICE_MODE') && + voiceEnabled && + (voiceState === 'recording' || voiceState === 'processing') + ) { + return } - return <> + + return ( + <> - {notifications.current && ('jsx' in notifications.current ? + {notifications.current && + ('jsx' in notifications.current ? ( + {notifications.current.jsx} - : + + ) : ( + {notifications.current.text} - )} - {isInOverageMode && !isTeamOrEnterprise && + + ))} + {isInOverageMode && !isTeamOrEnterprise && ( + Now using extra usage - } - {apiKeyHelperSlow && + + )} + {apiKeyHelperSlow && ( + apiKeyHelper is taking a while{' '} ({apiKeyHelperSlow}) - } - {(apiKeyStatus === 'invalid' || apiKeyStatus === 'missing') && + + )} + {(apiKeyStatus === 'invalid' || apiKeyStatus === 'missing') && ( + - {isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ? 'Authentication error · Try again' : 'Not logged in · Run /login'} + {isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) + ? 'Authentication error · Try again' + : 'Not logged in · Run /login'} - } - {debug && + + )} + {debug && ( + Debug mode - } - {apiKeyStatus !== 'invalid' && apiKeyStatus !== 'missing' && verbose && + + )} + {apiKeyStatus !== 'invalid' && apiKeyStatus !== 'missing' && verbose && ( + {tokenUsage} tokens - } - {!isBriefOnly && } - {shouldShowAutoUpdater && } - {feature('VOICE_MODE') ? voiceEnabled && voiceError && + + )} + {!isBriefOnly && ( + + )} + {shouldShowAutoUpdater && ( + + )} + {feature('VOICE_MODE') + ? voiceEnabled && + voiceError && ( + {voiceError} - : null} + + ) + : null} - ; + + ) } diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index bc80851fb..a678d41f6 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -1,196 +1,317 @@ -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import * as path from 'path'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { useCommandQueue } from 'src/hooks/useCommandQueue.js'; -import { type IDEAtMentioned, useIdeAtMentioned } from 'src/hooks/useIdeAtMentioned.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { type AppState, useAppState, useAppStateStore, useSetAppState } from 'src/state/AppState.js'; -import type { FooterItem } from 'src/state/AppStateStore.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { isQueuedCommandEditable, popAllEditable } from 'src/utils/messageQueueManager.js'; -import stripAnsi from 'strip-ansi'; -import { companionReservedColumns } from '../../buddy/CompanionSprite.js'; -import { findBuddyTriggerPositions, useBuddyNotification } from '../../buddy/useBuddyNotification.js'; -import { FastModePicker } from '../../commands/fast/fast.js'; -import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js'; -import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js'; -import { type Command, hasCommand } from '../../commands.js'; -import { useIsModalOverlayActive } from '../../context/overlayContext.js'; -import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js'; -import { formatImageRef, formatPastedTextRef, getPastedTextRefNumLines, parseReferences } from '../../history.js'; -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; -import { type HistoryMode, useArrowKeyHistory } from '../../hooks/useArrowKeyHistory.js'; -import { useDoublePress } from '../../hooks/useDoublePress.js'; -import { useHistorySearch } from '../../hooks/useHistorySearch.js'; -import type { IDESelection } from '../../hooks/useIdeSelection.js'; -import { useInputBuffer } from '../../hooks/useInputBuffer.js'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useTypeahead } from '../../hooks/useTypeahead.js'; -import type { BorderTextOptions } from '../../ink/render-border.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, type ClickEvent, type Key, Text, useInput } from '../../ink.js'; -import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js'; -import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'; -import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { MCPServerConnection } from '../../services/mcp/types.js'; -import { abortPromptSuggestion, logSuggestionSuppressed } from '../../services/PromptSuggestion/promptSuggestion.js'; -import { type ActiveSpeculationState, abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; -import { getActiveAgentForInput, getViewedTeammateTask } from '../../state/selectors.js'; -import { enterTeammateView, exitTeammateView, stopOrDismissAgent } from '../../state/teammateViewHelpers.js'; -import type { ToolPermissionContext } from '../../Tool.js'; -import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { isPanelAgentTask, type LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { isBackgroundTask } from '../../tasks/types.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; -import type { Message } from '../../types/message.js'; -import type { PermissionMode } from '../../types/permissions.js'; -import type { BaseTextInputProps, PromptInputMode, VimMode } from '../../types/textInputTypes.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { count } from '../../utils/array.js'; -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; -import { Cursor } from '../../utils/Cursor.js'; -import { getGlobalConfig, type PastedContent, saveGlobalConfig } from '../../utils/config.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { parseDirectMemberMessage, sendDirectMemberMessage } from '../../utils/directMemberMessage.js'; -import type { EffortLevel } from '../../utils/effort.js'; -import { env } from '../../utils/env.js'; -import { errorMessage } from '../../utils/errors.js'; -import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; -import { getFastModeUnavailableReason, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js'; -import { getImageFromClipboard, PASTE_THRESHOLD } from '../../utils/imagePaste.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import { cacheImagePath, storeImage } from '../../utils/imageStore.js'; -import { isMacosOptionChar, MACOS_OPTION_SPECIAL_CHARS } from '../../utils/keyboardShortcuts.js'; -import { logError } from '../../utils/log.js'; -import { isOpus1mMergeEnabled, modelDisplayString } from '../../utils/model/model.js'; -import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'; -import { cyclePermissionMode, getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js'; -import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'; -import { getPlatform } from '../../utils/platform.js'; -import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; -import { editPromptInEditor } from '../../utils/promptEditor.js'; -import { hasAutoModeOptIn } from '../../utils/settings/settings.js'; -import { findBtwTriggerPositions } from '../../utils/sideQuestion.js'; -import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'; -import { findSlackChannelPositions, getKnownChannelsVersion, hasSlackMcpServer, subscribeKnownChannels } from '../../utils/suggestions/slackChannelSuggestions.js'; -import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'; -import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js'; -import type { TeamSummary } from '../../utils/teamDiscovery.js'; -import { getTeammateColor } from '../../utils/teammate.js'; -import { isInProcessTeammate } from '../../utils/teammateContext.js'; -import { writeToMailbox } from '../../utils/teammateMailbox.js'; -import type { TextHighlight } from '../../utils/textHighlighting.js'; -import type { Theme } from '../../utils/theme.js'; -import { findThinkingTriggerPositions, getRainbowColor, isUltrathinkEnabled } from '../../utils/thinking.js'; -import { findTokenBudgetPositions } from '../../utils/tokenBudget.js'; -import { findUltraplanTriggerPositions, findUltrareviewTriggerPositions } from '../../utils/ultraplan/keyword.js'; -import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js'; -import { BridgeDialog } from '../BridgeDialog.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { getVisibleAgentTasks, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js'; -import { getEffortNotificationText } from '../EffortIndicator.js'; -import { getFastIconString } from '../FastIcon.js'; -import { GlobalSearchDialog } from '../GlobalSearchDialog.js'; -import { HistorySearchDialog } from '../HistorySearchDialog.js'; -import { ModelPicker } from '../ModelPicker.js'; -import { QuickOpenDialog } from '../QuickOpenDialog.js'; -import TextInput from '../TextInput.js'; -import { ThinkingToggle } from '../ThinkingToggle.js'; -import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js'; -import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; -import { TeamsDialog } from '../teams/TeamsDialog.js'; -import VimTextInput from '../VimTextInput.js'; -import { getModeFromInput, getValueFromInput } from './inputModes.js'; -import { FOOTER_TEMPORARY_STATUS_TIMEOUT, Notifications } from './Notifications.js'; -import PromptInputFooter from './PromptInputFooter.js'; -import type { SuggestionItem } from './PromptInputFooterSuggestions.js'; -import { PromptInputModeIndicator } from './PromptInputModeIndicator.js'; -import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js'; -import { PromptInputStashNotice } from './PromptInputStashNotice.js'; -import { useMaybeTruncateInput } from './useMaybeTruncateInput.js'; -import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js'; -import { useShowFastIconHint } from './useShowFastIconHint.js'; -import { useSwarmBanner } from './useSwarmBanner.js'; -import { isNonSpacePrintable, isVimModeEnabled } from './utils.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import * as path from 'path' +import * as React from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { useCommandQueue } from 'src/hooks/useCommandQueue.js' +import { + type IDEAtMentioned, + useIdeAtMentioned, +} from 'src/hooks/useIdeAtMentioned.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + type AppState, + useAppState, + useAppStateStore, + useSetAppState, +} from 'src/state/AppState.js' +import type { FooterItem } from 'src/state/AppStateStore.js' +import { getCwd } from 'src/utils/cwd.js' +import { + isQueuedCommandEditable, + popAllEditable, +} from 'src/utils/messageQueueManager.js' +import stripAnsi from 'strip-ansi' +import { companionReservedColumns } from '../../buddy/CompanionSprite.js' +import { + findBuddyTriggerPositions, + useBuddyNotification, +} from '../../buddy/useBuddyNotification.js' +import { FastModePicker } from '../../commands/fast/fast.js' +import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js' +import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js' +import { type Command, hasCommand } from '../../commands.js' +import { useIsModalOverlayActive } from '../../context/overlayContext.js' +import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js' +import { + formatImageRef, + formatPastedTextRef, + getPastedTextRefNumLines, + parseReferences, +} from '../../history.js' +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' +import { + type HistoryMode, + useArrowKeyHistory, +} from '../../hooks/useArrowKeyHistory.js' +import { useDoublePress } from '../../hooks/useDoublePress.js' +import { useHistorySearch } from '../../hooks/useHistorySearch.js' +import type { IDESelection } from '../../hooks/useIdeSelection.js' +import { useInputBuffer } from '../../hooks/useInputBuffer.js' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { useTypeahead } from '../../hooks/useTypeahead.js' +import type { BorderTextOptions } from '../../ink/render-border.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, type ClickEvent, type Key, Text, useInput } from '../../ink.js' +import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js' +import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' +import { + useKeybinding, + useKeybindings, +} from '../../keybindings/useKeybinding.js' +import type { MCPServerConnection } from '../../services/mcp/types.js' +import { + abortPromptSuggestion, + logSuggestionSuppressed, +} from '../../services/PromptSuggestion/promptSuggestion.js' +import { + type ActiveSpeculationState, + abortSpeculation, +} from '../../services/PromptSuggestion/speculation.js' +import { + getActiveAgentForInput, + getViewedTeammateTask, +} from '../../state/selectors.js' +import { + enterTeammateView, + exitTeammateView, + stopOrDismissAgent, +} from '../../state/teammateViewHelpers.js' +import type { ToolPermissionContext } from '../../Tool.js' +import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' +import { + isPanelAgentTask, + type LocalAgentTaskState, +} from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { isBackgroundTask } from '../../tasks/types.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import type { Message } from '../../types/message.js' +import type { PermissionMode } from '../../types/permissions.js' +import type { + BaseTextInputProps, + PromptInputMode, + VimMode, +} from '../../types/textInputTypes.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { count } from '../../utils/array.js' +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' +import { Cursor } from '../../utils/Cursor.js' +import { + getGlobalConfig, + type PastedContent, + saveGlobalConfig, +} from '../../utils/config.js' +import { logForDebugging } from '../../utils/debug.js' +import { + parseDirectMemberMessage, + sendDirectMemberMessage, +} from '../../utils/directMemberMessage.js' +import type { EffortLevel } from '../../utils/effort.js' +import { env } from '../../utils/env.js' +import { errorMessage } from '../../utils/errors.js' +import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' +import { + getFastModeUnavailableReason, + isFastModeAvailable, + isFastModeCooldown, + isFastModeEnabled, + isFastModeSupportedByModel, +} from '../../utils/fastMode.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js' +import { + getImageFromClipboard, + PASTE_THRESHOLD, +} from '../../utils/imagePaste.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import { cacheImagePath, storeImage } from '../../utils/imageStore.js' +import { + isMacosOptionChar, + MACOS_OPTION_SPECIAL_CHARS, +} from '../../utils/keyboardShortcuts.js' +import { logError } from '../../utils/log.js' +import { + isOpus1mMergeEnabled, + modelDisplayString, +} from '../../utils/model/model.js' +import { setAutoModeActive } from '../../utils/permissions/autoModeState.js' +import { + cyclePermissionMode, + getNextPermissionMode, +} from '../../utils/permissions/getNextPermissionMode.js' +import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js' +import { getPlatform } from '../../utils/platform.js' +import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' +import { editPromptInEditor } from '../../utils/promptEditor.js' +import { hasAutoModeOptIn } from '../../utils/settings/settings.js' +import { findBtwTriggerPositions } from '../../utils/sideQuestion.js' +import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js' +import { + findSlackChannelPositions, + getKnownChannelsVersion, + hasSlackMcpServer, + subscribeKnownChannels, +} from '../../utils/suggestions/slackChannelSuggestions.js' +import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js' +import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js' +import type { TeamSummary } from '../../utils/teamDiscovery.js' +import { getTeammateColor } from '../../utils/teammate.js' +import { isInProcessTeammate } from '../../utils/teammateContext.js' +import { writeToMailbox } from '../../utils/teammateMailbox.js' +import type { TextHighlight } from '../../utils/textHighlighting.js' +import type { Theme } from '../../utils/theme.js' +import { + findThinkingTriggerPositions, + getRainbowColor, + isUltrathinkEnabled, +} from '../../utils/thinking.js' +import { findTokenBudgetPositions } from '../../utils/tokenBudget.js' +import { + findUltraplanTriggerPositions, + findUltrareviewTriggerPositions, +} from '../../utils/ultraplan/keyword.js' +import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js' +import { BridgeDialog } from '../BridgeDialog.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { + getVisibleAgentTasks, + useCoordinatorTaskCount, +} from '../CoordinatorAgentStatus.js' +import { getEffortNotificationText } from '../EffortIndicator.js' +import { getFastIconString } from '../FastIcon.js' +import { GlobalSearchDialog } from '../GlobalSearchDialog.js' +import { HistorySearchDialog } from '../HistorySearchDialog.js' +import { ModelPicker } from '../ModelPicker.js' +import { QuickOpenDialog } from '../QuickOpenDialog.js' +import TextInput from '../TextInput.js' +import { ThinkingToggle } from '../ThinkingToggle.js' +import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js' +import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js' +import { TeamsDialog } from '../teams/TeamsDialog.js' +import VimTextInput from '../VimTextInput.js' +import { getModeFromInput, getValueFromInput } from './inputModes.js' +import { + FOOTER_TEMPORARY_STATUS_TIMEOUT, + Notifications, +} from './Notifications.js' +import PromptInputFooter from './PromptInputFooter.js' +import type { SuggestionItem } from './PromptInputFooterSuggestions.js' +import { PromptInputModeIndicator } from './PromptInputModeIndicator.js' +import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js' +import { PromptInputStashNotice } from './PromptInputStashNotice.js' +import { useMaybeTruncateInput } from './useMaybeTruncateInput.js' +import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js' +import { useShowFastIconHint } from './useShowFastIconHint.js' +import { useSwarmBanner } from './useSwarmBanner.js' +import { isNonSpacePrintable, isVimModeEnabled } from './utils.js' + type Props = { - debug: boolean; - ideSelection: IDESelection | undefined; - toolPermissionContext: ToolPermissionContext; - setToolPermissionContext: (ctx: ToolPermissionContext) => void; - apiKeyStatus: VerificationStatus; - commands: Command[]; - agents: AgentDefinition[]; - isLoading: boolean; - verbose: boolean; - messages: Message[]; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - input: string; - onInputChange: (value: string) => void; - mode: PromptInputMode; - onModeChange: (mode: PromptInputMode) => void; - stashedPrompt: { - text: string; - cursorOffset: number; - pastedContents: Record; - } | undefined; - setStashedPrompt: (value: { - text: string; - cursorOffset: number; - pastedContents: Record; - } | undefined) => void; - submitCount: number; - onShowMessageSelector: () => void; + debug: boolean + ideSelection: IDESelection | undefined + toolPermissionContext: ToolPermissionContext + setToolPermissionContext: (ctx: ToolPermissionContext) => void + apiKeyStatus: VerificationStatus + commands: Command[] + agents: AgentDefinition[] + isLoading: boolean + verbose: boolean + messages: Message[] + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + input: string + onInputChange: (value: string) => void + mode: PromptInputMode + onModeChange: (mode: PromptInputMode) => void + stashedPrompt: + | { + text: string + cursorOffset: number + pastedContents: Record + } + | undefined + setStashedPrompt: ( + value: + | { + text: string + cursorOffset: number + pastedContents: Record + } + | undefined, + ) => void + submitCount: number + onShowMessageSelector: () => void /** Fullscreen message actions: shift+↑ enters cursor. */ - onMessageActionsEnter?: () => void; - mcpClients: MCPServerConnection[]; - pastedContents: Record; - setPastedContents: React.Dispatch>>; - vimMode: VimMode; - setVimMode: (mode: VimMode) => void; - showBashesDialog: string | boolean; - setShowBashesDialog: (show: string | boolean) => void; - onExit: () => void; - getToolUseContext: (messages: Message[], newMessages: Message[], abortController: AbortController, mainLoopModel: string) => ProcessUserInputContext; - onSubmit: (input: string, helpers: PromptInputHelpers, speculationAccept?: { - state: ActiveSpeculationState; - speculationSessionTimeSavedMs: number; - setAppState: (f: (prev: AppState) => AppState) => void; - }, options?: { - fromKeybinding?: boolean; - }) => Promise; - onAgentSubmit?: (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => Promise; - isSearchingHistory: boolean; - setIsSearchingHistory: (isSearching: boolean) => void; - onDismissSideQuestion?: () => void; - isSideQuestionVisible?: boolean; - helpOpen: boolean; - setHelpOpen: React.Dispatch>; - hasSuppressedDialogs?: boolean; - isLocalJSXCommandActive?: boolean; + onMessageActionsEnter?: () => void + mcpClients: MCPServerConnection[] + pastedContents: Record + setPastedContents: React.Dispatch< + React.SetStateAction> + > + vimMode: VimMode + setVimMode: (mode: VimMode) => void + showBashesDialog: string | boolean + setShowBashesDialog: (show: string | boolean) => void + onExit: () => void + getToolUseContext: ( + messages: Message[], + newMessages: Message[], + abortController: AbortController, + mainLoopModel: string, + ) => ProcessUserInputContext + onSubmit: ( + input: string, + helpers: PromptInputHelpers, + speculationAccept?: { + state: ActiveSpeculationState + speculationSessionTimeSavedMs: number + setAppState: (f: (prev: AppState) => AppState) => void + }, + options?: { fromKeybinding?: boolean }, + ) => Promise + onAgentSubmit?: ( + input: string, + task: InProcessTeammateTaskState | LocalAgentTaskState, + helpers: PromptInputHelpers, + ) => Promise + isSearchingHistory: boolean + setIsSearchingHistory: (isSearching: boolean) => void + onDismissSideQuestion?: () => void + isSideQuestionVisible?: boolean + helpOpen: boolean + setHelpOpen: React.Dispatch> + hasSuppressedDialogs?: boolean + isLocalJSXCommandActive?: boolean insertTextRef?: React.MutableRefObject<{ - insert: (text: string) => void; - setInputWithCursor: (value: string, cursor: number) => void; - cursorOffset: number; - } | null>; - voiceInterimRange?: { - start: number; - end: number; - } | null; -}; + insert: (text: string) => void + setInputWithCursor: (value: string, cursor: number) => void + cursorOffset: number + } | null> + voiceInterimRange?: { start: number; end: number } | null +} // Bottom slot has maxHeight="50%"; reserve lines for footer, border, status. -const PROMPT_FOOTER_LINES = 5; -const MIN_INPUT_VIEWPORT_LINES = 3; +const PROMPT_FOOTER_LINES = 5 +const MIN_INPUT_VIEWPORT_LINES = 3 + function PromptInput({ debug, ideSelection, @@ -233,276 +354,359 @@ function PromptInput({ hasSuppressedDialogs, isLocalJSXCommandActive = false, insertTextRef, - voiceInterimRange + voiceInterimRange, }: Props): React.ReactNode { - const mainLoopModel = useMainLoopModel(); + const mainLoopModel = useMainLoopModel() // A local-jsx command (e.g., /mcp while agent is running) renders a full- // screen dialog on top of PromptInput via the immediate-command path with // shouldHidePromptInput: false. Those dialogs don't register in the overlay // system, so treat them as a modal overlay here to stop navigation keys from // leaking into TextInput/footer handlers and stacking a second dialog. - const isModalOverlayActive = useIsModalOverlayActive() || isLocalJSXCommandActive; - const [isAutoUpdating, setIsAutoUpdating] = useState(false); + const isModalOverlayActive = + useIsModalOverlayActive() || isLocalJSXCommandActive + const [isAutoUpdating, setIsAutoUpdating] = useState(false) const [exitMessage, setExitMessage] = useState<{ - show: boolean; - key?: string; - }>({ - show: false - }); - const [cursorOffset, setCursorOffset] = useState(input.length); + show: boolean + key?: string + }>({ show: false }) + const [cursorOffset, setCursorOffset] = useState(input.length) // Track the last input value set via internal handlers so we can detect // external input changes (e.g. speech-to-text injection) and move cursor to end. - const lastInternalInputRef = React.useRef(input); + const lastInternalInputRef = React.useRef(input) if (input !== lastInternalInputRef.current) { // Input changed externally (not through any internal handler) — move cursor to end - setCursorOffset(input.length); - lastInternalInputRef.current = input; + setCursorOffset(input.length) + lastInternalInputRef.current = input } // Wrap onInputChange to track internal changes before they trigger re-render - const trackAndSetInput = React.useCallback((value: string) => { - lastInternalInputRef.current = value; - onInputChange(value); - }, [onInputChange]); + const trackAndSetInput = React.useCallback( + (value: string) => { + lastInternalInputRef.current = value + onInputChange(value) + }, + [onInputChange], + ) // Expose an insertText function so callers (e.g. STT) can splice text at the // current cursor position instead of replacing the entire input. if (insertTextRef) { insertTextRef.current = { cursorOffset, insert: (text: string) => { - const needsSpace = cursorOffset === input.length && input.length > 0 && !/\s$/.test(input); - const insertText = needsSpace ? ' ' + text : text; - const newValue = input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset); - lastInternalInputRef.current = newValue; - onInputChange(newValue); - setCursorOffset(cursorOffset + insertText.length); + const needsSpace = + cursorOffset === input.length && + input.length > 0 && + !/\s$/.test(input) + const insertText = needsSpace ? ' ' + text : text + const newValue = + input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset) + lastInternalInputRef.current = newValue + onInputChange(newValue) + setCursorOffset(cursorOffset + insertText.length) }, setInputWithCursor: (value: string, cursor: number) => { - lastInternalInputRef.current = value; - onInputChange(value); - setCursorOffset(cursor); - } - }; + lastInternalInputRef.current = value + onInputChange(value) + setCursorOffset(cursor) + }, + } } - const store = useAppStateStore(); - const setAppState = useSetAppState(); - const tasks = useAppState(s => s.tasks); - const replBridgeConnected = useAppState(s => s.replBridgeConnected); - const replBridgeExplicit = useAppState(s => s.replBridgeExplicit); - const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting); + const store = useAppStateStore() + const setAppState = useSetAppState() + const tasks = useAppState(s => s.tasks) + const replBridgeConnected = useAppState(s => s.replBridgeConnected) + const replBridgeExplicit = useAppState(s => s.replBridgeExplicit) + const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting) // Must match BridgeStatusIndicator's render condition (PromptInputFooter.tsx) — // the pill returns null for implicit-and-not-reconnecting, so nav must too, // otherwise bridge becomes an invisible selection stop. - const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting); + const bridgeFooterVisible = + replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting) // Tmux pill (ant-only) — visible when there's an active tungsten session - const hasTungstenSession = useAppState(s => (process.env.USER_TYPE) === 'ant' && s.tungstenActiveSession !== undefined); - const tmuxFooterVisible = (process.env.USER_TYPE) === 'ant' && hasTungstenSession; + const hasTungstenSession = useAppState( + s => + process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined, + ) + const tmuxFooterVisible = + process.env.USER_TYPE === 'ant' && hasTungstenSession // WebBrowser pill — visible when a browser is open - const bagelFooterVisible = useAppState(s => false); - const teamContext = useAppState(s => s.teamContext); - const queuedCommands = useCommandQueue(); - const promptSuggestionState = useAppState(s => s.promptSuggestion); - const speculation = useAppState(s => s.speculation); - const speculationSessionTimeSavedMs = useAppState(s => s.speculationSessionTimeSavedMs); - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); - const viewSelectionMode = useAppState(s => s.viewSelectionMode); - const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates'; - const { - companion: _companion, - companionMuted - } = feature('BUDDY') ? getGlobalConfig() : { - companion: undefined, - companionMuted: undefined - }; - const companionFooterVisible = !!_companion && !companionMuted; + const bagelFooterVisible = useAppState(s => + false, + ) + const teamContext = useAppState(s => s.teamContext) + const queuedCommands = useCommandQueue() + const promptSuggestionState = useAppState(s => s.promptSuggestion) + const speculation = useAppState(s => s.speculation) + const speculationSessionTimeSavedMs = useAppState( + s => s.speculationSessionTimeSavedMs, + ) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const viewSelectionMode = useAppState(s => s.viewSelectionMode) + const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' + const { companion: _companion, companionMuted } = feature('BUDDY') + ? getGlobalConfig() + : { companion: undefined, companionMuted: undefined } + const companionFooterVisible = !!_companion && !companionMuted // Brief mode: BriefSpinner/BriefIdleStatus own the 2-row footprint above // the input. Dropping marginTop here lets the spinner sit flush against // the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx, // REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has // its own marginTop, so the gap stays even without ours. - const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false; - const mainLoopModel_ = useAppState(s => s.mainLoopModel); - const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession); - const thinkingEnabled = useAppState(s => s.thinkingEnabled); - const isFastMode = useAppState(s => isFastModeEnabled() ? s.fastMode : false); - const effortValue = useAppState(s => s.effortValue); - const viewedTeammate = getViewedTeammateTask(store.getState()); - const viewingAgentName = viewedTeammate?.identity.agentName; + const briefOwnsGap = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) && !viewingAgentTaskId + : false + const mainLoopModel_ = useAppState(s => s.mainLoopModel) + const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession) + const thinkingEnabled = useAppState(s => s.thinkingEnabled) + const isFastMode = useAppState(s => + isFastModeEnabled() ? s.fastMode : false, + ) + const effortValue = useAppState(s => s.effortValue) + const viewedTeammate = getViewedTeammateTask(store.getState()) + const viewingAgentName = viewedTeammate?.identity.agentName // identity.color is typed as `string | undefined` (not AgentColorName) because // teammate identity comes from file-based config. Validate before casting to // ensure we only use valid color names (falls back to cyan if invalid). - const viewingAgentColor = viewedTeammate?.identity.color && AGENT_COLORS.includes(viewedTeammate.identity.color as AgentColorName) ? viewedTeammate.identity.color as AgentColorName : undefined; + const viewingAgentColor = + viewedTeammate?.identity.color && + AGENT_COLORS.includes(viewedTeammate.identity.color as AgentColorName) + ? (viewedTeammate.identity.color as AgentColorName) + : undefined // In-process teammates sorted alphabetically for footer team selector - const inProcessTeammates = useMemo(() => getRunningTeammatesSorted(tasks), [tasks]); + const inProcessTeammates = useMemo( + () => getRunningTeammatesSorted(tasks), + [tasks], + ) // Team mode: all background tasks are in-process teammates - const isTeammateMode = inProcessTeammates.length > 0 || viewedTeammate !== undefined; + const isTeammateMode = + inProcessTeammates.length > 0 || viewedTeammate !== undefined // When viewing a teammate, show their permission mode in the footer instead of the leader's const effectiveToolPermissionContext = useMemo((): ToolPermissionContext => { if (viewedTeammate) { return { ...toolPermissionContext, - mode: viewedTeammate.permissionMode - }; + mode: viewedTeammate.permissionMode, + } } - return toolPermissionContext; - }, [viewedTeammate, toolPermissionContext]); - const { - historyQuery, - setHistoryQuery, - historyMatch, - historyFailedMatch - } = useHistorySearch(entry => { - setPastedContents(entry.pastedContents); - void onSubmit(entry.display); - }, input, trackAndSetInput, setCursorOffset, cursorOffset, onModeChange, mode, isSearchingHistory, setIsSearchingHistory, setPastedContents, pastedContents); + return toolPermissionContext + }, [viewedTeammate, toolPermissionContext]) + const { historyQuery, setHistoryQuery, historyMatch, historyFailedMatch } = + useHistorySearch( + entry => { + setPastedContents(entry.pastedContents) + void onSubmit(entry.display) + }, + input, + trackAndSetInput, + setCursorOffset, + cursorOffset, + onModeChange, + mode, + isSearchingHistory, + setIsSearchingHistory, + setPastedContents, + pastedContents, + ) // Counter for paste IDs (shared between images and text). // Compute initial value once from existing messages (for --continue/--resume). // useRef(fn()) evaluates fn() on every render and discards the result after // mount — getInitialPasteId walks all messages + regex-scans text blocks, // so guard with a lazy-init pattern to run it exactly once. - const nextPasteIdRef = useRef(-1); + const nextPasteIdRef = useRef(-1) if (nextPasteIdRef.current === -1) { - nextPasteIdRef.current = getInitialPasteId(messages); + nextPasteIdRef.current = getInitialPasteId(messages) } // Armed by onImagePaste; if the very next keystroke is a non-space // printable, inputFilter prepends a space before it. Any other input // (arrow, escape, backspace, paste, space) disarms without inserting. - const pendingSpaceAfterPillRef = useRef(false); - const [showTeamsDialog, setShowTeamsDialog] = useState(false); - const [showBridgeDialog, setShowBridgeDialog] = useState(false); - const [teammateFooterIndex, setTeammateFooterIndex] = useState(0); + const pendingSpaceAfterPillRef = useRef(false) + + const [showTeamsDialog, setShowTeamsDialog] = useState(false) + const [showBridgeDialog, setShowBridgeDialog] = useState(false) + const [teammateFooterIndex, setTeammateFooterIndex] = useState(0) // -1 sentinel: tasks pill is selected but no specific agent row is selected yet. // First ↓ selects the pill, second ↓ moves to row 0. Prevents double-select // of pill + row when both bg tasks (pill) and forked agents (rows) are visible. - const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); - const setCoordinatorTaskIndex = useCallback((v: number | ((prev: number) => number)) => setAppState(prev => { - const next = typeof v === 'function' ? v(prev.coordinatorTaskIndex) : v; - if (next === prev.coordinatorTaskIndex) return prev; - return { - ...prev, - coordinatorTaskIndex: next - }; - }), [setAppState]); - const coordinatorTaskCount = useCoordinatorTaskCount(); + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) + const setCoordinatorTaskIndex = useCallback( + (v: number | ((prev: number) => number)) => + setAppState(prev => { + const next = typeof v === 'function' ? v(prev.coordinatorTaskIndex) : v + if (next === prev.coordinatorTaskIndex) return prev + return { ...prev, coordinatorTaskIndex: next } + }), + [setAppState], + ) + const coordinatorTaskCount = useCoordinatorTaskCount() // The pill (BackgroundTaskStatus) only renders when non-local_agent bg tasks // exist. When only local_agent tasks are running (coordinator/fork mode), the // pill is absent, so the -1 sentinel would leave nothing visually selected. // In that case, skip -1 and treat 0 as the minimum selectable index. - const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]); - const minCoordinatorIndex = hasBgTaskPill ? -1 : 0; + const hasBgTaskPill = useMemo( + () => + Object.values(tasks).some( + t => + isBackgroundTask(t) && + !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + ), + [tasks], + ) + const minCoordinatorIndex = hasBgTaskPill ? -1 : 0 // Clamp index when tasks complete and the list shrinks beneath the cursor useEffect(() => { if (coordinatorTaskIndex >= coordinatorTaskCount) { - setCoordinatorTaskIndex(Math.max(minCoordinatorIndex, coordinatorTaskCount - 1)); + setCoordinatorTaskIndex( + Math.max(minCoordinatorIndex, coordinatorTaskCount - 1), + ) } else if (coordinatorTaskIndex < minCoordinatorIndex) { - setCoordinatorTaskIndex(minCoordinatorIndex); + setCoordinatorTaskIndex(minCoordinatorIndex) } - }, [coordinatorTaskCount, coordinatorTaskIndex, minCoordinatorIndex]); - const [isPasting, setIsPasting] = useState(false); - const [isExternalEditorActive, setIsExternalEditorActive] = useState(false); - const [showModelPicker, setShowModelPicker] = useState(false); - const [showQuickOpen, setShowQuickOpen] = useState(false); - const [showGlobalSearch, setShowGlobalSearch] = useState(false); - const [showHistoryPicker, setShowHistoryPicker] = useState(false); - const [showFastModePicker, setShowFastModePicker] = useState(false); - const [showThinkingToggle, setShowThinkingToggle] = useState(false); - const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false); - const [previousModeBeforeAuto, setPreviousModeBeforeAuto] = useState(null); - const autoModeOptInTimeoutRef = useRef(null); + }, [coordinatorTaskCount, coordinatorTaskIndex, minCoordinatorIndex]) + const [isPasting, setIsPasting] = useState(false) + const [isExternalEditorActive, setIsExternalEditorActive] = useState(false) + const [showModelPicker, setShowModelPicker] = useState(false) + const [showQuickOpen, setShowQuickOpen] = useState(false) + const [showGlobalSearch, setShowGlobalSearch] = useState(false) + const [showHistoryPicker, setShowHistoryPicker] = useState(false) + const [showFastModePicker, setShowFastModePicker] = useState(false) + const [showThinkingToggle, setShowThinkingToggle] = useState(false) + const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false) + const [previousModeBeforeAuto, setPreviousModeBeforeAuto] = + useState(null) + const autoModeOptInTimeoutRef = useRef(null) // Check if cursor is on the first line of input const isCursorOnFirstLine = useMemo(() => { - const firstNewlineIndex = input.indexOf('\n'); + const firstNewlineIndex = input.indexOf('\n') if (firstNewlineIndex === -1) { - return true; // No newlines, cursor is always on first line + return true // No newlines, cursor is always on first line } - return cursorOffset <= firstNewlineIndex; - }, [input, cursorOffset]); + return cursorOffset <= firstNewlineIndex + }, [input, cursorOffset]) + const isCursorOnLastLine = useMemo(() => { - const lastNewlineIndex = input.lastIndexOf('\n'); + const lastNewlineIndex = input.lastIndexOf('\n') if (lastNewlineIndex === -1) { - return true; // No newlines, cursor is always on last line + return true // No newlines, cursor is always on last line } - return cursorOffset > lastNewlineIndex; - }, [input, cursorOffset]); + return cursorOffset > lastNewlineIndex + }, [input, cursorOffset]) // Derive team info from teamContext (no filesystem I/O needed) // A session can only lead one team at a time const cachedTeams: TeamSummary[] = useMemo(() => { - if (!isAgentSwarmsEnabled()) return []; + if (!isAgentSwarmsEnabled()) return [] // In-process mode uses Shift+Down/Up navigation instead of footer menu - if (isInProcessEnabled()) return []; + if (isInProcessEnabled()) return [] if (!teamContext) { - return []; + return [] } - const teammateCount = count(Object.values(teamContext.teammates), t => t.name !== 'team-lead'); - return [{ - name: teamContext.teamName, - memberCount: teammateCount, - runningCount: 0, - idleCount: 0 - }]; - }, [teamContext]); + const teammateCount = count( + Object.values(teamContext.teammates), + t => t.name !== 'team-lead', + ) + return [ + { + name: teamContext.teamName, + memberCount: teammateCount, + runningCount: 0, + idleCount: 0, + }, + ] + }, [teamContext]) // ─── Footer pill navigation ───────────────────────────────────────────── // Which pills render below the input box. Order here IS the nav order // (down/right = forward, up/left = back). Selection lives in AppState so // pills rendered outside PromptInput (CompanionSprite) can read focus. - const runningTaskCount = useMemo(() => count(Object.values(tasks), t => t.status === 'running'), [tasks]); + const runningTaskCount = useMemo( + () => count(Object.values(tasks), t => t.status === 'running'), + [tasks], + ) // Panel shows retained-completed agents too (getVisibleAgentTasks), so the // pill must stay navigable whenever the panel has rows — not just when // something is running. - const tasksFooterVisible = (runningTaskCount > 0 || (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree); - const teamsFooterVisible = cachedTeams.length > 0; - const footerItems = useMemo(() => [tasksFooterVisible && 'tasks', tmuxFooterVisible && 'tmux', bagelFooterVisible && 'bagel', teamsFooterVisible && 'teams', bridgeFooterVisible && 'bridge', companionFooterVisible && 'companion'].filter(Boolean) as FooterItem[], [tasksFooterVisible, tmuxFooterVisible, bagelFooterVisible, teamsFooterVisible, bridgeFooterVisible, companionFooterVisible]); + const tasksFooterVisible = + (runningTaskCount > 0 || + (process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) && + !shouldHideTasksFooter(tasks, showSpinnerTree) + const teamsFooterVisible = cachedTeams.length > 0 + + const footerItems = useMemo( + () => + [ + tasksFooterVisible && 'tasks', + tmuxFooterVisible && 'tmux', + bagelFooterVisible && 'bagel', + teamsFooterVisible && 'teams', + bridgeFooterVisible && 'bridge', + companionFooterVisible && 'companion', + ].filter(Boolean) as FooterItem[], + [ + tasksFooterVisible, + tmuxFooterVisible, + bagelFooterVisible, + teamsFooterVisible, + bridgeFooterVisible, + companionFooterVisible, + ], + ) // Effective selection: null if the selected pill stopped rendering (bridge // disconnected, task finished). The derivation makes the UI correct // immediately; the useEffect below clears the raw state so it doesn't // resurrect when the same pill reappears (new task starts → focus stolen). - const rawFooterSelection = useAppState(s => s.footerSelection); - const footerItemSelected = rawFooterSelection && footerItems.includes(rawFooterSelection) ? rawFooterSelection : null; + const rawFooterSelection = useAppState(s => s.footerSelection) + const footerItemSelected = + rawFooterSelection && footerItems.includes(rawFooterSelection) + ? rawFooterSelection + : null + useEffect(() => { if (rawFooterSelection && !footerItemSelected) { - setAppState(prev => prev.footerSelection === null ? prev : { - ...prev, - footerSelection: null - }); + setAppState(prev => + prev.footerSelection === null + ? prev + : { ...prev, footerSelection: null }, + ) } - }, [rawFooterSelection, footerItemSelected, setAppState]); - const tasksSelected = footerItemSelected === 'tasks'; - const tmuxSelected = footerItemSelected === 'tmux'; - const bagelSelected = footerItemSelected === 'bagel'; - const teamsSelected = footerItemSelected === 'teams'; - const bridgeSelected = footerItemSelected === 'bridge'; + }, [rawFooterSelection, footerItemSelected, setAppState]) + + const tasksSelected = footerItemSelected === 'tasks' + const tmuxSelected = footerItemSelected === 'tmux' + const bagelSelected = footerItemSelected === 'bagel' + const teamsSelected = footerItemSelected === 'teams' + const bridgeSelected = footerItemSelected === 'bridge' + function selectFooterItem(item: FooterItem | null): void { - setAppState(prev => prev.footerSelection === item ? prev : { - ...prev, - footerSelection: item - }); + setAppState(prev => + prev.footerSelection === item ? prev : { ...prev, footerSelection: item }, + ) if (item === 'tasks') { - setTeammateFooterIndex(0); - setCoordinatorTaskIndex(minCoordinatorIndex); + setTeammateFooterIndex(0) + setCoordinatorTaskIndex(minCoordinatorIndex) } } // delta: +1 = down/right, -1 = up/left. Returns true if nav happened // (including deselecting at the start), false if at a boundary. function navigateFooter(delta: 1 | -1, exitAtStart = false): boolean { - const idx = footerItemSelected ? footerItems.indexOf(footerItemSelected) : -1; - const next = footerItems[idx + delta]; + const idx = footerItemSelected + ? footerItems.indexOf(footerItemSelected) + : -1 + const next = footerItems[idx + delta] if (next) { - selectFooterItem(next); - return true; + selectFooterItem(next) + return true } if (delta < 0 && exitAtStart) { - selectFooterItem(null); - return true; + selectFooterItem(null) + return true } - return false; + return false } // Prompt suggestion hook - reads suggestions generated by forked agent in query loop @@ -510,96 +714,159 @@ function PromptInput({ suggestion: promptSuggestion, markAccepted, logOutcomeAtSubmission, - markShown + markShown, } = usePromptSuggestion({ inputValue: input, - isAssistantResponding: isLoading - }); - const displayedValue = useMemo(() => isSearchingHistory && historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input, [isSearchingHistory, historyMatch, input]); - const thinkTriggers = useMemo(() => findThinkingTriggerPositions(displayedValue), [displayedValue]); - const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl); - const ultraplanLaunching = useAppState(s => s.ultraplanLaunching); - const ultraplanTriggers = useMemo(() => feature('ULTRAPLAN') && !ultraplanSessionUrl && !ultraplanLaunching ? findUltraplanTriggerPositions(displayedValue) : [], [displayedValue, ultraplanSessionUrl, ultraplanLaunching]); - const ultrareviewTriggers = useMemo(() => isUltrareviewEnabled() ? findUltrareviewTriggerPositions(displayedValue) : [], [displayedValue]); - const btwTriggers = useMemo(() => findBtwTriggerPositions(displayedValue), [displayedValue]); - const buddyTriggers = useMemo(() => findBuddyTriggerPositions(displayedValue), [displayedValue]); + isAssistantResponding: isLoading, + }) + + const displayedValue = useMemo( + () => + isSearchingHistory && historyMatch + ? getValueFromInput( + typeof historyMatch === 'string' + ? historyMatch + : historyMatch.display, + ) + : input, + [isSearchingHistory, historyMatch, input], + ) + + const thinkTriggers = useMemo( + () => findThinkingTriggerPositions(displayedValue), + [displayedValue], + ) + + const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl) + const ultraplanLaunching = useAppState(s => s.ultraplanLaunching) + const ultraplanTriggers = useMemo( + () => + feature('ULTRAPLAN') && !ultraplanSessionUrl && !ultraplanLaunching + ? findUltraplanTriggerPositions(displayedValue) + : [], + [displayedValue, ultraplanSessionUrl, ultraplanLaunching], + ) + + const ultrareviewTriggers = useMemo( + () => + isUltrareviewEnabled() + ? findUltrareviewTriggerPositions(displayedValue) + : [], + [displayedValue], + ) + + const btwTriggers = useMemo( + () => findBtwTriggerPositions(displayedValue), + [displayedValue], + ) + + const buddyTriggers = useMemo( + () => findBuddyTriggerPositions(displayedValue), + [displayedValue], + ) + const slashCommandTriggers = useMemo(() => { - const positions = findSlashCommandPositions(displayedValue); + const positions = findSlashCommandPositions(displayedValue) // Only highlight valid commands return positions.filter(pos => { - const commandName = displayedValue.slice(pos.start + 1, pos.end); // +1 to skip "/" - return hasCommand(commandName, commands); - }); - }, [displayedValue, commands]); - const tokenBudgetTriggers = useMemo(() => feature('TOKEN_BUDGET') ? findTokenBudgetPositions(displayedValue) : [], [displayedValue]); - const knownChannelsVersion = useSyncExternalStore(subscribeKnownChannels, getKnownChannelsVersion); - const slackChannelTriggers = useMemo(() => hasSlackMcpServer(store.getState().mcp.clients) ? findSlackChannelPositions(displayedValue) : [], - // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref - [displayedValue, knownChannelsVersion]); + const commandName = displayedValue.slice(pos.start + 1, pos.end) // +1 to skip "/" + return hasCommand(commandName, commands) + }) + }, [displayedValue, commands]) + + const tokenBudgetTriggers = useMemo( + () => + feature('TOKEN_BUDGET') ? findTokenBudgetPositions(displayedValue) : [], + [displayedValue], + ) + + const knownChannelsVersion = useSyncExternalStore( + subscribeKnownChannels, + getKnownChannelsVersion, + ) + const slackChannelTriggers = useMemo( + () => + hasSlackMcpServer(store.getState().mcp.clients) + ? findSlackChannelPositions(displayedValue) + : [], + // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref + [displayedValue, knownChannelsVersion], + ) // Find @name mentions and highlight with team member's color const memberMentionHighlights = useMemo((): Array<{ - start: number; - end: number; - themeColor: keyof Theme; + start: number + end: number + themeColor: keyof Theme }> => { - if (!isAgentSwarmsEnabled()) return []; - if (!teamContext?.teammates) return []; + if (!isAgentSwarmsEnabled()) return [] + if (!teamContext?.teammates) return [] + const highlights: Array<{ - start: number; - end: number; - themeColor: keyof Theme; - }> = []; - const members = teamContext.teammates; - if (!members) return highlights; + start: number + end: number + themeColor: keyof Theme + }> = [] + const members = teamContext.teammates + if (!members) return highlights // Find all @name patterns in the input - const regex = /(^|\s)@([\w-]+)/g; - const memberValues = Object.values(members); - let match; + const regex = /(^|\s)@([\w-]+)/g + const memberValues = Object.values(members) + let match while ((match = regex.exec(displayedValue)) !== null) { - const leadingSpace = match[1] ?? ''; - const nameStart = match.index + leadingSpace.length; - const fullMatch = match[0].trimStart(); - const name = match[2]; + const leadingSpace = match[1] ?? '' + const nameStart = match.index + leadingSpace.length + const fullMatch = match[0].trimStart() + const name = match[2] // Check if this name matches a team member - const member = memberValues.find(t => t.name === name); + const member = memberValues.find(t => t.name === name) if (member?.color) { - const themeColor = AGENT_COLOR_TO_THEME_COLOR[member.color as AgentColorName]; + const themeColor = + AGENT_COLOR_TO_THEME_COLOR[member.color as AgentColorName] if (themeColor) { highlights.push({ start: nameStart, end: nameStart + fullMatch.length, - themeColor - }); + themeColor, + }) } } } - return highlights; - }, [displayedValue, teamContext]); - const imageRefPositions = useMemo(() => parseReferences(displayedValue).filter(r => r.match.startsWith('[Image')).map(r => ({ - start: r.index, - end: r.index + r.match.length - })), [displayedValue]); + return highlights + }, [displayedValue, teamContext]) + + const imageRefPositions = useMemo( + () => + parseReferences(displayedValue) + .filter(r => r.match.startsWith('[Image')) + .map(r => ({ start: r.index, end: r.index + r.match.length })), + [displayedValue], + ) // chip.start is the "selected" state: the inverted chip IS the cursor. // chip.end stays a normal position so you can park the cursor right after // `]` like any other character. - const cursorAtImageChip = imageRefPositions.some(r => r.start === cursorOffset); + const cursorAtImageChip = imageRefPositions.some( + r => r.start === cursorOffset, + ) // up/down movement or a fullscreen click can land the cursor strictly // inside a chip; snap to the nearer boundary so it's never editable // char-by-char. useEffect(() => { - const inside = imageRefPositions.find(r => cursorOffset > r.start && cursorOffset < r.end); + const inside = imageRefPositions.find( + r => cursorOffset > r.start && cursorOffset < r.end, + ) if (inside) { - const mid = (inside.start + inside.end) / 2; - setCursorOffset(cursorOffset < mid ? inside.start : inside.end); + const mid = (inside.start + inside.end) / 2 + setCursorOffset(cursorOffset < mid ? inside.start : inside.end) } - }, [cursorOffset, imageRefPositions, setCursorOffset]); + }, [cursorOffset, imageRefPositions, setCursorOffset]) + const combinedHighlights = useMemo((): TextHighlight[] => { - const highlights: TextHighlight[] = []; + const highlights: TextHighlight[] = [] // Invert the [Image #N] chip when the cursor is at chip.start (the // "selected" state) so backspace-to-delete is visually obvious. @@ -610,17 +877,18 @@ function PromptInput({ end: ref.end, color: undefined, inverse: true, - priority: 8 - }); + priority: 8, + }) } } + if (isSearchingHistory && historyMatch && !historyFailedMatch) { highlights.push({ start: cursorOffset, end: cursorOffset + historyQuery.length, color: 'warning', - priority: 20 - }); + priority: 20, + }) } // Add "btw" highlighting (solid yellow) @@ -629,8 +897,8 @@ function PromptInput({ start: trigger.start, end: trigger.end, color: 'warning', - priority: 15 - }); + priority: 15, + }) } // Add /command highlighting (blue) @@ -639,8 +907,8 @@ function PromptInput({ start: trigger.start, end: trigger.end, color: 'suggestion', - priority: 5 - }); + priority: 5, + }) } // Add token budget highlighting (blue) @@ -649,16 +917,17 @@ function PromptInput({ start: trigger.start, end: trigger.end, color: 'suggestion', - priority: 5 - }); + priority: 5, + }) } + for (const trigger of slackChannelTriggers) { highlights.push({ start: trigger.start, end: trigger.end, color: 'suggestion', - priority: 5 - }); + priority: 5, + }) } // Add @name highlighting with team member's color @@ -667,8 +936,8 @@ function PromptInput({ start: mention.start, end: mention.end, color: mention.themeColor, - priority: 5 - }); + priority: 5, + }) } // Dim interim voice dictation text @@ -678,8 +947,8 @@ function PromptInput({ end: voiceInterimRange.end, color: undefined, dimColor: true, - priority: 1 - }); + priority: 1, + }) } // Rainbow highlighting for ultrathink keyword (per-character cycling colors) @@ -691,8 +960,8 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } } @@ -706,8 +975,8 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } } @@ -720,8 +989,8 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } @@ -733,16 +1002,33 @@ function PromptInput({ end: i + 1, color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), - priority: 10 - }); + priority: 10, + }) } } - return highlights; - }, [isSearchingHistory, historyQuery, historyMatch, historyFailedMatch, cursorOffset, btwTriggers, imageRefPositions, memberMentionHighlights, slashCommandTriggers, tokenBudgetTriggers, slackChannelTriggers, displayedValue, voiceInterimRange, thinkTriggers, ultraplanTriggers, ultrareviewTriggers, buddyTriggers]); - const { - addNotification, - removeNotification - } = useNotifications(); + + return highlights + }, [ + isSearchingHistory, + historyQuery, + historyMatch, + historyFailedMatch, + cursorOffset, + btwTriggers, + imageRefPositions, + memberMentionHighlights, + slashCommandTriggers, + tokenBudgetTriggers, + slackChannelTriggers, + displayedValue, + voiceInterimRange, + thinkTriggers, + ultraplanTriggers, + ultrareviewTriggers, + buddyTriggers, + ]) + + const { addNotification, removeNotification } = useNotifications() // Show ultrathink notification useEffect(() => { @@ -751,364 +1037,463 @@ function PromptInput({ key: 'ultrathink-active', text: 'Effort set to high for this turn', priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } else { - removeNotification('ultrathink-active'); + removeNotification('ultrathink-active') } - }, [addNotification, removeNotification, thinkTriggers.length]); + }, [addNotification, removeNotification, thinkTriggers.length]) + useEffect(() => { if (feature('ULTRAPLAN') && ultraplanTriggers.length) { addNotification({ key: 'ultraplan-active', text: 'This prompt will launch an ultraplan session in Claude Code on the web', priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } else { - removeNotification('ultraplan-active'); + removeNotification('ultraplan-active') } - }, [addNotification, removeNotification, ultraplanTriggers.length]); + }, [addNotification, removeNotification, ultraplanTriggers.length]) + useEffect(() => { if (isUltrareviewEnabled() && ultrareviewTriggers.length) { addNotification({ key: 'ultrareview-active', text: 'Run /ultrareview after Claude finishes to review these changes in the cloud', priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } - }, [addNotification, ultrareviewTriggers.length]); + }, [addNotification, ultrareviewTriggers.length]) // Track input length for stash hint - const prevInputLengthRef = useRef(input.length); - const peakInputLengthRef = useRef(input.length); + const prevInputLengthRef = useRef(input.length) + const peakInputLengthRef = useRef(input.length) // Dismiss stash hint when user makes any input change const dismissStashHint = useCallback(() => { - removeNotification('stash-hint'); - }, [removeNotification]); + removeNotification('stash-hint') + }, [removeNotification]) // Show stash hint when user gradually clears substantial input useEffect(() => { - const prevLength = prevInputLengthRef.current; - const peakLength = peakInputLengthRef.current; - const currentLength = input.length; - prevInputLengthRef.current = currentLength; + const prevLength = prevInputLengthRef.current + const peakLength = peakInputLengthRef.current + const currentLength = input.length + prevInputLengthRef.current = currentLength // Update peak when input grows if (currentLength > peakLength) { - peakInputLengthRef.current = currentLength; - return; + peakInputLengthRef.current = currentLength + return } // Reset state when input is empty if (currentLength === 0) { - peakInputLengthRef.current = 0; - return; + peakInputLengthRef.current = 0 + return } // Detect gradual clear: peak was high, current is low, but this wasn't a single big jump // (rapid clears like esc-esc go from 20+ to 0 in one step) - const clearedSubstantialInput = peakLength >= 20 && currentLength <= 5; - const wasRapidClear = prevLength >= 20 && currentLength <= 5; + const clearedSubstantialInput = peakLength >= 20 && currentLength <= 5 + const wasRapidClear = prevLength >= 20 && currentLength <= 5 + if (clearedSubstantialInput && !wasRapidClear) { - const config = getGlobalConfig(); + const config = getGlobalConfig() if (!config.hasUsedStash) { addNotification({ key: 'stash-hint', - jsx: + jsx: ( + Tip:{' '} - - , + + + ), priority: 'immediate', - timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT - }); + timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT, + }) } - peakInputLengthRef.current = currentLength; + peakInputLengthRef.current = currentLength } - }, [input.length, addNotification]); + }, [input.length, addNotification]) // Initialize input buffer for undo functionality - const { - pushToBuffer, - undo, - canUndo, - clearBuffer - } = useInputBuffer({ + const { pushToBuffer, undo, canUndo, clearBuffer } = useInputBuffer({ maxBufferSize: 50, - debounceMs: 1000 - }); + debounceMs: 1000, + }) + useMaybeTruncateInput({ input, pastedContents, onInputChange: trackAndSetInput, setCursorOffset, - setPastedContents - }); + setPastedContents, + }) + const defaultPlaceholder = usePromptInputPlaceholder({ input, submitCount, - viewingAgentName - }); - const onChange = useCallback((value: string) => { - if (value === '?') { - logEvent('tengu_help_toggled', {}); - setHelpOpen(v => !v); - return; - } - setHelpOpen(false); + viewingAgentName, + }) - // Dismiss stash hint when user makes any input change - dismissStashHint(); - - // Cancel any pending prompt suggestion and speculation when user types - abortPromptSuggestion(); - abortSpeculation(setAppState); - - // Check if this is a single character insertion at the start - const isSingleCharInsertion = value.length === input.length + 1; - const insertedAtStart = cursorOffset === 0; - const mode = getModeFromInput(value); - if (insertedAtStart && mode !== 'prompt') { - if (isSingleCharInsertion) { - onModeChange(mode); - return; + const onChange = useCallback( + (value: string) => { + if (value === '?') { + logEvent('tengu_help_toggled', {}) + setHelpOpen(v => !v) + return } - // Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login") - if (input.length === 0) { - onModeChange(mode); - const valueWithoutMode = getValueFromInput(value).replaceAll('\t', ' '); - pushToBuffer(input, cursorOffset, pastedContents); - trackAndSetInput(valueWithoutMode); - setCursorOffset(valueWithoutMode.length); - return; + setHelpOpen(false) + + // Dismiss stash hint when user makes any input change + dismissStashHint() + + // Cancel any pending prompt suggestion and speculation when user types + abortPromptSuggestion() + abortSpeculation(setAppState) + + // Check if this is a single character insertion at the start + const isSingleCharInsertion = value.length === input.length + 1 + const insertedAtStart = cursorOffset === 0 + const mode = getModeFromInput(value) + + if (insertedAtStart && mode !== 'prompt') { + if (isSingleCharInsertion) { + onModeChange(mode) + return + } + // Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login") + if (input.length === 0) { + onModeChange(mode) + const valueWithoutMode = getValueFromInput(value).replaceAll( + '\t', + ' ', + ) + pushToBuffer(input, cursorOffset, pastedContents) + trackAndSetInput(valueWithoutMode) + setCursorOffset(valueWithoutMode.length) + return + } } - } - const processedValue = value.replaceAll('\t', ' '); - // Push current state to buffer before making changes - if (input !== processedValue) { - pushToBuffer(input, cursorOffset, pastedContents); - } + const processedValue = value.replaceAll('\t', ' ') + + // Push current state to buffer before making changes + if (input !== processedValue) { + pushToBuffer(input, cursorOffset, pastedContents) + } + + // Deselect footer items when user types + setAppState(prev => + prev.footerSelection === null + ? prev + : { ...prev, footerSelection: null }, + ) + + trackAndSetInput(processedValue) + }, + [ + trackAndSetInput, + onModeChange, + input, + cursorOffset, + pushToBuffer, + pastedContents, + dismissStashHint, + setAppState, + ], + ) - // Deselect footer items when user types - setAppState(prev => prev.footerSelection === null ? prev : { - ...prev, - footerSelection: null - }); - trackAndSetInput(processedValue); - }, [trackAndSetInput, onModeChange, input, cursorOffset, pushToBuffer, pastedContents, dismissStashHint, setAppState]); const { resetHistory, onHistoryUp, onHistoryDown, dismissSearchHint, - historyIndex - } = useArrowKeyHistory((value: string, historyMode: HistoryMode, pastedContents: Record) => { - onChange(value); - onModeChange(historyMode); - setPastedContents(pastedContents); - }, input, pastedContents, setCursorOffset, mode); + historyIndex, + } = useArrowKeyHistory( + ( + value: string, + historyMode: HistoryMode, + pastedContents: Record, + ) => { + onChange(value) + onModeChange(historyMode) + setPastedContents(pastedContents) + }, + input, + pastedContents, + setCursorOffset, + mode, + ) // Dismiss search hint when user starts searching useEffect(() => { if (isSearchingHistory) { - dismissSearchHint(); + dismissSearchHint() } - }, [isSearchingHistory, dismissSearchHint]); + }, [isSearchingHistory, dismissSearchHint]) // Only use history navigation when there are 0 or 1 slash command suggestions. // Footer nav is NOT here — when a pill is selected, TextInput focus=false so // these never fire. The Footer keybinding context handles ↑/↓ instead. function handleHistoryUp() { if (suggestions.length > 1) { - return; + return } // Only navigate history when cursor is on the first line. // In multiline inputs, up arrow should move the cursor (handled by TextInput) // and only trigger history when at the top of the input. if (!isCursorOnFirstLine) { - return; + return } // If there's an editable queued command, move it to the input for editing when UP is pressed - const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable); + const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable) if (hasEditableCommand) { - void popAllCommandsFromQueue(); - return; + void popAllCommandsFromQueue() + return } - onHistoryUp(); + + onHistoryUp() } + function handleHistoryDown() { if (suggestions.length > 1) { - return; + return } // Only navigate history/footer when cursor is on the last line. // In multiline inputs, down arrow should move the cursor (handled by TextInput) // and only trigger navigation when at the bottom of the input. if (!isCursorOnLastLine) { - return; + return } // At bottom of history → enter footer at first visible pill if (onHistoryDown() && footerItems.length > 0) { - const first = footerItems[0]!; - selectFooterItem(first); + const first = footerItems[0]! + selectFooterItem(first) if (first === 'tasks' && !getGlobalConfig().hasSeenTasksHint) { - saveGlobalConfig(c => c.hasSeenTasksHint ? c : { - ...c, - hasSeenTasksHint: true - }); + saveGlobalConfig(c => + c.hasSeenTasksHint ? c : { ...c, hasSeenTasksHint: true }, + ) } } } // Create a suggestions state directly - we'll sync it with useTypeahead later const [suggestionsState, setSuggestionsStateRaw] = useState<{ - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string }>({ suggestions: [], selectedSuggestion: -1, - commandArgumentHint: undefined - }); + commandArgumentHint: undefined, + }) // Setter for suggestions state - const setSuggestionsState = useCallback((updater: typeof suggestionsState | ((prev: typeof suggestionsState) => typeof suggestionsState)) => { - setSuggestionsStateRaw(prev => typeof updater === 'function' ? updater(prev) : updater); - }, []); - const onSubmit = useCallback(async (inputParam: string, isSubmittingSlashCommand = false) => { - inputParam = inputParam.trimEnd(); + const setSuggestionsState = useCallback( + ( + updater: + | typeof suggestionsState + | ((prev: typeof suggestionsState) => typeof suggestionsState), + ) => { + setSuggestionsStateRaw(prev => + typeof updater === 'function' ? updater(prev) : updater, + ) + }, + [], + ) - // Don't submit if a footer indicator is being opened. Read fresh from - // store — footer:openSelected calls selectFooterItem(null) then onSubmit - // in the same tick, and the closure value hasn't updated yet. Apply the - // same "still visible?" derivation as footerItemSelected so a stale - // selection (pill disappeared) doesn't swallow Enter. - const state = store.getState(); - if (state.footerSelection && footerItems.includes(state.footerSelection)) { - return; - } + const onSubmit = useCallback( + async (inputParam: string, isSubmittingSlashCommand = false) => { + inputParam = inputParam.trimEnd() - // Enter in selection modes confirms selection (useBackgroundTaskNavigation). - // BaseTextInput's useInput registers before that hook (child effects fire first), - // so without this guard Enter would double-fire and auto-submit the suggestion. - if (state.viewSelectionMode === 'selecting-agent') { - return; - } - - // Check for images early - we need this for suggestion logic below - const hasImages = Object.values(pastedContents).some(c => c.type === 'image'); - - // If input is empty OR matches the suggestion, submit it - // But if there are images attached, don't auto-accept the suggestion - - // the user wants to submit just the image(s). - // Only in leader view — promptSuggestion is leader-context, not teammate. - const suggestionText = promptSuggestionState.text; - const inputMatchesSuggestion = inputParam.trim() === '' || inputParam === suggestionText; - if (inputMatchesSuggestion && suggestionText && !hasImages && !state.viewingAgentTaskId) { - // If speculation is active, inject messages immediately as they stream - if (speculation.status === 'active') { - markAccepted(); - // skipReset: resetSuggestion would abort the speculation before we accept it - logOutcomeAtSubmission(suggestionText, { - skipReset: true - }); - void onSubmitProp(suggestionText, { - setCursorOffset, - clearBuffer, - resetHistory - }, { - state: speculation, - speculationSessionTimeSavedMs: speculationSessionTimeSavedMs, - setAppState - }); - return; // Skip normal query - speculation handled it + // Don't submit if a footer indicator is being opened. Read fresh from + // store — footer:openSelected calls selectFooterItem(null) then onSubmit + // in the same tick, and the closure value hasn't updated yet. Apply the + // same "still visible?" derivation as footerItemSelected so a stale + // selection (pill disappeared) doesn't swallow Enter. + const state = store.getState() + if ( + state.footerSelection && + footerItems.includes(state.footerSelection) + ) { + return } - // Regular suggestion acceptance (requires shownAt > 0) - if (promptSuggestionState.shownAt > 0) { - markAccepted(); - inputParam = suggestionText; + // Enter in selection modes confirms selection (useBackgroundTaskNavigation). + // BaseTextInput's useInput registers before that hook (child effects fire first), + // so without this guard Enter would double-fire and auto-submit the suggestion. + if (state.viewSelectionMode === 'selecting-agent') { + return } - } - // Handle @name direct message - if (isAgentSwarmsEnabled()) { - const directMessage = parseDirectMemberMessage(inputParam); - if (directMessage) { - const result = await sendDirectMemberMessage(directMessage.recipientName, directMessage.message, teamContext, writeToMailbox); - if (result.success) { - addNotification({ - key: 'direct-message-sent', - text: `Sent to @${result.recipientName}`, - priority: 'immediate', - timeoutMs: 3000 - }); - trackAndSetInput(''); - setCursorOffset(0); - clearBuffer(); - resetHistory(); - return; - } else if ('error' in result && result.error === 'no_team_context') { - // No team context - fall through to normal prompt submission - } else { - // Unknown recipient - fall through to normal prompt submission - // This allows e.g. "@utils explain this code" to be sent as a prompt + // Check for images early - we need this for suggestion logic below + const hasImages = Object.values(pastedContents).some( + c => c.type === 'image', + ) + + // If input is empty OR matches the suggestion, submit it + // But if there are images attached, don't auto-accept the suggestion - + // the user wants to submit just the image(s). + // Only in leader view — promptSuggestion is leader-context, not teammate. + const suggestionText = promptSuggestionState.text + const inputMatchesSuggestion = + inputParam.trim() === '' || inputParam === suggestionText + if ( + inputMatchesSuggestion && + suggestionText && + !hasImages && + !state.viewingAgentTaskId + ) { + // If speculation is active, inject messages immediately as they stream + if (speculation.status === 'active') { + markAccepted() + // skipReset: resetSuggestion would abort the speculation before we accept it + logOutcomeAtSubmission(suggestionText, { skipReset: true }) + + void onSubmitProp( + suggestionText, + { + setCursorOffset, + clearBuffer, + resetHistory, + }, + { + state: speculation, + speculationSessionTimeSavedMs: speculationSessionTimeSavedMs, + setAppState, + }, + ) + return // Skip normal query - speculation handled it + } + + // Regular suggestion acceptance (requires shownAt > 0) + if (promptSuggestionState.shownAt > 0) { + markAccepted() + inputParam = suggestionText } } - } - // Allow submission if there are images attached, even without text - if (inputParam.trim() === '' && !hasImages) { - return; - } + // Handle @name direct message + if (isAgentSwarmsEnabled()) { + const directMessage = parseDirectMemberMessage(inputParam) + if (directMessage) { + const result = await sendDirectMemberMessage( + directMessage.recipientName, + directMessage.message, + teamContext, + writeToMailbox, + ) - // PromptInput UX: Check if suggestions dropdown is showing - // For directory suggestions, allow submission (Tab is used for completion) - const hasDirectorySuggestions = suggestionsState.suggestions.length > 0 && suggestionsState.suggestions.every(s => s.description === 'directory'); - if (suggestionsState.suggestions.length > 0 && !isSubmittingSlashCommand && !hasDirectorySuggestions) { - logForDebugging(`[onSubmit] early return: suggestions showing (count=${suggestionsState.suggestions.length})`); - return; // Don't submit, user needs to clear suggestions first - } + if (result.success) { + addNotification({ + key: 'direct-message-sent', + text: `Sent to @${result.recipientName}`, + priority: 'immediate', + timeoutMs: 3000, + }) + trackAndSetInput('') + setCursorOffset(0) + clearBuffer() + resetHistory() + return + } else if (result.error === 'no_team_context') { + // No team context - fall through to normal prompt submission + } else { + // Unknown recipient - fall through to normal prompt submission + // This allows e.g. "@utils explain this code" to be sent as a prompt + } + } + } - // Log suggestion outcome if one exists - if (promptSuggestionState.text && promptSuggestionState.shownAt > 0) { - logOutcomeAtSubmission(inputParam); - } + // Allow submission if there are images attached, even without text + if (inputParam.trim() === '' && !hasImages) { + return + } - // Clear stash hint notification on submit - removeNotification('stash-hint'); + // PromptInput UX: Check if suggestions dropdown is showing + // For directory suggestions, allow submission (Tab is used for completion) + const hasDirectorySuggestions = + suggestionsState.suggestions.length > 0 && + suggestionsState.suggestions.every(s => s.description === 'directory') - // Route input to viewed agent (in-process teammate or named local_agent). - const activeAgent = getActiveAgentForInput(store.getState()); - if (activeAgent.type !== 'leader' && onAgentSubmit) { - logEvent('tengu_transcript_input_to_teammate', {}); - await onAgentSubmit(inputParam, activeAgent.task, { + if ( + suggestionsState.suggestions.length > 0 && + !isSubmittingSlashCommand && + !hasDirectorySuggestions + ) { + logForDebugging( + `[onSubmit] early return: suggestions showing (count=${suggestionsState.suggestions.length})`, + ) + return // Don't submit, user needs to clear suggestions first + } + + // Log suggestion outcome if one exists + if (promptSuggestionState.text && promptSuggestionState.shownAt > 0) { + logOutcomeAtSubmission(inputParam) + } + + // Clear stash hint notification on submit + removeNotification('stash-hint') + + // Route input to viewed agent (in-process teammate or named local_agent). + const activeAgent = getActiveAgentForInput(store.getState()) + if (activeAgent.type !== 'leader' && onAgentSubmit) { + logEvent('tengu_transcript_input_to_teammate', {}) + await onAgentSubmit(inputParam, activeAgent.task, { + setCursorOffset, + clearBuffer, + resetHistory, + }) + return + } + + // Normal leader submission + await onSubmitProp(inputParam, { setCursorOffset, clearBuffer, - resetHistory - }); - return; - } - - // Normal leader submission - await onSubmitProp(inputParam, { - setCursorOffset, + resetHistory, + }) + }, + [ + promptSuggestionState, + speculation, + speculationSessionTimeSavedMs, + teamContext, + store, + footerItems, + suggestionsState.suggestions, + onSubmitProp, + onAgentSubmit, clearBuffer, - resetHistory - }); - }, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification]); + resetHistory, + logOutcomeAtSubmission, + setAppState, + markAccepted, + pastedContents, + removeNotification, + ], + ) + const { suggestions, selectedSuggestion, commandArgumentHint, inlineGhostText, - maxColumnWidth + maxColumnWidth, } = useTypeahead({ commands, onInputChange: trackAndSetInput, @@ -1122,21 +1507,30 @@ function PromptInput({ suggestionsState, suppressSuggestions: isSearchingHistory || historyIndex > 0, markAccepted, - onModeChange - }); + onModeChange, + }) // Track if prompt suggestion should be shown (computed later with terminal width). // Hidden in teammate view — suggestion is leader-context only. - const showPromptSuggestion = mode === 'prompt' && suggestions.length === 0 && promptSuggestion && !viewingAgentTaskId; + const showPromptSuggestion = + mode === 'prompt' && + suggestions.length === 0 && + promptSuggestion && + !viewingAgentTaskId if (showPromptSuggestion) { - markShown(); + markShown() } // If suggestion was generated but can't be shown due to timing, log suppression. // Exclude teammate view: markShown() is gated above, so shownAt stays 0 there — // but that's not a timing failure, the suggestion is valid when returning to leader. - if (promptSuggestionState.text && !promptSuggestion && promptSuggestionState.shownAt === 0 && !viewingAgentTaskId) { - logSuggestionSuppressed('timing', promptSuggestionState.text); + if ( + promptSuggestionState.text && + !promptSuggestion && + promptSuggestionState.shownAt === 0 && + !viewingAgentTaskId + ) { + logSuggestionSuppressed('timing', promptSuggestionState.text) setAppState(prev => ({ ...prev, promptSuggestion: { @@ -1144,42 +1538,47 @@ function PromptInput({ promptId: null, shownAt: 0, acceptedAt: 0, - generationRequestId: null - } - })); + generationRequestId: null, + }, + })) } - function onImagePaste(image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) { - logEvent('tengu_paste_image', {}); - onModeChange('prompt'); - const pasteId = nextPasteIdRef.current++; + + function onImagePaste( + image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) { + logEvent('tengu_paste_image', {}) + onModeChange('prompt') + + const pasteId = nextPasteIdRef.current++ + const newContent: PastedContent = { id: pasteId, type: 'image', content: image, - mediaType: mediaType || 'image/png', - // default to PNG if not provided + mediaType: mediaType || 'image/png', // default to PNG if not provided filename: filename || 'Pasted image', dimensions, - sourcePath - }; + sourcePath, + } // Cache path immediately (fast) so links work on render - cacheImagePath(newContent); + cacheImagePath(newContent) // Store image to disk in background - void storeImage(newContent); + void storeImage(newContent) // Update UI - setPastedContents(prev => ({ - ...prev, - [pasteId]: newContent - })); + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) // Multi-image paste calls onImagePaste in a loop. If the ref is already // armed, the previous pill's lazy space fires now (before this pill) // rather than being lost. - const prefix = pendingSpaceAfterPillRef.current ? ' ' : ''; - insertTextAtCursor(prefix + formatImageRef(pasteId)); - pendingSpaceAfterPillRef.current = true; + const prefix = pendingSpaceAfterPillRef.current ? ' ' : '' + insertTextAtCursor(prefix + formatImageRef(pasteId)) + pendingSpaceAfterPillRef.current = true } // Prune images whose [Image #N] placeholder is no longer in the input text. @@ -1187,224 +1586,260 @@ function PromptInput({ // the ref. onImagePaste batches setPastedContents + insertTextAtCursor in the // same event, so this effect sees the placeholder already present. useEffect(() => { - const referencedIds = new Set(parseReferences(input).map(r => r.id)); + const referencedIds = new Set(parseReferences(input).map(r => r.id)) setPastedContents(prev => { - const orphaned = Object.values(prev).filter(c => c.type === 'image' && !referencedIds.has(c.id)); - if (orphaned.length === 0) return prev; - const next = { - ...prev - }; - for (const img of orphaned) delete next[img.id]; - return next; - }); - }, [input, setPastedContents]); + const orphaned = Object.values(prev).filter( + c => c.type === 'image' && !referencedIds.has(c.id), + ) + if (orphaned.length === 0) return prev + const next = { ...prev } + for (const img of orphaned) delete next[img.id] + return next + }) + }, [input, setPastedContents]) + function onTextPaste(rawText: string) { - pendingSpaceAfterPillRef.current = false; + pendingSpaceAfterPillRef.current = false // Clean up pasted text - strip ANSI escape codes and normalize line endings and tabs - let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' '); + let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' ') // Match typed/auto-suggest: `!cmd` pasted into empty input enters bash mode. if (input.length === 0) { - const pastedMode = getModeFromInput(text); + const pastedMode = getModeFromInput(text) if (pastedMode !== 'prompt') { - onModeChange(pastedMode); - text = getValueFromInput(text); + onModeChange(pastedMode) + text = getValueFromInput(text) } } - const numLines = getPastedTextRefNumLines(text); + + const numLines = getPastedTextRefNumLines(text) // Limit the number of lines to show in the input // If the overall layout is too high then Ink will repaint // the entire terminal. // The actual required height is dependent on the content, this // is just an estimate. - const maxLines = Math.min(rows - 10, 2); + const maxLines = Math.min(rows - 10, 2) // Use special handling for long pasted text (>PASTE_THRESHOLD chars) // or if it exceeds the number of lines we want to show if (text.length > PASTE_THRESHOLD || numLines > maxLines) { - const pasteId = nextPasteIdRef.current++; + const pasteId = nextPasteIdRef.current++ + const newContent: PastedContent = { id: pasteId, type: 'text', - content: text - }; - setPastedContents(prev => ({ - ...prev, - [pasteId]: newContent - })); - insertTextAtCursor(formatPastedTextRef(pasteId, numLines)); + content: text, + } + + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) + + insertTextAtCursor(formatPastedTextRef(pasteId, numLines)) } else { // For shorter pastes, just insert the text normally - insertTextAtCursor(text); + insertTextAtCursor(text) } } - const lazySpaceInputFilter = useCallback((input: string, key: Key): string => { - if (!pendingSpaceAfterPillRef.current) return input; - pendingSpaceAfterPillRef.current = false; - if (isNonSpacePrintable(input, key)) return ' ' + input; - return input; - }, []); + + const lazySpaceInputFilter = useCallback( + (input: string, key: Key): string => { + if (!pendingSpaceAfterPillRef.current) return input + pendingSpaceAfterPillRef.current = false + if (isNonSpacePrintable(input, key)) return ' ' + input + return input + }, + [], + ) + function insertTextAtCursor(text: string) { // Push current state to buffer before inserting - pushToBuffer(input, cursorOffset, pastedContents); - const newInput = input.slice(0, cursorOffset) + text + input.slice(cursorOffset); - trackAndSetInput(newInput); - setCursorOffset(cursorOffset + text.length); + pushToBuffer(input, cursorOffset, pastedContents) + + const newInput = + input.slice(0, cursorOffset) + text + input.slice(cursorOffset) + trackAndSetInput(newInput) + setCursorOffset(cursorOffset + text.length) } - const doublePressEscFromEmpty = useDoublePress(() => {}, () => onShowMessageSelector()); + + const doublePressEscFromEmpty = useDoublePress( + () => {}, + () => onShowMessageSelector(), + ) // Function to get the queued command for editing. Returns true if commands were popped. const popAllCommandsFromQueue = useCallback((): boolean => { - const result = popAllEditable(input, cursorOffset); + const result = popAllEditable(input, cursorOffset) if (!result) { - return false; + return false } - trackAndSetInput(result.text); - onModeChange('prompt'); // Always prompt mode for queued commands - setCursorOffset(result.cursorOffset); + + trackAndSetInput(result.text) + onModeChange('prompt') // Always prompt mode for queued commands + setCursorOffset(result.cursorOffset) // Restore images from queued commands to pastedContents if (result.images.length > 0) { setPastedContents(prev => { - const newContents = { - ...prev - }; + const newContents = { ...prev } for (const image of result.images) { - newContents[image.id] = image; + newContents[image.id] = image } - return newContents; - }); + return newContents + }) } - return true; - }, [trackAndSetInput, onModeChange, input, cursorOffset, setPastedContents]); + + return true + }, [trackAndSetInput, onModeChange, input, cursorOffset, setPastedContents]) // Insert the at-mentioned reference (the file and, optionally, a line range) when // we receive an at-mentioned notification the IDE. const onIdeAtMentioned = function (atMentioned: IDEAtMentioned) { - logEvent('tengu_ext_at_mentioned', {}); - let atMentionedText: string; - const relativePath = path.relative(getCwd(), atMentioned.filePath); + logEvent('tengu_ext_at_mentioned', {}) + let atMentionedText: string + const relativePath = path.relative(getCwd(), atMentioned.filePath) if (atMentioned.lineStart && atMentioned.lineEnd) { - atMentionedText = atMentioned.lineStart === atMentioned.lineEnd ? `@${relativePath}#L${atMentioned.lineStart} ` : `@${relativePath}#L${atMentioned.lineStart}-${atMentioned.lineEnd} `; + atMentionedText = + atMentioned.lineStart === atMentioned.lineEnd + ? `@${relativePath}#L${atMentioned.lineStart} ` + : `@${relativePath}#L${atMentioned.lineStart}-${atMentioned.lineEnd} ` } else { - atMentionedText = `@${relativePath} `; + atMentionedText = `@${relativePath} ` } - const cursorChar = input[cursorOffset - 1] ?? ' '; + const cursorChar = input[cursorOffset - 1] ?? ' ' if (!/\s/.test(cursorChar)) { - atMentionedText = ` ${atMentionedText}`; + atMentionedText = ` ${atMentionedText}` } - insertTextAtCursor(atMentionedText); - }; - useIdeAtMentioned(mcpClients, onIdeAtMentioned); + insertTextAtCursor(atMentionedText) + } + useIdeAtMentioned(mcpClients, onIdeAtMentioned) // Handler for chat:undo - undo last edit const handleUndo = useCallback(() => { if (canUndo) { - const previousState = undo(); + const previousState = undo() if (previousState) { - trackAndSetInput(previousState.text); - setCursorOffset(previousState.cursorOffset); - setPastedContents(previousState.pastedContents); + trackAndSetInput(previousState.text) + setCursorOffset(previousState.cursorOffset) + setPastedContents(previousState.pastedContents) } } - }, [canUndo, undo, trackAndSetInput, setPastedContents]); + }, [canUndo, undo, trackAndSetInput, setPastedContents]) // Handler for chat:newline - insert a newline at the cursor position const handleNewline = useCallback(() => { - pushToBuffer(input, cursorOffset, pastedContents); - const newInput = input.slice(0, cursorOffset) + '\n' + input.slice(cursorOffset); - trackAndSetInput(newInput); - setCursorOffset(cursorOffset + 1); - }, [input, cursorOffset, trackAndSetInput, setCursorOffset, pushToBuffer, pastedContents]); + pushToBuffer(input, cursorOffset, pastedContents) + const newInput = + input.slice(0, cursorOffset) + '\n' + input.slice(cursorOffset) + trackAndSetInput(newInput) + setCursorOffset(cursorOffset + 1) + }, [ + input, + cursorOffset, + trackAndSetInput, + setCursorOffset, + pushToBuffer, + pastedContents, + ]) // Handler for chat:externalEditor - edit in $EDITOR const handleExternalEditor = useCallback(async () => { - logEvent('tengu_external_editor_used', {}); - setIsExternalEditorActive(true); + logEvent('tengu_external_editor_used', {}) + setIsExternalEditorActive(true) + try { // Pass pastedContents to expand collapsed text references - const result = await editPromptInEditor(input, pastedContents); + const result = await editPromptInEditor(input, pastedContents) + if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', - priority: 'high' - }); + priority: 'high', + }) } + if (result.content !== null && result.content !== input) { // Push current state to buffer before making changes - pushToBuffer(input, cursorOffset, pastedContents); - trackAndSetInput(result.content); - setCursorOffset(result.content.length); + pushToBuffer(input, cursorOffset, pastedContents) + + trackAndSetInput(result.content) + setCursorOffset(result.content.length) } } catch (err) { if (err instanceof Error) { - logError(err); + logError(err) } addNotification({ key: 'external-editor-error', text: `External editor failed: ${errorMessage(err)}`, color: 'warning', - priority: 'high' - }); + priority: 'high', + }) } finally { - setIsExternalEditorActive(false); + setIsExternalEditorActive(false) } - }, [input, cursorOffset, pastedContents, pushToBuffer, trackAndSetInput, addNotification]); + }, [ + input, + cursorOffset, + pastedContents, + pushToBuffer, + trackAndSetInput, + addNotification, + ]) // Handler for chat:stash - stash/unstash prompt const handleStash = useCallback(() => { if (input.trim() === '' && stashedPrompt !== undefined) { // Pop stash when input is empty - trackAndSetInput(stashedPrompt.text); - setCursorOffset(stashedPrompt.cursorOffset); - setPastedContents(stashedPrompt.pastedContents); - setStashedPrompt(undefined); + trackAndSetInput(stashedPrompt.text) + setCursorOffset(stashedPrompt.cursorOffset) + setPastedContents(stashedPrompt.pastedContents) + setStashedPrompt(undefined) } else if (input.trim() !== '') { // Push to stash (save text, cursor position, and pasted contents) - setStashedPrompt({ - text: input, - cursorOffset, - pastedContents - }); - trackAndSetInput(''); - setCursorOffset(0); - setPastedContents({}); + setStashedPrompt({ text: input, cursorOffset, pastedContents }) + trackAndSetInput('') + setCursorOffset(0) + setPastedContents({}) // Track usage for /discover and stop showing hint saveGlobalConfig(c => { - if (c.hasUsedStash) return c; - return { - ...c, - hasUsedStash: true - }; - }); + if (c.hasUsedStash) return c + return { ...c, hasUsedStash: true } + }) } - }, [input, cursorOffset, stashedPrompt, trackAndSetInput, setStashedPrompt, pastedContents, setPastedContents]); + }, [ + input, + cursorOffset, + stashedPrompt, + trackAndSetInput, + setStashedPrompt, + pastedContents, + setPastedContents, + ]) // Handler for chat:modelPicker - toggle model picker const handleModelPicker = useCallback(() => { - setShowModelPicker(prev => !prev); + setShowModelPicker(prev => !prev) if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }, [helpOpen]); + }, [helpOpen]) // Handler for chat:fastMode - toggle fast mode picker const handleFastModePicker = useCallback(() => { - setShowFastModePicker(prev => !prev); + setShowFastModePicker(prev => !prev) if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }, [helpOpen]); + }, [helpOpen]) // Handler for chat:thinkingToggle - toggle thinking mode const handleThinkingToggle = useCallback(() => { - setShowThinkingToggle(prev => !prev); + setShowThinkingToggle(prev => !prev) if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }, [helpOpen]); + }, [helpOpen]) // Handler for chat:cycleMode - cycle through permission modes const handleCycleMode = useCallback(() => { @@ -1412,21 +1847,23 @@ function PromptInput({ if (isAgentSwarmsEnabled() && viewedTeammate && viewingAgentTaskId) { const teammateContext: ToolPermissionContext = { ...toolPermissionContext, - mode: viewedTeammate.permissionMode - }; + mode: viewedTeammate.permissionMode, + } // Pass undefined for teamContext (unused but kept for API compatibility) - const nextMode = getNextPermissionMode(teammateContext, undefined); + const nextMode = getNextPermissionMode(teammateContext, undefined) + logEvent('tengu_mode_cycle', { - to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const teammateTaskId = viewingAgentTaskId; + to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + const teammateTaskId = viewingAgentTaskId setAppState(prev => { - const task = prev.tasks[teammateTaskId]; + const task = prev.tasks[teammateTaskId] if (!task || task.type !== 'in_process_teammate') { - return prev; + return prev } if (task.permissionMode === nextMode) { - return prev; + return prev } return { ...prev, @@ -1434,34 +1871,42 @@ function PromptInput({ ...prev.tasks, [teammateTaskId]: { ...task, - permissionMode: nextMode - } - } - }; - }); + permissionMode: nextMode, + }, + }, + } + }) + if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - return; + return } // Compute the next mode without triggering side effects first - logForDebugging(`[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`); - const nextMode = getNextPermissionMode(toolPermissionContext, teamContext); + logForDebugging( + `[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`, + ) + const nextMode = getNextPermissionMode(toolPermissionContext, teamContext) // Check if user is entering auto mode for the first time. Gated on the // persistent settings flag (hasAutoModeOptIn) rather than the broader // hasAutoModeOptInAnySource so that --enable-auto-mode users still see // the warning dialog once — the CLI flag should grant carousel access, // not bypass the safety text. - let isEnteringAutoModeFirstTime = false; + let isEnteringAutoModeFirstTime = false if (feature('TRANSCRIPT_CLASSIFIER')) { - isEnteringAutoModeFirstTime = nextMode === 'auto' && toolPermissionContext.mode !== 'auto' && !hasAutoModeOptIn() && !viewingAgentTaskId; // Only show for primary agent, not subagents + isEnteringAutoModeFirstTime = + nextMode === 'auto' && + toolPermissionContext.mode !== 'auto' && + !hasAutoModeOptIn() && + !viewingAgentTaskId // Only show for primary agent, not subagents } + if (feature('TRANSCRIPT_CLASSIFIER')) { if (isEnteringAutoModeFirstTime) { // Store previous mode so we can revert if user declines - setPreviousModeBeforeAuto(toolPermissionContext.mode); + setPreviousModeBeforeAuto(toolPermissionContext.mode) // Only update the UI mode label — do NOT call transitionPermissionMode // or cyclePermissionMode yet; we haven't confirmed with the user. @@ -1469,26 +1914,32 @@ function PromptInput({ ...prev, toolPermissionContext: { ...prev.toolPermissionContext, - mode: 'auto' - } - })); + mode: 'auto', + }, + })) setToolPermissionContext({ ...toolPermissionContext, - mode: 'auto' - }); + mode: 'auto', + }) // Show opt-in dialog after 400ms debounce if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current); + clearTimeout(autoModeOptInTimeoutRef.current) } - autoModeOptInTimeoutRef.current = setTimeout((setShowAutoModeOptIn, autoModeOptInTimeoutRef) => { - setShowAutoModeOptIn(true); - autoModeOptInTimeoutRef.current = null; - }, 400, setShowAutoModeOptIn, autoModeOptInTimeoutRef); + autoModeOptInTimeoutRef.current = setTimeout( + (setShowAutoModeOptIn, autoModeOptInTimeoutRef) => { + setShowAutoModeOptIn(true) + autoModeOptInTimeoutRef.current = null + }, + 400, + setShowAutoModeOptIn, + autoModeOptInTimeoutRef, + ) + if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - return; + return } } @@ -1500,14 +1951,14 @@ function PromptInput({ if (feature('TRANSCRIPT_CLASSIFIER')) { if (showAutoModeOptIn || autoModeOptInTimeoutRef.current) { if (showAutoModeOptIn) { - logEvent('tengu_auto_mode_opt_in_dialog_decline', {}); + logEvent('tengu_auto_mode_opt_in_dialog_decline', {}) } - setShowAutoModeOptIn(false); + setShowAutoModeOptIn(false) if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current); - autoModeOptInTimeoutRef.current = null; + clearTimeout(autoModeOptInTimeoutRef.current) + autoModeOptInTimeoutRef.current = null } - setPreviousModeBeforeAuto(null); + setPreviousModeBeforeAuto(null) // Fall through — mode is 'auto', cyclePermissionMode below goes to 'default'. } } @@ -1515,19 +1966,21 @@ function PromptInput({ // Now that we know this is NOT the first-time auto mode path, // call cyclePermissionMode to apply side effects (e.g. strip // dangerous permissions, activate classifier) - const { - context: preparedContext - } = cyclePermissionMode(toolPermissionContext, teamContext); + const { context: preparedContext } = cyclePermissionMode( + toolPermissionContext, + teamContext, + ) + logEvent('tengu_mode_cycle', { - to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) // Track when user enters plan mode if (nextMode === 'plan') { saveGlobalConfig(current => ({ ...current, - lastPlanModeUse: Date.now() - })); + lastPlanModeUse: Date.now(), + })) } // Set the mode via setAppState directly because setToolPermissionContext @@ -1538,101 +1991,134 @@ function PromptInput({ ...prev, toolPermissionContext: { ...preparedContext, - mode: nextMode - } - })); + mode: nextMode, + }, + })) setToolPermissionContext({ ...preparedContext, - mode: nextMode - }); + mode: nextMode, + }) // If this is a teammate, update config.json so team lead sees the change - syncTeammateMode(nextMode, teamContext?.teamName); + syncTeammateMode(nextMode, teamContext?.teamName) // Close help tips if they're open when mode is cycled if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }, [toolPermissionContext, teamContext, viewingAgentTaskId, viewedTeammate, setAppState, setToolPermissionContext, helpOpen, showAutoModeOptIn]); + }, [ + toolPermissionContext, + teamContext, + viewingAgentTaskId, + viewedTeammate, + setAppState, + setToolPermissionContext, + helpOpen, + showAutoModeOptIn, + ]) // Handler for auto mode opt-in dialog acceptance const handleAutoModeOptInAccept = useCallback(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { - setShowAutoModeOptIn(false); - setPreviousModeBeforeAuto(null); + setShowAutoModeOptIn(false) + setPreviousModeBeforeAuto(null) // Now that the user accepted, apply the full transition: activate the // auto mode backend (classifier, beta headers) and strip dangerous // permissions (e.g. Bash(*) always-allow rules). - const strippedContext = transitionPermissionMode(previousModeBeforeAuto ?? toolPermissionContext.mode, 'auto', toolPermissionContext); + const strippedContext = transitionPermissionMode( + previousModeBeforeAuto ?? toolPermissionContext.mode, + 'auto', + toolPermissionContext, + ) setAppState(prev => ({ ...prev, toolPermissionContext: { ...strippedContext, - mode: 'auto' - } - })); + mode: 'auto', + }, + })) setToolPermissionContext({ ...strippedContext, - mode: 'auto' - }); + mode: 'auto', + }) // Close help tips if they're open when auto mode is enabled if (helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } } - }, [helpOpen, setHelpOpen, previousModeBeforeAuto, toolPermissionContext, setAppState, setToolPermissionContext]); + }, [ + helpOpen, + setHelpOpen, + previousModeBeforeAuto, + toolPermissionContext, + setAppState, + setToolPermissionContext, + ]) // Handler for auto mode opt-in dialog decline const handleAutoModeOptInDecline = useCallback(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { - logForDebugging(`[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`); - setShowAutoModeOptIn(false); + logForDebugging( + `[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`, + ) + setShowAutoModeOptIn(false) if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current); - autoModeOptInTimeoutRef.current = null; + clearTimeout(autoModeOptInTimeoutRef.current) + autoModeOptInTimeoutRef.current = null } // Revert to previous mode and remove auto from the carousel // for the rest of this session if (previousModeBeforeAuto) { - setAutoModeActive(false); + setAutoModeActive(false) setAppState(prev => ({ ...prev, toolPermissionContext: { ...prev.toolPermissionContext, mode: previousModeBeforeAuto, - isAutoModeAvailable: false - } - })); + isAutoModeAvailable: false, + }, + })) setToolPermissionContext({ ...toolPermissionContext, mode: previousModeBeforeAuto, - isAutoModeAvailable: false - }); - setPreviousModeBeforeAuto(null); + isAutoModeAvailable: false, + }) + setPreviousModeBeforeAuto(null) } } - }, [previousModeBeforeAuto, toolPermissionContext, setAppState, setToolPermissionContext]); + }, [ + previousModeBeforeAuto, + toolPermissionContext, + setAppState, + setToolPermissionContext, + ]) // Handler for chat:imagePaste - paste image from clipboard const handleImagePaste = useCallback(() => { void getImageFromClipboard().then(imageData => { if (imageData) { - onImagePaste(imageData.base64, imageData.mediaType); + onImagePaste(imageData.base64, imageData.mediaType) } else { - const shortcutDisplay = getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'); - const message = env.isSSH() ? "No image found in clipboard. You're SSH'd; try scp?" : `No image found in clipboard. Use ${shortcutDisplay} to paste images.`; + const shortcutDisplay = getShortcutDisplay( + 'chat:imagePaste', + 'Chat', + 'ctrl+v', + ) + const message = env.isSSH() + ? "No image found in clipboard. You're SSH'd; try scp?" + : `No image found in clipboard. Use ${shortcutDisplay} to paste images.` addNotification({ key: 'no-image-in-clipboard', text: message, priority: 'immediate', - timeoutMs: 1000 - }); + timeoutMs: 1000, + }) } - }); - }, [addNotification, onImagePaste]); + }) + }, [addNotification, onImagePaste]) // Register chat:submit handler directly in the handler registry (not via // useKeybindings) so that only the ChordInterceptor can invoke it for chord @@ -1640,250 +2126,309 @@ function PromptInput({ // handled by TextInput directly (via onSubmit prop) and useTypeahead (for // autocomplete acceptance). Using useKeybindings would cause // stopImmediatePropagation on Enter, blocking autocomplete from seeing the key. - const keybindingContext = useOptionalKeybindingContext(); + const keybindingContext = useOptionalKeybindingContext() useEffect(() => { - if (!keybindingContext || isModalOverlayActive) return; + if (!keybindingContext || isModalOverlayActive) return return keybindingContext.registerHandler({ action: 'chat:submit', context: 'Chat', handler: () => { - void onSubmit(input); - } - }); - }, [keybindingContext, isModalOverlayActive, onSubmit, input]); + void onSubmit(input) + }, + }) + }, [keybindingContext, isModalOverlayActive, onSubmit, input]) // Chat context keybindings for editing shortcuts // Note: history:previous/history:next are NOT handled here. They are passed as // onHistoryUp/onHistoryDown props to TextInput, so that useTextInput's // upOrHistoryUp/downOrHistoryDown can try cursor movement first and only // fall through to history when the cursor can't move further. - const chatHandlers = useMemo(() => ({ - 'chat:undo': handleUndo, - 'chat:newline': handleNewline, - 'chat:externalEditor': handleExternalEditor, - 'chat:stash': handleStash, - 'chat:modelPicker': handleModelPicker, - 'chat:thinkingToggle': handleThinkingToggle, - 'chat:cycleMode': handleCycleMode, - 'chat:imagePaste': handleImagePaste - }), [handleUndo, handleNewline, handleExternalEditor, handleStash, handleModelPicker, handleThinkingToggle, handleCycleMode, handleImagePaste]); + const chatHandlers = useMemo( + () => ({ + 'chat:undo': handleUndo, + 'chat:newline': handleNewline, + 'chat:externalEditor': handleExternalEditor, + 'chat:stash': handleStash, + 'chat:modelPicker': handleModelPicker, + 'chat:thinkingToggle': handleThinkingToggle, + 'chat:cycleMode': handleCycleMode, + 'chat:imagePaste': handleImagePaste, + }), + [ + handleUndo, + handleNewline, + handleExternalEditor, + handleStash, + handleModelPicker, + handleThinkingToggle, + handleCycleMode, + handleImagePaste, + ], + ) + useKeybindings(chatHandlers, { context: 'Chat', - isActive: !isModalOverlayActive - }); + isActive: !isModalOverlayActive, + }) // Shift+↑ enters message-actions cursor. Separate isActive so ctrl+r search // doesn't leave stale isSearchingHistory on cursor-exit remount. useKeybinding('chat:messageActions', () => onMessageActionsEnter?.(), { context: 'Chat', - isActive: !isModalOverlayActive && !isSearchingHistory - }); + isActive: !isModalOverlayActive && !isSearchingHistory, + }) // Fast mode keybinding is only active when fast mode is enabled and available useKeybinding('chat:fastMode', handleFastModePicker, { context: 'Chat', - isActive: !isModalOverlayActive && isFastModeEnabled() && isFastModeAvailable() - }); + isActive: + !isModalOverlayActive && isFastModeEnabled() && isFastModeAvailable(), + }) // Handle help:dismiss keybinding (ESC closes help menu) // This is registered separately from Chat context so it has priority over // CancelRequestHandler when help menu is open - useKeybinding('help:dismiss', () => { - setHelpOpen(false); - }, { - context: 'Help', - isActive: helpOpen - }); + useKeybinding( + 'help:dismiss', + () => { + setHelpOpen(false) + }, + { context: 'Help', isActive: helpOpen }, + ) // Quick Open / Global Search. Hook calls are unconditional (Rules of Hooks); // the handler body is feature()-gated so the setState calls and component // references get tree-shaken in external builds. - const quickSearchActive = feature('QUICK_SEARCH') ? !isModalOverlayActive : false; - useKeybinding('app:quickOpen', () => { - if (feature('QUICK_SEARCH')) { - setShowQuickOpen(true); - setHelpOpen(false); - } - }, { - context: 'Global', - isActive: quickSearchActive - }); - useKeybinding('app:globalSearch', () => { - if (feature('QUICK_SEARCH')) { - setShowGlobalSearch(true); - setHelpOpen(false); - } - }, { - context: 'Global', - isActive: quickSearchActive - }); - useKeybinding('history:search', () => { - if (feature('HISTORY_PICKER')) { - setShowHistoryPicker(true); - setHelpOpen(false); - } - }, { - context: 'Global', - isActive: feature('HISTORY_PICKER') ? !isModalOverlayActive : false - }); + const quickSearchActive = feature('QUICK_SEARCH') + ? !isModalOverlayActive + : false + useKeybinding( + 'app:quickOpen', + () => { + if (feature('QUICK_SEARCH')) { + setShowQuickOpen(true) + setHelpOpen(false) + } + }, + { context: 'Global', isActive: quickSearchActive }, + ) + useKeybinding( + 'app:globalSearch', + () => { + if (feature('QUICK_SEARCH')) { + setShowGlobalSearch(true) + setHelpOpen(false) + } + }, + { context: 'Global', isActive: quickSearchActive }, + ) + + useKeybinding( + 'history:search', + () => { + if (feature('HISTORY_PICKER')) { + setShowHistoryPicker(true) + setHelpOpen(false) + } + }, + { + context: 'Global', + isActive: feature('HISTORY_PICKER') ? !isModalOverlayActive : false, + }, + ) // Handle Ctrl+C to abort speculation when idle (not loading) // CancelRequestHandler only handles Ctrl+C during active tasks - useKeybinding('app:interrupt', () => { - abortSpeculation(setAppState); - }, { - context: 'Global', - isActive: !isLoading && speculation.status === 'active' - }); + useKeybinding( + 'app:interrupt', + () => { + abortSpeculation(setAppState) + }, + { + context: 'Global', + isActive: !isLoading && speculation.status === 'active', + }, + ) // Footer indicator navigation keybindings. ↑/↓ live here (not in // handleHistoryUp/Down) because TextInput focus=false when a pill is // selected — its useInput is inactive, so this is the only path. - useKeybindings({ - 'footer:up': () => { - // ↑ scrolls within the coordinator task list before leaving the pill - if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) { - setCoordinatorTaskIndex(prev => prev - 1); - return; - } - navigateFooter(-1, true); - }, - 'footer:down': () => { - // ↓ scrolls within the coordinator task list, never leaves the pill - if (tasksSelected && (process.env.USER_TYPE) === 'ant' && coordinatorTaskCount > 0) { - if (coordinatorTaskIndex < coordinatorTaskCount - 1) { - setCoordinatorTaskIndex(prev => prev + 1); + useKeybindings( + { + 'footer:up': () => { + // ↑ scrolls within the coordinator task list before leaving the pill + if ( + tasksSelected && + process.env.USER_TYPE === 'ant' && + coordinatorTaskCount > 0 && + coordinatorTaskIndex > minCoordinatorIndex + ) { + setCoordinatorTaskIndex(prev => prev - 1) + return } - return; - } - if (tasksSelected && !isTeammateMode) { - setShowBashesDialog(true); - selectFooterItem(null); - return; - } - navigateFooter(1); - }, - 'footer:next': () => { - // Teammate mode: ←/→ cycles within the team member list - if (tasksSelected && isTeammateMode) { - const totalAgents = 1 + inProcessTeammates.length; - setTeammateFooterIndex(prev => (prev + 1) % totalAgents); - return; - } - navigateFooter(1); - }, - 'footer:previous': () => { - if (tasksSelected && isTeammateMode) { - const totalAgents = 1 + inProcessTeammates.length; - setTeammateFooterIndex(prev => (prev - 1 + totalAgents) % totalAgents); - return; - } - navigateFooter(-1); - }, - 'footer:openSelected': () => { - if (viewSelectionMode === 'selecting-agent') { - return; - } - switch (footerItemSelected) { - case 'companion': - if (feature('BUDDY')) { - selectFooterItem(null); - void onSubmit('/buddy'); + navigateFooter(-1, true) + }, + 'footer:down': () => { + // ↓ scrolls within the coordinator task list, never leaves the pill + if ( + tasksSelected && + process.env.USER_TYPE === 'ant' && + coordinatorTaskCount > 0 + ) { + if (coordinatorTaskIndex < coordinatorTaskCount - 1) { + setCoordinatorTaskIndex(prev => prev + 1) } - break; - case 'tasks': - if (isTeammateMode) { - // Enter switches to the selected agent's view - if (teammateFooterIndex === 0) { - exitTeammateView(setAppState); - } else { - const teammate = inProcessTeammates[teammateFooterIndex - 1]; - if (teammate) enterTeammateView(teammate.id, setAppState); + return + } + if (tasksSelected && !isTeammateMode) { + setShowBashesDialog(true) + selectFooterItem(null) + return + } + navigateFooter(1) + }, + 'footer:next': () => { + // Teammate mode: ←/→ cycles within the team member list + if (tasksSelected && isTeammateMode) { + const totalAgents = 1 + inProcessTeammates.length + setTeammateFooterIndex(prev => (prev + 1) % totalAgents) + return + } + navigateFooter(1) + }, + 'footer:previous': () => { + if (tasksSelected && isTeammateMode) { + const totalAgents = 1 + inProcessTeammates.length + setTeammateFooterIndex(prev => (prev - 1 + totalAgents) % totalAgents) + return + } + navigateFooter(-1) + }, + 'footer:openSelected': () => { + if (viewSelectionMode === 'selecting-agent') { + return + } + switch (footerItemSelected) { + case 'companion': + if (feature('BUDDY')) { + selectFooterItem(null) + void onSubmit('/buddy') } - } else if (coordinatorTaskIndex === 0 && coordinatorTaskCount > 0) { - exitTeammateView(setAppState); - } else { - const selectedTaskId = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]?.id; - if (selectedTaskId) { - enterTeammateView(selectedTaskId, setAppState); + break + case 'tasks': + if (isTeammateMode) { + // Enter switches to the selected agent's view + if (teammateFooterIndex === 0) { + exitTeammateView(setAppState) + } else { + const teammate = inProcessTeammates[teammateFooterIndex - 1] + if (teammate) enterTeammateView(teammate.id, setAppState) + } + } else if (coordinatorTaskIndex === 0 && coordinatorTaskCount > 0) { + exitTeammateView(setAppState) } else { - setShowBashesDialog(true); - selectFooterItem(null); + const selectedTaskId = + getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]?.id + if (selectedTaskId) { + enterTeammateView(selectedTaskId, setAppState) + } else { + setShowBashesDialog(true) + selectFooterItem(null) + } } - } - break; - case 'tmux': - if ((process.env.USER_TYPE) === 'ant') { - setAppState(prev => prev.tungstenPanelAutoHidden ? { - ...prev, - tungstenPanelAutoHidden: false - } : { - ...prev, - tungstenPanelVisible: !(prev.tungstenPanelVisible ?? true) - }); - } - break; - case 'bagel': - break; - case 'teams': - setShowTeamsDialog(true); - selectFooterItem(null); - break; - case 'bridge': - setShowBridgeDialog(true); - selectFooterItem(null); - break; - } - }, - 'footer:clearSelection': () => { - selectFooterItem(null); - }, - 'footer:close': () => { - if (tasksSelected && coordinatorTaskIndex >= 1) { - const task = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]; - if (!task) return false; - // When the selected row IS the viewed agent, 'x' types into the - // steering input. Any other row — dismiss it. - if (viewSelectionMode === 'viewing-agent' && task.id === viewingAgentTaskId) { - onChange(input.slice(0, cursorOffset) + 'x' + input.slice(cursorOffset)); - setCursorOffset(cursorOffset + 1); - return; + break + case 'tmux': + if (process.env.USER_TYPE === 'ant') { + setAppState(prev => + prev.tungstenPanelAutoHidden + ? { ...prev, tungstenPanelAutoHidden: false } + : { + ...prev, + tungstenPanelVisible: !( + prev.tungstenPanelVisible ?? true + ), + }, + ) + } + break + case 'bagel': + break + case 'teams': + setShowTeamsDialog(true) + selectFooterItem(null) + break + case 'bridge': + setShowBridgeDialog(true) + selectFooterItem(null) + break } - stopOrDismissAgent(task.id, setAppState); - if (task.status !== 'running') { - setCoordinatorTaskIndex(i => Math.max(minCoordinatorIndex, i - 1)); + }, + 'footer:clearSelection': () => { + selectFooterItem(null) + }, + 'footer:close': () => { + if (tasksSelected && coordinatorTaskIndex >= 1) { + const task = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1] + if (!task) return false + // When the selected row IS the viewed agent, 'x' types into the + // steering input. Any other row — dismiss it. + if ( + viewSelectionMode === 'viewing-agent' && + task.id === viewingAgentTaskId + ) { + onChange( + input.slice(0, cursorOffset) + 'x' + input.slice(cursorOffset), + ) + setCursorOffset(cursorOffset + 1) + return + } + stopOrDismissAgent(task.id, setAppState) + if (task.status !== 'running') { + setCoordinatorTaskIndex(i => Math.max(minCoordinatorIndex, i - 1)) + } + return } - return; - } - // Not handled — let 'x' fall through to type-to-exit - return false; - } - }, { - context: 'Footer', - isActive: !!footerItemSelected && !isModalOverlayActive - }); + // Not handled — let 'x' fall through to type-to-exit + return false + }, + }, + { + context: 'Footer', + isActive: !!footerItemSelected && !isModalOverlayActive, + }, + ) + useInput((char, key) => { // Skip all input handling when a full-screen dialog is open. These dialogs // render via early return, but hooks run unconditionally — so without this // guard, Escape inside a dialog leaks to the double-press message-selector. - if (showTeamsDialog || showQuickOpen || showGlobalSearch || showHistoryPicker) { - return; + if ( + showTeamsDialog || + showQuickOpen || + showGlobalSearch || + showHistoryPicker + ) { + return } // Detect failed Alt shortcuts on macOS (Option key produces special characters) if (getPlatform() === 'macos' && isMacosOptionChar(char)) { - const shortcut = MACOS_OPTION_SPECIAL_CHARS[char]; - const terminalName = getNativeCSIuTerminalDisplayName(); - const jsx = terminalName ? + const shortcut = MACOS_OPTION_SPECIAL_CHARS[char] + const terminalName = getNativeCSIuTerminalDisplayName() + const jsx = terminalName ? ( + To enable {shortcut}, set Option as Meta in{' '} {terminalName} preferences (⌘,) - : To enable {shortcut}, run /terminal-setup; + + ) : ( + To enable {shortcut}, run /terminal-setup + ) addNotification({ key: 'option-meta-hint', jsx, priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) // Don't return - let the character be typed so user sees the issue } @@ -1895,21 +2440,31 @@ function PromptInput({ // the input and type the char. Nav keys are captured by useKeybindings // above, so anything reaching here is genuinely not a footer action. // onChange clears footerSelection, so no explicit deselect. - if (footerItemSelected && char && !key.ctrl && !key.meta && !key.escape && !key.return) { - onChange(input.slice(0, cursorOffset) + char + input.slice(cursorOffset)); - setCursorOffset(cursorOffset + char.length); - return; + if ( + footerItemSelected && + char && + !key.ctrl && + !key.meta && + !key.escape && + !key.return + ) { + onChange(input.slice(0, cursorOffset) + char + input.slice(cursorOffset)) + setCursorOffset(cursorOffset + char.length) + return } // Exit special modes when backspace/escape/delete/ctrl+u is pressed at cursor position 0 - if (cursorOffset === 0 && (key.escape || key.backspace || key.delete || key.ctrl && char === 'u')) { - onModeChange('prompt'); - setHelpOpen(false); + if ( + cursorOffset === 0 && + (key.escape || key.backspace || key.delete || (key.ctrl && char === 'u')) + ) { + onModeChange('prompt') + setHelpOpen(false) } // Exit help mode when backspace is pressed and input is empty if (helpOpen && input === '' && (key.backspace || key.delete)) { - setHelpOpen(false); + setHelpOpen(false) } // esc is a little overloaded: @@ -1922,73 +2477,83 @@ function PromptInput({ if (key.escape) { // Abort active speculation if (speculation.status === 'active') { - abortSpeculation(setAppState); - return; + abortSpeculation(setAppState) + return } // Dismiss side question response if visible if (isSideQuestionVisible && onDismissSideQuestion) { - onDismissSideQuestion(); - return; + onDismissSideQuestion() + return } // Close help menu if open if (helpOpen) { - setHelpOpen(false); - return; + setHelpOpen(false) + return } // Footer selection clearing is now handled via Footer context keybindings // (footer:clearSelection action bound to escape) // If a footer item is selected, let the Footer keybinding handle it if (footerItemSelected) { - return; + return } // If there's an editable queued command, move it to the input for editing when ESC is pressed - const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable); + const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable) if (hasEditableCommand) { - void popAllCommandsFromQueue(); - return; + void popAllCommandsFromQueue() + return } + if (messages.length > 0 && !input && !isLoading) { - doublePressEscFromEmpty(); + doublePressEscFromEmpty() } } + if (key.return && helpOpen) { - setHelpOpen(false); + setHelpOpen(false) } - }); - const swarmBanner = useSwarmBanner(); - const fastModeCooldown = isFastModeEnabled() ? isFastModeCooldown() : false; - const showFastIcon = isFastModeEnabled() ? isFastMode && (isFastModeAvailable() || fastModeCooldown) : false; - const showFastIconHint = useShowFastIconHint(showFastIcon ?? false); + }) + + const swarmBanner = useSwarmBanner() + + const fastModeCooldown = isFastModeEnabled() ? isFastModeCooldown() : false + const showFastIcon = isFastModeEnabled() + ? isFastMode && (isFastModeAvailable() || fastModeCooldown) + : false + + const showFastIconHint = useShowFastIconHint(showFastIcon ?? false) // Show effort notification on startup and when effort changes. // Suppressed in brief/assistant mode — the value reflects the local // client's effort, not the connected agent's. - const effortNotificationText = briefOwnsGap ? undefined : getEffortNotificationText(effortValue, mainLoopModel); + const effortNotificationText = briefOwnsGap + ? undefined + : getEffortNotificationText(effortValue, mainLoopModel) useEffect(() => { if (!effortNotificationText) { - removeNotification('effort-level'); - return; + removeNotification('effort-level') + return } addNotification({ key: 'effort-level', text: effortNotificationText, priority: 'high', - timeoutMs: 12_000 - }); - }, [effortNotificationText, addNotification, removeNotification]); - useBuddyNotification(); - const companionSpeaking = feature('BUDDY') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.companionReaction !== undefined) : false; - const { - columns, - rows - } = useTerminalSize(); - const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking); + timeoutMs: 12_000, + }) + }, [effortNotificationText, addNotification, removeNotification]) + + useBuddyNotification() + + const companionSpeaking = feature('BUDDY') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.companionReaction !== undefined) + : false + const { columns, rows } = useTerminalSize() + const textInputColumns = + columns - 3 - companionReservedColumns(columns, companionSpeaking) // POC: click-to-position-cursor. Mouse tracking is only enabled inside // , so this is dormant in the normal main-screen REPL. @@ -1996,184 +2561,324 @@ function PromptInput({ // tightly wraps the text input so they map directly to (column, line) // in the Cursor wrap model. MeasuredText.getOffsetFromPosition handles // wide chars, wrapped lines, and clamps past-end clicks to line end. - const maxVisibleLines = isFullscreenEnvEnabled() ? Math.max(MIN_INPUT_VIEWPORT_LINES, Math.floor(rows / 2) - PROMPT_FOOTER_LINES) : undefined; - const handleInputClick = useCallback((e: ClickEvent) => { - // During history search the displayed text is historyMatch, not - // input, and showCursor is false anyway — skip rather than - // compute an offset against the wrong string. - if (!input || isSearchingHistory) return; - const c = Cursor.fromText(input, textInputColumns, cursorOffset); - const viewportStart = c.getViewportStartLine(maxVisibleLines); - const offset = c.measuredText.getOffsetFromPosition({ - line: e.localRow + viewportStart, - column: e.localCol - }); - setCursorOffset(offset); - }, [input, textInputColumns, isSearchingHistory, cursorOffset, maxVisibleLines]); - const handleOpenTasksDialog = useCallback((taskId?: string) => setShowBashesDialog(taskId ?? true), [setShowBashesDialog]); - const placeholder = showPromptSuggestion && promptSuggestion ? promptSuggestion : defaultPlaceholder; + const maxVisibleLines = isFullscreenEnvEnabled() + ? Math.max( + MIN_INPUT_VIEWPORT_LINES, + Math.floor(rows / 2) - PROMPT_FOOTER_LINES, + ) + : undefined + + const handleInputClick = useCallback( + (e: ClickEvent) => { + // During history search the displayed text is historyMatch, not + // input, and showCursor is false anyway — skip rather than + // compute an offset against the wrong string. + if (!input || isSearchingHistory) return + const c = Cursor.fromText(input, textInputColumns, cursorOffset) + const viewportStart = c.getViewportStartLine(maxVisibleLines) + const offset = c.measuredText.getOffsetFromPosition({ + line: e.localRow + viewportStart, + column: e.localCol, + }) + setCursorOffset(offset) + }, + [ + input, + textInputColumns, + isSearchingHistory, + cursorOffset, + maxVisibleLines, + ], + ) + + const handleOpenTasksDialog = useCallback( + (taskId?: string) => setShowBashesDialog(taskId ?? true), + [setShowBashesDialog], + ) + + const placeholder = + showPromptSuggestion && promptSuggestion + ? promptSuggestion + : defaultPlaceholder // Calculate if input has multiple lines - const isInputWrapped = useMemo(() => input.includes('\n'), [input]); + const isInputWrapped = useMemo(() => input.includes('\n'), [input]) // Memoized callbacks for model picker to prevent re-renders when unrelated // state (like notifications) changes. This prevents the inline model picker // from visually "jumping" when notifications arrive. - const handleModelSelect = useCallback((model: string | null, _effort: EffortLevel | undefined) => { - let wasFastModeDisabled = false; - setAppState(prev => { - wasFastModeDisabled = isFastModeEnabled() && !isFastModeSupportedByModel(model) && !!prev.fastMode; - return { - ...prev, - mainLoopModel: model, - mainLoopModelForSession: null, - // Turn off fast mode if switching to a model that doesn't support it - ...(wasFastModeDisabled && { - fastMode: false - }) - }; - }); - setShowModelPicker(false); - const effectiveFastMode = (isFastMode ?? false) && !wasFastModeDisabled; - let message = `Model set to ${modelDisplayString(model)}`; - if (isBilledAsExtraUsage(model, effectiveFastMode, isOpus1mMergeEnabled())) { - message += ' · Billed as extra usage'; - } - if (wasFastModeDisabled) { - message += ' · Fast mode OFF'; - } - addNotification({ - key: 'model-switched', - jsx: {message}, - priority: 'immediate', - timeoutMs: 3000 - }); - logEvent('tengu_model_picker_hotkey', { - model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }, [setAppState, addNotification, isFastMode]); + const handleModelSelect = useCallback( + (model: string | null, _effort: EffortLevel | undefined) => { + let wasFastModeDisabled = false + setAppState(prev => { + wasFastModeDisabled = + isFastModeEnabled() && + !isFastModeSupportedByModel(model) && + !!prev.fastMode + return { + ...prev, + mainLoopModel: model, + mainLoopModelForSession: null, + // Turn off fast mode if switching to a model that doesn't support it + ...(wasFastModeDisabled && { fastMode: false }), + } + }) + setShowModelPicker(false) + const effectiveFastMode = (isFastMode ?? false) && !wasFastModeDisabled + let message = `Model set to ${modelDisplayString(model)}` + if ( + isBilledAsExtraUsage(model, effectiveFastMode, isOpus1mMergeEnabled()) + ) { + message += ' · Billed as extra usage' + } + if (wasFastModeDisabled) { + message += ' · Fast mode OFF' + } + addNotification({ + key: 'model-switched', + jsx: {message}, + priority: 'immediate', + timeoutMs: 3000, + }) + logEvent('tengu_model_picker_hotkey', { + model: + model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + }, + [setAppState, addNotification, isFastMode], + ) + const handleModelCancel = useCallback(() => { - setShowModelPicker(false); - }, []); + setShowModelPicker(false) + }, []) // Memoize the model picker element to prevent unnecessary re-renders // when AppState changes for unrelated reasons (e.g., notifications arriving) const modelPickerElement = useMemo(() => { - if (!showModelPicker) return null; - return - - ; - }, [showModelPicker, mainLoopModel_, mainLoopModelForSession, handleModelSelect, handleModelCancel]); - const handleFastModeSelect = useCallback((result?: string) => { - setShowFastModePicker(false); - if (result) { - addNotification({ - key: 'fast-mode-toggled', - jsx: {result}, - priority: 'immediate', - timeoutMs: 3000 - }); - } - }, [addNotification]); + if (!showModelPicker) return null + return ( + + + + ) + }, [ + showModelPicker, + mainLoopModel_, + mainLoopModelForSession, + handleModelSelect, + handleModelCancel, + ]) + + const handleFastModeSelect = useCallback( + (result?: string) => { + setShowFastModePicker(false) + if (result) { + addNotification({ + key: 'fast-mode-toggled', + jsx: {result}, + priority: 'immediate', + timeoutMs: 3000, + }) + } + }, + [addNotification], + ) // Memoize the fast mode picker element const fastModePickerElement = useMemo(() => { - if (!showFastModePicker) return null; - return - - ; - }, [showFastModePicker, handleFastModeSelect]); + if (!showFastModePicker) return null + return ( + + + + ) + }, [showFastModePicker, handleFastModeSelect]) // Memoized callbacks for thinking toggle - const handleThinkingSelect = useCallback((enabled: boolean) => { - setAppState(prev => ({ - ...prev, - thinkingEnabled: enabled - })); - setShowThinkingToggle(false); - logEvent('tengu_thinking_toggled_hotkey', { - enabled - }); - addNotification({ - key: 'thinking-toggled-hotkey', - jsx: + const handleThinkingSelect = useCallback( + (enabled: boolean) => { + setAppState(prev => ({ + ...prev, + thinkingEnabled: enabled, + })) + setShowThinkingToggle(false) + logEvent('tengu_thinking_toggled_hotkey', { enabled }) + addNotification({ + key: 'thinking-toggled-hotkey', + jsx: ( + Thinking {enabled ? 'on' : 'off'} - , - priority: 'immediate', - timeoutMs: 3000 - }); - }, [setAppState, addNotification]); + + ), + priority: 'immediate', + timeoutMs: 3000, + }) + }, + [setAppState, addNotification], + ) + const handleThinkingCancel = useCallback(() => { - setShowThinkingToggle(false); - }, []); + setShowThinkingToggle(false) + }, []) // Memoize the thinking toggle element const thinkingToggleElement = useMemo(() => { - if (!showThinkingToggle) return null; - return - m.type === 'assistant')} /> - ; - }, [showThinkingToggle, thinkingEnabled, handleThinkingSelect, handleThinkingCancel, messages.length]); + if (!showThinkingToggle) return null + return ( + + m.type === 'assistant')} + /> + + ) + }, [ + showThinkingToggle, + thinkingEnabled, + handleThinkingSelect, + handleThinkingCancel, + messages.length, + ]) // Portal dialog to DialogOverlay in fullscreen so it escapes the bottom // slot's overflowY:hidden clip (same pattern as SuggestionsOverlay). // Must be called before early returns below to satisfy rules-of-hooks. // Memoized so the portal useEffect doesn't churn on every PromptInput render. - const autoModeOptInDialog = useMemo(() => feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? : null, [showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline]); - useSetPromptOverlayDialog(isFullscreenEnvEnabled() ? autoModeOptInDialog : null); + const autoModeOptInDialog = useMemo( + () => + feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? ( + + ) : null, + [showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline], + ) + useSetPromptOverlayDialog( + isFullscreenEnvEnabled() ? autoModeOptInDialog : null, + ) + if (showBashesDialog) { - return setShowBashesDialog(false)} toolUseContext={getToolUseContext(messages, [], new AbortController(), mainLoopModel)} initialDetailTaskId={typeof showBashesDialog === 'string' ? showBashesDialog : undefined} />; + return ( + setShowBashesDialog(false)} + toolUseContext={getToolUseContext( + messages, + [], + new AbortController(), + mainLoopModel, + )} + initialDetailTaskId={ + typeof showBashesDialog === 'string' ? showBashesDialog : undefined + } + /> + ) } + if (isAgentSwarmsEnabled() && showTeamsDialog) { - return { - setShowTeamsDialog(false); - }} />; + return ( + { + setShowTeamsDialog(false) + }} + /> + ) } + if (feature('QUICK_SEARCH')) { const insertWithSpacing = (text: string) => { - const cursorChar = input[cursorOffset - 1] ?? ' '; - insertTextAtCursor(/\s/.test(cursorChar) ? text : ` ${text}`); - }; + const cursorChar = input[cursorOffset - 1] ?? ' ' + insertTextAtCursor(/\s/.test(cursorChar) ? text : ` ${text}`) + } if (showQuickOpen) { - return setShowQuickOpen(false)} onInsert={insertWithSpacing} />; + return ( + setShowQuickOpen(false)} + onInsert={insertWithSpacing} + /> + ) } if (showGlobalSearch) { - return setShowGlobalSearch(false)} onInsert={insertWithSpacing} />; + return ( + setShowGlobalSearch(false)} + onInsert={insertWithSpacing} + /> + ) } } + if (feature('HISTORY_PICKER') && showHistoryPicker) { - return { - const entryMode = getModeFromInput(entry.display); - const value = getValueFromInput(entry.display); - onModeChange(entryMode); - trackAndSetInput(value); - setPastedContents(entry.pastedContents); - setCursorOffset(value.length); - setShowHistoryPicker(false); - }} onCancel={() => setShowHistoryPicker(false)} />; + return ( + { + const entryMode = getModeFromInput(entry.display) + const value = getValueFromInput(entry.display) + onModeChange(entryMode) + trackAndSetInput(value) + setPastedContents(entry.pastedContents) + setCursorOffset(value.length) + setShowHistoryPicker(false) + }} + onCancel={() => setShowHistoryPicker(false)} + /> + ) } // Show loop mode menu when requested (ant-only, eliminated from external builds) if (modelPickerElement) { - return modelPickerElement; + return modelPickerElement } + if (fastModePickerElement) { - return fastModePickerElement; + return fastModePickerElement } + if (thinkingToggleElement) { - return thinkingToggleElement; + return thinkingToggleElement } + if (showBridgeDialog) { - return { - setShowBridgeDialog(false); - selectFooterItem(null); - }} />; + return ( + { + setShowBridgeDialog(false) + selectFooterItem(null) + }} + /> + ) } + const baseProps: BaseTextInputProps = { multiline: true, onSubmit, onChange, - value: historyMatch ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input, + value: historyMatch + ? getValueFromInput( + typeof historyMatch === 'string' + ? historyMatch + : historyMatch.display, + ) + : input, // History navigation is handled via TextInput props (onHistoryUp/onHistoryDown), // NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown // to try cursor movement first and only fall through to history navigation when the @@ -2183,117 +2888,243 @@ function PromptInput({ onHistoryReset: resetHistory, placeholder, onExit, - onExitMessage: (show, key) => setExitMessage({ - show, - key - }), + onExitMessage: (show, key) => setExitMessage({ show, key }), onImagePaste, columns: textInputColumns, maxVisibleLines, - disableCursorMovementForUpDownKeys: suggestions.length > 0 || !!footerItemSelected, + disableCursorMovementForUpDownKeys: + suggestions.length > 0 || !!footerItemSelected, disableEscapeDoublePress: suggestions.length > 0, cursorOffset, onChangeCursorOffset: setCursorOffset, onPaste: onTextPaste, onIsPastingChange: setIsPasting, focus: !isSearchingHistory && !isModalOverlayActive && !footerItemSelected, - showCursor: !footerItemSelected && !isSearchingHistory && !cursorAtImageChip, + showCursor: + !footerItemSelected && !isSearchingHistory && !cursorAtImageChip, argumentHint: commandArgumentHint, - onUndo: canUndo ? () => { - const previousState = undo(); - if (previousState) { - trackAndSetInput(previousState.text); - setCursorOffset(previousState.cursorOffset); - setPastedContents(previousState.pastedContents); - } - } : undefined, + onUndo: canUndo + ? () => { + const previousState = undo() + if (previousState) { + trackAndSetInput(previousState.text) + setCursorOffset(previousState.cursorOffset) + setPastedContents(previousState.pastedContents) + } + } + : undefined, highlights: combinedHighlights, inlineGhostText, - inputFilter: lazySpaceInputFilter - }; + inputFilter: lazySpaceInputFilter, + } + const getBorderColor = (): keyof Theme => { const modeColors: Record = { - bash: 'bashBorder' - }; + bash: 'bashBorder', + } // Mode colors take priority, then teammate color, then default if (modeColors[mode]) { - return modeColors[mode]; + return modeColors[mode] } // In-process teammates run headless - don't apply teammate colors to leader UI if (isInProcessTeammate()) { - return 'promptBorder'; + return 'promptBorder' } // Check for teammate color from environment - const teammateColorName = getTeammateColor(); - if (teammateColorName && AGENT_COLORS.includes(teammateColorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName]; + const teammateColorName = getTeammateColor() + if ( + teammateColorName && + AGENT_COLORS.includes(teammateColorName as AgentColorName) + ) { + return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName] } - return 'promptBorder'; - }; + + return 'promptBorder' + } + if (isExternalEditorActive) { - return + return ( + Save and close editor to continue... - ; + + ) } - const textInputElement = isVimModeEnabled() ? : ; - return + + const textInputElement = isVimModeEnabled() ? ( + + ) : ( + + ) + + return ( + {!isFullscreenEnvEnabled() && } - {hasSuppressedDialogs && + {hasSuppressedDialogs && ( + Waiting for permission… - } + + )} - {swarmBanner ? <> + {swarmBanner ? ( + <> - {swarmBanner.text ? <> - {'─'.repeat(Math.max(0, columns - stringWidth(swarmBanner.text) - 4))} + {swarmBanner.text ? ( + <> + {'─'.repeat( + Math.max(0, columns - stringWidth(swarmBanner.text) - 4), + )} {' '} {swarmBanner.text}{' '} {'──'} - : '─'.repeat(columns)} + + ) : ( + '─'.repeat(columns) + )} - + {textInputElement} {'─'.repeat(columns)} - : - + + ) : ( + + {textInputElement} - } - 0} isLoading={isLoading} tasksSelected={tasksSelected} teamsSelected={teamsSelected} bridgeSelected={bridgeSelected} tmuxSelected={tmuxSelected} teammateFooterIndex={teammateFooterIndex} ideSelection={ideSelection} mcpClients={mcpClients} isPasting={isPasting} isInputWrapped={isInputWrapped} messages={messages} isSearching={isSearchingHistory} historyQuery={historyQuery} setHistoryQuery={setHistoryQuery} historyFailedMatch={historyFailedMatch} onOpenTasksDialog={isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined} /> + + )} + 0} + isLoading={isLoading} + tasksSelected={tasksSelected} + teamsSelected={teamsSelected} + bridgeSelected={bridgeSelected} + tmuxSelected={tmuxSelected} + teammateFooterIndex={teammateFooterIndex} + ideSelection={ideSelection} + mcpClients={mcpClients} + isPasting={isPasting} + isInputWrapped={isInputWrapped} + messages={messages} + isSearching={isSearchingHistory} + historyQuery={historyQuery} + setHistoryQuery={setHistoryQuery} + historyFailedMatch={historyFailedMatch} + onOpenTasksDialog={ + isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined + } + /> {isFullscreenEnvEnabled() ? null : autoModeOptInDialog} - {isFullscreenEnvEnabled() ? - // position=absolute takes zero layout height so the spinner - // doesn't shift when a notification appears/disappears. Yoga - // anchors absolute children at the parent's content-box origin; - // marginTop=-1 pulls it into the marginTop=1 gap row above the - // prompt border. In brief mode there is no such gap (briefOwnsGap - // strips our marginTop) and BriefSpinner sits flush against the - // border — marginTop=-2 skips over the spinner content into - // BriefSpinner's own marginTop=1 blank row. height=1 + - // overflow=hidden clips multi-line notifications to a single row. - // flex-end anchors the bottom line so the visible row is always - // the most recent. Suppressed while the slash overlay or - // auto-mode opt-in dialog is up by height=0 (NOT unmount) — this - // Box renders later in tree order so it would paint over their - // bottom row. Keeping Notifications mounted prevents AutoUpdater's - // initial-check effect from re-firing on every slash-completion - // toggle (PR#22413). - - - : null} - ; + {isFullscreenEnvEnabled() ? ( + // position=absolute takes zero layout height so the spinner + // doesn't shift when a notification appears/disappears. Yoga + // anchors absolute children at the parent's content-box origin; + // marginTop=-1 pulls it into the marginTop=1 gap row above the + // prompt border. In brief mode there is no such gap (briefOwnsGap + // strips our marginTop) and BriefSpinner sits flush against the + // border — marginTop=-2 skips over the spinner content into + // BriefSpinner's own marginTop=1 blank row. height=1 + + // overflow=hidden clips multi-line notifications to a single row. + // flex-end anchors the bottom line so the visible row is always + // the most recent. Suppressed while the slash overlay or + // auto-mode opt-in dialog is up by height=0 (NOT unmount) — this + // Box renders later in tree order so it would paint over their + // bottom row. Keeping Notifications mounted prevents AutoUpdater's + // initial-check effect from re-firing on every slash-completion + // toggle (PR#22413). + + + + ) : null} + + ) } /** @@ -2301,38 +3132,46 @@ function PromptInput({ * This handles --continue/--resume scenarios where we need to avoid ID collisions. */ function getInitialPasteId(messages: Message[]): number { - let maxId = 0; + let maxId = 0 for (const message of messages) { if (message.type === 'user') { // Check image paste IDs if (message.imagePasteIds) { - for (const id of message.imagePasteIds as number[]) { - if (id > maxId) maxId = id; + for (const id of message.imagePasteIds) { + if (id > maxId) maxId = id } } // Check text paste references in message content if (Array.isArray(message.message.content)) { for (const block of message.message.content) { if (block.type === 'text') { - const refs = parseReferences(block.text); + const refs = parseReferences(block.text) for (const ref of refs) { - if (ref.id > maxId) maxId = ref.id; + if (ref.id > maxId) maxId = ref.id } } } } } } - return maxId + 1; + return maxId + 1 } -function buildBorderText(showFastIcon: boolean, showFastIconHint: boolean, fastModeCooldown: boolean): BorderTextOptions | undefined { - if (!showFastIcon) return undefined; - const fastSeg = showFastIconHint ? `${getFastIconString(true, fastModeCooldown)} ${chalk.dim('/fast')}` : getFastIconString(true, fastModeCooldown); + +function buildBorderText( + showFastIcon: boolean, + showFastIconHint: boolean, + fastModeCooldown: boolean, +): BorderTextOptions | undefined { + if (!showFastIcon) return undefined + const fastSeg = showFastIconHint + ? `${getFastIconString(true, fastModeCooldown)} ${chalk.dim('/fast')}` + : getFastIconString(true, fastModeCooldown) return { content: ` ${fastSeg} `, position: 'top', align: 'end', - offset: 0 - }; + offset: 0, + } } -export default React.memo(PromptInput); + +export default React.memo(PromptInput) diff --git a/src/components/PromptInput/PromptInputFooter.tsx b/src/components/PromptInput/PromptInputFooter.tsx index 8f23dfdb9..652bdf3f0 100644 --- a/src/components/PromptInput/PromptInputFooter.tsx +++ b/src/components/PromptInput/PromptInputFooter.tsx @@ -1,65 +1,77 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { memo, type ReactNode, useMemo, useRef } from 'react'; -import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'; -import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'; -import { useSetPromptOverlay } from '../../context/promptOverlayContext.js'; -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; -import type { IDESelection } from '../../hooks/useIdeSelection.js'; -import { useSettings } from '../../hooks/useSettings.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Box, Text } from '../../ink.js'; -import type { MCPServerConnection } from '../../services/mcp/types.js'; -import { useAppState } from '../../state/AppState.js'; -import type { ToolPermissionContext } from '../../Tool.js'; -import type { Message } from '../../types/message.js'; -import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'; -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import { isUndercover } from '../../utils/undercover.js'; -import { CoordinatorTaskPanel, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js'; -import { getLastAssistantMessageId, StatusLine, statusLineShouldDisplay } from '../StatusLine.js'; -import { Notifications } from './Notifications.js'; -import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'; -import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'; -import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { memo, type ReactNode, useMemo, useRef } from 'react' +import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' +import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js' +import { useSetPromptOverlay } from '../../context/promptOverlayContext.js' +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' +import type { IDESelection } from '../../hooks/useIdeSelection.js' +import { useSettings } from '../../hooks/useSettings.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Text } from '../../ink.js' +import type { MCPServerConnection } from '../../services/mcp/types.js' +import { useAppState } from '../../state/AppState.js' +import type { ToolPermissionContext } from '../../Tool.js' +import type { Message } from '../../types/message.js' +import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js' +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import { isUndercover } from '../../utils/undercover.js' +import { + CoordinatorTaskPanel, + useCoordinatorTaskCount, +} from '../CoordinatorAgentStatus.js' +import { + getLastAssistantMessageId, + StatusLine, + statusLineShouldDisplay, +} from '../StatusLine.js' +import { Notifications } from './Notifications.js' +import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js' +import { + PromptInputFooterSuggestions, + type SuggestionItem, +} from './PromptInputFooterSuggestions.js' +import { PromptInputHelpMenu } from './PromptInputHelpMenu.js' + type Props = { - apiKeyStatus: VerificationStatus; - debug: boolean; + apiKeyStatus: VerificationStatus + debug: boolean exitMessage: { - show: boolean; - key?: string; - }; - vimMode: VimMode | undefined; - mode: PromptInputMode; - autoUpdaterResult: AutoUpdaterResult | null; - isAutoUpdating: boolean; - verbose: boolean; - onAutoUpdaterResult: (result: AutoUpdaterResult) => void; - onChangeIsUpdating: (isUpdating: boolean) => void; - suggestions: SuggestionItem[]; - selectedSuggestion: number; - maxColumnWidth?: number; - toolPermissionContext: ToolPermissionContext; - helpOpen: boolean; - suppressHint: boolean; - isLoading: boolean; - tasksSelected: boolean; - teamsSelected: boolean; - bridgeSelected: boolean; - tmuxSelected: boolean; - teammateFooterIndex?: number; - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; - isPasting?: boolean; - isInputWrapped?: boolean; - messages: Message[]; - isSearching: boolean; - historyQuery: string; - setHistoryQuery: (query: string) => void; - historyFailedMatch: boolean; - onOpenTasksDialog?: (taskId?: string) => void; -}; + show: boolean + key?: string + } + vimMode: VimMode | undefined + mode: PromptInputMode + autoUpdaterResult: AutoUpdaterResult | null + isAutoUpdating: boolean + verbose: boolean + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + onChangeIsUpdating: (isUpdating: boolean) => void + suggestions: SuggestionItem[] + selectedSuggestion: number + maxColumnWidth?: number + toolPermissionContext: ToolPermissionContext + helpOpen: boolean + suppressHint: boolean + isLoading: boolean + tasksSelected: boolean + teamsSelected: boolean + bridgeSelected: boolean + tmuxSelected: boolean + teammateFooterIndex?: number + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] + isPasting?: boolean + isInputWrapped?: boolean + messages: Message[] + isSearching: boolean + historyQuery: string + setHistoryQuery: (query: string) => void + historyFailedMatch: boolean + onOpenTasksDialog?: (taskId?: string) => void +} + function PromptInputFooter({ apiKeyStatus, debug, @@ -92,99 +104,176 @@ function PromptInputFooter({ historyQuery, setHistoryQuery, historyFailedMatch, - onOpenTasksDialog + onOpenTasksDialog, }: Props): ReactNode { - const settings = useSettings(); - const { - columns, - rows - } = useTerminalSize(); - const messagesRef = useRef(messages); - messagesRef.current = messages; - const lastAssistantMessageId = useMemo(() => getLastAssistantMessageId(messages), [messages]); - const isNarrow = columns < 80; + const settings = useSettings() + const { columns, rows } = useTerminalSize() + const messagesRef = useRef(messages) + messagesRef.current = messages + const lastAssistantMessageId = useMemo( + () => getLastAssistantMessageId(messages), + [messages], + ) + const isNarrow = columns < 80 // In fullscreen the bottom slot is flexShrink:0, so every row here is a row // stolen from the ScrollBox. Drop the optional StatusLine first. Non-fullscreen // has terminal scrollback to absorb overflow, so we never hide StatusLine there. - const isFullscreen = isFullscreenEnvEnabled(); - const isShort = isFullscreen && rows < 24; + const isFullscreen = isFullscreenEnvEnabled() + const isShort = isFullscreen && rows < 24 // Pill highlights when tasks is the active footer item AND no specific // agent row is selected. When coordinatorTaskIndex >= 0 the pointer has // moved into CoordinatorTaskPanel, so the pill should un-highlight. // coordinatorTaskCount === 0 covers the bash-only case (no agent rows // exist, pill is the only selectable item). - const coordinatorTaskCount = useCoordinatorTaskCount(); - const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); - const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0); + const coordinatorTaskCount = useCoordinatorTaskCount() + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) + const pillSelected = + tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0) // Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r - const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching; + const suppressHint = + suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching // Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx - const overlayData = useMemo(() => isFullscreen && suggestions.length ? { - suggestions, - selectedSuggestion, - maxColumnWidth - } : null, [isFullscreen, suggestions, selectedSuggestion, maxColumnWidth]); - useSetPromptOverlay(overlayData); + const overlayData = useMemo( + () => + isFullscreen && suggestions.length + ? { suggestions, selectedSuggestion, maxColumnWidth } + : null, + [isFullscreen, suggestions, selectedSuggestion, maxColumnWidth], + ) + useSetPromptOverlay(overlayData) + if (suggestions.length && !isFullscreen) { - return - - ; + return ( + + + + ) } + if (helpOpen) { - return ; + return ( + + ) } - return <> - + + return ( + <> + - {mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && } - + {mode === 'prompt' && + !isShort && + !exitMessage.show && + !isPasting && + statusLineShouldDisplay(settings) && ( + + )} + - {isFullscreen ? null : } - {(process.env.USER_TYPE) === 'ant' && isUndercover() && undercover} + {isFullscreen ? null : ( + + )} + {process.env.USER_TYPE === 'ant' && isUndercover() && ( + undercover + )} - {(process.env.USER_TYPE) === 'ant' && } - ; + {process.env.USER_TYPE === 'ant' && } + + ) } -export default memo(PromptInputFooter); + +export default memo(PromptInputFooter) + type BridgeStatusProps = { - bridgeSelected: boolean; -}; + bridgeSelected: boolean +} + function BridgeStatusIndicator({ - bridgeSelected + bridgeSelected, }: BridgeStatusProps): React.ReactNode { - if (!feature('BRIDGE_MODE')) return null; + if (!feature('BRIDGE_MODE')) return null // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const enabled = useAppState(s => s.replBridgeEnabled); + const enabled = useAppState(s => s.replBridgeEnabled) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const connected = useAppState(s_0 => s_0.replBridgeConnected); + const connected = useAppState(s => s.replBridgeConnected) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const sessionActive = useAppState(s_1 => s_1.replBridgeSessionActive); + const sessionActive = useAppState(s => s.replBridgeSessionActive) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const reconnecting = useAppState(s_2 => s_2.replBridgeReconnecting); + const reconnecting = useAppState(s => s.replBridgeReconnecting) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const explicit = useAppState(s_3 => s_3.replBridgeExplicit); + const explicit = useAppState(s => s.replBridgeExplicit) // Failed state is surfaced via notification (useReplBridge), not a footer pill. - if (!isBridgeEnabled() || !enabled) return null; + if (!isBridgeEnabled() || !enabled) return null + const status = getBridgeStatus({ error: undefined, connected, sessionActive, - reconnecting - }); + reconnecting, + }) // For implicit (config-driven) remote, only show the reconnecting state if (!explicit && status.label !== 'Remote Control reconnecting') { - return null; + return null } - return + + return ( + {status.label} {bridgeSelected && · Enter to view} - ; + + ) } diff --git a/src/components/PromptInput/PromptInputFooterLeftSide.tsx b/src/components/PromptInput/PromptInputFooterLeftSide.tsx index 9480af9eb..fc1be8124 100644 --- a/src/components/PromptInput/PromptInputFooterLeftSide.tsx +++ b/src/components/PromptInput/PromptInputFooterLeftSide.tsx @@ -1,239 +1,207 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle'; +import { feature } from 'bun:bundle' // Dead code elimination: conditional import for COORDINATOR_MODE /* eslint-disable @typescript-eslint/no-require-imports */ -const coordinatorModule = feature('COORDINATOR_MODE') ? require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js') : undefined; +const coordinatorModule = feature('COORDINATOR_MODE') + ? (require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js')) + : undefined /* eslint-enable @typescript-eslint/no-require-imports */ -import { Box, Text, Link } from '../../ink.js'; -import * as React from 'react'; -import figures from 'figures'; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js'; -import type { ToolPermissionContext } from '../../Tool.js'; -import { isVimModeEnabled } from './utils.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { isDefaultMode, permissionModeSymbol, permissionModeTitle, getModeColor } from '../../utils/permissions/PermissionMode.js'; -import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js'; -import { isBackgroundTask } from '../../tasks/types.js'; -import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js'; -import { count } from '../../utils/array.js'; -import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { TeamStatus } from '../teams/TeamStatus.js'; -import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'; -import { useAppState, useAppStateStore } from 'src/state/AppState.js'; -import { getIsRemoteMode } from '../../bootstrap/state.js'; -import HistorySearchInput from './HistorySearchInput.js'; -import { usePrStatus } from '../../hooks/usePrStatus.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useTasksV2 } from '../../hooks/useTasksV2.js'; -import { formatDuration } from '../../utils/format.js'; -import { VoiceWarmupHint } from './VoiceIndicator.js'; -import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'; -import { useVoiceState } from '../../context/voice.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import { isXtermJs } from '../../ink/terminal.js'; -import { useHasSelection, useSelection } from '../../ink/hooks/use-selection.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { getPlatform } from '../../utils/platform.js'; -import { PrBadge } from '../PrBadge.js'; +import { Box, Text, Link } from '../../ink.js' +import * as React from 'react' +import figures from 'figures' +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js' +import type { ToolPermissionContext } from '../../Tool.js' +import { isVimModeEnabled } from './utils.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { + isDefaultMode, + permissionModeSymbol, + permissionModeTitle, + getModeColor, +} from '../../utils/permissions/PermissionMode.js' +import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js' +import { isBackgroundTask } from '../../tasks/types.js' +import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js' +import { count } from '../../utils/array.js' +import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { TeamStatus } from '../teams/TeamStatus.js' +import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js' +import { useAppState, useAppStateStore } from 'src/state/AppState.js' +import { getIsRemoteMode } from '../../bootstrap/state.js' +import HistorySearchInput from './HistorySearchInput.js' +import { usePrStatus } from '../../hooks/usePrStatus.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { useTasksV2 } from '../../hooks/useTasksV2.js' +import { formatDuration } from '../../utils/format.js' +import { VoiceWarmupHint } from './VoiceIndicator.js' +import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js' +import { useVoiceState } from '../../context/voice.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import { isXtermJs } from '../../ink/terminal.js' +import { useHasSelection, useSelection } from '../../ink/hooks/use-selection.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { getPlatform } from '../../utils/platform.js' +import { PrBadge } from '../PrBadge.js' // Dead code elimination: conditional import for proactive mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') : null; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? require('../../proactive/index.js') + : null /* eslint-enable @typescript-eslint/no-require-imports */ -const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; -const NULL = () => null; -const MAX_VOICE_HINT_SHOWS = 3; +const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} +const NULL = () => null +const MAX_VOICE_HINT_SHOWS = 3 + type Props = { exitMessage: { - show: boolean; - key?: string; - }; - vimMode: VimMode | undefined; - mode: PromptInputMode; - toolPermissionContext: ToolPermissionContext; - suppressHint: boolean; - isLoading: boolean; - showMemoryTypeSelector?: boolean; - tasksSelected: boolean; - teamsSelected: boolean; - tmuxSelected: boolean; - teammateFooterIndex?: number; - isPasting?: boolean; - isSearching: boolean; - historyQuery: string; - setHistoryQuery: (query: string) => void; - historyFailedMatch: boolean; - onOpenTasksDialog?: (taskId?: string) => void; -}; -function ProactiveCountdown() { - const $ = _c(7); - const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL); - const [remainingSeconds, setRemainingSeconds] = useState(null); - let t0; - let t1; - if ($[0] !== nextTickAt) { - t0 = () => { - if (nextTickAt === null) { - setRemainingSeconds(null); - return; - } - const update = function update() { - const remaining = Math.max(0, Math.ceil((nextTickAt - Date.now()) / 1000)); - setRemainingSeconds(remaining); - }; - update(); - const interval = setInterval(update, 1000); - return () => clearInterval(interval); - }; - t1 = [nextTickAt]; - $[0] = nextTickAt; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; + show: boolean + key?: string } - useEffect(t0, t1); - if (remainingSeconds === null) { - return null; - } - const t2 = remainingSeconds * 1000; - let t3; - if ($[3] !== t2) { - t3 = formatDuration(t2, { - mostSignificantOnly: true - }); - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t3) { - t4 = waiting{" "}{t3}; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - return t4; + vimMode: VimMode | undefined + mode: PromptInputMode + toolPermissionContext: ToolPermissionContext + suppressHint: boolean + isLoading: boolean + showMemoryTypeSelector?: boolean + tasksSelected: boolean + teamsSelected: boolean + tmuxSelected: boolean + teammateFooterIndex?: number + isPasting?: boolean + isSearching: boolean + historyQuery: string + setHistoryQuery: (query: string) => void + historyFailedMatch: boolean + onOpenTasksDialog?: (taskId?: string) => void } -export function PromptInputFooterLeftSide(t0) { - const $ = _c(27); - const { - exitMessage, - vimMode, - mode, - toolPermissionContext, - suppressHint, - isLoading, - tasksSelected, - teamsSelected, - tmuxSelected, - teammateFooterIndex, - isPasting, - isSearching, - historyQuery, - setHistoryQuery, - historyFailedMatch, - onOpenTasksDialog - } = t0; - if (exitMessage.show) { - let t1; - if ($[0] !== exitMessage.key) { - t1 = Press {exitMessage.key} again to exit; - $[0] = exitMessage.key; - $[1] = t1; - } else { - t1 = $[1]; + +function ProactiveCountdown(): React.ReactNode { + const nextTickAt = useSyncExternalStore( + proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, + proactiveModule?.getNextTickAt ?? NULL, + NULL, + ) + + const [remainingSeconds, setRemainingSeconds] = useState(null) + + useEffect(() => { + if (nextTickAt === null) { + setRemainingSeconds(null) + return } - return t1; + + function update(): void { + const remaining = Math.max( + 0, + Math.ceil((nextTickAt! - Date.now()) / 1000), + ) + setRemainingSeconds(remaining) + } + + update() + const interval = setInterval(update, 1000) + return () => clearInterval(interval) + }, [nextTickAt]) + + if (remainingSeconds === null) return null + + return ( + + waiting{' '} + {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })} + + ) +} + +export function PromptInputFooterLeftSide({ + exitMessage, + vimMode, + mode, + toolPermissionContext, + suppressHint, + isLoading, + tasksSelected, + teamsSelected, + tmuxSelected, + teammateFooterIndex, + isPasting, + isSearching, + historyQuery, + setHistoryQuery, + historyFailedMatch, + onOpenTasksDialog, +}: Props): React.ReactNode { + if (exitMessage.show) { + return ( + + Press {exitMessage.key} again to exit + + ) } if (isPasting) { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Pasting text…; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + return ( + + Pasting text… + + ) } - let t1; - if ($[3] !== isSearching || $[4] !== vimMode) { - t1 = isVimModeEnabled() && vimMode === "INSERT" && !isSearching; - $[3] = isSearching; - $[4] = vimMode; - $[5] = t1; - } else { - t1 = $[5]; - } - const showVim = t1; - let t2; - if ($[6] !== historyFailedMatch || $[7] !== historyQuery || $[8] !== isSearching || $[9] !== setHistoryQuery) { - t2 = isSearching && ; - $[6] = historyFailedMatch; - $[7] = historyQuery; - $[8] = isSearching; - $[9] = setHistoryQuery; - $[10] = t2; - } else { - t2 = $[10]; - } - let t3; - if ($[11] !== showVim) { - t3 = showVim ? -- INSERT -- : null; - $[11] = showVim; - $[12] = t3; - } else { - t3 = $[12]; - } - const t4 = !suppressHint && !showVim; - let t5; - if ($[13] !== isLoading || $[14] !== mode || $[15] !== onOpenTasksDialog || $[16] !== t4 || $[17] !== tasksSelected || $[18] !== teammateFooterIndex || $[19] !== teamsSelected || $[20] !== tmuxSelected || $[21] !== toolPermissionContext) { - t5 = ; - $[13] = isLoading; - $[14] = mode; - $[15] = onOpenTasksDialog; - $[16] = t4; - $[17] = tasksSelected; - $[18] = teammateFooterIndex; - $[19] = teamsSelected; - $[20] = tmuxSelected; - $[21] = toolPermissionContext; - $[22] = t5; - } else { - t5 = $[22]; - } - let t6; - if ($[23] !== t2 || $[24] !== t3 || $[25] !== t5) { - t6 = {t2}{t3}{t5}; - $[23] = t2; - $[24] = t3; - $[25] = t5; - $[26] = t6; - } else { - t6 = $[26]; - } - return t6; + + const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching + + return ( + + {isSearching && ( + + )} + {showVim ? ( + + -- INSERT -- + + ) : null} + + + ) } + type ModeIndicatorProps = { - mode: PromptInputMode; - toolPermissionContext: ToolPermissionContext; - showHint: boolean; - isLoading: boolean; - tasksSelected: boolean; - teamsSelected: boolean; - tmuxSelected: boolean; - teammateFooterIndex?: number; - onOpenTasksDialog?: (taskId?: string) => void; -}; + mode: PromptInputMode + toolPermissionContext: ToolPermissionContext + showHint: boolean + isLoading: boolean + tasksSelected: boolean + teamsSelected: boolean + tmuxSelected: boolean + teammateFooterIndex?: number + onOpenTasksDialog?: (taskId?: string) => void +} + function ModeIndicator({ mode, toolPermissionContext, @@ -243,186 +211,334 @@ function ModeIndicator({ teamsSelected, tmuxSelected, teammateFooterIndex, - onOpenTasksDialog + onOpenTasksDialog, }: ModeIndicatorProps): React.ReactNode { - const { - columns - } = useTerminalSize(); - const modeCycleShortcut = useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'); - const tasks = useAppState(s => s.tasks); - const teamContext = useAppState(s_0 => s_0.teamContext); + const { columns } = useTerminalSize() + const modeCycleShortcut = useShortcutDisplay( + 'chat:cycleMode', + 'Chat', + 'shift+tab', + ) + const tasks = useAppState(s => s.tasks) + const teamContext = useAppState(s => s.teamContext) // Set once in initialState (main.tsx --remote mode) and never mutated — lazy // init captures the immutable value without a subscription. - const store = useAppStateStore(); - const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl); - const viewSelectionMode = useAppState(s_1 => s_1.viewSelectionMode); - const viewingAgentTaskId = useAppState(s_2 => s_2.viewingAgentTaskId); - const expandedView = useAppState(s_3 => s_3.expandedView); - const showSpinnerTree = expandedView === 'teammates'; - const prStatus = usePrStatus(isLoading, isPrStatusEnabled()); - const hasTmuxSession = useAppState(s_4 => (process.env.USER_TYPE) === 'ant' && s_4.tungstenActiveSession !== undefined); - const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL); + const store = useAppStateStore() + const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl) + const viewSelectionMode = useAppState(s => s.viewSelectionMode) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const expandedView = useAppState(s => s.expandedView) + const showSpinnerTree = expandedView === 'teammates' + const prStatus = usePrStatus(isLoading, isPrStatusEnabled()) + const hasTmuxSession = useAppState( + s => + process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined, + ) + + const nextTickAt = useSyncExternalStore( + proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, + proactiveModule?.getNextTickAt ?? NULL, + NULL, + ) // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_5 => s_5.voiceState) : 'idle' as const; - const voiceWarmingUp = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_6 => s_6.voiceWarmingUp) : false; - const hasSelection = useHasSelection(); - const selGetState = useSelection().getState; - const hasNextTick = nextTickAt !== null; - const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false; - const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !((process.env.USER_TYPE) === 'ant' && isPanelAgentTask(t))), [tasks]); - const tasksV2 = useTasksV2(); - const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0; - const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase(); - const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'); - const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k'); - const voiceKeyShortcut = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : ''; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) + const voiceWarmingUp = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceWarmingUp) + : false + const hasSelection = useHasSelection() + const selGetState = useSelection().getState + const hasNextTick = nextTickAt !== null + const isCoordinator = feature('COORDINATOR_MODE') + ? coordinatorModule?.isCoordinatorMode() === true + : false + const runningTaskCount = useMemo( + () => + count( + Object.values(tasks), + t => + isBackgroundTask(t) && + !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + ), + [tasks], + ) + const tasksV2 = useTasksV2() + const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0 + const escShortcut = useShortcutDisplay( + 'chat:cancel', + 'Chat', + 'esc', + ).toLowerCase() + const todosShortcut = useShortcutDisplay( + 'app:toggleTodos', + 'Global', + 'ctrl+t', + ) + const killAgentsShortcut = useShortcutDisplay( + 'chat:killAgents', + 'Chat', + 'ctrl+x ctrl+k', + ) + const voiceKeyShortcut = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') + : '' // Captured at mount so the hint doesn't flicker mid-session if another // CC instance increments the counter. Incremented once via useEffect the // first time voice is enabled in this session — approximates "hint was // shown" without tracking the exact render-time condition (which depends // on parts/hintParts computed after the early-return hooks boundary). - const [voiceHintUnderCap] = feature('VOICE_MODE') ? + const [voiceHintUnderCap] = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useState( + () => + (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < + MAX_VOICE_HINT_SHOWS, + ) + : [false] // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS) : [false]; - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null; + const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null useEffect(() => { if (feature('VOICE_MODE')) { - if (!voiceEnabled || !voiceHintUnderCap) return; - if (voiceHintIncrementedRef?.current) return; - if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true; - const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1; + if (!voiceEnabled || !voiceHintUnderCap) return + if (voiceHintIncrementedRef?.current) return + if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true + const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1 saveGlobalConfig(prev => { - if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev; - return { - ...prev, - voiceFooterHintSeenCount: newCount - }; - }); + if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev + return { ...prev, voiceFooterHintSeenCount: newCount } + }) } - }, [voiceEnabled, voiceHintUnderCap]); - const isKillAgentsConfirmShowing = useAppState(s_7 => s_7.notifications.current?.key === 'kill-agents-confirm'); + }, [voiceEnabled, voiceHintUnderCap]) + const isKillAgentsConfirmShowing = useAppState( + s => s.notifications.current?.key === 'kill-agents-confirm', + ) // Derive team info from teamContext (no filesystem I/O needed) // Match the same logic as TeamStatus to avoid trailing separator // In-process mode uses Shift+Down/Up navigation, not footer teams menu - const hasTeams = isAgentSwarmsEnabled() && !isInProcessEnabled() && teamContext !== undefined && count(Object.values(teamContext.teammates), t_0 => t_0.name !== 'team-lead') > 0; + const hasTeams = + isAgentSwarmsEnabled() && + !isInProcessEnabled() && + teamContext !== undefined && + count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0 + if (mode === 'bash') { - return ! for bash mode; + return ! for bash mode } - const currentMode = toolPermissionContext?.mode; - const hasActiveMode = !isDefaultMode(currentMode); - const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; - const isViewingTeammate = viewSelectionMode === 'viewing-agent' && viewedTask?.type === 'in_process_teammate'; - const isViewingCompletedTeammate = isViewingTeammate && viewedTask != null && viewedTask.status !== 'running'; - const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate; + + const currentMode = toolPermissionContext?.mode + const hasActiveMode = !isDefaultMode(currentMode) + const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined + const isViewingTeammate = + viewSelectionMode === 'viewing-agent' && + viewedTask?.type === 'in_process_teammate' + const isViewingCompletedTeammate = + isViewingTeammate && viewedTask != null && viewedTask.status !== 'running' + const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate // Count primary items (permission mode or coordinator mode, background tasks, and teams) - const primaryItemCount = (isCoordinator || hasActiveMode ? 1 : 0) + (hasBackgroundTasks ? 1 : 0) + (hasTeams ? 1 : 0); + const primaryItemCount = + (isCoordinator || hasActiveMode ? 1 : 0) + + (hasBackgroundTasks ? 1 : 0) + + (hasTeams ? 1 : 0) // PR indicator is short (~10 chars) — unlike the old diff indicator the // >=100 threshold was tuned for. Now that auto mode is effectively the // baseline, primaryItemCount is ≥1 for most sessions; keep the threshold // low enough to show PR status on standard 80-col terminals. - const shouldShowPrStatus = isPrStatusEnabled() && prStatus.number !== null && prStatus.reviewState !== null && prStatus.url !== null && primaryItemCount < 2 && (primaryItemCount === 0 || columns >= 80); + const shouldShowPrStatus = + isPrStatusEnabled() && + prStatus.number !== null && + prStatus.reviewState !== null && + prStatus.url !== null && + primaryItemCount < 2 && + (primaryItemCount === 0 || columns >= 80) // Hide the shift+tab hint when there are 2 primary items - const shouldShowModeHint = primaryItemCount < 2; + const shouldShowModeHint = primaryItemCount < 2 // Check if we have in-process teammates (showing pills) // In spinner-tree mode, pills are disabled - teammates appear in the spinner tree instead - const hasInProcessTeammates = !showSpinnerTree && hasBackgroundTasks && Object.values(tasks).some(t_1 => t_1.type === 'in_process_teammate'); - const hasTeammatePills = hasInProcessTeammates || !showSpinnerTree && isViewingTeammate; + const hasInProcessTeammates = + !showSpinnerTree && + hasBackgroundTasks && + Object.values(tasks).some(t => t.type === 'in_process_teammate') + const hasTeammatePills = + hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate) // In remote mode (`claude assistant`, --teleport) the agent runs elsewhere; // the local permission mode shown here doesn't reflect the agent's state. // Rendered before the tasks pill so a long pill label (e.g. ultraplan URL) // doesn't push the mode indicator off-screen. - const modePart = currentMode && hasActiveMode && !getIsRemoteMode() ? + const modePart = + currentMode && hasActiveMode && !getIsRemoteMode() ? ( + {permissionModeSymbol(currentMode)}{' '} {permissionModeTitle(currentMode).toLowerCase()} on - {shouldShowModeHint && + {shouldShowModeHint && ( + {' '} - - } - : null; + + + )} + + ) : null // Build parts array - exclude BackgroundTaskStatus when we have teammate pills // (teammate pills get their own row) const parts = [ - // Remote session indicator - ...(remoteSessionUrl ? [ + // Remote session indicator + ...(remoteSessionUrl + ? [ + {figures.circleDouble} remote - ] : []), - // BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so - // its click-target Box isn't nested inside the - // wrapper (reconciler throws on Box-in-Text). - // Tmux pill (ant-only) — appears right after tasks in nav order - ...(process.env.USER_TYPE === 'ant' && hasTmuxSession && typeof TungstenPill === 'function' ? [] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [] : []), ...(shouldShowPrStatus ? [] : [])]; + , + ] + : []), + // BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so + // its click-target Box isn't nested inside the + // wrapper (reconciler throws on Box-in-Text). + // Tmux pill (ant-only) — appears right after tasks in nav order + ...(process.env.USER_TYPE === 'ant' && hasTmuxSession + ? [] + : []), + ...(isAgentSwarmsEnabled() && hasTeams + ? [ + , + ] + : []), + ...(shouldShowPrStatus + ? [ + , + ] + : []), + ] // Check if any in-process teammates exist (for hint text cycling) - const hasAnyInProcessTeammates = Object.values(tasks).some(t_2 => t_2.type === 'in_process_teammate' && t_2.status === 'running'); - const hasRunningAgentTasks = Object.values(tasks).some(t_3 => t_3.type === 'local_agent' && t_3.status === 'running'); + const hasAnyInProcessTeammates = Object.values(tasks).some( + t => t.type === 'in_process_teammate' && t.status === 'running', + ) + const hasRunningAgentTasks = Object.values(tasks).some( + t => t.type === 'local_agent' && t.status === 'running', + ) // Get hint parts separately for potential second-line rendering - const hintParts = showHint ? getSpinnerHintParts(isLoading, escShortcut, todosShortcut, killAgentsShortcut, hasTaskItems, expandedView, hasAnyInProcessTeammates, hasRunningAgentTasks, isKillAgentsConfirmShowing) : []; + const hintParts = showHint + ? getSpinnerHintParts( + isLoading, + escShortcut, + todosShortcut, + killAgentsShortcut, + hasTaskItems, + expandedView, + hasAnyInProcessTeammates, + hasRunningAgentTasks, + isKillAgentsConfirmShowing, + ) + : [] + if (isViewingCompletedTeammate) { - parts.push( - - ); + parts.push( + + + , + ) } else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) { - parts.push(); + parts.push() } else if (!hasTeammatePills && showHint) { - parts.push(...hintParts); + parts.push(...hintParts) } // When we have teammate pills, always render them on their own line above other parts if (hasTeammatePills) { // Don't append spinner hints when viewing a completed teammate — // the "esc to return to team lead" hint already replaces "esc to interrupt" - const otherParts = [...(modePart ? [modePart] : []), ...parts, ...(isViewingCompletedTeammate ? [] : hintParts)]; - return + const otherParts = [ + ...(modePart ? [modePart] : []), + ...parts, + ...(isViewingCompletedTeammate ? [] : hintParts), + ] + return ( + - + - {otherParts.length > 0 && + {otherParts.length > 0 && ( + {otherParts} - } - ; + + )} + + ) } // Add "↓ to manage tasks" hint when panel has visible rows - const hasCoordinatorTasks = (process.env.USER_TYPE) === 'ant' && getVisibleAgentTasks(tasks).length > 0; + const hasCoordinatorTasks = + process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0 // Tasks pill renders as a Box sibling (not a parts entry) so its // click-target Box isn't nested inside — the // reconciler throws on Box-in-Text. Computed here so the empty-checks // below still treat "pill present" as non-empty. - const tasksPart = hasBackgroundTasks && !hasTeammatePills && !shouldHideTasksFooter(tasks, showSpinnerTree) ? : null; + const tasksPart = + hasBackgroundTasks && + !hasTeammatePills && + !shouldHideTasksFooter(tasks, showSpinnerTree) ? ( + + ) : null + if (parts.length === 0 && !tasksPart && !modePart && showHint) { - parts.push( + parts.push( + ? for shortcuts - ); + , + ) } // Only replace the idle voice hint when there's something to say — otherwise // fall through instead of showing an empty Byline. "esc to clear" was removed // (looked like "esc to interrupt" when idle; esc-clears-selection is standard // UX) leaving only ctrl+c (copyOnSelect off) and the xterm.js native-select hint. - const copyOnSelect = getGlobalConfig().copyOnSelect ?? true; - const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs()); + const copyOnSelect = getGlobalConfig().copyOnSelect ?? true + const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs()) // Warmup hint takes priority — when the user is actively holding // the activation key, show feedback regardless of other hints. if (feature('VOICE_MODE') && voiceEnabled && voiceWarmingUp) { - parts.push(); + parts.push() } else if (isFullscreenEnvEnabled() && selectionHintHasContent) { // xterm.js (VS Code/Cursor/Windsurf) force-selection modifier is // platform-specific and gated on macOS (SelectionService.shouldForceSelection): @@ -434,23 +550,52 @@ function ModeIndicator({ // option+click hint they just tried. // Non-reactive getState() read is safe: lastPressHadAlt is immutable // while hasSelection is true (set pre-drag, cleared with selection). - const isMac = getPlatform() === 'macos'; - const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false); - parts.push( + const isMac = getPlatform() === 'macos' + const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false) + parts.push( + - {!copyOnSelect && } - {isXtermJs() && (altClickFailed ? set macOptionClickForcesSelection in VS Code settings : )} + {!copyOnSelect && ( + + )} + {isXtermJs() && + (altClickFailed ? ( + set macOptionClickForcesSelection in VS Code settings + ) : ( + + ))} - ); - } else if (feature('VOICE_MODE') && parts.length > 0 && showHint && voiceEnabled && voiceState === 'idle' && hintParts.length === 0 && voiceHintUnderCap) { - parts.push( + , + ) + } else if ( + feature('VOICE_MODE') && + parts.length > 0 && + showHint && + voiceEnabled && + voiceState === 'idle' && + hintParts.length === 0 && + voiceHintUnderCap + ) { + parts.push( + hold {voiceKeyShortcut} to speak - ); + , + ) } + if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) { - parts.push( - {tasksSelected ? : } - ); + parts.push( + + {tasksSelected ? ( + + ) : ( + + )} + , + ) } // In fullscreen the bottom section is flexShrink:0 — every row here @@ -462,55 +607,98 @@ function ModeIndicator({ // from 0→1 row. Always render 1 row in fullscreen; return a space when // empty so Yoga reserves the row without painting anything visible. if (parts.length === 0 && !tasksPart && !modePart) { - return isFullscreenEnvEnabled() ? : null; + return isFullscreenEnvEnabled() ? : null } // flexShrink=0 keeps mode + pill at natural width; the remaining parts // truncate at the tail as one string inside the Text wrapper. - return - {modePart && + return ( + + {modePart && ( + {modePart} {(tasksPart || parts.length > 0) && · } - } - {tasksPart && + + )} + {tasksPart && ( + {tasksPart} {parts.length > 0 && · } - } - {parts.length > 0 && + + )} + {parts.length > 0 && ( + {parts} - } - ; + + )} + + ) } -function getSpinnerHintParts(isLoading: boolean, escShortcut: string, todosShortcut: string, killAgentsShortcut: string, hasTaskItems: boolean, expandedView: 'none' | 'tasks' | 'teammates', hasTeammates: boolean, hasRunningAgentTasks: boolean, isKillAgentsConfirmShowing: boolean): React.ReactElement[] { - let toggleAction: string; + +function getSpinnerHintParts( + isLoading: boolean, + escShortcut: string, + todosShortcut: string, + killAgentsShortcut: string, + hasTaskItems: boolean, + expandedView: 'none' | 'tasks' | 'teammates', + hasTeammates: boolean, + hasRunningAgentTasks: boolean, + isKillAgentsConfirmShowing: boolean, +): React.ReactElement[] { + let toggleAction: string if (hasTeammates) { // Cycling: none → tasks → teammates → none switch (expandedView) { case 'none': - toggleAction = 'show tasks'; - break; + toggleAction = 'show tasks' + break case 'tasks': - toggleAction = 'show teammates'; - break; + toggleAction = 'show teammates' + break case 'teammates': - toggleAction = 'hide'; - break; + toggleAction = 'hide' + break } } else { - toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks'; + toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks' } // Show the toggle hint only when there are task items to display or // teammates to cycle to - const showToggleHint = hasTaskItems || hasTeammates; - return [...(isLoading ? [ + const showToggleHint = hasTaskItems || hasTeammates + + return [ + ...(isLoading + ? [ + - ] : []), ...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing ? [ - - ] : []), ...(showToggleHint ? [ - - ] : [])]; + , + ] + : []), + ...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing + ? [ + + + , + ] + : []), + ...(showToggleHint + ? [ + + + , + ] + : []), + ] } + function isPrStatusEnabled(): boolean { - return getGlobalConfig().prStatusFooterEnabled ?? true; + return getGlobalConfig().prStatusFooterEnabled ?? true } diff --git a/src/components/PromptInput/PromptInputFooterSuggestions.tsx b/src/components/PromptInput/PromptInputFooterSuggestions.tsx index 5f1fa74a9..0728e5255 100644 --- a/src/components/PromptInput/PromptInputFooterSuggestions.tsx +++ b/src/components/PromptInput/PromptInputFooterSuggestions.tsx @@ -1,292 +1,248 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { memo, type ReactNode } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js'; -import type { Theme } from '../../utils/theme.js'; +import * as React from 'react' +import { memo, type ReactNode } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js' +import type { Theme } from '../../utils/theme.js' + export type SuggestionItem = { - id: string; - displayText: string; - tag?: string; - description?: string; - metadata?: unknown; - color?: keyof Theme; -}; -export type SuggestionType = 'command' | 'file' | 'directory' | 'agent' | 'shell' | 'custom-title' | 'slack-channel' | 'none'; -export const OVERLAY_MAX_ITEMS = 5; + id: string + displayText: string + tag?: string + description?: string + metadata?: unknown + color?: keyof Theme +} + +export type SuggestionType = + | 'command' + | 'file' + | 'directory' + | 'agent' + | 'shell' + | 'custom-title' + | 'slack-channel' + | 'none' + +export const OVERLAY_MAX_ITEMS = 5 /** * Get the icon for a suggestion based on its type * Icons: + for files, ◇ for MCP resources, * for agents */ function getIcon(itemId: string): string { - if (itemId.startsWith('file-')) return '+'; - if (itemId.startsWith('mcp-resource-')) return '◇'; - if (itemId.startsWith('agent-')) return '*'; - return '+'; + if (itemId.startsWith('file-')) return '+' + if (itemId.startsWith('mcp-resource-')) return '◇' + if (itemId.startsWith('agent-')) return '*' + return '+' } /** * Check if an item is a unified suggestion type (file, mcp-resource, or agent) */ function isUnifiedSuggestion(itemId: string): boolean { - return itemId.startsWith('file-') || itemId.startsWith('mcp-resource-') || itemId.startsWith('agent-'); + return ( + itemId.startsWith('file-') || + itemId.startsWith('mcp-resource-') || + itemId.startsWith('agent-') + ) } -const SuggestionItemRow = memo(function SuggestionItemRow(t0: { item: SuggestionItem; maxColumnWidth: number; isSelected: boolean }) { - const $ = _c(36); - const { - item, - maxColumnWidth, - isSelected - } = t0; - const columns = useTerminalSize().columns; - const isUnified = isUnifiedSuggestion(item.id); + +const SuggestionItemRow = memo(function SuggestionItemRow({ + item, + maxColumnWidth, + isSelected, +}: { + item: SuggestionItem + maxColumnWidth?: number + isSelected: boolean +}): ReactNode { + const columns = useTerminalSize().columns + const isUnified = isUnifiedSuggestion(item.id) + + // For unified suggestions (file, mcp-resource, agent), use single-line layout with icon if (isUnified) { - let t1; - if ($[0] !== item.id) { - t1 = getIcon(item.id); - $[0] = item.id; - $[1] = t1; - } else { - t1 = $[1]; - } - const icon = t1; - const textColor = isSelected ? "suggestion" : undefined; - const dimColor = !isSelected; - const isFile = item.id.startsWith("file-"); - const isMcpResource = item.id.startsWith("mcp-resource-"); - const separatorWidth = item.description ? 3 : 0; - let displayText; + const icon = getIcon(item.id) + const textColor: keyof Theme | undefined = isSelected + ? 'suggestion' + : undefined + const dimColor = !isSelected + + const isFile = item.id.startsWith('file-') + const isMcpResource = item.id.startsWith('mcp-resource-') + + // Calculate layout widths + // Layout: "X " (2) + displayText + " – " (3) + description + padding (4) + const iconWidth = 2 // icon + space (fixed) + const paddingWidth = 4 + const separatorWidth = item.description ? 3 : 0 // ' – ' separator + + // For files, truncate middle of path to show both directory context and filename + // For MCP resources, limit displayText to 30 chars (truncate from end) + // For agents, no truncation + let displayText: string if (isFile) { - let t2; - if ($[2] !== item.description) { - t2 = item.description ? Math.min(20, stringWidth(item.description)) : 0; - $[2] = item.description; - $[3] = t2; - } else { - t2 = $[3]; - } - const descReserve = t2; - const maxPathLength = columns - 2 - 4 - separatorWidth - descReserve; - let t3; - if ($[4] !== item.displayText || $[5] !== maxPathLength) { - t3 = truncatePathMiddle(item.displayText, maxPathLength); - $[4] = item.displayText; - $[5] = maxPathLength; - $[6] = t3; - } else { - t3 = $[6]; - } - displayText = t3; + // Reserve space for description if present, otherwise use all available space + const descReserve = item.description + ? Math.min(20, stringWidth(item.description)) + : 0 + const maxPathLength = + columns - iconWidth - paddingWidth - separatorWidth - descReserve + displayText = truncatePathMiddle(item.displayText, maxPathLength) + } else if (isMcpResource) { + const maxDisplayTextLength = 30 + displayText = truncateToWidth(item.displayText, maxDisplayTextLength) } else { - if (isMcpResource) { - let t2; - if ($[7] !== item.displayText) { - t2 = truncateToWidth(item.displayText, 30); - $[7] = item.displayText; - $[8] = t2; - } else { - t2 = $[8]; - } - displayText = t2; - } else { - displayText = item.displayText; - } + displayText = item.displayText } - const availableWidth = columns - 2 - stringWidth(displayText) - separatorWidth - 4; - let lineContent; + + const availableWidth = + columns - + iconWidth - + stringWidth(displayText) - + separatorWidth - + paddingWidth + + // Build the full line as a single string to prevent wrapping + let lineContent: string if (item.description) { - const maxDescLength = Math.max(0, availableWidth); - let t2; - if ($[9] !== item.description || $[10] !== maxDescLength) { - t2 = truncateToWidth(item.description.replace(/\s+/g, " "), maxDescLength); - $[9] = item.description; - $[10] = maxDescLength; - $[11] = t2; - } else { - t2 = $[11]; - } - const truncatedDesc = t2; - lineContent = `${icon} ${displayText} – ${truncatedDesc}`; + const maxDescLength = Math.max(0, availableWidth) + const truncatedDesc = truncateToWidth( + item.description.replace(/\s+/g, ' '), + maxDescLength, + ) + lineContent = `${icon} ${displayText} – ${truncatedDesc}` } else { - lineContent = `${icon} ${displayText}`; + lineContent = `${icon} ${displayText}` } - let t2; - if ($[12] !== dimColor || $[13] !== lineContent || $[14] !== textColor) { - t2 = {lineContent}; - $[12] = dimColor; - $[13] = lineContent; - $[14] = textColor; - $[15] = t2; - } else { - t2 = $[15]; - } - return t2; + + return ( + + {lineContent} + + ) } - const maxNameWidth = Math.floor(columns * 0.4); - const displayTextWidth = Math.min(maxColumnWidth ?? stringWidth(item.displayText) + 5, maxNameWidth); - const textColor_0 = item.color || (isSelected ? "suggestion" : undefined); - const shouldDim = !isSelected; - let displayText_0 = item.displayText; - if (stringWidth(displayText_0) > displayTextWidth - 2) { - const t1 = displayTextWidth - 2; - let t2; - if ($[16] !== displayText_0 || $[17] !== t1) { - t2 = truncateToWidth(displayText_0, t1); - $[16] = displayText_0; - $[17] = t1; - $[18] = t2; - } else { - t2 = $[18]; - } - displayText_0 = t2; + + // For non-unified suggestions (commands, shell, etc.), use improved layout from main + // Cap the command name column at 40% of terminal width to ensure description has space + const maxNameWidth = Math.floor(columns * 0.4) + const displayTextWidth = Math.min( + maxColumnWidth ?? stringWidth(item.displayText) + 5, + maxNameWidth, + ) + + const textColor = item.color || (isSelected ? 'suggestion' : undefined) + const shouldDim = !isSelected + + // Truncate and pad the display text to fixed width + let displayText = item.displayText + if (stringWidth(displayText) > displayTextWidth - 2) { + displayText = truncateToWidth(displayText, displayTextWidth - 2) } - const paddedDisplayText = displayText_0 + " ".repeat(Math.max(0, displayTextWidth - stringWidth(displayText_0))); - const tagText = item.tag ? `[${item.tag}] ` : ""; - const tagWidth = stringWidth(tagText); - const descriptionWidth = Math.max(0, columns - displayTextWidth - tagWidth - 4); - let t1; - if ($[19] !== descriptionWidth || $[20] !== item.description) { - t1 = item.description ? truncateToWidth(item.description.replace(/\s+/g, " "), descriptionWidth) : ""; - $[19] = descriptionWidth; - $[20] = item.description; - $[21] = t1; - } else { - t1 = $[21]; - } - const truncatedDescription = t1; - let t2; - if ($[22] !== paddedDisplayText || $[23] !== shouldDim || $[24] !== textColor_0) { - t2 = {paddedDisplayText}; - $[22] = paddedDisplayText; - $[23] = shouldDim; - $[24] = textColor_0; - $[25] = t2; - } else { - t2 = $[25]; - } - let t3; - if ($[26] !== tagText) { - t3 = tagText ? {tagText} : null; - $[26] = tagText; - $[27] = t3; - } else { - t3 = $[27]; - } - const t4 = isSelected ? "suggestion" : undefined; - const t5 = !isSelected; - let t6; - if ($[28] !== t4 || $[29] !== t5 || $[30] !== truncatedDescription) { - t6 = {truncatedDescription}; - $[28] = t4; - $[29] = t5; - $[30] = truncatedDescription; - $[31] = t6; - } else { - t6 = $[31]; - } - let t7; - if ($[32] !== t2 || $[33] !== t3 || $[34] !== t6) { - t7 = {t2}{t3}{t6}; - $[32] = t2; - $[33] = t3; - $[34] = t6; - $[35] = t7; - } else { - t7 = $[35]; - } - return t7; -}); + const paddedDisplayText = + displayText + + ' '.repeat(Math.max(0, displayTextWidth - stringWidth(displayText))) + + const tagText = item.tag ? `[${item.tag}] ` : '' + const tagWidth = stringWidth(tagText) + const descriptionWidth = Math.max( + 0, + columns - displayTextWidth - tagWidth - 4, + ) + // Skill descriptions can contain newlines (e.g. /claude-api's "TRIGGER + // when:" block). A multi-line row grows the overlay past minHeight; when + // the filter narrows past that skill, the overlay shrinks and leaves + // ghost rows. Flatten to one line before truncating. + const truncatedDescription = item.description + ? truncateToWidth(item.description.replace(/\s+/g, ' '), descriptionWidth) + : '' + + return ( + + + {paddedDisplayText} + + {tagText ? {tagText} : null} + + {truncatedDescription} + + + ) +}) + type Props = { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - maxColumnWidth?: number; + suggestions: SuggestionItem[] + selectedSuggestion: number + maxColumnWidth?: number /** * When true, the suggestions are rendered inside a position=absolute * overlay. We omit minHeight and flex-end so the y-clamp in the * renderer doesn't push fewer items down into the prompt area. */ - overlay?: boolean; -}; -export function PromptInputFooterSuggestions(t0) { - const $ = _c(22); - const { - suggestions, - selectedSuggestion, - maxColumnWidth: maxColumnWidthProp, - overlay - } = t0; - const { - rows - } = useTerminalSize(); - const maxVisibleItems = overlay ? OVERLAY_MAX_ITEMS : Math.min(6, Math.max(1, rows - 3)); + overlay?: boolean +} + +export function PromptInputFooterSuggestions({ + suggestions, + selectedSuggestion, + maxColumnWidth: maxColumnWidthProp, + overlay, +}: Props): ReactNode { + const { rows } = useTerminalSize() + // Maximum number of suggestions to show at once (leaving space for prompt). + // Overlay mode (fullscreen) uses a fixed 5 — the floating box sits over + // the ScrollBox, so terminal height isn't the constraint. + const maxVisibleItems = overlay + ? OVERLAY_MAX_ITEMS + : Math.min(6, Math.max(1, rows - 3)) + + // No suggestions to display if (suggestions.length === 0) { - return null; + return null } - let t1; - if ($[0] !== maxColumnWidthProp || $[1] !== suggestions) { - t1 = maxColumnWidthProp ?? Math.max(...suggestions.map(_temp)) + 5; - $[0] = maxColumnWidthProp; - $[1] = suggestions; - $[2] = t1; - } else { - t1 = $[2]; - } - const maxColumnWidth = t1; - const startIndex = Math.max(0, Math.min(selectedSuggestion - Math.floor(maxVisibleItems / 2), suggestions.length - maxVisibleItems)); - const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length); - let T0; - let t2; - let t3; - let t4; - if ($[3] !== endIndex || $[4] !== maxColumnWidth || $[5] !== overlay || $[6] !== selectedSuggestion || $[7] !== startIndex || $[8] !== suggestions) { - const visibleItems = suggestions.slice(startIndex, endIndex); - T0 = Box; - t2 = "column"; - t3 = overlay ? undefined : "flex-end"; - let t5; - if ($[13] !== maxColumnWidth || $[14] !== selectedSuggestion || $[15] !== suggestions) { - t5 = item_0 => ; - $[13] = maxColumnWidth; - $[14] = selectedSuggestion; - $[15] = suggestions; - $[16] = t5; - } else { - t5 = $[16]; - } - t4 = visibleItems.map(t5); - $[3] = endIndex; - $[4] = maxColumnWidth; - $[5] = overlay; - $[6] = selectedSuggestion; - $[7] = startIndex; - $[8] = suggestions; - $[9] = T0; - $[10] = t2; - $[11] = t3; - $[12] = t4; - } else { - T0 = $[9]; - t2 = $[10]; - t3 = $[11]; - t4 = $[12]; - } - let t5; - if ($[17] !== T0 || $[18] !== t2 || $[19] !== t3 || $[20] !== t4) { - t5 = {t4}; - $[17] = T0; - $[18] = t2; - $[19] = t3; - $[20] = t4; - $[21] = t5; - } else { - t5 = $[21]; - } - return t5; + + // Use prop if provided (stable width from all commands), otherwise calculate from visible + const maxColumnWidth = + maxColumnWidthProp ?? + Math.max(...suggestions.map(item => stringWidth(item.displayText))) + 5 + + // Calculate visible items range based on selected index + const startIndex = Math.max( + 0, + Math.min( + selectedSuggestion - Math.floor(maxVisibleItems / 2), + suggestions.length - maxVisibleItems, + ), + ) + const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length) + const visibleItems = suggestions.slice(startIndex, endIndex) + + // In non-overlay (inline) mode, justifyContent keeps suggestions + // anchored to the bottom (near the prompt). In overlay mode we omit + // both minHeight and flex-end: the parent is position=absolute with + // bottom='100%', so its y is clamped to 0 by the renderer when it + // would go negative. Adding minHeight + flex-end would create empty + // padding rows that shift the visible items down into the prompt area + // when the list has fewer items than maxVisibleItems. + return ( + + {visibleItems.map(item => ( + + ))} + + ) } -function _temp(item) { - return stringWidth(item.displayText); -} -export default memo(PromptInputFooterSuggestions); + +export default memo(PromptInputFooterSuggestions) diff --git a/src/components/PromptInput/PromptInputHelpMenu.tsx b/src/components/PromptInput/PromptInputHelpMenu.tsx index f8e275765..5f15327d2 100644 --- a/src/components/PromptInput/PromptInputHelpMenu.tsx +++ b/src/components/PromptInput/PromptInputHelpMenu.tsx @@ -1,357 +1,149 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; -import { getPlatform } from 'src/utils/platform.js'; -import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js'; -import { getNewlineInstructions } from './utils.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { Box, Text } from 'src/ink.js' +import { getPlatform } from 'src/utils/platform.js' +import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js' +import { getNewlineInstructions } from './utils.js' /** Format a shortcut for display in the help menu (e.g., "ctrl+o" → "ctrl + o") */ function formatShortcut(shortcut: string): string { - return shortcut.replace(/\+/g, ' + '); + return shortcut.replace(/\+/g, ' + ') } + type Props = { - dimColor?: boolean; - fixedWidth?: boolean; - gap?: number; - paddingX?: number; -}; -export function PromptInputHelpMenu(props) { - const $ = _c(99); - const { - dimColor, - fixedWidth, - gap, - paddingX - } = props; - const t0 = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let t1; - if ($[0] !== t0) { - t1 = formatShortcut(t0); - $[0] = t0; - $[1] = t1; - } else { - t1 = $[1]; - } - const transcriptShortcut = t1; - const t2 = useShortcutDisplay("app:toggleTodos", "Global", "ctrl+t"); - let t3; - if ($[2] !== t2) { - t3 = formatShortcut(t2); - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const todosShortcut = t3; - const t4 = useShortcutDisplay("chat:undo", "Chat", "ctrl+_"); - let t5; - if ($[4] !== t4) { - t5 = formatShortcut(t4); - $[4] = t4; - $[5] = t5; - } else { - t5 = $[5]; - } - const undoShortcut = t5; - const t6 = useShortcutDisplay("chat:stash", "Chat", "ctrl+s"); - let t7; - if ($[6] !== t6) { - t7 = formatShortcut(t6); - $[6] = t6; - $[7] = t7; - } else { - t7 = $[7]; - } - const stashShortcut = t7; - const t8 = useShortcutDisplay("chat:cycleMode", "Chat", "shift+tab"); - let t9; - if ($[8] !== t8) { - t9 = formatShortcut(t8); - $[8] = t8; - $[9] = t9; - } else { - t9 = $[9]; - } - const cycleModeShortcut = t9; - const t10 = useShortcutDisplay("chat:modelPicker", "Chat", "alt+p"); - let t11; - if ($[10] !== t10) { - t11 = formatShortcut(t10); - $[10] = t10; - $[11] = t11; - } else { - t11 = $[11]; - } - const modelPickerShortcut = t11; - const t12 = useShortcutDisplay("chat:fastMode", "Chat", "alt+o"); - let t13; - if ($[12] !== t12) { - t13 = formatShortcut(t12); - $[12] = t12; - $[13] = t13; - } else { - t13 = $[13]; - } - const fastModeShortcut = t13; - const t14 = useShortcutDisplay("chat:externalEditor", "Chat", "ctrl+g"); - let t15; - if ($[14] !== t14) { - t15 = formatShortcut(t14); - $[14] = t14; - $[15] = t15; - } else { - t15 = $[15]; - } - const externalEditorShortcut = t15; - const t16 = useShortcutDisplay("app:toggleTerminal", "Global", "meta+j"); - let t17; - if ($[16] !== t16) { - t17 = formatShortcut(t16); - $[16] = t16; - $[17] = t17; - } else { - t17 = $[17]; - } - const terminalShortcut = t17; - const t18 = useShortcutDisplay("chat:imagePaste", "Chat", "ctrl+v"); - let t19; - if ($[18] !== t18) { - t19 = formatShortcut(t18); - $[18] = t18; - $[19] = t19; - } else { - t19 = $[19]; - } - const imagePasteShortcut = t19; - let t20; - if ($[20] !== dimColor || $[21] !== terminalShortcut) { - t20 = feature("TERMINAL_PANEL") ? getFeatureValue_CACHED_MAY_BE_STALE("tengu_terminal_panel", false) ? {terminalShortcut} for terminal : null : null; - $[20] = dimColor; - $[21] = terminalShortcut; - $[22] = t20; - } else { - t20 = $[22]; - } - const terminalShortcutElement = t20; - const t21 = fixedWidth ? 24 : undefined; - let t22; - if ($[23] !== dimColor) { - t22 = ! for bash mode; - $[23] = dimColor; - $[24] = t22; - } else { - t22 = $[24]; - } - let t23; - if ($[25] !== dimColor) { - t23 = / for commands; - $[25] = dimColor; - $[26] = t23; - } else { - t23 = $[26]; - } - let t24; - if ($[27] !== dimColor) { - t24 = @ for file paths; - $[27] = dimColor; - $[28] = t24; - } else { - t24 = $[28]; - } - let t25; - if ($[29] !== dimColor) { - t25 = {"& for background"}; - $[29] = dimColor; - $[30] = t25; - } else { - t25 = $[30]; - } - let t26; - if ($[31] !== dimColor) { - t26 = /btw for side question; - $[31] = dimColor; - $[32] = t26; - } else { - t26 = $[32]; - } - let t27; - if ($[33] !== t21 || $[34] !== t22 || $[35] !== t23 || $[36] !== t24 || $[37] !== t25 || $[38] !== t26) { - t27 = {t22}{t23}{t24}{t25}{t26}; - $[33] = t21; - $[34] = t22; - $[35] = t23; - $[36] = t24; - $[37] = t25; - $[38] = t26; - $[39] = t27; - } else { - t27 = $[39]; - } - const t28 = fixedWidth ? 35 : undefined; - let t29; - if ($[40] !== dimColor) { - t29 = double tap esc to clear input; - $[40] = dimColor; - $[41] = t29; - } else { - t29 = $[41]; - } - let t30; - if ($[42] !== cycleModeShortcut || $[43] !== dimColor) { - t30 = {cycleModeShortcut}{" "}{false ? "to cycle modes" : "to auto-accept edits"}; - $[42] = cycleModeShortcut; - $[43] = dimColor; - $[44] = t30; - } else { - t30 = $[44]; - } - let t31; - if ($[45] !== dimColor || $[46] !== transcriptShortcut) { - t31 = {transcriptShortcut} for verbose output; - $[45] = dimColor; - $[46] = transcriptShortcut; - $[47] = t31; - } else { - t31 = $[47]; - } - let t32; - if ($[48] !== dimColor || $[49] !== todosShortcut) { - t32 = {todosShortcut} to toggle tasks; - $[48] = dimColor; - $[49] = todosShortcut; - $[50] = t32; - } else { - t32 = $[50]; - } - let t33; - if ($[51] === Symbol.for("react.memo_cache_sentinel")) { - t33 = getNewlineInstructions(); - $[51] = t33; - } else { - t33 = $[51]; - } - let t34; - if ($[52] !== dimColor) { - t34 = {t33}; - $[52] = dimColor; - $[53] = t34; - } else { - t34 = $[53]; - } - let t35; - if ($[54] !== t28 || $[55] !== t29 || $[56] !== t30 || $[57] !== t31 || $[58] !== t32 || $[59] !== t34 || $[60] !== terminalShortcutElement) { - t35 = {t29}{t30}{t31}{t32}{terminalShortcutElement}{t34}; - $[54] = t28; - $[55] = t29; - $[56] = t30; - $[57] = t31; - $[58] = t32; - $[59] = t34; - $[60] = terminalShortcutElement; - $[61] = t35; - } else { - t35 = $[61]; - } - let t36; - if ($[62] !== dimColor || $[63] !== undoShortcut) { - t36 = {undoShortcut} to undo; - $[62] = dimColor; - $[63] = undoShortcut; - $[64] = t36; - } else { - t36 = $[64]; - } - let t37; - if ($[65] !== dimColor) { - t37 = getPlatform() !== "windows" && ctrl + z to suspend; - $[65] = dimColor; - $[66] = t37; - } else { - t37 = $[66]; - } - let t38; - if ($[67] !== dimColor || $[68] !== imagePasteShortcut) { - t38 = {imagePasteShortcut} to paste images; - $[67] = dimColor; - $[68] = imagePasteShortcut; - $[69] = t38; - } else { - t38 = $[69]; - } - let t39; - if ($[70] !== dimColor || $[71] !== modelPickerShortcut) { - t39 = {modelPickerShortcut} to switch model; - $[70] = dimColor; - $[71] = modelPickerShortcut; - $[72] = t39; - } else { - t39 = $[72]; - } - let t40; - if ($[73] !== dimColor || $[74] !== fastModeShortcut) { - t40 = isFastModeEnabled() && isFastModeAvailable() && {fastModeShortcut} to toggle fast mode; - $[73] = dimColor; - $[74] = fastModeShortcut; - $[75] = t40; - } else { - t40 = $[75]; - } - let t41; - if ($[76] !== dimColor || $[77] !== stashShortcut) { - t41 = {stashShortcut} to stash prompt; - $[76] = dimColor; - $[77] = stashShortcut; - $[78] = t41; - } else { - t41 = $[78]; - } - let t42; - if ($[79] !== dimColor || $[80] !== externalEditorShortcut) { - t42 = {externalEditorShortcut} to edit in $EDITOR; - $[79] = dimColor; - $[80] = externalEditorShortcut; - $[81] = t42; - } else { - t42 = $[81]; - } - let t43; - if ($[82] !== dimColor) { - t43 = isKeybindingCustomizationEnabled() && /keybindings to customize; - $[82] = dimColor; - $[83] = t43; - } else { - t43 = $[83]; - } - let t44; - if ($[84] !== t36 || $[85] !== t37 || $[86] !== t38 || $[87] !== t39 || $[88] !== t40 || $[89] !== t41 || $[90] !== t42 || $[91] !== t43) { - t44 = {t36}{t37}{t38}{t39}{t40}{t41}{t42}{t43}; - $[84] = t36; - $[85] = t37; - $[86] = t38; - $[87] = t39; - $[88] = t40; - $[89] = t41; - $[90] = t42; - $[91] = t43; - $[92] = t44; - } else { - t44 = $[92]; - } - let t45; - if ($[93] !== gap || $[94] !== paddingX || $[95] !== t27 || $[96] !== t35 || $[97] !== t44) { - t45 = {t27}{t35}{t44}; - $[93] = gap; - $[94] = paddingX; - $[95] = t27; - $[96] = t35; - $[97] = t44; - $[98] = t45; - } else { - t45 = $[98]; - } - return t45; + dimColor?: boolean + fixedWidth?: boolean + gap?: number + paddingX?: number +} + +export function PromptInputHelpMenu(props: Props): React.ReactNode { + const { dimColor, fixedWidth, gap, paddingX } = props + + // Get configured shortcuts from keybinding system + const transcriptShortcut = formatShortcut( + useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'), + ) + const todosShortcut = formatShortcut( + useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'), + ) + const undoShortcut = formatShortcut( + useShortcutDisplay('chat:undo', 'Chat', 'ctrl+_'), + ) + const stashShortcut = formatShortcut( + useShortcutDisplay('chat:stash', 'Chat', 'ctrl+s'), + ) + const cycleModeShortcut = formatShortcut( + useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'), + ) + const modelPickerShortcut = formatShortcut( + useShortcutDisplay('chat:modelPicker', 'Chat', 'alt+p'), + ) + const fastModeShortcut = formatShortcut( + useShortcutDisplay('chat:fastMode', 'Chat', 'alt+o'), + ) + const externalEditorShortcut = formatShortcut( + useShortcutDisplay('chat:externalEditor', 'Chat', 'ctrl+g'), + ) + const terminalShortcut = formatShortcut( + useShortcutDisplay('app:toggleTerminal', 'Global', 'meta+j'), + ) + const imagePasteShortcut = formatShortcut( + useShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'), + ) + + // Compute terminal shortcut element outside JSX to satisfy feature() constraint + const terminalShortcutElement = feature('TERMINAL_PANEL') ? ( + getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false) ? ( + + {terminalShortcut} for terminal + + ) : null + ) : null + + return ( + + + + ! for bash mode + + + / for commands + + + @ for file paths + + + & for background + + + /btw for side question + + + + + double tap esc to clear input + + + + {cycleModeShortcut}{' '} + {process.env.USER_TYPE === 'ant' + ? 'to cycle modes' + : 'to auto-accept edits'} + + + + + {transcriptShortcut} for verbose output + + + + {todosShortcut} to toggle tasks + + {terminalShortcutElement} + + {getNewlineInstructions()} + + + + + {undoShortcut} to undo + + {getPlatform() !== 'windows' && ( + + ctrl + z to suspend + + )} + + {imagePasteShortcut} to paste images + + + {modelPickerShortcut} to switch model + + {isFastModeEnabled() && isFastModeAvailable() && ( + + + {fastModeShortcut} to toggle fast mode + + + )} + + {stashShortcut} to stash prompt + + + + {externalEditorShortcut} to edit in $EDITOR + + + {isKeybindingCustomizationEnabled() && ( + + /keybindings to customize + + )} + + + ) } diff --git a/src/components/PromptInput/PromptInputModeIndicator.tsx b/src/components/PromptInput/PromptInputModeIndicator.tsx index 3ca879ddb..4aa66bf7b 100644 --- a/src/components/PromptInput/PromptInputModeIndicator.tsx +++ b/src/components/PromptInput/PromptInputModeIndicator.tsx @@ -1,18 +1,22 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'; -import type { PromptInputMode } from 'src/types/textInputTypes.js'; -import { getTeammateColor } from 'src/utils/teammate.js'; -import type { Theme } from 'src/utils/theme.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; +import figures from 'figures' +import * as React from 'react' +import { Box, Text } from 'src/ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from 'src/tools/AgentTool/agentColorManager.js' +import type { PromptInputMode } from 'src/types/textInputTypes.js' +import { getTeammateColor } from 'src/utils/teammate.js' +import type { Theme } from 'src/utils/theme.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' + type Props = { - mode: PromptInputMode; - isLoading: boolean; - viewingAgentName?: string; - viewingAgentColor?: AgentColorName; -}; + mode: PromptInputMode + isLoading: boolean + viewingAgentName?: string + viewingAgentColor?: AgentColorName +} /** * Gets the theme color key for the teammate's assigned color. @@ -20,73 +24,81 @@ type Props = { */ function getTeammateThemeColor(): keyof Theme | undefined { if (!isAgentSwarmsEnabled()) { - return undefined; + return undefined } - const colorName = getTeammateColor(); + const colorName = getTeammateColor() if (!colorName) { - return undefined; + return undefined } if (AGENT_COLORS.includes(colorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]; + return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] } - return undefined; + return undefined } + type PromptCharProps = { - isLoading: boolean; + isLoading: boolean // Dead code elimination: parameter named themeColor to avoid "teammate" string in external builds - themeColor?: keyof Theme; -}; + themeColor?: keyof Theme +} /** * Renders the prompt character (❯). * Teammate color overrides the default color when set. */ -function PromptChar(t0) { - const $ = _c(3); - const { - isLoading, - themeColor - } = t0; - const teammateColor = themeColor; - const color = teammateColor ?? (false ? "subtle" : undefined); - let t1; - if ($[0] !== color || $[1] !== isLoading) { - t1 = {figures.pointer} ; - $[0] = color; - $[1] = isLoading; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; +function PromptChar({ + isLoading, + themeColor, +}: PromptCharProps): React.ReactNode { + // Assign to original name for clarity within the function + const teammateColor = themeColor + const isAnt = process.env.USER_TYPE === 'ant' + const color = teammateColor ?? (isAnt ? 'subtle' : undefined) + + return ( + + {figures.pointer}  + + ) } -export function PromptInputModeIndicator(t0) { - const $ = _c(6); - const { - mode, - isLoading, - viewingAgentName, - viewingAgentColor - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTeammateThemeColor(); - $[0] = t1; - } else { - t1 = $[0]; - } - const teammateColor = t1; - const viewedTeammateThemeColor = viewingAgentColor ? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor] : undefined; - let t2; - if ($[1] !== isLoading || $[2] !== mode || $[3] !== viewedTeammateThemeColor || $[4] !== viewingAgentName) { - t2 = {viewingAgentName ? : mode === "bash" ? : }; - $[1] = isLoading; - $[2] = mode; - $[3] = viewedTeammateThemeColor; - $[4] = viewingAgentName; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; + +export function PromptInputModeIndicator({ + mode, + isLoading, + viewingAgentName, + viewingAgentColor, +}: Props): React.ReactNode { + const teammateColor = getTeammateThemeColor() + + // Convert viewed teammate's color to theme color + // Falls back to PromptChar's default (subtle for ants, undefined for external) + const viewedTeammateThemeColor = viewingAgentColor + ? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor] + : undefined + + return ( + + {viewingAgentName ? ( + // Use teammate's color on the standard prompt character, matching established style + + ) : mode === 'bash' ? ( + + !  + + ) : ( + + )} + + ) } diff --git a/src/components/PromptInput/PromptInputQueuedCommands.tsx b/src/components/PromptInput/PromptInputQueuedCommands.tsx index 9fe1ad571..c4637b803 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.tsx @@ -1,17 +1,26 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useMemo } from 'react'; -import { Box } from 'src/ink.js'; -import { useAppState } from 'src/state/AppState.js'; -import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js'; -import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js'; -import { useCommandQueue } from '../../hooks/useCommandQueue.js'; -import type { QueuedCommand } from '../../types/textInputTypes.js'; -import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js'; -import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; -import { jsonParse } from '../../utils/slowOperations.js'; -import { Message } from '../Message.js'; -const EMPTY_SET = new Set(); +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useMemo } from 'react' +import { Box } from 'src/ink.js' +import { useAppState } from 'src/state/AppState.js' +import { + STATUS_TAG, + SUMMARY_TAG, + TASK_NOTIFICATION_TAG, +} from '../../constants/xml.js' +import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js' +import { useCommandQueue } from '../../hooks/useCommandQueue.js' +import type { QueuedCommand } from '../../types/textInputTypes.js' +import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js' +import { + createUserMessage, + EMPTY_LOOKUPS, + normalizeMessages, +} from '../../utils/messages.js' +import { jsonParse } from '../../utils/slowOperations.js' +import { Message } from '../Message.js' + +const EMPTY_SET = new Set() /** * Check if a command value is an idle notification that should be hidden. @@ -19,15 +28,15 @@ const EMPTY_SET = new Set(); */ function isIdleNotification(value: string): boolean { try { - const parsed = jsonParse(value); - return parsed?.type === 'idle_notification'; + const parsed = jsonParse(value) + return parsed?.type === 'idle_notification' } catch { - return false; + return false } } // Maximum number of task notification lines to show -const MAX_VISIBLE_NOTIFICATIONS = 3; +const MAX_VISIBLE_NOTIFICATIONS = 3 /** * Create a synthetic overflow notification message for capped task notifications. @@ -36,7 +45,7 @@ function createOverflowNotificationMessage(count: number): string { return `<${TASK_NOTIFICATION_TAG}> <${SUMMARY_TAG}>+${count} more tasks completed <${STATUS_TAG}>completed -`; +` } /** @@ -44,73 +53,114 @@ function createOverflowNotificationMessage(count: number): string { * Other command types are always shown in full. * Idle notifications are filtered out entirely. */ -function processQueuedCommands(queuedCommands: QueuedCommand[]): QueuedCommand[] { +function processQueuedCommands( + queuedCommands: QueuedCommand[], +): QueuedCommand[] { // Filter out idle notifications - they are processed silently - const filteredCommands = queuedCommands.filter(cmd => typeof cmd.value !== 'string' || !isIdleNotification(cmd.value)); + const filteredCommands = queuedCommands.filter( + cmd => typeof cmd.value !== 'string' || !isIdleNotification(cmd.value), + ) // Separate task notifications from other commands - const taskNotifications = filteredCommands.filter(cmd => cmd.mode === 'task-notification'); - const otherCommands = filteredCommands.filter(cmd => cmd.mode !== 'task-notification'); + const taskNotifications = filteredCommands.filter( + cmd => cmd.mode === 'task-notification', + ) + const otherCommands = filteredCommands.filter( + cmd => cmd.mode !== 'task-notification', + ) // If notifications fit within limit, return all commands as-is if (taskNotifications.length <= MAX_VISIBLE_NOTIFICATIONS) { - return [...otherCommands, ...taskNotifications]; + return [...otherCommands, ...taskNotifications] } // Show first (MAX_VISIBLE_NOTIFICATIONS - 1) notifications, then a summary - const visibleNotifications = taskNotifications.slice(0, MAX_VISIBLE_NOTIFICATIONS - 1); - const overflowCount = taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1); + const visibleNotifications = taskNotifications.slice( + 0, + MAX_VISIBLE_NOTIFICATIONS - 1, + ) + const overflowCount = + taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1) // Create synthetic overflow message const overflowCommand: QueuedCommand = { value: createOverflowNotificationMessage(overflowCount), - mode: 'task-notification' - }; - return [...otherCommands, ...visibleNotifications, overflowCommand]; + mode: 'task-notification', + } + + return [...otherCommands, ...visibleNotifications, overflowCommand] } + function PromptInputQueuedCommandsImpl(): React.ReactNode { - const queuedCommands = useCommandQueue(); - const viewingAgent = useAppState(s => !!s.viewingAgentTaskId); + const queuedCommands = useCommandQueue() + const viewingAgent = useAppState(s => !!s.viewingAgentTaskId) // Brief layout: dim queue items + skip the paddingX (brief messages // already indent themselves). Gate mirrors the brief-spinner/message // check elsewhere — no teammate-view override needed since this // component early-returns when viewing a teammate. - const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.isBriefOnly) : false; + const useBriefLayout = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false // createUserMessage mints a fresh UUID per call; without memoization, streaming // re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker. const messages = useMemo(() => { - if (queuedCommands.length === 0) return null; + if (queuedCommands.length === 0) return null // task-notification is shown via useInboxNotification; most isMeta commands // (scheduled tasks, proactive ticks) are system-generated and hidden. // Channel messages are the exception — isMeta but shown so the keyboard // user sees what arrived. - const visibleCommands = queuedCommands.filter(isQueuedCommandVisible); - if (visibleCommands.length === 0) return null; - const processedCommands = processQueuedCommands(visibleCommands); - return normalizeMessages(processedCommands.map(cmd => { - let content = cmd.value; - if (cmd.mode === 'bash' && typeof content === 'string') { - content = `${content}`; - } - // [Image #N] placeholders are inline in the text value (inserted at - // paste time), so the queue preview shows them without stub blocks. - return createUserMessage({ - content - }); - })); - }, [queuedCommands]); + const visibleCommands = queuedCommands.filter(isQueuedCommandVisible) + if (visibleCommands.length === 0) return null + const processedCommands = processQueuedCommands(visibleCommands) + return normalizeMessages( + processedCommands.map(cmd => { + let content = cmd.value + if (cmd.mode === 'bash' && typeof content === 'string') { + content = `${content}` + } + // [Image #N] placeholders are inline in the text value (inserted at + // paste time), so the queue preview shows them without stub blocks. + return createUserMessage({ content }) + }), + ) + }, [queuedCommands]) // Don't show leader's queued commands when viewing any agent's transcript if (viewingAgent || messages === null) { - return null; + return null } - return - {messages.map((message, i) => - - )} - ; + + return ( + + {messages.map((message, i) => ( + + + + ))} + + ) } -export const PromptInputQueuedCommands = React.memo(PromptInputQueuedCommandsImpl); + +export const PromptInputQueuedCommands = React.memo( + PromptInputQueuedCommandsImpl, +) diff --git a/src/components/PromptInput/PromptInputStashNotice.tsx b/src/components/PromptInput/PromptInputStashNotice.tsx index cf01db045..8a44e8607 100644 --- a/src/components/PromptInput/PromptInputStashNotice.tsx +++ b/src/components/PromptInput/PromptInputStashNotice.tsx @@ -1,24 +1,21 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Box, Text } from 'src/ink.js'; +import figures from 'figures' +import * as React from 'react' +import { Box, Text } from 'src/ink.js' + type Props = { - hasStash: boolean; -}; -export function PromptInputStashNotice(t0) { - const $ = _c(1); - const { - hasStash - } = t0; - if (!hasStash) { - return null; - } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {figures.pointerSmall} Stashed (auto-restores after submit); - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + hasStash: boolean +} + +export function PromptInputStashNotice({ hasStash }: Props): React.ReactNode { + if (!hasStash) { + return null + } + + return ( + + + {figures.pointerSmall} Stashed (auto-restores after submit) + + + ) } diff --git a/src/components/PromptInput/SandboxPromptFooterHint.tsx b/src/components/PromptInput/SandboxPromptFooterHint.tsx index 43b81fcfa..1324a9832 100644 --- a/src/components/PromptInput/SandboxPromptFooterHint.tsx +++ b/src/components/PromptInput/SandboxPromptFooterHint.tsx @@ -1,63 +1,61 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { type ReactNode, useEffect, useRef, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxPromptFooterHint() { - const $ = _c(6); - const [recentViolationCount, setRecentViolationCount] = useState(0); - const timerRef = useRef(null); - const detailsShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - if (!SandboxManager.isSandboxingEnabled()) { - return; - } - const store = SandboxManager.getSandboxViolationStore(); - let lastCount = store.getTotalCount(); - const unsubscribe = store.subscribe(() => { - const currentCount = store.getTotalCount(); - const newViolations = currentCount - lastCount; - if (newViolations > 0) { - setRecentViolationCount(newViolations); - lastCount = currentCount; - if (timerRef.current) { - clearTimeout(timerRef.current); - } - timerRef.current = setTimeout(setRecentViolationCount, 5000, 0); - } - }); - return () => { - unsubscribe(); +import * as React from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' + +export function SandboxPromptFooterHint(): ReactNode { + const [recentViolationCount, setRecentViolationCount] = useState(0) + const timerRef = useRef(null) + const detailsShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + + useEffect(() => { + if (!SandboxManager.isSandboxingEnabled()) { + return + } + + const store = SandboxManager.getSandboxViolationStore() + let lastCount = store.getTotalCount() + + const unsubscribe = store.subscribe(() => { + const currentCount = store.getTotalCount() + const newViolations = currentCount - lastCount + + if (newViolations > 0) { + setRecentViolationCount(newViolations) + lastCount = currentCount + if (timerRef.current) { - clearTimeout(timerRef.current); + clearTimeout(timerRef.current) } - }; - }; - t1 = []; - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; - } - useEffect(t0, t1); + + timerRef.current = setTimeout(setRecentViolationCount, 5000, 0) + } + }) + + return () => { + unsubscribe() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + if (!SandboxManager.isSandboxingEnabled() || recentViolationCount === 0) { - return null; + return null } - const t2 = recentViolationCount === 1 ? "operation" : "operations"; - let t3; - if ($[2] !== detailsShortcut || $[3] !== recentViolationCount || $[4] !== t2) { - t3 = ⧈ Sandbox blocked {recentViolationCount}{" "}{t2} ·{" "}{detailsShortcut} for details · /sandbox to disable; - $[2] = detailsShortcut; - $[3] = recentViolationCount; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; + + return ( + + + ⧈ Sandbox blocked {recentViolationCount}{' '} + {recentViolationCount === 1 ? 'operation' : 'operations'} ·{' '} + {detailsShortcut} for details · /sandbox to disable + + + ) } diff --git a/src/components/PromptInput/ShimmeredInput.tsx b/src/components/PromptInput/ShimmeredInput.tsx index 5fd163e04..11da7ad76 100644 --- a/src/components/PromptInput/ShimmeredInput.tsx +++ b/src/components/PromptInput/ShimmeredInput.tsx @@ -1,142 +1,121 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Ansi, Box, Text, useAnimationFrame } from '../../ink.js'; -import { segmentTextByHighlights, type TextHighlight } from '../../utils/textHighlighting.js'; -import { ShimmerChar } from '../Spinner/ShimmerChar.js'; +import * as React from 'react' +import { Ansi, Box, Text, useAnimationFrame } from '../../ink.js' +import { + segmentTextByHighlights, + type TextHighlight, +} from '../../utils/textHighlighting.js' +import { ShimmerChar } from '../Spinner/ShimmerChar.js' + type Props = { - text: string; - highlights: TextHighlight[]; -}; + text: string + highlights: TextHighlight[] +} + type LinePart = { - text: string; - highlight: TextHighlight | undefined; - start: number; -}; -export function HighlightedInput(t0) { - const $ = _c(23); - const { - text, - highlights - } = t0; - let lines; - if ($[0] !== highlights || $[1] !== text) { - const segments = segmentTextByHighlights(text, highlights); - lines = [[]]; - let pos = 0; + text: string + highlight: TextHighlight | undefined + start: number +} + +export function HighlightedInput({ text, highlights }: Props): React.ReactNode { + // The shimmer animation (below) re-renders this component at 20fps while the + // ultrathink keyword is present. text/highlights are referentially stable + // across animation ticks (parent doesn't re-render), so memoize everything + // that derives from them: segmentTextByHighlights alone is ~85µs/call + // (tokenize + sort + O(n²) overlap), which adds up fast at 20fps. + const { lines, hasShimmer, sweepStart, cycleLength } = React.useMemo(() => { + const segments = segmentTextByHighlights(text, highlights) + + // Split segments by newlines into per-line groups. Ink's row-direction Box + // indents continuation lines of a multi-line child to that child's X offset. + // By splitting at newlines, each line renders as its own row, avoiding the + // incorrect indentation when highlighted text is followed by wrapped content. + const lines: LinePart[][] = [[]] + let pos = 0 for (const segment of segments) { - const parts = segment.text.split("\n"); + const parts = segment.text.split('\n') for (let i = 0; i < parts.length; i++) { if (i > 0) { - lines.push([]); - pos = pos + 1; + lines.push([]) + pos += 1 } - const part = parts[i]; + const part = parts[i]! if (part.length > 0) { - lines[lines.length - 1].push({ + lines[lines.length - 1]!.push({ text: part, highlight: segment.highlight, - start: pos - }); + start: pos, + }) } - pos = pos + part.length; + pos += part.length } } - $[0] = highlights; - $[1] = text; - $[2] = lines; - } else { - lines = $[2]; - } - let t1; - if ($[3] !== highlights) { - t1 = highlights.some(_temp); - $[3] = highlights; - $[4] = t1; - } else { - t1 = $[4]; - } - const hasShimmer = t1; - let sweepStart = 0; - let cycleLength = 1; - if (hasShimmer) { - let lo = Infinity; - let hi = -Infinity; - if ($[5] !== hi || $[6] !== highlights || $[7] !== lo) { - for (const h_0 of highlights) { - if (h_0.shimmerColor) { - lo = Math.min(lo, h_0.start); - hi = Math.max(hi, h_0.end); + + // Scope the sweep to shimmer-highlighted ranges so cycle time doesn't grow + // with input length. Padding creates an offscreen pause between sweeps. + const hasShimmer = highlights.some(h => h.shimmerColor) + let sweepStart = 0 + let cycleLength = 1 + if (hasShimmer) { + const padding = 10 + let lo = Infinity + let hi = -Infinity + for (const h of highlights) { + if (h.shimmerColor) { + lo = Math.min(lo, h.start) + hi = Math.max(hi, h.end) } } - $[5] = hi; - $[6] = highlights; - $[7] = lo; - $[8] = lo; - $[9] = hi; - } else { - lo = $[8] as number; - hi = $[9] as number; + sweepStart = lo - padding + cycleLength = hi - lo + padding * 2 } - sweepStart = lo - 10; - cycleLength = hi - lo + 20; - } - let t2; - if ($[10] !== cycleLength || $[11] !== hasShimmer || $[12] !== lines || $[13] !== sweepStart) { - t2 = { - lines, - hasShimmer, - sweepStart, - cycleLength - }; - $[10] = cycleLength; - $[11] = hasShimmer; - $[12] = lines; - $[13] = sweepStart; - $[14] = t2; - } else { - t2 = $[14]; - } - const { - lines: lines_0, - hasShimmer: hasShimmer_0, - sweepStart: sweepStart_0, - cycleLength: cycleLength_0 - } = t2; - const [ref, time] = useAnimationFrame(hasShimmer_0 ? 50 : null); - const glimmerIndex = hasShimmer_0 ? sweepStart_0 + Math.floor(time / 50) % cycleLength_0 : -100; - let t3; - if ($[15] !== glimmerIndex || $[16] !== lines_0) { - let t4; - if ($[18] !== glimmerIndex) { - t4 = (lineParts, lineIndex) => {lineParts.length === 0 ? : lineParts.map((part_0, partIndex) => { - if (part_0.highlight?.shimmerColor && part_0.highlight.color) { - return {part_0.text.split("").map((char, charIndex) => )}; - } - return {part_0.text}; - })}; - $[18] = glimmerIndex; - $[19] = t4; - } else { - t4 = $[19]; - } - t3 = lines_0.map(t4); - $[15] = glimmerIndex; - $[16] = lines_0; - $[17] = t3; - } else { - t3 = $[17]; - } - let t4; - if ($[20] !== ref || $[21] !== t3) { - t4 = {t3}; - $[20] = ref; - $[21] = t3; - $[22] = t4; - } else { - t4 = $[22]; - } - return t4; -} -function _temp(h) { - return h.shimmerColor; + + return { lines, hasShimmer, sweepStart, cycleLength } + }, [text, highlights]) + + const [ref, time] = useAnimationFrame(hasShimmer ? 50 : null) + const glimmerIndex = hasShimmer + ? sweepStart + (Math.floor(time / 50) % cycleLength) + : -100 + + return ( + + {lines.map((lineParts, lineIndex) => ( + + {lineParts.length === 0 ? ( + + ) : ( + lineParts.map((part, partIndex) => { + if (part.highlight?.shimmerColor && part.highlight.color) { + return ( + + {part.text.split('').map((char, charIndex) => ( + + ))} + + ) + } + return ( + + {part.text} + + ) + }) + )} + + ))} + + ) } diff --git a/src/components/PromptInput/VoiceIndicator.tsx b/src/components/PromptInput/VoiceIndicator.tsx index 9bf0a6d8b..6dc73baf2 100644 --- a/src/components/PromptInput/VoiceIndicator.tsx +++ b/src/components/PromptInput/VoiceIndicator.tsx @@ -1,73 +1,32 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useSettings } from '../../hooks/useSettings.js'; -import { Box, Text, useAnimationFrame } from '../../ink.js'; -import { interpolateColor, toRGBColor } from '../Spinner/utils.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useSettings } from '../../hooks/useSettings.js' +import { Box, Text, useAnimationFrame } from '../../ink.js' +import { interpolateColor, toRGBColor } from '../Spinner/utils.js' + type Props = { - voiceState: 'idle' | 'recording' | 'processing'; -}; + voiceState: 'idle' | 'recording' | 'processing' +} // Processing shimmer colors: dim gray to lighter gray (matches ThinkingShimmerText) -const PROCESSING_DIM = { - r: 153, - g: 153, - b: 153 -}; -const PROCESSING_BRIGHT = { - r: 185, - g: 185, - b: 185 -}; -const PULSE_PERIOD_S = 2; // 2 second period for all pulsing animations +const PROCESSING_DIM = { r: 153, g: 153, b: 153 } +const PROCESSING_BRIGHT = { r: 185, g: 185, b: 185 } -export function VoiceIndicator(props) { - const $ = _c(2); - if (!feature("VOICE_MODE")) { - return null; - } - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +const PULSE_PERIOD_S = 2 // 2 second period for all pulsing animations + +export function VoiceIndicator(props: Props): React.ReactNode { + if (!feature('VOICE_MODE')) return null + return } -function VoiceIndicatorImpl(t0) { - const $ = _c(2); - const { - voiceState - } = t0; + +function VoiceIndicatorImpl({ voiceState }: Props): React.ReactNode { switch (voiceState) { - case "recording": - { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = listening…; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - case "processing": - { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - case "idle": - { - return null; - } + case 'recording': + return listening… + case 'processing': + return + case 'idle': + return null } } @@ -75,62 +34,30 @@ function VoiceIndicatorImpl(t0) { // is too brief for a 1s-period shimmer to register, and a 50ms animation // timer here runs concurrently with auto-repeat spaces arriving every // 30-80ms, compounding re-renders during an already-busy window. -export function VoiceWarmupHint() { - const $ = _c(1); - if (!feature("VOICE_MODE")) { - return null; - } - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = keep holding…; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +export function VoiceWarmupHint(): React.ReactNode { + if (!feature('VOICE_MODE')) return null + return keep holding… } -function ProcessingShimmer() { - const $ = _c(8); - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const [ref, time] = useAnimationFrame(reducedMotion ? null : 50); + +function ProcessingShimmer(): React.ReactNode { + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + const [ref, time] = useAnimationFrame(reducedMotion ? null : 50) + if (reducedMotion) { - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Voice: processing…; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; + return Voice: processing… } - const elapsedSec = time / 1000; - const opacity = (Math.sin(elapsedSec * Math.PI * 2 / PULSE_PERIOD_S) + 1) / 2; - let t0; - if ($[1] !== opacity) { - t0 = toRGBColor(interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity)); - $[1] = opacity; - $[2] = t0; - } else { - t0 = $[2]; - } - const color = t0; - let t1; - if ($[3] !== color) { - t1 = Voice: processing…; - $[3] = color; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] !== ref || $[6] !== t1) { - t2 = {t1}; - $[5] = ref; - $[6] = t1; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; + + const elapsedSec = time / 1000 + const opacity = + (Math.sin((elapsedSec * Math.PI * 2) / PULSE_PERIOD_S) + 1) / 2 + const color = toRGBColor( + interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity), + ) + + return ( + + Voice: processing… + + ) } diff --git a/src/components/agents/AgentDetail.tsx b/src/components/agents/AgentDetail.tsx index 4c0f50e56..4c817b134 100644 --- a/src/components/agents/AgentDetail.tsx +++ b/src/components/agents/AgentDetail.tsx @@ -1,219 +1,148 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Tools } from '../../Tool.js'; -import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js'; -import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js'; -import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js'; -import { type AgentDefinition, isBuiltInAgent } from '../../tools/AgentTool/loadAgentsDir.js'; -import { getAgentModelDisplay } from '../../utils/model/agent.js'; -import { Markdown } from '../Markdown.js'; -import { getActualRelativeAgentFilePath } from './agentFileUtils.js'; +import figures from 'figures' +import * as React from 'react' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Tools } from '../../Tool.js' +import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js' +import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js' +import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js' +import { + type AgentDefinition, + isBuiltInAgent, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { getAgentModelDisplay } from '../../utils/model/agent.js' +import { Markdown } from '../Markdown.js' +import { getActualRelativeAgentFilePath } from './agentFileUtils.js' + type Props = { - agent: AgentDefinition; - tools: Tools; - allAgents?: AgentDefinition[]; - onBack: () => void; -}; -export function AgentDetail(t0) { - const $ = _c(48); - const { - agent, - tools, - onBack - } = t0; - const resolvedTools = resolveAgentTools(agent, tools, false); - let t1; - if ($[0] !== agent) { - t1 = getActualRelativeAgentFilePath(agent); - $[0] = agent; - $[1] = t1; - } else { - t1 = $[1]; - } - const filePath = t1; - let t2; - if ($[2] !== agent.agentType) { - t2 = getAgentColor(agent.agentType); - $[2] = agent.agentType; - $[3] = t2; - } else { - t2 = $[3]; - } - const backgroundColor = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[4] = t3; - } else { - t3 = $[4]; - } - useKeybinding("confirm:no", onBack, t3); - let t4; - if ($[5] !== onBack) { - t4 = e => { - if (e.key === "return") { - e.preventDefault(); - onBack(); - } - }; - $[5] = onBack; - $[6] = t4; - } else { - t4 = $[6]; - } - const handleKeyDown = t4; - const renderToolsList = function renderToolsList() { - if (resolvedTools.hasWildcard) { - return All tools; - } - if (!agent.tools || agent.tools.length === 0) { - return None; - } - return <>{resolvedTools.validTools.length > 0 && {resolvedTools.validTools.join(", ")}}{resolvedTools.invalidTools.length > 0 && {figures.warning} Unrecognized:{" "}{resolvedTools.invalidTools.join(", ")}}; - }; - const T0 = Box; - const t5 = "column"; - const t6 = 1; - const t7 = 0; - const t8 = true; - let t9; - if ($[7] !== filePath) { - t9 = {filePath}; - $[7] = filePath; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Description (tells Claude when to use this agent):; - $[9] = t10; - } else { - t10 = $[9]; - } - let t11; - if ($[10] !== agent.whenToUse) { - t11 = {t10}{agent.whenToUse}; - $[10] = agent.whenToUse; - $[11] = t11; - } else { - t11 = $[11]; - } - const T1 = Box; - let t12; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Tools:{" "}; - $[12] = t12; - } else { - t12 = $[12]; - } - const t13 = renderToolsList(); - let t14; - if ($[13] !== T1 || $[14] !== t12 || $[15] !== t13) { - t14 = {t12}{t13}; - $[13] = T1; - $[14] = t12; - $[15] = t13; - $[16] = t14; - } else { - t14 = $[16]; - } - let t15; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t15 = Model; - $[17] = t15; - } else { - t15 = $[17]; - } - let t16; - if ($[18] !== agent.model) { - t16 = getAgentModelDisplay(agent.model); - $[18] = agent.model; - $[19] = t16; - } else { - t16 = $[19]; - } - let t17; - if ($[20] !== t16) { - t17 = {t15}: {t16}; - $[20] = t16; - $[21] = t17; - } else { - t17 = $[21]; - } - let t18; - if ($[22] !== agent.permissionMode) { - t18 = agent.permissionMode && Permission mode: {agent.permissionMode}; - $[22] = agent.permissionMode; - $[23] = t18; - } else { - t18 = $[23]; - } - let t19; - if ($[24] !== agent.memory) { - t19 = agent.memory && Memory: {getMemoryScopeDisplay(agent.memory)}; - $[24] = agent.memory; - $[25] = t19; - } else { - t19 = $[25]; - } - let t20; - if ($[26] !== agent.hooks) { - t20 = agent.hooks && Object.keys(agent.hooks).length > 0 && Hooks: {Object.keys(agent.hooks).join(", ")}; - $[26] = agent.hooks; - $[27] = t20; - } else { - t20 = $[27]; - } - let t21; - if ($[28] !== agent.skills) { - t21 = agent.skills && agent.skills.length > 0 && Skills:{" "}{agent.skills.length > 10 ? `${agent.skills.length} skills` : agent.skills.join(", ")}; - $[28] = agent.skills; - $[29] = t21; - } else { - t21 = $[29]; - } - let t22; - if ($[30] !== agent.agentType || $[31] !== backgroundColor) { - t22 = backgroundColor && Color:{" "}{" "}{agent.agentType}{" "}; - $[30] = agent.agentType; - $[31] = backgroundColor; - $[32] = t22; - } else { - t22 = $[32]; - } - let t23; - if ($[33] !== agent) { - t23 = !isBuiltInAgent(agent) && <>System prompt:{agent.getSystemPrompt()}; - $[33] = agent; - $[34] = t23; - } else { - t23 = $[34]; - } - let t24; - if ($[35] !== T0 || $[36] !== handleKeyDown || $[37] !== t11 || $[38] !== t14 || $[39] !== t17 || $[40] !== t18 || $[41] !== t19 || $[42] !== t20 || $[43] !== t21 || $[44] !== t22 || $[45] !== t23 || $[46] !== t9) { - t24 = {t9}{t11}{t14}{t17}{t18}{t19}{t20}{t21}{t22}{t23}; - $[35] = T0; - $[36] = handleKeyDown; - $[37] = t11; - $[38] = t14; - $[39] = t17; - $[40] = t18; - $[41] = t19; - $[42] = t20; - $[43] = t21; - $[44] = t22; - $[45] = t23; - $[46] = t9; - $[47] = t24; - } else { - t24 = $[47]; - } - return t24; + agent: AgentDefinition + tools: Tools + allAgents?: AgentDefinition[] + onBack: () => void +} + +export function AgentDetail({ agent, tools, onBack }: Props): React.ReactNode { + const resolvedTools = resolveAgentTools(agent, tools, false) + const filePath = getActualRelativeAgentFilePath(agent) + const backgroundColor = getAgentColor(agent.agentType) + + // Handle Esc to go back + useKeybinding('confirm:no', onBack, { context: 'Confirmation' }) + + // Handle Enter to go back + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + onBack() + } + } + + function renderToolsList(): React.ReactNode { + if (resolvedTools.hasWildcard) { + return All tools + } + + if (!agent.tools || agent.tools.length === 0) { + return None + } + + return ( + <> + {resolvedTools.validTools.length > 0 && ( + {resolvedTools.validTools.join(', ')} + )} + {resolvedTools.invalidTools.length > 0 && ( + + {figures.warning} Unrecognized:{' '} + {resolvedTools.invalidTools.join(', ')} + + )} + + ) + } + + return ( + + {filePath} + + + + Description (tells Claude when to use this agent): + + + {agent.whenToUse} + + + + + + Tools:{' '} + + {renderToolsList()} + + + + Model: {getAgentModelDisplay(agent.model)} + + + {agent.permissionMode && ( + + Permission mode: {agent.permissionMode} + + )} + + {agent.memory && ( + + Memory: {getMemoryScopeDisplay(agent.memory)} + + )} + + {agent.hooks && Object.keys(agent.hooks).length > 0 && ( + + Hooks: {Object.keys(agent.hooks).join(', ')} + + )} + + {agent.skills && agent.skills.length > 0 && ( + + Skills:{' '} + {agent.skills.length > 10 + ? `${agent.skills.length} skills` + : agent.skills.join(', ')} + + )} + + {backgroundColor && ( + + + Color:{' '} + + {' '} + {agent.agentType}{' '} + + + + )} + + {!isBuiltInAgent(agent) && ( + <> + + + System prompt: + + + + {agent.getSystemPrompt()} + + + )} + + ) } diff --git a/src/components/agents/AgentEditor.tsx b/src/components/agents/AgentEditor.tsx index e406cf5b2..e5c7b1847 100644 --- a/src/components/agents/AgentEditor.tsx +++ b/src/components/agents/AgentEditor.tsx @@ -1,177 +1,246 @@ -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import { useSetAppState } from 'src/state/AppState.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Tools } from '../../Tool.js'; -import { type AgentColorName, setAgentColor } from '../../tools/AgentTool/agentColorManager.js'; -import { type AgentDefinition, getActiveAgentsFromList, isCustomAgent, isPluginAgent } from '../../tools/AgentTool/loadAgentsDir.js'; -import { editFileInEditor } from '../../utils/promptEditor.js'; -import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js'; -import { ColorPicker } from './ColorPicker.js'; -import { ModelSelector } from './ModelSelector.js'; -import { ToolSelector } from './ToolSelector.js'; -import { getAgentSourceDisplayName } from './utils.js'; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useSetAppState } from 'src/state/AppState.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Tools } from '../../Tool.js' +import { + type AgentColorName, + setAgentColor, +} from '../../tools/AgentTool/agentColorManager.js' +import { + type AgentDefinition, + getActiveAgentsFromList, + isCustomAgent, + isPluginAgent, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { editFileInEditor } from '../../utils/promptEditor.js' +import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js' +import { ColorPicker } from './ColorPicker.js' +import { ModelSelector } from './ModelSelector.js' +import { ToolSelector } from './ToolSelector.js' +import { getAgentSourceDisplayName } from './utils.js' + type Props = { - agent: AgentDefinition; - tools: Tools; - onSaved: (message: string) => void; - onBack: () => void; -}; -type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model'; + agent: AgentDefinition + tools: Tools + onSaved: (message: string) => void + onBack: () => void +} + +type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model' + type SaveChanges = { - tools?: string[]; - color?: AgentColorName; - model?: string; -}; + tools?: string[] + color?: AgentColorName + model?: string +} + export function AgentEditor({ agent, tools, onSaved, - onBack + onBack, }: Props): React.ReactNode { - const setAppState = useSetAppState(); - const [editMode, setEditMode] = useState('menu'); - const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); - const [error, setError] = useState(null); - const [selectedColor, setSelectedColor] = useState(agent.color as AgentColorName | undefined); + const setAppState = useSetAppState() + const [editMode, setEditMode] = useState('menu') + const [selectedMenuIndex, setSelectedMenuIndex] = useState(0) + const [error, setError] = useState(null) + const [selectedColor, setSelectedColor] = useState< + AgentColorName | undefined + >(agent.color as AgentColorName | undefined) + const handleOpenInEditor = useCallback(async () => { - const filePath = getActualAgentFilePath(agent); - const result = await editFileInEditor(filePath); + const filePath = getActualAgentFilePath(agent) + const result = await editFileInEditor(filePath) + if (result.error) { - setError(result.error); + setError(result.error) } else { - onSaved(`Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`); + onSaved( + `Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`, + ) } - }, [agent, onSaved]); - const handleSave = useCallback(async (changes: SaveChanges = {}) => { - const { - tools: newTools, - color: newColor, - model: newModel - } = changes; - const finalColor = newColor ?? selectedColor; - const hasToolsChanged = newTools !== undefined; - const hasModelChanged = newModel !== undefined; - const hasColorChanged = finalColor !== agent.color; - if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { - return false; - } - try { - // Only custom/plugin agents can be edited - // this is for type safety; the UI shouldn't allow editing otherwise - if (!isCustomAgent(agent) && !isPluginAgent(agent)) { - return false; + }, [agent, onSaved]) + + const handleSave = useCallback( + async (changes: SaveChanges = {}) => { + const { tools: newTools, color: newColor, model: newModel } = changes + const finalColor = newColor ?? selectedColor + const hasToolsChanged = newTools !== undefined + const hasModelChanged = newModel !== undefined + const hasColorChanged = finalColor !== agent.color + + if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { + return false } - await updateAgentFile(agent, agent.whenToUse, newTools ?? agent.tools, agent.getSystemPrompt(), finalColor, newModel ?? agent.model); - if (hasColorChanged && finalColor) { - setAgentColor(agent.agentType, finalColor); - } - setAppState(state => { - const allAgents = state.agentDefinitions.allAgents.map(a => a.agentType === agent.agentType ? { - ...a, - tools: newTools ?? a.tools, - color: finalColor, - model: newModel ?? a.model - } : a); - return { - ...state, - agentDefinitions: { - ...state.agentDefinitions, - activeAgents: getActiveAgentsFromList(allAgents), - allAgents + + try { + // Only custom/plugin agents can be edited + // this is for type safety; the UI shouldn't allow editing otherwise + if (!isCustomAgent(agent) && !isPluginAgent(agent)) { + return false + } + + await updateAgentFile( + agent, + agent.whenToUse, + newTools ?? agent.tools, + agent.getSystemPrompt(), + finalColor, + newModel ?? agent.model, + ) + + if (hasColorChanged && finalColor) { + setAgentColor(agent.agentType, finalColor) + } + + setAppState(state => { + const allAgents = state.agentDefinitions.allAgents.map(a => + a.agentType === agent.agentType + ? { + ...a, + tools: newTools ?? a.tools, + color: finalColor, + model: newModel ?? a.model, + } + : a, + ) + return { + ...state, + agentDefinitions: { + ...state.agentDefinitions, + activeAgents: getActiveAgentsFromList(allAgents), + allAgents, + }, } - }; - }); - onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`); - return true; - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save agent'); - return false; - } - }, [agent, selectedColor, onSaved, setAppState]); - const menuItems = useMemo(() => [{ - label: 'Open in editor', - action: handleOpenInEditor - }, { - label: 'Edit tools', - action: () => setEditMode('edit-tools') - }, { - label: 'Edit model', - action: () => setEditMode('edit-model') - }, { - label: 'Edit color', - action: () => setEditMode('edit-color') - }], [handleOpenInEditor]); - const handleEscape = useCallback(() => { - setError(null); - if (editMode === 'menu') { - onBack(); - } else { - setEditMode('menu'); - } - }, [editMode, onBack]); - const handleMenuKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'up') { - e.preventDefault(); - setSelectedMenuIndex(index => Math.max(0, index - 1)); - } else if (e.key === 'down') { - e.preventDefault(); - setSelectedMenuIndex(index_0 => Math.min(menuItems.length - 1, index_0 + 1)); - } else if (e.key === 'return') { - e.preventDefault(); - const selectedItem = menuItems[selectedMenuIndex]; - if (selectedItem) { - void selectedItem.action(); + }) + + onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`) + return true + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save agent') + return false } + }, + [agent, selectedColor, onSaved, setAppState], + ) + + const menuItems = useMemo( + () => [ + { label: 'Open in editor', action: handleOpenInEditor }, + { label: 'Edit tools', action: () => setEditMode('edit-tools') }, + { label: 'Edit model', action: () => setEditMode('edit-model') }, + { label: 'Edit color', action: () => setEditMode('edit-color') }, + ], + [handleOpenInEditor], + ) + + const handleEscape = useCallback(() => { + setError(null) + if (editMode === 'menu') { + onBack() + } else { + setEditMode('menu') } - }, [menuItems, selectedMenuIndex]); - useKeybinding('confirm:no', handleEscape, { - context: 'Confirmation' - }); - const renderMenu = (): React.ReactNode => + }, [editMode, onBack]) + + const handleMenuKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'up') { + e.preventDefault() + setSelectedMenuIndex(index => Math.max(0, index - 1)) + } else if (e.key === 'down') { + e.preventDefault() + setSelectedMenuIndex(index => Math.min(menuItems.length - 1, index + 1)) + } else if (e.key === 'return') { + e.preventDefault() + const selectedItem = menuItems[selectedMenuIndex] + if (selectedItem) { + void selectedItem.action() + } + } + }, + [menuItems, selectedMenuIndex], + ) + + useKeybinding('confirm:no', handleEscape, { context: 'Confirmation' }) + + const renderMenu = (): React.ReactNode => ( + Source: {getAgentSourceDisplayName(agent.source)} - {menuItems.map((item, index_1) => - {index_1 === selectedMenuIndex ? `${figures.pointer} ` : ' '} + {menuItems.map((item, index) => ( + + {index === selectedMenuIndex ? `${figures.pointer} ` : ' '} {item.label} - )} + + ))} - {error && + {error && ( + {error} - } - ; + + )} + + ) + switch (editMode) { case 'menu': - return renderMenu(); + return renderMenu() + case 'edit-tools': - return { - setEditMode('menu'); - await handleSave({ - tools: finalTools - }); - }} />; + return ( + { + setEditMode('menu') + await handleSave({ tools: finalTools }) + }} + /> + ) + case 'edit-color': - return { - setSelectedColor(color); - setEditMode('menu'); - await handleSave({ - color - }); - }} />; + return ( + { + setSelectedColor(color) + setEditMode('menu') + await handleSave({ color }) + }} + /> + ) + case 'edit-model': - return { - setEditMode('menu'); - await handleSave({ - model - }); - }} />; + return ( + { + setEditMode('menu') + await handleSave({ model }) + }} + /> + ) + default: - return null; + return null } } diff --git a/src/components/agents/AgentNavigationFooter.tsx b/src/components/agents/AgentNavigationFooter.tsx index e20f7301d..9c4fa9f76 100644 --- a/src/components/agents/AgentNavigationFooter.tsx +++ b/src/components/agents/AgentNavigationFooter.tsx @@ -1,25 +1,23 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; +import * as React from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' + type Props = { - instructions?: string; -}; -export function AgentNavigationFooter(t0) { - const $ = _c(2); - const { - instructions: t1 - } = t0; - const instructions = t1 === undefined ? "Press \u2191\u2193 to navigate \xB7 Enter to select \xB7 Esc to go back" : t1; - const exitState = useExitOnCtrlCDWithKeybindings(); - const t2 = exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions; - let t3; - if ($[0] !== t2) { - t3 = {t2}; - $[0] = t2; - $[1] = t3; - } else { - t3 = $[1]; - } - return t3; + instructions?: string +} + +export function AgentNavigationFooter({ + instructions = 'Press ↑↓ to navigate · Enter to select · Esc to go back', +}: Props): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings() + + return ( + + + {exitState.pending + ? `Press ${exitState.keyName} again to exit` + : instructions} + + + ) } diff --git a/src/components/agents/AgentsList.tsx b/src/components/agents/AgentsList.tsx index 2e394fa1d..6eadf1ef7 100644 --- a/src/components/agents/AgentsList.tsx +++ b/src/components/agents/AgentsList.tsx @@ -1,439 +1,342 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js'; -import { AGENT_SOURCE_GROUPS, compareAgentsByName, getOverrideSourceLabel, resolveAgentModelDisplay } from '../../tools/AgentTool/agentDisplay.js'; -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; -import { count } from '../../utils/array.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { Divider } from '../design-system/Divider.js'; -import { getAgentSourceDisplayName } from './utils.js'; +import figures from 'figures' +import * as React from 'react' +import type { SettingSource } from 'src/utils/settings/constants.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js' +import { + AGENT_SOURCE_GROUPS, + compareAgentsByName, + getOverrideSourceLabel, + resolveAgentModelDisplay, +} from '../../tools/AgentTool/agentDisplay.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { count } from '../../utils/array.js' +import { Dialog } from '../design-system/Dialog.js' +import { Divider } from '../design-system/Divider.js' +import { getAgentSourceDisplayName } from './utils.js' + type Props = { - source: SettingSource | 'all' | 'built-in' | 'plugin'; - agents: ResolvedAgent[]; - onBack: () => void; - onSelect: (agent: AgentDefinition) => void; - onCreateNew?: () => void; - changes?: string[]; -}; -export function AgentsList(t0) { - const $ = _c(96); - const { - source, - agents, - onBack, - onSelect, - onCreateNew, - changes - } = t0; - const [selectedAgent, setSelectedAgent] = React.useState(null); - const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true); - let t1; - if ($[0] !== agents) { - t1 = [...agents].sort(compareAgentsByName); - $[0] = agents; - $[1] = t1; - } else { - t1 = $[1]; - } - const sortedAgents = t1; - const getOverrideInfo = _temp; - let t2; - if ($[2] !== isCreateNewSelected) { - t2 = () => {isCreateNewSelected ? `${figures.pointer} ` : " "}Create new agent; - $[2] = isCreateNewSelected; - $[3] = t2; - } else { - t2 = $[3]; - } - const renderCreateNewOption = t2; - let t3; - if ($[4] !== isCreateNewSelected || $[5] !== selectedAgent?.agentType || $[6] !== selectedAgent?.source) { - t3 = agent_0 => { - const isBuiltIn = agent_0.source === "built-in"; - const isSelected = !isBuiltIn && !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source; - const { - isOverridden, - overriddenBy - } = getOverrideInfo(agent_0); - const dimmed = isBuiltIn || isOverridden; - const textColor = !isBuiltIn && isSelected ? "suggestion" : undefined; - const resolvedModel = resolveAgentModelDisplay(agent_0); - return {isBuiltIn ? "" : isSelected ? `${figures.pointer} ` : " "}{agent_0.agentType}{resolvedModel && {" \xB7 "}{resolvedModel}}{agent_0.memory && {" \xB7 "}{agent_0.memory} memory}{overriddenBy && {" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}}; - }; - $[4] = isCreateNewSelected; - $[5] = selectedAgent?.agentType; - $[6] = selectedAgent?.source; - $[7] = t3; - } else { - t3 = $[7]; - } - const renderAgent = t3; - let t4; - if ($[8] !== sortedAgents || $[9] !== source) { - bb0: { - const nonBuiltIn = sortedAgents.filter(_temp2); - if (source === "all") { - t4 = AGENT_SOURCE_GROUPS.filter(_temp3).flatMap(t5 => { - const { - source: groupSource - } = t5; - return nonBuiltIn.filter(a_0 => a_0.source === groupSource); - }); - break bb0; - } - t4 = nonBuiltIn; + source: SettingSource | 'all' | 'built-in' | 'plugin' + agents: ResolvedAgent[] + onBack: () => void + onSelect: (agent: AgentDefinition) => void + onCreateNew?: () => void + changes?: string[] +} + +export function AgentsList({ + source, + agents, + onBack, + onSelect, + onCreateNew, + changes, +}: Props): React.ReactNode { + const [selectedAgent, setSelectedAgent] = + React.useState(null) + const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true) + + // Sort agents alphabetically by name within each source group + const sortedAgents = React.useMemo( + () => [...agents].sort(compareAgentsByName), + [agents], + ) + + const getOverrideInfo = (agent: ResolvedAgent) => { + return { + isOverridden: !!agent.overriddenBy, + overriddenBy: agent.overriddenBy || null, } - $[8] = sortedAgents; - $[9] = source; - $[10] = t4; - } else { - t4 = $[10]; } - const selectableAgentsInOrder = t4; - let t5; - let t6; - if ($[11] !== isCreateNewSelected || $[12] !== onCreateNew || $[13] !== selectableAgentsInOrder || $[14] !== selectedAgent) { - t5 = () => { - if (!selectedAgent && !isCreateNewSelected && selectableAgentsInOrder.length > 0) { - if (onCreateNew) { - setIsCreateNewSelected(true); - } else { - setSelectedAgent(selectableAgentsInOrder[0] || null); - } - } - }; - t6 = [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]; - $[11] = isCreateNewSelected; - $[12] = onCreateNew; - $[13] = selectableAgentsInOrder; - $[14] = selectedAgent; - $[15] = t5; - $[16] = t6; - } else { - t5 = $[15]; - t6 = $[16]; + + const renderCreateNewOption = () => { + return ( + + + {isCreateNewSelected ? `${figures.pointer} ` : ' '} + + + Create new agent + + + ) } - React.useEffect(t5, t6); - let t7; - if ($[17] !== isCreateNewSelected || $[18] !== onCreateNew || $[19] !== onSelect || $[20] !== selectableAgentsInOrder || $[21] !== selectedAgent) { - t7 = e => { - if (e.key === "return") { - e.preventDefault(); - if (isCreateNewSelected && onCreateNew) { - onCreateNew(); - } else { - if (selectedAgent) { - onSelect(selectedAgent); - } - } - return; - } - if (e.key !== "up" && e.key !== "down") { - return; - } - e.preventDefault(); - const hasCreateOption = !!onCreateNew; - const totalItems = selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0); - if (totalItems === 0) { - return; - } - let currentPosition = 0; - if (!isCreateNewSelected && selectedAgent) { - const agentIndex = selectableAgentsInOrder.findIndex(a_1 => a_1.agentType === selectedAgent.agentType && a_1.source === selectedAgent.source); - if (agentIndex >= 0) { - currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex; - } - } - const newPosition = e.key === "up" ? currentPosition === 0 ? totalItems - 1 : currentPosition - 1 : currentPosition === totalItems - 1 ? 0 : currentPosition + 1; - if (hasCreateOption && newPosition === 0) { - setIsCreateNewSelected(true); - setSelectedAgent(null); - } else { - const agentIndex_0 = hasCreateOption ? newPosition - 1 : newPosition; - const newAgent = selectableAgentsInOrder[agentIndex_0]; - if (newAgent) { - setIsCreateNewSelected(false); - setSelectedAgent(newAgent); - } - } - }; - $[17] = isCreateNewSelected; - $[18] = onCreateNew; - $[19] = onSelect; - $[20] = selectableAgentsInOrder; - $[21] = selectedAgent; - $[22] = t7; - } else { - t7 = $[22]; + + const renderAgent = (agent: ResolvedAgent) => { + const isBuiltIn = agent.source === 'built-in' + const isSelected = + !isBuiltIn && + !isCreateNewSelected && + selectedAgent?.agentType === agent.agentType && + selectedAgent?.source === agent.source + + const { isOverridden, overriddenBy } = getOverrideInfo(agent) + const dimmed = isBuiltIn || isOverridden + const textColor = !isBuiltIn && isSelected ? 'suggestion' : undefined + + const resolvedModel = resolveAgentModelDisplay(agent) + + return ( + + + {isBuiltIn ? '' : isSelected ? `${figures.pointer} ` : ' '} + + + {agent.agentType} + + {resolvedModel && ( + + {' · '} + {resolvedModel} + + )} + {agent.memory && ( + + {' · '} + {agent.memory} memory + + )} + {overriddenBy && ( + + {' '} + {figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)} + + )} + + ) } - const handleKeyDown = t7; - let t8; - if ($[23] !== renderAgent || $[24] !== sortedAgents) { - t8 = t9 => { - const title = t9 === undefined ? "Built-in (always available):" : t9; - const builtInAgents = sortedAgents.filter(_temp4); - return {title}{builtInAgents.map(renderAgent)}; - }; - $[23] = renderAgent; - $[24] = sortedAgents; - $[25] = t8; - } else { - t8 = $[25]; - } - const renderBuiltInAgentsSection = t8; - let t9; - if ($[26] !== renderAgent) { - t9 = (title_0, groupAgents) => { - if (!groupAgents.length) { - return null; - } - const folderPath = groupAgents[0]?.baseDir; - return {title_0}{folderPath && ({folderPath})}{groupAgents.map(agent_1 => renderAgent(agent_1))}; - }; - $[26] = renderAgent; - $[27] = t9; - } else { - t9 = $[27]; - } - const renderAgentGroup = t9; - let t10; - if ($[28] !== source) { - t10 = getAgentSourceDisplayName(source); - $[28] = source; - $[29] = t10; - } else { - t10 = $[29]; - } - const sourceTitle = t10; - let T0; - let T1; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - let t18; - let t19; - let t20; - let t21; - let t22; - if ($[30] !== changes || $[31] !== handleKeyDown || $[32] !== onBack || $[33] !== onCreateNew || $[34] !== renderAgent || $[35] !== renderAgentGroup || $[36] !== renderBuiltInAgentsSection || $[37] !== renderCreateNewOption || $[38] !== sortedAgents || $[39] !== source || $[40] !== sourceTitle) { - t22 = Symbol.for("react.early_return_sentinel"); - bb1: { - const builtInAgents_0 = sortedAgents.filter(_temp5); - const hasNoAgents = !sortedAgents.length || source !== "built-in" && !sortedAgents.some(_temp6); - if (hasNoAgents) { - let t23; - if ($[55] !== onCreateNew || $[56] !== renderCreateNewOption) { - t23 = onCreateNew && {renderCreateNewOption()}; - $[55] = onCreateNew; - $[56] = renderCreateNewOption; - $[57] = t23; - } else { - t23 = $[57]; - } - let t24; - let t25; - let t26; - if ($[58] === Symbol.for("react.memo_cache_sentinel")) { - t24 = No agents found. Create specialized subagents that Claude can delegate to.; - t25 = Each subagent has its own context window, custom system prompt, and specific tools.; - t26 = Try creating: Code Reviewer, Code Simplifier, Security Reviewer, Tech Lead, or UX Reviewer.; - $[58] = t24; - $[59] = t25; - $[60] = t26; - } else { - t24 = $[58]; - t25 = $[59]; - t26 = $[60]; - } - let t27; - if ($[61] !== renderBuiltInAgentsSection || $[62] !== sortedAgents || $[63] !== source) { - t27 = source !== "built-in" && sortedAgents.some(_temp7) && <>{renderBuiltInAgentsSection()}; - $[61] = renderBuiltInAgentsSection; - $[62] = sortedAgents; - $[63] = source; - $[64] = t27; - } else { - t27 = $[64]; - } - let t28; - if ($[65] !== handleKeyDown || $[66] !== t23 || $[67] !== t27) { - t28 = {t23}{t24}{t25}{t26}{t27}; - $[65] = handleKeyDown; - $[66] = t23; - $[67] = t27; - $[68] = t28; - } else { - t28 = $[68]; - } - let t29; - if ($[69] !== onBack || $[70] !== sourceTitle || $[71] !== t28) { - t29 = {t28}; - $[69] = onBack; - $[70] = sourceTitle; - $[71] = t28; - $[72] = t29; - } else { - t29 = $[72]; - } - t22 = t29; - break bb1; - } - T1 = Dialog; - t17 = sourceTitle; - let t23; - if ($[73] !== sortedAgents) { - t23 = count(sortedAgents, _temp8); - $[73] = sortedAgents; - $[74] = t23; - } else { - t23 = $[74]; - } - t18 = `${t23} agents`; - t19 = onBack; - t20 = true; - if ($[75] !== changes) { - t21 = changes && changes.length > 0 && {changes[changes.length - 1]}; - $[75] = changes; - $[76] = t21; - } else { - t21 = $[76]; - } - T0 = Box; - t11 = "column"; - t12 = 0; - t13 = true; - t14 = handleKeyDown; - if ($[77] !== onCreateNew || $[78] !== renderCreateNewOption) { - t15 = onCreateNew && {renderCreateNewOption()}; - $[77] = onCreateNew; - $[78] = renderCreateNewOption; - $[79] = t15; - } else { - t15 = $[79]; - } - t16 = source === "all" ? <>{AGENT_SOURCE_GROUPS.filter(_temp9).map(t24 => { - const { - label, - source: groupSource_0 - } = t24; - return {renderAgentGroup(label, sortedAgents.filter(a_7 => a_7.source === groupSource_0))}; - })}{builtInAgents_0.length > 0 && Built-in agents (always available){builtInAgents_0.map(renderAgent)}} : source === "built-in" ? <>Built-in agents are provided by default and cannot be modified.{sortedAgents.map(agent_2 => renderAgent(agent_2))} : <>{sortedAgents.filter(_temp0).map(agent_3 => renderAgent(agent_3))}{sortedAgents.some(_temp1) && <>{renderBuiltInAgentsSection()}}; + + const selectableAgentsInOrder = React.useMemo(() => { + const nonBuiltIn = sortedAgents.filter(a => a.source !== 'built-in') + if (source === 'all') { + return AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').flatMap( + ({ source: groupSource }) => + nonBuiltIn.filter(a => a.source === groupSource), + ) + } + return nonBuiltIn + }, [sortedAgents, source]) + + // Set initial selection + React.useEffect(() => { + if ( + !selectedAgent && + !isCreateNewSelected && + selectableAgentsInOrder.length > 0 + ) { + if (onCreateNew) { + setIsCreateNewSelected(true) + } else { + setSelectedAgent(selectableAgentsInOrder[0] || null) + } + } + }, [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + if (isCreateNewSelected && onCreateNew) { + onCreateNew() + } else if (selectedAgent) { + onSelect(selectedAgent) + } + return + } + + if (e.key !== 'up' && e.key !== 'down') return + e.preventDefault() + + // Handle navigation with "Create New Agent" option + const hasCreateOption = !!onCreateNew + const totalItems = + selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0) + + if (totalItems === 0) return + + // Calculate current position in list (0 = create new, 1+ = agents) + let currentPosition = 0 + if (!isCreateNewSelected && selectedAgent) { + const agentIndex = selectableAgentsInOrder.findIndex( + a => + a.agentType === selectedAgent.agentType && + a.source === selectedAgent.source, + ) + if (agentIndex >= 0) { + currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex + } + } + + // Calculate new position with wrap-around + const newPosition = + e.key === 'up' + ? currentPosition === 0 + ? totalItems - 1 + : currentPosition - 1 + : currentPosition === totalItems - 1 + ? 0 + : currentPosition + 1 + + // Update selection based on new position + if (hasCreateOption && newPosition === 0) { + setIsCreateNewSelected(true) + setSelectedAgent(null) + } else { + const agentIndex = hasCreateOption ? newPosition - 1 : newPosition + const newAgent = selectableAgentsInOrder[agentIndex] + if (newAgent) { + setIsCreateNewSelected(false) + setSelectedAgent(newAgent) + } } - $[30] = changes; - $[31] = handleKeyDown; - $[32] = onBack; - $[33] = onCreateNew; - $[34] = renderAgent; - $[35] = renderAgentGroup; - $[36] = renderBuiltInAgentsSection; - $[37] = renderCreateNewOption; - $[38] = sortedAgents; - $[39] = source; - $[40] = sourceTitle; - $[41] = T0; - $[42] = T1; - $[43] = t11; - $[44] = t12; - $[45] = t13; - $[46] = t14; - $[47] = t15; - $[48] = t16; - $[49] = t17; - $[50] = t18; - $[51] = t19; - $[52] = t20; - $[53] = t21; - $[54] = t22; - } else { - T0 = $[41]; - T1 = $[42]; - t11 = $[43]; - t12 = $[44]; - t13 = $[45]; - t14 = $[46]; - t15 = $[47]; - t16 = $[48]; - t17 = $[49]; - t18 = $[50]; - t19 = $[51]; - t20 = $[52]; - t21 = $[53]; - t22 = $[54]; } - if (t22 !== Symbol.for("react.early_return_sentinel")) { - return t22; + + const renderBuiltInAgentsSection = ( + title = 'Built-in (always available):', + ) => { + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + return ( + + + {title} + + {builtInAgents.map(renderAgent)} + + ) } - let t23; - if ($[80] !== T0 || $[81] !== t11 || $[82] !== t12 || $[83] !== t13 || $[84] !== t14 || $[85] !== t15 || $[86] !== t16) { - t23 = {t15}{t16}; - $[80] = T0; - $[81] = t11; - $[82] = t12; - $[83] = t13; - $[84] = t14; - $[85] = t15; - $[86] = t16; - $[87] = t23; - } else { - t23 = $[87]; + + const renderAgentGroup = (title: string, groupAgents: ResolvedAgent[]) => { + if (!groupAgents.length) return null + + const folderPath = groupAgents[0]?.baseDir + + return ( + + + + {title} + + {folderPath && ({folderPath})} + + {groupAgents.map(agent => renderAgent(agent))} + + ) } - let t24; - if ($[88] !== T1 || $[89] !== t17 || $[90] !== t18 || $[91] !== t19 || $[92] !== t20 || $[93] !== t21 || $[94] !== t23) { - t24 = {t21}{t23}; - $[88] = T1; - $[89] = t17; - $[90] = t18; - $[91] = t19; - $[92] = t20; - $[93] = t21; - $[94] = t23; - $[95] = t24; - } else { - t24 = $[95]; + + const sourceTitle = getAgentSourceDisplayName(source) + + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + + const hasNoAgents = + !sortedAgents.length || + (source !== 'built-in' && !sortedAgents.some(a => a.source !== 'built-in')) + + if (hasNoAgents) { + return ( + + + {onCreateNew && {renderCreateNewOption()}} + + No agents found. Create specialized subagents that Claude can + delegate to. + + + Each subagent has its own context window, custom system prompt, and + specific tools. + + + Try creating: Code Reviewer, Code Simplifier, Security Reviewer, + Tech Lead, or UX Reviewer. + + {source !== 'built-in' && + sortedAgents.some(a => a.source === 'built-in') && ( + <> + + {renderBuiltInAgentsSection()} + + )} + + + ) } - return t24; -} -function _temp1(a_9) { - return a_9.source === "built-in"; -} -function _temp0(a_8) { - return a_8.source !== "built-in"; -} -function _temp9(g_0) { - return g_0.source !== "built-in"; -} -function _temp8(a_6) { - return !a_6.overriddenBy; -} -function _temp7(a_5) { - return a_5.source === "built-in"; -} -function _temp6(a_4) { - return a_4.source !== "built-in"; -} -function _temp5(a_3) { - return a_3.source === "built-in"; -} -function _temp4(a_2) { - return a_2.source === "built-in"; -} -function _temp3(g) { - return g.source !== "built-in"; -} -function _temp2(a) { - return a.source !== "built-in"; -} -function _temp(agent) { - return { - isOverridden: !!agent.overriddenBy, - overriddenBy: agent.overriddenBy || null - }; + + return ( + !a.overriddenBy)} agents`} + onCancel={onBack} + hideInputGuide + > + {changes && changes.length > 0 && ( + + {changes[changes.length - 1]} + + )} + + {onCreateNew && {renderCreateNewOption()}} + {source === 'all' ? ( + <> + {AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').map( + ({ label, source: groupSource }) => ( + + {renderAgentGroup( + label, + sortedAgents.filter(a => a.source === groupSource), + )} + + ), + )} + {builtInAgents.length > 0 && ( + + + Built-in agents (always available) + + {builtInAgents.map(renderAgent)} + + )} + + ) : source === 'built-in' ? ( + <> + + Built-in agents are provided by default and cannot be modified. + + + {sortedAgents.map(agent => renderAgent(agent))} + + + ) : ( + <> + {sortedAgents + .filter(a => a.source !== 'built-in') + .map(agent => renderAgent(agent))} + {sortedAgents.some(a => a.source === 'built-in') && ( + <> + + {renderBuiltInAgentsSection()} + + )} + + )} + + + ) } diff --git a/src/components/agents/AgentsMenu.tsx b/src/components/agents/AgentsMenu.tsx index 5a3f56eed..91de932b4 100644 --- a/src/components/agents/AgentsMenu.tsx +++ b/src/components/agents/AgentsMenu.tsx @@ -1,799 +1,369 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import type { SettingSource } from 'src/utils/settings/constants.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useMergedTools } from '../../hooks/useMergedTools.js'; -import { Box, Text } from '../../ink.js'; -import { useAppState, useSetAppState } from '../../state/AppState.js'; -import type { Tools } from '../../Tool.js'; -import { type ResolvedAgent, resolveAgentOverrides } from '../../tools/AgentTool/agentDisplay.js'; -import { type AgentDefinition, getActiveAgentsFromList } from '../../tools/AgentTool/loadAgentsDir.js'; -import { toError } from '../../utils/errors.js'; -import { logError } from '../../utils/log.js'; -import { Select } from '../CustomSelect/select.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { AgentDetail } from './AgentDetail.js'; -import { AgentEditor } from './AgentEditor.js'; -import { AgentNavigationFooter } from './AgentNavigationFooter.js'; -import { AgentsList } from './AgentsList.js'; -import { deleteAgentFromFile } from './agentFileUtils.js'; -import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js'; -import type { ModeState } from './types.js'; +import chalk from 'chalk' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import type { SettingSource } from 'src/utils/settings/constants.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useMergedTools } from '../../hooks/useMergedTools.js' +import { Box, Text } from '../../ink.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' +import type { Tools } from '../../Tool.js' +import { + type ResolvedAgent, + resolveAgentOverrides, +} from '../../tools/AgentTool/agentDisplay.js' +import { + type AgentDefinition, + getActiveAgentsFromList, +} from '../../tools/AgentTool/loadAgentsDir.js' +import { toError } from '../../utils/errors.js' +import { logError } from '../../utils/log.js' +import { Select } from '../CustomSelect/select.js' +import { Dialog } from '../design-system/Dialog.js' +import { AgentDetail } from './AgentDetail.js' +import { AgentEditor } from './AgentEditor.js' +import { AgentNavigationFooter } from './AgentNavigationFooter.js' +import { AgentsList } from './AgentsList.js' +import { deleteAgentFromFile } from './agentFileUtils.js' +import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js' +import type { ModeState } from './types.js' + type Props = { - tools: Tools; - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function AgentsMenu(t0) { - const $ = _c(157); - const { - tools, - onExit - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - mode: "list-agents", - source: "all" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const [modeState, setModeState] = useState(t1); - const agentDefinitions = useAppState(_temp); - const mcpTools = useAppState(_temp2); - const toolPermissionContext = useAppState(_temp3); - const setAppState = useSetAppState(); - const { - allAgents, - activeAgents: agents - } = agentDefinitions; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [changes, setChanges] = useState(t2); - const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext); - useExitOnCtrlCDWithKeybindings(); - let t3; - if ($[2] !== allAgents) { - t3 = allAgents.filter(_temp4); - $[2] = allAgents; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== allAgents) { - t4 = allAgents.filter(_temp5); - $[4] = allAgents; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== allAgents) { - t5 = allAgents.filter(_temp6); - $[6] = allAgents; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== allAgents) { - t6 = allAgents.filter(_temp7); - $[8] = allAgents; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== allAgents) { - t7 = allAgents.filter(_temp8); - $[10] = allAgents; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== allAgents) { - t8 = allAgents.filter(_temp9); - $[12] = allAgents; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== allAgents) { - t9 = allAgents.filter(_temp0); - $[14] = allAgents; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== allAgents || $[17] !== t3 || $[18] !== t4 || $[19] !== t5 || $[20] !== t6 || $[21] !== t7 || $[22] !== t8 || $[23] !== t9) { - t10 = { - "built-in": t3, - userSettings: t4, - projectSettings: t5, - policySettings: t6, - localSettings: t7, - flagSettings: t8, - plugin: t9, - all: allAgents - }; - $[16] = allAgents; - $[17] = t3; - $[18] = t4; - $[19] = t5; - $[20] = t6; - $[21] = t7; - $[22] = t8; - $[23] = t9; - $[24] = t10; - } else { - t10 = $[24]; - } - const agentsBySource = t10; - let t11; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t11 = message => { - setChanges(prev => [...prev, message]); - setModeState({ - mode: "list-agents", - source: "all" - }); - }; - $[25] = t11; - } else { - t11 = $[25]; - } - const handleAgentCreated = t11; - let t12; - if ($[26] !== setAppState) { - t12 = async agent => { - ; + tools: Tools + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { + const [modeState, setModeState] = useState({ + mode: 'list-agents', + source: 'all', + }) + const agentDefinitions = useAppState(s => s.agentDefinitions) + const mcpTools = useAppState(s => s.mcp.tools) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const setAppState = useSetAppState() + const { allAgents, activeAgents: agents } = agentDefinitions + const [changes, setChanges] = useState([]) + + // Get MCP tools from app state and merge with local tools + const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext) + + useExitOnCtrlCDWithKeybindings() + + const agentsBySource: Record< + SettingSource | 'all' | 'built-in' | 'plugin', + AgentDefinition[] + > = useMemo( + () => ({ + 'built-in': allAgents.filter(a => a.source === 'built-in'), + userSettings: allAgents.filter(a => a.source === 'userSettings'), + projectSettings: allAgents.filter(a => a.source === 'projectSettings'), + policySettings: allAgents.filter(a => a.source === 'policySettings'), + localSettings: allAgents.filter(a => a.source === 'localSettings'), + flagSettings: allAgents.filter(a => a.source === 'flagSettings'), + plugin: allAgents.filter(a => a.source === 'plugin'), + all: allAgents, + }), + [allAgents], + ) + + const handleAgentCreated = useCallback((message: string) => { + setChanges(prev => [...prev, message]) + setModeState({ mode: 'list-agents', source: 'all' }) + }, []) + + const handleAgentDeleted = useCallback( + async (agent: AgentDefinition) => { try { - await deleteAgentFromFile(agent); + await deleteAgentFromFile(agent) setAppState(state => { - const allAgents_0 = state.agentDefinitions.allAgents.filter(a_6 => !(a_6.agentType === agent.agentType && a_6.source === agent.source)); + const allAgents = state.agentDefinitions.allAgents.filter( + a => + !(a.agentType === agent.agentType && a.source === agent.source), + ) return { ...state, agentDefinitions: { ...state.agentDefinitions, - allAgents: allAgents_0, - activeAgents: getActiveAgentsFromList(allAgents_0) - } - }; - }); - setChanges(prev_0 => [...prev_0, `Deleted agent: ${chalk.bold(agent.agentType)}`]); - setModeState({ - mode: "list-agents", - source: "all" - }); - } catch (t13) { - const error = t13; - logError(toError(error)); + allAgents, + activeAgents: getActiveAgentsFromList(allAgents), + }, + } + }) + + setChanges(prev => [ + ...prev, + `Deleted agent: ${chalk.bold(agent.agentType)}`, + ]) + // Go back to the agents list after deletion + setModeState({ mode: 'list-agents', source: 'all' }) + } catch (error) { + logError(toError(error)) } - }; - $[26] = setAppState; - $[27] = t12; - } else { - t12 = $[27]; - } - const handleAgentDeleted = t12; + }, + [setAppState], + ) + + // Render based on mode switch (modeState.mode) { - case "list-agents": - { - let t13; - if ($[28] !== agentsBySource || $[29] !== modeState.source) { - t13 = modeState.source === "all" ? [...agentsBySource["built-in"], ...agentsBySource.userSettings, ...agentsBySource.projectSettings, ...agentsBySource.localSettings, ...agentsBySource.policySettings, ...agentsBySource.flagSettings, ...agentsBySource.plugin] : agentsBySource[modeState.source]; - $[28] = agentsBySource; - $[29] = modeState.source; - $[30] = t13; - } else { - t13 = $[30]; - } - const agentsToShow = t13; - let t14; - if ($[31] !== agents || $[32] !== agentsToShow) { - t14 = resolveAgentOverrides(agentsToShow, agents); - $[31] = agents; - $[32] = agentsToShow; - $[33] = t14; - } else { - t14 = $[33]; - } - const allResolved = t14; - const resolvedAgents = allResolved; - let t15; - if ($[34] !== changes || $[35] !== onExit) { - t15 = () => { - const exitMessage = changes.length > 0 ? `Agent changes:\n${changes.join("\n")}` : undefined; - onExit(exitMessage ?? "Agents dialog dismissed", { - display: changes.length === 0 ? "system" : undefined - }); - }; - $[34] = changes; - $[35] = onExit; - $[36] = t15; - } else { - t15 = $[36]; - } - let t16; - if ($[37] !== modeState) { - t16 = agent_0 => setModeState({ - mode: "agent-menu", - agent: agent_0, - previousMode: modeState - }); - $[37] = modeState; - $[38] = t16; - } else { - t16 = $[38]; - } - let t17; - if ($[39] === Symbol.for("react.memo_cache_sentinel")) { - t17 = () => setModeState({ - mode: "create-agent" - }); - $[39] = t17; - } else { - t17 = $[39]; - } - let t18; - if ($[40] !== changes || $[41] !== modeState.source || $[42] !== resolvedAgents || $[43] !== t15 || $[44] !== t16) { - t18 = ; - $[40] = changes; - $[41] = modeState.source; - $[42] = resolvedAgents; - $[43] = t15; - $[44] = t16; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[46] = t19; - } else { - t19 = $[46]; - } - let t20; - if ($[47] !== t18) { - t20 = <>{t18}{t19}; - $[47] = t18; - $[48] = t20; - } else { - t20 = $[48]; - } - return t20; - } - case "create-agent": - { - let t13; - if ($[49] === Symbol.for("react.memo_cache_sentinel")) { - t13 = () => setModeState({ - mode: "list-agents", - source: "all" - }); - $[49] = t13; - } else { - t13 = $[49]; - } - let t14; - if ($[50] !== agents || $[51] !== mergedTools) { - t14 = ; - $[50] = agents; - $[51] = mergedTools; - $[52] = t14; - } else { - t14 = $[52]; - } - return t14; - } - case "agent-menu": - { - let t13; - if ($[53] !== allAgents || $[54] !== modeState.agent.agentType || $[55] !== modeState.agent.source) { - let t14; - if ($[57] !== modeState.agent.agentType || $[58] !== modeState.agent.source) { - t14 = a_9 => a_9.agentType === modeState.agent.agentType && a_9.source === modeState.agent.source; - $[57] = modeState.agent.agentType; - $[58] = modeState.agent.source; - $[59] = t14; - } else { - t14 = $[59]; - } - t13 = allAgents.find(t14); - $[53] = allAgents; - $[54] = modeState.agent.agentType; - $[55] = modeState.agent.source; - $[56] = t13; - } else { - t13 = $[56]; - } - const freshAgent_1 = t13; - const agentToUse = freshAgent_1 || modeState.agent; - const isEditable = agentToUse.source !== "built-in" && agentToUse.source !== "plugin" && agentToUse.source !== "flagSettings"; - let t14; - if ($[60] === Symbol.for("react.memo_cache_sentinel")) { - t14 = { - label: "View agent", - value: "view" - }; - $[60] = t14; - } else { - t14 = $[60]; - } - let t15; - if ($[61] !== isEditable) { - t15 = isEditable ? [{ - label: "Edit agent", - value: "edit" - }, { - label: "Delete agent", - value: "delete" - }] : []; - $[61] = isEditable; - $[62] = t15; - } else { - t15 = $[62]; - } - let t16; - if ($[63] === Symbol.for("react.memo_cache_sentinel")) { - t16 = { - label: "Back", - value: "back" - }; - $[63] = t16; - } else { - t16 = $[63]; - } - let t17; - if ($[64] !== t15) { - t17 = [t14, ...t15, t16]; - $[64] = t15; - $[65] = t17; - } else { - t17 = $[65]; - } - const menuItems = t17; - let t18; - if ($[66] !== agentToUse || $[67] !== modeState) { - t18 = value_0 => { - bb129: switch (value_0) { - case "view": - { - setModeState({ - mode: "view-agent", - agent: agentToUse, - previousMode: modeState.previousMode - }); - break bb129; - } - case "edit": - { - setModeState({ - mode: "edit-agent", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "delete": - { - setModeState({ - mode: "delete-confirm", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "back": - { - setModeState(modeState.previousMode); - } + case 'list-agents': { + const agentsToShow = + modeState.source === 'all' + ? [ + ...agentsBySource['built-in'], + ...agentsBySource['userSettings'], + ...agentsBySource['projectSettings'], + ...agentsBySource['localSettings'], + ...agentsBySource['policySettings'], + ...agentsBySource['flagSettings'], + ...agentsBySource['plugin'], + ] + : agentsBySource[modeState.source] + + // Resolve overrides and filter to the agents we want to show + const allResolved = resolveAgentOverrides(agentsToShow, agents) + const resolvedAgents: ResolvedAgent[] = allResolved + + return ( + <> + { + const exitMessage = + changes.length > 0 + ? `Agent changes:\n${changes.join('\n')}` + : undefined + onExit(exitMessage ?? 'Agents dialog dismissed', { + display: changes.length === 0 ? 'system' : undefined, + }) + }} + onSelect={agent => + setModeState({ + mode: 'agent-menu', + agent, + previousMode: modeState, + }) } - }; - $[66] = agentToUse; - $[67] = modeState; - $[68] = t18; - } else { - t18 = $[68]; + onCreateNew={() => setModeState({ mode: 'create-agent' })} + changes={changes} + /> + + + ) + } + + case 'create-agent': + return ( + setModeState({ mode: 'list-agents', source: 'all' })} + /> + ) + + case 'agent-menu': { + // Always use fresh agent data + const freshAgent = allAgents.find( + a => + a.agentType === modeState.agent.agentType && + a.source === modeState.agent.source, + ) + const agentToUse = freshAgent || modeState.agent + + const isEditable = + agentToUse.source !== 'built-in' && + agentToUse.source !== 'plugin' && + agentToUse.source !== 'flagSettings' + const menuItems = [ + { label: 'View agent', value: 'view' }, + ...(isEditable + ? [ + { label: 'Edit agent', value: 'edit' }, + { label: 'Delete agent', value: 'delete' }, + ] + : []), + { label: 'Back', value: 'back' }, + ] + + const handleMenuSelect = (value: string): void => { + switch (value) { + case 'view': + setModeState({ + mode: 'view-agent', + agent: agentToUse, + previousMode: modeState.previousMode, + }) + break + case 'edit': + setModeState({ + mode: 'edit-agent', + agent: agentToUse, + previousMode: modeState, + }) + break + case 'delete': + setModeState({ + mode: 'delete-confirm', + agent: agentToUse, + previousMode: modeState, + }) + break + case 'back': + setModeState(modeState.previousMode) + break } - const handleMenuSelect = t18; - let t19; - if ($[69] !== modeState.previousMode) { - t19 = () => setModeState(modeState.previousMode); - $[69] = modeState.previousMode; - $[70] = t19; - } else { - t19 = $[70]; - } - let t20; - if ($[71] !== modeState.previousMode) { - t20 = () => setModeState(modeState.previousMode); - $[71] = modeState.previousMode; - $[72] = t20; - } else { - t20 = $[72]; - } - let t21; - if ($[73] !== handleMenuSelect || $[74] !== menuItems || $[75] !== t20) { - t21 = setModeState(modeState.previousMode)} + /> + {changes.length > 0 && ( + + {changes[changes.length - 1]} + + )} + + + + + ) + } + + case 'view-agent': { + // Always use fresh agent data from allAgents + const freshAgent = allAgents.find( + a => + a.agentType === modeState.agent.agentType && + a.source === modeState.agent.source, + ) + const agentToDisplay = freshAgent || modeState.agent + + return ( + <> + + setModeState({ + mode: 'agent-menu', + agent: agentToDisplay, + previousMode: modeState.previousMode, + }) } - }; - $[113] = modeState; - $[114] = t14; - } else { - t14 = $[114]; - } - let t15; - if ($[115] !== modeState.agent.agentType) { - t15 = Are you sure you want to delete the agent{" "}{modeState.agent.agentType}?; - $[115] = modeState.agent.agentType; - $[116] = t15; - } else { - t15 = $[116]; - } - let t16; - if ($[117] !== modeState.agent.source) { - t16 = Source: {modeState.agent.source}; - $[117] = modeState.agent.source; - $[118] = t16; - } else { - t16 = $[118]; - } - let t17; - if ($[119] !== handleAgentDeleted || $[120] !== modeState) { - t17 = value => { - if (value === "yes") { - handleAgentDeleted(modeState.agent); - } else { - if ("previousMode" in modeState) { - setModeState(modeState.previousMode); + hideInputGuide + > + + setModeState({ + mode: 'agent-menu', + agent: agentToDisplay, + previousMode: modeState.previousMode, + }) } - } - }; - $[119] = handleAgentDeleted; - $[120] = modeState; - $[121] = t17; - } else { - t17 = $[121]; - } - let t18; - if ($[122] !== modeState) { - t18 = () => { - if ("previousMode" in modeState) { - setModeState(modeState.previousMode); - } - }; - $[122] = modeState; - $[123] = t18; - } else { - t18 = $[123]; - } - let t19; - if ($[124] !== t17 || $[125] !== t18) { - t19 = { + if (value === 'yes') { + void handleAgentDeleted(modeState.agent) + } else { + if ('previousMode' in modeState) { + setModeState(modeState.previousMode) + } + } + }} + onCancel={() => { + if ('previousMode' in modeState) { + setModeState(modeState.previousMode) + } + }} + /> + + + + + ) + } + + case 'edit-agent': { + // Always use fresh agent data + const freshAgent = allAgents.find( + a => + a.agentType === modeState.agent.agentType && + a.source === modeState.agent.source, + ) + const agentToEdit = freshAgent || modeState.agent + + return ( + <> + setModeState(modeState.previousMode)} + hideInputGuide + > + { + handleAgentCreated(message) + setModeState(modeState.previousMode) + }} + onBack={() => setModeState(modeState.previousMode)} + /> + + + + ) + } + default: - { - return null; - } + return null } } -function _temp0(a_5) { - return a_5.source === "plugin"; -} -function _temp9(a_4) { - return a_4.source === "flagSettings"; -} -function _temp8(a_3) { - return a_3.source === "localSettings"; -} -function _temp7(a_2) { - return a_2.source === "policySettings"; -} -function _temp6(a_1) { - return a_1.source === "projectSettings"; -} -function _temp5(a_0) { - return a_0.source === "userSettings"; -} -function _temp4(a) { - return a.source === "built-in"; -} -function _temp3(s_1) { - return s_1.toolPermissionContext; -} -function _temp2(s_0) { - return s_0.mcp.tools; -} -function _temp(s) { - return s.agentDefinitions; -} diff --git a/src/components/agents/ColorPicker.tsx b/src/components/agents/ColorPicker.tsx index 2f372e3c0..8549424cd 100644 --- a/src/components/agents/ColorPicker.tsx +++ b/src/components/agents/ColorPicker.tsx @@ -1,111 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useState } from 'react'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import { capitalize } from '../../utils/stringUtils.js'; -type ColorOption = AgentColorName | 'automatic'; -const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS]; +import figures from 'figures' +import React, { useState } from 'react' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import { capitalize } from '../../utils/stringUtils.js' + +type ColorOption = AgentColorName | 'automatic' + +const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS] + type Props = { - agentName: string; - currentColor?: AgentColorName | 'automatic'; - onConfirm: (color: AgentColorName | undefined) => void; -}; -export function ColorPicker(t0) { - const $ = _c(17); - const { - agentName, - currentColor: t1, - onConfirm - } = t0; - const currentColor = t1 === undefined ? "automatic" : t1; - let t2; - if ($[0] !== currentColor) { - t2 = COLOR_OPTIONS.findIndex(opt => opt === currentColor); - $[0] = currentColor; - $[1] = t2; - } else { - t2 = $[1]; - } - const [selectedIndex, setSelectedIndex] = useState(Math.max(0, t2)); - let t3; - if ($[2] !== onConfirm || $[3] !== selectedIndex) { - t3 = e => { - if (e.key === "up") { - e.preventDefault(); - setSelectedIndex(_temp); - } else { - if (e.key === "down") { - e.preventDefault(); - setSelectedIndex(_temp2); - } else { - if (e.key === "return") { - e.preventDefault(); - const selected = COLOR_OPTIONS[selectedIndex]; - onConfirm(selected === "automatic" ? undefined : selected); - } - } - } - }; - $[2] = onConfirm; - $[3] = selectedIndex; - $[4] = t3; - } else { - t3 = $[4]; - } - const handleKeyDown = t3; - const selectedValue = COLOR_OPTIONS[selectedIndex]; - let t4; - if ($[5] !== selectedIndex) { - t4 = COLOR_OPTIONS.map((option, index) => { - const isSelected = index === selectedIndex; - return {isSelected ? figures.pointer : " "}{option === "automatic" ? Automatic color : {" "}{capitalize(option)}}; - }); - $[5] = selectedIndex; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== t4) { - t5 = {t4}; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Preview: ; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== agentName || $[11] !== selectedValue) { - t7 = {t6}{selectedValue === undefined || selectedValue === "automatic" ? {" "}@{agentName}{" "} : {" "}@{agentName}{" "}}; - $[10] = agentName; - $[11] = selectedValue; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== handleKeyDown || $[14] !== t5 || $[15] !== t7) { - t8 = {t5}{t7}; - $[13] = handleKeyDown; - $[14] = t5; - $[15] = t7; - $[16] = t8; - } else { - t8 = $[16]; - } - return t8; + agentName: string + currentColor?: AgentColorName | 'automatic' + onConfirm: (color: AgentColorName | undefined) => void } -function _temp2(prev_0) { - return prev_0 < COLOR_OPTIONS.length - 1 ? prev_0 + 1 : 0; -} -function _temp(prev) { - return prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1; + +export function ColorPicker({ + agentName, + currentColor = 'automatic', + onConfirm, +}: Props): React.ReactNode { + const [selectedIndex, setSelectedIndex] = useState( + Math.max( + 0, + COLOR_OPTIONS.findIndex(opt => opt === currentColor), + ), + ) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'up') { + e.preventDefault() + setSelectedIndex(prev => (prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1)) + } else if (e.key === 'down') { + e.preventDefault() + setSelectedIndex(prev => (prev < COLOR_OPTIONS.length - 1 ? prev + 1 : 0)) + } else if (e.key === 'return') { + e.preventDefault() + const selected = COLOR_OPTIONS[selectedIndex] + onConfirm(selected === 'automatic' ? undefined : selected) + } + } + + const selectedValue = COLOR_OPTIONS[selectedIndex] + + return ( + + + {COLOR_OPTIONS.map((option, index) => { + const isSelected = index === selectedIndex + + return ( + + + {isSelected ? figures.pointer : ' '} + + + {option === 'automatic' ? ( + Automatic color + ) : ( + + + {' '} + + {capitalize(option)} + + )} + + ) + })} + + + + Preview: + {selectedValue === undefined || selectedValue === 'automatic' ? ( + + {' '} + @{agentName}{' '} + + ) : ( + + {' '} + @{agentName}{' '} + + )} + + + ) } diff --git a/src/components/agents/ModelSelector.tsx b/src/components/agents/ModelSelector.tsx index 9e186c7d2..4f1b2e8af 100644 --- a/src/components/agents/ModelSelector.tsx +++ b/src/components/agents/ModelSelector.tsx @@ -1,67 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { getAgentModelOptions } from '../../utils/model/agent.js'; -import { Select } from '../CustomSelect/select.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { getAgentModelOptions } from '../../utils/model/agent.js' +import { Select } from '../CustomSelect/select.js' + interface ModelSelectorProps { - initialModel?: string; - onComplete: (model?: string) => void; - onCancel?: () => void; + initialModel?: string + onComplete: (model?: string) => void + onCancel?: () => void } -export function ModelSelector(t0) { - const $ = _c(11); - const { - initialModel, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== initialModel) { - bb0: { - const base = getAgentModelOptions(); - if (initialModel && !base.some(o => o.value === initialModel)) { - t1 = [{ + +export function ModelSelector({ + initialModel, + onComplete, + onCancel, +}: ModelSelectorProps): React.ReactNode { + const modelOptions = React.useMemo(() => { + const base = getAgentModelOptions() + // If the agent's current model is a full ID (e.g. 'claude-opus-4-5') not + // in the alias list, inject it as an option so it can round-trip through + // confirm without being overwritten. + if (initialModel && !base.some(o => o.value === initialModel)) { + return [ + { value: initialModel, label: initialModel, - description: "Current model (custom ID)" - }, ...base]; - break bb0; - } - t1 = base; + description: 'Current model (custom ID)', + }, + ...base, + ] } - $[0] = initialModel; - $[1] = t1; - } else { - t1 = $[1]; - } - const modelOptions = t1; - const defaultModel = initialModel ?? "sonnet"; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Model determines the agent's reasoning capabilities and speed.; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onCancel || $[4] !== onComplete) { - t3 = () => onCancel ? onCancel() : onComplete(undefined); - $[3] = onCancel; - $[4] = onComplete; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== defaultModel || $[7] !== modelOptions || $[8] !== onComplete || $[9] !== t3) { - t4 = {t2} (onCancel ? onCancel() : onComplete(undefined))} + /> + + ) } diff --git a/src/components/agents/ToolSelector.tsx b/src/components/agents/ToolSelector.tsx index 27766abae..9bc20b7d8 100644 --- a/src/components/agents/ToolSelector.tsx +++ b/src/components/agents/ToolSelector.tsx @@ -1,561 +1,478 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useCallback, useMemo, useState } from 'react'; -import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js'; -import { isMcpTool } from 'src/services/mcp/utils.js'; -import type { Tool, Tools } from 'src/Tool.js'; -import { filterToolsForAgent } from 'src/tools/AgentTool/agentToolUtils.js'; -import { AGENT_TOOL_NAME } from 'src/tools/AgentTool/constants.js'; -import { BashTool } from 'src/tools/BashTool/BashTool.js'; -import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; -import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js'; -import { FileReadTool } from 'src/tools/FileReadTool/FileReadTool.js'; -import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js'; -import { GlobTool } from 'src/tools/GlobTool/GlobTool.js'; -import { GrepTool } from 'src/tools/GrepTool/GrepTool.js'; -import { ListMcpResourcesTool } from 'src/tools/ListMcpResourcesTool/ListMcpResourcesTool.js'; -import { NotebookEditTool } from 'src/tools/NotebookEditTool/NotebookEditTool.js'; -import { ReadMcpResourceTool } from 'src/tools/ReadMcpResourceTool/ReadMcpResourceTool.js'; -import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js'; -import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js'; -import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js'; -import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.js'; -import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js'; -import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { count } from '../../utils/array.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Divider } from '../design-system/Divider.js'; +import figures from 'figures' +import React, { useCallback, useMemo, useState } from 'react' +import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js' +import { isMcpTool } from 'src/services/mcp/utils.js' +import type { Tool, Tools } from 'src/Tool.js' +import { filterToolsForAgent } from 'src/tools/AgentTool/agentToolUtils.js' +import { AGENT_TOOL_NAME } from 'src/tools/AgentTool/constants.js' +import { BashTool } from 'src/tools/BashTool/BashTool.js' +import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' +import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js' +import { FileReadTool } from 'src/tools/FileReadTool/FileReadTool.js' +import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js' +import { GlobTool } from 'src/tools/GlobTool/GlobTool.js' +import { GrepTool } from 'src/tools/GrepTool/GrepTool.js' +import { ListMcpResourcesTool } from 'src/tools/ListMcpResourcesTool/ListMcpResourcesTool.js' +import { NotebookEditTool } from 'src/tools/NotebookEditTool/NotebookEditTool.js' +import { ReadMcpResourceTool } from 'src/tools/ReadMcpResourceTool/ReadMcpResourceTool.js' +import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js' +import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js' +import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js' +import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.js' +import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js' +import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { count } from '../../utils/array.js' +import { plural } from '../../utils/stringUtils.js' +import { Divider } from '../design-system/Divider.js' + type Props = { - tools: Tools; - initialTools: string[] | undefined; - onComplete: (selectedTools: string[] | undefined) => void; - onCancel?: () => void; -}; + tools: Tools + initialTools: string[] | undefined + onComplete: (selectedTools: string[] | undefined) => void + onCancel?: () => void +} + type ToolBucket = { - name: string; - toolNames: Set; - isMcp?: boolean; -}; + name: string + toolNames: Set + isMcp?: boolean +} + type ToolBuckets = { - READ_ONLY: ToolBucket; - EDIT: ToolBucket; - EXECUTION: ToolBucket; - MCP: ToolBucket; - OTHER: ToolBucket; -}; + READ_ONLY: ToolBucket + EDIT: ToolBucket + EXECUTION: ToolBucket + MCP: ToolBucket + OTHER: ToolBucket +} + function getToolBuckets(): ToolBuckets { return { READ_ONLY: { name: 'Read-only tools', - toolNames: new Set([GlobTool.name, GrepTool.name, ExitPlanModeV2Tool.name, FileReadTool.name, WebFetchTool.name, TodoWriteTool.name, WebSearchTool.name, TaskStopTool.name, TaskOutputTool.name, ListMcpResourcesTool.name, ReadMcpResourceTool.name]) + toolNames: new Set([ + GlobTool.name, + GrepTool.name, + ExitPlanModeV2Tool.name, + FileReadTool.name, + WebFetchTool.name, + TodoWriteTool.name, + WebSearchTool.name, + TaskStopTool.name, + TaskOutputTool.name, + ListMcpResourcesTool.name, + ReadMcpResourceTool.name, + ]), }, EDIT: { name: 'Edit tools', - toolNames: new Set([FileEditTool.name, FileWriteTool.name, NotebookEditTool.name]) + toolNames: new Set([ + FileEditTool.name, + FileWriteTool.name, + NotebookEditTool.name, + ]), }, EXECUTION: { name: 'Execution tools', - toolNames: new Set([BashTool.name, (process.env.USER_TYPE) === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined)) + toolNames: new Set( + [ + BashTool.name, + process.env.USER_TYPE === 'ant' ? TungstenTool.name : undefined, + ].filter(n => n !== undefined), + ), }, MCP: { name: 'MCP tools', - toolNames: new Set(), - // Dynamic - no static list - isMcp: true + toolNames: new Set(), // Dynamic - no static list + isMcp: true, }, OTHER: { name: 'Other tools', - toolNames: new Set() // Dynamic - catch-all for uncategorized tools - } - }; + toolNames: new Set(), // Dynamic - catch-all for uncategorized tools + }, + } } // Helper to get MCP server buckets dynamically function getMcpServerBuckets(tools: Tools): Array<{ - serverName: string; - tools: Tools; + serverName: string + tools: Tools }> { - const serverMap = new Map(); + const serverMap = new Map() + tools.forEach(tool => { if (isMcpTool(tool)) { - const mcpInfo = mcpInfoFromString(tool.name); + const mcpInfo = mcpInfoFromString(tool.name) if (mcpInfo?.serverName) { - const existing = serverMap.get(mcpInfo.serverName) || []; - existing.push(tool); - serverMap.set(mcpInfo.serverName, existing); + const existing = serverMap.get(mcpInfo.serverName) || [] + existing.push(tool) + serverMap.set(mcpInfo.serverName, existing) } } - }); - return Array.from(serverMap.entries()).map(([serverName, tools]) => ({ - serverName, - tools - })).sort((a, b) => a.serverName.localeCompare(b.serverName)); + }) + + return Array.from(serverMap.entries()) + .map(([serverName, tools]) => ({ serverName, tools })) + .sort((a, b) => a.serverName.localeCompare(b.serverName)) } -export function ToolSelector(t0) { - const $ = _c(69); - const { - tools, - initialTools, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== tools) { - t1 = filterToolsForAgent({ - tools, - isBuiltIn: false, - isAsync: false - }); - $[0] = tools; - $[1] = t1; - } else { - t1 = $[1]; + +export function ToolSelector({ + tools, + initialTools, + onComplete, + onCancel, +}: Props): React.ReactNode { + // Filter tools for custom agents + const customAgentTools = useMemo( + () => filterToolsForAgent({ tools, isBuiltIn: false, isAsync: false }), + [tools], + ) + + // Expand wildcard or undefined to explicit tool list for internal state + const expandedInitialTools = + !initialTools || initialTools.includes('*') + ? customAgentTools.map(t => t.name) + : initialTools + + const [selectedTools, setSelectedTools] = + useState(expandedInitialTools) + const [focusIndex, setFocusIndex] = useState(0) + const [showIndividualTools, setShowIndividualTools] = useState(false) + + // Filter selectedTools to only include tools that currently exist + // This handles MCP tools that disconnect while selected + const validSelectedTools = useMemo(() => { + const toolNames = new Set(customAgentTools.map(t => t.name)) + return selectedTools.filter(name => toolNames.has(name)) + }, [selectedTools, customAgentTools]) + + const selectedSet = new Set(validSelectedTools) + const isAllSelected = + validSelectedTools.length === customAgentTools.length && + customAgentTools.length > 0 + + const handleToggleTool = (toolName: string) => { + if (!toolName) return + + setSelectedTools(current => + current.includes(toolName) + ? current.filter(t => t !== toolName) + : [...current, toolName], + ) } - const customAgentTools = t1; - let t2; - if ($[2] !== customAgentTools || $[3] !== initialTools) { - t2 = !initialTools || initialTools.includes("*") ? customAgentTools.map(_temp) : initialTools; - $[2] = customAgentTools; - $[3] = initialTools; - $[4] = t2; - } else { - t2 = $[4]; - } - const expandedInitialTools = t2; - const [selectedTools, setSelectedTools] = useState(expandedInitialTools); - const [focusIndex, setFocusIndex] = useState(0); - const [showIndividualTools, setShowIndividualTools] = useState(false); - let t3; - if ($[5] !== customAgentTools) { - t3 = new Set(customAgentTools.map(_temp2)); - $[5] = customAgentTools; - $[6] = t3; - } else { - t3 = $[6]; - } - const toolNames = t3; - let t4; - if ($[7] !== selectedTools || $[8] !== toolNames) { - let t5; - if ($[10] !== toolNames) { - t5 = name => toolNames.has(name); - $[10] = toolNames; - $[11] = t5; - } else { - t5 = $[11]; - } - t4 = selectedTools.filter(t5); - $[7] = selectedTools; - $[8] = toolNames; - $[9] = t4; - } else { - t4 = $[9]; - } - const validSelectedTools = t4; - let t5; - if ($[12] !== validSelectedTools) { - t5 = new Set(validSelectedTools); - $[12] = validSelectedTools; - $[13] = t5; - } else { - t5 = $[13]; - } - const selectedSet = t5; - const isAllSelected = validSelectedTools.length === customAgentTools.length && customAgentTools.length > 0; - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = toolName => { - if (!toolName) { - return; + + const handleToggleTools = (toolNames: string[], select: boolean) => { + setSelectedTools(current => { + if (select) { + const toolsToAdd = toolNames.filter(t => !current.includes(t)) + return [...current, ...toolsToAdd] + } else { + return current.filter(t => !toolNames.includes(t)) } - setSelectedTools(current => current.includes(toolName) ? current.filter(t_1 => t_1 !== toolName) : [...current, toolName]); - }; - $[14] = t6; - } else { - t6 = $[14]; + }) } - const handleToggleTool = t6; - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = (toolNames_0, select) => { - setSelectedTools(current_0 => { - if (select) { - const toolsToAdd = toolNames_0.filter(t_2 => !current_0.includes(t_2)); - return [...current_0, ...toolsToAdd]; - } else { - return current_0.filter(t_3 => !toolNames_0.includes(t_3)); - } - }); - }; - $[15] = t7; - } else { - t7 = $[15]; + + const handleConfirm = () => { + // Convert to undefined if all tools are selected (for cleaner file format) + const allToolNames = customAgentTools.map(t => t.name) + const areAllToolsSelected = + validSelectedTools.length === allToolNames.length && + allToolNames.every(name => validSelectedTools.includes(name)) + const finalTools = areAllToolsSelected ? undefined : validSelectedTools + + onComplete(finalTools) } - const handleToggleTools = t7; - let t8; - if ($[16] !== customAgentTools || $[17] !== onComplete || $[18] !== validSelectedTools) { - t8 = () => { - const allToolNames = customAgentTools.map(_temp3); - const areAllToolsSelected = validSelectedTools.length === allToolNames.length && allToolNames.every(name_0 => validSelectedTools.includes(name_0)); - const finalTools = areAllToolsSelected ? undefined : validSelectedTools; - onComplete(finalTools); - }; - $[16] = customAgentTools; - $[17] = onComplete; - $[18] = validSelectedTools; - $[19] = t8; - } else { - t8 = $[19]; - } - const handleConfirm = t8; - let buckets; - if ($[20] !== customAgentTools) { - const toolBuckets = getToolBuckets(); - buckets = { + + // Group tools by bucket + const toolsByBucket = useMemo(() => { + const toolBuckets = getToolBuckets() + const buckets = { readOnly: [] as Tool[], edit: [] as Tool[], execution: [] as Tool[], mcp: [] as Tool[], - other: [] as Tool[] - }; + other: [] as Tool[], + } + customAgentTools.forEach(tool => { + // Check if it's an MCP tool first if (isMcpTool(tool)) { - buckets.mcp.push(tool); - } else { - if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { - buckets.readOnly.push(tool); - } else { - if (toolBuckets.EDIT.toolNames.has(tool.name)) { - buckets.edit.push(tool); - } else { - if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { - buckets.execution.push(tool); - } else { - if (tool.name !== AGENT_TOOL_NAME) { - buckets.other.push(tool); - } - } - } - } + buckets.mcp.push(tool) + } else if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { + buckets.readOnly.push(tool) + } else if (toolBuckets.EDIT.toolNames.has(tool.name)) { + buckets.edit.push(tool) + } else if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { + buckets.execution.push(tool) + } else if (tool.name !== AGENT_TOOL_NAME) { + // Catch-all for uncategorized tools (except Task) + buckets.other.push(tool) } - }); - $[20] = customAgentTools; - $[21] = buckets; - } else { - buckets = $[21]; - } - const toolsByBucket = buckets; - let t9; - if ($[22] !== selectedSet) { - t9 = bucketTools => { - const selected = count(bucketTools, (t_5: Tool) => selectedSet.has(t_5.name)); - const needsSelection = selected < bucketTools.length; - return () => { - const toolNames_1 = bucketTools.map(_temp4); - handleToggleTools(toolNames_1, needsSelection); - }; - }; - $[22] = selectedSet; - $[23] = t9; - } else { - t9 = $[23]; - } - const createBucketToggleAction = t9; - let navigableItems; - if ($[24] !== createBucketToggleAction || $[25] !== customAgentTools || $[26] !== focusIndex || $[27] !== handleConfirm || $[28] !== isAllSelected || $[29] !== selectedSet || $[30] !== showIndividualTools || $[31] !== toolsByBucket.edit || $[32] !== toolsByBucket.execution || $[33] !== toolsByBucket.mcp || $[34] !== toolsByBucket.other || $[35] !== toolsByBucket.readOnly) { - navigableItems = []; - navigableItems.push({ - id: "continue", - label: "Continue", - action: handleConfirm, - isContinue: true - }); - let t10; - if ($[37] !== customAgentTools || $[38] !== isAllSelected) { - t10 = () => { - const allToolNames_0 = customAgentTools.map(_temp5); - handleToggleTools(allToolNames_0, !isAllSelected); - }; - $[37] = customAgentTools; - $[38] = isAllSelected; - $[39] = t10; - } else { - t10 = $[39]; + }) + + return buckets + }, [customAgentTools]) + + const createBucketToggleAction = (bucketTools: Tool[]) => { + const selected = count(bucketTools, t => selectedSet.has(t.name)) + const needsSelection = selected < bucketTools.length + + return () => { + const toolNames = bucketTools.map(t => t.name) + handleToggleTools(toolNames, needsSelection) } + } + + // Build navigable items (no separators) + const navigableItems: Array<{ + id: string + label: string + action: () => void + isContinue?: boolean + isToggle?: boolean + isHeader?: boolean + }> = [] + + // Continue button + navigableItems.push({ + id: 'continue', + label: 'Continue', + action: handleConfirm, + isContinue: true, + }) + + // All tools + navigableItems.push({ + id: 'bucket-all', + label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, + action: () => { + const allToolNames = customAgentTools.map(t => t.name) + handleToggleTools(allToolNames, !isAllSelected) + }, + }) + + // Create bucket menu items + const toolBuckets = getToolBuckets() + const bucketConfigs = [ + { + id: 'bucket-readonly', + name: toolBuckets.READ_ONLY.name, + tools: toolsByBucket.readOnly, + }, + { + id: 'bucket-edit', + name: toolBuckets.EDIT.name, + tools: toolsByBucket.edit, + }, + { + id: 'bucket-execution', + name: toolBuckets.EXECUTION.name, + tools: toolsByBucket.execution, + }, + { + id: 'bucket-mcp', + name: toolBuckets.MCP.name, + tools: toolsByBucket.mcp, + }, + { + id: 'bucket-other', + name: toolBuckets.OTHER.name, + tools: toolsByBucket.other, + }, + ] + + bucketConfigs.forEach(({ id, name, tools: bucketTools }) => { + if (bucketTools.length === 0) return + + const selected = count(bucketTools, t => selectedSet.has(t.name)) + const isFullySelected = selected === bucketTools.length + navigableItems.push({ - id: "bucket-all", - label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, - action: t10 - }); - const toolBuckets_0 = getToolBuckets(); - const bucketConfigs = [{ - id: "bucket-readonly", - name: toolBuckets_0.READ_ONLY.name, - tools: toolsByBucket.readOnly - }, { - id: "bucket-edit", - name: toolBuckets_0.EDIT.name, - tools: toolsByBucket.edit - }, { - id: "bucket-execution", - name: toolBuckets_0.EXECUTION.name, - tools: toolsByBucket.execution - }, { - id: "bucket-mcp", - name: toolBuckets_0.MCP.name, - tools: toolsByBucket.mcp - }, { - id: "bucket-other", - name: toolBuckets_0.OTHER.name, - tools: toolsByBucket.other - }]; - bucketConfigs.forEach(t11 => { - const { - id, - name: name_1, - tools: bucketTools_0 - } = t11; - if (bucketTools_0.length === 0) { - return; + id, + label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name}`, + action: createBucketToggleAction(bucketTools), + }) + }) + + // Toggle button for individual tools + const toggleButtonIndex = navigableItems.length + navigableItems.push({ + id: 'toggle-individual', + label: showIndividualTools + ? 'Hide advanced options' + : 'Show advanced options', + action: () => { + setShowIndividualTools(!showIndividualTools) + // If hiding tools and focus is on an individual tool, move focus to toggle button + if (showIndividualTools && focusIndex > toggleButtonIndex) { + setFocusIndex(toggleButtonIndex) } - const selected_0 = count(bucketTools_0, (t_8: Tool) => selectedSet.has(t_8.name)); - const isFullySelected = selected_0 === bucketTools_0.length; + }, + isToggle: true, + }) + + // Memoize MCP server buckets (must be outside conditional for hooks rules) + const mcpServerBuckets = useMemo( + () => getMcpServerBuckets(customAgentTools), + [customAgentTools], + ) + + // Individual tools (only if expanded) + if (showIndividualTools) { + // Add MCP server buckets if any exist + if (mcpServerBuckets.length > 0) { navigableItems.push({ - id, - label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name_1}`, - action: createBucketToggleAction(bucketTools_0) - }); - }); - const toggleButtonIndex = navigableItems.length; - let t12; - if ($[40] !== focusIndex || $[41] !== showIndividualTools || $[42] !== toggleButtonIndex) { - t12 = () => { - setShowIndividualTools(!showIndividualTools); - if (showIndividualTools && focusIndex > toggleButtonIndex) { - setFocusIndex(toggleButtonIndex); - } - }; - $[40] = focusIndex; - $[41] = showIndividualTools; - $[42] = toggleButtonIndex; - $[43] = t12; + id: 'mcp-servers-header', + label: 'MCP Servers:', + action: () => {}, // No action - just a header + isHeader: true, + }) + + mcpServerBuckets.forEach(({ serverName, tools: serverTools }) => { + const selected = count(serverTools, t => selectedSet.has(t.name)) + const isFullySelected = selected === serverTools.length + + navigableItems.push({ + id: `mcp-server-${serverName}`, + label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${serverName} (${serverTools.length} ${plural(serverTools.length, 'tool')})`, + action: () => { + const toolNames = serverTools.map(t => t.name) + handleToggleTools(toolNames, !isFullySelected) + }, + }) + }) + + // Add separator header before individual tools + navigableItems.push({ + id: 'tools-header', + label: 'Individual Tools:', + action: () => {}, + isHeader: true, + }) + } + + // Add individual tools + customAgentTools.forEach(tool => { + let displayName = tool.name + if (tool.name.startsWith('mcp__')) { + const mcpInfo = mcpInfoFromString(tool.name) + displayName = mcpInfo + ? `${mcpInfo.toolName} (${mcpInfo.serverName})` + : tool.name + } + + navigableItems.push({ + id: `tool-${tool.name}`, + label: `${selectedSet.has(tool.name) ? figures.checkboxOn : figures.checkboxOff} ${displayName}`, + action: () => handleToggleTool(tool.name), + }) + }) + } + + const handleCancel = useCallback(() => { + if (onCancel) { + onCancel() } else { - t12 = $[43]; + onComplete(initialTools) } - navigableItems.push({ - id: "toggle-individual", - label: showIndividualTools ? "Hide advanced options" : "Show advanced options", - action: t12, - isToggle: true - }); - const mcpServerBuckets = getMcpServerBuckets(customAgentTools); - if (showIndividualTools) { - if (mcpServerBuckets.length > 0) { - navigableItems.push({ - id: "mcp-servers-header", - label: "MCP Servers:", - action: _temp6, - isHeader: true - }); - mcpServerBuckets.forEach(t13 => { - const { - serverName, - tools: serverTools - } = t13; - const selected_1 = count(serverTools, t_9 => selectedSet.has(t_9.name)); - const isFullySelected_0 = selected_1 === serverTools.length; - navigableItems.push({ - id: `mcp-server-${serverName}`, - label: `${isFullySelected_0 ? figures.checkboxOn : figures.checkboxOff} ${serverName} (${serverTools.length} ${plural(serverTools.length, "tool")})`, - action: () => { - const toolNames_2 = serverTools.map(_temp7); - handleToggleTools(toolNames_2, !isFullySelected_0); - } - }); - }); - navigableItems.push({ - id: "tools-header", - label: "Individual Tools:", - action: _temp8, - isHeader: true - }); + }, [onCancel, onComplete, initialTools]) + + useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'return') { + e.preventDefault() + const item = navigableItems[focusIndex] + if (item && !item.isHeader) { + item.action() } - customAgentTools.forEach(tool_0 => { - let displayName = tool_0.name; - if (tool_0.name.startsWith("mcp__")) { - const mcpInfo = mcpInfoFromString(tool_0.name); - displayName = mcpInfo ? `${mcpInfo.toolName} (${mcpInfo.serverName})` : tool_0.name; - } - navigableItems.push({ - id: `tool-${tool_0.name}`, - label: `${selectedSet.has(tool_0.name) ? figures.checkboxOn : figures.checkboxOff} ${displayName}`, - action: () => handleToggleTool(tool_0.name) - }); - }); + } else if (e.key === 'up') { + e.preventDefault() + let newIndex = focusIndex - 1 + // Skip headers when navigating up + while (newIndex > 0 && navigableItems[newIndex]?.isHeader) { + newIndex-- + } + setFocusIndex(Math.max(0, newIndex)) + } else if (e.key === 'down') { + e.preventDefault() + let newIndex = focusIndex + 1 + // Skip headers when navigating down + while ( + newIndex < navigableItems.length - 1 && + navigableItems[newIndex]?.isHeader + ) { + newIndex++ + } + setFocusIndex(Math.min(navigableItems.length - 1, newIndex)) } - $[24] = createBucketToggleAction; - $[25] = customAgentTools; - $[26] = focusIndex; - $[27] = handleConfirm; - $[28] = isAllSelected; - $[29] = selectedSet; - $[30] = showIndividualTools; - $[31] = toolsByBucket.edit; - $[32] = toolsByBucket.execution; - $[33] = toolsByBucket.mcp; - $[34] = toolsByBucket.other; - $[35] = toolsByBucket.readOnly; - $[36] = navigableItems; - } else { - navigableItems = $[36]; } - let t10; - if ($[44] !== initialTools || $[45] !== onCancel || $[46] !== onComplete) { - t10 = () => { - if (onCancel) { - onCancel(); - } else { - onComplete(initialTools); - } - }; - $[44] = initialTools; - $[45] = onCancel; - $[46] = onComplete; - $[47] = t10; - } else { - t10 = $[47]; - } - const handleCancel = t10; - let t11; - if ($[48] === Symbol.for("react.memo_cache_sentinel")) { - t11 = { - context: "Confirmation" - }; - $[48] = t11; - } else { - t11 = $[48]; - } - useKeybinding("confirm:no", handleCancel, t11); - let t12; - if ($[49] !== focusIndex || $[50] !== navigableItems) { - t12 = e => { - if (e.key === "return") { - e.preventDefault(); - const item = navigableItems[focusIndex]; - if (item && !item.isHeader) { - item.action(); - } - } else { - if (e.key === "up") { - e.preventDefault(); - let newIndex = focusIndex - 1; - while (newIndex > 0 && navigableItems[newIndex]?.isHeader) { - newIndex--; - } - setFocusIndex(Math.max(0, newIndex)); - } else { - if (e.key === "down") { - e.preventDefault(); - let newIndex_0 = focusIndex + 1; - while (newIndex_0 < navigableItems.length - 1 && navigableItems[newIndex_0]?.isHeader) { - newIndex_0++; - } - setFocusIndex(Math.min(navigableItems.length - 1, newIndex_0)); - } - } - } - }; - $[49] = focusIndex; - $[50] = navigableItems; - $[51] = t12; - } else { - t12 = $[51]; - } - const handleKeyDown = t12; - const t13 = focusIndex === 0 ? "suggestion" : undefined; - const t14 = focusIndex === 0; - const t15 = focusIndex === 0 ? `${figures.pointer} ` : " "; - let t16; - if ($[52] !== t13 || $[53] !== t14 || $[54] !== t15) { - t16 = {t15}[ Continue ]; - $[52] = t13; - $[53] = t14; - $[54] = t15; - $[55] = t16; - } else { - t16 = $[55]; - } - let t17; - if ($[56] === Symbol.for("react.memo_cache_sentinel")) { - t17 = ; - $[56] = t17; - } else { - t17 = $[56]; - } - let t18; - if ($[57] !== navigableItems) { - t18 = navigableItems.slice(1); - $[57] = navigableItems; - $[58] = t18; - } else { - t18 = $[58]; - } - let t19; - if ($[59] !== focusIndex || $[60] !== t18) { - t19 = t18.map((item_0, index) => { - const isCurrentlyFocused = index + 1 === focusIndex; - const isToggleButton = item_0.isToggle; - const isHeader = item_0.isHeader; - return {isToggleButton && }{isHeader && index > 0 && }{isHeader ? "" : isCurrentlyFocused ? `${figures.pointer} ` : " "}{isToggleButton ? `[ ${item_0.label} ]` : item_0.label}; - }); - $[59] = focusIndex; - $[60] = t18; - $[61] = t19; - } else { - t19 = $[61]; - } - const t20 = isAllSelected ? "All tools selected" : `${selectedSet.size} of ${customAgentTools.length} tools selected`; - let t21; - if ($[62] !== t20) { - t21 = {t20}; - $[62] = t20; - $[63] = t21; - } else { - t21 = $[63]; - } - let t22; - if ($[64] !== handleKeyDown || $[65] !== t16 || $[66] !== t19 || $[67] !== t21) { - t22 = {t16}{t17}{t19}{t21}; - $[64] = handleKeyDown; - $[65] = t16; - $[66] = t19; - $[67] = t21; - $[68] = t22; - } else { - t22 = $[68]; - } - return t22; -} -function _temp8() {} -function _temp7(t_10) { - return t_10.name; -} -function _temp6() {} -function _temp5(t_7) { - return t_7.name; -} -function _temp4(t_6) { - return t_6.name; -} -function _temp3(t_4) { - return t_4.name; -} -function _temp2(t_0) { - return t_0.name; -} -function _temp(t) { - return t.name; + + return ( + + {/* Render Continue button */} + + {focusIndex === 0 ? `${figures.pointer} ` : ' '}[ Continue ] + + + {/* Separator */} + + + {/* Render all navigable items except Continue (which is at index 0) */} + {navigableItems.slice(1).map((item, index) => { + const isCurrentlyFocused = index + 1 === focusIndex + const isToggleButton = item.isToggle + const isHeader = item.isHeader + + return ( + + {/* Add separator before toggle button */} + {isToggleButton && } + + {/* Add margin before headers */} + {isHeader && index > 0 && } + + + {isHeader + ? '' + : isCurrentlyFocused + ? `${figures.pointer} ` + : ' '} + {isToggleButton ? `[ ${item.label} ]` : item.label} + + + ) + })} + + + + {isAllSelected + ? 'All tools selected' + : `${selectedSet.size} of ${customAgentTools.length} tools selected`} + + + + ) } diff --git a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx index bad4005a4..b9959d91d 100644 --- a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx +++ b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx @@ -1,96 +1,68 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { isAutoMemoryEnabled } from '../../../memdir/paths.js'; -import type { Tools } from '../../../Tool.js'; -import type { AgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js'; -import { WizardProvider } from '../../wizard/index.js'; -import type { WizardStepComponent } from '../../wizard/types.js'; -import type { AgentWizardData } from './types.js'; -import { ColorStep } from './wizard-steps/ColorStep.js'; -import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js'; -import { DescriptionStep } from './wizard-steps/DescriptionStep.js'; -import { GenerateStep } from './wizard-steps/GenerateStep.js'; -import { LocationStep } from './wizard-steps/LocationStep.js'; -import { MemoryStep } from './wizard-steps/MemoryStep.js'; -import { MethodStep } from './wizard-steps/MethodStep.js'; -import { ModelStep } from './wizard-steps/ModelStep.js'; -import { PromptStep } from './wizard-steps/PromptStep.js'; -import { ToolsStep } from './wizard-steps/ToolsStep.js'; -import { TypeStep } from './wizard-steps/TypeStep.js'; +import React, { type ReactNode } from 'react' +import { isAutoMemoryEnabled } from '../../../memdir/paths.js' +import type { Tools } from '../../../Tool.js' +import type { AgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js' +import { WizardProvider } from '../../wizard/index.js' +import type { WizardStepComponent } from '../../wizard/types.js' +import type { AgentWizardData } from './types.js' +import { ColorStep } from './wizard-steps/ColorStep.js' +import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js' +import { DescriptionStep } from './wizard-steps/DescriptionStep.js' +import { GenerateStep } from './wizard-steps/GenerateStep.js' +import { LocationStep } from './wizard-steps/LocationStep.js' +import { MemoryStep } from './wizard-steps/MemoryStep.js' +import { MethodStep } from './wizard-steps/MethodStep.js' +import { ModelStep } from './wizard-steps/ModelStep.js' +import { PromptStep } from './wizard-steps/PromptStep.js' +import { ToolsStep } from './wizard-steps/ToolsStep.js' +import { TypeStep } from './wizard-steps/TypeStep.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onComplete: (message: string) => void; - onCancel: () => void; -}; -export function CreateAgentWizard(t0) { - const $ = _c(17); - const { - tools, - existingAgents, - onComplete, - onCancel - } = t0; - let t1; - if ($[0] !== existingAgents) { - t1 = () => ; - $[0] = existingAgents; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== tools) { - t2 = () => ; - $[2] = tools; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = isAutoMemoryEnabled() ? [MemoryStep] : []; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== existingAgents || $[6] !== onComplete || $[7] !== tools) { - t4 = () => ; - $[5] = existingAgents; - $[6] = onComplete; - $[7] = tools; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t1 || $[10] !== t2 || $[11] !== t4) { - t5 = [LocationStep, MethodStep, GenerateStep, t1, PromptStep, DescriptionStep, t2, ModelStep, ColorStep, ...t3, t4]; - $[9] = t1; - $[10] = t2; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - const steps = t5; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {}; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== onCancel || $[15] !== steps) { - t7 = ; - $[14] = onCancel; - $[15] = steps; - $[16] = t7; - } else { - t7 = $[16]; - } - return t7; + tools: Tools + existingAgents: AgentDefinition[] + onComplete: (message: string) => void + onCancel: () => void +} + +export function CreateAgentWizard({ + tools, + existingAgents, + onComplete, + onCancel, +}: Props): ReactNode { + // Create step components with props + const steps: WizardStepComponent[] = [ + LocationStep, // 0 + MethodStep, // 1 + GenerateStep, // 2 + () => , // 3 + PromptStep, // 4 + DescriptionStep, // 5 + () => , // 6 + ModelStep, // 7 + ColorStep, // 8 + // MemoryStep is conditionally included based on GrowthBook gate + ...(isAutoMemoryEnabled() ? [MemoryStep] : []), + () => ( + + ), + ] + + return ( + + steps={steps} + initialData={{}} + onComplete={() => { + // Wizard completion is handled by ConfirmStepWrapper + // which calls onComplete with the appropriate message + }} + onCancel={onCancel} + title="Create new agent" + showStepCounter={false} + /> + ) } -function _temp() {} diff --git a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx index 9ec059371..adc35e27c 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx @@ -1,83 +1,64 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import type { AgentColorName } from '../../../../tools/AgentTool/agentColorManager.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ColorPicker } from '../../ColorPicker.js'; -import type { AgentWizardData } from '../types.js'; -export function ColorStep() { - const $ = _c(14); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Confirmation" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import type { AgentColorName } from '../../../../tools/AgentTool/agentColorManager.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ColorPicker } from '../../ColorPicker.js' +import type { AgentWizardData } from '../types.js' + +export function ColorStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + // Handle escape key - ColorPicker handles its own escape internally + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + + const handleConfirm = (color?: string): void => { + updateWizardData({ + selectedColor: color, + // Prepare final agent for confirmation + finalAgent: { + agentType: wizardData.agentType!, + whenToUse: wizardData.whenToUse!, + getSystemPrompt: () => wizardData.systemPrompt!, + tools: wizardData.selectedTools, + ...(wizardData.selectedModel + ? { model: wizardData.selectedModel } + : {}), + ...(color ? { color: color as AgentColorName } : {}), + source: wizardData.location!, + }, + }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== goNext || $[2] !== updateWizardData || $[3] !== wizardData.agentType || $[4] !== wizardData.location || $[5] !== wizardData.selectedModel || $[6] !== wizardData.selectedTools || $[7] !== wizardData.systemPrompt || $[8] !== wizardData.whenToUse) { - t1 = color => { - updateWizardData({ - selectedColor: color, - finalAgent: { - agentType: wizardData.agentType, - whenToUse: wizardData.whenToUse, - getSystemPrompt: () => wizardData.systemPrompt, - tools: wizardData.selectedTools, - ...(wizardData.selectedModel ? { - model: wizardData.selectedModel - } : {}), - ...(color ? { - color: color as AgentColorName - } : {}), - source: wizardData.location - } - }); - goNext(); - }; - $[1] = goNext; - $[2] = updateWizardData; - $[3] = wizardData.agentType; - $[4] = wizardData.location; - $[5] = wizardData.selectedModel; - $[6] = wizardData.selectedTools; - $[7] = wizardData.systemPrompt; - $[8] = wizardData.whenToUse; - $[9] = t1; - } else { - t1 = $[9]; - } - const handleConfirm = t1; - let t2; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[10] = t2; - } else { - t2 = $[10]; - } - const t3 = wizardData.agentType || "agent"; - let t4; - if ($[11] !== handleConfirm || $[12] !== t3) { - t4 = ; - $[11] = handleConfirm; - $[12] = t3; - $[13] = t4; - } else { - t4 = $[13]; - } - return t4; + + return ( + + + + + + } + > + + + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx index b696d861b..bfa035eb5 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx @@ -1,377 +1,168 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import type { KeyboardEvent } from '../../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; -import type { Tools } from '../../../../Tool.js'; -import { getMemoryScopeDisplay } from '../../../../tools/AgentTool/agentMemory.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { truncateToWidth } from '../../../../utils/format.js'; -import { getAgentModelDisplay } from '../../../../utils/model/agent.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js'; -import { validateAgent } from '../../validateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import type { KeyboardEvent } from '../../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' +import type { Tools } from '../../../../Tool.js' +import { getMemoryScopeDisplay } from '../../../../tools/AgentTool/agentMemory.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { truncateToWidth } from '../../../../utils/format.js' +import { getAgentModelDisplay } from '../../../../utils/model/agent.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js' +import { validateAgent } from '../../validateAgent.js' +import type { AgentWizardData } from '../types.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onSave: () => void; - onSaveAndEdit: () => void; - error?: string | null; -}; -export function ConfirmStep(t0) { - const $ = _c(88); - const { - tools, - existingAgents, - onSave, - onSaveAndEdit, - error - } = t0; - const { - goBack, - wizardData - } = useWizard(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; + tools: Tools + existingAgents: AgentDefinition[] + onSave: () => void + onSaveAndEdit: () => void + error?: string | null +} + +export function ConfirmStep({ + tools, + existingAgents, + onSave, + onSaveAndEdit, + error, +}: Props): ReactNode { + const { goBack, wizardData } = useWizard() + + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 's' || e.key === 'return') { + e.preventDefault() + onSave() + } else if (e.key === 'e') { + e.preventDefault() + onSaveAndEdit() + } } - useKeybinding("confirm:no", goBack, t1); - let t2; - if ($[1] !== onSave || $[2] !== onSaveAndEdit) { - t2 = e => { - if (e.key === "s" || e.key === "return") { - e.preventDefault(); - onSave(); - } else { - if (e.key === "e") { - e.preventDefault(); - onSaveAndEdit(); - } + + const agent = wizardData.finalAgent! + const validation = validateAgent(agent, tools, existingAgents) + + const systemPromptPreview = truncateToWidth(agent.getSystemPrompt(), 240) + const whenToUsePreview = truncateToWidth(agent.whenToUse, 240) + + const getToolsDisplay = (toolNames: string[] | undefined): string => { + // undefined means "all tools" per PR semantic + if (toolNames === undefined) return 'All tools' + if (toolNames.length === 0) return 'None' + if (toolNames.length === 1) return toolNames[0] || 'None' + if (toolNames.length === 2) return toolNames.join(' and ') + return `${toolNames.slice(0, -1).join(', ')}, and ${toolNames[toolNames.length - 1]}` + } + + // Compute memory display outside JSX + const memoryDisplayElement = isAutoMemoryEnabled() ? ( + + Memory: {getMemoryScopeDisplay(agent.memory)} + + ) : null + + return ( + + + + + } - }; - $[1] = onSave; - $[2] = onSaveAndEdit; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleKeyDown = t2; - const agent = wizardData.finalAgent; - let T0; - let T1; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - let t18; - let t19; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[4] !== agent || $[5] !== existingAgents || $[6] !== handleKeyDown || $[7] !== tools || $[8] !== wizardData.location) { - const validation = validateAgent(agent, tools, existingAgents); - let t20; - if ($[28] !== agent) { - t20 = truncateToWidth(agent.getSystemPrompt(), 240); - $[28] = agent; - $[29] = t20; - } else { - t20 = $[29]; - } - const systemPromptPreview = t20; - let t21; - if ($[30] !== agent.whenToUse) { - t21 = truncateToWidth(agent.whenToUse, 240); - $[30] = agent.whenToUse; - $[31] = t21; - } else { - t21 = $[31]; - } - const whenToUsePreview = t21; - const getToolsDisplay = _temp; - let t22; - if ($[32] !== agent.memory) { - t22 = isAutoMemoryEnabled() ? Memory: {getMemoryScopeDisplay(agent.memory)} : null; - $[32] = agent.memory; - $[33] = t22; - } else { - t22 = $[33]; - } - const memoryDisplayElement = t22; - T1 = WizardDialogLayout; - t18 = "Confirm and save"; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[34] = t19; - } else { - t19 = $[34]; - } - T0 = Box; - t3 = "column"; - t4 = 0; - t5 = true; - t6 = handleKeyDown; - let t23; - if ($[35] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Name; - $[35] = t23; - } else { - t23 = $[35]; - } - if ($[36] !== agent.agentType) { - t7 = {t23}: {agent.agentType}; - $[36] = agent.agentType; - $[37] = t7; - } else { - t7 = $[37]; - } - let t24; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t24 = Location; - $[38] = t24; - } else { - t24 = $[38]; - } - let t25; - if ($[39] !== agent.agentType || $[40] !== wizardData.location) { - t25 = getNewRelativeAgentFilePath({ - source: wizardData.location, - agentType: agent.agentType - }); - $[39] = agent.agentType; - $[40] = wizardData.location; - $[41] = t25; - } else { - t25 = $[41]; - } - if ($[42] !== t25) { - t8 = {t24}:{" "}{t25}; - $[42] = t25; - $[43] = t8; - } else { - t8 = $[43]; - } - let t26; - if ($[44] === Symbol.for("react.memo_cache_sentinel")) { - t26 = Tools; - $[44] = t26; - } else { - t26 = $[44]; - } - let t27; - if ($[45] !== agent.tools) { - t27 = getToolsDisplay(agent.tools); - $[45] = agent.tools; - $[46] = t27; - } else { - t27 = $[46]; - } - if ($[47] !== t27) { - t9 = {t26}: {t27}; - $[47] = t27; - $[48] = t9; - } else { - t9 = $[48]; - } - let t28; - if ($[49] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Model; - $[49] = t28; - } else { - t28 = $[49]; - } - let t29; - if ($[50] !== agent.model) { - t29 = getAgentModelDisplay(agent.model); - $[50] = agent.model; - $[51] = t29; - } else { - t29 = $[51]; - } - if ($[52] !== t29) { - t10 = {t28}: {t29}; - $[52] = t29; - $[53] = t10; - } else { - t10 = $[53]; - } - t11 = memoryDisplayElement; - if ($[54] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Description (tells Claude when to use this agent):; - $[54] = t12; - } else { - t12 = $[54]; - } - if ($[55] !== whenToUsePreview) { - t13 = {whenToUsePreview}; - $[55] = whenToUsePreview; - $[56] = t13; - } else { - t13 = $[56]; - } - if ($[57] === Symbol.for("react.memo_cache_sentinel")) { - t14 = System prompt:; - $[57] = t14; - } else { - t14 = $[57]; - } - if ($[58] !== systemPromptPreview) { - t15 = {systemPromptPreview}; - $[58] = systemPromptPreview; - $[59] = t15; - } else { - t15 = $[59]; - } - t16 = validation.warnings.length > 0 && Warnings:{validation.warnings.map(_temp2)}; - t17 = validation.errors.length > 0 && Errors:{validation.errors.map(_temp3)}; - $[4] = agent; - $[5] = existingAgents; - $[6] = handleKeyDown; - $[7] = tools; - $[8] = wizardData.location; - $[9] = T0; - $[10] = T1; - $[11] = t10; - $[12] = t11; - $[13] = t12; - $[14] = t13; - $[15] = t14; - $[16] = t15; - $[17] = t16; - $[18] = t17; - $[19] = t18; - $[20] = t19; - $[21] = t3; - $[22] = t4; - $[23] = t5; - $[24] = t6; - $[25] = t7; - $[26] = t8; - $[27] = t9; - } else { - T0 = $[9]; - T1 = $[10]; - t10 = $[11]; - t11 = $[12]; - t12 = $[13]; - t13 = $[14]; - t14 = $[15]; - t15 = $[16]; - t16 = $[17]; - t17 = $[18]; - t18 = $[19]; - t19 = $[20]; - t3 = $[21]; - t4 = $[22]; - t5 = $[23]; - t6 = $[24]; - t7 = $[25]; - t8 = $[26]; - t9 = $[27]; - } - let t20; - if ($[60] !== error) { - t20 = error && {error}; - $[60] = error; - $[61] = t20; - } else { - t20 = $[61]; - } - let t21; - if ($[62] === Symbol.for("react.memo_cache_sentinel")) { - t21 = s; - $[62] = t21; - } else { - t21 = $[62]; - } - let t22; - if ($[63] === Symbol.for("react.memo_cache_sentinel")) { - t22 = Enter; - $[63] = t22; - } else { - t22 = $[63]; - } - let t23; - if ($[64] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Press {t21} or {t22} to save,{" "}e to save and edit; - $[64] = t23; - } else { - t23 = $[64]; - } - let t24; - if ($[65] !== T0 || $[66] !== t10 || $[67] !== t11 || $[68] !== t12 || $[69] !== t13 || $[70] !== t14 || $[71] !== t15 || $[72] !== t16 || $[73] !== t17 || $[74] !== t20 || $[75] !== t3 || $[76] !== t4 || $[77] !== t5 || $[78] !== t6 || $[79] !== t7 || $[80] !== t8 || $[81] !== t9) { - t24 = {t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t20}{t23}; - $[65] = T0; - $[66] = t10; - $[67] = t11; - $[68] = t12; - $[69] = t13; - $[70] = t14; - $[71] = t15; - $[72] = t16; - $[73] = t17; - $[74] = t20; - $[75] = t3; - $[76] = t4; - $[77] = t5; - $[78] = t6; - $[79] = t7; - $[80] = t8; - $[81] = t9; - $[82] = t24; - } else { - t24 = $[82]; - } - let t25; - if ($[83] !== T1 || $[84] !== t18 || $[85] !== t19 || $[86] !== t24) { - t25 = {t24}; - $[83] = T1; - $[84] = t18; - $[85] = t19; - $[86] = t24; - $[87] = t25; - } else { - t25 = $[87]; - } - return t25; -} -function _temp3(err, i_0) { - return {" "}• {err}; -} -function _temp2(warning, i) { - return {" "}• {warning}; -} -function _temp(toolNames) { - if (toolNames === undefined) { - return "All tools"; - } - if (toolNames.length === 0) { - return "None"; - } - if (toolNames.length === 1) { - return toolNames[0] || "None"; - } - if (toolNames.length === 2) { - return toolNames.join(" and "); - } - return `${toolNames.slice(0, -1).join(", ")}, and ${toolNames[toolNames.length - 1]}`; + > + + + Name: {agent.agentType} + + + Location:{' '} + {getNewRelativeAgentFilePath({ + source: wizardData.location!, + agentType: agent.agentType, + })} + + + Tools: {getToolsDisplay(agent.tools)} + + + Model: {getAgentModelDisplay(agent.model)} + + {memoryDisplayElement} + + + + Description (tells Claude when to use this agent): + + + + {whenToUsePreview} + + + + + System prompt: + + + + {systemPromptPreview} + + + {validation.warnings.length > 0 && ( + + Warnings: + {validation.warnings.map((warning, i) => ( + + {' '} + • {warning} + + ))} + + )} + + {validation.errors.length > 0 && ( + + Errors: + {validation.errors.map((err, i) => ( + + {' '} + • {err} + + ))} + + )} + + {error && ( + + {error} + + )} + + + + Press s or Enter to save,{' '} + e to save and edit + + + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx index 0def7267b..013de633a 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx @@ -1,73 +1,112 @@ -import chalk from 'chalk'; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useSetAppState } from 'src/state/AppState.js'; -import type { Tools } from '../../../../Tool.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { getActiveAgentsFromList } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { editFileInEditor } from '../../../../utils/promptEditor.js'; -import { useWizard } from '../../../wizard/index.js'; -import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js'; -import type { AgentWizardData } from '../types.js'; -import { ConfirmStep } from './ConfirmStep.js'; +import chalk from 'chalk' +import React, { type ReactNode, useCallback, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { useSetAppState } from 'src/state/AppState.js' +import type { Tools } from '../../../../Tool.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { getActiveAgentsFromList } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { editFileInEditor } from '../../../../utils/promptEditor.js' +import { useWizard } from '../../../wizard/index.js' +import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js' +import type { AgentWizardData } from '../types.js' +import { ConfirmStep } from './ConfirmStep.js' + type Props = { - tools: Tools; - existingAgents: AgentDefinition[]; - onComplete: (message: string) => void; -}; + tools: Tools + existingAgents: AgentDefinition[] + onComplete: (message: string) => void +} + export function ConfirmStepWrapper({ tools, existingAgents, - onComplete + onComplete, }: Props): ReactNode { - const { - wizardData - } = useWizard(); - const [saveError, setSaveError] = useState(null); - const setAppState = useSetAppState(); - const saveAgent = useCallback(async (openInEditor: boolean): Promise => { - if (!wizardData?.finalAgent) return; - try { - await saveAgentToFile(wizardData.location!, wizardData.finalAgent.agentType, wizardData.finalAgent.whenToUse, wizardData.finalAgent.tools, wizardData.finalAgent.getSystemPrompt(), true, wizardData.finalAgent.color, wizardData.finalAgent.model, wizardData.finalAgent.memory); - setAppState(state => { - if (!wizardData.finalAgent) return state; - const allAgents = state.agentDefinitions.allAgents.concat(wizardData.finalAgent); - return { - ...state, - agentDefinitions: { - ...state.agentDefinitions, - activeAgents: getActiveAgentsFromList(allAgents), - allAgents + const { wizardData } = useWizard() + const [saveError, setSaveError] = useState(null) + const setAppState = useSetAppState() + + const saveAgent = useCallback( + async (openInEditor: boolean): Promise => { + if (!wizardData?.finalAgent) return + + try { + await saveAgentToFile( + wizardData.location!, + wizardData.finalAgent.agentType, + wizardData.finalAgent.whenToUse, + wizardData.finalAgent.tools, + wizardData.finalAgent.getSystemPrompt(), + true, + wizardData.finalAgent.color, + wizardData.finalAgent.model, + wizardData.finalAgent.memory, + ) + + setAppState(state => { + if (!wizardData.finalAgent) return state + + const allAgents = state.agentDefinitions.allAgents.concat( + wizardData.finalAgent, + ) + return { + ...state, + agentDefinitions: { + ...state.agentDefinitions, + activeAgents: getActiveAgentsFromList(allAgents), + allAgents, + }, } - }; - }); - if (openInEditor) { - const filePath = getNewAgentFilePath({ + }) + + if (openInEditor) { + const filePath = getNewAgentFilePath({ + source: wizardData.location!, + agentType: wizardData.finalAgent.agentType, + }) + await editFileInEditor(filePath) + } + + logEvent('tengu_agent_created', { + agent_type: wizardData.finalAgent.agentType, + generation_method: wizardData.wasGenerated ? 'generated' : 'manual', source: wizardData.location!, - agentType: wizardData.finalAgent.agentType - }); - await editFileInEditor(filePath); + tool_count: wizardData.finalAgent.tools?.length ?? 'all', + has_custom_model: !!wizardData.finalAgent.model, + has_custom_color: !!wizardData.finalAgent.color, + has_memory: !!wizardData.finalAgent.memory, + memory_scope: wizardData.finalAgent.memory ?? 'none', + ...(openInEditor ? { opened_in_editor: true } : {}), + } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + + const message = openInEditor + ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + + `If you made edits, restart to load the latest version.` + : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}` + onComplete(message) + } catch (err) { + setSaveError( + err instanceof Error ? err.message : 'Failed to save agent', + ) } - logEvent('tengu_agent_created', { - agent_type: wizardData.finalAgent.agentType, - generation_method: wizardData.wasGenerated ? 'generated' : 'manual', - source: wizardData.location!, - tool_count: wizardData.finalAgent.tools?.length ?? 'all', - has_custom_model: !!wizardData.finalAgent.model, - has_custom_color: !!wizardData.finalAgent.color, - has_memory: !!wizardData.finalAgent.memory, - memory_scope: wizardData.finalAgent.memory ?? 'none', - ...(openInEditor ? { - opened_in_editor: true - } : {}) - } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS); - const message = openInEditor ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + `If you made edits, restart to load the latest version.` : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}`; - onComplete(message); - } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save agent'); - } - }, [wizardData, onComplete, setAppState]); - const handleSave = useCallback(() => saveAgent(false), [saveAgent]); - const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]); - return ; + }, + [wizardData, onComplete, setAppState], + ) + + const handleSave = useCallback(() => saveAgent(false), [saveAgent]) + + const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]) + + return ( + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx index 504ff0fd1..1138cc3d3 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx @@ -1,122 +1,94 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function DescriptionStep() { - const $ = _c(18); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || ""); - const [cursorOffset, setCursorOffset] = useState(whenToUse.length); - const [error, setError] = useState(null); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode, useCallback, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function DescriptionStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || '') + const [cursorOffset, setCursorOffset] = useState(whenToUse.length) + const [error, setError] = useState(null) + + // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleExternalEditor = useCallback(async () => { + const result = await editPromptInEditor(whenToUse) + if (result.content !== null) { + setWhenToUse(result.content) + setCursorOffset(result.content.length) + } + }, [whenToUse]) + + useKeybinding('chat:externalEditor', handleExternalEditor, { + context: 'Chat', + }) + + const handleSubmit = (value: string): void => { + const trimmedValue = value.trim() + if (!trimmedValue) { + setError('Description is required') + return + } + + setError(null) + updateWizardData({ whenToUse: trimmedValue }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== whenToUse) { - t1 = async () => { - const result = await editPromptInEditor(whenToUse); - if (result.content !== null) { - setWhenToUse(result.content); - setCursorOffset(result.content.length); + + return ( + + + + + + } - }; - $[1] = whenToUse; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleExternalEditor = t1; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Chat" - }; - $[3] = t2; - } else { - t2 = $[3]; - } - useKeybinding("chat:externalEditor", handleExternalEditor, t2); - let t3; - if ($[4] !== goNext || $[5] !== updateWizardData) { - t3 = value => { - const trimmedValue = value.trim(); - if (!trimmedValue) { - setError("Description is required"); - return; - } - setError(null); - updateWizardData({ - whenToUse: trimmedValue - }); - goNext(); - }; - $[4] = goNext; - $[5] = updateWizardData; - $[6] = t3; - } else { - t3 = $[6]; - } - const handleSubmit = t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = When should Claude use this agent?; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== cursorOffset || $[10] !== handleSubmit || $[11] !== whenToUse) { - t6 = ; - $[9] = cursorOffset; - $[10] = handleSubmit; - $[11] = whenToUse; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== error) { - t7 = error && {error}; - $[13] = error; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== t6 || $[16] !== t7) { - t8 = {t5}{t6}{t7}; - $[15] = t6; - $[16] = t7; - $[17] = t8; - } else { - t8 = $[17]; - } - return t8; + > + + When should Claude use this agent? + + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx index 892833bc3..1cb7ae69d 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx @@ -1,58 +1,57 @@ -import { APIUserAbortError } from '@anthropic-ai/sdk'; -import React, { type ReactNode, useCallback, useRef, useState } from 'react'; -import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { createAbortController } from '../../../../utils/abortController.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { Spinner } from '../../../Spinner.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { generateAgent } from '../../generateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import { APIUserAbortError } from '@anthropic-ai/sdk' +import React, { type ReactNode, useCallback, useRef, useState } from 'react' +import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { createAbortController } from '../../../../utils/abortController.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { Spinner } from '../../../Spinner.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { generateAgent } from '../../generateAgent.js' +import type { AgentWizardData } from '../types.js' + export function GenerateStep(): ReactNode { - const { - updateWizardData, - goBack, - goToStep, - wizardData - } = useWizard(); - const [prompt, setPrompt] = useState(wizardData.generationPrompt || ''); - const [isGenerating, setIsGenerating] = useState(false); - const [error, setError] = useState(null); - const [cursorOffset, setCursorOffset] = useState(prompt.length); - const model = useMainLoopModel(); - const abortControllerRef = useRef(null); + const { updateWizardData, goBack, goToStep, wizardData } = + useWizard() + const [prompt, setPrompt] = useState(wizardData.generationPrompt || '') + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState(null) + const [cursorOffset, setCursorOffset] = useState(prompt.length) + const model = useMainLoopModel() + const abortControllerRef = useRef(null) // Cancel generation when escape pressed during generation const handleCancelGeneration = useCallback(() => { if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - setIsGenerating(false); - setError('Generation cancelled'); + abortControllerRef.current.abort() + abortControllerRef.current = null + setIsGenerating(false) + setError('Generation cancelled') } - }, []); + }, []) // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleCancelGeneration, { context: 'Settings', - isActive: isGenerating - }); + isActive: isGenerating, + }) + const handleExternalEditor = useCallback(async () => { - const result = await editPromptInEditor(prompt); + const result = await editPromptInEditor(prompt) if (result.content !== null) { - setPrompt(result.content); - setCursorOffset(result.content.length); + setPrompt(result.content) + setCursorOffset(result.content.length) } - }, [prompt]); + }, [prompt]) + useKeybinding('chat:externalEditor', handleExternalEditor, { context: 'Chat', - isActive: !isGenerating - }); + isActive: !isGenerating, + }) // Go back when escape pressed while not generating const handleGoBack = useCallback(() => { @@ -62,81 +61,141 @@ export function GenerateStep(): ReactNode { systemPrompt: '', whenToUse: '', generatedAgent: undefined, - wasGenerated: false - }); - setPrompt(''); - setError(null); - goBack(); - }, [updateWizardData, goBack]); + wasGenerated: false, + }) + setPrompt('') + setError(null) + goBack() + }, [updateWizardData, goBack]) // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleGoBack, { context: 'Settings', - isActive: !isGenerating - }); + isActive: !isGenerating, + }) + const handleGenerate = async (): Promise => { - const trimmedPrompt = prompt.trim(); + const trimmedPrompt = prompt.trim() if (!trimmedPrompt) { - setError('Please describe what the agent should do'); - return; + setError('Please describe what the agent should do') + return } - setError(null); - setIsGenerating(true); + + setError(null) + setIsGenerating(true) updateWizardData({ generationPrompt: trimmedPrompt, - isGenerating: true - }); + isGenerating: true, + }) // Create abort controller for this generation - const controller = createAbortController(); - abortControllerRef.current = controller; + const controller = createAbortController() + abortControllerRef.current = controller + try { - const generated = await generateAgent(trimmedPrompt, model, [], controller.signal); + const generated = await generateAgent( + trimmedPrompt, + model, + [], + controller.signal, + ) + updateWizardData({ agentType: generated.identifier, whenToUse: generated.whenToUse, systemPrompt: generated.systemPrompt, generatedAgent: generated, isGenerating: false, - wasGenerated: true - }); + wasGenerated: true, + }) // Skip directly to ToolsStep (index 6) - matching original flow - goToStep(6); + goToStep(6) } catch (err) { // Don't show error if it was cancelled (already set in escape handler) if (err instanceof APIUserAbortError) { // User cancelled - no error to show - } else if (err instanceof Error && !err.message.includes('No assistant message found')) { - setError(err.message || 'Failed to generate agent'); + } else if ( + err instanceof Error && + !err.message.includes('No assistant message found') + ) { + setError(err.message || 'Failed to generate agent') } - updateWizardData({ - isGenerating: false - }); + updateWizardData({ isGenerating: false }) } finally { - setIsGenerating(false); - abortControllerRef.current = null; + setIsGenerating(false) + abortControllerRef.current = null } - }; - const subtitle = 'Describe what this agent should do and when it should be used (be comprehensive for best results)'; + } + + const subtitle = + 'Describe what this agent should do and when it should be used (be comprehensive for best results)' + if (isGenerating) { - return }> + return ( + + } + > Generating agent from description... - ; + + ) } - return - - - - }> + + return ( + + + + + + } + > - {error && + {error && ( + {error} - } - + + )} + - ; + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx index cf0a544d5..a7fd0a2bc 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx @@ -1,79 +1,55 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import type { SettingSource } from '../../../../utils/settings/constants.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function LocationStep() { - const $ = _c(11); - const { - goNext, - updateWizardData, - cancel - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - label: "Project (.claude/agents/)", - value: "projectSettings" as SettingSource - }; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = [t0, { - label: "Personal (~/.claude/agents/)", - value: "userSettings" as SettingSource - }]; - $[1] = t1; - } else { - t1 = $[1]; - } - const locationOptions = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== goNext || $[4] !== updateWizardData) { - t3 = value => { - updateWizardData({ - location: value as SettingSource - }); - goNext(); - }; - $[3] = goNext; - $[4] = updateWizardData; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== cancel) { - t4 = () => cancel(); - $[6] = cancel; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = { + updateWizardData({ location: value as SettingSource }) + goNext() + }} + onCancel={() => cancel()} + /> + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx index fc5cad0f3..3c987cf77 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx @@ -1,112 +1,102 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; -import { type AgentMemoryScope, loadAgentMemoryPrompt } from '../../../../tools/AgentTool/agentMemory.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' +import { + type AgentMemoryScope, + loadAgentMemoryPrompt, +} from '../../../../tools/AgentTool/agentMemory.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Select } from '../../../CustomSelect/select.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + type MemoryOption = { - label: string; - value: AgentMemoryScope | 'none'; -}; -export function MemoryStep() { - const $ = _c(13); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Confirmation" - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useKeybinding("confirm:no", goBack, t0); - const isUserScope = wizardData.location === "userSettings"; - let t1; - if ($[1] !== isUserScope) { - t1 = isUserScope ? [{ - label: "User scope (~/.claude/agent-memory/) (Recommended)", - value: "user" - }, { - label: "None (no persistent memory)", - value: "none" - }, { - label: "Project scope (.claude/agent-memory/)", - value: "project" - }, { - label: "Local scope (.claude/agent-memory-local/)", - value: "local" - }] : [{ - label: "Project scope (.claude/agent-memory/) (Recommended)", - value: "project" - }, { - label: "None (no persistent memory)", - value: "none" - }, { - label: "User scope (~/.claude/agent-memory/)", - value: "user" - }, { - label: "Local scope (.claude/agent-memory-local/)", - value: "local" - }]; - $[1] = isUserScope; - $[2] = t1; - } else { - t1 = $[2]; - } - const memoryOptions = t1; - let t2; - if ($[3] !== goNext || $[4] !== updateWizardData || $[5] !== wizardData.finalAgent || $[6] !== wizardData.systemPrompt) { - t2 = value => { - const memory = value === "none" ? undefined : value as AgentMemoryScope; - const agentType = wizardData.finalAgent?.agentType; - updateWizardData({ - selectedMemory: memory, - finalAgent: wizardData.finalAgent ? { - ...wizardData.finalAgent, - memory, - getSystemPrompt: isAutoMemoryEnabled() && memory && agentType ? () => wizardData.systemPrompt + "\n\n" + loadAgentMemoryPrompt(agentType, memory) : () => wizardData.systemPrompt - } : undefined - }); - goNext(); - }; - $[3] = goNext; - $[4] = updateWizardData; - $[5] = wizardData.finalAgent; - $[6] = wizardData.systemPrompt; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleSelect = t2; - let t3; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[8] = t3; - } else { - t3 = $[8]; - } - let t4; - if ($[9] !== goBack || $[10] !== handleSelect || $[11] !== memoryOptions) { - t4 = + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx index 5e9f40418..8f8252e12 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx @@ -1,79 +1,65 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { Box } from '../../../../ink.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Select } from '../../../CustomSelect/select.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function MethodStep() { - const $ = _c(11); - const { - goNext, - goBack, - updateWizardData, - goToStep - } = useWizard(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = [{ - label: "Generate with Claude (recommended)", - value: "generate" - }, { - label: "Manual configuration", - value: "manual" - }]; - $[0] = t0; - } else { - t0 = $[0]; - } - const methodOptions = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== goNext || $[3] !== goToStep || $[4] !== updateWizardData) { - t2 = value => { - const method = value as 'generate' | 'manual'; - updateWizardData({ - method, - wasGenerated: method === "generate" - }); - if (method === "generate") { - goNext(); - } else { - goToStep(3); +import React, { type ReactNode } from 'react' +import { Box } from '../../../../ink.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Select } from '../../../CustomSelect/select.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function MethodStep(): ReactNode { + const { goNext, goBack, updateWizardData, goToStep } = + useWizard() + + const methodOptions = [ + { + label: 'Generate with Claude (recommended)', + value: 'generate', + }, + { + label: 'Manual configuration', + value: 'manual', + }, + ] + + return ( + + + + + } - }; - $[2] = goNext; - $[3] = goToStep; - $[4] = updateWizardData; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== goBack) { - t3 = () => goBack(); - $[6] = goBack; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== t2 || $[9] !== t3) { - t4 = { + const method = value as 'generate' | 'manual' + updateWizardData({ + method, + wasGenerated: method === 'generate', + }) + + // Dynamic navigation based on method + if (method === 'generate') { + goNext() // Go to GenerateStep (index 2) + } else { + goToStep(3) // Skip to TypeStep (index 3) + } + }} + onCancel={() => goBack()} + /> + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx index b53ffd683..586cc6cc8 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx @@ -1,51 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ModelSelector } from '../../ModelSelector.js'; -import type { AgentWizardData } from '../types.js'; -export function ModelStep() { - const $ = _c(8); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t0; - if ($[0] !== goNext || $[1] !== updateWizardData) { - t0 = model => { - updateWizardData({ - selectedModel: model - }); - goNext(); - }; - $[0] = goNext; - $[1] = updateWizardData; - $[2] = t0; - } else { - t0 = $[2]; +import React, { type ReactNode } from 'react' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ModelSelector } from '../../ModelSelector.js' +import type { AgentWizardData } from '../types.js' + +export function ModelStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + const handleComplete = (model?: string): void => { + updateWizardData({ selectedModel: model }) + goNext() } - const handleComplete = t0; - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== wizardData.selectedModel) { - t2 = ; - $[4] = goBack; - $[5] = handleComplete; - $[6] = wizardData.selectedModel; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; + + return ( + + + + + + } + > + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx index 1b8224c28..4d6747520 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx @@ -1,127 +1,97 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import { editPromptInEditor } from '../../../../utils/promptEditor.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import type { AgentWizardData } from '../types.js'; -export function PromptStep() { - const $ = _c(20); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [systemPrompt, setSystemPrompt] = useState(wizardData.systemPrompt || ""); - const [cursorOffset, setCursorOffset] = useState(systemPrompt.length); - const [error, setError] = useState(null); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; +import React, { type ReactNode, useCallback, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import { editPromptInEditor } from '../../../../utils/promptEditor.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import type { AgentWizardData } from '../types.js' + +export function PromptStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [systemPrompt, setSystemPrompt] = useState( + wizardData.systemPrompt || '', + ) + const [cursorOffset, setCursorOffset] = useState(systemPrompt.length) + const [error, setError] = useState(null) + + // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleExternalEditor = useCallback(async () => { + const result = await editPromptInEditor(systemPrompt) + if (result.content !== null) { + setSystemPrompt(result.content) + setCursorOffset(result.content.length) + } + }, [systemPrompt]) + + useKeybinding('chat:externalEditor', handleExternalEditor, { + context: 'Chat', + }) + + const handleSubmit = (): void => { + const trimmedPrompt = systemPrompt.trim() + if (!trimmedPrompt) { + setError('System prompt is required') + return + } + + setError(null) + updateWizardData({ systemPrompt: trimmedPrompt }) + goNext() } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== systemPrompt) { - t1 = async () => { - const result = await editPromptInEditor(systemPrompt); - if (result.content !== null) { - setSystemPrompt(result.content); - setCursorOffset(result.content.length); + + return ( + + + + + + } - }; - $[1] = systemPrompt; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleExternalEditor = t1; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Chat" - }; - $[3] = t2; - } else { - t2 = $[3]; - } - useKeybinding("chat:externalEditor", handleExternalEditor, t2); - let t3; - if ($[4] !== goNext || $[5] !== systemPrompt || $[6] !== updateWizardData) { - t3 = () => { - const trimmedPrompt = systemPrompt.trim(); - if (!trimmedPrompt) { - setError("System prompt is required"); - return; - } - setError(null); - updateWizardData({ - systemPrompt: trimmedPrompt - }); - goNext(); - }; - $[4] = goNext; - $[5] = systemPrompt; - $[6] = updateWizardData; - $[7] = t3; - } else { - t3 = $[7]; - } - const handleSubmit = t3; - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Enter the system prompt for your agent:; - t6 = Be comprehensive for best results; - $[9] = t5; - $[10] = t6; - } else { - t5 = $[9]; - t6 = $[10]; - } - let t7; - if ($[11] !== cursorOffset || $[12] !== handleSubmit || $[13] !== systemPrompt) { - t7 = ; - $[11] = cursorOffset; - $[12] = handleSubmit; - $[13] = systemPrompt; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== error) { - t8 = error && {error}; - $[15] = error; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== t7 || $[18] !== t8) { - t9 = {t5}{t6}{t7}{t8}; - $[17] = t7; - $[18] = t8; - $[19] = t9; - } else { - t9 = $[19]; - } - return t9; + > + + Enter the system prompt for your agent: + Be comprehensive for best results + + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx index 0c982da6a..501509ff5 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx @@ -1,60 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import type { Tools } from '../../../../Tool.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { ToolSelector } from '../../ToolSelector.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode } from 'react' +import type { Tools } from '../../../../Tool.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { ToolSelector } from '../../ToolSelector.js' +import type { AgentWizardData } from '../types.js' + type Props = { - tools: Tools; -}; -export function ToolsStep(t0) { - const $ = _c(9); - const { - tools - } = t0; - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - let t1; - if ($[0] !== goNext || $[1] !== updateWizardData) { - t1 = selectedTools => { - updateWizardData({ - selectedTools - }); - goNext(); - }; - $[0] = goNext; - $[1] = updateWizardData; - $[2] = t1; - } else { - t1 = $[2]; - } - const handleComplete = t1; - const initialTools = wizardData.selectedTools; - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== initialTools || $[7] !== tools) { - t3 = ; - $[4] = goBack; - $[5] = handleComplete; - $[6] = initialTools; - $[7] = tools; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; + tools: Tools +} + +export function ToolsStep({ tools }: Props): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + const handleComplete = (selectedTools: string[] | undefined): void => { + updateWizardData({ selectedTools }) + goNext() + } + + // Pass through undefined to preserve "all tools" semantic + // ToolSelector will expand it internally for display purposes + const initialTools = wizardData.selectedTools + + return ( + + + + + + } + > + + + ) } diff --git a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx index 70c085cc5..6ff025492 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx @@ -1,102 +1,83 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useState } from 'react'; -import { Box, Text } from '../../../../ink.js'; -import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; -import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; -import { Byline } from '../../../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; -import TextInput from '../../../TextInput.js'; -import { useWizard } from '../../../wizard/index.js'; -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; -import { validateAgentType } from '../../validateAgent.js'; -import type { AgentWizardData } from '../types.js'; +import React, { type ReactNode, useState } from 'react' +import { Box, Text } from '../../../../ink.js' +import { useKeybinding } from '../../../../keybindings/useKeybinding.js' +import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js' +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' +import { Byline } from '../../../design-system/Byline.js' +import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js' +import TextInput from '../../../TextInput.js' +import { useWizard } from '../../../wizard/index.js' +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' +import { validateAgentType } from '../../validateAgent.js' +import type { AgentWizardData } from '../types.js' + type Props = { - existingAgents: AgentDefinition[]; -}; -export function TypeStep(_props) { - const $ = _c(15); - const { - goNext, - goBack, - updateWizardData, - wizardData - } = useWizard(); - const [agentType, setAgentType] = useState(wizardData.agentType || ""); - const [error, setError] = useState(null); - const [cursorOffset, setCursorOffset] = useState(agentType.length); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - context: "Settings" - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useKeybinding("confirm:no", goBack, t0); - let t1; - if ($[1] !== goNext || $[2] !== updateWizardData) { - t1 = value => { - const trimmedValue = value.trim(); - const validationError = validateAgentType(trimmedValue); - if (validationError) { - setError(validationError); - return; - } - setError(null); - updateWizardData({ - agentType: trimmedValue - }); - goNext(); - }; - $[1] = goNext; - $[2] = updateWizardData; - $[3] = t1; - } else { - t1 = $[3]; - } - const handleSubmit = t1; - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Enter a unique identifier for your agent:; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== agentType || $[7] !== cursorOffset || $[8] !== handleSubmit) { - t4 = ; - $[6] = agentType; - $[7] = cursorOffset; - $[8] = handleSubmit; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== error) { - t5 = error && {error}; - $[10] = error; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t4 || $[13] !== t5) { - t6 = {t3}{t4}{t5}; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + existingAgents: AgentDefinition[] +} + +export function TypeStep(_props: Props): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [agentType, setAgentType] = useState(wizardData.agentType || '') + const [error, setError] = useState(null) + const [cursorOffset, setCursorOffset] = useState(agentType.length) + + // Handle escape key - Go back to MethodStep + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const handleSubmit = (value: string): void => { + const trimmedValue = value.trim() + const validationError = validateAgentType(trimmedValue) + + if (validationError) { + setError(validationError) + return + } + + setError(null) + updateWizardData({ agentType: trimmedValue }) + goNext() + } + + return ( + + + + + + } + > + + Enter a unique identifier for your agent: + + + + + {error && ( + + {error} + + )} + + + ) } diff --git a/src/components/design-system/Byline.tsx b/src/components/design-system/Byline.tsx index be41b584c..b0ddc97f3 100644 --- a/src/components/design-system/Byline.tsx +++ b/src/components/design-system/Byline.tsx @@ -1,10 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Children, isValidElement } from 'react'; -import { Text } from '../../ink.js'; +import React, { Children, isValidElement } from 'react' +import { Text } from '../../ink.js' + type Props = { /** The items to join with a middot separator */ - children: React.ReactNode; -}; + children: React.ReactNode +} /** * Joins children with a middot separator (" · ") for inline metadata display. @@ -34,43 +34,24 @@ type Props = { * * */ -export function Byline(t0) { - const $ = _c(5); - const { - children - } = t0; - let t1; - let t2; - if ($[0] !== children) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const validChildren = Children.toArray(children); - if (validChildren.length === 0) { - t2 = null; - break bb0; - } - t1 = validChildren.map(_temp); - } - $[0] = children; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; +export function Byline({ children }: Props): React.ReactNode { + // Children.toArray already filters out null, undefined, and booleans + const validChildren = Children.toArray(children) + + if (validChildren.length === 0) { + return null } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - let t3; - if ($[3] !== t1) { - t3 = <>{t1}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} -function _temp(child, index) { - return {index > 0 && · }{child}; + + return ( + <> + {validChildren.map((child, index) => ( + + {index > 0 && · } + {child} + + ))} + + ) } diff --git a/src/components/design-system/Dialog.tsx b/src/components/design-system/Dialog.tsx index 5461c6c74..4472bd0d0 100644 --- a/src/components/design-system/Dialog.tsx +++ b/src/components/design-system/Dialog.tsx @@ -1,23 +1,26 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { Theme } from '../../utils/theme.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from './Byline.js'; -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'; -import { Pane } from './Pane.js'; +import React from 'react' +import { + type ExitState, + useExitOnCtrlCDWithKeybindings, +} from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { Theme } from '../../utils/theme.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from './Byline.js' +import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' +import { Pane } from './Pane.js' + type DialogProps = { - title: React.ReactNode; - subtitle?: React.ReactNode; - children: React.ReactNode; - onCancel: () => void; - color?: keyof Theme; - hideInputGuide?: boolean; - hideBorder?: boolean; + title: React.ReactNode + subtitle?: React.ReactNode + children: React.ReactNode + onCancel: () => void + color?: keyof Theme + hideInputGuide?: boolean + hideBorder?: boolean /** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */ - inputGuide?: (exitState: ExitState) => React.ReactNode; + inputGuide?: (exitState: ExitState) => React.ReactNode /** * Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt * (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text @@ -25,113 +28,73 @@ type DialogProps = { * consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on * press, delete-forward on ctrl+d with text). Defaults to `true`. */ - isCancelActive?: boolean; -}; -export function Dialog(t0) { - const $ = _c(27); - const { - title, - subtitle, - children, - onCancel, - color: t1, - hideInputGuide, - hideBorder, - inputGuide, - isCancelActive: t2 - } = t0; - const color = t1 === undefined ? "permission" : t1; - const isCancelActive = t2 === undefined ? true : t2; - const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive); - let t3; - if ($[0] !== isCancelActive) { - t3 = { - context: "Confirmation", - isActive: isCancelActive - }; - $[0] = isCancelActive; - $[1] = t3; - } else { - t3 = $[1]; - } - useKeybinding("confirm:no", onCancel, t3); - let t4; - if ($[2] !== exitState.keyName || $[3] !== exitState.pending) { - t4 = exitState.pending ? Press {exitState.keyName} again to exit : ; - $[2] = exitState.keyName; - $[3] = exitState.pending; - $[4] = t4; - } else { - t4 = $[4]; - } - const defaultInputGuide = t4; - let t5; - if ($[5] !== color || $[6] !== title) { - t5 = {title}; - $[5] = color; - $[6] = title; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== subtitle) { - t6 = subtitle && {subtitle}; - $[8] = subtitle; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== t5 || $[11] !== t6) { - t7 = {t5}{t6}; - $[10] = t5; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== children || $[14] !== t7) { - t8 = {t7}{children}; - $[13] = children; - $[14] = t7; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] !== defaultInputGuide || $[17] !== exitState || $[18] !== hideInputGuide || $[19] !== inputGuide) { - t9 = !hideInputGuide && {inputGuide ? inputGuide(exitState) : defaultInputGuide}; - $[16] = defaultInputGuide; - $[17] = exitState; - $[18] = hideInputGuide; - $[19] = inputGuide; - $[20] = t9; - } else { - t9 = $[20]; - } - let t10; - if ($[21] !== t8 || $[22] !== t9) { - t10 = <>{t8}{t9}; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const content = t10; - if (hideBorder) { - return content; - } - let t11; - if ($[24] !== color || $[25] !== content) { - t11 = {content}; - $[24] = color; - $[25] = content; - $[26] = t11; - } else { - t11 = $[26]; - } - return t11; + isCancelActive?: boolean +} + +export function Dialog({ + title, + subtitle, + children, + onCancel, + color = 'permission', + hideInputGuide, + hideBorder, + inputGuide, + isCancelActive = true, +}: DialogProps): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings( + undefined, + undefined, + isCancelActive, + ) + + // Use configurable keybinding for ESC to cancel. + // isCancelActive lets consumers (e.g. ElicitationDialog) disable this while + // an embedded TextInput is focused, so that keys like 'n' reach the field + // instead of being consumed here. + useKeybinding('confirm:no', onCancel, { + context: 'Confirmation', + isActive: isCancelActive, + }) + + const defaultInputGuide = exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + + + + ) + + const content = ( + <> + + + + {title} + + {subtitle && {subtitle}} + + {children} + + {!hideInputGuide && ( + + + {inputGuide ? inputGuide(exitState) : defaultInputGuide} + + + )} + + ) + + if (hideBorder) { + return content + } + + return {content} } diff --git a/src/components/design-system/Divider.tsx b/src/components/design-system/Divider.tsx index 362f4c283..a88982be5 100644 --- a/src/components/design-system/Divider.tsx +++ b/src/components/design-system/Divider.tsx @@ -1,33 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Ansi, Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; +import React from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Ansi, Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' + type DividerProps = { /** * Width of the divider in characters. * Defaults to terminal width. */ - width?: number; + width?: number /** * Theme color for the divider. * If not provided, dimColor is used. */ - color?: keyof Theme; + color?: keyof Theme /** * Character to use for the divider line. * @default '─' */ - char?: string; + char?: string /** * Padding to subtract from the width (e.g., for indentation). * @default 0 */ - padding?: number; + padding?: number /** * Title shown in the middle of the divider. @@ -37,8 +37,8 @@ type DividerProps = { * // ─────────── Title ─────────── * */ - title?: string; -}; + title?: string +} /** * A horizontal divider line. @@ -63,86 +63,35 @@ type DividerProps = { * // With centered title * */ -export function Divider(t0) { - const $ = _c(21); - const { - width, - color, - char: t1, - padding: t2, - title - } = t0; - const char = t1 === undefined ? "\u2500" : t1; - const padding = t2 === undefined ? 0 : t2; - const { - columns: terminalWidth - } = useTerminalSize(); - const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding); +export function Divider({ + width, + color, + char = '─', + padding = 0, + title, +}: DividerProps): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding) + if (title) { - const titleWidth = stringWidth(title) + 2; - const sideWidth = Math.max(0, effectiveWidth - titleWidth); - const leftWidth = Math.floor(sideWidth / 2); - const rightWidth = sideWidth - leftWidth; - const t3 = !color; - let t4; - if ($[0] !== char || $[1] !== leftWidth) { - t4 = char.repeat(leftWidth); - $[0] = char; - $[1] = leftWidth; - $[2] = t4; - } else { - t4 = $[2]; - } - let t5; - if ($[3] !== title) { - t5 = {title}; - $[3] = title; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] !== char || $[6] !== rightWidth) { - t6 = char.repeat(rightWidth); - $[5] = char; - $[6] = rightWidth; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== color || $[9] !== t3 || $[10] !== t4 || $[11] !== t5 || $[12] !== t6) { - t7 = {t4}{" "}{t5}{" "}{t6}; - $[8] = color; - $[9] = t3; - $[10] = t4; - $[11] = t5; - $[12] = t6; - $[13] = t7; - } else { - t7 = $[13]; - } - return t7; + const titleWidth = stringWidth(title) + 2 // +2 for spaces around title + const sideWidth = Math.max(0, effectiveWidth - titleWidth) + const leftWidth = Math.floor(sideWidth / 2) + const rightWidth = sideWidth - leftWidth + return ( + + {char.repeat(leftWidth)}{' '} + + {title} + {' '} + {char.repeat(rightWidth)} + + ) } - const t3 = !color; - let t4; - if ($[14] !== char || $[15] !== effectiveWidth) { - t4 = char.repeat(effectiveWidth); - $[14] = char; - $[15] = effectiveWidth; - $[16] = t4; - } else { - t4 = $[16]; - } - let t5; - if ($[17] !== color || $[18] !== t3 || $[19] !== t4) { - t5 = {t4}; - $[17] = color; - $[18] = t3; - $[19] = t4; - $[20] = t5; - } else { - t5 = $[20]; - } - return t5; + + return ( + + {char.repeat(effectiveWidth)} + + ) } diff --git a/src/components/design-system/FuzzyPicker.tsx b/src/components/design-system/FuzzyPicker.tsx index e84f1aafd..fc1b9fe9e 100644 --- a/src/components/design-system/FuzzyPicker.tsx +++ b/src/components/design-system/FuzzyPicker.tsx @@ -1,70 +1,73 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { useSearchInput } from '../../hooks/useSearchInput.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { clamp } from '../../ink/layout/geometry.js'; -import { Box, Text, useTerminalFocus } from '../../ink.js'; -import { SearchBox } from '../SearchBox.js'; -import { Byline } from './Byline.js'; -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'; -import { ListItem } from './ListItem.js'; -import { Pane } from './Pane.js'; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { useSearchInput } from '../../hooks/useSearchInput.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { clamp } from '../../ink/layout/geometry.js' +import { Box, Text, useTerminalFocus } from '../../ink.js' +import { SearchBox } from '../SearchBox.js' +import { Byline } from './Byline.js' +import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' +import { ListItem } from './ListItem.js' +import { Pane } from './Pane.js' + type PickerAction = { /** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */ - action: string; - handler: (item: T) => void; -}; + action: string + handler: (item: T) => void +} + type Props = { - title: string; - placeholder?: string; - initialQuery?: string; - items: readonly T[]; - getKey: (item: T) => string; + title: string + placeholder?: string + initialQuery?: string + items: readonly T[] + getKey: (item: T) => string /** Keep to one line — preview handles overflow. */ - renderItem: (item: T, isFocused: boolean) => React.ReactNode; - renderPreview?: (item: T) => React.ReactNode; + renderItem: (item: T, isFocused: boolean) => React.ReactNode + renderPreview?: (item: T) => React.ReactNode /** 'right' keeps hints stable (no bounce), but needs width. */ - previewPosition?: 'bottom' | 'right'; - visibleCount?: number; + previewPosition?: 'bottom' | 'right' + visibleCount?: number /** * 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows * always match screen direction — ↑ walks visually up regardless. */ - direction?: 'down' | 'up'; + direction?: 'down' | 'up' /** Caller owns filtering: re-filter on each call and pass new items. */ - onQueryChange: (query: string) => void; + onQueryChange: (query: string) => void /** Enter key. Primary action. */ - onSelect: (item: T) => void; + onSelect: (item: T) => void /** * Tab key. If provided, Tab no longer aliases Enter — it gets its own * handler and hint. Shift+Tab falls through to this if onShiftTab is unset. */ - onTab?: PickerAction; + onTab?: PickerAction /** Shift+Tab key. Gets its own hint. */ - onShiftTab?: PickerAction; + onShiftTab?: PickerAction /** * Fires when the focused item changes (via arrows or when items reset). * Useful for async preview loading — keeps I/O out of renderPreview. */ - onFocus?: (item: T | undefined) => void; - onCancel: () => void; + onFocus?: (item: T | undefined) => void + onCancel: () => void /** Shown when items is empty. Caller bakes loading/searching state into this. */ - emptyMessage?: string | ((query: string) => string); + emptyMessage?: string | ((query: string) => string) /** * Status line below the list, e.g. "500+ matches" or "42 matches…". * Caller decides when to show it — pass undefined to hide. */ - matchLabel?: string; - selectAction?: string; - extraHints?: React.ReactNode; -}; -const DEFAULT_VISIBLE = 8; + matchLabel?: string + selectAction?: string + extraHints?: React.ReactNode +} + +const DEFAULT_VISIBLE = 8 // Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3 // rows) + hints. matchLabel adds +1 when present, accounted for separately. -const CHROME_ROWS = 10; -const MIN_VISIBLE = 2; +const CHROME_ROWS = 10 +const MIN_VISIBLE = 2 + export function FuzzyPicker({ title, placeholder = 'Type to search…', @@ -85,117 +88,168 @@ export function FuzzyPicker({ emptyMessage = 'No results', matchLabel, selectAction = 'select', - extraHints + extraHints, }: Props): React.ReactNode { - const isTerminalFocused = useTerminalFocus(); - const { - rows, - columns - } = useTerminalSize(); - const [focusedIndex, setFocusedIndex] = useState(0); + const isTerminalFocused = useTerminalFocus() + const { rows, columns } = useTerminalSize() + const [focusedIndex, setFocusedIndex] = useState(0) // Cap visibleCount so the picker never exceeds the terminal height. When it // overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up // by the overflow amount and a previously-drawn line flashes blank. - const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0))); + const visibleCount = Math.max( + MIN_VISIBLE, + Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)), + ) // Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently // below that. Compact mode drops shift+tab and shortens labels. - const compact = columns < 120; + const compact = columns < 120 + const step = (delta: 1 | -1) => { - setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)); - }; + setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)) + } // onKeyDown fires after useSearchInput's useInput, so onExit must be a // no-op — return/downArrow are handled by handleKeyDown below. onCancel // still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so // a held backspace doesn't eject the user from the dialog. - const { - query, - cursorOffset - } = useSearchInput({ + const { query, cursorOffset } = useSearchInput({ isActive: true, onExit: () => {}, onCancel, initialQuery, - backspaceExitsOnEmpty: false - }); + backspaceExitsOnEmpty: false, + }) + const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'up' || e.ctrl && e.key === 'p') { - e.preventDefault(); - e.stopImmediatePropagation(); - step(direction === 'up' ? 1 : -1); - return; + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + e.stopImmediatePropagation() + step(direction === 'up' ? 1 : -1) + return } - if (e.key === 'down' || e.ctrl && e.key === 'n') { - e.preventDefault(); - e.stopImmediatePropagation(); - step(direction === 'up' ? -1 : 1); - return; + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + e.stopImmediatePropagation() + step(direction === 'up' ? -1 : 1) + return } if (e.key === 'return') { - e.preventDefault(); - e.stopImmediatePropagation(); - const selected = items[focusedIndex]; - if (selected) onSelect(selected); - return; + e.preventDefault() + e.stopImmediatePropagation() + const selected = items[focusedIndex] + if (selected) onSelect(selected) + return } if (e.key === 'tab') { - e.preventDefault(); - e.stopImmediatePropagation(); - const selected = items[focusedIndex]; - if (!selected) return; - const tabAction = e.shift ? onShiftTab ?? onTab : onTab; + e.preventDefault() + e.stopImmediatePropagation() + const selected = items[focusedIndex] + if (!selected) return + const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab if (tabAction) { - tabAction.handler(selected); + tabAction.handler(selected) } else { - onSelect(selected); + onSelect(selected) } } - }; + } + useEffect(() => { - onQueryChange(query); - setFocusedIndex(0); + onQueryChange(query) + setFocusedIndex(0) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]); + }, [query]) + useEffect(() => { - setFocusedIndex(i => clamp(i, 0, items.length - 1)); - }, [items.length]); - const focused = items[focusedIndex]; + setFocusedIndex(i => clamp(i, 0, items.length - 1)) + }, [items.length]) + + const focused = items[focusedIndex] useEffect(() => { - onFocus?.(focused); + onFocus?.(focused) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focused]); - const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount); - const visible = items.slice(windowStart, windowStart + visibleCount); - const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage; - const searchBox = ; - const listBlock = ; - const preview = renderPreview && focused ? + }, [focused]) + + const windowStart = clamp( + focusedIndex - visibleCount + 1, + 0, + items.length - visibleCount, + ) + const visible = items.slice(windowStart, windowStart + visibleCount) + + const emptyText = + typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage + + const searchBox = ( + + ) + + const listBlock = ( + + ) + + const preview = + renderPreview && focused ? ( + {renderPreview(focused)} - : null; + + ) : null // Structure must not depend on preview truthiness — when focused goes // undefined (e.g. delete clears matches), switching row→fragment would // change both layout AND gap count, bouncing the searchBox below. - const listGroup = renderPreview && previewPosition === 'right' ? + const listGroup = + renderPreview && previewPosition === 'right' ? ( + {listBlock} {matchLabel && {matchLabel}} {preview ?? } - : - // Box (not fragment) so the outer gap={1} doesn't insert a blank line - // between list/matchLabel/preview — that read as extra space above the - // prompt in direction='up'. - + + ) : ( + // Box (not fragment) so the outer gap={1} doesn't insert a blank line + // between list/matchLabel/preview — that read as extra space above the + // prompt in direction='up'. + {listBlock} {matchLabel && {matchLabel}} {preview} - ; - const inputAbove = direction !== 'up'; - return - + + ) + + const inputAbove = direction !== 'up' + return ( + + {title} @@ -204,108 +258,93 @@ export function FuzzyPicker({ {!inputAbove && searchBox} - - - {onTab && } - {onShiftTab && !compact && } + + + {onTab && ( + + )} + {onShiftTab && !compact && ( + + )} {extraHints} - ; + + ) } -type ListProps = Pick, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & { - visible: readonly T[]; - windowStart: number; - total: number; - focusedIndex: number; - emptyText: string; -}; -function List(t0) { - const $ = _c(27); - const { - visible, - windowStart, - visibleCount, - total, - focusedIndex, - direction, - getKey, - renderItem, - emptyText - } = t0; + +type ListProps = Pick< + Props, + 'visibleCount' | 'direction' | 'getKey' | 'renderItem' +> & { + visible: readonly T[] + windowStart: number + total: number + focusedIndex: number + emptyText: string +} + +function List({ + visible, + windowStart, + visibleCount, + total, + focusedIndex, + direction, + getKey, + renderItem, + emptyText, +}: ListProps): React.ReactNode { if (visible.length === 0) { - let t1; - if ($[0] !== emptyText) { - t1 = {emptyText}; - $[0] = emptyText; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1 || $[3] !== visibleCount) { - t2 = {t1}; - $[2] = t1; - $[3] = visibleCount; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + return ( + + {emptyText} + + ) } - let t1; - if ($[5] !== direction || $[6] !== focusedIndex || $[7] !== getKey || $[8] !== renderItem || $[9] !== total || $[10] !== visible || $[11] !== visibleCount || $[12] !== windowStart) { - let t2; - if ($[14] !== direction || $[15] !== focusedIndex || $[16] !== getKey || $[17] !== renderItem || $[18] !== total || $[19] !== visible.length || $[20] !== visibleCount || $[21] !== windowStart) { - t2 = (item, i) => { - const actualIndex = windowStart + i; - const isFocused = actualIndex === focusedIndex; - const atLowEdge = i === 0 && windowStart > 0; - const atHighEdge = i === visible.length - 1 && windowStart + visibleCount < total; - return {renderItem(item, isFocused)}; - }; - $[14] = direction; - $[15] = focusedIndex; - $[16] = getKey; - $[17] = renderItem; - $[18] = total; - $[19] = visible.length; - $[20] = visibleCount; - $[21] = windowStart; - $[22] = t2; - } else { - t2 = $[22]; - } - t1 = visible.map(t2); - $[5] = direction; - $[6] = focusedIndex; - $[7] = getKey; - $[8] = renderItem; - $[9] = total; - $[10] = visible; - $[11] = visibleCount; - $[12] = windowStart; - $[13] = t1; - } else { - t1 = $[13]; - } - const rows = t1; - const t2 = direction === "up" ? "column-reverse" : "column"; - let t3; - if ($[23] !== rows || $[24] !== t2 || $[25] !== visibleCount) { - t3 = {rows}; - $[23] = rows; - $[24] = t2; - $[25] = visibleCount; - $[26] = t3; - } else { - t3 = $[26]; - } - return t3; + + const rows = visible.map((item, i) => { + const actualIndex = windowStart + i + const isFocused = actualIndex === focusedIndex + const atLowEdge = i === 0 && windowStart > 0 + const atHighEdge = + i === visible.length - 1 && windowStart + visibleCount! < total + return ( + + {renderItem(item, isFocused)} + + ) + }) + + return ( + + {rows} + + ) } + function firstWord(s: string): string { - const i = s.indexOf(' '); - return i === -1 ? s : s.slice(0, i); + const i = s.indexOf(' ') + return i === -1 ? s : s.slice(0, i) } diff --git a/src/components/design-system/KeyboardShortcutHint.tsx b/src/components/design-system/KeyboardShortcutHint.tsx index 19b51d05b..7d3c136d1 100644 --- a/src/components/design-system/KeyboardShortcutHint.tsx +++ b/src/components/design-system/KeyboardShortcutHint.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import Text from '../../ink/components/Text.js'; +import React from 'react' +import Text from '../../ink/components/Text.js' + type Props = { /** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */ - shortcut: string; + shortcut: string /** The action the key performs (e.g., "expand", "select", "navigate") */ - action: string; + action: string /** Whether to wrap the hint in parentheses. Default: false */ - parens?: boolean; + parens?: boolean /** Whether to render the shortcut in bold. Default: false */ - bold?: boolean; -}; + bold?: boolean +} /** * Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)" @@ -35,46 +35,24 @@ type Props = { * * */ -export function KeyboardShortcutHint(t0) { - const $ = _c(9); - const { - shortcut, - action, - parens: t1, - bold: t2 - } = t0; - const parens = t1 === undefined ? false : t1; - const bold = t2 === undefined ? false : t2; - let t3; - if ($[0] !== bold || $[1] !== shortcut) { - t3 = bold ? {shortcut} : shortcut; - $[0] = bold; - $[1] = shortcut; - $[2] = t3; - } else { - t3 = $[2]; - } - const shortcutText = t3; +export function KeyboardShortcutHint({ + shortcut, + action, + parens = false, + bold = false, +}: Props): React.ReactNode { + const shortcutText = bold ? {shortcut} : shortcut + if (parens) { - let t4; - if ($[3] !== action || $[4] !== shortcutText) { - t4 = ({shortcutText} to {action}); - $[3] = action; - $[4] = shortcutText; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; + return ( + + ({shortcutText} to {action}) + + ) } - let t4; - if ($[6] !== action || $[7] !== shortcutText) { - t4 = {shortcutText} to {action}; - $[6] = action; - $[7] = shortcutText; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + return ( + + {shortcutText} to {action} + + ) } diff --git a/src/components/design-system/ListItem.tsx b/src/components/design-system/ListItem.tsx index 0ee8068cc..2d142be03 100644 --- a/src/components/design-system/ListItem.tsx +++ b/src/components/design-system/ListItem.tsx @@ -1,44 +1,44 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import type { ReactNode } from 'react'; -import React from 'react'; -import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js'; -import { Box, Text } from '../../ink.js'; +import figures from 'figures' +import type { ReactNode } from 'react' +import React from 'react' +import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js' +import { Box, Text } from '../../ink.js' + type ListItemProps = { /** * Whether this item is currently focused (keyboard selection). * Shows the pointer indicator (❯) when true. */ - isFocused: boolean; + isFocused: boolean /** * Whether this item is selected (chosen/checked). * Shows the checkmark indicator (✓) when true. * @default false */ - isSelected?: boolean; + isSelected?: boolean /** * The content to display for this item. */ - children: ReactNode; + children: ReactNode /** * Optional description text displayed below the main content. */ - description?: string; + description?: string /** * Show a down arrow indicator instead of pointer (for scroll hints). * Only applies when not focused. */ - showScrollDown?: boolean; + showScrollDown?: boolean /** * Show an up arrow indicator instead of pointer (for scroll hints). * Only applies when not focused. */ - showScrollUp?: boolean; + showScrollUp?: boolean /** * Whether to apply automatic styling to the children based on focus/selection state. @@ -46,21 +46,21 @@ type ListItemProps = { * - When false: children are rendered as-is, allowing custom styling * @default true */ - styled?: boolean; + styled?: boolean /** * Whether this item is disabled. Disabled items show dimmed text and no indicators. * @default false */ - disabled?: boolean; + disabled?: boolean /** * Whether this ListItem should declare the terminal cursor position. * Set false when a child (e.g. BaseTextInput) declares its own cursor. * @default true */ - declareCursor?: boolean; -}; + declareCursor?: boolean +} /** * A list item component for selection UIs (dropdowns, multi-selects, menus). @@ -101,143 +101,88 @@ type ListItemProps = { * Custom styled content * */ -export function ListItem(t0) { - const $ = _c(32); - const { - isFocused, - isSelected: t1, - children, - description, - showScrollDown, - showScrollUp, - styled: t2, - disabled: t3, - declareCursor - } = t0; - const isSelected = t1 === undefined ? false : t1; - const styled = t2 === undefined ? true : t2; - const disabled = t3 === undefined ? false : t3; - let t4; - if ($[0] !== disabled || $[1] !== isFocused || $[2] !== showScrollDown || $[3] !== showScrollUp) { - t4 = function renderIndicator() { - if (disabled) { - return ; - } - if (isFocused) { - return {figures.pointer}; - } - if (showScrollDown) { - return {figures.arrowDown}; - } - if (showScrollUp) { - return {figures.arrowUp}; - } - return ; - }; - $[0] = disabled; - $[1] = isFocused; - $[2] = showScrollDown; - $[3] = showScrollUp; - $[4] = t4; - } else { - t4 = $[4]; +export function ListItem({ + isFocused, + isSelected = false, + children, + description, + showScrollDown, + showScrollUp, + styled = true, + disabled = false, + declareCursor, +}: ListItemProps): React.ReactNode { + // Determine which indicator to show + function renderIndicator(): ReactNode { + if (disabled) { + return + } + + if (isFocused) { + return {figures.pointer} + } + + if (showScrollDown) { + return {figures.arrowDown} + } + + if (showScrollUp) { + return {figures.arrowUp} + } + + return } - const renderIndicator = t4; - let t5; - if ($[5] !== disabled || $[6] !== isFocused || $[7] !== isSelected || $[8] !== styled) { - const getTextColor = function getTextColor() { - if (disabled) { - return "inactive"; - } - if (!styled) { - return; - } - if (isSelected) { - return "success"; - } - if (isFocused) { - return "suggestion"; - } - }; - t5 = getTextColor(); - $[5] = disabled; - $[6] = isFocused; - $[7] = isSelected; - $[8] = styled; - $[9] = t5; - } else { - t5 = $[9]; + + // Determine text color based on state + function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined { + if (disabled) { + return 'inactive' + } + + if (!styled) { + return undefined + } + + if (isSelected) { + return 'success' + } + + if (isFocused) { + return 'suggestion' + } + + return undefined } - const textColor = t5; - const t6 = isFocused && !disabled && declareCursor !== false; - let t7; - if ($[10] !== t6) { - t7 = { - line: 0, - column: 0, - active: t6 - }; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - const cursorRef = useDeclaredCursor(t7); - let t8; - if ($[12] !== renderIndicator) { - t8 = renderIndicator(); - $[12] = renderIndicator; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] !== children || $[15] !== disabled || $[16] !== styled || $[17] !== textColor) { - t9 = styled ? {children} : children; - $[14] = children; - $[15] = disabled; - $[16] = styled; - $[17] = textColor; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== disabled || $[20] !== isSelected) { - t10 = isSelected && !disabled && {figures.tick}; - $[19] = disabled; - $[20] = isSelected; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== t10 || $[23] !== t8 || $[24] !== t9) { - t11 = {t8}{t9}{t10}; - $[22] = t10; - $[23] = t8; - $[24] = t9; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== description) { - t12 = description && {description}; - $[26] = description; - $[27] = t12; - } else { - t12 = $[27]; - } - let t13; - if ($[28] !== cursorRef || $[29] !== t11 || $[30] !== t12) { - t13 = {t11}{t12}; - $[28] = cursorRef; - $[29] = t11; - $[30] = t12; - $[31] = t13; - } else { - t13 = $[31]; - } - return t13; + + const textColor = getTextColor() + + // Park the native terminal cursor on the pointer indicator so screen + // readers / magnifiers track the focused item. (0,0) is the top-left of + // this Box, where the pointer renders. + const cursorRef = useDeclaredCursor({ + line: 0, + column: 0, + active: isFocused && !disabled && declareCursor !== false, + }) + + return ( + + + {renderIndicator()} + {styled ? ( + + {children} + + ) : ( + children + )} + {isSelected && !disabled && {figures.tick}} + + {description && ( + + {description} + + )} + + ) } diff --git a/src/components/design-system/LoadingState.tsx b/src/components/design-system/LoadingState.tsx index aa05dd941..046f726fa 100644 --- a/src/components/design-system/LoadingState.tsx +++ b/src/components/design-system/LoadingState.tsx @@ -1,30 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Spinner } from '../Spinner.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { Spinner } from '../Spinner.js' + type LoadingStateProps = { /** * The loading message to display next to the spinner. */ - message: string; + message: string /** * Display the message in bold. * @default false */ - bold?: boolean; + bold?: boolean /** * Display the message in dimmed color. * @default false */ - dimColor?: boolean; + dimColor?: boolean /** * Optional subtitle displayed below the main message. */ - subtitle?: string; -}; + subtitle?: string +} /** * A spinner with loading message for async operations. @@ -45,49 +45,22 @@ type LoadingStateProps = { * subtitle="Fetching your Claude Code sessions..." * /> */ -export function LoadingState(t0) { - const $ = _c(10); - const { - message, - bold: t1, - dimColor: t2, - subtitle - } = t0; - const bold = t1 === undefined ? false : t1; - const dimColor = t2 === undefined ? false : t2; - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[0] = t3; - } else { - t3 = $[0]; - } - let t4; - if ($[1] !== bold || $[2] !== dimColor || $[3] !== message) { - t4 = {t3}{" "}{message}; - $[1] = bold; - $[2] = dimColor; - $[3] = message; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== subtitle) { - t5 = subtitle && {subtitle}; - $[5] = subtitle; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== t4 || $[8] !== t5) { - t6 = {t4}{t5}; - $[7] = t4; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - return t6; +export function LoadingState({ + message, + bold = false, + dimColor = false, + subtitle, +}: LoadingStateProps): React.ReactNode { + return ( + + + + + {' '} + {message} + + + {subtitle && {subtitle}} + + ) } diff --git a/src/components/design-system/Pane.tsx b/src/components/design-system/Pane.tsx index 4f1264bea..9c10907d3 100644 --- a/src/components/design-system/Pane.tsx +++ b/src/components/design-system/Pane.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { Box } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; -import { Divider } from './Divider.js'; +import React from 'react' +import { useIsInsideModal } from '../../context/modalContext.js' +import { Box } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' +import { Divider } from './Divider.js' + type PaneProps = { - children: React.ReactNode; + children: React.ReactNode /** * Theme color for the top border line. */ - color?: keyof Theme; -}; + color?: keyof Theme +} /** * A pane — a region of the terminal that appears below the REPL prompt, @@ -30,47 +30,28 @@ type PaneProps = { * ... * */ -export function Pane(t0) { - const $ = _c(9); - const { - children, - color - } = t0; +export function Pane({ children, color }: PaneProps): React.ReactNode { + // When rendered inside FullscreenLayout's modal slot, its ▔ divider IS + // the frame. Skip our own Divider (would double-frame) and the extra top + // padding. This lets slash-command screens that wrap in Pane (e.g. + // /model → ModelPicker) route through the modal slot unchanged. if (useIsInsideModal()) { - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + // flexShrink=0: the modal slot's absolute Box has no explicit height + // (grows to fit, maxHeight cap). With flexGrow=1, re-renders cause + // yoga to resolve this Box's height to 0 against the undetermined + // parent — /permissions body blanks on Down arrow. See #23592. + return ( + + {children} + + ) } - let t1; - if ($[2] !== color) { - t1 = ; - $[2] = color; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== children) { - t2 = {children}; - $[4] = children; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== t1 || $[7] !== t2) { - t3 = {t1}{t2}; - $[6] = t1; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; + return ( + + + + {children} + + + ) } diff --git a/src/components/design-system/ProgressBar.tsx b/src/components/design-system/ProgressBar.tsx index 0d27c514b..590fcd265 100644 --- a/src/components/design-system/ProgressBar.tsx +++ b/src/components/design-system/ProgressBar.tsx @@ -1,85 +1,54 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; +import React from 'react' +import { Text } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' + type Props = { /** * How much progress to display, between 0 and 1 inclusive */ - ratio: number; // [0, 1] + ratio: number // [0, 1] /** * How many characters wide to draw the progress bar */ - width: number; // how many characters wide + width: number // how many characters wide /** * Optional color for the filled portion of the bar */ - fillColor?: keyof Theme; + fillColor?: keyof Theme /** * Optional color for the empty portion of the bar */ - emptyColor?: keyof Theme; -}; -const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; -export function ProgressBar(t0) { - const $ = _c(13); - const { - ratio: inputRatio, - width, - fillColor, - emptyColor - } = t0; - const ratio = Math.min(1, Math.max(0, inputRatio)); - const whole = Math.floor(ratio * width); - let t1; - if ($[0] !== whole) { - t1 = BLOCKS[BLOCKS.length - 1].repeat(whole); - $[0] = whole; - $[1] = t1; - } else { - t1 = $[1]; - } - let segments; - if ($[2] !== ratio || $[3] !== t1 || $[4] !== whole || $[5] !== width) { - segments = [t1]; - if (whole < width) { - const remainder = ratio * width - whole; - const middle = Math.floor(remainder * BLOCKS.length); - segments.push(BLOCKS[middle]); - const empty = width - whole - 1; - if (empty > 0) { - let t2; - if ($[7] !== empty) { - t2 = BLOCKS[0].repeat(empty); - $[7] = empty; - $[8] = t2; - } else { - t2 = $[8]; - } - segments.push(t2); - } - } - $[2] = ratio; - $[3] = t1; - $[4] = whole; - $[5] = width; - $[6] = segments; - } else { - segments = $[6]; - } - const t2 = segments.join(""); - let t3; - if ($[9] !== emptyColor || $[10] !== fillColor || $[11] !== t2) { - t3 = {t2}; - $[9] = emptyColor; - $[10] = fillColor; - $[11] = t2; - $[12] = t3; - } else { - t3 = $[12]; - } - return t3; + emptyColor?: keyof Theme +} + +const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] + +export function ProgressBar({ + ratio: inputRatio, + width, + fillColor, + emptyColor, +}: Props): React.ReactNode { + const ratio = Math.min(1, Math.max(0, inputRatio)) + const whole = Math.floor(ratio * width) + const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)] + if (whole < width) { + const remainder = ratio * width - whole + const middle = Math.floor(remainder * BLOCKS.length) + segments.push(BLOCKS[middle]!) + + const empty = width - whole - 1 + if (empty > 0) { + segments.push(BLOCKS[0]!.repeat(empty)) + } + } + + return ( + + {segments.join('')} + + ) } diff --git a/src/components/design-system/Ratchet.tsx b/src/components/design-system/Ratchet.tsx index a63cffb33..91580ff05 100644 --- a/src/components/design-system/Ratchet.tsx +++ b/src/components/design-system/Ratchet.tsx @@ -1,79 +1,45 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js'; -import { Box, type DOMElement, measureElement } from '../../ink.js'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js' +import { Box, type DOMElement, measureElement } from '../../ink.js' + type Props = { - children: React.ReactNode; - lock?: 'always' | 'offscreen'; -}; -export function Ratchet(t0) { - const $ = _c(10); - const { - children, - lock: t1 - } = t0; - const lock = t1 === undefined ? "always" : t1; - const [viewportRef, t2] = useTerminalViewport(); - const { - isVisible - } = t2; - const { - rows - } = useTerminalSize(); - const innerRef = useRef(null); - const maxHeight = useRef(0); - const [minHeight, setMinHeight] = useState(0); - let t3; - if ($[0] !== viewportRef) { - t3 = el => { - viewportRef(el); - }; - $[0] = viewportRef; - $[1] = t3; - } else { - t3 = $[1]; - } - const outerRef = t3; - const engaged = lock === "always" || !isVisible; - let t4; - if ($[2] !== rows) { - t4 = () => { - if (!innerRef.current) { - return; - } - const { - height - } = measureElement(innerRef.current); - if (height > maxHeight.current) { - maxHeight.current = Math.min(height, rows); - setMinHeight(maxHeight.current); - } - }; - $[2] = rows; - $[3] = t4; - } else { - t4 = $[3]; - } - useLayoutEffect(t4); - const t5 = engaged ? minHeight : undefined; - let t6; - if ($[4] !== children) { - t6 = {children}; - $[4] = children; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== outerRef || $[7] !== t5 || $[8] !== t6) { - t7 = {t6}; - $[6] = outerRef; - $[7] = t5; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - return t7; + children: React.ReactNode + lock?: 'always' | 'offscreen' +} + +export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode { + const [viewportRef, { isVisible }] = useTerminalViewport() + const { rows } = useTerminalSize() + const innerRef = useRef(null) + const maxHeight = useRef(0) + const [minHeight, setMinHeight] = useState(0) + + const outerRef = useCallback( + (el: DOMElement | null) => { + viewportRef(el) + }, + [viewportRef], + ) + + const engaged = lock === 'always' || !isVisible + + useLayoutEffect(() => { + if (!innerRef.current) { + return + } + const { height } = measureElement(innerRef.current) + if (height > maxHeight.current) { + maxHeight.current = Math.min(height, rows) + setMinHeight(maxHeight.current) + } + }) + + return ( + + + {children} + + + ) } diff --git a/src/components/design-system/StatusIcon.tsx b/src/components/design-system/StatusIcon.tsx index d50693edd..832c83a9e 100644 --- a/src/components/design-system/StatusIcon.tsx +++ b/src/components/design-system/StatusIcon.tsx @@ -1,8 +1,9 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Text } from '../../ink.js'; -type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading'; +import figures from 'figures' +import React from 'react' +import { Text } from '../../ink.js' + +type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading' + type Props = { /** * The status to display. Determines both the icon and color. @@ -14,42 +15,28 @@ type Props = { * - `pending`: Dimmed circle (○) * - `loading`: Dimmed ellipsis (…) */ - status: Status; + status: Status /** * Include a trailing space after the icon. Useful when followed by text. * @default false */ - withSpace?: boolean; -}; -const STATUS_CONFIG: Record = { - success: { - icon: figures.tick, - color: 'success' - }, - error: { - icon: figures.cross, - color: 'error' - }, - warning: { - icon: figures.warning, - color: 'warning' - }, - info: { - icon: figures.info, - color: 'suggestion' - }, - pending: { - icon: figures.circle, - color: undefined - }, - loading: { - icon: '…', - color: undefined + withSpace?: boolean +} + +const STATUS_CONFIG: Record< + Status, + { + icon: string + color: 'success' | 'error' | 'warning' | 'suggestion' | undefined } -}; +> = { + success: { icon: figures.tick, color: 'success' }, + error: { icon: figures.cross, color: 'error' }, + warning: { icon: figures.warning, color: 'warning' }, + info: { icon: figures.info, color: 'suggestion' }, + pending: { icon: figures.circle, color: undefined }, + loading: { icon: '…', color: undefined }, +} /** * Renders a status indicator icon with appropriate color. @@ -69,26 +56,16 @@ const STATUS_CONFIG: Record */ -export function StatusIcon(t0) { - const $ = _c(5); - const { - status, - withSpace: t1 - } = t0; - const withSpace = t1 === undefined ? false : t1; - const config = STATUS_CONFIG[status]; - const t2 = !config.color; - const t3 = withSpace && " "; - let t4; - if ($[0] !== config.color || $[1] !== config.icon || $[2] !== t2 || $[3] !== t3) { - t4 = {config.icon}{t3}; - $[0] = config.color; - $[1] = config.icon; - $[2] = t2; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - return t4; +export function StatusIcon({ + status, + withSpace = false, +}: Props): React.ReactNode { + const config = STATUS_CONFIG[status] + + return ( + + {config.icon} + {withSpace && ' '} + + ) } diff --git a/src/components/design-system/Tabs.tsx b/src/components/design-system/Tabs.tsx index db8d0d59e..40bae7baa 100644 --- a/src/components/design-system/Tabs.tsx +++ b/src/components/design-system/Tabs.tsx @@ -1,28 +1,37 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { useIsInsideModal, useModalScrollRef } from '../../context/modalContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import ScrollBox from '../../ink/components/ScrollBox.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { Theme } from '../../utils/theme.js'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { + useIsInsideModal, + useModalScrollRef, +} from '../../context/modalContext.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import ScrollBox from '../../ink/components/ScrollBox.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { Theme } from '../../utils/theme.js' + type TabsProps = { - children: Array>; - title?: string; - color?: keyof Theme; - defaultTab?: string; - hidden?: boolean; - useFullWidth?: boolean; + children: Array> + title?: string + color?: keyof Theme + defaultTab?: string + hidden?: boolean + useFullWidth?: boolean /** Controlled mode: current selected tab id/title */ - selectedTab?: string; + selectedTab?: string /** Controlled mode: callback when tab changes */ - onTabChange?: (tabId: string) => void; + onTabChange?: (tabId: string) => void /** Optional banner to display below tabs header */ - banner?: React.ReactNode; + banner?: React.ReactNode /** Disable keyboard navigation (e.g. when a child component handles arrow keys) */ - disableNavigation?: boolean; + disableNavigation?: boolean /** * Initial focus state for the tab header row. Defaults to true (header * focused, nav always works). Keep the default for Select/list content — @@ -31,28 +40,30 @@ type TabsProps = { * content actually binds left/right/tab (e.g. enum cycling), and show a * "↑ tabs" footer hint — without it tabs look broken. */ - initialHeaderFocused?: boolean; + initialHeaderFocused?: boolean /** * Fixed height for the content area. When set, all tabs render within the * same height (overflow hidden) so switching tabs doesn't cause layout * shifts. Shorter tabs get whitespace; taller tabs are clipped. */ - contentHeight?: number; + contentHeight?: number /** * Let Tab/←/→ switch tabs from focused content. Opt-in since some * content uses those keys; pass a reactive boolean to cede them when * needed. Switching from content focuses the header. */ - navFromContent?: boolean; -}; + navFromContent?: boolean +} + type TabsContextValue = { - selectedTab: string | undefined; - width: number | undefined; - headerFocused: boolean; - focusHeader: () => void; - blurHeader: () => void; - registerOptIn: () => () => void; -}; + selectedTab: string | undefined + width: number | undefined + headerFocused: boolean + focusHeader: () => void + blurHeader: () => void + registerOptIn: () => () => void +} + const TabsContext = createContext({ selectedTab: undefined, width: undefined, @@ -61,236 +72,248 @@ const TabsContext = createContext({ headerFocused: false, focusHeader: () => {}, blurHeader: () => {}, - registerOptIn: () => () => {} -}); -export function Tabs(t0) { - const $ = _c(25); - const { - title, - color, - defaultTab, - children, - hidden, - useFullWidth, - selectedTab: controlledSelectedTab, - onTabChange, - banner, - disableNavigation, - initialHeaderFocused: t1, - contentHeight, - navFromContent: t2 - } = t0; - const initialHeaderFocused = t1 === undefined ? true : t1; - const navFromContent = t2 === undefined ? false : t2; - const { - columns: terminalWidth - } = useTerminalSize(); - const tabs = children.map(_temp); - const defaultTabIndex = defaultTab ? tabs.findIndex(tab => defaultTab === tab[0]) : 0; - const isControlled = controlledSelectedTab !== undefined; - const [internalSelectedTab, setInternalSelectedTab] = useState(defaultTabIndex !== -1 ? defaultTabIndex : 0); - const controlledTabIndex = isControlled ? tabs.findIndex(tab_0 => tab_0[0] === controlledSelectedTab) : -1; - const selectedTabIndex = isControlled ? controlledTabIndex !== -1 ? controlledTabIndex : 0 : internalSelectedTab; - const modalScrollRef = useModalScrollRef(); - const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused); - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setHeaderFocused(true); - $[0] = t3; - } else { - t3 = $[0]; - } - const focusHeader = t3; - let t4; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t4 = () => setHeaderFocused(false); - $[1] = t4; - } else { - t4 = $[1]; - } - const blurHeader = t4; - const [optInCount, setOptInCount] = useState(0); - let t5; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t5 = () => { - setOptInCount(_temp2); - return () => setOptInCount(_temp3); - }; - $[2] = t5; - } else { - t5 = $[2]; - } - const registerOptIn = t5; - const optedIn = optInCount > 0; - const handleTabChange = offset => { - const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length; - const newTabId = tabs[newIndex]?.[0]; + registerOptIn: () => () => {}, +}) + +export function Tabs({ + title, + color, + defaultTab, + children, + hidden, + useFullWidth, + selectedTab: controlledSelectedTab, + onTabChange, + banner, + disableNavigation, + initialHeaderFocused = true, + contentHeight, + navFromContent = false, +}: TabsProps): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const tabs = children.map(child => [ + child.props.id ?? child.props.title, + child.props.title, + ]) + const defaultTabIndex = defaultTab + ? tabs.findIndex(tab => defaultTab === tab[0]) + : 0 + + // Support both controlled and uncontrolled modes + const isControlled = controlledSelectedTab !== undefined + const [internalSelectedTab, setInternalSelectedTab] = useState( + defaultTabIndex !== -1 ? defaultTabIndex : 0, + ) + + // In controlled mode, find the index of the controlled tab + const controlledTabIndex = isControlled + ? tabs.findIndex(tab => tab[0] === controlledSelectedTab) + : -1 + const selectedTabIndex = isControlled + ? controlledTabIndex !== -1 + ? controlledTabIndex + : 0 + : internalSelectedTab + + const modalScrollRef = useModalScrollRef() + + // Header focus: left/right/tab only switch tabs when the header row is + // focused. Children with interactive content call focusHeader() (via + // useTabHeaderFocus) on up-arrow to hand focus back here; down-arrow + // returns it. Tabs that never call the hook see no behavior change — + // initialHeaderFocused defaults to true so nav always works. + const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused) + const focusHeader = useCallback(() => setHeaderFocused(true), []) + const blurHeader = useCallback(() => setHeaderFocused(false), []) + // Count of mounted children using useTabHeaderFocus(). Down-arrow blur and + // the ↓ hint only engage when at least one child has opted in — otherwise + // pressing down on a legacy tab would strand the user with nav disabled. + const [optInCount, setOptInCount] = useState(0) + const registerOptIn = useCallback(() => { + setOptInCount(n => n + 1) + return () => setOptInCount(n => n - 1) + }, []) + const optedIn = optInCount > 0 + + const handleTabChange = (offset: number) => { + const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length + const newTabId = tabs[newIndex]?.[0] + if (isControlled && onTabChange && newTabId) { - onTabChange(newTabId); + onTabChange(newTabId) } else { - setInternalSelectedTab(newIndex); + setInternalSelectedTab(newIndex) } - setHeaderFocused(true); - }; - const t6 = !hidden && !disableNavigation && headerFocused; - let t7; - if ($[3] !== t6) { - t7 = { - context: "Tabs", - isActive: t6 - }; - $[3] = t6; - $[4] = t7; - } else { - t7 = $[4]; + // Tab switching is a header action — stay focused so the user can keep + // cycling. The newly mounted tab can blur via its own interaction. + setHeaderFocused(true) } - useKeybindings({ - "tabs:next": () => handleTabChange(1), - "tabs:previous": () => handleTabChange(-1) - }, t7); - let t8; - if ($[5] !== headerFocused || $[6] !== hidden || $[7] !== optedIn) { - t8 = e => { - if (!headerFocused || !optedIn || hidden) { - return; - } - if (e.key === "down") { - e.preventDefault(); - setHeaderFocused(false); - } - }; - $[5] = headerFocused; - $[6] = hidden; - $[7] = optedIn; - $[8] = t8; - } else { - t8 = $[8]; - } - const handleKeyDown = t8; - const t9 = navFromContent && !headerFocused && optedIn && !hidden && !disableNavigation; - let t10; - if ($[9] !== t9) { - t10 = { - context: "Tabs", - isActive: t9 - }; - $[9] = t9; - $[10] = t10; - } else { - t10 = $[10]; - } - useKeybindings({ - "tabs:next": () => { - handleTabChange(1); - setHeaderFocused(true); + + useKeybindings( + { + 'tabs:next': () => handleTabChange(1), + 'tabs:previous': () => handleTabChange(-1), }, - "tabs:previous": () => { - handleTabChange(-1); - setHeaderFocused(true); + { + context: 'Tabs', + isActive: !hidden && !disableNavigation && headerFocused, + }, + ) + + // When the header is focused, down-arrow returns focus to content. Only + // active when the selected tab has opted in via useTabHeaderFocus() — + // legacy tabs have nowhere to return focus to. + const handleKeyDown = (e: KeyboardEvent) => { + if (!headerFocused || !optedIn || hidden) return + if (e.key === 'down') { + e.preventDefault() + setHeaderFocused(false) } - }, t10); - const titleWidth = title ? stringWidth(title) + 1 : 0; - const tabsWidth = tabs.reduce(_temp4, 0); - const usedWidth = titleWidth + tabsWidth; - const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0; - const contentWidth = useFullWidth ? terminalWidth : undefined; - const T0 = Box; - const t11 = "column"; - const t12 = 0; - const t13 = true; - const t14 = modalScrollRef ? 0 : undefined; - const t15 = !hidden && {title !== undefined && {title}}{tabs.map((t16, i) => { - const [id, title_0] = t16; - const isCurrent = selectedTabIndex === i; - const hasColorCursor = color && isCurrent && headerFocused; - return {" "}{title_0}{" "}; - })}{spacerWidth > 0 && {" ".repeat(spacerWidth)}}; - let t17; - if ($[11] !== children || $[12] !== contentHeight || $[13] !== contentWidth || $[14] !== hidden || $[15] !== modalScrollRef || $[16] !== selectedTabIndex) { - t17 = modalScrollRef ? {children} : {children}; - $[11] = children; - $[12] = contentHeight; - $[13] = contentWidth; - $[14] = hidden; - $[15] = modalScrollRef; - $[16] = selectedTabIndex; - $[17] = t17; - } else { - t17 = $[17]; } - let t18; - if ($[18] !== T0 || $[19] !== banner || $[20] !== handleKeyDown || $[21] !== t14 || $[22] !== t15 || $[23] !== t17) { - t18 = {t15}{banner}{t17}; - $[18] = T0; - $[19] = banner; - $[20] = handleKeyDown; - $[21] = t14; - $[22] = t15; - $[23] = t17; - $[24] = t18; - } else { - t18 = $[24]; - } - return {t18}; -} -function _temp4(sum, t0) { - const [, tabTitle] = t0; - return sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1; -} -function _temp3(n_0) { - return n_0 - 1; -} -function _temp2(n) { - return n + 1; -} -function _temp(child) { - return [child.props.id ?? child.props.title, child.props.title]; + + // Opt-in: same tabs:next/previous actions, active from content. Focuses + // the header so subsequent presses cycle via the handler above. + useKeybindings( + { + 'tabs:next': () => { + handleTabChange(1) + setHeaderFocused(true) + }, + 'tabs:previous': () => { + handleTabChange(-1) + setHeaderFocused(true) + }, + }, + { + context: 'Tabs', + isActive: + navFromContent && + !headerFocused && + optedIn && + !hidden && + !disableNavigation, + }, + ) + + // Calculate spacing to fill the available width. No keyboard hint in the + // header row — content footers own hints (see useTabHeaderFocus docs). + const titleWidth = title ? stringWidth(title) + 1 : 0 // +1 for gap + const tabsWidth = tabs.reduce( + (sum, [, tabTitle]) => sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1, // +2 for padding, +1 for gap + 0, + ) + const usedWidth = titleWidth + tabsWidth + const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0 + + const contentWidth = useFullWidth ? terminalWidth : undefined + + return ( + + + {!hidden && ( + + {title !== undefined && ( + + {title} + + )} + {tabs.map(([id, title], i) => { + const isCurrent = selectedTabIndex === i + const hasColorCursor = color && isCurrent && headerFocused + return ( + + {' '} + {title}{' '} + + ) + })} + {spacerWidth > 0 && {' '.repeat(spacerWidth)}} + + )} + {banner} + {modalScrollRef ? ( + // Inside the modal slot: own the ScrollBox here so the tabs + // header row above sits OUTSIDE the scroll area — it can never + // scroll off. The ref reaches REPL's ScrollKeybindingHandler via + // ModalContext. Keyed by selectedTabIndex → remounts on tab + // switch, resetting scrollTop to 0 without scrollTo() timing games. + + + {children} + + + ) : ( + + {children} + + )} + + + ) } + type TabProps = { - title: string; - id?: string; - children: React.ReactNode; -}; -export function Tab(t0) { - const $ = _c(4); - const { - title, - id, - children - } = t0; - const { - selectedTab, - width - } = useContext(TabsContext); - const insideModal = useIsInsideModal(); - if (selectedTab !== (id ?? title)) { - return null; - } - const t1 = insideModal ? 0 : undefined; - let t2; - if ($[0] !== children || $[1] !== t1 || $[2] !== width) { - t2 = {children}; - $[0] = children; - $[1] = t1; - $[2] = width; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; + title: string + id?: string + children: React.ReactNode } -export function useTabsWidth() { - const { - width - } = useContext(TabsContext); - return width; + +export function Tab({ title, id, children }: TabProps): React.ReactNode { + const { selectedTab, width } = useContext(TabsContext) + const insideModal = useIsInsideModal() + if (selectedTab !== (id ?? title)) { + return null + } + + return ( + + {children} + + ) +} + +export function useTabsWidth(): number | undefined { + const { width } = useContext(TabsContext) + return width } /** @@ -304,36 +327,13 @@ export function useTabsWidth() { * no onUpFromFirstItem to recover. Split the component so the hook only runs * when the Select renders. */ -export function useTabHeaderFocus() { - const $ = _c(6); - const { - headerFocused, - focusHeader, - blurHeader, - registerOptIn - } = useContext(TabsContext); - let t0; - if ($[0] !== registerOptIn) { - t0 = [registerOptIn]; - $[0] = registerOptIn; - $[1] = t0; - } else { - t0 = $[1]; - } - useEffect(registerOptIn, t0); - let t1; - if ($[2] !== blurHeader || $[3] !== focusHeader || $[4] !== headerFocused) { - t1 = { - headerFocused, - focusHeader, - blurHeader - }; - $[2] = blurHeader; - $[3] = focusHeader; - $[4] = headerFocused; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; +export function useTabHeaderFocus(): { + headerFocused: boolean + focusHeader: () => void + blurHeader: () => void +} { + const { headerFocused, focusHeader, blurHeader, registerOptIn } = + useContext(TabsContext) + useEffect(registerOptIn, [registerOptIn]) + return { headerFocused, focusHeader, blurHeader } } diff --git a/src/components/design-system/ThemeProvider.tsx b/src/components/design-system/ThemeProvider.tsx index 373f73072..ef60d23a1 100644 --- a/src/components/design-system/ThemeProvider.tsx +++ b/src/components/design-system/ThemeProvider.tsx @@ -1,169 +1,160 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import useStdin from '../../ink/hooks/use-stdin.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { getSystemThemeName, type SystemTheme } from '../../utils/systemTheme.js'; -import type { ThemeName, ThemeSetting } from '../../utils/theme.js'; +import { feature } from 'bun:bundle' +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import useStdin from '../../ink/hooks/use-stdin.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { + getSystemThemeName, + type SystemTheme, +} from '../../utils/systemTheme.js' +import type { ThemeName, ThemeSetting } from '../../utils/theme.js' + type ThemeContextValue = { /** The saved user preference. May be 'auto'. */ - themeSetting: ThemeSetting; - setThemeSetting: (setting: ThemeSetting) => void; - setPreviewTheme: (setting: ThemeSetting) => void; - savePreview: () => void; - cancelPreview: () => void; + themeSetting: ThemeSetting + setThemeSetting: (setting: ThemeSetting) => void + setPreviewTheme: (setting: ThemeSetting) => void + savePreview: () => void + cancelPreview: () => void /** The resolved theme to render with. Never 'auto'. */ - currentTheme: ThemeName; -}; + currentTheme: ThemeName +} // Non-'auto' default so useTheme() works without a provider (tests, tooling). -const DEFAULT_THEME: ThemeName = 'dark'; +const DEFAULT_THEME: ThemeName = 'dark' + const ThemeContext = createContext({ themeSetting: DEFAULT_THEME, setThemeSetting: () => {}, setPreviewTheme: () => {}, savePreview: () => {}, cancelPreview: () => {}, - currentTheme: DEFAULT_THEME -}); + currentTheme: DEFAULT_THEME, +}) + type Props = { - children: React.ReactNode; - initialState?: ThemeSetting; - onThemeSave?: (setting: ThemeSetting) => void; -}; + children: React.ReactNode + initialState?: ThemeSetting + onThemeSave?: (setting: ThemeSetting) => void +} + function defaultInitialTheme(): ThemeSetting { - return getGlobalConfig().theme; + return getGlobalConfig().theme } + function defaultSaveTheme(setting: ThemeSetting): void { - saveGlobalConfig(current => ({ - ...current, - theme: setting - })); + saveGlobalConfig(current => ({ ...current, theme: setting })) } + export function ThemeProvider({ children, initialState, - onThemeSave = defaultSaveTheme + onThemeSave = defaultSaveTheme, }: Props) { - const [themeSetting, setThemeSetting] = useState(initialState ?? defaultInitialTheme); - const [previewTheme, setPreviewTheme] = useState(null); + const [themeSetting, setThemeSetting] = useState( + initialState ?? defaultInitialTheme, + ) + const [previewTheme, setPreviewTheme] = useState(null) // Track terminal theme for 'auto' resolution. Seeds from $COLORFGBG (or // 'dark' if unset); the OSC 11 watcher corrects it on first poll. - const [systemTheme, setSystemTheme] = useState(() => (initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark'); + const [systemTheme, setSystemTheme] = useState(() => + (initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark', + ) // The setting currently in effect (preview wins while picker is open) - const activeSetting = previewTheme ?? themeSetting; - const { - internal_querier - } = useStdin(); + const activeSetting = previewTheme ?? themeSetting + + const { internal_querier } = useStdin() // Watch for live terminal theme changes while 'auto' is active. // Positive feature() pattern so the watcher import is dead-code-eliminated // in external builds. useEffect(() => { if (feature('AUTO_THEME')) { - if (activeSetting !== 'auto' || !internal_querier) return; - let cleanup: (() => void) | undefined; - let cancelled = false; - void import('../../utils/systemThemeWatcher.js').then(({ - watchSystemTheme - }) => { - if (cancelled) return; - cleanup = watchSystemTheme(internal_querier, setSystemTheme); - }); + if (activeSetting !== 'auto' || !internal_querier) return + let cleanup: (() => void) | undefined + let cancelled = false + void import('../../utils/systemThemeWatcher.js').then( + ({ watchSystemTheme }) => { + if (cancelled) return + cleanup = watchSystemTheme(internal_querier, setSystemTheme) + }, + ) return () => { - cancelled = true; - cleanup?.(); - }; + cancelled = true + cleanup?.() + } } - }, [activeSetting, internal_querier]); - const currentTheme: ThemeName = activeSetting === 'auto' ? systemTheme : activeSetting; - const value = useMemo(() => ({ - themeSetting, - setThemeSetting: (newSetting: ThemeSetting) => { - setThemeSetting(newSetting); - setPreviewTheme(null); - // Switching to 'auto' restarts the watcher (activeSetting dep), whose - // first poll fires immediately. Seed from the cache so the OSC - // round-trip doesn't flash the wrong palette. - if (newSetting === 'auto') { - setSystemTheme(getSystemThemeName()); - } - onThemeSave?.(newSetting); - }, - setPreviewTheme: (newSetting_0: ThemeSetting) => { - setPreviewTheme(newSetting_0); - if (newSetting_0 === 'auto') { - setSystemTheme(getSystemThemeName()); - } - }, - savePreview: () => { - if (previewTheme !== null) { - setThemeSetting(previewTheme); - setPreviewTheme(null); - onThemeSave?.(previewTheme); - } - }, - cancelPreview: () => { - if (previewTheme !== null) { - setPreviewTheme(null); - } - }, - currentTheme - }), [themeSetting, previewTheme, currentTheme, onThemeSave]); - return {children}; + }, [activeSetting, internal_querier]) + + const currentTheme: ThemeName = + activeSetting === 'auto' ? systemTheme : activeSetting + + const value = useMemo( + () => ({ + themeSetting, + setThemeSetting: (newSetting: ThemeSetting) => { + setThemeSetting(newSetting) + setPreviewTheme(null) + // Switching to 'auto' restarts the watcher (activeSetting dep), whose + // first poll fires immediately. Seed from the cache so the OSC + // round-trip doesn't flash the wrong palette. + if (newSetting === 'auto') { + setSystemTheme(getSystemThemeName()) + } + onThemeSave?.(newSetting) + }, + setPreviewTheme: (newSetting: ThemeSetting) => { + setPreviewTheme(newSetting) + if (newSetting === 'auto') { + setSystemTheme(getSystemThemeName()) + } + }, + savePreview: () => { + if (previewTheme !== null) { + setThemeSetting(previewTheme) + setPreviewTheme(null) + onThemeSave?.(previewTheme) + } + }, + cancelPreview: () => { + if (previewTheme !== null) { + setPreviewTheme(null) + } + }, + currentTheme, + }), + [themeSetting, previewTheme, currentTheme, onThemeSave], + ) + + return {children} } /** * Returns the resolved theme for rendering (never 'auto') and a setter that * accepts any ThemeSetting (including 'auto'). */ -export function useTheme() { - const $ = _c(3); - const { - currentTheme, - setThemeSetting - } = useContext(ThemeContext); - let t0; - if ($[0] !== currentTheme || $[1] !== setThemeSetting) { - t0 = [currentTheme, setThemeSetting]; - $[0] = currentTheme; - $[1] = setThemeSetting; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; +export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] { + const { currentTheme, setThemeSetting } = useContext(ThemeContext) + return [currentTheme, setThemeSetting] } /** * Returns the raw theme setting as stored in config. Use this in UI that * needs to show 'auto' as a distinct choice (e.g., ThemePicker). */ -export function useThemeSetting() { - return useContext(ThemeContext).themeSetting; +export function useThemeSetting(): ThemeSetting { + return useContext(ThemeContext).themeSetting } + export function usePreviewTheme() { - const $ = _c(4); - const { - setPreviewTheme, - savePreview, - cancelPreview - } = useContext(ThemeContext); - let t0; - if ($[0] !== cancelPreview || $[1] !== savePreview || $[2] !== setPreviewTheme) { - t0 = { - setPreviewTheme, - savePreview, - cancelPreview - }; - $[0] = cancelPreview; - $[1] = savePreview; - $[2] = setPreviewTheme; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + const { setPreviewTheme, savePreview, cancelPreview } = + useContext(ThemeContext) + return { setPreviewTheme, savePreview, cancelPreview } } diff --git a/src/components/design-system/ThemedBox.tsx b/src/components/design-system/ThemedBox.tsx index 0b56f18a6..10fbe9137 100644 --- a/src/components/design-system/ThemedBox.tsx +++ b/src/components/design-system/ThemedBox.tsx @@ -1,155 +1,112 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type PropsWithChildren, type Ref } from 'react'; -import Box from '../../ink/components/Box.js'; -import type { DOMElement } from '../../ink/dom.js'; -import type { ClickEvent } from '../../ink/events/click-event.js'; -import type { FocusEvent } from '../../ink/events/focus-event.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import type { Color, Styles } from '../../ink/styles.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { useTheme } from './ThemeProvider.js'; +import React, { type PropsWithChildren, type Ref } from 'react' +import Box from '../../ink/components/Box.js' +import type { DOMElement } from '../../ink/dom.js' +import type { ClickEvent } from '../../ink/events/click-event.js' +import type { FocusEvent } from '../../ink/events/focus-event.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import type { Color, Styles } from '../../ink/styles.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { useTheme } from './ThemeProvider.js' // Color props that accept theme keys type ThemedColorProps = { - readonly borderColor?: keyof Theme | Color; - readonly borderTopColor?: keyof Theme | Color; - readonly borderBottomColor?: keyof Theme | Color; - readonly borderLeftColor?: keyof Theme | Color; - readonly borderRightColor?: keyof Theme | Color; - readonly backgroundColor?: keyof Theme | Color; -}; + readonly borderColor?: keyof Theme | Color + readonly borderTopColor?: keyof Theme | Color + readonly borderBottomColor?: keyof Theme | Color + readonly borderLeftColor?: keyof Theme | Color + readonly borderRightColor?: keyof Theme | Color + readonly backgroundColor?: keyof Theme | Color +} // Base Styles without color props (they'll be overridden) -type BaseStylesWithoutColors = Omit; -export type Props = BaseStylesWithoutColors & ThemedColorProps & { - ref?: Ref; - tabIndex?: number; - autoFocus?: boolean; - onClick?: (event: ClickEvent) => void; - onFocus?: (event: FocusEvent) => void; - onFocusCapture?: (event: FocusEvent) => void; - onBlur?: (event: FocusEvent) => void; - onBlurCapture?: (event: FocusEvent) => void; - onKeyDown?: (event: KeyboardEvent) => void; - onKeyDownCapture?: (event: KeyboardEvent) => void; - onMouseEnter?: () => void; - onMouseLeave?: () => void; -}; +type BaseStylesWithoutColors = Omit< + Styles, + | 'textWrap' + | 'borderColor' + | 'borderTopColor' + | 'borderBottomColor' + | 'borderLeftColor' + | 'borderRightColor' + | 'backgroundColor' +> + +export type Props = BaseStylesWithoutColors & + ThemedColorProps & { + ref?: Ref + tabIndex?: number + autoFocus?: boolean + onClick?: (event: ClickEvent) => void + onFocus?: (event: FocusEvent) => void + onFocusCapture?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void + onKeyDown?: (event: KeyboardEvent) => void + onKeyDownCapture?: (event: KeyboardEvent) => void + onMouseEnter?: () => void + onMouseLeave?: () => void + } /** * Resolves a color value that may be a theme key to a raw Color. */ -function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined { - if (!color) return undefined; +function resolveColor( + color: keyof Theme | Color | undefined, + theme: Theme, +): Color | undefined { + if (!color) return undefined // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) { - return color as Color; + if ( + color.startsWith('rgb(') || + color.startsWith('#') || + color.startsWith('ansi256(') || + color.startsWith('ansi:') + ) { + return color as Color } // It's a theme key - resolve it - return theme[color as keyof Theme] as Color; + return theme[color as keyof Theme] as Color } /** * Theme-aware Box component that resolves theme color keys to raw colors. * This wraps the base Box component with theme resolution for border colors. */ -function ThemedBox(t0) { - const $ = _c(33); - let backgroundColor; - let borderBottomColor; - let borderColor; - let borderLeftColor; - let borderRightColor; - let borderTopColor; - let children; - let ref; - let rest; - if ($[0] !== t0) { - ({ - borderColor, - borderTopColor, - borderBottomColor, - borderLeftColor, - borderRightColor, - backgroundColor, - children, - ref, - ...rest - } = t0); - $[0] = t0; - $[1] = backgroundColor; - $[2] = borderBottomColor; - $[3] = borderColor; - $[4] = borderLeftColor; - $[5] = borderRightColor; - $[6] = borderTopColor; - $[7] = children; - $[8] = ref; - $[9] = rest; - } else { - backgroundColor = $[1]; - borderBottomColor = $[2]; - borderColor = $[3]; - borderLeftColor = $[4]; - borderRightColor = $[5]; - borderTopColor = $[6]; - children = $[7]; - ref = $[8]; - rest = $[9]; - } - const [themeName] = useTheme(); - let resolvedBorderBottomColor; - let resolvedBorderColor; - let resolvedBorderLeftColor; - let resolvedBorderRightColor; - let resolvedBorderTopColor; - let t1; - if ($[10] !== backgroundColor || $[11] !== borderBottomColor || $[12] !== borderColor || $[13] !== borderLeftColor || $[14] !== borderRightColor || $[15] !== borderTopColor || $[16] !== themeName) { - const theme = getTheme(themeName); - resolvedBorderColor = resolveColor(borderColor, theme); - resolvedBorderTopColor = resolveColor(borderTopColor, theme); - resolvedBorderBottomColor = resolveColor(borderBottomColor, theme); - resolvedBorderLeftColor = resolveColor(borderLeftColor, theme); - resolvedBorderRightColor = resolveColor(borderRightColor, theme); - t1 = resolveColor(backgroundColor, theme); - $[10] = backgroundColor; - $[11] = borderBottomColor; - $[12] = borderColor; - $[13] = borderLeftColor; - $[14] = borderRightColor; - $[15] = borderTopColor; - $[16] = themeName; - $[17] = resolvedBorderBottomColor; - $[18] = resolvedBorderColor; - $[19] = resolvedBorderLeftColor; - $[20] = resolvedBorderRightColor; - $[21] = resolvedBorderTopColor; - $[22] = t1; - } else { - resolvedBorderBottomColor = $[17]; - resolvedBorderColor = $[18]; - resolvedBorderLeftColor = $[19]; - resolvedBorderRightColor = $[20]; - resolvedBorderTopColor = $[21]; - t1 = $[22]; - } - const resolvedBackgroundColor = t1; - let t2; - if ($[23] !== children || $[24] !== ref || $[25] !== resolvedBackgroundColor || $[26] !== resolvedBorderBottomColor || $[27] !== resolvedBorderColor || $[28] !== resolvedBorderLeftColor || $[29] !== resolvedBorderRightColor || $[30] !== resolvedBorderTopColor || $[31] !== rest) { - t2 = {children}; - $[23] = children; - $[24] = ref; - $[25] = resolvedBackgroundColor; - $[26] = resolvedBorderBottomColor; - $[27] = resolvedBorderColor; - $[28] = resolvedBorderLeftColor; - $[29] = resolvedBorderRightColor; - $[30] = resolvedBorderTopColor; - $[31] = rest; - $[32] = t2; - } else { - t2 = $[32]; - } - return t2; +function ThemedBox({ + borderColor, + borderTopColor, + borderBottomColor, + borderLeftColor, + borderRightColor, + backgroundColor, + children, + ref, + ...rest +}: PropsWithChildren): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + + // Resolve theme keys to raw colors + const resolvedBorderColor = resolveColor(borderColor, theme) + const resolvedBorderTopColor = resolveColor(borderTopColor, theme) + const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme) + const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme) + const resolvedBorderRightColor = resolveColor(borderRightColor, theme) + const resolvedBackgroundColor = resolveColor(backgroundColor, theme) + + return ( + + {children} + + ) } -export default ThemedBox; + +export default ThemedBox diff --git a/src/components/design-system/ThemedText.tsx b/src/components/design-system/ThemedText.tsx index abaa68f23..3c32b8bc3 100644 --- a/src/components/design-system/ThemedText.tsx +++ b/src/components/design-system/ThemedText.tsx @@ -1,123 +1,132 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React, { useContext } from 'react'; -import Text from '../../ink/components/Text.js'; -import type { Color, Styles } from '../../ink/styles.js'; -import { getTheme, type Theme } from '../../utils/theme.js'; -import { useTheme } from './ThemeProvider.js'; +import type { ReactNode } from 'react' +import React, { useContext } from 'react' +import Text from '../../ink/components/Text.js' +import type { Color, Styles } from '../../ink/styles.js' +import { getTheme, type Theme } from '../../utils/theme.js' +import { useTheme } from './ThemeProvider.js' /** Colors uncolored ThemedText in the subtree. Precedence: explicit `color` > * this > dimColor. Crosses Box boundaries (Ink's style cascade doesn't). */ -export const TextHoverColorContext = React.createContext(undefined); +export const TextHoverColorContext = React.createContext< + keyof Theme | undefined +>(undefined) + export type Props = { /** * Change text color. Accepts a theme key or raw color value. */ - readonly color?: keyof Theme | Color; + readonly color?: keyof Theme | Color /** * Same as `color`, but for background. Must be a theme key. */ - readonly backgroundColor?: keyof Theme; + readonly backgroundColor?: keyof Theme /** * Dim the color using the theme's inactive color. * This is compatible with bold (unlike ANSI dim). */ - readonly dimColor?: boolean; + readonly dimColor?: boolean /** * Make the text bold. */ - readonly bold?: boolean; + readonly bold?: boolean /** * Make the text italic. */ - readonly italic?: boolean; + readonly italic?: boolean /** * Make the text underlined. */ - readonly underline?: boolean; + readonly underline?: boolean /** * Make the text crossed with a line. */ - readonly strikethrough?: boolean; + readonly strikethrough?: boolean /** * Inverse background and foreground colors. */ - readonly inverse?: boolean; + readonly inverse?: boolean /** * This property tells Ink to wrap or truncate text if its width is larger than container. * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines. * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off. */ - readonly wrap?: Styles['textWrap']; - readonly children?: ReactNode; -}; + readonly wrap?: Styles['textWrap'] + + readonly children?: ReactNode +} /** * Resolves a color value that may be a theme key to a raw Color. */ -function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined { - if (!color) return undefined; +function resolveColor( + color: keyof Theme | Color | undefined, + theme: Theme, +): Color | undefined { + if (!color) return undefined // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) { - return color as Color; + if ( + color.startsWith('rgb(') || + color.startsWith('#') || + color.startsWith('ansi256(') || + color.startsWith('ansi:') + ) { + return color as Color } // It's a theme key - resolve it - return theme[color as keyof Theme] as Color; + return theme[color as keyof Theme] as Color } /** * Theme-aware Text component that resolves theme color keys to raw colors. * This wraps the base Text component with theme resolution. */ -export default function ThemedText(t0) { - const $ = _c(10); - const { - color, - backgroundColor, - dimColor: t1, - bold: t2, - italic: t3, - underline: t4, - strikethrough: t5, - inverse: t6, - wrap: t7, - children - } = t0; - const dimColor = t1 === undefined ? false : t1; - const bold = t2 === undefined ? false : t2; - const italic = t3 === undefined ? false : t3; - const underline = t4 === undefined ? false : t4; - const strikethrough = t5 === undefined ? false : t5; - const inverse = t6 === undefined ? false : t6; - const wrap = t7 === undefined ? "wrap" : t7; - const [themeName] = useTheme(); - const theme = getTheme(themeName); - const hoverColor = useContext(TextHoverColorContext); - const resolvedColor = !color && hoverColor ? resolveColor(hoverColor, theme) : dimColor ? theme.inactive as Color : resolveColor(color, theme); - const resolvedBackgroundColor = backgroundColor ? theme[backgroundColor] as Color : undefined; - let t8; - if ($[0] !== bold || $[1] !== children || $[2] !== inverse || $[3] !== italic || $[4] !== resolvedBackgroundColor || $[5] !== resolvedColor || $[6] !== strikethrough || $[7] !== underline || $[8] !== wrap) { - t8 = {children}; - $[0] = bold; - $[1] = children; - $[2] = inverse; - $[3] = italic; - $[4] = resolvedBackgroundColor; - $[5] = resolvedColor; - $[6] = strikethrough; - $[7] = underline; - $[8] = wrap; - $[9] = t8; - } else { - t8 = $[9]; - } - return t8; +export default function ThemedText({ + color, + backgroundColor, + dimColor = false, + bold = false, + italic = false, + underline = false, + strikethrough = false, + inverse = false, + wrap = 'wrap', + children, +}: Props): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + const hoverColor = useContext(TextHoverColorContext) + + // Resolve theme keys to raw colors + const resolvedColor = + !color && hoverColor + ? resolveColor(hoverColor, theme) + : dimColor + ? (theme.inactive as Color) + : resolveColor(color, theme) + const resolvedBackgroundColor = backgroundColor + ? (theme[backgroundColor] as Color) + : undefined + + return ( + + {children} + + ) } diff --git a/src/components/skills/SkillsMenu.tsx b/src/components/skills/SkillsMenu.tsx index df78c1af4..f8f2896a6 100644 --- a/src/components/skills/SkillsMenu.tsx +++ b/src/components/skills/SkillsMenu.tsx @@ -1,236 +1,205 @@ -import { c as _c } from "react/compiler-runtime"; -import capitalize from 'lodash-es/capitalize.js'; -import * as React from 'react'; -import { useMemo } from 'react'; -import { type Command, type CommandBase, type CommandResultDisplay, getCommandName, type PromptCommand } from '../../commands.js'; -import { Box, Text } from '../../ink.js'; -import { estimateSkillFrontmatterTokens, getSkillsPath } from '../../skills/loadSkillsDir.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatTokens } from '../../utils/format.js'; -import { getSettingSourceName, type SettingSource } from '../../utils/settings/constants.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Dialog } from '../design-system/Dialog.js'; +import capitalize from 'lodash-es/capitalize.js' +import * as React from 'react' +import { useMemo } from 'react' +import { + type Command, + type CommandBase, + type CommandResultDisplay, + getCommandName, + type PromptCommand, +} from '../../commands.js' +import { Box, Text } from '../../ink.js' +import { + estimateSkillFrontmatterTokens, + getSkillsPath, +} from '../../skills/loadSkillsDir.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatTokens } from '../../utils/format.js' +import { + getSettingSourceName, + type SettingSource, +} from '../../utils/settings/constants.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Dialog } from '../design-system/Dialog.js' // Skills are always PromptCommands with CommandBase properties -type SkillCommand = CommandBase & PromptCommand; -type SkillSource = SettingSource | 'plugin' | 'mcp'; +type SkillCommand = CommandBase & PromptCommand + +type SkillSource = SettingSource | 'plugin' | 'mcp' + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - commands: Command[]; -}; + onExit: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + commands: Command[] +} + function getSourceTitle(source: SkillSource): string { if (source === 'plugin') { - return 'Plugin skills'; + return 'Plugin skills' } if (source === 'mcp') { - return 'MCP skills'; + return 'MCP skills' } - return `${capitalize(getSettingSourceName(source))} skills`; + return `${capitalize(getSettingSourceName(source))} skills` } -function getSourceSubtitle(source: SkillSource, skills: SkillCommand[]): string | undefined { + +function getSourceSubtitle( + source: SkillSource, + skills: SkillCommand[], +): string | undefined { // MCP skills show server names; file-based skills show filesystem paths. // Skill names are `:`, not `mcp____…`. if (source === 'mcp') { - const servers = [...new Set(skills.map(s => { - const idx = s.name.indexOf(':'); - return idx > 0 ? s.name.slice(0, idx) : null; - }).filter((n): n is string => n != null))]; - return servers.length > 0 ? servers.join(', ') : undefined; + const servers = [ + ...new Set( + skills + .map(s => { + const idx = s.name.indexOf(':') + return idx > 0 ? s.name.slice(0, idx) : null + }) + .filter((n): n is string => n != null), + ), + ] + return servers.length > 0 ? servers.join(', ') : undefined } - const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')); - const hasCommandsSkills = skills.some(s => s.loadedFrom === 'commands_DEPRECATED'); - return hasCommandsSkills ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` : skillsPath; + const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')) + const hasCommandsSkills = skills.some( + s => s.loadedFrom === 'commands_DEPRECATED', + ) + return hasCommandsSkills + ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` + : skillsPath } -export function SkillsMenu(t0) { - const $ = _c(35); - const { - onExit, - commands - } = t0; - let t1; - if ($[0] !== commands) { - t1 = commands.filter(_temp); - $[0] = commands; - $[1] = t1; - } else { - t1 = $[1]; - } - const skills = t1; - let groups; - if ($[2] !== skills) { - groups = { + +export function SkillsMenu({ onExit, commands }: Props): React.ReactNode { + // Filter commands for skills and cast to SkillCommand + const skills = useMemo(() => { + return commands.filter( + (cmd): cmd is SkillCommand => + cmd.type === 'prompt' && + (cmd.loadedFrom === 'skills' || + cmd.loadedFrom === 'commands_DEPRECATED' || + cmd.loadedFrom === 'plugin' || + cmd.loadedFrom === 'mcp'), + ) + }, [commands]) + + const skillsBySource = useMemo((): Record => { + const groups: Record = { policySettings: [], userSettings: [], projectSettings: [], localSettings: [], flagSettings: [], plugin: [], - mcp: [] - }; + mcp: [], + } + for (const skill of skills) { - const source = skill.source as SkillSource; + const source = skill.source as SkillSource if (source in groups) { - groups[source].push(skill); + groups[source].push(skill) } } + for (const group of Object.values(groups)) { - (group as Array<{ name: string }>).sort(_temp2); + group.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b))) } - $[2] = skills; - $[3] = groups; - } else { - groups = $[3]; + + return groups + }, [skills]) + + const handleCancel = (): void => { + onExit('Skills dialog dismissed', { display: 'system' }) } - const skillsBySource = groups; - let t2; - if ($[4] !== onExit) { - t2 = () => { - onExit("Skills dialog dismissed", { - display: "system" - }); - }; - $[4] = onExit; - $[5] = t2; - } else { - t2 = $[5]; - } - const handleCancel = t2; + if (skills.length === 0) { - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Create skills in .claude/skills/ or ~/.claude/skills/; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== handleCancel) { - t5 = {t3}{t4}; - $[8] = handleCancel; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + return ( + + + Create skills in .claude/skills/ or ~/.claude/skills/ + + + + + + ) } - const renderSkill = _temp3; - let t3; - if ($[10] !== skillsBySource) { - t3 = source_0 => { - const groupSkills = skillsBySource[source_0]; - if (groupSkills.length === 0) { - return null; - } - const title = getSourceTitle(source_0); - const subtitle = getSourceSubtitle(source_0, groupSkills); - return {title}{subtitle && ({subtitle})}{groupSkills.map(skill_1 => renderSkill(skill_1))}; - }; - $[10] = skillsBySource; - $[11] = t3; - } else { - t3 = $[11]; + + const renderSkill = (skill: SkillCommand) => { + const estimatedTokens = estimateSkillFrontmatterTokens(skill) + const tokenDisplay = `~${formatTokens(estimatedTokens)}` + const pluginName = + skill.source === 'plugin' + ? skill.pluginInfo?.pluginManifest.name + : undefined + + return ( + + {getCommandName(skill)} + + {pluginName ? ` · ${pluginName}` : ''} · {tokenDisplay} description + tokens + + + ) } - const renderSkillGroup = t3; - const t4 = skills.length; - let t5; - if ($[12] !== skills.length) { - t5 = plural(skills.length, "skill"); - $[12] = skills.length; - $[13] = t5; - } else { - t5 = $[13]; + + const renderSkillGroup = (source: SkillSource) => { + const groupSkills = skillsBySource[source] + if (groupSkills.length === 0) return null + + const title = getSourceTitle(source) + const subtitle = getSourceSubtitle(source, groupSkills) + + return ( + + + + {title} + + {subtitle && ({subtitle})} + + {groupSkills.map(skill => renderSkill(skill))} + + ) } - const t6 = `${t4} ${t5}`; - let t7; - if ($[14] !== renderSkillGroup) { - t7 = renderSkillGroup("projectSettings"); - $[14] = renderSkillGroup; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== renderSkillGroup) { - t8 = renderSkillGroup("userSettings"); - $[16] = renderSkillGroup; - $[17] = t8; - } else { - t8 = $[17]; - } - let t9; - if ($[18] !== renderSkillGroup) { - t9 = renderSkillGroup("policySettings"); - $[18] = renderSkillGroup; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== renderSkillGroup) { - t10 = renderSkillGroup("plugin"); - $[20] = renderSkillGroup; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== renderSkillGroup) { - t11 = renderSkillGroup("mcp"); - $[22] = renderSkillGroup; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== t10 || $[25] !== t11 || $[26] !== t7 || $[27] !== t8 || $[28] !== t9) { - t12 = {t7}{t8}{t9}{t10}{t11}; - $[24] = t10; - $[25] = t11; - $[26] = t7; - $[27] = t8; - $[28] = t9; - $[29] = t12; - } else { - t12 = $[29]; - } - let t13; - if ($[30] === Symbol.for("react.memo_cache_sentinel")) { - t13 = ; - $[30] = t13; - } else { - t13 = $[30]; - } - let t14; - if ($[31] !== handleCancel || $[32] !== t12 || $[33] !== t6) { - t14 = {t12}{t13}; - $[31] = handleCancel; - $[32] = t12; - $[33] = t6; - $[34] = t14; - } else { - t14 = $[34]; - } - return t14; -} -function _temp3(skill_0) { - const estimatedTokens = estimateSkillFrontmatterTokens(skill_0); - const tokenDisplay = `~${formatTokens(estimatedTokens)}`; - const pluginName = skill_0.source === "plugin" ? skill_0.pluginInfo?.pluginManifest.name : undefined; - return {getCommandName(skill_0)}{pluginName ? ` · ${pluginName}` : ""} · {tokenDisplay} description tokens; -} -function _temp2(a, b) { - return getCommandName(a).localeCompare(getCommandName(b)); -} -function _temp(cmd) { - return cmd.type === "prompt" && (cmd.loadedFrom === "skills" || cmd.loadedFrom === "commands_DEPRECATED" || cmd.loadedFrom === "plugin" || cmd.loadedFrom === "mcp"); + + return ( + + + {renderSkillGroup('projectSettings')} + {renderSkillGroup('userSettings')} + {renderSkillGroup('policySettings')} + {renderSkillGroup('plugin')} + {renderSkillGroup('mcp')} + + + + + + ) } diff --git a/src/components/tasks/AsyncAgentDetailDialog.tsx b/src/components/tasks/AsyncAgentDetailDialog.tsx index a942c105e..4174d4fa5 100644 --- a/src/components/tasks/AsyncAgentDetailDialog.tsx +++ b/src/components/tasks/AsyncAgentDetailDialog.tsx @@ -1,228 +1,200 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { getEmptyToolPermissionContext } from '../../Tool.js'; -import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { getTools } from '../../tools.js'; -import { formatNumber } from '../../utils/format.js'; -import { extractTag } from '../../utils/messages.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { UserPlanMessage } from '../messages/UserPlanMessage.js'; -import { renderToolActivity } from './renderToolActivity.js'; -import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js'; +import React, { useMemo } from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { getTools } from '../../tools.js' +import { formatNumber } from '../../utils/format.js' +import { extractTag } from '../../utils/messages.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { UserPlanMessage } from '../messages/UserPlanMessage.js' +import { renderToolActivity } from './renderToolActivity.js' +import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js' + type Props = { - agent: DeepImmutable; - onDone: () => void; - onKillAgent?: () => void; - onBack?: () => void; -}; -export function AsyncAgentDetailDialog(t0) { - const $ = _c(54); - const { - agent, - onDone, - onKillAgent, - onBack - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTools(getEmptyToolPermissionContext()); - $[0] = t1; - } else { - t1 = $[0]; - } - const tools = t1; - const elapsedTime = useElapsedTime(agent.startTime, agent.status === "running", 1000, agent.totalPausedMs ?? 0); - let t2; - if ($[1] !== onDone) { - t2 = { - "confirm:yes": onDone - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybindings(t2, t3); - let t4; - if ($[4] !== agent.status || $[5] !== onBack || $[6] !== onDone || $[7] !== onKillAgent) { - t4 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && agent.status === "running" && onKillAgent) { - e.preventDefault(); - onKillAgent(); - } - } - } - }; - $[4] = agent.status; - $[5] = onBack; - $[6] = onDone; - $[7] = onKillAgent; - $[8] = t4; - } else { - t4 = $[8]; - } - const handleKeyDown = t4; - let t5; - if ($[9] !== agent.prompt) { - t5 = extractTag(agent.prompt, "plan"); - $[9] = agent.prompt; - $[10] = t5; - } else { - t5 = $[10]; - } - const planContent = t5; - const displayPrompt = agent.prompt.length > 300 ? agent.prompt.substring(0, 297) + "\u2026" : agent.prompt; - const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount; - const toolUseCount = agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount; - const t6 = agent.selectedAgent?.agentType ?? "agent"; - const t7 = agent.description || "Async agent"; - let t8; - if ($[11] !== t6 || $[12] !== t7) { - t8 = {t6} ›{" "}{t7}; - $[11] = t6; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - const title = t8; - let t9; - if ($[14] !== agent.status) { - t9 = agent.status !== "running" && {getTaskStatusIcon(agent.status)}{" "}{agent.status === "completed" ? "Completed" : agent.status === "failed" ? "Failed" : "Stopped"}{" \xB7 "}; - $[14] = agent.status; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== tokenCount) { - t10 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[16] = tokenCount; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== toolUseCount) { - t11 = toolUseCount !== undefined && toolUseCount > 0 && <>{" "}· {toolUseCount} {toolUseCount === 1 ? "tool" : "tools"}; - $[18] = toolUseCount; - $[19] = t11; - } else { - t11 = $[19]; - } - let t12; - if ($[20] !== elapsedTime || $[21] !== t10 || $[22] !== t11) { - t12 = {elapsedTime}{t10}{t11}; - $[20] = elapsedTime; - $[21] = t10; - $[22] = t11; - $[23] = t12; - } else { - t12 = $[23]; - } - let t13; - if ($[24] !== t12 || $[25] !== t9) { - t13 = {t9}{t12}; - $[24] = t12; - $[25] = t9; - $[26] = t13; - } else { - t13 = $[26]; - } - const subtitle = t13; - let t14; - if ($[27] !== agent.status || $[28] !== onBack || $[29] !== onKillAgent) { - t14 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{agent.status === "running" && onKillAgent && }; - $[27] = agent.status; - $[28] = onBack; - $[29] = onKillAgent; - $[30] = t14; - } else { - t14 = $[30]; - } - let t15; - if ($[31] !== agent.progress || $[32] !== agent.status || $[33] !== theme) { - t15 = agent.status === "running" && agent.progress?.recentActivities && agent.progress.recentActivities.length > 0 && Progress{agent.progress.recentActivities.map((activity, i) => {i === agent.progress.recentActivities.length - 1 ? "\u203A " : " "}{renderToolActivity(activity, tools, theme)})}; - $[31] = agent.progress; - $[32] = agent.status; - $[33] = theme; - $[34] = t15; - } else { - t15 = $[34]; - } - let t16; - if ($[35] !== displayPrompt || $[36] !== planContent) { - t16 = planContent ? : Prompt{displayPrompt}; - $[35] = displayPrompt; - $[36] = planContent; - $[37] = t16; - } else { - t16 = $[37]; - } - let t17; - if ($[38] !== agent.error || $[39] !== agent.status) { - t17 = agent.status === "failed" && agent.error && Error{agent.error}; - $[38] = agent.error; - $[39] = agent.status; - $[40] = t17; - } else { - t17 = $[40]; - } - let t18; - if ($[41] !== t15 || $[42] !== t16 || $[43] !== t17) { - t18 = {t15}{t16}{t17}; - $[41] = t15; - $[42] = t16; - $[43] = t17; - $[44] = t18; - } else { - t18 = $[44]; - } - let t19; - if ($[45] !== onDone || $[46] !== subtitle || $[47] !== t14 || $[48] !== t18 || $[49] !== title) { - t19 = {t18}; - $[45] = onDone; - $[46] = subtitle; - $[47] = t14; - $[48] = t18; - $[49] = title; - $[50] = t19; - } else { - t19 = $[50]; - } - let t20; - if ($[51] !== handleKeyDown || $[52] !== t19) { - t20 = {t19}; - $[51] = handleKeyDown; - $[52] = t19; - $[53] = t20; - } else { - t20 = $[53]; - } - return t20; + agent: DeepImmutable + onDone: () => void + onKillAgent?: () => void + onBack?: () => void +} + +export function AsyncAgentDetailDialog({ + agent, + onDone, + onKillAgent, + onBack, +}: Props): React.ReactNode { + const [theme] = useTheme() + + // Get tools for rendering activity messages + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + + const elapsedTime = useElapsedTime( + agent.startTime, + agent.status === 'running', + 1000, + agent.totalPausedMs ?? 0, + ) + + // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) + // internally but does NOT auto-wire confirm:yes. + useKeybindings( + { + 'confirm:yes': onDone, + }, + { context: 'Confirmation' }, + ) + + // Component-specific shortcuts shown in UI hints (x=stop) and + // navigation keys (space=dismiss, left=back). These are context-dependent + // actions tied to agent state, not standard dialog keybindings. + // Note: Dialog component already handles ESC via confirm:no keybinding; + // confirm:yes (Enter/y) is handled by useKeybindings above. + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && agent.status === 'running' && onKillAgent) { + e.preventDefault() + onKillAgent() + } + } + + // Extract plan from prompt - if present, we show the plan instead of the prompt + const planContent = extractTag(agent.prompt, 'plan') + + const displayPrompt = + agent.prompt.length > 300 + ? agent.prompt.substring(0, 297) + '…' + : agent.prompt + + // Get tokens and tool uses (from result if completed, otherwise from progress) + const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount + const toolUseCount = + agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount + + const title = ( + + {agent.selectedAgent?.agentType ?? 'agent'} ›{' '} + {agent.description || 'Async agent'} + + ) + + // Build subtitle with status and stats + const subtitle = ( + + {agent.status !== 'running' && ( + + {getTaskStatusIcon(agent.status)}{' '} + {agent.status === 'completed' + ? 'Completed' + : agent.status === 'failed' + ? 'Failed' + : 'Stopped'} + {' · '} + + )} + + {elapsedTime} + {tokenCount !== undefined && tokenCount > 0 && ( + <> · {formatNumber(tokenCount)} tokens + )} + {toolUseCount !== undefined && toolUseCount > 0 && ( + <> + {' '} + · {toolUseCount} {toolUseCount === 1 ? 'tool' : 'tools'} + + )} + + + ) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {agent.status === 'running' && onKillAgent && ( + + )} + + ) + } + > + + {/* Recent activities for running agents */} + {agent.status === 'running' && + agent.progress?.recentActivities && + agent.progress.recentActivities.length > 0 && ( + + + Progress + + {agent.progress.recentActivities.map((activity, i) => ( + + {i === agent.progress!.recentActivities!.length - 1 + ? '› ' + : ' '} + {renderToolActivity(activity, tools, theme)} + + ))} + + )} + + {/* Plan section (if present) - shown instead of prompt */} + {planContent ? ( + + + + ) : ( + /* Prompt section - only shown when no plan */ + + + Prompt + + {displayPrompt} + + )} + + {/* Error details if failed */} + {agent.status === 'failed' && agent.error && ( + + + Error + + + {agent.error} + + + )} + + + + ) } diff --git a/src/components/tasks/BackgroundTask.tsx b/src/components/tasks/BackgroundTask.tsx index a6923b1da..fd48d09e7 100644 --- a/src/components/tasks/BackgroundTask.tsx +++ b/src/components/tasks/BackgroundTask.tsx @@ -1,344 +1,146 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from 'src/ink.js'; -import type { BackgroundTaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { truncate } from 'src/utils/format.js'; -import { toInkColor } from 'src/utils/ink.js'; -import { plural } from 'src/utils/stringUtils.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { RemoteSessionProgress } from './RemoteSessionProgress.js'; -import { ShellProgress, TaskStatusText } from './ShellProgress.js'; -import { describeTeammateActivity } from './taskStatusUtils.js'; +import * as React from 'react' +import { Text } from 'src/ink.js' +import type { BackgroundTaskState } from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { truncate } from 'src/utils/format.js' +import { toInkColor } from 'src/utils/ink.js' +import { plural } from 'src/utils/stringUtils.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { RemoteSessionProgress } from './RemoteSessionProgress.js' +import { ShellProgress, TaskStatusText } from './ShellProgress.js' +import { describeTeammateActivity } from './taskStatusUtils.js' + type Props = { - task: DeepImmutable; - maxActivityWidth?: number; -}; -export function BackgroundTask(t0) { - const $ = _c(92); - const { - task, - maxActivityWidth - } = t0; - const activityLimit = maxActivityWidth ?? 40; + task: DeepImmutable + maxActivityWidth?: number +} + +export function BackgroundTask({ + task, + maxActivityWidth, +}: Props): React.ReactNode { + const activityLimit = maxActivityWidth ?? 40 switch (task.type) { - case "local_bash": - { - const t1 = task.kind === "monitor" ? task.description : task.command; - let t2; - if ($[0] !== activityLimit || $[1] !== t1) { - t2 = truncate(t1, activityLimit, true); - $[0] = activityLimit; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== task) { - t3 = ; - $[3] = task; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{" "}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; - } - case "remote_agent": - { - if (task.isRemoteReview) { - let t1; - if ($[8] !== task) { - t1 = ; - $[8] = task; - $[9] = t1; - } else { - t1 = $[9]; - } - return t1; - } - const running = task.status === "running" || task.status === "pending"; - const t1 = running ? DIAMOND_OPEN : DIAMOND_FILLED; - let t2; - if ($[10] !== t1) { - t2 = {t1} ; - $[10] = t1; - $[11] = t2; - } else { - t2 = $[11]; - } - let t3; - if ($[12] !== activityLimit || $[13] !== task.title) { - t3 = truncate(task.title, activityLimit, true); - $[12] = activityLimit; - $[13] = task.title; - $[14] = t3; - } else { - t3 = $[14]; - } - let t4; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t4 = · ; - $[15] = t4; - } else { - t4 = $[15]; - } - let t5; - if ($[16] !== task) { - t5 = ; - $[16] = task; - $[17] = t5; - } else { - t5 = $[17]; - } - let t6; - if ($[18] !== t2 || $[19] !== t3 || $[20] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[18] = t2; - $[19] = t3; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - return t6; - } - case "local_agent": - { - let t1; - if ($[22] !== activityLimit || $[23] !== task.description) { - t1 = truncate(task.description, activityLimit, true); - $[22] = activityLimit; - $[23] = task.description; - $[24] = t1; - } else { - t1 = $[24]; - } - const t2 = task.status === "completed" ? "done" : undefined; - const t3 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t4; - if ($[25] !== t2 || $[26] !== t3 || $[27] !== task.status) { - t4 = ; - $[25] = t2; - $[26] = t3; - $[27] = task.status; - $[28] = t4; - } else { - t4 = $[28]; - } - let t5; - if ($[29] !== t1 || $[30] !== t4) { - t5 = {t1}{" "}{t4}; - $[29] = t1; - $[30] = t4; - $[31] = t5; - } else { - t5 = $[31]; - } - return t5; - } - case "in_process_teammate": - { - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - if ($[32] !== activityLimit || $[33] !== task) { - const activity = describeTeammateActivity(task); - T1 = Text; - let t5; - if ($[40] !== task.identity.color) { - t5 = toInkColor(task.identity.color); - $[40] = task.identity.color; - $[41] = t5; - } else { - t5 = $[41]; - } - if ($[42] !== t5 || $[43] !== task.identity.agentName) { - t4 = @{task.identity.agentName}; - $[42] = t5; - $[43] = task.identity.agentName; - $[44] = t4; - } else { - t4 = $[44]; - } - T0 = Text; - t1 = true; - t2 = ": "; - t3 = truncate(activity, activityLimit, true); - $[32] = activityLimit; - $[33] = task; - $[34] = T0; - $[35] = T1; - $[36] = t1; - $[37] = t2; - $[38] = t3; - $[39] = t4; - } else { - T0 = $[34]; - T1 = $[35]; - t1 = $[36]; - t2 = $[37]; - t3 = $[38]; - t4 = $[39]; - } - let t5; - if ($[45] !== T0 || $[46] !== t1 || $[47] !== t2 || $[48] !== t3) { - t5 = {t2}{t3}; - $[45] = T0; - $[46] = t1; - $[47] = t2; - $[48] = t3; - $[49] = t5; - } else { - t5 = $[49]; - } - let t6; - if ($[50] !== T1 || $[51] !== t4 || $[52] !== t5) { - t6 = {t4}{t5}; - $[50] = T1; - $[51] = t4; - $[52] = t5; - $[53] = t6; - } else { - t6 = $[53]; - } - return t6; - } - case "local_workflow": - { - const t1 = task.workflowName ?? task.summary ?? task.description; - let t2; - if ($[54] !== activityLimit || $[55] !== t1) { - t2 = truncate(t1, activityLimit, true); - $[54] = activityLimit; - $[55] = t1; - $[56] = t2; - } else { - t2 = $[56]; - } - let t3; - if ($[57] !== task.agentCount || $[58] !== task.status) { - t3 = task.status === "running" ? `${task.agentCount} ${plural(task.agentCount, "agent")}` : task.status === "completed" ? "done" : undefined; - $[57] = task.agentCount; - $[58] = task.status; - $[59] = t3; - } else { - t3 = $[59]; - } - const t4 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t5; - if ($[60] !== t3 || $[61] !== t4 || $[62] !== task.status) { - t5 = ; - $[60] = t3; - $[61] = t4; - $[62] = task.status; - $[63] = t5; - } else { - t5 = $[63]; - } - let t6; - if ($[64] !== t2 || $[65] !== t5) { - t6 = {t2}{" "}{t5}; - $[64] = t2; - $[65] = t5; - $[66] = t6; - } else { - t6 = $[66]; - } - return t6; - } - case "monitor_mcp": - { - let t1; - if ($[67] !== activityLimit || $[68] !== task.description) { - t1 = truncate(task.description, activityLimit, true); - $[67] = activityLimit; - $[68] = task.description; - $[69] = t1; - } else { - t1 = $[69]; - } - const t2 = task.status === "completed" ? "done" : undefined; - const t3 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t4; - if ($[70] !== t2 || $[71] !== t3 || $[72] !== task.status) { - t4 = ; - $[70] = t2; - $[71] = t3; - $[72] = task.status; - $[73] = t4; - } else { - t4 = $[73]; - } - let t5; - if ($[74] !== t1 || $[75] !== t4) { - t5 = {t1}{" "}{t4}; - $[74] = t1; - $[75] = t4; - $[76] = t5; - } else { - t5 = $[76]; - } - return t5; - } - case "dream": - { - const n = task.filesTouched.length; - let t1; - if ($[77] !== n || $[78] !== task.phase || $[79] !== task.sessionsReviewing) { - t1 = task.phase === "updating" && n > 0 ? `${n} ${plural(n, "file")}` : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, "session")}`; - $[77] = n; - $[78] = task.phase; - $[79] = task.sessionsReviewing; - $[80] = t1; - } else { - t1 = $[80]; - } - const detail = t1; - let t2; - if ($[81] !== detail || $[82] !== task.phase) { - t2 = · {task.phase} · {detail}; - $[81] = detail; - $[82] = task.phase; - $[83] = t2; - } else { - t2 = $[83]; - } - const t3 = task.status === "completed" ? "done" : undefined; - const t4 = task.status === "completed" && !task.notified ? ", unread" : undefined; - let t5; - if ($[84] !== t3 || $[85] !== t4 || $[86] !== task.status) { - t5 = ; - $[84] = t3; - $[85] = t4; - $[86] = task.status; - $[87] = t5; - } else { - t5 = $[87]; - } - let t6; - if ($[88] !== t2 || $[89] !== t5 || $[90] !== task.description) { - t6 = {task.description}{" "}{t2}{" "}{t5}; - $[88] = t2; - $[89] = t5; - $[90] = task.description; - $[91] = t6; - } else { - t6 = $[91]; - } - return t6; + case 'local_bash': + return ( + + {truncate( + task.kind === 'monitor' ? task.description : task.command, + activityLimit, + true, + )}{' '} + + + ) + case 'remote_agent': { + // Lite-review renders its own rainbow line (title + live counts), + // so we don't prefix the title — the rainbow already includes it. + if (task.isRemoteReview) { + return ( + + + + ) } + const running = task.status === 'running' || task.status === 'pending' + return ( + + {running ? DIAMOND_OPEN : DIAMOND_FILLED} + {truncate(task.title, activityLimit, true)} + · + + + ) + } + case 'local_agent': + return ( + + {truncate(task.description, activityLimit, true)}{' '} + + + ) + case 'in_process_teammate': { + const activity = describeTeammateActivity(task) + return ( + + + @{task.identity.agentName} + + : {truncate(activity, activityLimit, true)} + + ) + } + case 'local_workflow': + return ( + + {truncate( + task.workflowName ?? task.summary ?? task.description, + activityLimit, + true, + )}{' '} + + + ) + case 'monitor_mcp': + return ( + + {truncate(task.description, activityLimit, true)}{' '} + + + ) + case 'dream': { + const n = task.filesTouched.length + const detail = + task.phase === 'updating' && n > 0 + ? `${n} ${plural(n, 'file')}` + : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, 'session')}` + return ( + + {task.description}{' '} + + · {task.phase} · {detail} + {' '} + + + ) + } } } diff --git a/src/components/tasks/BackgroundTaskStatus.tsx b/src/components/tasks/BackgroundTaskStatus.tsx index 37bfd8009..26d46cf98 100644 --- a/src/components/tasks/BackgroundTaskStatus.tsx +++ b/src/components/tasks/BackgroundTaskStatus.tsx @@ -1,428 +1,310 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useMemo, useState } from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { stringWidth } from 'src/ink/stringWidth.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; -import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js'; -import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js'; -import { Box, Text } from '../../ink.js'; -import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; -import type { Theme } from '../../utils/theme.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { shouldHideTasksFooter } from './taskStatusUtils.js'; +import figures from 'figures' +import * as React from 'react' +import { useMemo, useState } from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { stringWidth } from 'src/ink/stringWidth.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from 'src/state/teammateViewHelpers.js' +import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js' +import { + type BackgroundTaskState, + isBackgroundTask, + type TaskState, +} from 'src/tasks/types.js' +import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js' +import { Box, Text } from '../../ink.js' +import { + AGENT_COLOR_TO_THEME_COLOR, + AGENT_COLORS, + type AgentColorName, +} from '../../tools/AgentTool/agentColorManager.js' +import type { Theme } from '../../utils/theme.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { shouldHideTasksFooter } from './taskStatusUtils.js' + type Props = { - tasksSelected: boolean; - isViewingTeammate?: boolean; - teammateFooterIndex?: number; - isLeaderIdle?: boolean; - onOpenDialog?: (taskId?: string) => void; -}; -export function BackgroundTaskStatus(t0) { - const $ = _c(48); - const { - tasksSelected, - isViewingTeammate, - teammateFooterIndex: t1, - isLeaderIdle: t2, - onOpenDialog - } = t0; - const teammateFooterIndex = t1 === undefined ? 0 : t1; - const isLeaderIdle = t2 === undefined ? false : t2; - const setAppState = useSetAppState(); - const { - columns - } = useTerminalSize(); - const tasks = useAppState(_temp); - const viewingAgentTaskId = useAppState(_temp2); - let t3; - if ($[0] !== tasks) { - t3 = (Object.values(tasks ?? {}) as TaskState[]).filter(_temp3); - $[0] = tasks; - $[1] = t3; - } else { - t3 = $[1]; - } - const runningTasks = t3; - const expandedView = useAppState(_temp4); - const showSpinnerTree = expandedView === "teammates"; - const allTeammates = !showSpinnerTree && runningTasks.length > 0 && runningTasks.every(_temp5); - let t4; - if ($[2] !== runningTasks) { - t4 = runningTasks.filter(_temp6).sort(_temp7); - $[2] = runningTasks; - $[3] = t4; - } else { - t4 = $[3]; - } - const teammateEntries = t4; - let t5; - if ($[4] !== isLeaderIdle) { - t5 = { - name: "main", + tasksSelected: boolean + isViewingTeammate?: boolean + teammateFooterIndex?: number + isLeaderIdle?: boolean + onOpenDialog?: (taskId?: string) => void +} + +export function BackgroundTaskStatus({ + tasksSelected, + isViewingTeammate, + teammateFooterIndex = 0, + isLeaderIdle = false, + onOpenDialog, +}: Props): React.ReactNode { + const setAppState = useSetAppState() + const { columns } = useTerminalSize() + const tasks = useAppState(s => s.tasks) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + + const runningTasks = useMemo( + () => + (Object.values(tasks ?? {}) as TaskState[]).filter( + t => + isBackgroundTask(t) && + !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + ), + [tasks], + ) + + // Check if all tasks are in-process teammates (team mode) + // In spinner-tree mode, don't show teammate pills (teammates appear in the spinner tree) + const expandedView = useAppState(s => s.expandedView) + const showSpinnerTree = expandedView === 'teammates' + const allTeammates = + !showSpinnerTree && + runningTasks.length > 0 && + runningTasks.every(t => t.type === 'in_process_teammate') + + // Memoize teammate-related computations at the top level (rules of hooks) + const teammateEntries = useMemo( + () => + runningTasks + .filter( + (t): t is BackgroundTaskState & { type: 'in_process_teammate' } => + t.type === 'in_process_teammate', + ) + .sort((a, b) => + a.identity.agentName.localeCompare(b.identity.agentName), + ), + [runningTasks], + ) + + // Build array of all pills with their activity state + // Each pill is "@{name}" and separator is " " (1 char) + // Sort idle agents to the end, but only when not in selection mode + // to avoid reordering while user is arrowing through the list + // "main" always stays first regardless of idle state + const allPills = useMemo(() => { + const mainPill = { + name: 'main', color: undefined as keyof Theme | undefined, isIdle: isLeaderIdle, - taskId: undefined as string | undefined - }; - $[4] = isLeaderIdle; - $[5] = t5; - } else { - t5 = $[5]; - } - const mainPill = t5; - let t6; - if ($[6] !== mainPill || $[7] !== tasksSelected || $[8] !== teammateEntries) { - const teammatePills = teammateEntries.map(_temp8); + taskId: undefined as string | undefined, + } + + const teammatePills = teammateEntries.map(t => ({ + name: t.identity.agentName, + color: getAgentThemeColor(t.identity.color), + isIdle: t.isIdle, + taskId: t.id, + })) + + // Only sort teammates when not selecting to avoid reordering during navigation if (!tasksSelected) { - teammatePills.sort(_temp9); + teammatePills.sort((a, b) => { + // Active agents first, idle agents last + if (a.isIdle !== b.isIdle) return a.isIdle ? 1 : -1 + return 0 // Keep original order within each group + }) } - const pills = [mainPill, ...teammatePills]; - t6 = pills.map(_temp0); - $[6] = mainPill; - $[7] = tasksSelected; - $[8] = teammateEntries; - $[9] = t6; - } else { - t6 = $[9]; - } - const allPills = t6; - let t7; - if ($[10] !== allPills) { - t7 = allPills.map(_temp1); - $[10] = allPills; - $[11] = t7; - } else { - t7 = $[11]; - } - const pillWidths = t7; - if (allTeammates || !showSpinnerTree && isViewingTeammate) { - const selectedIdx = tasksSelected ? teammateFooterIndex : -1; - let t8; - if ($[12] !== teammateEntries || $[13] !== viewingAgentTaskId) { - t8 = viewingAgentTaskId ? teammateEntries.findIndex(t_3 => t_3.id === viewingAgentTaskId) + 1 : 0; - $[12] = teammateEntries; - $[13] = viewingAgentTaskId; - $[14] = t8; - } else { - t8 = $[14]; - } - const viewedIdx = t8; - const availableWidth = Math.max(20, columns - 20 - 4); - const t9 = selectedIdx >= 0 ? selectedIdx : 0; - let t10; - if ($[15] !== availableWidth || $[16] !== pillWidths || $[17] !== t9) { - t10 = calculateHorizontalScrollWindow(pillWidths, availableWidth, 2, t9); - $[15] = availableWidth; - $[16] = pillWidths; - $[17] = t9; - $[18] = t10; - } else { - t10 = $[18]; - } - const { - startIndex, - endIndex, - showLeftArrow, - showRightArrow - } = t10; - let t11; - if ($[19] !== allPills || $[20] !== endIndex || $[21] !== startIndex) { - t11 = allPills.slice(startIndex, endIndex); - $[19] = allPills; - $[20] = endIndex; - $[21] = startIndex; - $[22] = t11; - } else { - t11 = $[22]; - } - const visiblePills = t11; - let t12; - if ($[23] !== showLeftArrow) { - t12 = showLeftArrow && {figures.arrowLeft} ; - $[23] = showLeftArrow; - $[24] = t12; - } else { - t12 = $[24]; - } - let t13; - if ($[25] !== selectedIdx || $[26] !== setAppState || $[27] !== viewedIdx || $[28] !== visiblePills) { - t13 = visiblePills.map((pill_1, i_1) => { - const needsSeparator = i_1 > 0; - return {needsSeparator && } pill_1.taskId ? enterTeammateView(pill_1.taskId, setAppState) : exitTeammateView(setAppState)} />; - }); - $[25] = selectedIdx; - $[26] = setAppState; - $[27] = viewedIdx; - $[28] = visiblePills; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== showRightArrow) { - t14 = showRightArrow && {figures.arrowRight}; - $[30] = showRightArrow; - $[31] = t14; - } else { - t14 = $[31]; - } - let t15; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {" \xB7 "}; - $[32] = t15; - } else { - t15 = $[32]; - } - let t16; - if ($[33] !== t12 || $[34] !== t13 || $[35] !== t14) { - t16 = <>{t12}{t13}{t14}{t15}; - $[33] = t12; - $[34] = t13; - $[35] = t14; - $[36] = t16; - } else { - t16 = $[36]; - } - return t16; + + // main always first, then sorted teammates + const pills = [mainPill, ...teammatePills] + + // Add idx after sorting + return pills.map((pill, i) => ({ ...pill, idx: i })) + }, [teammateEntries, isLeaderIdle, tasksSelected]) + + // Calculate pill widths (including separator space, except first) + const pillWidths = useMemo( + () => + allPills.map((pill, i) => { + const pillText = `@${pill.name}` + // First pill has no leading space, others have 1 space separator + return stringWidth(pillText) + (i > 0 ? 1 : 0) + }), + [allPills], + ) + + if (allTeammates || (!showSpinnerTree && isViewingTeammate)) { + const selectedIdx = tasksSelected ? teammateFooterIndex : -1 + // Which agent is currently foregrounded (bold) + const viewedIdx = viewingAgentTaskId + ? teammateEntries.findIndex(t => t.id === viewingAgentTaskId) + 1 + : 0 // 0 = main/leader + + // Calculate available width for pills + // Reserve space for: arrows, hint, and minimal padding + // Pills are rendered on their own line when in team mode + const ARROW_WIDTH = 2 // arrow char + space + const HINT_WIDTH = 20 // shift+↓ to expand + const PADDING = 4 // minimal safety margin + const availableWidth = Math.max(20, columns - HINT_WIDTH - PADDING) + + // Calculate visible window of pills + const { startIndex, endIndex, showLeftArrow, showRightArrow } = + calculateHorizontalScrollWindow( + pillWidths, + availableWidth, + ARROW_WIDTH, + selectedIdx >= 0 ? selectedIdx : 0, + ) + + const visiblePills = allPills.slice(startIndex, endIndex) + + return ( + <> + {showLeftArrow && {figures.arrowLeft} } + {visiblePills.map((pill, i) => { + // First visible pill has no leading separator + // (left arrow already provides spacing if present) + const needsSeparator = i > 0 + return ( + + {needsSeparator && } + + pill.taskId + ? enterTeammateView(pill.taskId, setAppState) + : exitTeammateView(setAppState) + } + /> + + ) + })} + {showRightArrow && {figures.arrowRight}} + + {' · '} + + + + ) } + + // In spinner-tree mode, don't show any footer status for teammates + // (they appear in the spinner tree above) if (shouldHideTasksFooter(tasks ?? {}, showSpinnerTree)) { - return null; + return null } + if (runningTasks.length === 0) { - return null; + return null } - let t8; - if ($[37] !== runningTasks) { - t8 = getPillLabel(runningTasks); - $[37] = runningTasks; - $[38] = t8; - } else { - t8 = $[38]; - } - let t9; - if ($[39] !== onOpenDialog || $[40] !== t8 || $[41] !== tasksSelected) { - t9 = {t8}; - $[39] = onOpenDialog; - $[40] = t8; - $[41] = tasksSelected; - $[42] = t9; - } else { - t9 = $[42]; - } - let t10; - if ($[43] !== runningTasks) { - t10 = pillNeedsCta(runningTasks) && · {figures.arrowDown} to view; - $[43] = runningTasks; - $[44] = t10; - } else { - t10 = $[44]; - } - let t11; - if ($[45] !== t10 || $[46] !== t9) { - t11 = <>{t9}{t10}; - $[45] = t10; - $[46] = t9; - $[47] = t11; - } else { - t11 = $[47]; - } - return t11; -} -function _temp1(pill_0, i_0) { - const pillText = `@${pill_0.name}`; - return stringWidth(pillText) + (i_0 > 0 ? 1 : 0); -} -function _temp0(pill, i) { - return { - ...pill, - idx: i - }; -} -function _temp9(a_0, b_0) { - if (a_0.isIdle !== b_0.isIdle) { - return a_0.isIdle ? 1 : -1; - } - return 0; -} -function _temp8(t_2) { - return { - name: t_2.identity.agentName, - color: getAgentThemeColor(t_2.identity.color), - isIdle: t_2.isIdle, - taskId: t_2.id - }; -} -function _temp7(a, b) { - return a.identity.agentName.localeCompare(b.identity.agentName); -} -function _temp6(t_1) { - return t_1.type === "in_process_teammate"; -} -function _temp5(t_0) { - return t_0.type === "in_process_teammate"; -} -function _temp4(s_1) { - return s_1.expandedView; -} -function _temp3(t) { - return isBackgroundTask(t) && !(false && isPanelAgentTask(t)); -} -function _temp2(s_0) { - return s_0.viewingAgentTaskId; -} -function _temp(s) { - return s.tasks; + + return ( + <> + + {getPillLabel(runningTasks)} + + {pillNeedsCta(runningTasks) && ( + · {figures.arrowDown} to view + )} + + ) } + type AgentPillProps = { - name: string; - color?: keyof Theme; - isSelected: boolean; - isViewed: boolean; - isIdle: boolean; - onClick?: () => void; -}; -function AgentPill(t0) { - const $ = _c(19); - const { - name, - color, - isSelected, - isViewed, - isIdle, - onClick - } = t0; - const [hover, setHover] = useState(false); - const highlighted = isSelected || hover; - let label; + name: string + color?: keyof Theme + isSelected: boolean + isViewed: boolean + isIdle: boolean + onClick?: () => void +} + +function AgentPill({ + name, + color, + isSelected, + isViewed, + isIdle, + onClick, +}: AgentPillProps): React.ReactNode { + const [hover, setHover] = useState(false) + // Hover mirrors the keyboard-selected look so the affordance is familiar. + const highlighted = isSelected || hover + + let label: React.ReactNode if (highlighted) { - let t1; - if ($[0] !== color || $[1] !== isViewed || $[2] !== name) { - t1 = color ? @{name} : @{name}; - $[0] = color; - $[1] = isViewed; - $[2] = name; - $[3] = t1; - } else { - t1 = $[3]; - } - label = t1; + label = color ? ( + + @{name} + + ) : ( + + @{name} + + ) + } else if (isIdle) { + label = ( + + @{name} + + ) + } else if (isViewed) { + label = ( + + @{name} + + ) } else { - if (isIdle) { - let t1; - if ($[4] !== isViewed || $[5] !== name) { - t1 = @{name}; - $[4] = isViewed; - $[5] = name; - $[6] = t1; - } else { - t1 = $[6]; - } - label = t1; - } else { - if (isViewed) { - let t1; - if ($[7] !== color || $[8] !== name) { - t1 = @{name}; - $[7] = color; - $[8] = name; - $[9] = t1; - } else { - t1 = $[9]; - } - label = t1; - } else { - const t1 = !color; - let t2; - if ($[10] !== color || $[11] !== name || $[12] !== t1) { - t2 = @{name}; - $[10] = color; - $[11] = name; - $[12] = t1; - $[13] = t2; - } else { - t2 = $[13]; - } - label = t2; - } - } + label = ( + + @{name} + + ) } - if (!onClick) { - return label; - } - let t1; - let t2; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setHover(true); - t2 = () => setHover(false); - $[14] = t1; - $[15] = t2; - } else { - t1 = $[14]; - t2 = $[15]; - } - let t3; - if ($[16] !== label || $[17] !== onClick) { - t3 = {label}; - $[16] = label; - $[17] = onClick; - $[18] = t3; - } else { - t3 = $[18]; - } - return t3; + + if (!onClick) return label + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {label} + + ) } -function SummaryPill(t0) { - const $ = _c(8); - const { - selected, - onClick, - children - } = t0; - const [hover, setHover] = useState(false); - const t1 = selected || hover; - let t2; - if ($[0] !== children || $[1] !== t1) { - t2 = {children}; - $[0] = children; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - const label = t2; - if (!onClick) { - return label; - } - let t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setHover(true); - t4 = () => setHover(false); - $[3] = t3; - $[4] = t4; - } else { - t3 = $[3]; - t4 = $[4]; - } - let t5; - if ($[5] !== label || $[6] !== onClick) { - t5 = {label}; - $[5] = label; - $[6] = onClick; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; + +function SummaryPill({ + selected, + onClick, + children, +}: { + selected: boolean + onClick?: () => void + children: React.ReactNode +}): React.ReactNode { + const [hover, setHover] = useState(false) + const label = ( + + {children} + + ) + if (!onClick) return label + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {label} + + ) } -function getAgentThemeColor(colorName: string | undefined): keyof Theme | undefined { - if (!colorName) return undefined; + +function getAgentThemeColor( + colorName: string | undefined, +): keyof Theme | undefined { + if (!colorName) return undefined if (AGENT_COLORS.includes(colorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]; + return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] } - return undefined; + return undefined } diff --git a/src/components/tasks/BackgroundTasksDialog.tsx b/src/components/tasks/BackgroundTasksDialog.tsx index c7bbd9b60..d9f119cf1 100644 --- a/src/components/tasks/BackgroundTasksDialog.tsx +++ b/src/components/tasks/BackgroundTasksDialog.tsx @@ -1,171 +1,214 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import React, { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; -import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; -import type { ToolUseContext } from 'src/Tool.js'; -import { DreamTask, type DreamTaskState } from 'src/tasks/DreamTask/DreamTask.js'; -import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js'; -import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js'; -import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js'; -import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js'; +import { feature } from 'bun:bundle' +import figures from 'figures' +import React, { + type ReactNode, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from 'react' +import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from 'src/state/teammateViewHelpers.js' +import type { ToolUseContext } from 'src/Tool.js' +import { + DreamTask, + type DreamTaskState, +} from 'src/tasks/DreamTask/DreamTask.js' +import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js' +import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' +import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' +import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js' // Type import is erased at build time — safe even though module is ant-gated. -import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js'; -import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js'; -import { RemoteAgentTask, type RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { intersperse } from 'src/utils/array.js'; -import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js'; -import { stopUltraplan } from '../../commands/ultraplan.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -import { count } from '../../utils/array.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js'; -import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js'; -import { DreamDetailDialog } from './DreamDetailDialog.js'; -import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js'; -import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js'; -import { ShellDetailDialog } from './ShellDetailDialog.js'; -type ViewState = { - mode: 'list'; -} | { - mode: 'detail'; - itemId: string; -}; +import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js' +import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js' +import { + RemoteAgentTask, + type RemoteAgentTaskState, +} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' +import { + type BackgroundTaskState, + isBackgroundTask, + type TaskState, +} from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { intersperse } from 'src/utils/array.js' +import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js' +import { stopUltraplan } from '../../commands/ultraplan.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { count } from '../../utils/array.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js' +import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js' +import { DreamDetailDialog } from './DreamDetailDialog.js' +import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js' +import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js' +import { ShellDetailDialog } from './ShellDetailDialog.js' + +type ViewState = { mode: 'list' } | { mode: 'detail'; itemId: string } + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - toolUseContext: ToolUseContext; - initialDetailTaskId?: string; -}; -type ListItem = { - id: string; - type: 'local_bash'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'remote_agent'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'local_agent'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'in_process_teammate'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'local_workflow'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'monitor_mcp'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'dream'; - label: string; - status: string; - task: DeepImmutable; -} | { - id: string; - type: 'leader'; - label: string; - status: 'running'; -}; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + toolUseContext: ToolUseContext + initialDetailTaskId?: string +} + +type ListItem = + | { + id: string + type: 'local_bash' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'remote_agent' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'local_agent' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'in_process_teammate' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'local_workflow' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'monitor_mcp' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'dream' + label: string + status: string + task: DeepImmutable + } + | { + id: string + type: 'leader' + label: string + status: 'running' + } // WORKFLOW_SCRIPTS is ant-only (build_flags.yaml). Static imports would leak // ~1.3K lines into external builds. Gate with feature() + require so the // bundler can dead-code-eliminate the branch. /* eslint-disable @typescript-eslint/no-require-imports */ -const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') ? (require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js')).WorkflowDetailDialog : null; -const workflowTaskModule = feature('WORKFLOW_SCRIPTS') ? require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') : null; -const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null; -const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null; -const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null; +const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') + ? ( + require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js') + ).WorkflowDetailDialog + : null +const workflowTaskModule = feature('WORKFLOW_SCRIPTS') + ? (require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js')) + : null +const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null +const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null +const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null // Relative path, not `src/...` path-mapping — Bun's DCE can statically // resolve + eliminate `./` requires, but path-mapped strings stay opaque // and survive as dead literals in the bundle. Matches tasks.ts pattern. -const monitorMcpModule = feature('MONITOR_TOOL') ? require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js') : null; -const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null; -const MonitorMcpDetailDialog = feature('MONITOR_TOOL') ? (require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js')).MonitorMcpDetailDialog : null; +const monitorMcpModule = feature('MONITOR_TOOL') + ? (require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js')) + : null +const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null +const MonitorMcpDetailDialog = feature('MONITOR_TOOL') + ? ( + require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js') + ).MonitorMcpDetailDialog + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Helper to get filtered background tasks (excludes foregrounded local_agent) -function getSelectableBackgroundTasks(tasks: Record | undefined, foregroundedTaskId: string | undefined): TaskState[] { - const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask); - return backgroundTasks.filter(task => !(task.type === 'local_agent' && task.id === foregroundedTaskId)); +function getSelectableBackgroundTasks( + tasks: Record | undefined, + foregroundedTaskId: string | undefined, +): TaskState[] { + const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask) + return backgroundTasks.filter( + task => !(task.type === 'local_agent' && task.id === foregroundedTaskId), + ) } + export function BackgroundTasksDialog({ onDone, toolUseContext, - initialDetailTaskId + initialDetailTaskId, }: Props): React.ReactNode { - const tasks = useAppState(s => s.tasks); - const foregroundedTaskId = useAppState(s_0 => s_0.foregroundedTaskId); - const showSpinnerTree = useAppState(s_1 => s_1.expandedView) === 'teammates'; - const setAppState = useSetAppState(); - const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k'); - const typedTasks = tasks as Record | undefined; + const tasks = useAppState(s => s.tasks) + const foregroundedTaskId = useAppState(s => s.foregroundedTaskId) + const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' + const setAppState = useSetAppState() + const killAgentsShortcut = useShortcutDisplay( + 'chat:killAgents', + 'Chat', + 'ctrl+x ctrl+k', + ) + const typedTasks = tasks as Record | undefined // Track if we skipped list view on mount (for back button behavior) - const skippedListOnMount = useRef(false); + const skippedListOnMount = useRef(false) // Compute initial view state - skip list if caller provided a specific task, // or if there's exactly one task const [viewState, setViewState] = useState(() => { if (initialDetailTaskId) { - skippedListOnMount.current = true; - return { - mode: 'detail', - itemId: initialDetailTaskId - }; + skippedListOnMount.current = true + return { mode: 'detail', itemId: initialDetailTaskId } } - const allItems = getSelectableBackgroundTasks(typedTasks, foregroundedTaskId); + const allItems = getSelectableBackgroundTasks( + typedTasks, + foregroundedTaskId, + ) if (allItems.length === 1) { - skippedListOnMount.current = true; - return { - mode: 'detail', - itemId: allItems[0]!.id - }; + skippedListOnMount.current = true + return { mode: 'detail', itemId: allItems[0]!.id } } - return { - mode: 'list' - }; - }); - const [selectedIndex, setSelectedIndex] = useState(0); + return { mode: 'list' } + }) + const [selectedIndex, setSelectedIndex] = useState(0) // Register as modal overlay so parent Chat keybindings (up/down for history) // are deactivated while this dialog is open - useRegisterOverlay('background-tasks-dialog', undefined); + useRegisterOverlay('background-tasks-dialog') // Memoize the sorted and categorized items together to ensure stable references const { @@ -175,37 +218,48 @@ export function BackgroundTasksDialog({ teammateTasks, workflowTasks, mcpMonitors, - dreamTasks: dreamTasks_0, - allSelectableItems + dreamTasks, + allSelectableItems, } = useMemo(() => { // Filter to only show running/pending background tasks, matching the status bar count - const backgroundTasks = Object.values(typedTasks ?? {}).filter(isBackgroundTask); - const allItems_0 = backgroundTasks.map(toListItem); - const sorted = allItems_0.sort((a, b) => { - const aStatus = a.status; - const bStatus = b.status; - if (aStatus === 'running' && bStatus !== 'running') return -1; - if (aStatus !== 'running' && bStatus === 'running') return 1; - const aTime = 'task' in a ? a.task.startTime : 0; - const bTime = 'task' in b ? b.task.startTime : 0; - return bTime - aTime; - }); - const bash = sorted.filter(item => item.type === 'local_bash'); - const remote = sorted.filter(item_0 => item_0.type === 'remote_agent'); + const backgroundTasks = Object.values(typedTasks ?? {}).filter( + isBackgroundTask, + ) + const allItems = backgroundTasks.map(toListItem) + const sorted = allItems.sort((a, b) => { + const aStatus = a.status + const bStatus = b.status + if (aStatus === 'running' && bStatus !== 'running') return -1 + if (aStatus !== 'running' && bStatus === 'running') return 1 + const aTime = 'task' in a ? a.task.startTime : 0 + const bTime = 'task' in b ? b.task.startTime : 0 + return bTime - aTime + }) + const bash = sorted.filter(item => item.type === 'local_bash') + const remote = sorted.filter(item => item.type === 'remote_agent') // Exclude foregrounded task - it's being viewed in the main UI, not a background task - const agent = sorted.filter(item_1 => item_1.type === 'local_agent' && item_1.id !== foregroundedTaskId); - const workflows = sorted.filter(item_2 => item_2.type === 'local_workflow'); - const monitorMcp = sorted.filter(item_3 => item_3.type === 'monitor_mcp'); - const dreamTasks = sorted.filter(item_4 => item_4.type === 'dream'); + const agent = sorted.filter( + item => item.type === 'local_agent' && item.id !== foregroundedTaskId, + ) + const workflows = sorted.filter(item => item.type === 'local_workflow') + const monitorMcp = sorted.filter(item => item.type === 'monitor_mcp') + const dreamTasks = sorted.filter(item => item.type === 'dream') // In spinner-tree mode, exclude teammates from the dialog (they appear in the tree) - const teammates = showSpinnerTree ? [] : sorted.filter(item_5 => item_5.type === 'in_process_teammate'); + const teammates = showSpinnerTree + ? [] + : sorted.filter(item => item.type === 'in_process_teammate') // Add leader entry when there are teammates, so users can foreground back to leader - const leaderItem: ListItem[] = teammates.length > 0 ? [{ - id: '__leader__', - type: 'leader', - label: `@${TEAM_LEAD_NAME}`, - status: 'running' - }] : []; + const leaderItem: ListItem[] = + teammates.length > 0 + ? [ + { + id: '__leader__', + type: 'leader', + label: `@${TEAM_LEAD_NAME}`, + status: 'running', + }, + ] + : [] return { bashTasks: bash, remoteSessions: remote, @@ -217,135 +271,177 @@ export function BackgroundTasksDialog({ // Order MUST match JSX render order (teammates \u2192 bash \u2192 monitorMcp \u2192 // remote \u2192 agent \u2192 workflows \u2192 dream) so \u2193/\u2191 navigation moves the cursor // visually downward. - allSelectableItems: [...leaderItem, ...teammates, ...bash, ...monitorMcp, ...remote, ...agent, ...workflows, ...dreamTasks] - }; - }, [typedTasks, foregroundedTaskId, showSpinnerTree]); - const currentSelection = allSelectableItems[selectedIndex] ?? null; + allSelectableItems: [ + ...leaderItem, + ...teammates, + ...bash, + ...monitorMcp, + ...remote, + ...agent, + ...workflows, + ...dreamTasks, + ], + } + }, [typedTasks, foregroundedTaskId, showSpinnerTree]) + + const currentSelection = allSelectableItems[selectedIndex] ?? null // Use configurable keybindings for standard navigation and confirm/cancel. // confirm:no is handled by Dialog's onCancel prop. - useKeybindings({ - 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), - 'confirm:next': () => setSelectedIndex(prev_0 => Math.min(allSelectableItems.length - 1, prev_0 + 1)), - 'confirm:yes': () => { - const current = allSelectableItems[selectedIndex]; - if (current) { - if (current.type === 'leader') { - exitTeammateView(setAppState); - onDone('Viewing leader', { - display: 'system' - }); - } else { - setViewState({ - mode: 'detail', - itemId: current.id - }); + useKeybindings( + { + 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), + 'confirm:next': () => + setSelectedIndex(prev => + Math.min(allSelectableItems.length - 1, prev + 1), + ), + 'confirm:yes': () => { + const current = allSelectableItems[selectedIndex] + if (current) { + if (current.type === 'leader') { + exitTeammateView(setAppState) + onDone('Viewing leader', { display: 'system' }) + } else { + setViewState({ mode: 'detail', itemId: current.id }) + } } - } - } - }, { - context: 'Confirmation', - isActive: viewState.mode === 'list' - }); + }, + }, + { context: 'Confirmation', isActive: viewState.mode === 'list' }, + ) // Component-specific shortcuts (x=stop, f=foreground, right=zoom) shown in UI. // These are task-type and status dependent, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { // Only handle input when in list mode - if (viewState.mode !== 'list') return; + if (viewState.mode !== 'list') return + if (e.key === 'left') { - e.preventDefault(); - onDone('Background tasks dialog dismissed', { - display: 'system' - }); - return; + e.preventDefault() + onDone('Background tasks dialog dismissed', { display: 'system' }) + return } // Compute current selection at the time of the key press - const currentSelection_0 = allSelectableItems[selectedIndex]; - if (!currentSelection_0) return; // everything below requires a selection + const currentSelection = allSelectableItems[selectedIndex] + if (!currentSelection) return // everything below requires a selection if (e.key === 'x') { - e.preventDefault(); - if (currentSelection_0.type === 'local_bash' && currentSelection_0.status === 'running') { - void killShellTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'local_agent' && currentSelection_0.status === 'running') { - void killAgentTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'in_process_teammate' && currentSelection_0.status === 'running') { - void killTeammateTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'local_workflow' && currentSelection_0.status === 'running' && killWorkflowTask) { - killWorkflowTask(currentSelection_0.id, setAppState); - } else if (currentSelection_0.type === 'monitor_mcp' && currentSelection_0.status === 'running' && killMonitorMcp) { - killMonitorMcp(currentSelection_0.id, setAppState); - } else if (currentSelection_0.type === 'dream' && currentSelection_0.status === 'running') { - void killDreamTask(currentSelection_0.id); - } else if (currentSelection_0.type === 'remote_agent' && currentSelection_0.status === 'running') { - if (currentSelection_0.task.isUltraplan) { - void stopUltraplan(currentSelection_0.id, currentSelection_0.task.sessionId, setAppState); + e.preventDefault() + if ( + currentSelection.type === 'local_bash' && + currentSelection.status === 'running' + ) { + void killShellTask(currentSelection.id) + } else if ( + currentSelection.type === 'local_agent' && + currentSelection.status === 'running' + ) { + void killAgentTask(currentSelection.id) + } else if ( + currentSelection.type === 'in_process_teammate' && + currentSelection.status === 'running' + ) { + void killTeammateTask(currentSelection.id) + } else if ( + currentSelection.type === 'local_workflow' && + currentSelection.status === 'running' && + killWorkflowTask + ) { + killWorkflowTask(currentSelection.id, setAppState) + } else if ( + currentSelection.type === 'monitor_mcp' && + currentSelection.status === 'running' && + killMonitorMcp + ) { + killMonitorMcp(currentSelection.id, setAppState) + } else if ( + currentSelection.type === 'dream' && + currentSelection.status === 'running' + ) { + void killDreamTask(currentSelection.id) + } else if ( + currentSelection.type === 'remote_agent' && + currentSelection.status === 'running' + ) { + if (currentSelection.task.isUltraplan) { + void stopUltraplan( + currentSelection.id, + currentSelection.task.sessionId, + setAppState, + ) } else { - void killRemoteAgentTask(currentSelection_0.id); + void killRemoteAgentTask(currentSelection.id) } } } + if (e.key === 'f') { - if (currentSelection_0.type === 'in_process_teammate' && currentSelection_0.status === 'running') { - e.preventDefault(); - enterTeammateView(currentSelection_0.id, setAppState); - onDone('Viewing teammate', { - display: 'system' - }); - } else if (currentSelection_0.type === 'leader') { - e.preventDefault(); - exitTeammateView(setAppState); - onDone('Viewing leader', { - display: 'system' - }); + if ( + currentSelection.type === 'in_process_teammate' && + currentSelection.status === 'running' + ) { + e.preventDefault() + enterTeammateView(currentSelection.id, setAppState) + onDone('Viewing teammate', { display: 'system' }) + } else if (currentSelection.type === 'leader') { + e.preventDefault() + exitTeammateView(setAppState) + onDone('Viewing leader', { display: 'system' }) } } - }; + } + async function killShellTask(taskId: string): Promise { - await LocalShellTask.kill(taskId, setAppState); + await LocalShellTask.kill(taskId, setAppState) } - async function killAgentTask(taskId_0: string): Promise { - await LocalAgentTask.kill(taskId_0, setAppState); + + async function killAgentTask(taskId: string): Promise { + await LocalAgentTask.kill(taskId, setAppState) } - async function killTeammateTask(taskId_1: string): Promise { - await InProcessTeammateTask.kill(taskId_1, setAppState); + + async function killTeammateTask(taskId: string): Promise { + await InProcessTeammateTask.kill(taskId, setAppState) } - async function killDreamTask(taskId_2: string): Promise { - await DreamTask.kill(taskId_2, setAppState); + + async function killDreamTask(taskId: string): Promise { + await DreamTask.kill(taskId, setAppState) } - async function killRemoteAgentTask(taskId_3: string): Promise { - await RemoteAgentTask.kill(taskId_3, setAppState); + + async function killRemoteAgentTask(taskId: string): Promise { + await RemoteAgentTask.kill(taskId, setAppState) } // Wrap onDone in useEffectEvent to get a stable reference that always calls // the current onDone callback without causing the effect to re-fire. - const onDoneEvent = useEffectEvent(onDone); + const onDoneEvent = useEffectEvent(onDone) + useEffect(() => { if (viewState.mode !== 'list') { - const task = (typedTasks ?? {})[viewState.itemId]; + const task = (typedTasks ?? {})[viewState.itemId] // Workflow tasks get a grace: their detail view stays open through // completion so the user sees the final state before eviction. - if (!task || task.type !== 'local_workflow' && !isBackgroundTask(task)) { + if ( + !task || + (task.type !== 'local_workflow' && !isBackgroundTask(task)) + ) { // Task was removed or is no longer a background task (e.g. killed). // If we skipped the list on mount, close the dialog entirely. if (skippedListOnMount.current) { onDoneEvent('Background tasks dialog dismissed', { - display: 'system' - }); + display: 'system', + }) } else { - setViewState({ - mode: 'list' - }); + setViewState({ mode: 'list' }) } } } - const totalItems = allSelectableItems.length; + + const totalItems = allSelectableItems.length if (selectedIndex >= totalItems && totalItems > 0) { - setSelectedIndex(totalItems - 1); + setSelectedIndex(totalItems - 1) } - }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]); + }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]) // Helper to go back to list view (or close dialog if we skipped list on // mount AND there's still only ≤1 item). Checking current count prevents @@ -353,142 +449,421 @@ export function BackgroundTasksDialog({ // then a second task started, 'back' should show the list — not close. const goBackToList = () => { if (skippedListOnMount.current && allSelectableItems.length <= 1) { - onDone('Background tasks dialog dismissed', { - display: 'system' - }); + onDone('Background tasks dialog dismissed', { display: 'system' }) } else { - skippedListOnMount.current = false; - setViewState({ - mode: 'list' - }); + skippedListOnMount.current = false + setViewState({ mode: 'list' }) } - }; + } // If an item is selected, show the appropriate view if (viewState.mode !== 'list' && typedTasks) { - const task_0 = typedTasks[viewState.itemId]; - if (!task_0) { - return null; + const task = typedTasks[viewState.itemId] + if (!task) { + return null } // Detail mode - show appropriate detail dialog - switch (task_0.type) { + switch (task.type) { case 'local_bash': - return void killShellTask(task_0.id)} onBack={goBackToList} key={`shell-${task_0.id}`} />; + return ( + void killShellTask(task.id)} + onBack={goBackToList} + key={`shell-${task.id}`} + /> + ) case 'local_agent': - return void killAgentTask(task_0.id)} onBack={goBackToList} key={`agent-${task_0.id}`} />; + return ( + void killAgentTask(task.id)} + onBack={goBackToList} + key={`agent-${task.id}`} + /> + ) case 'remote_agent': - return void stopUltraplan(task_0.id, task_0.sessionId, setAppState) : () => void killRemoteAgentTask(task_0.id)} key={`session-${task_0.id}`} />; + return ( + + void stopUltraplan(task.id, task.sessionId, setAppState) + : () => void killRemoteAgentTask(task.id) + } + key={`session-${task.id}`} + /> + ) case 'in_process_teammate': - return void killTeammateTask(task_0.id) : undefined} onBack={goBackToList} onForeground={task_0.status === 'running' ? () => { - enterTeammateView(task_0.id, setAppState); - onDone('Viewing teammate', { - display: 'system' - }); - } : undefined} key={`teammate-${task_0.id}`} />; + return ( + void killTeammateTask(task.id) + : undefined + } + onBack={goBackToList} + onForeground={ + task.status === 'running' + ? () => { + enterTeammateView(task.id, setAppState) + onDone('Viewing teammate', { display: 'system' }) + } + : undefined + } + key={`teammate-${task.id}`} + /> + ) case 'local_workflow': - if (!WorkflowDetailDialog) return null; - return killWorkflowTask(task_0.id, setAppState) : undefined} onSkipAgent={task_0.status === 'running' && skipWorkflowAgent ? agentId => skipWorkflowAgent(task_0.id, agentId, setAppState) : undefined} onRetryAgent={task_0.status === 'running' && retryWorkflowAgent ? agentId_0 => retryWorkflowAgent(task_0.id, agentId_0, setAppState) : undefined} onBack={goBackToList} key={`workflow-${task_0.id}`} />; + if (!WorkflowDetailDialog) return null + return ( + killWorkflowTask(task.id, setAppState) + : undefined + } + onSkipAgent={ + task.status === 'running' && skipWorkflowAgent + ? agentId => skipWorkflowAgent(task.id, agentId, setAppState) + : undefined + } + onRetryAgent={ + task.status === 'running' && retryWorkflowAgent + ? agentId => retryWorkflowAgent(task.id, agentId, setAppState) + : undefined + } + onBack={goBackToList} + key={`workflow-${task.id}`} + /> + ) case 'monitor_mcp': - if (!MonitorMcpDetailDialog) return null; - return killMonitorMcp(task_0.id, setAppState) : undefined} onBack={goBackToList} key={`monitor-mcp-${task_0.id}`} />; + if (!MonitorMcpDetailDialog) return null + return ( + killMonitorMcp(task.id, setAppState) + : undefined + } + onBack={goBackToList} + key={`monitor-mcp-${task.id}`} + /> + ) case 'dream': - return onDone('Background tasks dialog dismissed', { - display: 'system' - })} onBack={goBackToList} onKill={task_0.status === 'running' ? () => void killDreamTask(task_0.id) : undefined} key={`dream-${task_0.id}`} />; + return ( + + onDone('Background tasks dialog dismissed', { + display: 'system', + }) + } + onBack={goBackToList} + onKill={ + task.status === 'running' + ? () => void killDreamTask(task.id) + : undefined + } + key={`dream-${task.id}`} + /> + ) } } - const runningBashCount = count(bashTasks, _ => _.status === 'running'); - const runningAgentCount = count(remoteSessions, __0 => __0.status === 'running' || __0.status === 'pending') + count(agentTasks, __1 => __1.status === 'running'); - const runningTeammateCount = count(teammateTasks, __2 => __2.status === 'running'); - const subtitle = intersperse([...(runningTeammateCount > 0 ? [ + + const runningBashCount = count(bashTasks, _ => _.status === 'running') + const runningAgentCount = + count( + remoteSessions, + _ => _.status === 'running' || _.status === 'pending', + ) + count(agentTasks, _ => _.status === 'running') + const runningTeammateCount = count(teammateTasks, _ => _.status === 'running') + const subtitle = intersperse( + [ + ...(runningTeammateCount > 0 + ? [ + {runningTeammateCount}{' '} {runningTeammateCount !== 1 ? 'agents' : 'agent'} - ] : []), ...(runningBashCount > 0 ? [ + , + ] + : []), + ...(runningBashCount > 0 + ? [ + {runningBashCount}{' '} {runningBashCount !== 1 ? 'active shells' : 'active shell'} - ] : []), ...(runningAgentCount > 0 ? [ + , + ] + : []), + ...(runningAgentCount > 0 + ? [ + {runningAgentCount}{' '} {runningAgentCount !== 1 ? 'active agents' : 'active agent'} - ] : [])], index => · ); - const actions = [, , ...(currentSelection?.type === 'in_process_teammate' && currentSelection.status === 'running' ? [] : []), ...((currentSelection?.type === 'local_bash' || currentSelection?.type === 'local_agent' || currentSelection?.type === 'in_process_teammate' || currentSelection?.type === 'local_workflow' || currentSelection?.type === 'monitor_mcp' || currentSelection?.type === 'dream' || currentSelection?.type === 'remote_agent') && currentSelection.status === 'running' ? [] : []), ...(agentTasks.some(t => t.status === 'running') ? [] : []), ]; - const handleCancel = () => onDone('Background tasks dialog dismissed', { - display: 'system' - }); + , + ] + : []), + ], + index => · , + ) + + const actions = [ + , + , + ...(currentSelection?.type === 'in_process_teammate' && + currentSelection.status === 'running' + ? [ + , + ] + : []), + ...((currentSelection?.type === 'local_bash' || + currentSelection?.type === 'local_agent' || + currentSelection?.type === 'in_process_teammate' || + currentSelection?.type === 'local_workflow' || + currentSelection?.type === 'monitor_mcp' || + currentSelection?.type === 'dream' || + currentSelection?.type === 'remote_agent') && + currentSelection.status === 'running' + ? [] + : []), + ...(agentTasks.some(t => t.status === 'running') + ? [ + , + ] + : []), + , + ] + + const handleCancel = () => + onDone('Background tasks dialog dismissed', { display: 'system' }) + function renderInputGuide(exitState: ExitState): React.ReactNode { if (exitState.pending) { - return Press {exitState.keyName} again to exit; + return Press {exitState.keyName} again to exit } - return {actions}; + return {actions} } - return - {subtitle}} onCancel={handleCancel} color="background" inputGuide={renderInputGuide}> - {allSelectableItems.length === 0 ? No tasks currently running : - {teammateTasks.length > 0 && - {(bashTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && + + return ( + + {subtitle}} + onCancel={handleCancel} + color="background" + inputGuide={renderInputGuide} + > + {allSelectableItems.length === 0 ? ( + No tasks currently running + ) : ( + + {teammateTasks.length > 0 && ( + + {(bashTasks.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0) && ( + {' '}Agents ( {count(teammateTasks, i => i.type !== 'leader')}) - } + + )} - + - } + + )} - {bashTasks.length > 0 && 0 ? 1 : 0}> - {(teammateTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && + {bashTasks.length > 0 && ( + 0 ? 1 : 0} + > + {(teammateTasks.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0) && ( + {' '}Shells ({bashTasks.length}) - } + + )} - {bashTasks.map(item_6 => )} + {bashTasks.map(item => ( + + ))} - } + + )} - {mcpMonitors.length > 0 && 0 || bashTasks.length > 0 ? 1 : 0}> + {mcpMonitors.length > 0 && ( + 0 || bashTasks.length > 0 ? 1 : 0 + } + > {' '}Monitors ({mcpMonitors.length}) - {mcpMonitors.map(item_7 => )} + {mcpMonitors.map(item => ( + + ))} - } + + )} - {remoteSessions.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 ? 1 : 0}> + {remoteSessions.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 + ? 1 + : 0 + } + > {' '}Remote agents ({remoteSessions.length} ) - {remoteSessions.map(item_8 => )} + {remoteSessions.map(item => ( + + ))} - } + + )} - {agentTasks.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 ? 1 : 0}> + {agentTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 + ? 1 + : 0 + } + > {' '}Local agents ({agentTasks.length}) - {agentTasks.map(item_9 => )} + {agentTasks.map(item => ( + + ))} - } + + )} - {workflowTasks.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 ? 1 : 0}> + {workflowTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0 + ? 1 + : 0 + } + > {' '}Workflows ({workflowTasks.length}) - {workflowTasks.map(item_10 => )} + {workflowTasks.map(item => ( + + ))} - } + + )} - {dreamTasks_0.length > 0 && 0 || bashTasks.length > 0 || mcpMonitors.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0 || workflowTasks.length > 0 ? 1 : 0}> + {dreamTasks.length > 0 && ( + 0 || + bashTasks.length > 0 || + mcpMonitors.length > 0 || + remoteSessions.length > 0 || + agentTasks.length > 0 || + workflowTasks.length > 0 + ? 1 + : 0 + } + > - {dreamTasks_0.map(item_11 => )} + {dreamTasks.map(item => ( + + ))} - } - } + + )} + + )} - ; + + ) } + function toListItem(task: BackgroundTaskState): ListItem { switch (task.type) { case 'local_bash': @@ -497,155 +872,141 @@ function toListItem(task: BackgroundTaskState): ListItem { type: 'local_bash', label: task.kind === 'monitor' ? task.description : task.command, status: task.status, - task - }; + task, + } case 'remote_agent': return { id: task.id, type: 'remote_agent', label: task.title, status: task.status, - task - }; + task, + } case 'local_agent': return { id: task.id, type: 'local_agent', label: task.description, status: task.status, - task - }; + task, + } case 'in_process_teammate': return { id: task.id, type: 'in_process_teammate', label: `@${task.identity.agentName}`, status: task.status, - task - }; + task, + } case 'local_workflow': return { id: task.id, type: 'local_workflow', label: task.summary ?? task.description, status: task.status, - task - }; + task, + } case 'monitor_mcp': return { id: task.id, type: 'monitor_mcp', label: task.description, status: task.status, - task - }; + task, + } case 'dream': return { id: task.id, type: 'dream', label: task.description, status: task.status, - task - }; - } -} -function Item(t0) { - const $ = _c(14); - const { - item, - isSelected - } = t0; - const { - columns - } = useTerminalSize(); - const maxActivityWidth = Math.max(30, columns - 26); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = isCoordinatorMode(); - $[0] = t1; - } else { - t1 = $[0]; - } - const useGreyPointer = t1; - const t2 = useGreyPointer && isSelected; - const t3 = isSelected ? figures.pointer + " " : " "; - let t4; - if ($[1] !== t2 || $[2] !== t3) { - t4 = {t3}; - $[1] = t2; - $[2] = t3; - $[3] = t4; - } else { - t4 = $[3]; - } - const t5 = isSelected && !useGreyPointer ? "suggestion" : undefined; - let t6; - if ($[4] !== item.task || $[5] !== item.type || $[6] !== maxActivityWidth) { - t6 = item.type === "leader" ? @{TEAM_LEAD_NAME} : ; - $[4] = item.task; - $[5] = item.type; - $[6] = maxActivityWidth; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== t5 || $[9] !== t6) { - t7 = {t6}; - $[8] = t5; - $[9] = t6; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t4 || $[12] !== t7) { - t8 = {t4}{t7}; - $[11] = t4; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; -} -function TeammateTaskGroups(t0) { - const $ = _c(3); - const { - teammateTasks, - currentSelectionId - } = t0; - let t1; - if ($[0] !== currentSelectionId || $[1] !== teammateTasks) { - const leaderItems = teammateTasks.filter(_temp); - const teammateItems = teammateTasks.filter(_temp2); - const teams = new Map(); - for (const item of teammateItems) { - const teamName = item.task.identity.teamName; - const group = teams.get(teamName); - if (group) { - group.push(item); - } else { - teams.set(teamName, [item]); + task, } - } - const teamEntries = [...teams.entries()]; - t1 = <>{teamEntries.map(t2 => { - const [teamName_0, items] = t2; - const memberCount = items.length + leaderItems.length; - return {" "}Team: {teamName_0} ({memberCount}){leaderItems.map(item_0 => )}{items.map(item_1 => )}; - })}; - $[0] = currentSelectionId; - $[1] = teammateTasks; - $[2] = t1; - } else { - t1 = $[2]; } - return t1; } -function _temp2(i_0) { - return i_0.type === "in_process_teammate"; + +function Item({ + item, + isSelected, +}: { + item: ListItem + isSelected: boolean +}): ReactNode { + const { columns } = useTerminalSize() + // Dialog border (2) + padding (2) + pointer prefix (2) + name/status overhead (~20) + const maxActivityWidth = Math.max(30, columns - 26) + // In coordinator mode, use grey pointer instead of blue + const useGreyPointer = isCoordinatorMode() + + return ( + + + {isSelected ? figures.pointer + ' ' : ' '} + + + {item.type === 'leader' ? ( + @{TEAM_LEAD_NAME} + ) : ( + + )} + + + ) } -function _temp(i) { - return i.type === "leader"; + +function TeammateTaskGroups({ + teammateTasks, + currentSelectionId, +}: { + teammateTasks: ListItem[] + currentSelectionId: string | undefined +}): ReactNode { + // Separate leader from teammates, group teammates by team + const leaderItems = teammateTasks.filter(i => i.type === 'leader') + const teammateItems = teammateTasks.filter( + i => i.type === 'in_process_teammate', + ) + const teams = new Map() + for (const item of teammateItems) { + const teamName = item.task.identity.teamName + const group = teams.get(teamName) + if (group) { + group.push(item) + } else { + teams.set(teamName, [item]) + } + } + const teamEntries = [...teams.entries()] + return ( + <> + {teamEntries.map(([teamName, items]) => { + const memberCount = items.length + leaderItems.length + return ( + + + {' '}Team: {teamName} ({memberCount}) + + {/* Render leader first within each team */} + {leaderItems.map(item => ( + + ))} + {items.map(item => ( + + ))} + + ) + })} + + ) } diff --git a/src/components/tasks/DreamDetailDialog.tsx b/src/components/tasks/DreamDetailDialog.tsx index 46470c000..bea310946 100644 --- a/src/components/tasks/DreamDetailDialog.tsx +++ b/src/components/tasks/DreamDetailDialog.tsx @@ -1,250 +1,136 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js'; -import { plural } from '../../utils/stringUtils.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js' +import { plural } from '../../utils/stringUtils.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - task: DeepImmutable; - onDone: () => void; - onBack?: () => void; - onKill?: () => void; -}; + task: DeepImmutable + onDone: () => void + onBack?: () => void + onKill?: () => void +} // How many recent turns to render. Earlier turns collapse to a count. -const VISIBLE_TURNS = 6; -export function DreamDetailDialog(t0) { - const $ = _c(70); - const { - task, - onDone, - onBack, - onKill - } = t0; - const elapsedTime = useElapsedTime(task.startTime, task.status === "running", 1000, 0); - let t1; - if ($[0] !== onDone) { - t1 = { - "confirm:yes": onDone - }; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; +const VISIBLE_TURNS = 6 + +export function DreamDetailDialog({ + task, + onDone, + onBack, + onKill, +}: Props): React.ReactNode { + const elapsedTime = useElapsedTime( + task.startTime, + task.status === 'running', + 1000, + 0, + ) + + // Dialog handles confirm:no (Esc) → onCancel. Wire confirm:yes (Enter/y) too. + useKeybindings({ 'confirm:yes': onDone }, { context: 'Confirmation' }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && task.status === 'running' && onKill) { + e.preventDefault() + onKill() + } } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useKeybindings(t1, t2); - let t3; - if ($[3] !== onBack || $[4] !== onDone || $[5] !== onKill || $[6] !== task.status) { - t3 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && task.status === "running" && onKill) { - e.preventDefault(); - onKill(); - } + + // Turns with text to show. Tool-only turns (text='') are dropped entirely — + // the per-turn toolUseCount already captures that work. + const visibleTurns = task.turns.filter(t => t.text !== '') + const shown = visibleTurns.slice(-VISIBLE_TURNS) + const hidden = visibleTurns.length - shown.length + + return ( + + + {elapsedTime} · reviewing {task.sessionsReviewing}{' '} + {plural(task.sessionsReviewing, 'session')} + {task.filesTouched.length > 0 && ( + <> + {' '} + · {task.filesTouched.length}{' '} + {plural(task.filesTouched.length, 'file')} touched + + )} + } - } - }; - $[3] = onBack; - $[4] = onDone; - $[5] = onKill; - $[6] = task.status; - $[7] = t3; - } else { - t3 = $[7]; - } - const handleKeyDown = t3; - let T0; - let T1; - let T2; - let t10; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[8] !== elapsedTime || $[9] !== handleKeyDown || $[10] !== onBack || $[11] !== onDone || $[12] !== onKill || $[13] !== task.filesTouched.length || $[14] !== task.sessionsReviewing || $[15] !== task.status || $[16] !== task.turns) { - const visibleTurns = task.turns.filter(_temp); - const shown = visibleTurns.slice(-VISIBLE_TURNS); - const hidden = visibleTurns.length - shown.length; - T2 = Box; - t13 = "column"; - t14 = 0; - t15 = true; - t16 = handleKeyDown; - T1 = Dialog; - t8 = "Memory consolidation"; - const t17 = task.sessionsReviewing; - let t18; - if ($[33] !== task.sessionsReviewing) { - t18 = plural(task.sessionsReviewing, "session"); - $[33] = task.sessionsReviewing; - $[34] = t18; - } else { - t18 = $[34]; - } - let t19; - if ($[35] !== task.filesTouched.length) { - t19 = task.filesTouched.length > 0 && <>{" "}· {task.filesTouched.length}{" "}{plural(task.filesTouched.length, "file")} touched; - $[35] = task.filesTouched.length; - $[36] = t19; - } else { - t19 = $[36]; - } - if ($[37] !== elapsedTime || $[38] !== t18 || $[39] !== t19 || $[40] !== task.sessionsReviewing) { - t9 = {elapsedTime} · reviewing {t17}{" "}{t18}{t19}; - $[37] = elapsedTime; - $[38] = t18; - $[39] = t19; - $[40] = task.sessionsReviewing; - $[41] = t9; - } else { - t9 = $[41]; - } - t10 = onDone; - t11 = "background"; - if ($[42] !== onBack || $[43] !== onKill || $[44] !== task.status) { - t12 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{task.status === "running" && onKill && }; - $[42] = onBack; - $[43] = onKill; - $[44] = task.status; - $[45] = t12; - } else { - t12 = $[45]; - } - T0 = Box; - t4 = "column"; - t5 = 1; - let t20; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t20 = Status:; - $[46] = t20; - } else { - t20 = $[46]; - } - if ($[47] !== task.status) { - t6 = {t20}{" "}{task.status === "running" ? running : task.status === "completed" ? {task.status} : {task.status}}; - $[47] = task.status; - $[48] = t6; - } else { - t6 = $[48]; - } - t7 = shown.length === 0 ? {task.status === "running" ? "Starting\u2026" : "(no text output)"} : <>{hidden > 0 && ({hidden} earlier {plural(hidden, "turn")})}{shown.map(_temp2)}; - $[8] = elapsedTime; - $[9] = handleKeyDown; - $[10] = onBack; - $[11] = onDone; - $[12] = onKill; - $[13] = task.filesTouched.length; - $[14] = task.sessionsReviewing; - $[15] = task.status; - $[16] = task.turns; - $[17] = T0; - $[18] = T1; - $[19] = T2; - $[20] = t10; - $[21] = t11; - $[22] = t12; - $[23] = t13; - $[24] = t14; - $[25] = t15; - $[26] = t16; - $[27] = t4; - $[28] = t5; - $[29] = t6; - $[30] = t7; - $[31] = t8; - $[32] = t9; - } else { - T0 = $[17]; - T1 = $[18]; - T2 = $[19]; - t10 = $[20]; - t11 = $[21]; - t12 = $[22]; - t13 = $[23]; - t14 = $[24]; - t15 = $[25]; - t16 = $[26]; - t4 = $[27]; - t5 = $[28]; - t6 = $[29]; - t7 = $[30]; - t8 = $[31]; - t9 = $[32]; - } - let t17; - if ($[49] !== T0 || $[50] !== t4 || $[51] !== t5 || $[52] !== t6 || $[53] !== t7) { - t17 = {t6}{t7}; - $[49] = T0; - $[50] = t4; - $[51] = t5; - $[52] = t6; - $[53] = t7; - $[54] = t17; - } else { - t17 = $[54]; - } - let t18; - if ($[55] !== T1 || $[56] !== t10 || $[57] !== t11 || $[58] !== t12 || $[59] !== t17 || $[60] !== t8 || $[61] !== t9) { - t18 = {t17}; - $[55] = T1; - $[56] = t10; - $[57] = t11; - $[58] = t12; - $[59] = t17; - $[60] = t8; - $[61] = t9; - $[62] = t18; - } else { - t18 = $[62]; - } - let t19; - if ($[63] !== T2 || $[64] !== t13 || $[65] !== t14 || $[66] !== t15 || $[67] !== t16 || $[68] !== t18) { - t19 = {t18}; - $[63] = T2; - $[64] = t13; - $[65] = t14; - $[66] = t15; - $[67] = t16; - $[68] = t18; - $[69] = t19; - } else { - t19 = $[69]; - } - return t19; -} -function _temp2(turn, i) { - return {turn.text}{turn.toolUseCount > 0 && {" "}({turn.toolUseCount}{" "}{plural(turn.toolUseCount, "tool")})}; -} -function _temp(t) { - return t.text !== ""; + onCancel={onDone} + color="background" + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {task.status === 'running' && onKill && ( + + )} + + ) + } + > + + + Status:{' '} + {task.status === 'running' ? ( + running + ) : task.status === 'completed' ? ( + {task.status} + ) : ( + {task.status} + )} + + + {shown.length === 0 ? ( + + {task.status === 'running' ? 'Starting…' : '(no text output)'} + + ) : ( + <> + {hidden > 0 && ( + + ({hidden} earlier {plural(hidden, 'turn')}) + + )} + {shown.map((turn, i) => ( + + {turn.text} + {turn.toolUseCount > 0 && ( + + {' '}({turn.toolUseCount}{' '} + {plural(turn.toolUseCount, 'tool')}) + + )} + + ))} + + )} + + + + ) } diff --git a/src/components/tasks/InProcessTeammateDetailDialog.tsx b/src/components/tasks/InProcessTeammateDetailDialog.tsx index c8b25f80f..b59bbbd5e 100644 --- a/src/components/tasks/InProcessTeammateDetailDialog.tsx +++ b/src/components/tasks/InProcessTeammateDetailDialog.tsx @@ -1,265 +1,193 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useMemo } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { getEmptyToolPermissionContext } from '../../Tool.js'; -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { getTools } from '../../tools.js'; -import { formatNumber, truncateToWidth } from '../../utils/format.js'; -import { toInkColor } from '../../utils/ink.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { renderToolActivity } from './renderToolActivity.js'; -import { describeTeammateActivity } from './taskStatusUtils.js'; +import React, { useMemo } from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' +import { getTools } from '../../tools.js' +import { formatNumber, truncateToWidth } from '../../utils/format.js' +import { toInkColor } from '../../utils/ink.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { renderToolActivity } from './renderToolActivity.js' +import { describeTeammateActivity } from './taskStatusUtils.js' + type Props = { - teammate: DeepImmutable; - onDone: () => void; - onKill?: () => void; - onBack?: () => void; - onForeground?: () => void; -}; -export function InProcessTeammateDetailDialog(t0) { - const $ = _c(63); - const { - teammate, - onDone, - onKill, - onBack, - onForeground - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTools(getEmptyToolPermissionContext()); - $[0] = t1; - } else { - t1 = $[0]; - } - const tools = t1; - const elapsedTime = useElapsedTime(teammate.startTime, teammate.status === "running", 1000, teammate.totalPausedMs ?? 0); - let t2; - if ($[1] !== onDone) { - t2 = { - "confirm:yes": onDone - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - context: "Confirmation" - }; - $[3] = t3; - } else { - t3 = $[3]; - } - useKeybindings(t2, t3); - let t4; - if ($[4] !== onBack || $[5] !== onDone || $[6] !== onForeground || $[7] !== onKill || $[8] !== teammate.status) { - t4 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone(); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && teammate.status === "running" && onKill) { - e.preventDefault(); - onKill(); - } else { - if (e.key === "f" && teammate.status === "running" && onForeground) { - e.preventDefault(); - onForeground(); - } - } - } - } - }; - $[4] = onBack; - $[5] = onDone; - $[6] = onForeground; - $[7] = onKill; - $[8] = teammate.status; - $[9] = t4; - } else { - t4 = $[9]; - } - const handleKeyDown = t4; - let t5; - if ($[10] !== teammate) { - t5 = describeTeammateActivity(teammate); - $[10] = teammate; - $[11] = t5; - } else { - t5 = $[11]; - } - const activity = t5; - const tokenCount = teammate.result?.totalTokens ?? teammate.progress?.tokenCount; - const toolUseCount = teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount; - let t6; - if ($[12] !== teammate.prompt) { - t6 = truncateToWidth(teammate.prompt, 300); - $[12] = teammate.prompt; - $[13] = t6; - } else { - t6 = $[13]; - } - const displayPrompt = t6; - let t7; - if ($[14] !== teammate.identity.color) { - t7 = toInkColor(teammate.identity.color); - $[14] = teammate.identity.color; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t7 || $[17] !== teammate.identity.agentName) { - t8 = @{teammate.identity.agentName}; - $[16] = t7; - $[17] = teammate.identity.agentName; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== activity) { - t9 = activity && ({activity}); - $[19] = activity; - $[20] = t9; - } else { - t9 = $[20]; - } - let t10; - if ($[21] !== t8 || $[22] !== t9) { - t10 = {t8}{t9}; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const title = t10; - let t11; - if ($[24] !== teammate.status) { - t11 = teammate.status !== "running" && {teammate.status === "completed" ? "Completed" : teammate.status === "failed" ? "Failed" : "Stopped"}{" \xB7 "}; - $[24] = teammate.status; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== tokenCount) { - t12 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[26] = tokenCount; - $[27] = t12; - } else { - t12 = $[27]; - } - let t13; - if ($[28] !== toolUseCount) { - t13 = toolUseCount !== undefined && toolUseCount > 0 && <>{" "}· {toolUseCount} {toolUseCount === 1 ? "tool" : "tools"}; - $[28] = toolUseCount; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== elapsedTime || $[31] !== t12 || $[32] !== t13) { - t14 = {elapsedTime}{t12}{t13}; - $[30] = elapsedTime; - $[31] = t12; - $[32] = t13; - $[33] = t14; - } else { - t14 = $[33]; - } - let t15; - if ($[34] !== t11 || $[35] !== t14) { - t15 = {t11}{t14}; - $[34] = t11; - $[35] = t14; - $[36] = t15; - } else { - t15 = $[36]; - } - const subtitle = t15; - let t16; - if ($[37] !== onBack || $[38] !== onForeground || $[39] !== onKill || $[40] !== teammate.status) { - t16 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{teammate.status === "running" && onKill && }{teammate.status === "running" && onForeground && }; - $[37] = onBack; - $[38] = onForeground; - $[39] = onKill; - $[40] = teammate.status; - $[41] = t16; - } else { - t16 = $[41]; - } - let t17; - if ($[42] !== teammate.progress || $[43] !== teammate.status || $[44] !== theme) { - t17 = teammate.status === "running" && teammate.progress?.recentActivities && teammate.progress.recentActivities.length > 0 && Progress{teammate.progress.recentActivities.map((activity_0, i) => {i === teammate.progress.recentActivities.length - 1 ? "\u203A " : " "}{renderToolActivity(activity_0, tools, theme)})}; - $[42] = teammate.progress; - $[43] = teammate.status; - $[44] = theme; - $[45] = t17; - } else { - t17 = $[45]; - } - let t18; - if ($[46] === Symbol.for("react.memo_cache_sentinel")) { - t18 = Prompt; - $[46] = t18; - } else { - t18 = $[46]; - } - let t19; - if ($[47] !== displayPrompt) { - t19 = {t18}{displayPrompt}; - $[47] = displayPrompt; - $[48] = t19; - } else { - t19 = $[48]; - } - let t20; - if ($[49] !== teammate.error || $[50] !== teammate.status) { - t20 = teammate.status === "failed" && teammate.error && Error{teammate.error}; - $[49] = teammate.error; - $[50] = teammate.status; - $[51] = t20; - } else { - t20 = $[51]; - } - let t21; - if ($[52] !== onDone || $[53] !== subtitle || $[54] !== t16 || $[55] !== t17 || $[56] !== t19 || $[57] !== t20 || $[58] !== title) { - t21 = {t17}{t19}{t20}; - $[52] = onDone; - $[53] = subtitle; - $[54] = t16; - $[55] = t17; - $[56] = t19; - $[57] = t20; - $[58] = title; - $[59] = t21; - } else { - t21 = $[59]; - } - let t22; - if ($[60] !== handleKeyDown || $[61] !== t21) { - t22 = {t21}; - $[60] = handleKeyDown; - $[61] = t21; - $[62] = t22; - } else { - t22 = $[62]; - } - return t22; + teammate: DeepImmutable + onDone: () => void + onKill?: () => void + onBack?: () => void + onForeground?: () => void +} +export function InProcessTeammateDetailDialog({ + teammate, + onDone, + onKill, + onBack, + onForeground, +}: Props): React.ReactNode { + const [theme] = useTheme() + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + + const elapsedTime = useElapsedTime( + teammate.startTime, + teammate.status === 'running', + 1000, + teammate.totalPausedMs ?? 0, + ) + + // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) + useKeybindings( + { + 'confirm:yes': onDone, + }, + { context: 'Confirmation' }, + ) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone() + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && teammate.status === 'running' && onKill) { + e.preventDefault() + onKill() + } else if (e.key === 'f' && teammate.status === 'running' && onForeground) { + e.preventDefault() + onForeground() + } + } + + const activity = describeTeammateActivity(teammate) + + const tokenCount = + teammate.result?.totalTokens ?? teammate.progress?.tokenCount + const toolUseCount = + teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount + + const displayPrompt = truncateToWidth(teammate.prompt, 300) + + const title = ( + + + @{teammate.identity.agentName} + + {activity && ({activity})} + + ) + + const subtitle = ( + + {teammate.status !== 'running' && ( + + {teammate.status === 'completed' + ? 'Completed' + : teammate.status === 'failed' + ? 'Failed' + : 'Stopped'} + {' · '} + + )} + + {elapsedTime} + {tokenCount !== undefined && tokenCount > 0 && ( + <> · {formatNumber(tokenCount)} tokens + )} + {toolUseCount !== undefined && toolUseCount > 0 && ( + <> + {' '} + · {toolUseCount} {toolUseCount === 1 ? 'tool' : 'tools'} + + )} + + + ) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {teammate.status === 'running' && onKill && ( + + )} + {teammate.status === 'running' && onForeground && ( + + )} + + ) + } + > + {/* Recent activities for running teammates */} + {teammate.status === 'running' && + teammate.progress?.recentActivities && + teammate.progress.recentActivities.length > 0 && ( + + + Progress + + {teammate.progress.recentActivities.map((activity, i) => ( + + {i === teammate.progress!.recentActivities!.length - 1 + ? '› ' + : ' '} + {renderToolActivity(activity, tools, theme)} + + ))} + + )} + + {/* Prompt section */} + + + Prompt + + {displayPrompt} + + + {/* Error details if failed */} + {teammate.status === 'failed' && teammate.error && ( + + + Error + + + {teammate.error} + + + )} + + + ) } diff --git a/src/components/tasks/RemoteSessionDetailDialog.tsx b/src/components/tasks/RemoteSessionDetailDialog.tsx index f5435ead7..55c897fd9 100644 --- a/src/components/tasks/RemoteSessionDetailDialog.tsx +++ b/src/components/tasks/RemoteSessionDetailDialog.tsx @@ -1,41 +1,48 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useMemo, useState } from 'react'; -import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'; -import type { ToolUseContext } from 'src/Tool.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Link, Text } from '../../ink.js'; -import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'; -import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js'; -import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js'; -import { openBrowser } from '../../utils/browser.js'; -import { errorMessage } from '../../utils/errors.js'; -import { formatDuration, truncateToWidth } from '../../utils/format.js'; -import { toInternalMessages } from '../../utils/messages/mappers.js'; -import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; -import { plural } from '../../utils/stringUtils.js'; -import { teleportResumeCodeSession } from '../../utils/teleport.js'; -import { Select } from '../CustomSelect/select.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Message } from '../Message.js'; -import { formatReviewStageCounts, RemoteSessionProgress } from './RemoteSessionProgress.js'; +import figures from 'figures' +import React, { useMemo, useState } from 'react' +import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js' +import type { ToolUseContext } from 'src/Tool.js' +import type { DeepImmutable } from 'src/types/utils.js' +import type { CommandResultDisplay } from '../../commands.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { useElapsedTime } from '../../hooks/useElapsedTime.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Link, Text } from '../../ink.js' +import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { + AGENT_TOOL_NAME, + LEGACY_AGENT_TOOL_NAME, +} from '../../tools/AgentTool/constants.js' +import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js' +import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js' +import { openBrowser } from '../../utils/browser.js' +import { errorMessage } from '../../utils/errors.js' +import { formatDuration, truncateToWidth } from '../../utils/format.js' +import { toInternalMessages } from '../../utils/messages/mappers.js' +import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js' +import { plural } from '../../utils/stringUtils.js' +import { teleportResumeCodeSession } from '../../utils/teleport.js' +import { Select } from '../CustomSelect/select.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Message } from '../Message.js' +import { + formatReviewStageCounts, + RemoteSessionProgress, +} from './RemoteSessionProgress.js' + type Props = { - session: DeepImmutable; - toolUseContext: ToolUseContext; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onBack?: () => void; - onKill?: () => void; -}; + session: DeepImmutable + toolUseContext: ToolUseContext + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onBack?: () => void + onKill?: () => void +} // Compact one-line summary: tool name + first meaningful string arg. // Lighter than tool.renderToolUseMessage (no registry lookup / schema parse). @@ -44,746 +51,423 @@ type Props = { export function formatToolUseSummary(name: string, input: unknown): string { // plan_ready phase is only reached via ExitPlanMode tool if (name === EXIT_PLAN_MODE_V2_TOOL_NAME) { - return 'Review the plan in Claude Code on the web'; + return 'Review the plan in Claude Code on the web' } - if (!input || typeof input !== 'object') return name; + if (!input || typeof input !== 'object') return name // AskUserQuestion: show the question text as a CTA, not the tool name. // Input shape is {questions: [{question, header, options}]}. if (name === ASK_USER_QUESTION_TOOL_NAME && 'questions' in input) { - const qs = input.questions; + const qs = input.questions if (Array.isArray(qs) && qs[0] && typeof qs[0] === 'object') { // Prefer question (full text) over header (max-12-char tag). header // is a required schema field so checking it first would make the // question fallback dead code. - const q = 'question' in qs[0] && typeof qs[0].question === 'string' && qs[0].question ? qs[0].question : 'header' in qs[0] && typeof qs[0].header === 'string' ? qs[0].header : null; + const q = + 'question' in qs[0] && + typeof qs[0].question === 'string' && + qs[0].question + ? qs[0].question + : 'header' in qs[0] && typeof qs[0].header === 'string' + ? qs[0].header + : null if (q) { - const oneLine = q.replace(/\s+/g, ' ').trim(); - return `Answer in browser: ${truncateToWidth(oneLine, 50)}`; + const oneLine = q.replace(/\s+/g, ' ').trim() + return `Answer in browser: ${truncateToWidth(oneLine, 50)}` } } } for (const v of Object.values(input)) { if (typeof v === 'string' && v.trim()) { - const oneLine = v.replace(/\s+/g, ' ').trim(); - return `${name} ${truncateToWidth(oneLine, 60)}`; + const oneLine = v.replace(/\s+/g, ' ').trim() + return `${name} ${truncateToWidth(oneLine, 60)}` } } - return name; + return name } + const PHASE_LABEL = { needs_input: 'input required', - plan_ready: 'ready' -} as const; + plan_ready: 'ready', +} as const + const AGENT_VERB = { needs_input: 'waiting', - plan_ready: 'done' -} as const; -function UltraplanSessionDetail(t0) { - const $ = _c(70); - const { - session, - onDone, - onBack, - onKill - } = t0; - const running = session.status === "running" || session.status === "pending"; - const phase = session.ultraplanPhase; - const statusText = running ? phase ? PHASE_LABEL[phase] : "running" : session.status; - const elapsedTime = useElapsedTime(session.startTime, running, 1000, 0, session.endTime); - let spawns = 0; - let calls = 0; - let lastBlock = null; - for (const msg of session.log) { - if (msg.type !== "assistant") { - continue; - } - for (const block of msg.message.content) { - if (block.type !== "tool_use") { - continue; - } - calls++; - lastBlock = block; - if (block.name === AGENT_TOOL_NAME || block.name === LEGACY_AGENT_TOOL_NAME) { - spawns++; + plan_ready: 'done', +} as const + +function UltraplanSessionDetail({ + session, + onDone, + onBack, + onKill, +}: Omit): React.ReactNode { + const running = session.status === 'running' || session.status === 'pending' + const phase = session.ultraplanPhase + const statusText = running + ? phase + ? PHASE_LABEL[phase] + : 'running' + : session.status + const elapsedTime = useElapsedTime( + session.startTime, + running, + 1000, + 0, + session.endTime, + ) + + // Counts are eventually correct (lag ≤ poll interval). agentsWorking starts + // at 1 (the main session agent) and increments per subagent spawn. toolCalls + // is main-session only — subagent calls may not surface in this stream. + const { agentsWorking, toolCalls, lastToolCall } = useMemo(() => { + let spawns = 0 + let calls = 0 + let lastBlock: { name: string; input: unknown } | null = null + for (const msg of session.log) { + if (msg.type !== 'assistant') continue + for (const block of msg.message.content) { + if (block.type !== 'tool_use') continue + calls++ + lastBlock = block + if ( + block.name === AGENT_TOOL_NAME || + block.name === LEGACY_AGENT_TOOL_NAME + ) { + spawns++ + } } } - } - const t1 = 1 + spawns; - let t2; - if ($[0] !== lastBlock) { - t2 = lastBlock ? formatToolUseSummary(lastBlock.name, lastBlock.input) : null; - $[0] = lastBlock; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== calls || $[3] !== t1 || $[4] !== t2) { - t3 = { - agentsWorking: t1, + return { + agentsWorking: 1 + spawns, toolCalls: calls, - lastToolCall: t2 - }; - $[2] = calls; - $[3] = t1; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const { - agentsWorking, - toolCalls, - lastToolCall - } = t3; - let t4; - if ($[6] !== session.sessionId) { - t4 = getRemoteTaskSessionUrl(session.sessionId); - $[6] = session.sessionId; - $[7] = t4; - } else { - t4 = $[7]; - } - const sessionUrl = t4; - let t5; - if ($[8] !== onBack || $[9] !== onDone) { - t5 = onBack ?? (() => onDone("Remote session details dismissed", { - display: "system" - })); - $[8] = onBack; - $[9] = onDone; - $[10] = t5; - } else { - t5 = $[10]; - } - const goBackOrClose = t5; - const [confirmingStop, setConfirmingStop] = useState(false); + lastToolCall: lastBlock + ? formatToolUseSummary(lastBlock.name, lastBlock.input) + : null, + } + }, [session.log]) + + const sessionUrl = getRemoteTaskSessionUrl(session.sessionId) + const goBackOrClose = + onBack ?? + (() => onDone('Remote session details dismissed', { display: 'system' })) + const [confirmingStop, setConfirmingStop] = useState(false) + if (confirmingStop) { - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => setConfirmingStop(false); - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t7 = This will terminate the Claude Code on the web session.; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - label: "Terminate session", - value: "stop" as const - }; - $[13] = t8; - } else { - t8 = $[13]; - } - let t9; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [t8, { - label: "Back", - value: "back" as const - }]; - $[14] = t9; - } else { - t9 = $[14]; - } - let t10; - if ($[15] !== goBackOrClose || $[16] !== onKill) { - t10 = {t7} { + if (v === 'stop') { + onKill?.() + goBackOrClose() + } else { + setConfirmingStop(false) + } + }} + /> + + + ) } - const t6 = phase === "plan_ready" ? DIAMOND_FILLED : DIAMOND_OPEN; - let t7; - if ($[18] !== t6) { - t7 = {t6}{" "}; - $[18] = t6; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t8 = ultraplan; - $[20] = t8; - } else { - t8 = $[20]; - } - let t9; - if ($[21] !== elapsedTime || $[22] !== statusText) { - t9 = {" \xB7 "}{elapsedTime}{" \xB7 "}{statusText}; - $[21] = elapsedTime; - $[22] = statusText; - $[23] = t9; - } else { - t9 = $[23]; - } - let t10; - if ($[24] !== t7 || $[25] !== t9) { - t10 = {t7}{t8}{t9}; - $[24] = t7; - $[25] = t9; - $[26] = t10; - } else { - t10 = $[26]; - } - let t11; - if ($[27] !== phase) { - t11 = phase === "plan_ready" && {figures.tick} ; - $[27] = phase; - $[28] = t11; - } else { - t11 = $[28]; - } - let t12; - if ($[29] !== agentsWorking) { - t12 = plural(agentsWorking, "agent"); - $[29] = agentsWorking; - $[30] = t12; - } else { - t12 = $[30]; - } - const t13 = phase ? AGENT_VERB[phase] : "working"; - let t14; - if ($[31] !== toolCalls) { - t14 = plural(toolCalls, "call"); - $[31] = toolCalls; - $[32] = t14; - } else { - t14 = $[32]; - } - let t15; - if ($[33] !== agentsWorking || $[34] !== t11 || $[35] !== t12 || $[36] !== t13 || $[37] !== t14 || $[38] !== toolCalls) { - t15 = {t11}{agentsWorking} {t12}{" "}{t13} · {toolCalls} tool{" "}{t14}; - $[33] = agentsWorking; - $[34] = t11; - $[35] = t12; - $[36] = t13; - $[37] = t14; - $[38] = toolCalls; - $[39] = t15; - } else { - t15 = $[39]; - } - let t16; - if ($[40] !== lastToolCall) { - t16 = lastToolCall && {lastToolCall}; - $[40] = lastToolCall; - $[41] = t16; - } else { - t16 = $[41]; - } - let t17; - if ($[42] !== sessionUrl) { - t17 = {sessionUrl}; - $[42] = sessionUrl; - $[43] = t17; - } else { - t17 = $[43]; - } - let t18; - if ($[44] !== sessionUrl || $[45] !== t17) { - t18 = {t17}; - $[44] = sessionUrl; - $[45] = t17; - $[46] = t18; - } else { - t18 = $[46]; - } - let t19; - if ($[47] === Symbol.for("react.memo_cache_sentinel")) { - t19 = { - label: "Review in Claude Code on the web", - value: "open" as const - }; - $[47] = t19; - } else { - t19 = $[47]; - } - let t20; - if ($[48] !== onKill || $[49] !== running) { - t20 = onKill && running ? [{ - label: "Stop ultraplan", - value: "stop" as const - }] : []; - $[48] = onKill; - $[49] = running; - $[50] = t20; - } else { - t20 = $[50]; - } - let t21; - if ($[51] === Symbol.for("react.memo_cache_sentinel")) { - t21 = { - label: "Back", - value: "back" as const - }; - $[51] = t21; - } else { - t21 = $[51]; - } - let t22; - if ($[52] !== t20) { - t22 = [t19, ...t20, t21]; - $[52] = t20; - $[53] = t22; - } else { - t22 = $[53]; - } - let t23; - if ($[54] !== goBackOrClose || $[55] !== onDone || $[56] !== sessionUrl) { - t23 = v_0 => { - switch (v_0) { - case "open": - { - openBrowser(sessionUrl); - onDone(); - return; - } - case "stop": - { - setConfirmingStop(true); - return; - } - case "back": - { - goBackOrClose(); - return; - } + + return ( + + + {phase === 'plan_ready' ? DIAMOND_FILLED : DIAMOND_OPEN}{' '} + + ultraplan + + {' · '} + {elapsedTime} + {' · '} + {statusText} + + } - }; - $[54] = goBackOrClose; - $[55] = onDone; - $[56] = sessionUrl; - $[57] = t23; - } else { - t23 = $[57]; - } - let t24; - if ($[58] !== t22 || $[59] !== t23) { - t24 = { + switch (v) { + case 'open': + void openBrowser(sessionUrl) + // Close the dialog so the user lands back at the prompt with + // any half-written input intact (inputValue persists across + // the showBashesDialog toggle). + onDone() + return + case 'stop': + setConfirmingStop(true) + return + case 'back': + goBackOrClose() + return + } + }} + /> + + + ) } -const STAGES = ['finding', 'verifying', 'synthesizing'] as const; + +const STAGES = ['finding', 'verifying', 'synthesizing'] as const const STAGE_LABELS: Record<(typeof STAGES)[number], string> = { finding: 'Find', verifying: 'Verify', - synthesizing: 'Dedupe' -}; + synthesizing: 'Dedupe', +} // Setup → Find → Verify → Dedupe pipeline. Current stage in cloud teal, // rest dim. When completed, all stages dim with a trailing green ✓. The // "Setup" label shows before the orchestrator writes its first progress // snapshot (container boot + repo clone), so the 0-found display doesn't // look like a hung finder. -function StagePipeline(t0) { - const $ = _c(15); - const { - stage, - completed, - hasProgress - } = t0; - let t1; - if ($[0] !== stage) { - t1 = stage ? STAGES.indexOf(stage) : -1; - $[0] = stage; - $[1] = t1; - } else { - t1 = $[1]; - } - const currentIdx = t1; - const inSetup = !completed && !hasProgress; - let t2; - if ($[2] !== inSetup) { - t2 = inSetup ? Setup : Setup; - $[2] = inSetup; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== completed || $[6] !== currentIdx || $[7] !== inSetup) { - t4 = STAGES.map((s, i) => { - const isCurrent = !completed && !inSetup && i === currentIdx; - return {i > 0 && }{isCurrent ? {STAGE_LABELS[s]} : {STAGE_LABELS[s]}}; - }); - $[5] = completed; - $[6] = currentIdx; - $[7] = inSetup; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== completed) { - t5 = completed && ; - $[9] = completed; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t2 || $[12] !== t4 || $[13] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[11] = t2; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; +function StagePipeline({ + stage, + completed, + hasProgress, +}: { + stage: 'finding' | 'verifying' | 'synthesizing' | undefined + completed: boolean + hasProgress: boolean +}): React.ReactNode { + const currentIdx = stage ? STAGES.indexOf(stage) : -1 + const inSetup = !completed && !hasProgress + return ( + + {inSetup ? ( + Setup + ) : ( + Setup + )} + + {STAGES.map((s, i) => { + const isCurrent = !completed && !inSetup && i === currentIdx + return ( + + {i > 0 && } + {isCurrent ? ( + {STAGE_LABELS[s]} + ) : ( + {STAGE_LABELS[s]} + )} + + ) + })} + {completed && } + + ) } // Stage-appropriate counts line. Running-state formatting delegates to // formatReviewStageCounts (shared with the pill) so the two views can't // drift; completed state is dialog-specific (findings summary). -function reviewCountsLine(session: DeepImmutable): string { - const p = session.reviewProgress; +function reviewCountsLine( + session: DeepImmutable, +): string { + const p = session.reviewProgress // No progress data — the orchestrator never wrote a snapshot. Don't // claim "0 findings" when completed; we just don't know. - if (!p) return session.status === 'completed' ? 'done' : 'setting up'; - const verified = p.bugsVerified; - const refuted = p.bugsRefuted ?? 0; + if (!p) return session.status === 'completed' ? 'done' : 'setting up' + const verified = p.bugsVerified + const refuted = p.bugsRefuted ?? 0 if (session.status === 'completed') { - const parts = [`${verified} ${plural(verified, 'finding')}`]; - if (refuted > 0) parts.push(`${refuted} refuted`); - return parts.join(' · '); + const parts = [`${verified} ${plural(verified, 'finding')}`] + if (refuted > 0) parts.push(`${refuted} refuted`) + return parts.join(' · ') } - return formatReviewStageCounts(p.stage, p.bugsFound, verified, refuted); + return formatReviewStageCounts(p.stage, p.bugsFound, verified, refuted) } -type MenuAction = 'open' | 'stop' | 'back' | 'dismiss'; -function ReviewSessionDetail(t0) { - const $ = _c(56); - const { - session, - onDone, - onBack, - onKill - } = t0; - const completed = session.status === "completed"; - const running = session.status === "running" || session.status === "pending"; - const [confirmingStop, setConfirmingStop] = useState(false); - const elapsedTime = useElapsedTime(session.startTime, running, 1000, 0, session.endTime); - let t1; - if ($[0] !== onDone) { - t1 = () => onDone("Remote session details dismissed", { - display: "system" - }); - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleClose = t1; - const goBackOrClose = onBack ?? handleClose; - let t2; - if ($[2] !== session.sessionId) { - t2 = getRemoteTaskSessionUrl(session.sessionId); - $[2] = session.sessionId; - $[3] = t2; - } else { - t2 = $[3]; - } - const sessionUrl = t2; - const statusLabel = completed ? "ready" : running ? "running" : session.status; + +type MenuAction = 'open' | 'stop' | 'back' | 'dismiss' + +function ReviewSessionDetail({ + session, + onDone, + onBack, + onKill, +}: Omit): React.ReactNode { + const completed = session.status === 'completed' + const running = session.status === 'running' || session.status === 'pending' + const [confirmingStop, setConfirmingStop] = useState(false) + + // useElapsedTime drives the 1Hz tick so the timer advances while the + // dialog is open — the previous inline elapsed-time calculation only + // re-rendered on session state changes (poll interval), which looked + // like the clock was stuck. + const elapsedTime = useElapsedTime( + session.startTime, + running, + 1000, + 0, + session.endTime, + ) + + const handleClose = () => + onDone('Remote session details dismissed', { display: 'system' }) + const goBackOrClose = onBack ?? handleClose + + const sessionUrl = getRemoteTaskSessionUrl(session.sessionId) + const statusLabel = completed ? 'ready' : running ? 'running' : session.status + if (confirmingStop) { - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => setConfirmingStop(false); - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = This archives the remote session and stops local tracking. The review will not complete and any findings so far are discarded.; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Stop ultrareview", - value: "stop" as const - }; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [t5, { - label: "Back", - value: "back" as const - }]; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== goBackOrClose || $[9] !== onKill) { - t7 = {t4} { + if (v === 'stop') { + onKill?.() + goBackOrClose() + } else { + setConfirmingStop(false) + } + }} + /> + + + ) } - let t3; - if ($[11] !== completed || $[12] !== onKill || $[13] !== running) { - t3 = completed ? [{ - label: "Open in Claude Code on the web", - value: "open" - }, { - label: "Dismiss", - value: "dismiss" - }] : [{ - label: "Open in Claude Code on the web", - value: "open" - }, ...(onKill && running ? [{ - label: "Stop ultrareview", - value: "stop" as const - }] : []), { - label: "Back", - value: "back" - }]; - $[11] = completed; - $[12] = onKill; - $[13] = running; - $[14] = t3; - } else { - t3 = $[14]; + + const options: { label: string; value: MenuAction }[] = completed + ? [ + { label: 'Open in Claude Code on the web', value: 'open' }, + { label: 'Dismiss', value: 'dismiss' }, + ] + : [ + { label: 'Open in Claude Code on the web', value: 'open' }, + ...(onKill && running + ? [{ label: 'Stop ultrareview', value: 'stop' as const }] + : []), + { label: 'Back', value: 'back' }, + ] + + const handleSelect = (action: MenuAction) => { + switch (action) { + case 'open': + void openBrowser(sessionUrl) + onDone() + break + case 'stop': + setConfirmingStop(true) + break + case 'back': + goBackOrClose() + break + case 'dismiss': + handleClose() + break + } } - const options = t3; - let t4; - if ($[15] !== goBackOrClose || $[16] !== handleClose || $[17] !== onDone || $[18] !== sessionUrl) { - t4 = action => { - bb45: switch (action) { - case "open": - { - openBrowser(sessionUrl); - onDone(); - break bb45; - } - case "stop": - { - setConfirmingStop(true); - break bb45; - } - case "back": - { - goBackOrClose(); - break bb45; - } - case "dismiss": - { - handleClose(); - } + + return ( + + + {completed ? DIAMOND_FILLED : DIAMOND_OPEN}{' '} + + ultrareview + + {' · '} + {elapsedTime} + {' · '} + {statusLabel} + + } - }; - $[15] = goBackOrClose; - $[16] = handleClose; - $[17] = onDone; - $[18] = sessionUrl; - $[19] = t4; - } else { - t4 = $[19]; - } - const handleSelect = t4; - const t5 = completed ? DIAMOND_FILLED : DIAMOND_OPEN; - let t6; - if ($[20] !== t5) { - t6 = {t5}{" "}; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - let t7; - if ($[22] === Symbol.for("react.memo_cache_sentinel")) { - t7 = ultrareview; - $[22] = t7; - } else { - t7 = $[22]; - } - let t8; - if ($[23] !== elapsedTime || $[24] !== statusLabel) { - t8 = {" \xB7 "}{elapsedTime}{" \xB7 "}{statusLabel}; - $[23] = elapsedTime; - $[24] = statusLabel; - $[25] = t8; - } else { - t8 = $[25]; - } - let t9; - if ($[26] !== t6 || $[27] !== t8) { - t9 = {t6}{t7}{t8}; - $[26] = t6; - $[27] = t8; - $[28] = t9; - } else { - t9 = $[28]; - } - const t10 = session.reviewProgress?.stage; - const t11 = !!session.reviewProgress; - let t12; - if ($[29] !== completed || $[30] !== t10 || $[31] !== t11) { - t12 = ; - $[29] = completed; - $[30] = t10; - $[31] = t11; - $[32] = t12; - } else { - t12 = $[32]; - } - let t13; - if ($[33] !== session) { - t13 = reviewCountsLine(session); - $[33] = session; - $[34] = t13; - } else { - t13 = $[34]; - } - let t14; - if ($[35] !== t13) { - t14 = {t13}; - $[35] = t13; - $[36] = t14; - } else { - t14 = $[36]; - } - let t15; - if ($[37] !== sessionUrl) { - t15 = {sessionUrl}; - $[37] = sessionUrl; - $[38] = t15; - } else { - t15 = $[38]; - } - let t16; - if ($[39] !== sessionUrl || $[40] !== t15) { - t16 = {t15}; - $[39] = sessionUrl; - $[40] = t15; - $[41] = t16; - } else { - t16 = $[41]; - } - let t17; - if ($[42] !== t14 || $[43] !== t16) { - t17 = {t14}{t16}; - $[42] = t14; - $[43] = t16; - $[44] = t17; - } else { - t17 = $[44]; - } - let t18; - if ($[45] !== handleSelect || $[46] !== options) { - t18 = + + + ) } + export function RemoteSessionDetailDialog({ session, toolUseContext, onDone, onBack, - onKill + onKill, }: Props): React.ReactNode { - const [isTeleporting, setIsTeleporting] = useState(false); - const [teleportError, setTeleportError] = useState(null); + const [isTeleporting, setIsTeleporting] = useState(false) + const [teleportError, setTeleportError] = useState(null) // Get last few messages from remote session for display. // Scan all messages (not just the last 3 raw entries) because the tail of @@ -791,74 +475,119 @@ export function RemoteSessionDetailDialog({ // Placed before the early returns so hook call order is stable (Rules of Hooks). // Ultraplan/review sessions never read this — skip the normalize work for them. const lastMessages = useMemo(() => { - if (session.isUltraplan || session.isRemoteReview) return []; - return normalizeMessages(toInternalMessages(session.log as SDKMessage[])).filter(_ => _.type !== 'progress').slice(-3); - }, [session]); + if (session.isUltraplan || session.isRemoteReview) return [] + return normalizeMessages(toInternalMessages(session.log as SDKMessage[])) + .filter(_ => _.type !== 'progress') + .slice(-3) + }, [session]) + if (session.isUltraplan) { - return ; + return ( + + ) } // Review sessions get the stage-pipeline view; everything else keeps the // generic label/value + recent-messages dialog below. if (session.isRemoteReview) { - return ; + return ( + + ) } - const handleClose = () => onDone('Remote session details dismissed', { - display: 'system' - }); + + const handleClose = () => + onDone('Remote session details dismissed', { display: 'system' }) // Component-specific shortcuts shown in UI hints (t=teleport, space=dismiss, // left=back). These are state-dependent actions, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { if (e.key === ' ') { - e.preventDefault(); - onDone('Remote session details dismissed', { - display: 'system' - }); + e.preventDefault() + onDone('Remote session details dismissed', { display: 'system' }) } else if (e.key === 'left' && onBack) { - e.preventDefault(); - onBack(); + e.preventDefault() + onBack() } else if (e.key === 't' && !isTeleporting) { - e.preventDefault(); - void handleTeleport(); + e.preventDefault() + void handleTeleport() } else if (e.key === 'return') { - e.preventDefault(); - handleClose(); + e.preventDefault() + handleClose() } - }; + } // Handle teleporting to remote session async function handleTeleport(): Promise { - setIsTeleporting(true); - setTeleportError(null); + setIsTeleporting(true) + setTeleportError(null) + try { - await teleportResumeCodeSession(session.sessionId); + await teleportResumeCodeSession(session.sessionId) } catch (err) { - setTeleportError(errorMessage(err)); + setTeleportError(errorMessage(err)) } finally { - setIsTeleporting(false); + setIsTeleporting(false) } } // Truncate title if too long (for display purposes) - const displayTitle = truncateToWidth(session.title, 50); + const displayTitle = truncateToWidth(session.title, 50) // Map TaskStatus to display status (handle 'pending') - const displayStatus = session.status === 'pending' ? 'starting' : session.status; - return - exitState.pending ? Press {exitState.keyName} again to exit : + const displayStatus = + session.status === 'pending' ? 'starting' : session.status + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + {onBack && } - {!isTeleporting && } - }> + {!isTeleporting && ( + + )} + + ) + } + > Status:{' '} - {displayStatus === 'running' || displayStatus === 'starting' ? {displayStatus} : displayStatus === 'completed' ? {displayStatus} : {displayStatus}} + {displayStatus === 'running' || displayStatus === 'starting' ? ( + {displayStatus} + ) : displayStatus === 'completed' ? ( + {displayStatus} + ) : ( + {displayStatus} + )} Runtime:{' '} - {formatDuration((session.endTime ?? Date.now()) - session.startTime)} + {formatDuration( + (session.endTime ?? Date.now()) - session.startTime, + )} Title: {displayTitle} @@ -876,12 +605,30 @@ export function RemoteSessionDetailDialog({ {/* Remote session messages section */} - {session.log.length > 0 && + {session.log.length > 0 && ( + Recent messages: - {lastMessages.map((msg, i) => 0} tools={toolUseContext.options.tools} commands={toolUseContext.options.commands} verbose={toolUseContext.options.verbose} inProgressToolUseIDs={new Set()} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} />)} + {lastMessages.map((msg, i) => ( + 0} + tools={toolUseContext.options.tools} + commands={toolUseContext.options.commands} + verbose={toolUseContext.options.verbose} + inProgressToolUseIDs={new Set()} + progressMessagesForMessage={[]} + shouldAnimate={false} + shouldShowDot={false} + style="condensed" + isTranscriptMode={false} + isStatic={true} + /> + ))} @@ -889,15 +636,21 @@ export function RemoteSessionDetailDialog({ messages - } + + )} {/* Teleport error message */} - {teleportError && + {teleportError && ( + Teleport failed: {teleportError} - } + + )} {/* Teleporting status */} - {isTeleporting && Teleporting to session…} + {isTeleporting && ( + Teleporting to session… + )} - ; + + ) } diff --git a/src/components/tasks/RemoteSessionProgress.tsx b/src/components/tasks/RemoteSessionProgress.tsx index 2da0140a5..c1711cd8a 100644 --- a/src/components/tasks/RemoteSessionProgress.tsx +++ b/src/components/tasks/RemoteSessionProgress.tsx @@ -1,14 +1,17 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useRef } from 'react'; -import type { RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; -import { useSettings } from '../../hooks/useSettings.js'; -import { Text, useAnimationFrame } from '../../ink.js'; -import { count } from '../../utils/array.js'; -import { getRainbowColor } from '../../utils/thinking.js'; -const TICK_MS = 80; -type ReviewStage = NonNullable['stage']>; +import React, { useRef } from 'react' +import type { RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' +import { useSettings } from '../../hooks/useSettings.js' +import { Text, useAnimationFrame } from '../../ink.js' +import { count } from '../../utils/array.js' +import { getRainbowColor } from '../../utils/thinking.js' + +const TICK_MS = 80 + +type ReviewStage = NonNullable< + NonNullable['stage'] +> /** * Stage-appropriate counts line for a running review. Shared between the @@ -19,52 +22,48 @@ type ReviewStage = NonNullable 0) parts.push(`${refuted} refuted`); - parts.push('deduping'); - return parts.join(' · '); + const parts = [`${verified} verified`] + if (refuted > 0) parts.push(`${refuted} refuted`) + parts.push('deduping') + return parts.join(' · ') } if (stage === 'verifying') { - const parts = [`${found} found`, `${verified} verified`]; - if (refuted > 0) parts.push(`${refuted} refuted`); - return parts.join(' · '); + const parts = [`${found} found`, `${verified} verified`] + if (refuted > 0) parts.push(`${refuted} refuted`) + return parts.join(' · ') } // stage === 'finding' - return found > 0 ? `${found} found` : 'finding'; + return found > 0 ? `${found} found` : 'finding' } // Per-character rainbow gradient, same treatment as the ultraplan keyword. // The phase offset lets the gradient cycle — so the colors sweep along the // text on each animation frame instead of being static. -function RainbowText(t0) { - const $ = _c(5); - const { - text, - phase: t1 - } = t0; - const phase = t1 === undefined ? 0 : t1; - let t2; - if ($[0] !== text) { - t2 = [...text]; - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== phase || $[3] !== t2) { - t3 = <>{t2.map((ch, i) => {ch})}; - $[2] = phase; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; +function RainbowText({ + text, + phase = 0, +}: { + text: string + phase?: number +}): React.ReactNode { + return ( + <> + {[...text].map((ch, i) => ( + + {ch} + + ))} + + ) } // Smooth-tick a count toward target, +1 per frame. Same pattern as the @@ -74,169 +73,129 @@ function RainbowText(t0) { // the clock is frozen), bypass the tick and jump straight to target — // otherwise a frozen `time` would leave the ref stuck at its init value. function useSmoothCount(target: number, time: number, snap: boolean): number { - const displayed = useRef(target); - const lastTick = useRef(time); + const displayed = useRef(target) + const lastTick = useRef(time) if (snap || target < displayed.current) { - displayed.current = target; + displayed.current = target } else if (target > displayed.current && time !== lastTick.current) { - displayed.current += 1; - lastTick.current = time; + displayed.current += 1 + lastTick.current = time } - return displayed.current; + return displayed.current } -function ReviewRainbowLine(t0) { - const $ = _c(15); - const { - session - } = t0; - const settings = useSettings(); - const reducedMotion = settings.prefersReducedMotion ?? false; - const p = session.reviewProgress; - const running = session.status === "running"; - const [, time] = useAnimationFrame(running && !reducedMotion ? TICK_MS : null); - const targetFound = p?.bugsFound ?? 0; - const targetVerified = p?.bugsVerified ?? 0; - const targetRefuted = p?.bugsRefuted ?? 0; - const snap = reducedMotion || !running; - const found = useSmoothCount(targetFound, time, snap); - const verified = useSmoothCount(targetVerified, time, snap); - const refuted = useSmoothCount(targetRefuted, time, snap); - const phase = Math.floor(time / (TICK_MS * 3)) % 7; - if (session.status === "completed") { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = <>{DIAMOND_FILLED} ready · shift+↓ to view; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + +function ReviewRainbowLine({ + session, +}: { + session: DeepImmutable +}): React.ReactNode { + const settings = useSettings() + const reducedMotion = settings.prefersReducedMotion ?? false + const p = session.reviewProgress + const running = session.status === 'running' + // Animation clock runs only while running — completed/failed are static. + // Disabled entirely when the user prefers reduced motion. + // + // The ref is intentionally discarded: this component is rendered inside + // wrappers (BackgroundTasksDialog, RemoteSessionDetailDialog), and + // Ink can't nest inside . Dropping the ref means + // useTerminalViewport's isVisible stays true, so the clock ticks even when + // scrolled off-screen — acceptable for a single 30-char line. + const [, time] = useAnimationFrame(running && !reducedMotion ? TICK_MS : null) + + const targetFound = p?.bugsFound ?? 0 + const targetVerified = p?.bugsVerified ?? 0 + const targetRefuted = p?.bugsRefuted ?? 0 + // snap when the clock isn't advancing (reduced motion, or not running) — + // useAnimationFrame(null) freezes `time` at its mount value, which would + // leave the tick-gate permanently false. + const snap = reducedMotion || !running + const found = useSmoothCount(targetFound, time, snap) + const verified = useSmoothCount(targetVerified, time, snap) + const refuted = useSmoothCount(targetRefuted, time, snap) + + // Phase advances every 3 ticks so the gradient sweep is visible but + // not frantic. Modulo keeps it in the 7-color cycle. + const phase = Math.floor(time / (TICK_MS * 3)) % 7 + + // ◇ open diamond while running (teal, matches cloud-session accent), ◆ + // filled when terminal. Rainbow is scoped to the word `ultrareview` only — + // per design feedback, "there is a limit to the glittering rainbow". + // Counts stay dimColor. + if (session.status === 'completed') { + return ( + <> + {DIAMOND_FILLED} + + ready · shift+↓ to view + + ) } - if (session.status === "failed") { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = <>{DIAMOND_FILLED} {" \xB7 "}error; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + if (session.status === 'failed') { + return ( + <> + {DIAMOND_FILLED} + + + {' · '} + error + + + ) } - let t1; - if ($[2] !== found || $[3] !== p || $[4] !== refuted || $[5] !== verified) { - t1 = !p ? "setting up" : formatReviewStageCounts(p.stage, found, verified, refuted); - $[2] = found; - $[3] = p; - $[4] = refuted; - $[5] = verified; - $[6] = t1; - } else { - t1 = $[6]; - } - const tail = t1; - let t2; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {DIAMOND_OPEN} ; - $[7] = t2; - } else { - t2 = $[7]; - } - const t3 = running ? phase : 0; - let t4; - if ($[8] !== t3) { - t4 = ; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== tail) { - t5 = · {tail}; - $[10] = tail; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t4 || $[13] !== t5) { - t6 = <>{t2}{t4}{t5}; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + + // The !p branch ("setting up") covers the window before the orchestrator + // writes its first progress snapshot — container boot + repo clone can + // take 1-3 min, during which "0 found" looked hung. + const tail = !p + ? 'setting up' + : formatReviewStageCounts(p.stage, found, verified, refuted) + return ( + <> + {DIAMOND_OPEN} + + · {tail} + + ) } -export function RemoteSessionProgress(t0) { - const $ = _c(11); - const { - session - } = t0; + +export function RemoteSessionProgress({ + session, +}: { + session: DeepImmutable +}): React.ReactNode { + // Lite-review: rainbow gradient over the full line, ultraplan-style. + // BackgroundTask.tsx delegates the whole wrapper here so the + // gradient spans the title, not just the trailing status. if (session.isRemoteReview) { - let t1; - if ($[0] !== session) { - t1 = ; - $[0] = session; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + return } - if (session.status === "completed") { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = done; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; + + if (session.status === 'completed') { + return ( + + done + + ) } - if (session.status === "failed") { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = error; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + + if (session.status === 'failed') { + return ( + + error + + ) } + if (!session.todoList.length) { - let t1; - if ($[4] !== session.status) { - t1 = {session.status}…; - $[4] = session.status; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; + return {session.status}… } - let t1; - if ($[6] !== session.todoList) { - t1 = count(session.todoList, _temp); - $[6] = session.todoList; - $[7] = t1; - } else { - t1 = $[7]; - } - const completed = t1; - const total = session.todoList.length; - let t2; - if ($[8] !== completed || $[9] !== total) { - t2 = {completed}/{total}; - $[8] = completed; - $[9] = total; - $[10] = t2; - } else { - t2 = $[10]; - } - return t2; -} -function _temp(_) { - return _.status === "completed"; + + const completed = count(session.todoList, _ => _.status === 'completed') + const total = session.todoList.length + return ( + + {completed}/{total} + + ) } diff --git a/src/components/tasks/ShellDetailDialog.tsx b/src/components/tasks/ShellDetailDialog.tsx index d42472c31..a81bafc8b 100644 --- a/src/components/tasks/ShellDetailDialog.tsx +++ b/src/components/tasks/ShellDetailDialog.tsx @@ -1,403 +1,247 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Suspense, use, useDeferredValue, useEffect, useState } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { LocalShellTaskState } from '../../tasks/LocalShellTask/guards.js'; -import { formatDuration, formatFileSize, truncateToWidth } from '../../utils/format.js'; -import { tailFile } from '../../utils/fsOperations.js'; -import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React, { + Suspense, + use, + useDeferredValue, + useEffect, + useState, +} from 'react' +import type { DeepImmutable } from 'src/types/utils.js' +import type { CommandResultDisplay } from '../../commands.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' +import { Box, Text } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { LocalShellTaskState } from '../../tasks/LocalShellTask/guards.js' +import { + formatDuration, + formatFileSize, + truncateToWidth, +} from '../../utils/format.js' +import { tailFile } from '../../utils/fsOperations.js' +import { getTaskOutputPath } from '../../utils/task/diskOutput.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - shell: DeepImmutable; - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - onKillShell?: () => void; - onBack?: () => void; -}; -const SHELL_DETAIL_TAIL_BYTES = 8192; + shell: DeepImmutable + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + onKillShell?: () => void + onBack?: () => void +} + +const SHELL_DETAIL_TAIL_BYTES = 8192 + type TaskOutputResult = { - content: string; - bytesTotal: number; -}; + content: string + bytesTotal: number +} /** * Read the tail of the task output file. Only reads the last few KB, * not the entire file. */ -async function getTaskOutput(shell: DeepImmutable): Promise { - const path = getTaskOutputPath(shell.id); +async function getTaskOutput( + shell: DeepImmutable, +): Promise { + const path = getTaskOutputPath(shell.id) try { - const result = await tailFile(path, SHELL_DETAIL_TAIL_BYTES); - return { - content: result.content, - bytesTotal: result.bytesTotal - }; + const result = await tailFile(path, SHELL_DETAIL_TAIL_BYTES) + return { content: result.content, bytesTotal: result.bytesTotal } } catch { - return { - content: '', - bytesTotal: 0 - }; + return { content: '', bytesTotal: 0 } } } -export function ShellDetailDialog(t0) { - const $ = _c(57); - const { - shell, - onDone, - onKillShell, - onBack - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== shell) { - t1 = () => getTaskOutput(shell); - $[0] = shell; - $[1] = t1; - } else { - t1 = $[1]; + +export function ShellDetailDialog({ + shell, + onDone, + onKillShell, + onBack, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + + // Promise created in initializer (not during render). For running shells, + // the effect timer replaces it periodically to pick up new output. + // useDeferredValue keeps showing the previous output while the new promise + // resolves, preventing the Suspense fallback from flickering. + const [outputPromise, setOutputPromise] = useState>( + () => getTaskOutput(shell), + ) + const deferredOutputPromise = useDeferredValue(outputPromise) + + useEffect(() => { + if (shell.status !== 'running') { + return + } + const timer = setInterval( + (setOutputPromise, shell) => setOutputPromise(getTaskOutput(shell)), + 1000, + setOutputPromise, + shell, + ) + return () => clearInterval(timer) + }, [shell.id, shell.status]) + + // Handle standard close action + const handleClose = () => + onDone('Shell details dismissed', { display: 'system' }) + + // Handle additional close actions beyond Dialog's built-in Esc handler + useKeybindings( + { + 'confirm:yes': handleClose, + }, + { context: 'Confirmation' }, + ) + + // Handle dialog-specific keys + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault() + onDone('Shell details dismissed', { display: 'system' }) + } else if (e.key === 'left' && onBack) { + e.preventDefault() + onBack() + } else if (e.key === 'x' && shell.status === 'running' && onKillShell) { + e.preventDefault() + onKillShell() + } } - const [outputPromise, setOutputPromise] = useState(t1); - const deferredOutputPromise = useDeferredValue(outputPromise); - let t2; - if ($[2] !== shell) { - t2 = () => { - if (shell.status !== "running") { - return; - } - const timer = setInterval(_temp, 1000, setOutputPromise, shell); - return () => clearInterval(timer); - }; - $[2] = shell; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== shell.id || $[5] !== shell.status) { - t3 = [shell.id, shell.status]; - $[4] = shell.id; - $[5] = shell.status; - $[6] = t3; - } else { - t3 = $[6]; - } - useEffect(t2, t3); - let t4; - if ($[7] !== onDone) { - t4 = () => onDone("Shell details dismissed", { - display: "system" - }); - $[7] = onDone; - $[8] = t4; - } else { - t4 = $[8]; - } - const handleClose = t4; - let t5; - if ($[9] !== handleClose) { - t5 = { - "confirm:yes": handleClose - }; - $[9] = handleClose; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - context: "Confirmation" - }; - $[11] = t6; - } else { - t6 = $[11]; - } - useKeybindings(t5, t6); - let t7; - if ($[12] !== onBack || $[13] !== onDone || $[14] !== onKillShell || $[15] !== shell.status) { - t7 = e => { - if (e.key === " ") { - e.preventDefault(); - onDone("Shell details dismissed", { - display: "system" - }); - } else { - if (e.key === "left" && onBack) { - e.preventDefault(); - onBack(); - } else { - if (e.key === "x" && shell.status === "running" && onKillShell) { - e.preventDefault(); - onKillShell(); - } + + // Truncate command if too long (for display purposes) + const isMonitor = shell.kind === 'monitor' + const displayCommand = truncateToWidth(shell.command, 280) + + return ( + + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + {onBack && } + + {shell.status === 'running' && onKillShell && ( + + )} + + ) } - } - }; - $[12] = onBack; - $[13] = onDone; - $[14] = onKillShell; - $[15] = shell.status; - $[16] = t7; - } else { - t7 = $[16]; - } - const handleKeyDown = t7; - const isMonitor = shell.kind === "monitor"; - let t8; - if ($[17] !== shell.command) { - t8 = truncateToWidth(shell.command, 280); - $[17] = shell.command; - $[18] = t8; - } else { - t8 = $[18]; - } - const displayCommand = t8; - const t9 = isMonitor ? "Monitor details" : "Shell details"; - let t10; - if ($[19] !== onBack || $[20] !== onKillShell || $[21] !== shell.status) { - t10 = exitState => exitState.pending ? Press {exitState.keyName} again to exit : {onBack && }{shell.status === "running" && onKillShell && }; - $[19] = onBack; - $[20] = onKillShell; - $[21] = shell.status; - $[22] = t10; - } else { - t10 = $[22]; - } - let t11; - if ($[23] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Status:; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== shell.result || $[25] !== shell.status) { - t12 = {t11}{" "}{shell.status === "running" ? {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`} : shell.status === "completed" ? {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`} : {shell.status}{shell.result?.code !== undefined && ` (exit code: ${shell.result.code})`}}; - $[24] = shell.result; - $[25] = shell.status; - $[26] = t12; - } else { - t12 = $[26]; - } - let t13; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Runtime:; - $[27] = t13; - } else { - t13 = $[27]; - } - let t14; - if ($[28] !== shell.endTime) { - t14 = shell.endTime ?? Date.now(); - $[28] = shell.endTime; - $[29] = t14; - } else { - t14 = $[29]; - } - const t15 = t14 - shell.startTime; - let t16; - if ($[30] !== t15) { - t16 = formatDuration(t15); - $[30] = t15; - $[31] = t16; - } else { - t16 = $[31]; - } - let t17; - if ($[32] !== t16) { - t17 = {t13}{" "}{t16}; - $[32] = t16; - $[33] = t17; - } else { - t17 = $[33]; - } - const t18 = isMonitor ? "Script:" : "Command:"; - let t19; - if ($[34] !== t18) { - t19 = {t18}; - $[34] = t18; - $[35] = t19; - } else { - t19 = $[35]; - } - let t20; - if ($[36] !== displayCommand || $[37] !== t19) { - t20 = {t19}{" "}{displayCommand}; - $[36] = displayCommand; - $[37] = t19; - $[38] = t20; - } else { - t20 = $[38]; - } - let t21; - if ($[39] !== t12 || $[40] !== t17 || $[41] !== t20) { - t21 = {t12}{t17}{t20}; - $[39] = t12; - $[40] = t17; - $[41] = t20; - $[42] = t21; - } else { - t21 = $[42]; - } - let t22; - if ($[43] === Symbol.for("react.memo_cache_sentinel")) { - t22 = Output:; - $[43] = t22; - } else { - t22 = $[43]; - } - let t23; - if ($[44] === Symbol.for("react.memo_cache_sentinel")) { - t23 = Loading output…; - $[44] = t23; - } else { - t23 = $[44]; - } - let t24; - if ($[45] !== columns || $[46] !== deferredOutputPromise) { - t24 = {t22}; - $[45] = columns; - $[46] = deferredOutputPromise; - $[47] = t24; - } else { - t24 = $[47]; - } - let t25; - if ($[48] !== handleClose || $[49] !== t10 || $[50] !== t21 || $[51] !== t24 || $[52] !== t9) { - t25 = {t21}{t24}; - $[48] = handleClose; - $[49] = t10; - $[50] = t21; - $[51] = t24; - $[52] = t9; - $[53] = t25; - } else { - t25 = $[53]; - } - let t26; - if ($[54] !== handleKeyDown || $[55] !== t25) { - t26 = {t25}; - $[54] = handleKeyDown; - $[55] = t25; - $[56] = t26; - } else { - t26 = $[56]; - } - return t26; -} -function _temp(setOutputPromise_0, shell_0) { - return setOutputPromise_0(getTaskOutput(shell_0)); + > + + + Status:{' '} + {shell.status === 'running' ? ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + ) : shell.status === 'completed' ? ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + ) : ( + + {shell.status} + {shell.result?.code !== undefined && + ` (exit code: ${shell.result.code})`} + + )} + + + Runtime:{' '} + {formatDuration((shell.endTime ?? Date.now()) - shell.startTime)} + + + {isMonitor ? 'Script:' : 'Command:'}{' '} + {displayCommand} + + + + + Output: + Loading output…}> + + + + + + ) } + type ShellOutputContentProps = { - outputPromise: Promise; - columns: number; -}; -function ShellOutputContent(t0) { - const $ = _c(19); - const { - outputPromise, - columns - } = t0; - const { - content, - bytesTotal - } = use(outputPromise) as any; + outputPromise: Promise + columns: number +} + +function ShellOutputContent({ + outputPromise, + columns, +}: ShellOutputContentProps): React.ReactNode { + const { content, bytesTotal } = use(outputPromise) + if (!content) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = No output available; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + return No output available } - let isIncomplete; - let rendered; - if ($[1] !== bytesTotal || $[2] !== content) { - const starts = []; - let pos = content.length; - for (let i = 0; i < 10 && pos > 0; i++) { - const prev = content.lastIndexOf("\n", pos - 1); - starts.push(prev + 1); - pos = prev; - } - starts.reverse(); - isIncomplete = bytesTotal > content.length; - rendered = []; - for (let i_0 = 0; i_0 < starts.length; i_0++) { - const start = starts[i_0]; - const end = i_0 < starts.length - 1 ? starts[i_0 + 1] - 1 : content.length; - const line = content.slice(start, end); - if (line) { - rendered.push(line); - } - } - $[1] = bytesTotal; - $[2] = content; - $[3] = isIncomplete; - $[4] = rendered; - } else { - isIncomplete = $[3]; - rendered = $[4]; + + // Find last 10 line boundaries via lastIndexOf + const starts: number[] = [] + let pos = content.length + for (let i = 0; i < 10 && pos > 0; i++) { + const prev = content.lastIndexOf('\n', pos - 1) + starts.push(prev + 1) + pos = prev } - const t1 = columns - 6; - let t2; - if ($[5] !== rendered) { - t2 = rendered.map(_temp2); - $[5] = rendered; - $[6] = t2; - } else { - t2 = $[6]; + starts.reverse() + const isIncomplete = bytesTotal > content.length + + // Build lines, skip empty trailing/leading segments + const rendered: string[] = [] + for (let i = 0; i < starts.length; i++) { + const start = starts[i]! + const end = i < starts.length - 1 ? starts[i + 1]! - 1 : content.length + const line = content.slice(start, end) + if (line) rendered.push(line) } - let t3; - if ($[7] !== t1 || $[8] !== t2) { - t3 = {t2}; - $[7] = t1; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - const t4 = `Showing ${rendered.length} lines`; - let t5; - if ($[10] !== bytesTotal || $[11] !== isIncomplete) { - t5 = isIncomplete ? ` of ${formatFileSize(bytesTotal)}` : ""; - $[10] = bytesTotal; - $[11] = isIncomplete; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - let t7; - if ($[16] !== t3 || $[17] !== t6) { - t7 = <>{t3}{t6}; - $[16] = t3; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - return t7; -} -function _temp2(line_0, i_1) { - return {line_0}; + + return ( + <> + + {rendered.map((line, i) => ( + + {line} + + ))} + + + {`Showing ${rendered.length} lines`} + {isIncomplete ? ` of ${formatFileSize(bytesTotal)}` : ''} + + + ) } diff --git a/src/components/tasks/ShellProgress.tsx b/src/components/tasks/ShellProgress.tsx index 6e9a671c0..b70494c16 100644 --- a/src/components/tasks/ShellProgress.tsx +++ b/src/components/tasks/ShellProgress.tsx @@ -1,86 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ReactNode } from 'react'; -import React from 'react'; -import { Text } from 'src/ink.js'; -import type { TaskStatus } from 'src/Task.js'; -import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js'; -import type { DeepImmutable } from 'src/types/utils.js'; +import type { ReactNode } from 'react' +import React from 'react' +import { Text } from 'src/ink.js' +import type { TaskStatus } from 'src/Task.js' +import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' +import type { DeepImmutable } from 'src/types/utils.js' + type TaskStatusTextProps = { - status: TaskStatus; - label?: string; - suffix?: string; -}; -export function TaskStatusText(t0) { - const $ = _c(4); - const { - status, - label, - suffix - } = t0; - const displayLabel = label ?? status; - const color = status === "completed" ? "success" : status === "failed" ? "error" : status === "killed" ? "warning" : undefined; - let t1; - if ($[0] !== color || $[1] !== displayLabel || $[2] !== suffix) { - t1 = ({displayLabel}{suffix}); - $[0] = color; - $[1] = displayLabel; - $[2] = suffix; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + status: TaskStatus + label?: string + suffix?: string } -export function ShellProgress(t0) { - const $ = _c(4); - const { - shell - } = t0; + +export function TaskStatusText({ + status, + label, + suffix, +}: TaskStatusTextProps): ReactNode { + const displayLabel = label ?? status + const color = + status === 'completed' + ? 'success' + : status === 'failed' + ? 'error' + : status === 'killed' + ? 'warning' + : undefined + return ( + + ({displayLabel} + {suffix}) + + ) +} + +export function ShellProgress({ + shell, +}: { + shell: DeepImmutable +}): ReactNode { switch (shell.status) { - case "completed": - { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - case "failed": - { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; - } - case "killed": - { - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - case "running": - case "pending": - { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; - } + case 'completed': + return + case 'failed': + return + case 'killed': + return + case 'running': + case 'pending': + return } } diff --git a/src/components/tasks/renderToolActivity.tsx b/src/components/tasks/renderToolActivity.tsx index e2e4ebae7..a6e1c60a2 100644 --- a/src/components/tasks/renderToolActivity.tsx +++ b/src/components/tasks/renderToolActivity.tsx @@ -1,32 +1,39 @@ -import React from 'react'; -import { Text } from '../../ink.js'; -import type { Tools } from '../../Tool.js'; -import { findToolByName } from '../../Tool.js'; -import type { ToolActivity } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import type { ThemeName } from '../../utils/theme.js'; -export function renderToolActivity(activity: ToolActivity, tools: Tools, theme: ThemeName): React.ReactNode { - const tool = findToolByName(tools, activity.toolName); +import React from 'react' +import { Text } from '../../ink.js' +import type { Tools } from '../../Tool.js' +import { findToolByName } from '../../Tool.js' +import type { ToolActivity } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import type { ThemeName } from '../../utils/theme.js' + +export function renderToolActivity( + activity: ToolActivity, + tools: Tools, + theme: ThemeName, +): React.ReactNode { + const tool = findToolByName(tools, activity.toolName) if (!tool) { - return activity.toolName; + return activity.toolName } try { - const parsed = tool.inputSchema.safeParse(activity.input); - const parsedInput = parsed.success ? parsed.data : {}; - const userFacingName = tool.userFacingName(parsedInput); + const parsed = tool.inputSchema.safeParse(activity.input) + const parsedInput = parsed.success ? parsed.data : {} + const userFacingName = tool.userFacingName(parsedInput) if (!userFacingName) { - return activity.toolName; + return activity.toolName } const toolArgs = tool.renderToolUseMessage(parsedInput, { theme, - verbose: false - }); + verbose: false, + }) if (toolArgs) { - return + return ( + {userFacingName}({toolArgs}) - ; + + ) } - return userFacingName; + return userFacingName } catch { - return activity.toolName; + return activity.toolName } } diff --git a/src/components/tasks/taskStatusUtils.tsx b/src/components/tasks/taskStatusUtils.tsx index a70cbd6ca..91cb14cbf 100644 --- a/src/components/tasks/taskStatusUtils.tsx +++ b/src/components/tasks/taskStatusUtils.tsx @@ -2,71 +2,73 @@ * Shared utilities for displaying task status across different task types. */ -import figures from 'figures'; -import type { TaskStatus } from 'src/Task.js'; -import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js'; -import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; -import { isBackgroundTask, type TaskState } from 'src/tasks/types.js'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { summarizeRecentActivities } from 'src/utils/collapseReadSearch.js'; +import figures from 'figures' +import type { TaskStatus } from 'src/Task.js' +import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' +import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +import { isBackgroundTask, type TaskState } from 'src/tasks/types.js' +import type { DeepImmutable } from 'src/types/utils.js' +import { summarizeRecentActivities } from 'src/utils/collapseReadSearch.js' /** * Returns true if the given task status represents a terminal (finished) state. */ export function isTerminalStatus(status: TaskStatus): boolean { - return status === 'completed' || status === 'failed' || status === 'killed'; + return status === 'completed' || status === 'failed' || status === 'killed' } /** * Returns the appropriate icon for a task based on status and state flags. */ -export function getTaskStatusIcon(status: TaskStatus, options?: { - isIdle?: boolean; - awaitingApproval?: boolean; - hasError?: boolean; - shutdownRequested?: boolean; -}): string { - const { - isIdle, - awaitingApproval, - hasError, - shutdownRequested - } = options ?? {}; - if (hasError) return figures.cross; - if (awaitingApproval) return figures.questionMarkPrefix; - if (shutdownRequested) return figures.warning; +export function getTaskStatusIcon( + status: TaskStatus, + options?: { + isIdle?: boolean + awaitingApproval?: boolean + hasError?: boolean + shutdownRequested?: boolean + }, +): string { + const { isIdle, awaitingApproval, hasError, shutdownRequested } = + options ?? {} + + if (hasError) return figures.cross + if (awaitingApproval) return figures.questionMarkPrefix + if (shutdownRequested) return figures.warning + if (status === 'running') { - if (isIdle) return figures.ellipsis; - return figures.play; + if (isIdle) return figures.ellipsis + return figures.play } - if (status === 'completed') return figures.tick; - if (status === 'failed' || status === 'killed') return figures.cross; - return figures.bullet; + if (status === 'completed') return figures.tick + if (status === 'failed' || status === 'killed') return figures.cross + return figures.bullet } /** * Returns the appropriate semantic color for a task based on status and state flags. */ -export function getTaskStatusColor(status: TaskStatus, options?: { - isIdle?: boolean; - awaitingApproval?: boolean; - hasError?: boolean; - shutdownRequested?: boolean; -}): 'success' | 'error' | 'warning' | 'background' { - const { - isIdle, - awaitingApproval, - hasError, - shutdownRequested - } = options ?? {}; - if (hasError) return 'error'; - if (awaitingApproval) return 'warning'; - if (shutdownRequested) return 'warning'; - if (isIdle) return 'background'; - if (status === 'completed') return 'success'; - if (status === 'failed') return 'error'; - if (status === 'killed') return 'warning'; - return 'background'; +export function getTaskStatusColor( + status: TaskStatus, + options?: { + isIdle?: boolean + awaitingApproval?: boolean + hasError?: boolean + shutdownRequested?: boolean + }, +): 'success' | 'error' | 'warning' | 'background' { + const { isIdle, awaitingApproval, hasError, shutdownRequested } = + options ?? {} + + if (hasError) return 'error' + if (awaitingApproval) return 'warning' + if (shutdownRequested) return 'warning' + if (isIdle) return 'background' + + if (status === 'completed') return 'success' + if (status === 'failed') return 'error' + if (status === 'killed') return 'warning' + return 'background' } /** @@ -74,11 +76,18 @@ export function getTaskStatusColor(status: TaskStatus, options?: { * accounting for shutdown/approval/idle states and falling back through * recent-activity summary → last activity description → 'working'. */ -export function describeTeammateActivity(t: DeepImmutable): string { - if (t.shutdownRequested) return 'stopping'; - if (t.awaitingPlanApproval) return 'awaiting approval'; - if (t.isIdle) return 'idle'; - return (t.progress?.recentActivities && summarizeRecentActivities(t.progress.recentActivities)) ?? t.progress?.lastActivity?.activityDescription ?? 'working'; +export function describeTeammateActivity( + t: DeepImmutable, +): string { + if (t.shutdownRequested) return 'stopping' + if (t.awaitingPlanApproval) return 'awaiting approval' + if (t.isIdle) return 'idle' + return ( + (t.progress?.recentActivities && + summarizeRecentActivities(t.progress.recentActivities)) ?? + t.progress?.lastActivity?.activityDescription ?? + 'working' + ) } /** @@ -90,17 +99,21 @@ export function describeTeammateActivity(t: DeepImmutable{children}; - $[0] = children; - $[1] = color; - $[2] = goBack; - $[3] = subtitle; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== footerText) { - t4 = ; - $[6] = footerText; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = <>{t3}{t4}; - $[8] = t3; - $[9] = t4; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; + goBack, + } = useWizard() + const title = titleOverride || providerTitle || 'Wizard' + const stepSuffix = + showStepCounter !== false ? ` (${currentStepIndex + 1}/${totalSteps})` : '' + + return ( + <> + + {children} + + + + ) } diff --git a/src/components/wizard/WizardNavigationFooter.tsx b/src/components/wizard/WizardNavigationFooter.tsx index 183334a91..35a03ee81 100644 --- a/src/components/wizard/WizardNavigationFooter.tsx +++ b/src/components/wizard/WizardNavigationFooter.tsx @@ -1,23 +1,37 @@ -import React, { type ReactNode } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import React, { type ReactNode } from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' + type Props = { - instructions?: ReactNode; -}; + instructions?: ReactNode +} + export function WizardNavigationFooter({ - instructions = + instructions = ( + - + + ), }: Props): ReactNode { - const exitState = useExitOnCtrlCDWithKeybindings(); - return + const exitState = useExitOnCtrlCDWithKeybindings() + + return ( + - {exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions} + {exitState.pending + ? `Press ${exitState.keyName} again to exit` + : instructions} - ; + + ) } diff --git a/src/components/wizard/WizardProvider.tsx b/src/components/wizard/WizardProvider.tsx index 3160ea610..6707cb95a 100644 --- a/src/components/wizard/WizardProvider.tsx +++ b/src/components/wizard/WizardProvider.tsx @@ -1,156 +1,96 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { createContext, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import type { WizardContextValue, WizardProviderProps } from './types.js'; +import React, { + createContext, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import type { WizardContextValue, WizardProviderProps } from './types.js' // Use any here for the context since it will be cast properly when used // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const WizardContext = createContext | null>(null); -export function WizardProvider(t0) { - const $ = _c(38); - const { - steps, - initialData: t1, - onComplete, - onCancel, - children, - title, - showStepCounter: t2 - } = t0; - let t3; - if ($[0] !== t1) { - t3 = t1 === undefined ? {} as T : t1; - $[0] = t1; - $[1] = t3; - } else { - t3 = $[1]; - } - const initialData = t3; - const showStepCounter = t2 === undefined ? true : t2; - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [wizardData, setWizardData] = useState(initialData); - const [isCompleted, setIsCompleted] = useState(false); - let t4; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t4 = []; - $[2] = t4; - } else { - t4 = $[2]; - } - const [navigationHistory, setNavigationHistory] = useState(t4); - useExitOnCtrlCDWithKeybindings(); - let t5; - let t6; - if ($[3] !== isCompleted || $[4] !== onComplete || $[5] !== wizardData) { - t5 = () => { - if (isCompleted) { - setNavigationHistory([]); - onComplete(wizardData); - } - }; - t6 = [isCompleted, wizardData, onComplete]; - $[3] = isCompleted; - $[4] = onComplete; - $[5] = wizardData; - $[6] = t5; - $[7] = t6; - } else { - t5 = $[6]; - t6 = $[7]; - } - useEffect(t5, t6); - let t7; - if ($[8] !== currentStepIndex || $[9] !== navigationHistory || $[10] !== steps.length) { - t7 = () => { - if (currentStepIndex < steps.length - 1) { - if (navigationHistory.length > 0) { - setNavigationHistory(prev => [...prev, currentStepIndex]); - } - setCurrentStepIndex(_temp); - } else { - setIsCompleted(true); - } - }; - $[8] = currentStepIndex; - $[9] = navigationHistory; - $[10] = steps.length; - $[11] = t7; - } else { - t7 = $[11]; - } - const goNext = t7; - let t8; - if ($[12] !== currentStepIndex || $[13] !== navigationHistory || $[14] !== onCancel) { - t8 = () => { +export const WizardContext = createContext | null>(null) + +export function WizardProvider>({ + steps, + initialData = {} as T, + onComplete, + onCancel, + children, + title, + showStepCounter = true, +}: WizardProviderProps): ReactNode { + const [currentStepIndex, setCurrentStepIndex] = useState(0) + const [wizardData, setWizardData] = useState(initialData) + const [isCompleted, setIsCompleted] = useState(false) + const [navigationHistory, setNavigationHistory] = useState([]) + + useExitOnCtrlCDWithKeybindings() + + // Handle completion in useEffect to avoid updating parent during render + useEffect(() => { + if (isCompleted) { + setNavigationHistory([]) + void onComplete(wizardData) + } + }, [isCompleted, wizardData, onComplete]) + + const goNext = useCallback(() => { + if (currentStepIndex < steps.length - 1) { + // If we have history (non-linear flow), add current step to it if (navigationHistory.length > 0) { - const previousStep = navigationHistory[navigationHistory.length - 1]; - if (previousStep !== undefined) { - setNavigationHistory(_temp2); - setCurrentStepIndex(previousStep); - } - } else { - if (currentStepIndex > 0) { - setCurrentStepIndex(_temp3); - } else { - if (onCancel) { - onCancel(); - } - } + setNavigationHistory(prev => [...prev, currentStepIndex]) } - }; - $[12] = currentStepIndex; - $[13] = navigationHistory; - $[14] = onCancel; - $[15] = t8; - } else { - t8 = $[15]; - } - const goBack = t8; - let t9; - if ($[16] !== currentStepIndex || $[17] !== steps.length) { - t9 = index => { + + setCurrentStepIndex(prev => prev + 1) + } else { + // Mark as completed, which will trigger useEffect + setIsCompleted(true) + } + }, [currentStepIndex, steps.length, navigationHistory]) + + const goBack = useCallback(() => { + // Check if we have navigation history to use + if (navigationHistory.length > 0) { + const previousStep = navigationHistory[navigationHistory.length - 1] + if (previousStep !== undefined) { + setNavigationHistory(prev => prev.slice(0, -1)) + setCurrentStepIndex(previousStep) + } + } else if (currentStepIndex > 0) { + // Fallback to simple decrement if no history + setCurrentStepIndex(prev => prev - 1) + } else if (onCancel) { + onCancel() + } + }, [currentStepIndex, navigationHistory, onCancel]) + + const goToStep = useCallback( + (index: number) => { if (index >= 0 && index < steps.length) { - setNavigationHistory(prev_3 => [...prev_3, currentStepIndex]); - setCurrentStepIndex(index); + // Push current step to history before jumping + setNavigationHistory(prev => [...prev, currentStepIndex]) + setCurrentStepIndex(index) } - }; - $[16] = currentStepIndex; - $[17] = steps.length; - $[18] = t9; - } else { - t9 = $[18]; - } - const goToStep = t9; - let t10; - if ($[19] !== onCancel) { - t10 = () => { - setNavigationHistory([]); - if (onCancel) { - onCancel(); - } - }; - $[19] = onCancel; - $[20] = t10; - } else { - t10 = $[20]; - } - const cancel = t10; - let t11; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t11 = updates => { - setWizardData(prev_4 => ({ - ...prev_4, - ...updates - })); - }; - $[21] = t11; - } else { - t11 = $[21]; - } - const updateWizardData = t11; - let t12; - if ($[22] !== cancel || $[23] !== currentStepIndex || $[24] !== goBack || $[25] !== goNext || $[26] !== goToStep || $[27] !== showStepCounter || $[28] !== steps.length || $[29] !== title || $[30] !== wizardData) { - t12 = { + }, + [currentStepIndex, steps.length], + ) + + const cancel = useCallback(() => { + setNavigationHistory([]) + if (onCancel) { + onCancel() + } + }, [onCancel]) + + const updateWizardData = useCallback((updates: Partial) => { + setWizardData(prev => ({ ...prev, ...updates })) + }, []) + + const contextValue = useMemo>( + () => ({ currentStepIndex, totalSteps: steps.length, wizardData, @@ -161,52 +101,31 @@ export function WizardProvider(t0) { goToStep, cancel, title, - showStepCounter - }; - $[22] = cancel; - $[23] = currentStepIndex; - $[24] = goBack; - $[25] = goNext; - $[26] = goToStep; - $[27] = showStepCounter; - $[28] = steps.length; - $[29] = title; - $[30] = wizardData; - $[31] = t12; - } else { - t12 = $[31]; - } - const contextValue = t12; - const CurrentStepComponent = steps[currentStepIndex]; + showStepCounter, + }), + [ + currentStepIndex, + steps.length, + wizardData, + updateWizardData, + goNext, + goBack, + goToStep, + cancel, + title, + showStepCounter, + ], + ) + + const CurrentStepComponent = steps[currentStepIndex] + if (!CurrentStepComponent || isCompleted) { - return null; + return null } - let t13; - if ($[32] !== CurrentStepComponent || $[33] !== children) { - t13 = children || ; - $[32] = CurrentStepComponent; - $[33] = children; - $[34] = t13; - } else { - t13 = $[34]; - } - let t14; - if ($[35] !== contextValue || $[36] !== t13) { - t14 = {t13}; - $[35] = contextValue; - $[36] = t13; - $[37] = t14; - } else { - t14 = $[37]; - } - return t14; -} -function _temp3(prev_2) { - return prev_2 - 1; -} -function _temp2(prev_1) { - return prev_1.slice(0, -1); -} -function _temp(prev_0) { - return prev_0 + 1; + + return ( + + {children || } + + ) }