fix: address audit findings across status-line and verbose-mode features

- useStatusLine: clamp used/remaining percentage to [0,100], track
  totalLinesRemoved as trigger, clean up debounceRef on unmount
- AppContainer: use drainQueue from useMessageQueue instead of manual
  messageQueueRef to avoid stale-ref reads between renders
- builtin-agents: add WRITE_FILE tool to statusline-setup agent, improve
  PS1 parsing instructions (unquoted assignments, \[/\]/\e escapes),
  strip ANSI colors, remove unreachable symlink instruction
- CompactToolGroupDisplay: fix misleading hint "show full tool output"
  to "toggle verbose mode" across all 6 locales
- AppContainer.test: add missing drainQueue mock
This commit is contained in:
wenshao 2026-04-08 18:45:44 +08:00
parent c36953816c
commit 520ed4e040
11 changed files with 69 additions and 39 deletions

View file

@ -1971,6 +1971,6 @@ export default {
verbose: 'ausführlich',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Vollständige Tool-Ausgabe und Denkprozess im ausführlichen Modus anzeigen (mit Strg+O umschalten).',
'Press Ctrl+O to show full tool output':
'Strg+O für vollständige Tool-Ausgabe drücken',
'Press Ctrl+O to toggle verbose mode':
'Strg+O zum Umschalten des ausführlichen Modus drücken',
};

View file

@ -2011,6 +2011,5 @@ export default {
verbose: 'verbose',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Press Ctrl+O to show full tool output',
'Press Ctrl+O to toggle verbose mode': 'Press Ctrl+O to toggle verbose mode',
};

View file

@ -1463,5 +1463,5 @@ export default {
verbose: '詳細',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'詳細モードで完全なツール出力と思考を表示しますCtrl+O で切り替え)。',
'Press Ctrl+O to show full tool output': 'Ctrl+O で完全なツール出力を表示',
'Press Ctrl+O to toggle verbose mode': 'Ctrl+O で詳細モードを切り替え',
};

View file

@ -1961,6 +1961,6 @@ export default {
verbose: 'detalhado',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Mostrar saída completa da ferramenta e raciocínio no modo detalhado (alternar com Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Pressione Ctrl+O para exibir a saída completa da ferramenta',
'Press Ctrl+O to toggle verbose mode':
'Pressione Ctrl+O para alternar o modo detalhado',
};

View file

@ -1968,6 +1968,6 @@ export default {
verbose: 'подробный',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Показывать полный вывод инструментов и процесс рассуждений в подробном режиме (переключить с помощью Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Нажмите Ctrl+O для показа полного вывода инструментов',
'Press Ctrl+O to toggle verbose mode':
'Нажмите Ctrl+O для переключения подробного режима',
};

View file

@ -1816,5 +1816,5 @@ export default {
verbose: '详细',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'详细模式下显示完整工具输出和思考过程Ctrl+O 切换)。',
'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果',
'Press Ctrl+O to toggle verbose mode': '按 Ctrl+O 切换详细模式',
};

View file

@ -243,6 +243,7 @@ describe('AppContainer State Management', () => {
addMessage: vi.fn(),
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
drainQueue: vi.fn().mockReturnValue([]),
});
mockedUseAutoAcceptIndicator.mockReturnValue(false);
mockedUseGitBranchName.mockReturnValue('main');
@ -455,6 +456,7 @@ describe('AppContainer State Management', () => {
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
drainQueue: vi.fn().mockReturnValue([]),
});
render(

View file

@ -776,24 +776,22 @@ export const AppContainer = (props: AppContainerProps) => {
disabled: agentViewState.activeView !== 'main',
});
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
});
const {
messageQueue,
addMessage,
clearQueue,
getQueuedMessagesText,
drainQueue,
} = useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
});
// Bridge message queue to mid-turn drain via ref.
// Sync ref on every render so the drain callback always reads latest state.
const messageQueueRef = useRef(messageQueue);
messageQueueRef.current = messageQueue;
midTurnDrainRef.current = () => {
const queue = messageQueueRef.current;
if (queue.length === 0) return [];
messageQueueRef.current = [];
clearQueue();
return [...queue];
};
// drainQueue reads from the synchronous queueRef inside useMessageQueue,
// so it always sees the latest state even between renders.
midTurnDrainRef.current = drainQueue;
// Callback for handling final submit (must be after addMessage from useMessageQueue)
const handleFinalSubmit = useCallback(

View file

@ -101,7 +101,7 @@ export const CompactToolGroupDisplay: React.FC<
{/* Hint line */}
<Text color={theme.text.secondary}>
{t('Press Ctrl+O to show full tool output')}
{t('Press Ctrl+O to toggle verbose mode')}
</Text>
</Box>
);

