fix(effort): keep effort indicator visible in prompt footer (#1919)

* fix: keep effort indicator visible

* fix: preserve footer status for effort fallback

* fix: simplify effort footer selection logic

* fix: harden effort footer fallback

* test: cover brief effort footer suppression

* fix: ignore empty footer notifications

* test: cover live effort footer updates

* fix: handle empty JSX footer notifications
This commit is contained in:
Xiang Han
2026-07-10 16:33:11 +08:00
committed by GitHub
parent 9e06951e6e
commit b588c26db2
4 changed files with 361 additions and 37 deletions
+6 -1
View File
@@ -9,6 +9,11 @@ type IdeStatusIndicatorProps = {
ideSelection: IDESelection | undefined;
mcpClients?: MCPServerConnection[];
};
export function hasIdeSelection(ideSelection: IDESelection | undefined): boolean {
return Boolean(ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0));
}
export function IdeStatusIndicator(t0) {
const $ = _c(7);
const {
@@ -18,7 +23,7 @@ export function IdeStatusIndicator(t0) {
const {
status: ideStatus
} = useIdeConnectionStatus(mcpClients);
const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0);
const shouldShowIdeSelection = ideStatus === "connected" && hasIdeSelection(ideSelection);
if (ideStatus === null || !shouldShowIdeSelection || !ideSelection) {
return null;
}
@@ -0,0 +1,312 @@
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import { feature } from 'bun:bundle'
import React, { useEffect } from 'react'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import type { Notification } from '../../context/notifications.js'
import type { IDESelection } from '../../hooks/useIdeSelection.js'
import { createRoot } from '../../ink.js'
import type { MCPServerConnection } from '../../services/mcp/types.js'
import {
AppStateProvider,
getDefaultAppState,
useSetAppState,
} from '../../state/AppState.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import type { Message } from '../../types/message.js'
import type { EffortValue } from '../../utils/effort.js'
import { renderToString } from '../../utils/staticRender.js'
const actualAutoUpdaterWrapper = await import(
`../AutoUpdaterWrapper.js?actual=${Date.now()}-${Math.random()}`
)
const EFFORT_ENV_KEY = 'CLAUDE_CODE_EFFORT_LEVEL'
let savedEffortEnv: string | undefined
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) break
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) break
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) lastFrame = frame
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
function createTestStreams() {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: () => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return { stdout, stdin, getOutput: () => output }
}
async function waitForFrame(
getOutput: () => string,
predicate: (frame: string) => boolean,
timeoutMs = 3000,
): Promise<string> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const frame = stripAnsi(extractLastFrame(getOutput()))
if (predicate(frame)) return frame
await Bun.sleep(10)
}
throw new Error('Timed out waiting for rendered frame')
}
beforeEach(async () => {
await acquireSharedMutationLock(
'components/PromptInput/Notifications.effort.test.tsx',
)
savedEffortEnv = process.env[EFFORT_ENV_KEY]
delete process.env[EFFORT_ENV_KEY]
mock.module('../AutoUpdaterWrapper.js', () => ({
AutoUpdaterWrapper: () => null,
}))
})
afterEach(() => {
try {
if (savedEffortEnv === undefined) {
delete process.env[EFFORT_ENV_KEY]
} else {
process.env[EFFORT_ENV_KEY] = savedEffortEnv
}
mock.module('../AutoUpdaterWrapper.js', () => actualAutoUpdaterWrapper)
} finally {
releaseSharedMutationLock()
}
})
async function renderNotifications({
effortValue,
currentNotification = null,
ideSelection = undefined,
mcpClients = undefined,
isBriefOnly = false,
viewingAgentTaskId = undefined,
}: {
effortValue: EffortValue | undefined
currentNotification?: Notification | null
ideSelection?: IDESelection
mcpClients?: MCPServerConnection[]
isBriefOnly?: boolean
viewingAgentTaskId?: string
}): Promise<string> {
const { Notifications } = await import(
`./Notifications.js?ts=${Date.now()}-${Math.random()}`
)
return renderToString(
<AppStateProvider
initialState={{
...getDefaultAppState(),
mainLoopModelForSession: 'claude-opus-4-8',
effortValue,
isBriefOnly,
viewingAgentTaskId,
notifications: {
current: currentNotification,
queue: [],
},
}}
>
<Notifications
apiKeyStatus="valid"
autoUpdaterResult={{ version: null, status: 'success' }}
debug={false}
isAutoUpdating={false}
verbose={false}
messages={[] as Message[]}
onAutoUpdaterResult={() => {}}
onChangeIsUpdating={() => {}}
ideSelection={ideSelection}
mcpClients={mcpClients}
/>
</AppStateProvider>,
120,
)
}
test('renders effort as the stable footer fallback when no notification is active', async () => {
const output = await renderNotifications({ effortValue: 'medium' })
expect(output).toContain('medium · /effort')
})
test('updates the mounted effort footer when app state changes', async () => {
const { Notifications } = await import(
`./Notifications.js?mounted=${Date.now()}-${Math.random()}`
)
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
let setAppState: ReturnType<typeof useSetAppState> | undefined
function AppStateController() {
const setState = useSetAppState()
useEffect(() => {
setAppState = setState
}, [setState])
return null
}
root.render(
<AppStateProvider
initialState={{
...getDefaultAppState(),
mainLoopModelForSession: 'claude-opus-4-8',
effortValue: 'high',
}}
>
<Notifications
apiKeyStatus="valid"
autoUpdaterResult={{ version: null, status: 'success' }}
debug={false}
isAutoUpdating={false}
verbose={false}
messages={[] as Message[]}
onAutoUpdaterResult={() => {}}
onChangeIsUpdating={() => {}}
ideSelection={undefined}
mcpClients={undefined}
/>
<AppStateController />
</AppStateProvider>,
)
try {
const initialFrame = await waitForFrame(getOutput, frame =>
frame.includes('high · /effort'),
)
expect(initialFrame).toContain('high · /effort')
setAppState!(state => ({ ...state, effortValue: 'low' }))
const updatedFrame = await waitForFrame(getOutput, frame =>
frame.includes('low · /effort'),
)
expect(updatedFrame).toContain('low · /effort')
expect(updatedFrame).not.toContain('high · /effort')
} finally {
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
}
})
test('lets transient notifications temporarily occupy the footer slot', async () => {
const output = await renderNotifications({
effortValue: 'medium',
currentNotification: {
key: 'other',
text: 'Other notice',
priority: 'high',
},
})
expect(output).toContain('Other notice')
expect(output).not.toContain('medium · /effort')
})
test('ignores empty text notifications when rendering the effort footer fallback', async () => {
const output = await renderNotifications({
effortValue: 'medium',
currentNotification: {
key: 'empty',
text: '',
priority: 'low',
},
})
expect(output).toContain('medium · /effort')
})
test('ignores empty JSX notifications when rendering the effort footer fallback', async () => {
const output = await renderNotifications({
effortValue: 'medium',
currentNotification: {
key: 'empty-jsx',
jsx: null,
priority: 'low',
},
})
expect(output).toContain('medium · /effort')
})
test('preserves IDE selection status before the effort fallback', async () => {
const output = await renderNotifications({
effortValue: 'medium',
ideSelection: {
lineCount: 0,
filePath: '/tmp/example.ts',
},
mcpClients: [
{
name: 'ide',
type: 'connected',
capabilities: {},
config: {
type: 'sse-ide',
url: 'http://localhost:1234',
ideName: 'VS Code',
scope: 'local',
},
client: {},
cleanup: async () => {},
} as unknown as MCPServerConnection,
],
})
expect(output).toContain('In example.ts')
expect(output).not.toContain('medium · /effort')
})
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
test('respects brief footer ownership and teammate view', async () => {
const briefOutput = await renderNotifications({
effortValue: 'medium',
isBriefOnly: true,
})
const teammateOutput = await renderNotifications({
effortValue: 'medium',
isBriefOnly: true,
viewingAgentTaskId: 'task-123',
})
expect(briefOutput).not.toContain('medium · /effort')
expect(teammateOutput).toContain('medium · /effort')
})
}
+43 -18
View File
@@ -27,7 +27,8 @@ import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js';
import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { IdeStatusIndicator } from '../IdeStatusIndicator.js';
import { getEffortNotificationText } from '../EffortIndicator.js';
import { hasIdeSelection, IdeStatusIndicator } from '../IdeStatusIndicator.js';
import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js';
import { SentryErrorBoundary } from '../SentryErrorBoundary.js';
import { TokenWarning } from '../TokenWarning.js';
@@ -53,7 +54,7 @@ type Props = {
isNarrow?: boolean;
};
export function Notifications(t0) {
const $ = _c(34);
const $ = _c(35);
const {
apiKeyStatus,
autoUpdaterResult,
@@ -124,7 +125,7 @@ export function Notifications(t0) {
t6 = $[7];
}
useEffect(t5, t6);
const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0);
const shouldShowIdeSelection = ideStatus === "connected" && hasIdeSelection(ideSelection);
const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== "success";
const isInOverageMode = claudeAiLimits.isUsingOverage;
let t7;
@@ -175,8 +176,8 @@ export function Notifications(t0) {
const t11 = isNarrow ? "flex-start" : "flex-end";
const t12 = isInOverageMode ?? false;
let t13;
if ($[15] !== apiKeyStatus || $[16] !== autoUpdaterResult || $[17] !== debug || $[18] !== ideSelection || $[19] !== isAutoUpdating || $[20] !== isShowingCompactMessage || $[21] !== mainLoopModel || $[22] !== mcpClients || $[23] !== notifications || $[24] !== onAutoUpdaterResult || $[25] !== onChangeIsUpdating || $[26] !== shouldShowAutoUpdater || $[27] !== t12 || $[28] !== tokenUsage || $[29] !== verbose) {
t13 = <NotificationContent ideSelection={ideSelection} mcpClients={mcpClients} notifications={notifications} isInOverageMode={t12} isTeamOrEnterprise={isTeamOrEnterprise} apiKeyStatus={apiKeyStatus} debug={debug} verbose={verbose} tokenUsage={tokenUsage} mainLoopModel={mainLoopModel} shouldShowAutoUpdater={shouldShowAutoUpdater} autoUpdaterResult={autoUpdaterResult} isAutoUpdating={isAutoUpdating} isShowingCompactMessage={isShowingCompactMessage} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} />;
if ($[15] !== apiKeyStatus || $[16] !== autoUpdaterResult || $[17] !== debug || $[18] !== ideSelection || $[19] !== isAutoUpdating || $[20] !== isShowingCompactMessage || $[21] !== mainLoopModel || $[22] !== mcpClients || $[23] !== notifications || $[24] !== onAutoUpdaterResult || $[25] !== onChangeIsUpdating || $[26] !== shouldShowAutoUpdater || $[27] !== t12 || $[28] !== tokenUsage || $[29] !== shouldShowIdeSelection || $[30] !== verbose) {
t13 = <NotificationContent ideSelection={ideSelection} mcpClients={mcpClients} notifications={notifications} isInOverageMode={t12} isTeamOrEnterprise={isTeamOrEnterprise} apiKeyStatus={apiKeyStatus} debug={debug} verbose={verbose} tokenUsage={tokenUsage} mainLoopModel={mainLoopModel} shouldShowAutoUpdater={shouldShowAutoUpdater} shouldShowIdeSelection={shouldShowIdeSelection} autoUpdaterResult={autoUpdaterResult} isAutoUpdating={isAutoUpdating} isShowingCompactMessage={isShowingCompactMessage} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} />;
$[15] = apiKeyStatus;
$[16] = autoUpdaterResult;
$[17] = debug;
@@ -191,19 +192,20 @@ export function Notifications(t0) {
$[26] = shouldShowAutoUpdater;
$[27] = t12;
$[28] = tokenUsage;
$[29] = verbose;
$[30] = t13;
$[29] = shouldShowIdeSelection;
$[30] = verbose;
$[31] = t13;
} else {
t13 = $[30];
t13 = $[31];
}
let t14;
if ($[31] !== t11 || $[32] !== t13) {
if ($[32] !== t11 || $[33] !== t13) {
t14 = <SentryErrorBoundary><Box flexDirection="column" alignItems={t11} flexShrink={0} overflowX="hidden">{t13}</Box></SentryErrorBoundary>;
$[31] = t11;
$[32] = t13;
$[33] = t14;
$[32] = t11;
$[33] = t13;
$[34] = t14;
} else {
t14 = $[33];
t14 = $[34];
}
return t14;
}
@@ -225,6 +227,7 @@ function NotificationContent({
tokenUsage,
mainLoopModel,
shouldShowAutoUpdater,
shouldShowIdeSelection,
autoUpdaterResult,
isAutoUpdating,
isShowingCompactMessage,
@@ -245,6 +248,7 @@ function NotificationContent({
tokenUsage: number;
mainLoopModel: string;
shouldShowAutoUpdater: boolean;
shouldShowIdeSelection: boolean;
autoUpdaterResult: AutoUpdaterResult | null;
isAutoUpdating: boolean;
isShowingCompactMessage: boolean;
@@ -277,6 +281,31 @@ function NotificationContent({
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_1 => s_1.isBriefOnly) : false;
const viewingAgentTaskId = feature('KAIROS') || feature('KAIROS_BRIEF') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_2 => s_2.viewingAgentTaskId) : undefined;
const briefOwnsGap = isBriefOnly && !viewingAgentTaskId;
const shouldShowEffortFallback = !briefOwnsGap && !shouldShowIdeSelection;
const effortValue = useAppState(s_3 => s_3.effortValue);
const effortNotificationText = shouldShowEffortFallback ? getEffortNotificationText(effortValue, mainLoopModel) : undefined;
let notificationNode: ReactNode = null;
if (notifications.current) {
if ('jsx' in notifications.current) {
const notificationJsx = notifications.current.jsx;
if (notificationJsx !== null && notificationJsx !== undefined && notificationJsx !== '' && typeof notificationJsx !== 'boolean') {
notificationNode = <Text wrap="truncate" key={notifications.current.key}>
{notificationJsx}
</Text>;
}
} else if (notifications.current.text) {
notificationNode = <Text color={notifications.current.color} dimColor={!notifications.current.color} wrap="truncate">
{notifications.current.text}
</Text>;
}
}
const effortFallbackNode = effortNotificationText ? <Text dimColor wrap="truncate" key="effort-fallback">
{effortNotificationText}
</Text> : null;
// When voice is actively recording or processing, replace all
// notifications with just the voice indicator.
@@ -285,11 +314,7 @@ function NotificationContent({
}
return <>
<IdeStatusIndicator ideSelection={ideSelection} mcpClients={mcpClients} />
{notifications.current && ('jsx' in notifications.current ? <Text wrap="truncate" key={notifications.current.key}>
{notifications.current.jsx}
</Text> : <Text color={notifications.current.color} dimColor={!notifications.current.color} wrap="truncate">
{notifications.current.text}
</Text>)}
{notificationNode ?? effortFallbackNode}
{isInOverageMode && !isTeamOrEnterprise && <Box>
<Text dimColor wrap="truncate">
Now using extra usage
@@ -99,7 +99,6 @@ import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js';
import { BridgeDialog } from '../BridgeDialog.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { getVisibleAgentTasks, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js';
import { getEffortNotificationText } from '../EffortIndicator.js';
import { getFastIconString } from '../FastIcon.js';
import { GlobalSearchDialog } from '../GlobalSearchDialog.js';
import { HistorySearchDialog } from '../HistorySearchDialog.js';
@@ -336,7 +335,6 @@ function PromptInput({
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
const isFastMode = useAppState(s => isFastModeEnabled() ? s.fastMode : false);
const effortValue = useAppState(s => s.effortValue);
const viewedTeammate = getViewedTeammateTask(store.getState());
const viewingAgentName = viewedTeammate?.identity.agentName;
// identity.color is typed as `string | undefined` (not AgentColorName) because
@@ -2013,22 +2011,6 @@ function PromptInput({
const showFastIcon = isFastModeEnabled() ? isFastMode && (isFastModeAvailable() || fastModeCooldown) : false;
const showFastIconHint = useShowFastIconHint(showFastIcon ?? false);
// Show effort notification on startup and when effort changes.
// Suppressed in brief/assistant mode — the value reflects the local
// client's effort, not the connected agent's.
const effortNotificationText = briefOwnsGap ? undefined : getEffortNotificationText(effortValue, mainLoopModel);
useEffect(() => {
if (!effortNotificationText) {
removeNotification('effort-level');
return;
}
addNotification({
key: 'effort-level',
text: effortNotificationText,
priority: 'high',
timeoutMs: 12_000
});
}, [effortNotificationText, addNotification, removeNotification]);
useBuddyNotification();
const companionSpeaking = isBuddyEnabled() ?
useAppState(s => s.companionReaction !== undefined) : false;