From 23fcbf9004c71dc8e45de60d4455f45fe78fda75 Mon Sep 17 00:00:00 2001 From: unraid Date: Wed, 22 Apr 2026 22:38:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20UI=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=A2=9E=E5=BC=BA=E4=B8=8E=E6=B5=8B=E8=AF=95=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/components/EffortIndicator.ts | 3 + .../useFrustrationDetection.test.tsx | 116 ++ .../FeedbackSurvey/useFrustrationDetection.ts | 66 +- src/components/InvalidConfigDialog.tsx | 1 + src/components/Settings/Config.tsx | 1518 +++++++---------- src/components/agents/SnapshotUpdateDialog.ts | 90 +- .../__tests__/SnapshotUpdateDialog.test.tsx | 115 ++ .../messages/SnipBoundaryMessage.tsx | 23 + .../messages/UserCrossSessionMessage.tsx | 31 + .../messages/UserForkBoilerplateMessage.tsx | 30 + .../messages/UserGitHubWebhookMessage.tsx | 36 + src/components/shell/OutputLine.tsx | 1 + src/components/teams/TeamsDialog.tsx | 601 +++---- 13 files changed, 1332 insertions(+), 1299 deletions(-) create mode 100644 src/components/FeedbackSurvey/__tests__/useFrustrationDetection.test.tsx create mode 100644 src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx create mode 100644 src/components/messages/SnipBoundaryMessage.tsx create mode 100644 src/components/messages/UserCrossSessionMessage.tsx create mode 100644 src/components/messages/UserForkBoilerplateMessage.tsx create mode 100644 src/components/messages/UserGitHubWebhookMessage.tsx diff --git a/src/components/EffortIndicator.ts b/src/components/EffortIndicator.ts index caaaedcb2..fa6a4e01f 100644 --- a/src/components/EffortIndicator.ts +++ b/src/components/EffortIndicator.ts @@ -3,6 +3,7 @@ import { EFFORT_LOW, EFFORT_MAX, EFFORT_MEDIUM, + EFFORT_XHIGH, } from '../constants/figures.js' import { type EffortLevel, @@ -32,6 +33,8 @@ export function effortLevelToSymbol(level: EffortLevel): string { return EFFORT_MEDIUM case 'high': return EFFORT_HIGH + case 'xhigh': + return EFFORT_XHIGH case 'max': return EFFORT_MAX default: diff --git a/src/components/FeedbackSurvey/__tests__/useFrustrationDetection.test.tsx b/src/components/FeedbackSurvey/__tests__/useFrustrationDetection.test.tsx new file mode 100644 index 000000000..cb4cf9368 --- /dev/null +++ b/src/components/FeedbackSurvey/__tests__/useFrustrationDetection.test.tsx @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import * as React from 'react'; +import { renderToString } from '../../../utils/staticRender.js'; +import type { Message } from '../../../types/message.js'; + +let transcriptShareDismissed = false; +let productFeedbackAllowed = true; +const mockSubmitTranscriptShare = mock(async () => ({ success: true })); + +mock.module('../../../utils/config.js', () => ({ + getGlobalConfig: () => ({ transcriptShareDismissed }), + saveGlobalConfig: ( + updater: (current: { transcriptShareDismissed?: boolean }) => { + transcriptShareDismissed?: boolean; + }, + ) => { + const next = updater({ transcriptShareDismissed }); + transcriptShareDismissed = next.transcriptShareDismissed ?? false; + }, +})); +mock.module('../../../services/policyLimits/index.js', () => ({ + isPolicyAllowed: () => productFeedbackAllowed, +})); +mock.module('../submitTranscriptShare.js', () => ({ + submitTranscriptShare: mockSubmitTranscriptShare, +})); + +const { useFrustrationDetection } = await import('../useFrustrationDetection.js'); + +type DetectionResult = ReturnType; + +function apiError(uuid: string): Message { + return { + type: 'assistant', + uuid: uuid as any, + isApiErrorMessage: true, + message: { role: 'assistant', content: [] }, + }; +} + +async function renderDetection(props: { + messages: Message[]; + isLoading?: boolean; + hasActivePrompt?: boolean; + otherSurveyOpen?: boolean; +}): Promise { + let result: DetectionResult | null = null; + function Probe(): React.ReactNode { + result = useFrustrationDetection( + props.messages, + props.isLoading ?? false, + props.hasActivePrompt ?? false, + props.otherSurveyOpen ?? false, + ); + return null; + } + + await renderToString(); + if (!result) { + throw new Error('useFrustrationDetection did not render'); + } + return result; +} + +afterEach(() => { + transcriptShareDismissed = false; + productFeedbackAllowed = true; + mockSubmitTranscriptShare.mockClear(); +}); + +describe('useFrustrationDetection', () => { + test('stays closed without frustration signals', async () => { + const result = await renderDetection({ messages: [] }); + + expect(result.state).toBe('closed'); + expect(typeof result.handleTranscriptSelect).toBe('function'); + }); + + test('opens a transcript prompt for repeated API errors', async () => { + const result = await renderDetection({ + messages: [apiError('a'), apiError('b')], + }); + + expect(result.state).toBe('transcript_prompt'); + }); + + test('does not prompt while loading, prompting, blocked by another survey, dismissed, or policy-denied', async () => { + const messages = [apiError('a'), apiError('b')]; + + expect((await renderDetection({ messages, isLoading: true })).state).toBe('closed'); + expect((await renderDetection({ messages, hasActivePrompt: true })).state).toBe('closed'); + expect((await renderDetection({ messages, otherSurveyOpen: true })).state).toBe('closed'); + + transcriptShareDismissed = true; + expect((await renderDetection({ messages })).state).toBe('closed'); + + transcriptShareDismissed = false; + productFeedbackAllowed = false; + expect((await renderDetection({ messages })).state).toBe('closed'); + }); + + test('submits transcript share when the user accepts', async () => { + const result = await renderDetection({ + messages: [apiError('a'), apiError('b')], + }); + + result.handleTranscriptSelect('yes'); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockSubmitTranscriptShare).toHaveBeenCalledWith( + [apiError('a'), apiError('b')], + 'frustration', + expect.any(String), + ); + }); +}); diff --git a/src/components/FeedbackSurvey/useFrustrationDetection.ts b/src/components/FeedbackSurvey/useFrustrationDetection.ts index b2f028a34..0419aa5a0 100644 --- a/src/components/FeedbackSurvey/useFrustrationDetection.ts +++ b/src/components/FeedbackSurvey/useFrustrationDetection.ts @@ -1,9 +1,59 @@ -// Auto-generated stub — replace with real implementation -export function useFrustrationDetection( - _messages: unknown[], - _isLoading: boolean, - _hasActivePrompt: boolean, - _otherSurveyOpen: boolean, -): { state: 'closed' | 'open'; handleTranscriptSelect: () => void } { - return { state: 'closed', handleTranscriptSelect: () => {} }; +import { useState } from 'react' +import type { Message } from '../../types/message.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { isPolicyAllowed } from '../../services/policyLimits/index.js' +import { submitTranscriptShare } from './submitTranscriptShare.js' + +type FrustrationState = 'closed' | 'transcript_prompt' | 'submitted' + +export type FrustrationDetectionResult = { + state: FrustrationState + handleTranscriptSelect: (choice: string) => void +} + +function detectFrustration(messages: Message[]): boolean { + const apiErrors = messages.filter(m => (m as any).isApiErrorMessage) + return apiErrors.length >= 2 +} + +export function useFrustrationDetection( + messages: Message[], + isLoading: boolean, + hasActivePrompt: boolean, + otherSurveyOpen: boolean, +): FrustrationDetectionResult { + const [state, setState] = useState('closed') + + const config = getGlobalConfig() as { transcriptShareDismissed?: boolean } + if (config.transcriptShareDismissed) { + return { state: 'closed', handleTranscriptSelect: () => {} } + } + + if (!isPolicyAllowed('product_feedback' as any)) { + return { state: 'closed', handleTranscriptSelect: () => {} } + } + + if (isLoading || hasActivePrompt || otherSurveyOpen) { + return { state: 'closed', handleTranscriptSelect: () => {} } + } + + const frustrated = detectFrustration(messages) + + const effectiveState = + frustrated && state === 'closed' ? 'transcript_prompt' : state + + function handleTranscriptSelect(choice: string) { + if (choice === 'yes') { + void submitTranscriptShare(messages, 'frustration', crypto.randomUUID()) + setState('submitted') + } else { + saveGlobalConfig((current: any) => ({ + ...current, + transcriptShareDismissed: true, + })) + setState('closed') + } + } + + return { state: effectiveState, handleTranscriptSelect } } diff --git a/src/components/InvalidConfigDialog.tsx b/src/components/InvalidConfigDialog.tsx index 7bbc04b14..f7d5be613 100644 --- a/src/components/InvalidConfigDialog.tsx +++ b/src/components/InvalidConfigDialog.tsx @@ -83,6 +83,7 @@ export async function showInvalidConfigDialog({ theme: SAFE_ERROR_THEME_NAME, } + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: render must be awaited inside executor await new Promise(async resolve => { const { unmount } = await render( diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 4499fa056..8c84a0360 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -1,27 +1,19 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle' -import { type KeyboardEvent, Box, Text, useTheme, useThemeSetting, useTerminalFocus } from '@anthropic/ink' -import * as React from 'react' -import { useState, useCallback } from 'react' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import figures from 'figures' -import { - type GlobalConfig, - saveGlobalConfig, - getCurrentProjectConfig, - type OutputStyle, -} from '../../utils/config.js' -import { normalizeApiKeyForConfig } from '../../utils/authPortable.js' +import { feature } from 'bun:bundle'; +import { type KeyboardEvent, Box, Text, useTheme, useThemeSetting, useTerminalFocus } from '@anthropic/ink'; +import * as React from 'react'; +import { useState, useCallback } from 'react'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import figures from 'figures'; +import { type GlobalConfig, saveGlobalConfig, getCurrentProjectConfig, type OutputStyle } from '../../utils/config.js'; +import { normalizeApiKeyForConfig } from '../../utils/authPortable.js'; import { getGlobalConfig, getAutoUpdaterDisabledReason, formatAutoUpdaterDisabledReason, getRemoteControlAtStartup, -} from '../../utils/config.js' -import chalk from 'chalk' +} from '../../utils/config.js'; +import chalk from 'chalk'; import { permissionModeTitle, permissionModeFromString, @@ -31,74 +23,54 @@ import { PERMISSION_MODES, type ExternalPermissionMode, type PermissionMode, -} from '../../utils/permissions/PermissionMode.js' +} from '../../utils/permissions/PermissionMode.js'; import { getAutoModeEnabledState, hasAutoModeOptInAnySource, transitionPlanAutoMode, -} from '../../utils/permissions/permissionSetup.js' -import { logError } from '../../utils/log.js' +} from '../../utils/permissions/permissionSetup.js'; +import { logError } from '../../utils/log.js'; import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, -} from 'src/services/analytics/index.js' -import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' -import { ThemePicker } from '../ThemePicker.js' -import { - useAppState, - useSetAppState, - useAppStateStore, -} from '../../state/AppState.js' -import { ModelPicker } from '../ModelPicker.js' -import { - modelDisplayString, - isOpus1mMergeEnabled, -} from '../../utils/model/model.js' -import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' -import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js' -import { - ChannelDowngradeDialog, - type ChannelDowngradeChoice, -} from '../ChannelDowngradeDialog.js' -import { Dialog } from '@anthropic/ink' -import { Select } from '../CustomSelect/index.js' -import { OutputStylePicker } from '../OutputStylePicker.js' -import { LanguagePicker } from '../LanguagePicker.js' +} from 'src/services/analytics/index.js'; +import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'; +import { ThemePicker } from '../ThemePicker.js'; +import { useAppState, useSetAppState, useAppStateStore } from '../../state/AppState.js'; +import { ModelPicker } from '../ModelPicker.js'; +import { modelDisplayString, isOpus1mMergeEnabled } from '../../utils/model/model.js'; +import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; +import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js'; +import { ChannelDowngradeDialog, type ChannelDowngradeChoice } from '../ChannelDowngradeDialog.js'; +import { Dialog } from '@anthropic/ink'; +import { Select } from '../CustomSelect/index.js'; +import { OutputStylePicker } from '../OutputStylePicker.js'; +import { LanguagePicker } from '../LanguagePicker.js'; import { type MemoryFileInfo, getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes, -} from 'src/utils/claudemd.js' -import { Byline, KeyboardShortcutHint, useTabHeaderFocus } from '@anthropic/ink' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { useIsInsideModal } from '../../context/modalContext.js' -import { SearchBox } from '../SearchBox.js' -import { - isSupportedTerminal, - hasAccessToIDEExtensionDiffFeature, -} from '../../utils/ide.js' -import { - getInitialSettings, - getSettingsForSource, - updateSettingsForSource, -} from '../../utils/settings/settings.js' -import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js' -import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js' -import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js' -import type { - LocalJSXCommandContext, - CommandResultDisplay, -} from '../../commands.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +} from 'src/utils/claudemd.js'; +import { Byline, KeyboardShortcutHint, useTabHeaderFocus } from '@anthropic/ink'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { useIsInsideModal } from '../../context/modalContext.js'; +import { SearchBox } from '../SearchBox.js'; +import { isSupportedTerminal, hasAccessToIDEExtensionDiffFeature } from '../../utils/ide.js'; +import { getInitialSettings, getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; +import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js'; +import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js'; +import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js'; +import type { LocalJSXCommandContext, CommandResultDisplay } from '../../commands.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; import { getCliTeammateModeOverride, clearCliTeammateModeOverride, -} from '../../utils/swarm/backends/teammateModeSnapshot.js' -import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js' -import { useSearchInput } from '../../hooks/useSearchInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +} from '../../utils/swarm/backends/teammateModeSnapshot.js'; +import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js'; +import { useSearchInput } from '../../hooks/useSearchInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, @@ -106,50 +78,48 @@ import { isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel, -} from '../../utils/fastMode.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +} from '../../utils/fastMode.js'; +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import { getPlatform } from '../../utils/platform.js'; type Props = { - onClose: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - context: LocalJSXCommandContext - setTabsHidden: (hidden: boolean) => void - onIsSearchModeChange?: (inSearchMode: boolean) => void - contentHeight?: number -} + onClose: (result?: string, options?: { display?: CommandResultDisplay }) => void; + context: LocalJSXCommandContext; + setTabsHidden: (hidden: boolean) => void; + onIsSearchModeChange?: (inSearchMode: boolean) => void; + contentHeight?: number; +}; type SettingBase = | { - id: string - label: string + id: string; + label: string; } | { - id: string - label: React.ReactNode - searchText: string - } + id: string; + label: React.ReactNode; + searchText: string; + }; type Setting = | (SettingBase & { - value: boolean - onChange(value: boolean): void - type: 'boolean' + value: boolean; + onChange(value: boolean): void; + type: 'boolean'; }) | (SettingBase & { - value: string - options: string[] - onChange(value: string): void - type: 'enum' + value: string; + options: string[]; + onChange(value: string): void; + type: 'enum'; }) | (SettingBase & { // For enums that are set by a custom component, we don't need to pass options, // but we still need a value to display in the top-level config menu - value: string - onChange(value: string): void - type: 'managedEnum' - }) + value: string; + onChange(value: string): void; + type: 'managedEnum'; + }); type SubMenu = | 'Theme' @@ -159,7 +129,7 @@ type SubMenu = | 'OutputStyle' | 'ChannelDowngrade' | 'Language' - | 'EnableAutoUpdates' + | 'EnableAutoUpdates'; export function Config({ onClose, context, @@ -167,46 +137,42 @@ export function Config({ onIsSearchModeChange, contentHeight, }: Props): React.ReactNode { - const { headerFocused, focusHeader } = useTabHeaderFocus() - const insideModal = useIsInsideModal() - const [, setTheme] = useTheme() - const themeSetting = useThemeSetting() - const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()) - const initialConfig = React.useRef(getGlobalConfig()) - const [settingsData, setSettingsData] = useState(getInitialSettings()) - const initialSettingsData = React.useRef(getInitialSettings()) + const { headerFocused, focusHeader } = useTabHeaderFocus(); + const insideModal = useIsInsideModal(); + const [, setTheme] = useTheme(); + const themeSetting = useThemeSetting(); + const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()); + const initialConfig = React.useRef(getGlobalConfig()); + const [settingsData, setSettingsData] = useState(getInitialSettings()); + const initialSettingsData = React.useRef(getInitialSettings()); const [currentOutputStyle, setCurrentOutputStyle] = useState( settingsData?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME, - ) - const initialOutputStyle = React.useRef(currentOutputStyle) - const [currentLanguage, setCurrentLanguage] = useState( - settingsData?.language, - ) - const initialLanguage = React.useRef(currentLanguage) - const [selectedIndex, setSelectedIndex] = useState(0) - const [scrollOffset, setScrollOffset] = useState(0) - const [isSearchMode, setIsSearchMode] = useState(true) - const isTerminalFocused = useTerminalFocus() - const { rows } = useTerminalSize() + ); + const initialOutputStyle = React.useRef(currentOutputStyle); + const [currentLanguage, setCurrentLanguage] = useState(settingsData?.language); + const initialLanguage = React.useRef(currentLanguage); + const [selectedIndex, setSelectedIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [isSearchMode, setIsSearchMode] = useState(true); + const isTerminalFocused = useTerminalFocus(); + const { rows } = useTerminalSize(); // contentHeight is set by Settings.tsx (same value passed to Tabs to fix // pane height across all tabs — prevents layout jank when switching). // Reserve ~10 rows for chrome (search box, gaps, footer, scroll hints). // Fallback calc for standalone rendering (tests). - const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30) - const maxVisible = Math.max(5, paneCap - 10) - const mainLoopModel = useAppState(s => s.mainLoopModel) - const verbose = useAppState(s => s.verbose) - const thinkingEnabled = useAppState(s => s.thinkingEnabled) - const isFastMode = useAppState(s => - isFastModeEnabled() ? s.fastMode : false, - ) - const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled) + const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30); + const maxVisible = Math.max(5, paneCap - 10); + const mainLoopModel = useAppState(s => s.mainLoopModel); + const verbose = useAppState(s => s.verbose); + const thinkingEnabled = useAppState(s => s.thinkingEnabled); + const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false)); + const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled); // Show auto in the default-mode dropdown when the user has opted in OR the // config is fully 'enabled' — even if currently circuit-broken ('disabled'), // an opted-in user should still see it in settings (it's a temporary state). const showAutoInDefaultModePicker = feature('TRANSCRIPT_CLASSIFIER') ? hasAutoModeOptInAnySource() || getAutoModeEnabledState() === 'enabled' - : false + : false; // Chat/Transcript view picker is visible to entitled users (pass the GB // gate) even if they haven't opted in this session — it IS the persistent // opt-in. 'chat' written here is read at next startup by main.tsx which @@ -217,28 +183,24 @@ export function Config({ ? ( require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') ).isBriefEntitled() - : false + : false; /* eslint-enable @typescript-eslint/no-require-imports */ - const setAppState = useSetAppState() - const [changes, setChanges] = useState<{ [key: string]: unknown }>({}) - const initialThinkingEnabled = React.useRef(thinkingEnabled) + const setAppState = useSetAppState(); + const [changes, setChanges] = useState<{ [key: string]: unknown }>({}); + const initialThinkingEnabled = React.useRef(thinkingEnabled); // Per-source settings snapshots for revert-on-escape. getInitialSettings() // returns merged-across-sources which can't tell us what to delete vs // restore; per-source snapshots + updateSettingsForSource's // undefined-deletes-key semantics can. Lazy-init via useState (no setter) to // avoid reading settings files on every render — useRef evaluates its arg // eagerly even though only the first result is kept. - const [initialLocalSettings] = useState(() => - getSettingsForSource('localSettings'), - ) - const [initialUserSettings] = useState(() => - getSettingsForSource('userSettings'), - ) - const initialThemeSetting = React.useRef(themeSetting) + const [initialLocalSettings] = useState(() => getSettingsForSource('localSettings')); + const [initialUserSettings] = useState(() => getSettingsForSource('userSettings')); + const initialThemeSetting = React.useRef(themeSetting); // AppState fields Config may modify — snapshot once at mount. - const store = useAppStateStore() + const store = useAppStateStore(); const [initialAppState] = useState(() => { - const s = store.getState() + const s = store.getState(); return { mainLoopModel: s.mainLoopModel, mainLoopModelForSession: s.mainLoopModelForSession, @@ -250,19 +212,19 @@ export function Config({ replBridgeEnabled: s.replBridgeEnabled, replBridgeOutboundOnly: s.replBridgeOutboundOnly, settings: s.settings, - } - }) + }; + }); // Bootstrap state snapshot — userMsgOptIn is outside AppState, so // revertChanges needs to restore it separately. Without this, cycling // defaultView to 'chat' then Escape leaves the tool active while the // display filter reverts — the exact ambient-activation behavior this // PR's entitlement/opt-in split is meant to prevent. - const [initialUserMsgOptIn] = useState(() => getUserMsgOptIn()) + const [initialUserMsgOptIn] = useState(() => getUserMsgOptIn()); // Set on first user-visible change; gates revertChanges() on Escape so // opening-then-closing doesn't trigger redundant disk writes. - const isDirty = React.useRef(false) - const [showThinkingWarning, setShowThinkingWarning] = useState(false) - const [showSubmenu, setShowSubmenu] = useState(null) + const isDirty = React.useRef(false); + const [showThinkingWarning, setShowThinkingWarning] = useState(false); + const [showSubmenu, setShowSubmenu] = useState(null); const { query: searchQuery, setQuery: setSearchQuery, @@ -274,74 +236,65 @@ export function Config({ // Ctrl+C/D must reach Settings' useExitOnCtrlCD; 'd' also avoids // double-action (delete-char + exit-pending). passthroughCtrlKeys: ['c', 'd'], - }) + }); // Tell the parent when Config's own Esc handler is active so Settings cedes // confirm:no. Only true when search mode owns the keyboard — not when the // tab header is focused (then Settings must handle Esc-to-close). - const ownsEsc = isSearchMode && !headerFocused + const ownsEsc = isSearchMode && !headerFocused; React.useEffect(() => { - onIsSearchModeChange?.(ownsEsc) - }, [ownsEsc, onIsSearchModeChange]) + onIsSearchModeChange?.(ownsEsc); + }, [ownsEsc, onIsSearchModeChange]); - const isConnectedToIde = hasAccessToIDEExtensionDiffFeature( - context.options.mcpClients, - ) + const isConnectedToIde = hasAccessToIDEExtensionDiffFeature(context.options.mcpClients); - const isFileCheckpointingAvailable = !isEnvTruthy( - process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING, - ) + const isFileCheckpointingAvailable = !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING); - const memoryFiles = React.use(getMemoryFiles(true)) as MemoryFileInfo[] - const shouldShowExternalIncludesToggle = - hasExternalClaudeMdIncludes(memoryFiles) + const memoryFiles = React.use(getMemoryFiles(true)) as MemoryFileInfo[]; + const shouldShowExternalIncludesToggle = hasExternalClaudeMdIncludes(memoryFiles); - const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason() + const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason(); function onChangeMainModelConfig(value: string | null): void { - const previousModel = mainLoopModel + const previousModel = mainLoopModel; logEvent('tengu_config_model_changed', { - from_model: - previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - to_model: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + from_model: previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + to_model: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null, - })) + })); setChanges(prev => { const valStr = modelDisplayString(value) + - (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) - ? ' · Billed as extra usage' - : '') + (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) ? ' · Billed as extra usage' : ''); if ('model' in prev) { - const { model, ...rest } = prev - return { ...rest, model: valStr } + const { model, ...rest } = prev; + return { ...rest, model: valStr }; } - return { ...prev, model: valStr } - }) + return { ...prev, model: valStr }; + }); } function onChangeVerbose(value: boolean): void { // Update the global config to persist the setting - saveGlobalConfig(current => ({ ...current, verbose: value })) - setGlobalConfig({ ...getGlobalConfig(), verbose: value }) + saveGlobalConfig(current => ({ ...current, verbose: value })); + setGlobalConfig({ ...getGlobalConfig(), verbose: value }); // Update the app state for immediate UI feedback setAppState(prev => ({ ...prev, verbose: value, - })) + })); setChanges(prev => { if ('verbose' in prev) { - const { verbose, ...rest } = prev - return rest + const { verbose, ...rest } = prev; + return rest; } - return { ...prev, verbose: value } - }) + return { ...prev, verbose: value }; + }); } // TODO: Add MCP servers @@ -353,11 +306,11 @@ export function Config({ value: globalConfig.autoCompactEnabled, type: 'boolean' as const, onChange(autoCompactEnabled: boolean) { - saveGlobalConfig(current => ({ ...current, autoCompactEnabled })) - setGlobalConfig({ ...getGlobalConfig(), autoCompactEnabled }) + saveGlobalConfig(current => ({ ...current, autoCompactEnabled })); + setGlobalConfig({ ...getGlobalConfig(), autoCompactEnabled }); logEvent('tengu_auto_compact_setting_changed', { enabled: autoCompactEnabled, - }) + }); }, }, { @@ -368,15 +321,15 @@ export function Config({ onChange(spinnerTipsEnabled: boolean) { updateSettingsForSource('localSettings', { spinnerTipsEnabled, - }) + }); // Update local state to reflect the change immediately setSettingsData(prev => ({ ...prev, spinnerTipsEnabled, - })) + })); logEvent('tengu_tips_setting_changed', { enabled: spinnerTipsEnabled, - }) + }); }, }, { @@ -387,19 +340,19 @@ export function Config({ onChange(prefersReducedMotion: boolean) { updateSettingsForSource('localSettings', { prefersReducedMotion, - }) + }); setSettingsData(prev => ({ ...prev, prefersReducedMotion, - })) + })); // Sync to AppState so components react immediately setAppState(prev => ({ ...prev, settings: { ...prev.settings, prefersReducedMotion }, - })) + })); logEvent('tengu_reduce_motion_setting_changed', { enabled: prefersReducedMotion, - }) + }); }, }, { @@ -408,11 +361,11 @@ export function Config({ value: thinkingEnabled ?? true, type: 'boolean' as const, onChange(enabled: boolean) { - setAppState(prev => ({ ...prev, thinkingEnabled: enabled })) + setAppState(prev => ({ ...prev, thinkingEnabled: enabled })); updateSettingsForSource('userSettings', { alwaysThinkingEnabled: enabled ? undefined : false, - }) - logEvent('tengu_thinking_toggled', { enabled }) + }); + logEvent('tengu_thinking_toggled', { enabled }); }, }, // Fast mode toggle (ant-only, eliminated from external builds) @@ -424,28 +377,28 @@ export function Config({ value: !!isFastMode, type: 'boolean' as const, onChange(enabled: boolean) { - clearFastModeCooldown() + clearFastModeCooldown(); updateSettingsForSource('userSettings', { fastMode: enabled ? true : undefined, - }) + }); if (enabled) { setAppState(prev => ({ ...prev, mainLoopModel: getFastModeModel(), mainLoopModelForSession: null, fastMode: true, - })) + })); setChanges(prev => ({ ...prev, model: getFastModeModel(), 'Fast mode': 'ON', - })) + })); } else { setAppState(prev => ({ ...prev, fastMode: false, - })) - setChanges(prev => ({ ...prev, 'Fast mode': 'OFF' })) + })); + setChanges(prev => ({ ...prev, 'Fast mode': 'OFF' })); } }, }, @@ -462,10 +415,10 @@ export function Config({ setAppState(prev => ({ ...prev, promptSuggestionEnabled: enabled, - })) + })); updateSettingsForSource('userSettings', { promptSuggestionEnabled: enabled ? undefined : false, - }) + }); }, }, ] @@ -476,17 +429,19 @@ export function Config({ id: 'poorMode', label: 'Poor mode (save tokens)', value: (() => { - const PoorMode = require('../../commands/poor/poorMode.js') as typeof import('../../commands/poor/poorMode.js') - return PoorMode.isPoorModeActive() + const PoorMode = + require('../../commands/poor/poorMode.js') as typeof import('../../commands/poor/poorMode.js'); + return PoorMode.isPoorModeActive(); })(), type: 'boolean' as const, onChange(enabled: boolean) { - const PoorMode = require('../../commands/poor/poorMode.js') as typeof import('../../commands/poor/poorMode.js') - PoorMode.setPoorMode(enabled) + const PoorMode = + require('../../commands/poor/poorMode.js') as typeof import('../../commands/poor/poorMode.js'); + PoorMode.setPoorMode(enabled); setAppState(prev => ({ ...prev, promptSuggestionEnabled: !enabled, - })) + })); }, }, ] @@ -501,19 +456,19 @@ export function Config({ type: 'boolean' as const, onChange(enabled: boolean) { saveGlobalConfig(current => { - if (current.speculationEnabled === enabled) return current + if (current.speculationEnabled === enabled) return current; return { ...current, speculationEnabled: enabled, - } - }) + }; + }); setGlobalConfig({ ...getGlobalConfig(), speculationEnabled: enabled, - }) + }); logEvent('tengu_speculation_setting_changed', { enabled, - }) + }); }, }, ] @@ -529,14 +484,14 @@ export function Config({ saveGlobalConfig(current => ({ ...current, fileCheckpointingEnabled: enabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), fileCheckpointingEnabled: enabled, - }) + }); logEvent('tengu_file_history_snapshots_setting_changed', { enabled: enabled, - }) + }); }, }, ] @@ -557,11 +512,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, terminalProgressBarEnabled, - })) - setGlobalConfig({ ...getGlobalConfig(), terminalProgressBarEnabled }) + })); + setGlobalConfig({ ...getGlobalConfig(), terminalProgressBarEnabled }); logEvent('tengu_terminal_progress_bar_setting_changed', { enabled: terminalProgressBarEnabled, - }) + }); }, }, ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false) @@ -575,14 +530,14 @@ export function Config({ saveGlobalConfig(current => ({ ...current, showStatusInTerminalTab, - })) + })); setGlobalConfig({ ...getGlobalConfig(), showStatusInTerminalTab, - }) + }); logEvent('tengu_terminal_tab_status_setting_changed', { enabled: showStatusInTerminalTab, - }) + }); }, }, ] @@ -593,11 +548,11 @@ export function Config({ value: globalConfig.showTurnDuration, type: 'boolean' as const, onChange(showTurnDuration: boolean) { - saveGlobalConfig(current => ({ ...current, showTurnDuration })) - setGlobalConfig({ ...getGlobalConfig(), showTurnDuration }) + saveGlobalConfig(current => ({ ...current, showTurnDuration })); + setGlobalConfig({ ...getGlobalConfig(), showTurnDuration }); logEvent('tengu_show_turn_duration_setting_changed', { enabled: showTurnDuration, - }) + }); }, }, { @@ -605,40 +560,31 @@ export function Config({ label: 'Default permission mode', value: settingsData?.permissions?.defaultMode || 'default', options: (() => { - const priorityOrder: PermissionMode[] = ['default', 'plan'] - const allModes: readonly PermissionMode[] = feature( - 'TRANSCRIPT_CLASSIFIER', - ) + const priorityOrder: PermissionMode[] = ['default', 'plan']; + const allModes: readonly PermissionMode[] = feature('TRANSCRIPT_CLASSIFIER') ? PERMISSION_MODES - : EXTERNAL_PERMISSION_MODES - const excluded: PermissionMode[] = ['bypassPermissions'] + : EXTERNAL_PERMISSION_MODES; + const excluded: PermissionMode[] = ['bypassPermissions']; if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) { - excluded.push('auto') + excluded.push('auto'); } - return [ - ...priorityOrder, - ...allModes.filter( - m => !priorityOrder.includes(m) && !excluded.includes(m), - ), - ] + return [...priorityOrder, ...allModes.filter(m => !priorityOrder.includes(m) && !excluded.includes(m))]; })(), type: 'enum' as const, onChange(mode: string) { - const parsedMode = permissionModeFromString(mode) + const parsedMode = permissionModeFromString(mode); // Internal modes (e.g. auto) are stored directly - const validatedMode = isExternalPermissionMode(parsedMode) - ? toExternalPermissionMode(parsedMode) - : parsedMode + const validatedMode = isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode; const result = updateSettingsForSource('userSettings', { permissions: { ...settingsData?.permissions, defaultMode: validatedMode as ExternalPermissionMode, }, - }) + }); if (result.error) { - logError(result.error) - return + logError(result.error); + return; } // Update local state to reflect the change immediately. @@ -651,15 +597,13 @@ export function Config({ ...prev?.permissions, defaultMode: validatedMode as (typeof PERMISSION_MODES)[number], }, - })) + })); // Track changes - setChanges(prev => ({ ...prev, defaultPermissionMode: mode })) + setChanges(prev => ({ ...prev, defaultPermissionMode: mode })); logEvent('tengu_config_changed', { - setting: - 'defaultPermissionMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: - mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + setting: 'defaultPermissionMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ...(feature('TRANSCRIPT_CLASSIFIER') && showAutoInDefaultModePicker @@ -667,30 +611,28 @@ export function Config({ { id: 'useAutoModeDuringPlan', label: 'Use auto mode during plan', - value: - (settingsData as { useAutoModeDuringPlan?: boolean } | undefined) - ?.useAutoModeDuringPlan ?? true, + value: (settingsData as { useAutoModeDuringPlan?: boolean } | undefined)?.useAutoModeDuringPlan ?? true, type: 'boolean' as const, onChange(useAutoModeDuringPlan: boolean) { updateSettingsForSource('userSettings', { useAutoModeDuringPlan, - }) + }); setSettingsData(prev => ({ ...prev, useAutoModeDuringPlan, - })) + })); // Internal writes suppress the file watcher, so // applySettingsChange won't fire. Reconcile directly so // mid-plan toggles take effect immediately. setAppState(prev => { - const next = transitionPlanAutoMode(prev.toolPermissionContext) - if (next === prev.toolPermissionContext) return prev - return { ...prev, toolPermissionContext: next } - }) + const next = transitionPlanAutoMode(prev.toolPermissionContext); + if (next === prev.toolPermissionContext) return prev; + return { ...prev, toolPermissionContext: next }; + }); setChanges(prev => ({ ...prev, 'Use auto mode during plan': useAutoModeDuringPlan, - })) + })); }, }, ] @@ -701,11 +643,11 @@ export function Config({ value: globalConfig.respectGitignore, type: 'boolean' as const, onChange(respectGitignore: boolean) { - saveGlobalConfig(current => ({ ...current, respectGitignore })) - setGlobalConfig({ ...getGlobalConfig(), respectGitignore }) + saveGlobalConfig(current => ({ ...current, respectGitignore })); + setGlobalConfig({ ...getGlobalConfig(), respectGitignore }); logEvent('tengu_respect_gitignore_setting_changed', { enabled: respectGitignore, - }) + }); }, }, { @@ -714,15 +656,12 @@ export function Config({ value: globalConfig.copyFullResponse, type: 'boolean' as const, onChange(copyFullResponse: boolean) { - saveGlobalConfig(current => ({ ...current, copyFullResponse })) - setGlobalConfig({ ...getGlobalConfig(), copyFullResponse }) + saveGlobalConfig(current => ({ ...current, copyFullResponse })); + setGlobalConfig({ ...getGlobalConfig(), copyFullResponse }); logEvent('tengu_config_changed', { - setting: - 'copyFullResponse' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: String( - copyFullResponse, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + setting: 'copyFullResponse' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: String(copyFullResponse) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, // Copy-on-select is only meaningful with in-app selection (fullscreen @@ -735,15 +674,12 @@ export function Config({ value: globalConfig.copyOnSelect ?? true, type: 'boolean' as const, onChange(copyOnSelect: boolean) { - saveGlobalConfig(current => ({ ...current, copyOnSelect })) - setGlobalConfig({ ...getGlobalConfig(), copyOnSelect }) + saveGlobalConfig(current => ({ ...current, copyOnSelect })); + setGlobalConfig({ ...getGlobalConfig(), copyOnSelect }); logEvent('tengu_config_changed', { - setting: - 'copyOnSelect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: String( - copyOnSelect, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + setting: 'copyOnSelect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: String(copyOnSelect) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -775,30 +711,19 @@ export function Config({ }, { id: 'notifChannel', - label: - feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') - ? 'Local notifications' - : 'Notifications', + label: feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') ? 'Local notifications' : 'Notifications', value: globalConfig.preferredNotifChannel, - options: [ - 'auto', - 'iterm2', - 'terminal_bell', - 'iterm2_with_bell', - 'kitty', - 'ghostty', - 'notifications_disabled', - ], + options: ['auto', 'iterm2', 'terminal_bell', 'iterm2_with_bell', 'kitty', 'ghostty', 'notifications_disabled'], type: 'enum', onChange(notifChannel: GlobalConfig['preferredNotifChannel']) { saveGlobalConfig(current => ({ ...current, preferredNotifChannel: notifChannel, - })) + })); setGlobalConfig({ ...getGlobalConfig(), preferredNotifChannel: notifChannel, - }) + }); }, }, ...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') @@ -812,11 +737,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, taskCompleteNotifEnabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), taskCompleteNotifEnabled, - }) + }); }, }, { @@ -828,11 +753,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, inputNeededNotifEnabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), inputNeededNotifEnabled, - }) + }); }, }, { @@ -844,11 +769,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, agentPushNotifEnabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), agentPushNotifEnabled, - }) + }); }, }, ] @@ -868,34 +793,27 @@ export function Config({ // 'default' means the setting is unset — currently resolves to // transcript (main.tsx falls through when defaultView !== 'chat'). // String() narrows the conditional-schema-spread union to string. - value: - settingsData?.defaultView === undefined - ? 'default' - : String(settingsData.defaultView), + value: settingsData?.defaultView === undefined ? 'default' : String(settingsData.defaultView), options: ['transcript', 'chat', 'default'], type: 'enum' as const, onChange(selected: string) { - const defaultView = - selected === 'default' - ? undefined - : (selected as 'chat' | 'transcript') - updateSettingsForSource('localSettings', { defaultView }) - setSettingsData(prev => ({ ...prev, defaultView })) - const nextBrief = defaultView === 'chat' + const defaultView = selected === 'default' ? undefined : (selected as 'chat' | 'transcript'); + updateSettingsForSource('localSettings', { defaultView }); + setSettingsData(prev => ({ ...prev, defaultView })); + const nextBrief = defaultView === 'chat'; setAppState(prev => { - if (prev.isBriefOnly === nextBrief) return prev - return { ...prev, isBriefOnly: nextBrief } - }) + if (prev.isBriefOnly === nextBrief) return prev; + return { ...prev, isBriefOnly: nextBrief }; + }); // Keep userMsgOptIn in sync so the tool list follows the view. // Two-way now (same as /brief) — accepting a cache invalidation // is better than leaving the tool on after switching away. // Reverted on Escape via initialUserMsgOptIn snapshot. - setUserMsgOptIn(nextBrief) - setChanges(prev => ({ ...prev, 'Default view': selected })) + setUserMsgOptIn(nextBrief); + setChanges(prev => ({ ...prev, 'Default view': selected })); logEvent('tengu_default_view_setting_changed', { - value: (defaultView ?? - 'unset') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + value: (defaultView ?? 'unset') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -911,27 +829,23 @@ export function Config({ id: 'editorMode', label: 'Editor mode', // Convert 'emacs' to 'normal' for backward compatibility - value: - globalConfig.editorMode === 'emacs' - ? 'normal' - : globalConfig.editorMode || 'normal', + value: globalConfig.editorMode === 'emacs' ? 'normal' : globalConfig.editorMode || 'normal', options: ['normal', 'vim'], type: 'enum', onChange(value: string) { saveGlobalConfig(current => ({ ...current, editorMode: value as GlobalConfig['editorMode'], - })) + })); setGlobalConfig({ ...getGlobalConfig(), editorMode: value as GlobalConfig['editorMode'], - }) + }); logEvent('tengu_editor_mode_changed', { mode: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, { @@ -941,19 +855,19 @@ export function Config({ type: 'boolean' as const, onChange(enabled: boolean) { saveGlobalConfig(current => { - if (current.prStatusFooterEnabled === enabled) return current + if (current.prStatusFooterEnabled === enabled) return current; return { ...current, prStatusFooterEnabled: enabled, - } - }) + }; + }); setGlobalConfig({ ...getGlobalConfig(), prStatusFooterEnabled: enabled, - }) + }); logEvent('tengu_pr_status_footer_setting_changed', { enabled, - }) + }); }, }, { @@ -975,17 +889,16 @@ export function Config({ saveGlobalConfig(current => ({ ...current, diffTool: diffTool as GlobalConfig['diffTool'], - })) + })); setGlobalConfig({ ...getGlobalConfig(), diffTool: diffTool as GlobalConfig['diffTool'], - }) + }); logEvent('tengu_diff_tool_changed', { tool: diffTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -998,14 +911,13 @@ export function Config({ value: globalConfig.autoConnectIde ?? false, type: 'boolean' as const, onChange(autoConnectIde: boolean) { - saveGlobalConfig(current => ({ ...current, autoConnectIde })) - setGlobalConfig({ ...getGlobalConfig(), autoConnectIde }) + saveGlobalConfig(current => ({ ...current, autoConnectIde })); + setGlobalConfig({ ...getGlobalConfig(), autoConnectIde }); logEvent('tengu_auto_connect_ide_changed', { enabled: autoConnectIde, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -1021,14 +933,13 @@ export function Config({ saveGlobalConfig(current => ({ ...current, autoInstallIdeExtension, - })) - setGlobalConfig({ ...getGlobalConfig(), autoInstallIdeExtension }) + })); + setGlobalConfig({ ...getGlobalConfig(), autoInstallIdeExtension }); logEvent('tengu_auto_install_ide_extension_changed', { enabled: autoInstallIdeExtension, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -1042,63 +953,62 @@ export function Config({ saveGlobalConfig(current => ({ ...current, claudeInChromeDefaultEnabled: enabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), claudeInChromeDefaultEnabled: enabled, - }) + }); logEvent('tengu_claude_in_chrome_setting_changed', { enabled, - }) + }); }, }, // Teammate mode (only shown when agent swarms are enabled) ...(isAgentSwarmsEnabled() ? (() => { - const cliOverride = getCliTeammateModeOverride() - const label = cliOverride - ? `Teammate mode [overridden: ${cliOverride}]` - : 'Teammate mode' + const cliOverride = getCliTeammateModeOverride(); + const label = cliOverride ? `Teammate mode [overridden: ${cliOverride}]` : 'Teammate mode'; + const isWindows = getPlatform() === 'windows'; + const teammateModeOptions = isWindows + ? ['auto', 'tmux', 'windows-terminal', 'in-process'] + : ['auto', 'tmux', 'in-process']; return [ { id: 'teammateMode', label, value: globalConfig.teammateMode ?? 'auto', - options: ['auto', 'tmux', 'in-process'], + options: teammateModeOptions, type: 'enum' as const, onChange(mode: string) { - if ( - mode !== 'auto' && - mode !== 'tmux' && - mode !== 'in-process' - ) { - return + if (mode !== 'auto' && mode !== 'tmux' && mode !== 'windows-terminal' && mode !== 'in-process') { + return; + } + if (mode === 'windows-terminal' && !isWindows) { + return; } // Clear CLI override and set new mode (pass mode to avoid race condition) - clearCliTeammateModeOverride(mode) + clearCliTeammateModeOverride(mode); saveGlobalConfig(current => ({ ...current, teammateMode: mode, - })) + })); setGlobalConfig({ ...getGlobalConfig(), teammateMode: mode, - }) + }); logEvent('tengu_teammate_mode_changed', { mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); }, }, { id: 'teammateDefaultModel', label: 'Default teammate model', - value: teammateModelDisplayString( - globalConfig.teammateDefaultModel, - ), + value: teammateModelDisplayString(globalConfig.teammateDefaultModel), type: 'managedEnum' as const, onChange() {}, }, - ] + ]; })() : []), // Remote at startup toggle — gated on build flag + GrowthBook + policy @@ -1117,41 +1027,36 @@ export function Config({ if (selected === 'default') { // Unset the config key so it falls back to the platform default saveGlobalConfig(current => { - if (current.remoteControlAtStartup === undefined) - return current - const next = { ...current } - delete next.remoteControlAtStartup - return next - }) + if (current.remoteControlAtStartup === undefined) return current; + const next = { ...current }; + delete next.remoteControlAtStartup; + return next; + }); setGlobalConfig({ ...getGlobalConfig(), remoteControlAtStartup: undefined, - }) + }); } else { - const enabled = selected === 'true' + const enabled = selected === 'true'; saveGlobalConfig(current => { - if (current.remoteControlAtStartup === enabled) return current - return { ...current, remoteControlAtStartup: enabled } - }) + if (current.remoteControlAtStartup === enabled) return current; + return { ...current, remoteControlAtStartup: enabled }; + }); setGlobalConfig({ ...getGlobalConfig(), remoteControlAtStartup: enabled, - }) + }); } // Sync to AppState so useReplBridge reacts immediately - const resolved = getRemoteControlAtStartup() + const resolved = getRemoteControlAtStartup(); setAppState(prev => { - if ( - prev.replBridgeEnabled === resolved && - !prev.replBridgeOutboundOnly - ) - return prev + if (prev.replBridgeEnabled === resolved && !prev.replBridgeOutboundOnly) return prev; return { ...prev, replBridgeEnabled: resolved, replBridgeOutboundOnly: false, - } - }) + }; + }); }, }, ] @@ -1162,11 +1067,11 @@ export function Config({ id: 'showExternalIncludesDialog', label: 'External CLAUDE.md includes', value: (() => { - const projectConfig = getCurrentProjectConfig() + const projectConfig = getCurrentProjectConfig(); if (projectConfig.hasClaudeMdExternalIncludesApproved) { - return 'true' + return 'true'; } else { - return 'false' + return 'false'; } })(), type: 'managedEnum' as const, @@ -1182,10 +1087,7 @@ export function Config({ id: 'apiKey', label: ( - Use custom API key:{' '} - - {normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)} - + Use custom API key: {normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)} ), searchText: 'Use custom API key', @@ -1198,94 +1100,82 @@ export function Config({ type: 'boolean' as const, onChange(useCustomKey: boolean) { saveGlobalConfig(current => { - const updated = { ...current } + const updated = { ...current }; if (!updated.customApiKeyResponses) { updated.customApiKeyResponses = { approved: [], rejected: [], - } + }; } if (!updated.customApiKeyResponses.approved) { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, approved: [], - } + }; } if (!updated.customApiKeyResponses.rejected) { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, rejected: [], - } + }; } if (process.env.ANTHROPIC_API_KEY) { - const truncatedKey = normalizeApiKeyForConfig( - process.env.ANTHROPIC_API_KEY, - ) + const truncatedKey = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); if (useCustomKey) { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, approved: [ - ...( - updated.customApiKeyResponses.approved ?? [] - ).filter(k => k !== truncatedKey), + ...(updated.customApiKeyResponses.approved ?? []).filter(k => k !== truncatedKey), truncatedKey, ], - rejected: ( - updated.customApiKeyResponses.rejected ?? [] - ).filter(k => k !== truncatedKey), - } + rejected: (updated.customApiKeyResponses.rejected ?? []).filter(k => k !== truncatedKey), + }; } else { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, - approved: ( - updated.customApiKeyResponses.approved ?? [] - ).filter(k => k !== truncatedKey), + approved: (updated.customApiKeyResponses.approved ?? []).filter(k => k !== truncatedKey), rejected: [ - ...( - updated.customApiKeyResponses.rejected ?? [] - ).filter(k => k !== truncatedKey), + ...(updated.customApiKeyResponses.rejected ?? []).filter(k => k !== truncatedKey), truncatedKey, ], - } + }; } } - return updated - }) - setGlobalConfig(getGlobalConfig()) + return updated; + }); + setGlobalConfig(getGlobalConfig()); }, }, ] : []), - ] + ]; // Filter settings based on search query const filteredSettingsItems = React.useMemo(() => { - if (!searchQuery) return settingsItems - const lowerQuery = searchQuery.toLowerCase() + if (!searchQuery) return settingsItems; + const lowerQuery = searchQuery.toLowerCase(); return settingsItems.filter(setting => { - if (setting.id.toLowerCase().includes(lowerQuery)) return true - const searchableText = - 'searchText' in setting ? setting.searchText : setting.label - return searchableText.toLowerCase().includes(lowerQuery) - }) - }, [settingsItems, searchQuery]) + if (setting.id.toLowerCase().includes(lowerQuery)) return true; + const searchableText = 'searchText' in setting ? setting.searchText : setting.label; + return searchableText.toLowerCase().includes(lowerQuery); + }); + }, [settingsItems, searchQuery]); // Adjust selected index when filtered list shrinks, and keep the selected // item visible when maxVisible changes (e.g., terminal resize). React.useEffect(() => { if (selectedIndex >= filteredSettingsItems.length) { - const newIndex = Math.max(0, filteredSettingsItems.length - 1) - setSelectedIndex(newIndex) - setScrollOffset(Math.max(0, newIndex - maxVisible + 1)) - return + const newIndex = Math.max(0, filteredSettingsItems.length - 1); + setSelectedIndex(newIndex); + setScrollOffset(Math.max(0, newIndex - maxVisible + 1)); + return; } setScrollOffset(prev => { - if (selectedIndex < prev) return selectedIndex - if (selectedIndex >= prev + maxVisible) - return selectedIndex - maxVisible + 1 - return prev - }) - }, [filteredSettingsItems.length, selectedIndex, maxVisible]) + if (selectedIndex < prev) return selectedIndex; + if (selectedIndex >= prev + maxVisible) return selectedIndex - maxVisible + 1; + return prev; + }); + }, [filteredSettingsItems.length, selectedIndex, maxVisible]); // Keep the selected item visible within the scroll window. // Called synchronously from navigation handlers to avoid a render frame @@ -1293,13 +1183,13 @@ export function Config({ const adjustScrollOffset = useCallback( (newIndex: number) => { setScrollOffset(prev => { - if (newIndex < prev) return newIndex - if (newIndex >= prev + maxVisible) return newIndex - maxVisible + 1 - return prev - }) + if (newIndex < prev) return newIndex; + if (newIndex >= prev + maxVisible) return newIndex - maxVisible + 1; + return prev; + }); }, [maxVisible], - ) + ); // Enter: keep all changes (already persisted by onChange handlers), close // with a summary of what changed. @@ -1307,164 +1197,101 @@ export function Config({ // Submenu handling: each submenu has its own Enter/Esc — don't close // the whole panel while one is open. if (showSubmenu !== null) { - return + return; } // Log any changes that were made // TODO: Make these proper messages - const formattedChanges: string[] = Object.entries(changes).map( - ([key, value]) => { - logEvent('tengu_config_changed', { - key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return `Set ${key} to ${chalk.bold(value)}` - }, - ) + const formattedChanges: string[] = Object.entries(changes).map(([key, value]) => { + logEvent('tengu_config_changed', { + key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + return `Set ${key} to ${chalk.bold(value)}`; + }); // Check for API key changes // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). - const effectiveApiKey = isRunningOnHomespace() - ? undefined - : process.env.ANTHROPIC_API_KEY + const effectiveApiKey = isRunningOnHomespace() ? undefined : process.env.ANTHROPIC_API_KEY; const initialUsingCustomKey = Boolean( effectiveApiKey && - initialConfig.current.customApiKeyResponses?.approved?.includes( - normalizeApiKeyForConfig(effectiveApiKey), - ), - ) + initialConfig.current.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(effectiveApiKey)), + ); const currentUsingCustomKey = Boolean( effectiveApiKey && - globalConfig.customApiKeyResponses?.approved?.includes( - normalizeApiKeyForConfig(effectiveApiKey), - ), - ) + globalConfig.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(effectiveApiKey)), + ); if (initialUsingCustomKey !== currentUsingCustomKey) { - formattedChanges.push( - `${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`, - ) + formattedChanges.push(`${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`); logEvent('tengu_config_changed', { key: 'env.ANTHROPIC_API_KEY' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: - currentUsingCustomKey as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + value: currentUsingCustomKey as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } if (globalConfig.theme !== initialConfig.current.theme) { - formattedChanges.push(`Set theme to ${chalk.bold(globalConfig.theme)}`) + formattedChanges.push(`Set theme to ${chalk.bold(globalConfig.theme)}`); } - if ( - globalConfig.preferredNotifChannel !== - initialConfig.current.preferredNotifChannel - ) { - formattedChanges.push( - `Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`, - ) + if (globalConfig.preferredNotifChannel !== initialConfig.current.preferredNotifChannel) { + formattedChanges.push(`Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`); } if (currentOutputStyle !== initialOutputStyle.current) { - formattedChanges.push( - `Set output style to ${chalk.bold(currentOutputStyle)}`, - ) + formattedChanges.push(`Set output style to ${chalk.bold(currentOutputStyle)}`); } if (currentLanguage !== initialLanguage.current) { - formattedChanges.push( - `Set response language to ${chalk.bold(currentLanguage ?? 'Default (English)')}`, - ) + formattedChanges.push(`Set response language to ${chalk.bold(currentLanguage ?? 'Default (English)')}`); } if (globalConfig.editorMode !== initialConfig.current.editorMode) { - formattedChanges.push( - `Set editor mode to ${chalk.bold(globalConfig.editorMode || 'emacs')}`, - ) + formattedChanges.push(`Set editor mode to ${chalk.bold(globalConfig.editorMode || 'emacs')}`); } if (globalConfig.diffTool !== initialConfig.current.diffTool) { - formattedChanges.push( - `Set diff tool to ${chalk.bold(globalConfig.diffTool)}`, - ) + formattedChanges.push(`Set diff tool to ${chalk.bold(globalConfig.diffTool)}`); } if (globalConfig.autoConnectIde !== initialConfig.current.autoConnectIde) { - formattedChanges.push( - `${globalConfig.autoConnectIde ? 'Enabled' : 'Disabled'} auto-connect to IDE`, - ) + formattedChanges.push(`${globalConfig.autoConnectIde ? 'Enabled' : 'Disabled'} auto-connect to IDE`); } - if ( - globalConfig.autoInstallIdeExtension !== - initialConfig.current.autoInstallIdeExtension - ) { + if (globalConfig.autoInstallIdeExtension !== initialConfig.current.autoInstallIdeExtension) { formattedChanges.push( `${globalConfig.autoInstallIdeExtension ? 'Enabled' : 'Disabled'} auto-install IDE extension`, - ) + ); } - if ( - globalConfig.autoCompactEnabled !== - initialConfig.current.autoCompactEnabled - ) { - formattedChanges.push( - `${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`, - ) + if (globalConfig.autoCompactEnabled !== initialConfig.current.autoCompactEnabled) { + formattedChanges.push(`${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`); } - if ( - globalConfig.respectGitignore !== initialConfig.current.respectGitignore - ) { + if (globalConfig.respectGitignore !== initialConfig.current.respectGitignore) { formattedChanges.push( `${globalConfig.respectGitignore ? 'Enabled' : 'Disabled'} respect .gitignore in file picker`, - ) + ); } - if ( - globalConfig.copyFullResponse !== initialConfig.current.copyFullResponse - ) { - formattedChanges.push( - `${globalConfig.copyFullResponse ? 'Enabled' : 'Disabled'} always copy full response`, - ) + if (globalConfig.copyFullResponse !== initialConfig.current.copyFullResponse) { + formattedChanges.push(`${globalConfig.copyFullResponse ? 'Enabled' : 'Disabled'} always copy full response`); } if (globalConfig.copyOnSelect !== initialConfig.current.copyOnSelect) { - formattedChanges.push( - `${globalConfig.copyOnSelect ? 'Enabled' : 'Disabled'} copy on select`, - ) + formattedChanges.push(`${globalConfig.copyOnSelect ? 'Enabled' : 'Disabled'} copy on select`); } - if ( - globalConfig.terminalProgressBarEnabled !== - initialConfig.current.terminalProgressBarEnabled - ) { + if (globalConfig.terminalProgressBarEnabled !== initialConfig.current.terminalProgressBarEnabled) { formattedChanges.push( `${globalConfig.terminalProgressBarEnabled ? 'Enabled' : 'Disabled'} terminal progress bar`, - ) + ); } - if ( - globalConfig.showStatusInTerminalTab !== - initialConfig.current.showStatusInTerminalTab - ) { - formattedChanges.push( - `${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`, - ) + if (globalConfig.showStatusInTerminalTab !== initialConfig.current.showStatusInTerminalTab) { + formattedChanges.push(`${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`); } - if ( - globalConfig.showTurnDuration !== initialConfig.current.showTurnDuration - ) { - formattedChanges.push( - `${globalConfig.showTurnDuration ? 'Enabled' : 'Disabled'} turn duration`, - ) + if (globalConfig.showTurnDuration !== initialConfig.current.showTurnDuration) { + formattedChanges.push(`${globalConfig.showTurnDuration ? 'Enabled' : 'Disabled'} turn duration`); } - if ( - globalConfig.remoteControlAtStartup !== - initialConfig.current.remoteControlAtStartup - ) { + if (globalConfig.remoteControlAtStartup !== initialConfig.current.remoteControlAtStartup) { const remoteLabel = globalConfig.remoteControlAtStartup === undefined ? 'Reset Remote Control to default' - : `${globalConfig.remoteControlAtStartup ? 'Enabled' : 'Disabled'} Remote Control for all sessions` - formattedChanges.push(remoteLabel) + : `${globalConfig.remoteControlAtStartup ? 'Enabled' : 'Disabled'} Remote Control for all sessions`; + formattedChanges.push(remoteLabel); } - if ( - settingsData?.autoUpdatesChannel !== - initialSettingsData.current?.autoUpdatesChannel - ) { - formattedChanges.push( - `Set auto-update channel to ${chalk.bold(settingsData?.autoUpdatesChannel ?? 'latest')}`, - ) + if (settingsData?.autoUpdatesChannel !== initialSettingsData.current?.autoUpdatesChannel) { + formattedChanges.push(`Set auto-update channel to ${chalk.bold(settingsData?.autoUpdatesChannel ?? 'latest')}`); } if (formattedChanges.length > 0) { - onClose(formattedChanges.join('\n')) + onClose(formattedChanges.join('\n')); } else { - onClose('Config dialog dismissed', { display: 'system' }) + onClose('Config dialog dismissed', { display: 'system' }); } }, [ showSubmenu, @@ -1474,11 +1301,9 @@ export function Config({ currentOutputStyle, currentLanguage, settingsData?.autoUpdatesChannel, - isFastModeEnabled() - ? (settingsData as Record | undefined)?.fastMode - : undefined, + isFastModeEnabled() ? (settingsData as Record | undefined)?.fastMode : undefined, onClose, - ]) + ]); // Restore all state stores to their mount-time snapshots. Changes are // applied to disk/AppState immediately on toggle, so "cancel" means @@ -1488,22 +1313,22 @@ export function Config({ // config overwrite since setTheme internally calls saveGlobalConfig with // a partial update — we want the full snapshot to be the last write. if (themeSetting !== initialThemeSetting.current) { - setTheme(initialThemeSetting.current) + setTheme(initialThemeSetting.current); } // Global config: full overwrite from snapshot. saveGlobalConfig skips if // the returned ref equals current (test mode checks ref; prod writes to // disk but content is identical). - saveGlobalConfig(() => initialConfig.current) + saveGlobalConfig(() => initialConfig.current); // Settings files: restore each key Config may have touched. undefined // deletes the key (updateSettingsForSource customizer at settings.ts:368). - const il = initialLocalSettings + const il = initialLocalSettings; updateSettingsForSource('localSettings', { spinnerTipsEnabled: il?.spinnerTipsEnabled, prefersReducedMotion: il?.prefersReducedMotion, defaultView: il?.defaultView, outputStyle: il?.outputStyle, - }) - const iu = initialUserSettings + }); + const iu = initialUserSettings; updateSettingsForSource('userSettings', { alwaysThinkingEnabled: iu?.alwaysThinkingEnabled, fastMode: iu?.fastMode, @@ -1513,9 +1338,7 @@ export function Config({ language: iu?.language, ...(feature('TRANSCRIPT_CLASSIFIER') ? { - useAutoModeDuringPlan: ( - iu as { useAutoModeDuringPlan?: boolean } | undefined - )?.useAutoModeDuringPlan, + useAutoModeDuringPlan: (iu as { useAutoModeDuringPlan?: boolean } | undefined)?.useAutoModeDuringPlan, } : {}), // ThemePicker's Ctrl+T writes this key directly — include it so the @@ -1528,12 +1351,10 @@ export function Config({ // Explicitly include defaultMode so undefined triggers the customizer's // delete path even when iu.permissions lacks that key. permissions: - iu?.permissions === undefined - ? undefined - : { ...iu.permissions, defaultMode: iu.permissions.defaultMode }, - }) + iu?.permissions === undefined ? undefined : { ...iu.permissions, defaultMode: iu.permissions.defaultMode }, + }); // AppState: batch-restore all possibly-touched fields. - const ia = initialAppState + const ia = initialAppState; setAppState(prev => ({ ...prev, mainLoopModel: ia.mainLoopModel, @@ -1549,12 +1370,12 @@ export function Config({ // Reconcile auto-mode state after useAutoModeDuringPlan revert above — // the onChange handler may have activated/deactivated auto mid-plan. toolPermissionContext: transitionPlanAutoMode(prev.toolPermissionContext), - })) + })); // Bootstrap state: restore userMsgOptIn. Only touched by the defaultView // onChange above, so no feature() guard needed here (that path only // exists when showDefaultViewPicker is true). if (getUserMsgOptIn() !== initialUserMsgOptIn) { - setUserMsgOptIn(initialUserMsgOptIn) + setUserMsgOptIn(initialUserMsgOptIn); } }, [ themeSetting, @@ -1564,18 +1385,18 @@ export function Config({ initialAppState, initialUserMsgOptIn, setAppState, - ]) + ]); // Escape: revert all changes (if any) and close. const handleEscape = useCallback(() => { if (showSubmenu !== null) { - return + return; } if (isDirty.current) { - revertChanges() + revertChanges(); } - onClose('Config dialog dismissed', { display: 'system' }) - }, [showSubmenu, revertChanges, onClose]) + onClose('Config dialog dismissed', { display: 'system' }); + }, [showSubmenu, revertChanges, onClose]); // Disable when submenu is open so the submenu's Dialog handles ESC, and in // search mode so the onKeyDown handler (which clears-then-exits search) @@ -1583,35 +1404,35 @@ export function Config({ useKeybinding('confirm:no', handleEscape, { context: 'Settings', isActive: showSubmenu === null && !isSearchMode && !headerFocused, - }) + }); // Save-and-close fires on Enter only when not in search mode (Enter there // exits search to the list — see the isSearchMode branch in handleKeyDown). useKeybinding('settings:close', handleSaveAndClose, { context: 'Settings', isActive: showSubmenu === null && !isSearchMode && !headerFocused, - }) + }); // Settings navigation and toggle actions via configurable keybindings. // Only active when not in search mode and no submenu is open. const toggleSetting = useCallback(() => { - const setting = filteredSettingsItems[selectedIndex] + const setting = filteredSettingsItems[selectedIndex]; if (!setting || !setting.onChange) { - return + return; } if (setting.type === 'boolean') { - isDirty.current = true - setting.onChange(!setting.value) + isDirty.current = true; + setting.onChange(!setting.value); if (setting.id === 'thinkingEnabled') { - const newValue = !setting.value - const backToInitial = newValue === initialThinkingEnabled.current + const newValue = !setting.value; + const backToInitial = newValue === initialThinkingEnabled.current; if (backToInitial) { - setShowThinkingWarning(false) + setShowThinkingWarning(false); } else if (context.messages.some(m => m.type === 'assistant')) { - setShowThinkingWarning(true) + setShowThinkingWarning(true); } } - return + return; } if ( @@ -1626,70 +1447,69 @@ export function Config({ // completion callback, not here (submenu may be cancelled). switch (setting.id) { case 'theme': - setShowSubmenu('Theme') - setTabsHidden(true) - return + setShowSubmenu('Theme'); + setTabsHidden(true); + return; case 'model': - setShowSubmenu('Model') - setTabsHidden(true) - return + setShowSubmenu('Model'); + setTabsHidden(true); + return; case 'teammateDefaultModel': - setShowSubmenu('TeammateModel') - setTabsHidden(true) - return + setShowSubmenu('TeammateModel'); + setTabsHidden(true); + return; case 'showExternalIncludesDialog': - setShowSubmenu('ExternalIncludes') - setTabsHidden(true) - return + setShowSubmenu('ExternalIncludes'); + setTabsHidden(true); + return; case 'outputStyle': - setShowSubmenu('OutputStyle') - setTabsHidden(true) - return + setShowSubmenu('OutputStyle'); + setTabsHidden(true); + return; case 'language': - setShowSubmenu('Language') - setTabsHidden(true) - return + setShowSubmenu('Language'); + setTabsHidden(true); + return; } } if (setting.id === 'autoUpdatesChannel') { if (autoUpdaterDisabledReason) { // Auto-updates are disabled - show enable dialog instead - setShowSubmenu('EnableAutoUpdates') - setTabsHidden(true) - return + setShowSubmenu('EnableAutoUpdates'); + setTabsHidden(true); + return; } - const currentChannel = settingsData?.autoUpdatesChannel ?? 'latest' + const currentChannel = settingsData?.autoUpdatesChannel ?? 'latest'; if (currentChannel === 'latest') { // Switching to stable - show downgrade dialog - setShowSubmenu('ChannelDowngrade') - setTabsHidden(true) + setShowSubmenu('ChannelDowngrade'); + setTabsHidden(true); } else { // Switching to latest - just do it and clear minimumVersion - isDirty.current = true + isDirty.current = true; updateSettingsForSource('userSettings', { autoUpdatesChannel: 'latest', minimumVersion: undefined, - }) + }); setSettingsData(prev => ({ ...prev, autoUpdatesChannel: 'latest', minimumVersion: undefined, - })) + })); logEvent('tengu_autoupdate_channel_changed', { - channel: - 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + channel: 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } - return + return; } if (setting.type === 'enum') { - isDirty.current = true - const currentIndex = setting.options.indexOf(setting.value) - const nextIndex = (currentIndex + 1) % setting.options.length - setting.onChange(setting.options[nextIndex]!) - return + isDirty.current = true; + const currentIndex = setting.options.indexOf(setting.value); + const nextIndex = (currentIndex + 1) % setting.options.length; + setting.onChange(setting.options[nextIndex]!); + return; } }, [ autoUpdaterDisabledReason, @@ -1697,17 +1517,14 @@ export function Config({ selectedIndex, settingsData?.autoUpdatesChannel, setTabsHidden, - ]) + ]); const moveSelection = (delta: -1 | 1): void => { - setShowThinkingWarning(false) - const newIndex = Math.max( - 0, - Math.min(filteredSettingsItems.length - 1, selectedIndex + delta), - ) - setSelectedIndex(newIndex) - adjustScrollOffset(newIndex) - } + setShowThinkingWarning(false); + const newIndex = Math.max(0, Math.min(filteredSettingsItems.length - 1, selectedIndex + delta)); + setSelectedIndex(newIndex); + adjustScrollOffset(newIndex); + }; useKeybindings( { @@ -1716,11 +1533,11 @@ export function Config({ // ↑ at top enters search mode so users can type-to-filter after // reaching the list boundary. Wheel-up (scroll:lineUp) clamps // instead — overshoot shouldn't move focus away from the list. - setShowThinkingWarning(false) - setIsSearchMode(true) - setScrollOffset(0) + setShowThinkingWarning(false); + setIsSearchMode(true); + setScrollOffset(0); } else { - moveSelection(-1) + moveSelection(-1); } }, 'select:next': () => moveSelection(1), @@ -1732,92 +1549,79 @@ export function Config({ 'scroll:lineDown': () => moveSelection(1), 'select:accept': toggleSetting, 'settings:search': () => { - setIsSearchMode(true) - setSearchQuery('') + setIsSearchMode(true); + setSearchQuery(''); }, }, { context: 'Settings', isActive: showSubmenu === null && !isSearchMode && !headerFocused, }, - ) + ); // Combined key handling across search/list modes. Branch order mirrors // the original useInput gate priority: submenu and header short-circuit // first (their own handlers own input), then search vs. list. const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (showSubmenu !== null) return - if (headerFocused) return + if (showSubmenu !== null) return; + if (headerFocused) return; // Search mode: Esc clears then exits, Enter/↓ moves to the list. if (isSearchMode) { if (e.key === 'escape') { - e.preventDefault() + e.preventDefault(); if (searchQuery.length > 0) { - setSearchQuery('') + setSearchQuery(''); } else { - setIsSearchMode(false) + setIsSearchMode(false); } - return + return; } if (e.key === 'return' || e.key === 'down' || e.key === 'wheeldown') { - e.preventDefault() - setIsSearchMode(false) - setSelectedIndex(0) - setScrollOffset(0) + e.preventDefault(); + setIsSearchMode(false); + setSelectedIndex(0); + setScrollOffset(0); } - return + return; } // List mode: left/right/tab cycle the selected option's value. These // keys used to switch tabs; now they only do so when the tab row is // explicitly focused (see headerFocused in Settings.tsx). if (e.key === 'left' || e.key === 'right' || e.key === 'tab') { - e.preventDefault() - toggleSetting() - return + e.preventDefault(); + toggleSetting(); + return; } // Fallback: printable characters (other than those bound to actions) // enter search mode. Carve out j/k// — useKeybindings (still on the // useInput path) consumes these via stopImmediatePropagation, but // onKeyDown dispatches independently so we must skip them explicitly. - if (e.ctrl || e.meta) return - if (e.key === 'j' || e.key === 'k' || e.key === '/') return + if (e.ctrl || e.meta) return; + if (e.key === 'j' || e.key === 'k' || e.key === '/') return; if (e.key.length === 1 && e.key !== ' ') { - e.preventDefault() - setIsSearchMode(true) - setSearchQuery(e.key) + e.preventDefault(); + setIsSearchMode(true); + setSearchQuery(e.key); } }, - [ - showSubmenu, - headerFocused, - isSearchMode, - searchQuery, - setSearchQuery, - toggleSetting, - ], - ) + [showSubmenu, headerFocused, isSearchMode, searchQuery, setSearchQuery, toggleSetting], + ); return ( - + {showSubmenu === 'Theme' ? ( <> { - isDirty.current = true - setTheme(setting) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setTheme(setting); + setShowSubmenu(null); + setTabsHidden(false); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} hideEscToCancel skipExitHandling={true} // Skip exit handling as Config already handles it @@ -1841,20 +1645,18 @@ export function Config({ { - isDirty.current = true - onChangeMainModelConfig(model) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + onChangeMainModelConfig(model); + setShowSubmenu(null); + setTabsHidden(false); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} showFastModeNotice={ isFastModeEnabled() - ? isFastMode && - isFastModeSupportedByModel(mainLoopModel) && - isFastModeAvailable() + ? isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable() : false } /> @@ -1877,39 +1679,33 @@ export function Config({ skipSettingsWrite headerText="Default model for newly spawned teammates. The leader can override via the tool call's model parameter." onSelect={(model, _effort) => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); // First-open-then-Enter from unset: picker highlights "Default" // (initial=null) and confirming would write null, silently // switching Opus-fallback → follow-leader. Treat as no-op. - if ( - globalConfig.teammateDefaultModel === undefined && - model === null - ) { - return + if (globalConfig.teammateDefaultModel === undefined && model === null) { + return; } - isDirty.current = true + isDirty.current = true; saveGlobalConfig(current => - current.teammateDefaultModel === model - ? current - : { ...current, teammateDefaultModel: model }, - ) + current.teammateDefaultModel === model ? current : { ...current, teammateDefaultModel: model }, + ); setGlobalConfig({ ...getGlobalConfig(), teammateDefaultModel: model, - }) + }); setChanges(prev => ({ ...prev, teammateDefaultModel: teammateModelDisplayString(model), - })) + })); logEvent('tengu_teammate_default_model_changed', { - model: - model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} /> @@ -1928,8 +1724,8 @@ export function Config({ <> { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} externalIncludes={getExternalClaudeMdIncludes(memoryFiles as MemoryFileInfo[])} /> @@ -1950,28 +1746,26 @@ export function Config({ { - isDirty.current = true - setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME); + setShowSubmenu(null); + setTabsHidden(false); // Save to local settings updateSettingsForSource('localSettings', { outputStyle: style, - }) + }); void logEvent('tengu_output_style_changed', { style: (style ?? DEFAULT_OUTPUT_STYLE_NAME) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - settings_source: - 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + settings_source: 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} /> @@ -1991,37 +1785,30 @@ export function Config({ { - isDirty.current = true - setCurrentLanguage(language) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setCurrentLanguage(language); + setShowSubmenu(null); + setTabsHidden(false); // Save to user settings updateSettingsForSource('userSettings', { language, - }) + }); void logEvent('tengu_language_changed', { - language: (language ?? - 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + language: (language ?? 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} /> - + @@ -2029,8 +1816,8 @@ export function Config({ { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} hideBorder hideInputGuide @@ -2043,10 +1830,7 @@ export function Config({ : 'Auto-updates are disabled in development builds.'} {autoUpdaterDisabledReason?.type === 'env' && ( - - Unset {autoUpdaterDisabledReason.envVar} to re-enable - auto-updates. - + Unset {autoUpdaterDisabledReason.envVar} to re-enable auto-updates. )} ) : ( @@ -2062,29 +1846,28 @@ export function Config({ }, ]} onChange={(channel: string) => { - isDirty.current = true - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setShowSubmenu(null); + setTabsHidden(false); saveGlobalConfig(current => ({ ...current, autoUpdates: true, - })) - setGlobalConfig({ ...getGlobalConfig(), autoUpdates: true }) + })); + setGlobalConfig({ ...getGlobalConfig(), autoUpdates: true }); updateSettingsForSource('userSettings', { autoUpdatesChannel: channel as 'latest' | 'stable', minimumVersion: undefined, - }) + }); setSettingsData(prev => ({ ...prev, autoUpdatesChannel: channel as 'latest' | 'stable', minimumVersion: undefined, - })) + })); logEvent('tengu_autoupdate_enabled', { - channel: - channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + channel: channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} /> )} @@ -2093,46 +1876,41 @@ export function Config({ { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); if (choice === 'cancel') { // User cancelled - don't change anything - return + return; } - isDirty.current = true + isDirty.current = true; // Switch to stable channel const newSettings: { - autoUpdatesChannel: 'stable' - minimumVersion?: string + autoUpdatesChannel: 'stable'; + minimumVersion?: string; } = { autoUpdatesChannel: 'stable', - } + }; if (choice === 'stay') { // User wants to stay on current version until stable catches up - newSettings.minimumVersion = MACRO.VERSION + newSettings.minimumVersion = MACRO.VERSION; } - updateSettingsForSource('userSettings', newSettings) + updateSettingsForSource('userSettings', newSettings); setSettingsData(prev => ({ ...prev, ...newSettings, - })) + })); logEvent('tengu_autoupdate_channel_changed', { - channel: - 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + channel: 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, minimum_version_set: choice === 'stay', - }) + }); }} /> ) : ( - + )} - {filteredSettingsItems - .slice(scrollOffset, scrollOffset + maxVisible) - .map((setting, i) => { - const actualIndex = scrollOffset + i - const isSelected = - actualIndex === selectedIndex && - !headerFocused && - !isSearchMode + {filteredSettingsItems.slice(scrollOffset, scrollOffset + maxVisible).map((setting, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === selectedIndex && !headerFocused && !isSearchMode; - return ( - - - - - {isSelected ? figures.pointer : ' '}{' '} - {setting.label} - - - - {setting.type === 'boolean' ? ( - <> - - {setting.value.toString()} - - {showThinkingWarning && - setting.id === 'thinkingEnabled' && ( - - {' '} - Changing thinking mode mid-conversation - will increase latency and may reduce - quality. - - )} - - ) : setting.id === 'theme' ? ( - - {THEME_LABELS[setting.value.toString()] ?? - setting.value.toString()} - - ) : setting.id === 'notifChannel' ? ( - - - - ) : setting.id === 'defaultPermissionMode' ? ( - - {permissionModeTitle( - setting.value as PermissionMode, - )} - - ) : setting.id === 'autoUpdatesChannel' && - autoUpdaterDisabledReason ? ( - - - disabled - - - ( - {formatAutoUpdaterDisabledReason( - autoUpdaterDisabledReason, - )} - ) - - - ) : ( - - {setting.value.toString()} - - )} - + return ( + + + + + {isSelected ? figures.pointer : ' '} {setting.label} + - - ) - })} + + {setting.type === 'boolean' ? ( + <> + {setting.value.toString()} + {showThinkingWarning && setting.id === 'thinkingEnabled' && ( + + {' '} + Changing thinking mode mid-conversation will increase latency and may reduce quality. + + )} + + ) : setting.id === 'theme' ? ( + + {THEME_LABELS[setting.value.toString()] ?? setting.value.toString()} + + ) : setting.id === 'notifChannel' ? ( + + + + ) : setting.id === 'defaultPermissionMode' ? ( + + {permissionModeTitle(setting.value as PermissionMode)} + + ) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? ( + + disabled + ({formatAutoUpdaterDisabledReason(autoUpdaterDisabledReason)}) + + ) : ( + {setting.value.toString()} + )} + + + + ); + })} {scrollOffset + maxVisible < filteredSettingsItems.length && ( - {figures.arrowDown}{' '} - {filteredSettingsItems.length - scrollOffset - maxVisible}{' '} - more below + {figures.arrowDown} {filteredSettingsItems.length - scrollOffset - maxVisible} more below )} @@ -2254,12 +1991,7 @@ export function Config({ - + ) : isSearchMode ? ( @@ -2268,12 +2000,7 @@ export function Config({ Type to filter - + ) : ( @@ -2297,27 +2024,22 @@ export function Config({ fallback="/" description="search" /> - + )} )} - ) + ); } function teammateModelDisplayString(value: string | null | undefined): string { if (value === undefined) { - return modelDisplayString(getHardcodedTeammateModelFallback()) + return modelDisplayString(getHardcodedTeammateModelFallback()); } - if (value === null) return "Default (leader's model)" - return modelDisplayString(value) + if (value === null) return "Default (leader's model)"; + return modelDisplayString(value); } const THEME_LABELS: Record = { @@ -2328,41 +2050,41 @@ const THEME_LABELS: Record = { 'light-daltonized': 'Light mode (colorblind-friendly)', 'dark-ansi': 'Dark mode (ANSI colors only)', 'light-ansi': 'Light mode (ANSI colors only)', -} +}; function NotifChannelLabel({ value }: { value: string }): React.ReactNode { switch (value) { case 'auto': - return 'Auto' + return 'Auto'; case 'iterm2': return ( iTerm2 (OSC 9) - ) + ); case 'terminal_bell': return ( Terminal Bell (\a) - ) + ); case 'kitty': return ( Kitty (OSC 99) - ) + ); case 'ghostty': return ( Ghostty (OSC 777) - ) + ); case 'iterm2_with_bell': - return 'iTerm2 w/ Bell' + return 'iTerm2 w/ Bell'; case 'notifications_disabled': - return 'Disabled' + return 'Disabled'; default: - return value + return value; } } diff --git a/src/components/agents/SnapshotUpdateDialog.ts b/src/components/agents/SnapshotUpdateDialog.ts index a23511b4d..2ad06f696 100644 --- a/src/components/agents/SnapshotUpdateDialog.ts +++ b/src/components/agents/SnapshotUpdateDialog.ts @@ -1,13 +1,79 @@ -// Auto-generated stub — replace with real implementation -import type React from 'react'; -import type { AgentMemoryScope } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js'; +import React from 'react' +import { Dialog, Text } from '@anthropic/ink' +import type { AgentMemoryScope } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js' +import { Select } from '../CustomSelect/index.js' -export {}; -export const SnapshotUpdateDialog: React.FC<{ - agentType: string; - scope: AgentMemoryScope; - snapshotTimestamp: string; - onComplete: (choice: 'merge' | 'keep' | 'replace') => void; - onCancel: () => void; -}> = (() => null); -export const buildMergePrompt: (agentType: string, scope: AgentMemoryScope) => string = (() => ''); +interface SnapshotUpdateDialogProps { + agentType: string + scope: AgentMemoryScope + snapshotTimestamp: string + onComplete: (choice: 'merge' | 'keep' | 'replace') => void + onCancel: () => void +} + +// Ink uses React.createElement instead of JSX here so the real implementation +// can live in a .ts file (bun's `.js` import resolver picks up .ts before +// .tsx in this repo's layout, so co-locating both extensions would shadow +// this module with an empty stub). +export function SnapshotUpdateDialog({ + agentType, + scope, + snapshotTimestamp, + onComplete, + onCancel, +}: SnapshotUpdateDialogProps): React.ReactElement { + const children = [ + React.createElement( + Text, + { dimColor: true, key: 'timestamp' }, + `Snapshot timestamp: ${snapshotTimestamp}`, + ), + React.createElement(Select, { + key: 'select', + defaultFocusValue: 'merge', + options: [ + { + label: 'Merge snapshot into current memory', + value: 'merge', + description: + 'Keep current memory and ask Claude to merge in the snapshot changes.', + }, + { + label: 'Keep current memory', + value: 'keep', + description: + 'Ignore this snapshot update and continue with current memory.', + }, + { + label: 'Replace with snapshot', + value: 'replace', + description: + 'Overwrite current memory files with the snapshot contents.', + }, + ], + onChange: onComplete as (value: unknown) => void, + }), + ] + return React.createElement(Dialog, { + title: 'Agent memory snapshot update', + subtitle: `A newer ${scope} memory snapshot is available for ${agentType}.`, + onCancel, + color: 'warning' as const, + children, + }) +} + +export function buildMergePrompt( + agentType: string, + scope: AgentMemoryScope, +): string { + return `A newer ${scope} persistent memory snapshot is available for the "${agentType}" agent. + +Please merge the snapshot update into the current ${scope} agent memory before continuing: +- Preserve useful current memory entries. +- Incorporate newer or more accurate information from the snapshot. +- Resolve duplicates or conflicts in favor of the most current, specific information. +- Keep the memory concise and relevant to future runs of this agent. + +After merging, continue with the user's request.` +} diff --git a/src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx b/src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx new file mode 100644 index 000000000..b38f947fe --- /dev/null +++ b/src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'bun:test'; +import * as React from 'react'; +import { launchSnapshotUpdateDialog } from '../../../dialogLaunchers.js'; +import { buildMergePrompt, SnapshotUpdateDialog } from '../SnapshotUpdateDialog.js'; +import { Select } from '../../CustomSelect/index.js'; + +function getSnapshotDialogFromRenderedTree(rendered: React.ReactElement) { + const appStateProvider = rendered as React.ReactElement<{ + children: React.ReactElement; + }>; + const keybindingSetup = appStateProvider.props.children as React.ReactElement<{ + children: React.ReactElement; + }>; + return keybindingSetup.props.children as React.ReactElement<{ + agentType: string; + scope: string; + snapshotTimestamp: string; + onComplete: (choice: 'merge' | 'keep' | 'replace') => void; + onCancel: () => void; + }>; +} + +async function waitForRender(getRendered: () => React.ReactElement | null): Promise { + for (let i = 0; i < 10; i++) { + const rendered = getRendered(); + if (rendered) return rendered; + await new Promise(resolve => setTimeout(resolve, 0)); + } + throw new Error('Snapshot update dialog was not rendered'); +} + +describe('SnapshotUpdateDialog', () => { + test('launchSnapshotUpdateDialog wires props and keep-on-cancel semantics through showSetupDialog', async () => { + let rendered: React.ReactElement | null = null; + const root = { + render(node: React.ReactElement) { + rendered = node; + }, + } as any; + + const resultPromise = launchSnapshotUpdateDialog(root, { + agentType: 'researcher', + scope: 'project', + snapshotTimestamp: '2026-04-15T12:00:00.000Z', + }); + + const dialogElement = getSnapshotDialogFromRenderedTree(await waitForRender(() => rendered)); + + expect(dialogElement.type).toBe(SnapshotUpdateDialog); + expect(dialogElement.props.agentType).toBe('researcher'); + expect(dialogElement.props.scope).toBe('project'); + expect(dialogElement.props.snapshotTimestamp).toBe('2026-04-15T12:00:00.000Z'); + + dialogElement.props.onCancel(); + await expect(resultPromise).resolves.toBe('keep'); + }); + + test('launchSnapshotUpdateDialog forwards explicit completion choices', async () => { + let rendered: React.ReactElement | null = null; + const root = { + render(node: React.ReactElement) { + rendered = node; + }, + } as any; + + const resultPromise = launchSnapshotUpdateDialog(root, { + agentType: 'researcher', + scope: 'user', + snapshotTimestamp: '2026-04-15T12:00:00.000Z', + }); + + const dialogElement = getSnapshotDialogFromRenderedTree(await waitForRender(() => rendered)); + dialogElement.props.onComplete('replace'); + + await expect(resultPromise).resolves.toBe('replace'); + }); + + test('buildMergePrompt is non-empty and varies with both agentType and scope', () => { + const projectPrompt = buildMergePrompt('researcher', 'project'); + const userPrompt = buildMergePrompt('researcher', 'user'); + const plannerPrompt = buildMergePrompt('planner', 'project'); + + expect(projectPrompt.trim().length).toBeGreaterThan(0); + expect(projectPrompt).toContain('researcher'); + expect(projectPrompt).toContain('project'); + expect(projectPrompt.toLowerCase()).toContain('snapshot'); + expect(projectPrompt.toLowerCase()).toContain('merge'); + expect(projectPrompt).not.toBe(userPrompt); + expect(projectPrompt).not.toBe(plannerPrompt); + }); + + test('renders snapshot metadata and choice options from its public props', () => { + const element = SnapshotUpdateDialog({ + agentType: 'researcher', + scope: 'project', + snapshotTimestamp: '2026-04-15T12:00:00.000Z', + onComplete: () => {}, + onCancel: () => {}, + } as any) as React.ReactElement<{ title: string; subtitle: string; children: React.ReactNode[] }>; + + expect(element.props.title).toBe('Agent memory snapshot update'); + expect(element.props.subtitle).toContain('researcher'); + expect(element.props.subtitle).toContain('project'); + + const [timestamp, select] = element.props.children as Array>>; + expect(timestamp.props.children).toContain('2026-04-15T12:00:00.000Z'); + expect(select.type).toBe(Select); + expect(select.props.options.map((option: { value: string }) => option.value)).toEqual(['merge', 'keep', 'replace']); + expect(select.props.options.map((option: { label: string }) => option.label)).toEqual([ + 'Merge snapshot into current memory', + 'Keep current memory', + 'Replace with snapshot', + ]); + }); +}); diff --git a/src/components/messages/SnipBoundaryMessage.tsx b/src/components/messages/SnipBoundaryMessage.tsx new file mode 100644 index 000000000..193d548c6 --- /dev/null +++ b/src/components/messages/SnipBoundaryMessage.tsx @@ -0,0 +1,23 @@ +/** + * SnipBoundaryMessage — visual separator showing where conversation was snipped. + */ +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Message } from '../../types/message.js'; + +type Props = { + message: Message; +}; + +export function SnipBoundaryMessage({ message }: Props): React.ReactNode { + const content = + typeof (message as Record).content === 'string' + ? ((message as Record).content as string) + : '[snip] Conversation history before this point has been snipped.'; + + return ( + + ── {content} ── + + ); +} diff --git a/src/components/messages/UserCrossSessionMessage.tsx b/src/components/messages/UserCrossSessionMessage.tsx new file mode 100644 index 000000000..5a0a6b1e6 --- /dev/null +++ b/src/components/messages/UserCrossSessionMessage.tsx @@ -0,0 +1,31 @@ +/** + * UserCrossSessionMessage — render a message received from another Claude session + * via UDS_INBOX (SendMessage tool). + */ +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { extractTag } from '../../utils/messages.js'; + +type Props = { + addMargin: boolean; + param: TextBlockParam; +}; + +export function UserCrossSessionMessage({ param, addMargin }: Props): React.ReactNode { + const text = param.text; + const extracted = extractTag(text, 'cross-session-message'); + if (!extracted) { + return null; + } + + const fromMatch = text.match(/from="([^"]*)"/); + const from = fromMatch?.[1] ?? 'another session'; + + return ( + + [{from}] + {extracted} + + ); +} diff --git a/src/components/messages/UserForkBoilerplateMessage.tsx b/src/components/messages/UserForkBoilerplateMessage.tsx new file mode 100644 index 000000000..3dacf1c77 --- /dev/null +++ b/src/components/messages/UserForkBoilerplateMessage.tsx @@ -0,0 +1,30 @@ +/** + * UserForkBoilerplateMessage — render the fork/subagent boilerplate directive. + */ +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { extractTag } from '../../utils/messages.js'; + +type Props = { + addMargin: boolean; + param: TextBlockParam; +}; + +export function UserForkBoilerplateMessage({ param, addMargin }: Props): React.ReactNode { + const text = param.text; + const extracted = extractTag(text, 'fork-boilerplate'); + if (!extracted) { + return null; + } + + const firstLine = extracted.trim().split('\n')[0] ?? ''; + const preview = firstLine.length > 80 ? firstLine.slice(0, 77) + '...' : firstLine; + + return ( + + [fork] + {preview} + + ); +} diff --git a/src/components/messages/UserGitHubWebhookMessage.tsx b/src/components/messages/UserGitHubWebhookMessage.tsx new file mode 100644 index 000000000..4bad55bf3 --- /dev/null +++ b/src/components/messages/UserGitHubWebhookMessage.tsx @@ -0,0 +1,36 @@ +/** + * UserGitHubWebhookMessage — render inbound GitHub webhook activity. + */ +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { extractTag } from '../../utils/messages.js'; + +type Props = { + addMargin: boolean; + param: TextBlockParam; +}; + +export function UserGitHubWebhookMessage({ param, addMargin }: Props): React.ReactNode { + const text = param.text; + const extracted = extractTag(text, 'github-webhook-activity'); + if (!extracted) { + return null; + } + + const eventMatch = extracted.match(/event[_-]?type[":\s]+["']?(\w+)/); + const repoMatch = extracted.match(/repo(?:sitory)?[":\s]+["']?([^"'\s,}]+)/); + const event = eventMatch?.[1] ?? 'activity'; + const repo = repoMatch?.[1] ?? ''; + const repoSuffix = repo ? ` in ${repo}` : ''; + + return ( + + [GitHub] + + {event} + {repoSuffix} + + + ); +} diff --git a/src/components/shell/OutputLine.tsx b/src/components/shell/OutputLine.tsx index 0b2c280af..e7ffaa108 100644 --- a/src/components/shell/OutputLine.tsx +++ b/src/components/shell/OutputLine.tsx @@ -106,6 +106,7 @@ export function OutputLine({ export function stripUnderlineAnsi(content: string): string { return content.replace( // eslint-disable-next-line no-control-regex + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape code regex /\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g, '', ) diff --git a/src/components/teams/TeamsDialog.tsx b/src/components/teams/TeamsDialog.tsx index 48019d6e0..69b078e67 100644 --- a/src/components/teams/TeamsDialog.tsx +++ b/src/components/teams/TeamsDialog.tsx @@ -1,309 +1,262 @@ -import { randomUUID } from 'crypto' -import figures from 'figures' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useInterval } from 'usehooks-ts' -import { useRegisterOverlay } from '../../context/overlayContext.js' +import { randomUUID } from 'crypto'; +import figures from 'figures'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useInterval } from 'usehooks-ts'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow dialog navigation -import { Box, Text, useInput, stringWidth } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' -import { - type AppState, - useAppState, - useSetAppState, -} from '../../state/AppState.js' -import { getEmptyToolPermissionContext } from '../../Tool.js' -import { AGENT_COLOR_TO_THEME_COLOR } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' -import { logForDebugging } from '../../utils/debug.js' -import { execFileNoThrow } from '../../utils/execFileNoThrow.js' -import { truncateToWidth } from '../../utils/format.js' -import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js' +import { Box, Text, useInput, stringWidth } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; +import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js'; +import { getEmptyToolPermissionContext } from '../../Tool.js'; +import { AGENT_COLOR_TO_THEME_COLOR } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; +import { truncateToWidth } from '../../utils/format.js'; +import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js'; import { getModeColor, type PermissionMode, permissionModeFromString, permissionModeSymbol, -} from '../../utils/permissions/PermissionMode.js' -import { jsonStringify } from '../../utils/slowOperations.js' -import { - IT2_COMMAND, - isInsideTmuxSync, -} from '../../utils/swarm/backends/detection.js' -import { - ensureBackendsRegistered, - getBackendByType, - getCachedBackend, -} from '../../utils/swarm/backends/registry.js' -import type { PaneBackendType } from '../../utils/swarm/backends/types.js' -import { - getSwarmSocketName, - TMUX_COMMAND, -} from '../../utils/swarm/constants.js' +} from '../../utils/permissions/PermissionMode.js'; +import { jsonStringify } from '../../utils/slowOperations.js'; +import { IT2_COMMAND, isInsideTmuxSync } from '../../utils/swarm/backends/detection.js'; +import { ensureBackendsRegistered, getBackendByType, getCachedBackend } from '../../utils/swarm/backends/registry.js'; +import { isPaneBackend, type PaneBackendType } from '../../utils/swarm/backends/types.js'; +import { getSwarmSocketName, TMUX_COMMAND } from '../../utils/swarm/constants.js'; import { addHiddenPaneId, removeHiddenPaneId, removeMemberFromTeam, setMemberMode, setMultipleMemberModes, -} from '../../utils/swarm/teamHelpers.js' -import { - listTasks, - type Task, - unassignTeammateTasks, -} from '../../utils/tasks.js' -import { - getTeammateStatuses, - type TeammateStatus, - type TeamSummary, -} from '../../utils/teamDiscovery.js' +} from '../../utils/swarm/teamHelpers.js'; +import { listTasks, type Task, unassignTeammateTasks } from '../../utils/tasks.js'; +import { getTeammateStatuses, type TeammateStatus, type TeamSummary } from '../../utils/teamDiscovery.js'; import { createModeSetRequestMessage, sendShutdownRequestToMailbox, writeToMailbox, -} from '../../utils/teammateMailbox.js' -import { Dialog } from '@anthropic/ink' -import ThemedText from '../design-system/ThemedText.js' +} from '../../utils/teammateMailbox.js'; +import { Dialog } from '@anthropic/ink'; +import ThemedText from '../design-system/ThemedText.js'; type Props = { - initialTeams?: TeamSummary[] - onDone: () => void -} + initialTeams?: TeamSummary[]; + onDone: () => void; +}; type DialogLevel = | { type: 'teammateList'; teamName: string } - | { type: 'teammateDetail'; teamName: string; memberName: string } + | { type: 'teammateDetail'; teamName: string; memberName: string }; /** * Dialog for viewing teammates in the current team */ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode { // Register as overlay so CancelRequestHandler doesn't intercept escape - useRegisterOverlay('teams-dialog') + useRegisterOverlay('teams-dialog'); // initialTeams is derived from teamContext in PromptInput (no filesystem I/O) - const setAppState = useSetAppState() + const setAppState = useSetAppState(); // Initialize dialogLevel with first team name if available - const firstTeamName = initialTeams?.[0]?.name ?? '' + const firstTeamName = initialTeams?.[0]?.name ?? ''; const [dialogLevel, setDialogLevel] = useState({ type: 'teammateList', teamName: firstTeamName, - }) - const [selectedIndex, setSelectedIndex] = useState(0) - const [refreshKey, setRefreshKey] = useState(0) + }); + const [selectedIndex, setSelectedIndex] = useState(0); + const [refreshKey, setRefreshKey] = useState(0); // initialTeams is now always provided from PromptInput (derived from teamContext) // No filesystem I/O needed here const teammateStatuses = useMemo(() => { - return getTeammateStatuses(dialogLevel.teamName) + return getTeammateStatuses(dialogLevel.teamName); // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, [dialogLevel.teamName, refreshKey]) + }, [dialogLevel.teamName, refreshKey]); // Periodically refresh to pick up mode changes from teammates useInterval(() => { - setRefreshKey(k => k + 1) - }, 1000) + setRefreshKey(k => k + 1); + }, 1000); const currentTeammate = useMemo(() => { - if (dialogLevel.type !== 'teammateDetail') return null - return teammateStatuses.find(t => t.name === dialogLevel.memberName) ?? null - }, [dialogLevel, teammateStatuses]) + if (dialogLevel.type !== 'teammateDetail') return null; + return teammateStatuses.find(t => t.name === dialogLevel.memberName) ?? null; + }, [dialogLevel, teammateStatuses]); // Get isBypassPermissionsModeAvailable from AppState - const isBypassAvailable = useAppState( - s => s.toolPermissionContext.isBypassPermissionsModeAvailable, - ) + const isBypassAvailable = useAppState(s => s.toolPermissionContext.isBypassPermissionsModeAvailable); const goBackToList = (): void => { - setDialogLevel({ type: 'teammateList', teamName: dialogLevel.teamName }) - setSelectedIndex(0) - } + setDialogLevel({ type: 'teammateList', teamName: dialogLevel.teamName }); + setSelectedIndex(0); + }; // Handler for confirm:cycleMode - cycle teammate permission modes const handleCycleMode = useCallback(() => { if (dialogLevel.type === 'teammateDetail' && currentTeammate) { // Detail view: cycle just this teammate - cycleTeammateMode( - currentTeammate, - dialogLevel.teamName, - isBypassAvailable, - ) - setRefreshKey(k => k + 1) - } else if ( - dialogLevel.type === 'teammateList' && - teammateStatuses.length > 0 - ) { + cycleTeammateMode(currentTeammate, dialogLevel.teamName, isBypassAvailable); + setRefreshKey(k => k + 1); + } else if (dialogLevel.type === 'teammateList' && teammateStatuses.length > 0) { // List view: cycle all teammates in tandem - cycleAllTeammateModes( - teammateStatuses, - dialogLevel.teamName, - isBypassAvailable, - ) - setRefreshKey(k => k + 1) + cycleAllTeammateModes(teammateStatuses, dialogLevel.teamName, isBypassAvailable); + setRefreshKey(k => k + 1); } - }, [dialogLevel, currentTeammate, teammateStatuses, isBypassAvailable]) + }, [dialogLevel, currentTeammate, teammateStatuses, isBypassAvailable]); // Use keybindings for mode cycling - useKeybindings( - { 'confirm:cycleMode': handleCycleMode }, - { context: 'Confirmation' }, - ) + useKeybindings({ 'confirm:cycleMode': handleCycleMode }, { context: 'Confirmation' }); useInput((input, key) => { // Handle left arrow to go back if (key.leftArrow) { if (dialogLevel.type === 'teammateDetail') { - goBackToList() + goBackToList(); } - return + return; } // Handle up/down navigation if (key.upArrow || key.downArrow) { - const maxIndex = getMaxIndex() + const maxIndex = getMaxIndex(); if (key.upArrow) { - setSelectedIndex(prev => Math.max(0, prev - 1)) + setSelectedIndex(prev => Math.max(0, prev - 1)); } else { - setSelectedIndex(prev => Math.min(maxIndex, prev + 1)) + setSelectedIndex(prev => Math.min(maxIndex, prev + 1)); } - return + return; } // Handle Enter to drill down or view output if (key.return) { - if ( - dialogLevel.type === 'teammateList' && - teammateStatuses[selectedIndex] - ) { + if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) { setDialogLevel({ type: 'teammateDetail', teamName: dialogLevel.teamName, memberName: teammateStatuses[selectedIndex].name, - }) + }); } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { // View output - switch to tmux pane void viewTeammateOutput( currentTeammate.tmuxPaneId, - currentTeammate.backendType, - ) - onDone() + currentTeammate.backendType && isPaneBackend(currentTeammate.backendType) + ? currentTeammate.backendType + : undefined, + ); + onDone(); } - return + return; } // Handle 'k' to kill teammate if (input === 'k') { - if ( - dialogLevel.type === 'teammateList' && - teammateStatuses[selectedIndex] - ) { + if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) { void killTeammate( teammateStatuses[selectedIndex].tmuxPaneId, - teammateStatuses[selectedIndex].backendType, + teammateStatuses[selectedIndex].backendType && isPaneBackend(teammateStatuses[selectedIndex].backendType) + ? teammateStatuses[selectedIndex].backendType + : undefined, dialogLevel.teamName, teammateStatuses[selectedIndex].agentId, teammateStatuses[selectedIndex].name, setAppState, ).then(() => { - setRefreshKey(k => k + 1) + setRefreshKey(k => k + 1); // Adjust selection if needed - setSelectedIndex(prev => - Math.max(0, Math.min(prev, teammateStatuses.length - 2)), - ) - }) + setSelectedIndex(prev => Math.max(0, Math.min(prev, teammateStatuses.length - 2))); + }); } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { void killTeammate( currentTeammate.tmuxPaneId, - currentTeammate.backendType, + currentTeammate.backendType && isPaneBackend(currentTeammate.backendType) + ? currentTeammate.backendType + : undefined, dialogLevel.teamName, currentTeammate.agentId, currentTeammate.name, setAppState, - ) - goBackToList() + ); + goBackToList(); } - return + return; } // Handle 's' for shutdown of selected teammate if (input === 's') { - if ( - dialogLevel.type === 'teammateList' && - teammateStatuses[selectedIndex] - ) { - const teammate = teammateStatuses[selectedIndex] + if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) { + const teammate = teammateStatuses[selectedIndex]; void sendShutdownRequestToMailbox( teammate.name, dialogLevel.teamName, 'Graceful shutdown requested by team lead', - ) + ); } else if (dialogLevel.type === 'teammateDetail' && currentTeammate) { void sendShutdownRequestToMailbox( currentTeammate.name, dialogLevel.teamName, 'Graceful shutdown requested by team lead', - ) - goBackToList() + ); + goBackToList(); } - return + return; } // Handle 'h' to hide/show individual teammate (only for backends that support it) if (input === 'h') { - const backend = getCachedBackend() + const backend = getCachedBackend(); const teammate = dialogLevel.type === 'teammateList' ? teammateStatuses[selectedIndex] : dialogLevel.type === 'teammateDetail' ? currentTeammate - : null + : null; if (teammate && backend?.supportsHideShow) { - void toggleTeammateVisibility(teammate, dialogLevel.teamName).then( - () => { - // Force refresh of teammate statuses - setRefreshKey(k => k + 1) - }, - ) + void toggleTeammateVisibility(teammate, dialogLevel.teamName).then(() => { + // Force refresh of teammate statuses + setRefreshKey(k => k + 1); + }); if (dialogLevel.type === 'teammateDetail') { - goBackToList() + goBackToList(); } } - return + return; } // Handle 'H' to hide/show all teammates (only for backends that support it) if (input === 'H' && dialogLevel.type === 'teammateList') { - const backend = getCachedBackend() + const backend = getCachedBackend(); if (backend?.supportsHideShow && teammateStatuses.length > 0) { // If any are visible, hide all. Otherwise, show all. - const anyVisible = teammateStatuses.some(t => !t.isHidden) + const anyVisible = teammateStatuses.some(t => !t.isHidden); void Promise.all( teammateStatuses.map(t => - anyVisible - ? hideTeammate(t, dialogLevel.teamName) - : showTeammate(t, dialogLevel.teamName), + anyVisible ? hideTeammate(t, dialogLevel.teamName) : showTeammate(t, dialogLevel.teamName), ), ).then(() => { // Force refresh of teammate statuses - setRefreshKey(k => k + 1) - }) + setRefreshKey(k => k + 1); + }); } - return + return; } // Handle 'p' to prune (kill) all idle teammates if (input === 'p' && dialogLevel.type === 'teammateList') { - const idleTeammates = teammateStatuses.filter(t => t.status === 'idle') + const idleTeammates = teammateStatuses.filter(t => t.status === 'idle'); if (idleTeammates.length > 0) { void Promise.all( idleTeammates.map(t => killTeammate( t.tmuxPaneId, - t.backendType, + t.backendType && isPaneBackend(t.backendType) ? t.backendType : undefined, dialogLevel.teamName, t.agentId, t.name, @@ -311,29 +264,21 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode { ), ), ).then(() => { - setRefreshKey(k => k + 1) - setSelectedIndex(prev => - Math.max( - 0, - Math.min( - prev, - teammateStatuses.length - idleTeammates.length - 1, - ), - ), - ) - }) + setRefreshKey(k => k + 1); + setSelectedIndex(prev => Math.max(0, Math.min(prev, teammateStatuses.length - idleTeammates.length - 1))); + }); } - return + return; } // Note: Mode cycling (shift+tab) is handled via useKeybindings with confirm:cycleMode action - }) + }); function getMaxIndex(): number { if (dialogLevel.type === 'teammateList') { - return Math.max(0, teammateStatuses.length - 1) + return Math.max(0, teammateStatuses.length - 1); } - return 0 + return 0; } // Render based on dialog level @@ -345,215 +290,150 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode { selectedIndex={selectedIndex} onCancel={onDone} /> - ) + ); } if (dialogLevel.type === 'teammateDetail' && currentTeammate) { - return ( - - ) + return ; } - return null + return null; } type TeamDetailViewProps = { - teamName: string - teammates: TeammateStatus[] - selectedIndex: number - onCancel: () => void -} + teamName: string; + teammates: TeammateStatus[]; + selectedIndex: number; + onCancel: () => void; +}; -function TeamDetailView({ - teamName, - teammates, - selectedIndex, - onCancel, -}: TeamDetailViewProps): React.ReactNode { - const subtitle = `${teammates.length} ${teammates.length === 1 ? 'teammate' : 'teammates'}` +function TeamDetailView({ teamName, teammates, selectedIndex, onCancel }: TeamDetailViewProps): React.ReactNode { + const subtitle = `${teammates.length} ${teammates.length === 1 ? 'teammate' : 'teammates'}`; // Check if the backend supports hide/show - const supportsHideShow = getCachedBackend()?.supportsHideShow ?? false + const supportsHideShow = getCachedBackend()?.supportsHideShow ?? false; // Get the display text for the cycle mode shortcut - const cycleModeShortcut = useShortcutDisplay( - 'confirm:cycleMode', - 'Confirmation', - 'shift+tab', - ) + const cycleModeShortcut = useShortcutDisplay('confirm:cycleMode', 'Confirmation', 'shift+tab'); return ( <> - + {teammates.length === 0 ? ( No teammates ) : ( {teammates.map((teammate, index) => ( - + ))} )} - {figures.arrowUp}/{figures.arrowDown} select · Enter view · k kill · s - shutdown · p prune idle + {figures.arrowUp}/{figures.arrowDown} select · Enter view · k kill · s shutdown · p prune idle {supportsHideShow && ' · h hide/show · H hide/show all'} {' · '} {cycleModeShortcut} sync cycle modes for all · Esc close - ) + ); } type TeammateListItemProps = { - teammate: TeammateStatus - isSelected: boolean -} + teammate: TeammateStatus; + isSelected: boolean; +}; -function TeammateListItem({ - teammate, - isSelected, -}: TeammateListItemProps): React.ReactNode { - const isIdle = teammate.status === 'idle' +function TeammateListItem({ teammate, isSelected }: TeammateListItemProps): React.ReactNode { + const isIdle = teammate.status === 'idle'; // Only dim if idle AND not selected - selection highlighting takes precedence - const shouldDim = isIdle && !isSelected + const shouldDim = isIdle && !isSelected; // Get mode display - const mode = teammate.mode - ? permissionModeFromString(teammate.mode) - : 'default' - const modeSymbol = permissionModeSymbol(mode) - const modeColor = getModeColor(mode) + const mode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default'; + const modeSymbol = permissionModeSymbol(mode); + const modeColor = getModeColor(mode); return ( {isSelected ? figures.pointer + ' ' : ' '} {teammate.isHidden && [hidden] } {isIdle && [idle] } - {modeSymbol && {modeSymbol} }@ - {teammate.name} + {modeSymbol && {modeSymbol} }@{teammate.name} {teammate.model && ({teammate.model})} - ) + ); } type TeammateDetailViewProps = { - teammate: TeammateStatus - teamName: string - onCancel: () => void -} + teammate: TeammateStatus; + teamName: string; + onCancel: () => void; +}; -function TeammateDetailView({ - teammate, - teamName, - onCancel, -}: TeammateDetailViewProps): React.ReactNode { - const [promptExpanded, setPromptExpanded] = useState(false) +function TeammateDetailView({ teammate, teamName, onCancel }: TeammateDetailViewProps): React.ReactNode { + const [promptExpanded, setPromptExpanded] = useState(false); // Get the display text for the cycle mode shortcut - const cycleModeShortcut = useShortcutDisplay( - 'confirm:cycleMode', - 'Confirmation', - 'shift+tab', - ) + const cycleModeShortcut = useShortcutDisplay('confirm:cycleMode', 'Confirmation', 'shift+tab'); const themeColor = teammate.color - ? AGENT_COLOR_TO_THEME_COLOR[ - teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR - ] - : undefined + ? AGENT_COLOR_TO_THEME_COLOR[teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR] + : undefined; // Get tasks assigned to this teammate - const [teammateTasks, setTeammateTasks] = useState([]) + const [teammateTasks, setTeammateTasks] = useState([]); useEffect(() => { - let cancelled = false + let cancelled = false; void listTasks(teamName).then(allTasks => { - if (cancelled) return + if (cancelled) return; // Filter tasks owned by this teammate (by agentId or name) - setTeammateTasks( - allTasks.filter( - task => - task.owner === teammate.agentId || task.owner === teammate.name, - ), - ) - }) + setTeammateTasks(allTasks.filter(task => task.owner === teammate.agentId || task.owner === teammate.name)); + }); return () => { - cancelled = true - } - }, [teamName, teammate.agentId, teammate.name]) + cancelled = true; + }; + }, [teamName, teammate.agentId, teammate.name]); useInput(input => { // Handle 'p' to expand/collapse prompt if (input === 'p') { - setPromptExpanded(prev => !prev) + setPromptExpanded(prev => !prev); } - }) + }); // Determine working directory display - const workingPath = teammate.worktreePath || teammate.cwd + const workingPath = teammate.worktreePath || teammate.cwd; // Build subtitle with metadata - const subtitleParts: string[] = [] - if (teammate.model) subtitleParts.push(teammate.model) + const subtitleParts: string[] = []; + if (teammate.model) subtitleParts.push(teammate.model); if (workingPath) { - subtitleParts.push( - teammate.worktreePath ? `worktree: ${workingPath}` : workingPath, - ) + subtitleParts.push(teammate.worktreePath ? `worktree: ${workingPath}` : workingPath); } - const subtitle = subtitleParts.join(' · ') || undefined + const subtitle = subtitleParts.join(' · ') || undefined; // Get mode display for title - const mode = teammate.mode - ? permissionModeFromString(teammate.mode) - : 'default' - const modeSymbol = permissionModeSymbol(mode) - const modeColor = getModeColor(mode) + const mode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default'; + const modeSymbol = permissionModeSymbol(mode); + const modeColor = getModeColor(mode); // Build title with mode symbol and colored name if applicable const title = ( <> {modeSymbol && {modeSymbol} } - {themeColor ? ( - {`@${teammate.name}`} - ) : ( - `@${teammate.name}` - )} + {themeColor ? {`@${teammate.name}`} : `@${teammate.name}`} - ) + ); return ( <> - + {/* Tasks section */} {teammateTasks.length > 0 && ( Tasks {teammateTasks.map(task => ( - - {task.status === 'completed' ? figures.tick : '◼'}{' '} - {task.subject} + + {task.status === 'completed' ? figures.tick : '◼'} {task.subject} ))} @@ -564,12 +444,8 @@ function TeammateDetailView({ Prompt - {promptExpanded - ? teammate.prompt - : truncateToWidth(teammate.prompt, 80)} - {stringWidth(teammate.prompt) > 80 && !promptExpanded && ( - (p to expand) - )} + {promptExpanded ? teammate.prompt : truncateToWidth(teammate.prompt, 80)} + {stringWidth(teammate.prompt) > 80 && !promptExpanded && (p to expand)} )} @@ -583,7 +459,7 @@ function TeammateDetailView({ - ) + ); } async function killTeammate( @@ -602,36 +478,28 @@ async function killTeammate( // Use ensureBackendsRegistered (not detectAndGetBackend) — this process may // be a teammate that never ran detection, but we only need class imports // here, not subprocess probes that could throw in a different environment. - await ensureBackendsRegistered() - await getBackendByType(backendType).killPane(paneId, !isInsideTmuxSync()) + await ensureBackendsRegistered(); + await getBackendByType(backendType).killPane(paneId, !isInsideTmuxSync()); } catch (error) { - logForDebugging(`[TeamsDialog] Failed to kill pane ${paneId}: ${error}`) + logForDebugging(`[TeamsDialog] Failed to kill pane ${paneId}: ${error}`); } } else { // backendType undefined: old team files predating this field, or in-process. // Old tmux-file case is a migration gap — the pane is orphaned. In-process // teammates have no pane to kill, so this is correct for them. - logForDebugging( - `[TeamsDialog] Skipping pane kill for ${paneId}: no backendType recorded`, - ) + logForDebugging(`[TeamsDialog] Skipping pane kill for ${paneId}: no backendType recorded`); } // Remove from team config file - removeMemberFromTeam(teamName, paneId) + removeMemberFromTeam(teamName, paneId); // Unassign tasks and build notification message - const { notificationMessage } = await unassignTeammateTasks( - teamName, - teammateId, - teammateName, - 'terminated', - ) + const { notificationMessage } = await unassignTeammateTasks(teamName, teammateId, teammateName, 'terminated'); // Update AppState to keep status line in sync and notify the lead setAppState(prev => { - if (!prev.teamContext?.teammates) return prev - if (!(teammateId in prev.teamContext.teammates)) return prev - const { [teammateId]: _, ...remainingTeammates } = - prev.teamContext.teammates + if (!prev.teamContext?.teammates) return prev; + if (!(teammateId in prev.teamContext.teammates)) return prev; + const { [teammateId]: _, ...remainingTeammates } = prev.teamContext.teammates; return { ...prev, teamContext: { @@ -653,40 +521,39 @@ async function killTeammate( }, ], }, - } - }) - logForDebugging(`[TeamsDialog] Removed ${teammateId} from teamContext`) + }; + }); + logForDebugging(`[TeamsDialog] Removed ${teammateId} from teamContext`); } -async function viewTeammateOutput( - paneId: string, - backendType: PaneBackendType | undefined, -): Promise { +async function viewTeammateOutput(paneId: string, backendType: PaneBackendType | undefined): Promise { if (backendType === 'iterm2') { // -s is required to target a specific session (ITermBackend.ts:216-217) - await execFileNoThrow(IT2_COMMAND, ['session', 'focus', '-s', paneId]) + await execFileNoThrow(IT2_COMMAND, ['session', 'focus', '-s', paneId]); + } else if (backendType === 'windows-terminal') { + // Windows Terminal spawns each teammate as a separate window/tab; wt.exe + // does not expose an API to focus a pre-existing tab by name. The user + // switches tabs manually (Ctrl+Tab) — dialog closing is enough here. + logForDebugging(`[TeamsDialog] viewTeammateOutput: Windows Terminal pane ${paneId} — manual tab switch required`); } else { // External-tmux teammates live on the swarm socket — without -L, this // targets the default server and silently no-ops. Mirrors runTmuxInSwarm // in TmuxBackend.ts:85-89. const args = isInsideTmuxSync() ? ['select-pane', '-t', paneId] - : ['-L', getSwarmSocketName(), 'select-pane', '-t', paneId] - await execFileNoThrow(TMUX_COMMAND, args) + : ['-L', getSwarmSocketName(), 'select-pane', '-t', paneId]; + await execFileNoThrow(TMUX_COMMAND, args); } } /** * Toggle visibility of a teammate pane (hide if visible, show if hidden) */ -async function toggleTeammateVisibility( - teammate: TeammateStatus, - teamName: string, -): Promise { +async function toggleTeammateVisibility(teammate: TeammateStatus, teamName: string): Promise { if (teammate.isHidden) { - await showTeammate(teammate, teamName) + await showTeammate(teammate, teamName); } else { - await hideTeammate(teammate, teamName) + await hideTeammate(teammate, teamName); } } @@ -694,39 +561,27 @@ async function toggleTeammateVisibility( * Hide a teammate pane using the backend abstraction. * Only available for ant users (gated for dead code elimination in external builds) */ -async function hideTeammate( - teammate: TeammateStatus, - teamName: string, -): Promise { -} +async function hideTeammate(teammate: TeammateStatus, teamName: string): Promise {} /** * Show a previously hidden teammate pane using the backend abstraction. * Only available for ant users (gated for dead code elimination in external builds) */ -async function showTeammate( - teammate: TeammateStatus, - teamName: string, -): Promise { -} +async function showTeammate(teammate: TeammateStatus, teamName: string): Promise {} /** * Send a mode change message to a single teammate * Also updates config.json directly so the UI reflects the change immediately */ -function sendModeChangeToTeammate( - teammateName: string, - teamName: string, - targetMode: PermissionMode, -): void { +function sendModeChangeToTeammate(teammateName: string, teamName: string, targetMode: PermissionMode): void { // Update config.json directly so UI shows the change immediately - setMemberMode(teamName, teammateName, targetMode) + setMemberMode(teamName, teammateName, targetMode); // Also send message so teammate updates their local permission context const message = createModeSetRequestMessage({ mode: targetMode, from: 'team-lead', - }) + }); void writeToMailbox( teammateName, { @@ -735,30 +590,22 @@ function sendModeChangeToTeammate( timestamp: new Date().toISOString(), }, teamName, - ) - logForDebugging( - `[TeamsDialog] Sent mode change to ${teammateName}: ${targetMode}`, - ) + ); + logForDebugging(`[TeamsDialog] Sent mode change to ${teammateName}: ${targetMode}`); } /** * Cycle a single teammate's mode */ -function cycleTeammateMode( - teammate: TeammateStatus, - teamName: string, - isBypassAvailable: boolean, -): void { - const currentMode = teammate.mode - ? permissionModeFromString(teammate.mode) - : 'default' +function cycleTeammateMode(teammate: TeammateStatus, teamName: string, isBypassAvailable: boolean): void { + const currentMode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default'; const context = { ...getEmptyToolPermissionContext(), mode: currentMode, isBypassPermissionsModeAvailable: isBypassAvailable, - } - const nextMode = getNextPermissionMode(context) - sendModeChangeToTeammate(teammate.name, teamName, nextMode) + }; + const nextMode = getNextPermissionMode(context); + sendModeChangeToTeammate(teammate.name, teamName, nextMode); } /** @@ -767,17 +614,11 @@ function cycleTeammateMode( * If same, cycle all to next mode * Uses batch update to avoid race conditions */ -function cycleAllTeammateModes( - teammates: TeammateStatus[], - teamName: string, - isBypassAvailable: boolean, -): void { - if (teammates.length === 0) return +function cycleAllTeammateModes(teammates: TeammateStatus[], teamName: string, isBypassAvailable: boolean): void { + if (teammates.length === 0) return; - const modes = teammates.map(t => - t.mode ? permissionModeFromString(t.mode) : 'default', - ) - const allSame = modes.every(m => m === modes[0]) + const modes = teammates.map(t => (t.mode ? permissionModeFromString(t.mode) : 'default')); + const allSame = modes.every(m => m === modes[0]); // Determine target mode for all teammates const targetMode = !allSame @@ -786,21 +627,21 @@ function cycleAllTeammateModes( ...getEmptyToolPermissionContext(), mode: modes[0] ?? 'default', isBypassPermissionsModeAvailable: isBypassAvailable, - }) + }); // Batch update config.json in a single atomic operation const modeUpdates = teammates.map(t => ({ memberName: t.name, mode: targetMode, - })) - setMultipleMemberModes(teamName, modeUpdates) + })); + setMultipleMemberModes(teamName, modeUpdates); // Send mailbox messages to each teammate for (const teammate of teammates) { const message = createModeSetRequestMessage({ mode: targetMode, from: 'team-lead', - }) + }); void writeToMailbox( teammate.name, { @@ -809,9 +650,7 @@ function cycleAllTeammateModes( timestamp: new Date().toISOString(), }, teamName, - ) + ); } - logForDebugging( - `[TeamsDialog] Sent mode change to all ${teammates.length} teammates: ${targetMode}`, - ) + logForDebugging(`[TeamsDialog] Sent mode change to all ${teammates.length} teammates: ${targetMode}`); }