View file

@ -175,6 +175,8 @@ export function useStatusLine(): {
const { currentModel, branchName } = uiState;
const totalToolCalls = uiState.sessionStats.metrics.tools.totalCalls;
const totalLinesAdded = uiState.sessionStats.metrics.files.totalLinesAdded;
const totalLinesRemoved =
uiState.sessionStats.metrics.files.totalLinesRemoved;
const effectiveVim = vimEnabled ? vimMode : undefined;
const prevStateRef = useRef<{
promptTokenCount: number;
@ -183,6 +185,7 @@ export function useStatusLine(): {
branchName: string | undefined;
totalToolCalls: number;
totalLinesAdded: number;
totalLinesRemoved: number;
}>({
promptTokenCount: lastPromptTokenCount,
currentModel,
@ -190,6 +193,7 @@ export function useStatusLine(): {
branchName,
totalToolCalls,
totalLinesAdded,
totalLinesRemoved,
});
// Guard: when true, the mount effect has already called doUpdate so the
@ -216,8 +220,15 @@ export function useStatusLine(): {
cfg.getContentGeneratorConfig()?.contextWindowSize || 0;
const usedPercentage =
contextWindowSize > 0
? Math.round((stats.lastPromptTokenCount / contextWindowSize) * 1000) /
10
? Math.min(
100,
Math.max(
0,
Math.round(
(stats.lastPromptTokenCount / contextWindowSize) * 1000,
) / 10,
),
)
: 0;
let totalInputTokens = 0;
@ -238,9 +249,15 @@ export function useStatusLine(): {
used_percentage: usedPercentage,
remaining_percentage:
contextWindowSize > 0
? Math.round(
(1 - stats.lastPromptTokenCount / contextWindowSize) * 1000,
) / 10
? Math.min(
100,
Math.max(
0,
Math.round(
(1 - stats.lastPromptTokenCount / contextWindowSize) * 1000,
) / 10,
),
)
: 100,
current_usage: stats.lastPromptTokenCount,
total_input_tokens: totalInputTokens,
@ -256,7 +273,7 @@ export function useStatusLine(): {
}),
metrics: buildMetricsPayload(m),
...(vimEnabledRef.current && {
vim: { mode: vimModeRef.current ?? 'INSERT' },
vim: { mode: vimModeRef.current },
}),
};
@ -328,7 +345,8 @@ export function useStatusLine(): {
effectiveVim !== prev.effectiveVim ||
branchName !== prev.branchName ||
totalToolCalls !== prev.totalToolCalls ||
totalLinesAdded !== prev.totalLinesAdded
totalLinesAdded !== prev.totalLinesAdded ||
totalLinesRemoved !== prev.totalLinesRemoved
) {
prev.promptTokenCount = lastPromptTokenCount;
prev.currentModel = currentModel;
@ -336,6 +354,7 @@ export function useStatusLine(): {
prev.branchName = branchName;
prev.totalToolCalls = totalToolCalls;
prev.totalLinesAdded = totalLinesAdded;
prev.totalLinesRemoved = totalLinesRemoved;
scheduleUpdate();
}
}, [
@ -346,6 +365,7 @@ export function useStatusLine(): {
branchName,
totalToolCalls,
totalLinesAdded,
totalLinesRemoved,
scheduleUpdate,
]);
@ -379,6 +399,7 @@ export function useStatusLine(): {
genRef.current++;
if (debounceRef.current !== undefined) {
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps