mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): refine tool detail cards (#6399)
Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
parent
2fc6b08b52
commit
0f98842ff2
22 changed files with 954 additions and 333 deletions
|
|
@ -27,7 +27,7 @@
|
|||
padding-right: env(safe-area-inset-right);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
|
|
@ -521,6 +521,11 @@
|
|||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.approvalOverlay:focus,
|
||||
.approvalOverlay:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scrollToBottomLayer {
|
||||
position: absolute;
|
||||
top: -44px;
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog';
|
|||
import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog';
|
||||
import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog';
|
||||
import { SettingsMessage } from './components/messages/SettingsMessage';
|
||||
import { resolveShellOutputMaxLines } from './components/messages/ToolGroup';
|
||||
import { isAskUserQuestionToolName } from './components/messages/toolFormatting';
|
||||
import { ToolApproval } from './components/messages/ToolApproval';
|
||||
import { AskUserQuestion } from './components/messages/AskUserQuestion';
|
||||
|
|
@ -1976,7 +1975,6 @@ export function App({
|
|||
)?.values.effective;
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
})();
|
||||
const shellOutputMaxLines = resolveShellOutputMaxLines(workspaceSettings);
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const compactModeRef = useRef(compactMode);
|
||||
compactModeRef.current = compactMode;
|
||||
|
|
@ -4364,7 +4362,10 @@ export function App({
|
|||
</section>
|
||||
)}
|
||||
{mainView === 'scheduledTasks' && (
|
||||
<div className={styles.fullPage} data-testid="scheduled-tasks-page">
|
||||
<div
|
||||
className={styles.fullPage}
|
||||
data-testid="scheduled-tasks-page"
|
||||
>
|
||||
<div className={styles.fullPageHeader}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -4461,7 +4462,6 @@ export function App({
|
|||
isResponding={streamingState !== 'idle'}
|
||||
activeTurnStartedAt={activeTurnStartedAt}
|
||||
workspaceCwd={connection.workspaceCwd || ''}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
hideSessionTimeline={
|
||||
effectiveChatWidthMode === 'wide'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ const assistantMsg = (id: string, content: string): Message =>
|
|||
({ id, role: 'assistant', content, timestamp: 0 }) as Message;
|
||||
|
||||
function item(message: Message) {
|
||||
return <MessageItem message={message} shellOutputMaxLines={50} />;
|
||||
return <MessageItem message={message} />;
|
||||
}
|
||||
|
||||
describe('MessageItem error isolation', () => {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ interface MessageItemProps {
|
|||
onBranchSession?: () => void;
|
||||
showAssistantActions?: boolean;
|
||||
showAssistantBranch?: boolean;
|
||||
shellOutputMaxLines: number;
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
|
|
@ -44,7 +43,6 @@ export const MessageItem = memo(function MessageItem({
|
|||
onBranchSession,
|
||||
showAssistantActions = false,
|
||||
showAssistantBranch = false,
|
||||
shellOutputMaxLines,
|
||||
}: MessageItemProps) {
|
||||
const body = ((): ReactElement | null => {
|
||||
switch (message.role) {
|
||||
|
|
@ -77,7 +75,6 @@ export const MessageItem = memo(function MessageItem({
|
|||
tools={message.tools}
|
||||
pendingApproval={pendingApproval}
|
||||
workspaceCwd={workspaceCwd}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
/>
|
||||
);
|
||||
case 'plan':
|
||||
|
|
@ -239,7 +236,6 @@ function areMessageItemPropsEqual(
|
|||
if (prev.onBranchSession !== next.onBranchSession) return false;
|
||||
if (prev.showAssistantActions !== next.showAssistantActions) return false;
|
||||
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
|
||||
if (prev.shellOutputMaxLines !== next.shellOutputMaxLines) return false;
|
||||
return areMessagesEqual(prev.message, next.message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,6 @@ function mount(
|
|||
loadingTranscript={opts.loadingTranscript}
|
||||
catchingUp={opts.catchingUp}
|
||||
isResponding={opts.isResponding}
|
||||
shellOutputMaxLines={50}
|
||||
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
|
|
@ -205,7 +204,6 @@ function renderInto(
|
|||
loadingTranscript={opts.loadingTranscript}
|
||||
catchingUp={opts.catchingUp}
|
||||
isResponding={opts.isResponding}
|
||||
shellOutputMaxLines={50}
|
||||
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
.turnAnswerRow {
|
||||
max-width: 100%;
|
||||
}
|
||||
.turnStatusRow {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.turnAnswerRow {
|
||||
margin-top: 10px;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ interface MessageListProps {
|
|||
tailContent?: ReactNode;
|
||||
tailKey?: string;
|
||||
virtualScrollThreshold?: number;
|
||||
shellOutputMaxLines: number;
|
||||
activeTurnStartedAt?: number;
|
||||
/**
|
||||
* When true, scroll the tail content into view the moment it first appears
|
||||
|
|
@ -1712,7 +1711,6 @@ export const MessageList = memo(
|
|||
tailContent,
|
||||
tailKey = 'tail',
|
||||
virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD,
|
||||
shellOutputMaxLines,
|
||||
autoScrollTailIntoView = false,
|
||||
hideSessionTimeline = false,
|
||||
showRetryHint = false,
|
||||
|
|
@ -2646,7 +2644,6 @@ export const MessageList = memo(
|
|||
displayItem.message.role === 'assistant' &&
|
||||
displayItem.message.id === lastCompletedAssistantId
|
||||
}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -2681,7 +2678,6 @@ export const MessageList = memo(
|
|||
showRetryHint,
|
||||
onRetryClick,
|
||||
onBranchSession,
|
||||
shellOutputMaxLines,
|
||||
handleToggleCollapse,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -188,15 +188,26 @@
|
|||
|
||||
.thinkingChevronRight,
|
||||
.thinkingChevronDown {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 1px solid currentColor;
|
||||
border-bottom: 1px solid currentColor;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.thinkingChevronRight::before,
|
||||
.thinkingChevronRight::after,
|
||||
.thinkingChevronDown::before,
|
||||
.thinkingChevronDown::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.thinkingSummary:hover .thinkingChevronRight,
|
||||
.thinkingSummary:hover .thinkingChevronDown,
|
||||
.thinkingSummary:focus-visible .thinkingChevronRight,
|
||||
|
|
@ -205,15 +216,38 @@
|
|||
}
|
||||
|
||||
.thinkingChevronRight {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.thinkingChevronRight::before {
|
||||
top: calc(50% - 2px);
|
||||
left: calc(50% - 3px);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.thinkingChevronRight::after {
|
||||
top: calc(50% + 2px);
|
||||
left: calc(50% - 3px);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.thinkingChevronDown {
|
||||
transform: rotate(45deg);
|
||||
margin-top: -3px;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thinkingChevronDown::before {
|
||||
top: 50%;
|
||||
left: calc(50% - 5px);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.thinkingChevronDown::after {
|
||||
top: 50%;
|
||||
left: calc(50% - 1px);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.thinkingExpandedWrap {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,8 +199,7 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
background: var(--subtle-bg);
|
||||
border-bottom: 0.5px solid var(--border);
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.codeBlockLang {
|
||||
|
|
@ -238,6 +237,7 @@
|
|||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: var(--secondary);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.codeBlockContent pre {
|
||||
|
|
|
|||
|
|
@ -198,14 +198,20 @@
|
|||
}
|
||||
|
||||
.embeddedPanel .inlineDetail {
|
||||
padding: 0;
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embeddedPanel .detail {
|
||||
margin: 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--background);
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.embeddedPanel .nameCell {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk';
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { ACPToolCall } from '../../adapters/types';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
|
||||
vi.mock('../../App', async () => {
|
||||
const { createContext } = await import('react');
|
||||
|
|
@ -14,17 +17,36 @@ vi.mock('../../App', async () => {
|
|||
const {
|
||||
buildUnifiedDiff,
|
||||
extractDiff,
|
||||
fencedCodeBlock,
|
||||
formatSingleToolSummary,
|
||||
formatRunningSingleToolSummary,
|
||||
formatToolGroupSummary,
|
||||
getActiveTool,
|
||||
getRawFileDiff,
|
||||
getToolHeaderKind,
|
||||
hasActiveTool,
|
||||
hasExpandableContent,
|
||||
isActiveToolStatus,
|
||||
isWebFetchToolName,
|
||||
resolveShellOutputMaxLines,
|
||||
languageForPath,
|
||||
shouldAutoExpand,
|
||||
ToolGroup,
|
||||
ToolLine,
|
||||
} = await import('./ToolGroup');
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, container } of mounted.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function makeTool(overrides: Partial<ACPToolCall> = {}): ACPToolCall {
|
||||
return {
|
||||
callId: 'call-1',
|
||||
|
|
@ -34,15 +56,66 @@ function makeTool(overrides: Partial<ACPToolCall> = {}): ACPToolCall {
|
|||
};
|
||||
}
|
||||
|
||||
function renderToolLine(tool: ACPToolCall): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<ToolLine tool={tool} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
mounted.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
function renderToolGroup(tools: ACPToolCall[]): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<ToolGroup tools={tools} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
mounted.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
const t = (key: string, values?: Record<string, string | number>): string => {
|
||||
if (key === 'toolGroup.running') {
|
||||
return `Running ${values?.name ?? 'tool'}${
|
||||
return `Running ${values?.name ?? 'tool'}${values?.duration ? ` ${values.duration}` : ''}${
|
||||
Number(values?.count ?? 0) > 1 ? ` · ${values?.count ?? 0} tools` : ''
|
||||
}`;
|
||||
}
|
||||
if (key === 'toolGroup.summary') {
|
||||
return `Ran ${values?.count ?? 0} tool${values?.count === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (key === 'toolGroup.summary.editedFiles') {
|
||||
return `Edited ${values?.count ?? 0} files`;
|
||||
}
|
||||
if (key === 'toolGroup.summary.ranCommands') {
|
||||
return `Ran ${values?.count ?? 0} commands`;
|
||||
}
|
||||
if (key === 'toolGroup.summary.readFiles') {
|
||||
return `Read ${values?.count ?? 0} files`;
|
||||
}
|
||||
if (key === 'toolGroup.summary.searched') {
|
||||
return `Searched ${values?.count ?? 0} times`;
|
||||
}
|
||||
if (key === 'toolGroup.summary.updatedTodos') {
|
||||
return `Updated todos ${values?.count ?? 0} times`;
|
||||
}
|
||||
if (key === 'toolGroup.summary.askedUser') {
|
||||
return 'Asked user';
|
||||
}
|
||||
if (key === 'toolGroup.summary.otherTools') {
|
||||
return `Called ${values?.count ?? 0} other tools`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
|
|
@ -51,18 +124,6 @@ const zhT = (key: string, values?: Record<string, string | number>): string => {
|
|||
return t(key, values);
|
||||
};
|
||||
|
||||
function setting(value: unknown): DaemonSettingDescriptor {
|
||||
return {
|
||||
key: 'ui.shellOutputMaxLines',
|
||||
type: 'number',
|
||||
label: 'Shell output max lines',
|
||||
category: 'ui',
|
||||
requiresRestart: false,
|
||||
default: 5,
|
||||
values: { effective: value },
|
||||
};
|
||||
}
|
||||
|
||||
describe('tool group summary logic', () => {
|
||||
it('detects active tool statuses', () => {
|
||||
expect(isActiveToolStatus('pending')).toBe(true);
|
||||
|
|
@ -99,15 +160,98 @@ describe('tool group summary logic', () => {
|
|||
expect(formatToolGroupSummary(tools, zhT)).toBe('Running 读取文件');
|
||||
});
|
||||
|
||||
it('falls back to a completed summary without tool names', () => {
|
||||
it('summarizes completed tool groups by common action type', () => {
|
||||
const tools = [
|
||||
makeTool({ callId: 'shell', status: 'completed' }),
|
||||
makeTool({ callId: 'read', toolName: 'ReadFile', status: 'completed' }),
|
||||
makeTool({ callId: 'edit', toolName: 'edit', status: 'completed' }),
|
||||
makeTool({ callId: 'grep', toolName: 'grep', status: 'completed' }),
|
||||
makeTool({
|
||||
callId: 'todo',
|
||||
toolName: 'todo_write',
|
||||
status: 'completed',
|
||||
}),
|
||||
makeTool({
|
||||
callId: 'ask',
|
||||
toolName: 'ask_user_question',
|
||||
status: 'completed',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(hasActiveTool(tools)).toBe(false);
|
||||
expect(getActiveTool(tools).callId).toBe('read');
|
||||
expect(formatToolGroupSummary(tools, t)).toBe('Ran 2 tools');
|
||||
expect(getActiveTool(tools).callId).toBe('ask');
|
||||
expect(formatToolGroupSummary(tools, t)).toBe(
|
||||
'Edited 1 files Ran 1 commands Read 1 files Searched 1 times Updated todos 1 times Asked user',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats a single tool summary from the tool itself', () => {
|
||||
expect(
|
||||
formatSingleToolSummary(
|
||||
makeTool({
|
||||
toolName: 'Shell',
|
||||
args: { command: 'npm run build' },
|
||||
}),
|
||||
t,
|
||||
),
|
||||
).toBe('Shell npm run build');
|
||||
});
|
||||
|
||||
it('uses action summaries for single todo and ask-user tools', () => {
|
||||
expect(
|
||||
formatSingleToolSummary(makeTool({ toolName: 'todo_write' }), t),
|
||||
).toBe('Updated todos 1 times');
|
||||
expect(
|
||||
formatSingleToolSummary(makeTool({ toolName: 'ask_user_question' }), t),
|
||||
).toBe('Asked user');
|
||||
});
|
||||
|
||||
it('keeps running state and command details for a single active tool', () => {
|
||||
expect(
|
||||
formatRunningSingleToolSummary(
|
||||
makeTool({
|
||||
toolName: 'Shell',
|
||||
status: 'in_progress',
|
||||
args: { command: 'npm run build' },
|
||||
}),
|
||||
t,
|
||||
'0:03',
|
||||
),
|
||||
).toBe('Running Shell npm run build 0:03');
|
||||
});
|
||||
|
||||
it('truncates long single tool descriptions in the chat summary', () => {
|
||||
const summary = formatSingleToolSummary(
|
||||
makeTool({
|
||||
toolName: 'Shell',
|
||||
args: { command: 'x'.repeat(200) },
|
||||
}),
|
||||
t,
|
||||
);
|
||||
|
||||
expect(summary.length).toBeLessThan(140);
|
||||
expect(summary).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool expandability', () => {
|
||||
it('only marks tools with actual detail views as expandable by output', () => {
|
||||
expect(
|
||||
hasExpandableContent(
|
||||
makeTool({
|
||||
toolName: 'Shell',
|
||||
content: [{ type: 'content', content: { text: 'first\nsecond' } }],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasExpandableContent(
|
||||
makeTool({
|
||||
toolName: 'list_directory',
|
||||
rawOutput: 'a\nb',
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -125,6 +269,9 @@ describe('tool kind logic', () => {
|
|||
expect(getToolHeaderKind(makeTool({ toolName: 'todo_write' }))).toBe(
|
||||
'todo',
|
||||
);
|
||||
expect(getToolHeaderKind(makeTool({ toolName: 'ask_user_question' }))).toBe(
|
||||
'ask',
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes web fetch aliases', () => {
|
||||
|
|
@ -150,13 +297,82 @@ describe('tool kind logic', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('tool row rendering', () => {
|
||||
it('renders ANSI shell output as styled spans instead of escape text', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'Shell',
|
||||
status: 'failed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { text: '\u001b[31mfailed\u001b[0m\nplain' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('failed');
|
||||
expect(container.textContent).not.toContain('\u001b[31m');
|
||||
expect(container.querySelector('pre span[style*="color"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('wraps a single expanded agent body in a headerless card', () => {
|
||||
const container = renderToolGroup([
|
||||
makeTool({
|
||||
toolName: 'Task',
|
||||
status: 'in_progress',
|
||||
args: { description: 'Investigate build failure' },
|
||||
subContent: 'working through the issue',
|
||||
}),
|
||||
]);
|
||||
const summary = container.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
act(() => summary.click());
|
||||
|
||||
const card = container.querySelector('[class*="expandedAgentCard"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.textContent).toContain('working through the issue');
|
||||
expect(container.querySelector('[class*="expandedCardHeader"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps glob details visible in the header after expanding', () => {
|
||||
const pattern =
|
||||
'**/very-long-component-pattern-that-crosses-the-expand-threshold-*.tsx';
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'glob',
|
||||
args: {
|
||||
pattern,
|
||||
path: 'packages/web-shell/client',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
text: 'packages/web-shell/client/App.tsx',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const header = container.querySelector('[role="button"]') as HTMLElement;
|
||||
|
||||
expect(header.textContent).toContain(pattern);
|
||||
act(() => header.click());
|
||||
expect(header.textContent).toContain(pattern);
|
||||
expect(header.textContent).toContain('packages/web-shell/client');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool output logic', () => {
|
||||
it('resolves shell output max lines from settings', () => {
|
||||
expect(resolveShellOutputMaxLines([])).toBe(5);
|
||||
expect(resolveShellOutputMaxLines([setting(12)])).toBe(12);
|
||||
expect(resolveShellOutputMaxLines([setting(2.8)])).toBe(2);
|
||||
expect(resolveShellOutputMaxLines([setting(-1)])).toBe(0);
|
||||
expect(resolveShellOutputMaxLines([setting('bad')])).toBe(5);
|
||||
it('sanitizes read-file languages before building markdown fences', () => {
|
||||
expect(languageForPath('src/App.tsx')).toBe('tsx');
|
||||
expect(languageForPath('diagram.mermaid')).toBe('text');
|
||||
expect(languageForPath('bad.weird\nlang')).toBe('text');
|
||||
expect(fencedCodeBlock('tsx', 'const fence = "~~~";')).toBe(
|
||||
'~~~~tsx\nconst fence = "~~~";\n~~~~',
|
||||
);
|
||||
});
|
||||
|
||||
it('suppresses truncated session diffs from raw output', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import {
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type {
|
||||
ACPToolCall,
|
||||
PermissionRequest,
|
||||
|
|
@ -18,6 +25,7 @@ import {
|
|||
} from '../../utils/todos';
|
||||
import { useSharedNow } from '../../hooks/useSharedNow';
|
||||
import { TodoEventSummary, TodoFullList } from './TodoView';
|
||||
import { Markdown } from './Markdown';
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatElapsed,
|
||||
|
|
@ -53,12 +61,9 @@ interface ToolGroupProps {
|
|||
tools: ACPToolCall[];
|
||||
pendingApproval?: PermissionRequest | null;
|
||||
workspaceCwd?: string;
|
||||
shellOutputMaxLines?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5;
|
||||
|
||||
function hasExpandableContent(tool: ACPToolCall): boolean {
|
||||
export function hasExpandableContent(tool: ACPToolCall): boolean {
|
||||
const name = tool.toolName.toLowerCase();
|
||||
if (isAskUserQuestionToolName(tool.toolName)) return !!extractText(tool);
|
||||
// write_file shows content from args even before completion
|
||||
|
|
@ -186,61 +191,33 @@ export function buildUnifiedDiff(oldText: string, newText: string): string {
|
|||
return result.reverse().join('\n');
|
||||
}
|
||||
|
||||
const MAX_BASH_LINE_CHARS = 150;
|
||||
const MAX_READ_LINES = 25;
|
||||
|
||||
// A description longer than this is likely ellipsised on a normal-width row, so
|
||||
// the row becomes expandable to re-flow the full text into a wrapped block.
|
||||
const DESCRIPTION_EXPAND_THRESHOLD = 60;
|
||||
const MAX_MARKDOWN_READ_CHARS = 200_000;
|
||||
const MAX_MARKDOWN_READ_LINES = 1000;
|
||||
const READ_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cjs: 'javascript',
|
||||
cts: 'typescript',
|
||||
h: 'c',
|
||||
hpp: 'cpp',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
mjs: 'javascript',
|
||||
mts: 'typescript',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
sh: 'bash',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
yml: 'yaml',
|
||||
};
|
||||
|
||||
export function resolveShellOutputMaxLines(
|
||||
settings: readonly DaemonSettingDescriptor[],
|
||||
): number {
|
||||
const setting = settings.find((s) => s.key === 'ui.shellOutputMaxLines');
|
||||
const value = setting?.values.effective;
|
||||
const raw =
|
||||
typeof value === 'number' ? value : DEFAULT_SHELL_OUTPUT_MAX_LINES;
|
||||
return Math.max(0, Math.floor(raw || 0));
|
||||
}
|
||||
|
||||
function truncateLine(line: string, max: number): string {
|
||||
if (line.length <= max) return line;
|
||||
return line.slice(0, max) + ' …';
|
||||
}
|
||||
|
||||
function ExpandedBashOutput({
|
||||
tool,
|
||||
maxLines,
|
||||
}: {
|
||||
tool: ACPToolCall;
|
||||
maxLines: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
function ExpandedBashOutput({ tool }: { tool: ACPToolCall }) {
|
||||
const output = useMemo(() => extractText(tool) || '', [tool]);
|
||||
const lines = useMemo(() => output.split('\n'), [output]);
|
||||
const isLong = maxLines > 0 && lines.length > maxLines;
|
||||
const hiddenLinesCount = Math.max(0, lines.length - maxLines);
|
||||
const hasTruncatedLine = useMemo(
|
||||
() => lines.some((l) => l.length > MAX_BASH_LINE_CHARS),
|
||||
[lines],
|
||||
);
|
||||
const expandable = isLong || hasTruncatedLine;
|
||||
const displayText = useMemo(() => {
|
||||
if (showAll) return output;
|
||||
if (isLong) {
|
||||
return [
|
||||
`... first ${hiddenLinesCount} lines hidden ...`,
|
||||
...lines
|
||||
.slice(-maxLines)
|
||||
.map((l) => truncateLine(l, MAX_BASH_LINE_CHARS)),
|
||||
].join('\n');
|
||||
}
|
||||
return lines.map((l) => truncateLine(l, MAX_BASH_LINE_CHARS)).join('\n');
|
||||
}, [hiddenLinesCount, isLong, lines, maxLines, output, showAll]);
|
||||
const ansiSegments = useMemo(
|
||||
() => (hasAnsi(displayText) ? parseAnsi(displayText) : null),
|
||||
[displayText],
|
||||
() => (hasAnsi(output) ? parseAnsi(output) : null),
|
||||
[output],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -259,55 +236,61 @@ function ExpandedBashOutput({
|
|||
{seg.text}
|
||||
</span>
|
||||
))
|
||||
: displayText}
|
||||
: output}
|
||||
</pre>
|
||||
{expandable && (
|
||||
<button
|
||||
className={styles.expandBtn}
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
aria-expanded={showAll}
|
||||
>
|
||||
{showAll
|
||||
? t('tool.showLess')
|
||||
: isLong
|
||||
? t('tool.showAll', { count: lines.length })
|
||||
: t('tool.showFullLines')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedReadContent({ tool }: { tool: ACPToolCall }) {
|
||||
const { t } = useI18n();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const content = useMemo(() => extractText(tool) || '', [tool]);
|
||||
const lines = useMemo(() => content.split('\n'), [content]);
|
||||
const isLong = lines.length > MAX_READ_LINES;
|
||||
const displayText = useMemo(
|
||||
() =>
|
||||
isLong && !showAll ? lines.slice(0, MAX_READ_LINES).join('\n') : content,
|
||||
[content, isLong, lines, showAll],
|
||||
);
|
||||
const language = languageForPath(getReadFilePath(tool));
|
||||
const plainText =
|
||||
language === 'text' ||
|
||||
content.length > MAX_MARKDOWN_READ_CHARS ||
|
||||
exceedsLineLimit(content, MAX_MARKDOWN_READ_LINES);
|
||||
|
||||
return (
|
||||
<div className={styles.expandedRead}>
|
||||
<pre className={styles.expandedOutput}>{displayText}</pre>
|
||||
{isLong && (
|
||||
<button
|
||||
className={styles.expandBtn}
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
aria-expanded={showAll}
|
||||
>
|
||||
{showAll
|
||||
? t('tool.showLess')
|
||||
: t('tool.linesTotal', { count: lines.length })}
|
||||
</button>
|
||||
{plainText ? (
|
||||
<pre className={styles.expandedOutput}>{content}</pre>
|
||||
) : (
|
||||
<Markdown content={fencedCodeBlock(language, content)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReadFilePath(tool: ACPToolCall): string {
|
||||
const filePath = tool.args?.file_path ?? tool.args?.path;
|
||||
return typeof filePath === 'string' ? filePath : '';
|
||||
}
|
||||
|
||||
function exceedsLineLimit(text: string, maxLines: number): boolean {
|
||||
let lines = 1;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10 && ++lines > maxLines) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function languageForPath(filePath: string): string {
|
||||
const ext = filePath.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase();
|
||||
if (!ext || ext === filePath.toLowerCase()) return 'text';
|
||||
if (ext === 'mermaid' || ext === 'mmd') return 'text';
|
||||
const language = READ_LANGUAGE_ALIASES[ext] ?? ext;
|
||||
return /^[\w+.#-]+$/.test(language) ? language : 'text';
|
||||
}
|
||||
|
||||
export function fencedCodeBlock(language: string, code: string): string {
|
||||
const longestFence =
|
||||
code
|
||||
.match(/~{3,}/g)
|
||||
?.reduce((max, fence) => Math.max(max, fence.length), 0) ?? 0;
|
||||
const fence = '~'.repeat(Math.max(3, longestFence + 1));
|
||||
return `${fence}${language}\n${code}\n${fence}`;
|
||||
}
|
||||
|
||||
function ExpandedEditContent({ tool }: { tool: ACPToolCall }) {
|
||||
const diff = useMemo(() => extractDiff(tool), [tool]);
|
||||
const text = useMemo(() => extractText(tool) || '', [tool]);
|
||||
|
|
@ -323,6 +306,26 @@ function ExpandedEditContent({ tool }: { tool: ACPToolCall }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ToolExpandedCard({
|
||||
title,
|
||||
detail,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
detail?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.expandedCard}>
|
||||
<div className={styles.expandedCardHeader}>
|
||||
<span className={styles.expandedCardTitle}>{title}</span>
|
||||
{detail && <span className={styles.expandedCardDetail}>{detail}</span>}
|
||||
</div>
|
||||
{children && <div className={styles.expandedCardBody}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWriteContent(tool: ACPToolCall): string {
|
||||
if (tool.args?.content) return tool.args.content as string;
|
||||
if (tool.args?.new_string) return tool.args.new_string as string;
|
||||
|
|
@ -345,20 +348,24 @@ function TodoToolBody({
|
|||
tool,
|
||||
todos,
|
||||
expanded,
|
||||
title,
|
||||
}: {
|
||||
tool: ACPToolCall;
|
||||
todos: TodoItem[];
|
||||
expanded: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
const timeline = useContext(TodoTimelineContext);
|
||||
const events = timeline.get(tool.callId)?.events ?? [];
|
||||
return (
|
||||
<div className={styles.todoBody}>
|
||||
{expanded ? (
|
||||
return expanded ? (
|
||||
<ToolExpandedCard title={title}>
|
||||
<div className={styles.todoBody}>
|
||||
<TodoFullList todos={todos} />
|
||||
) : (
|
||||
<TodoEventSummary todos={todos} events={events} />
|
||||
)}
|
||||
</div>
|
||||
</ToolExpandedCard>
|
||||
) : (
|
||||
<div className={styles.todoBody}>
|
||||
<TodoEventSummary todos={todos} events={events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -367,8 +374,11 @@ interface ToolLineProps {
|
|||
tool: ACPToolCall;
|
||||
approval?: PermissionRequest | null;
|
||||
workspaceCwd?: string;
|
||||
shellOutputMaxLines?: number;
|
||||
summaryOnly?: boolean;
|
||||
forceExpanded?: boolean;
|
||||
forceExpandable?: boolean;
|
||||
hideHeader?: boolean;
|
||||
hideCollapsedOutput?: boolean;
|
||||
}
|
||||
|
||||
function getAgentDisplayInfo(
|
||||
|
|
@ -468,6 +478,7 @@ function ExpandedAskUserQuestionOutput({ tool }: { tool: ACPToolCall }) {
|
|||
export function getToolHeaderKind(tool: ACPToolCall): ToolHeaderKind {
|
||||
const name = tool.toolName.toLowerCase();
|
||||
if (isSubAgentToolCall(tool)) return 'agent';
|
||||
if (isAskUserQuestionToolName(tool.toolName)) return 'ask';
|
||||
if (isShellToolName(name)) return 'shell';
|
||||
if (isWebFetchToolName(name)) return 'fetch';
|
||||
if (isTodoWriteToolName(name)) return 'todo';
|
||||
|
|
@ -542,11 +553,101 @@ export function formatToolGroupSummary(
|
|||
});
|
||||
}
|
||||
|
||||
const summary = formatCompletedToolSummary(tools, t);
|
||||
if (summary) return summary;
|
||||
|
||||
return t('toolGroup.summary', {
|
||||
count: tools.length,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSingleToolSummary(
|
||||
tool: ACPToolCall,
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
workspaceCwd?: string,
|
||||
): string {
|
||||
if (isTodoWriteToolName(tool.toolName)) {
|
||||
return t('toolGroup.summary.updatedTodos', { count: 1 });
|
||||
}
|
||||
if (isAskUserQuestionToolName(tool.toolName)) {
|
||||
return t('toolGroup.summary.askedUser', { count: 1 });
|
||||
}
|
||||
|
||||
const displayName = localizeToolDisplayName(tool.toolName, t);
|
||||
const description = truncateText(getToolDescription(tool, workspaceCwd), 120);
|
||||
return [displayName, description].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function formatRunningSingleToolSummary(
|
||||
tool: ACPToolCall,
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
duration?: string,
|
||||
workspaceCwd?: string,
|
||||
): string {
|
||||
return t('toolGroup.running', {
|
||||
name: formatSingleToolSummary(tool, t, workspaceCwd),
|
||||
count: 1,
|
||||
duration: duration ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
function formatCompletedToolSummary(
|
||||
tools: ACPToolCall[],
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
): string {
|
||||
let edited = 0;
|
||||
let commands = 0;
|
||||
let read = 0;
|
||||
let searched = 0;
|
||||
let todos = 0;
|
||||
let asked = 0;
|
||||
let other = 0;
|
||||
|
||||
for (const tool of tools) {
|
||||
const name = tool.toolName.toLowerCase();
|
||||
if (isShellToolName(name)) {
|
||||
commands++;
|
||||
} else if (
|
||||
name === 'edit' ||
|
||||
name === 'editfile' ||
|
||||
name === 'write' ||
|
||||
name === 'write_file' ||
|
||||
name === 'writefile'
|
||||
) {
|
||||
edited++;
|
||||
} else if (name === 'read' || name === 'read_file' || name === 'readfile') {
|
||||
read++;
|
||||
} else if (
|
||||
name === 'grep' ||
|
||||
name === 'grep_search' ||
|
||||
name === 'search' ||
|
||||
name === 'glob' ||
|
||||
name === 'web_search' ||
|
||||
name === 'websearch'
|
||||
) {
|
||||
searched++;
|
||||
} else if (isTodoWriteToolName(name)) {
|
||||
todos++;
|
||||
} else if (isAskUserQuestionToolName(name)) {
|
||||
asked++;
|
||||
} else {
|
||||
other++;
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [
|
||||
edited ? t('toolGroup.summary.editedFiles', { count: edited }) : '',
|
||||
commands ? t('toolGroup.summary.ranCommands', { count: commands }) : '',
|
||||
read ? t('toolGroup.summary.readFiles', { count: read }) : '',
|
||||
searched ? t('toolGroup.summary.searched', { count: searched }) : '',
|
||||
todos ? t('toolGroup.summary.updatedTodos', { count: todos }) : '',
|
||||
asked ? t('toolGroup.summary.askedUser') : '',
|
||||
other ? t('toolGroup.summary.otherTools', { count: other }) : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function hasActiveTool(tools: ACPToolCall[]): boolean {
|
||||
return tools.some((tool) => isActiveToolStatus(tool.status));
|
||||
}
|
||||
|
|
@ -679,6 +780,27 @@ function TodoIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
function AskUserIcon() {
|
||||
return (
|
||||
<svg
|
||||
className={styles.chatSummaryToolIcon}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.4 3.2h7.2c.7 0 1.25.55 1.25 1.25v3.4c0 .7-.55 1.25-1.25 1.25H7.5L4.5 11V9.1H3.4c-.7 0-1.25-.55-1.25-1.25v-3.4c0-.7.55-1.25 1.25-1.25Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentIcon() {
|
||||
return (
|
||||
<svg
|
||||
|
|
@ -702,6 +824,7 @@ function AgentIcon() {
|
|||
function ToolSummaryIcon({ tool }: { tool: ACPToolCall }) {
|
||||
const kind = getToolHeaderKind(tool);
|
||||
if (kind === 'agent') return <AgentIcon />;
|
||||
if (kind === 'ask') return <AskUserIcon />;
|
||||
if (kind === 'edit' || kind === 'write') return <PencilIcon />;
|
||||
if (kind === 'fetch') return <WebFetchIcon />;
|
||||
if (kind === 'read') return <FileIcon />;
|
||||
|
|
@ -767,8 +890,11 @@ function areToolLinePropsEqual(
|
|||
): boolean {
|
||||
if (prev.approval?.id !== next.approval?.id) return false;
|
||||
if (prev.workspaceCwd !== next.workspaceCwd) return false;
|
||||
if (prev.shellOutputMaxLines !== next.shellOutputMaxLines) return false;
|
||||
if (prev.summaryOnly !== next.summaryOnly) return false;
|
||||
if (prev.forceExpanded !== next.forceExpanded) return false;
|
||||
if (prev.forceExpandable !== next.forceExpandable) return false;
|
||||
if (prev.hideHeader !== next.hideHeader) return false;
|
||||
if (prev.hideCollapsedOutput !== next.hideCollapsedOutput) return false;
|
||||
const a = prev.tool;
|
||||
const b = next.tool;
|
||||
return (
|
||||
|
|
@ -816,13 +942,16 @@ export const ToolLine = memo(function ToolLine({
|
|||
tool,
|
||||
approval,
|
||||
workspaceCwd,
|
||||
shellOutputMaxLines = DEFAULT_SHELL_OUTPUT_MAX_LINES,
|
||||
summaryOnly = false,
|
||||
forceExpanded = false,
|
||||
forceExpandable = false,
|
||||
hideHeader = false,
|
||||
hideCollapsedOutput = false,
|
||||
}: ToolLineProps) {
|
||||
const { t } = useI18n();
|
||||
const compactMode = useContext(CompactModeContext);
|
||||
const [expanded, setExpanded] = useState(
|
||||
() => !compactMode && shouldAutoExpand(tool),
|
||||
() => forceExpanded || (!compactMode && shouldAutoExpand(tool)),
|
||||
);
|
||||
// Set once the user explicitly toggles this row, so auto-collapse-on-
|
||||
// completion never silently overrides their choice.
|
||||
|
|
@ -830,12 +959,14 @@ export const ToolLine = memo(function ToolLine({
|
|||
|
||||
useEffect(
|
||||
() => {
|
||||
setExpanded(compactMode ? false : shouldAutoExpand(tool));
|
||||
setExpanded(
|
||||
forceExpanded || (compactMode ? false : shouldAutoExpand(tool)),
|
||||
);
|
||||
// A new tool identity (or compact-mode toggle) resets the manual latch.
|
||||
userToggledRef.current = false;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[compactMode, tool.callId, tool.toolName],
|
||||
[compactMode, forceExpanded, tool.callId, tool.toolName],
|
||||
);
|
||||
const isAgent = isSubAgentToolCall(tool);
|
||||
const hasApproval = approval && approval.toolCallId === tool.callId;
|
||||
|
|
@ -853,10 +984,15 @@ export const ToolLine = memo(function ToolLine({
|
|||
// user chose, driven from their own panel) and failures stay open so the
|
||||
// error output remains visible.
|
||||
useEffect(() => {
|
||||
if (!isAgent && tool.status === 'completed' && !userToggledRef.current) {
|
||||
if (
|
||||
!forceExpanded &&
|
||||
!isAgent &&
|
||||
tool.status === 'completed' &&
|
||||
!userToggledRef.current
|
||||
) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [isAgent, tool.status]);
|
||||
}, [forceExpanded, isAgent, tool.status]);
|
||||
|
||||
if (isAgent) {
|
||||
const info = getAgentDisplayInfo(tool, now);
|
||||
|
|
@ -876,31 +1012,41 @@ export const ToolLine = memo(function ToolLine({
|
|||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const showExpanded = expanded || !!hasApproval || !!hasSubToolApproval;
|
||||
const showExpanded =
|
||||
forceExpanded || expanded || !!hasApproval || !!hasSubToolApproval;
|
||||
const panel = (
|
||||
<SubAgentPanel tool={tool} hideHeader defaultExpanded inline />
|
||||
);
|
||||
return (
|
||||
<div className={styles.line}>
|
||||
<div
|
||||
className={`${styles.lineMain} ${styles.lineExpandable}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<StatusIcon status={isComplete ? info.status : tool.status} />
|
||||
<span className={styles.lineName}>{displayName}</span>
|
||||
<ToolHeaderExtra
|
||||
info={{
|
||||
kind: 'agent',
|
||||
tool,
|
||||
displayName,
|
||||
description: info.description
|
||||
? truncateText(info.description, 60)
|
||||
: '',
|
||||
elapsed: isComplete ? completeMeta : runningMeta,
|
||||
workspaceCwd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className={`${styles.lineMain} ${styles.lineExpandable}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<StatusIcon status={isComplete ? info.status : tool.status} />
|
||||
<span className={styles.lineName}>{displayName}</span>
|
||||
<ToolHeaderExtra
|
||||
info={{
|
||||
kind: 'agent',
|
||||
tool,
|
||||
displayName,
|
||||
description: info.description
|
||||
? truncateText(info.description, 60)
|
||||
: '',
|
||||
elapsed: isComplete ? completeMeta : runningMeta,
|
||||
workspaceCwd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showExpanded && (
|
||||
<div className={styles.lineDetail}>
|
||||
<SubAgentPanel tool={tool} hideHeader defaultExpanded inline />
|
||||
{hideHeader ? (
|
||||
<div className={styles.expandedAgentCard}>{panel}</div>
|
||||
) : (
|
||||
panel
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -922,107 +1068,153 @@ export const ToolLine = memo(function ToolLine({
|
|||
const todoCompleted = todoItems
|
||||
? todoItems.filter((td) => td.status === 'completed').length
|
||||
: 0;
|
||||
const isShell = isShellToolName(name);
|
||||
const isSearch =
|
||||
name === 'grep' ||
|
||||
name === 'grep_search' ||
|
||||
name === 'search' ||
|
||||
name === 'glob';
|
||||
const isRead = name === 'read' || name === 'read_file' || name === 'readfile';
|
||||
// A row expands when it has a todo list to reveal, detail output
|
||||
// (bash/diff/read content), or a description long enough to be ellipsised.
|
||||
// When a long description is expanded we move it out of the header into a
|
||||
// wrapped block below, so the header drops its single-line copy.
|
||||
const descExpandable = !isTodo && isDescriptionExpandable(description);
|
||||
const expandable = isTodo
|
||||
? hasTodoList
|
||||
: hasExpandableContent(tool) || descExpandable;
|
||||
const relocateDescription = expanded && descExpandable;
|
||||
const expandable =
|
||||
!forceExpanded &&
|
||||
(forceExpandable ||
|
||||
(isTodo ? hasTodoList : hasExpandableContent(tool) || descExpandable));
|
||||
// Whether the expanded row renders a kind-specific detail view. When it does
|
||||
// not (e.g. grep/glob/web_fetch with a long description), keep the result
|
||||
// summary visible instead of replacing it with an empty detail area.
|
||||
const detailView = hasDetailView(tool);
|
||||
const showDescriptionInDetail = expanded && descExpandable;
|
||||
const useMarkdownDetail = isRead;
|
||||
const hideDescriptionInHeader =
|
||||
showDescriptionInDetail && !isShell && !isSearch && !isRead;
|
||||
const expandedCardDetail = description;
|
||||
const showExpandedSummaryPanel =
|
||||
!isTodo && expanded && !detailView && (showDescriptionInDetail || result);
|
||||
|
||||
return (
|
||||
<div className={styles.line}>
|
||||
<div
|
||||
className={`${styles.lineMain} ${expandable ? styles.lineExpandable : ''}`}
|
||||
title={
|
||||
expandable
|
||||
? expanded
|
||||
? t('tool.collapseHint')
|
||||
: t('tool.expand')
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
onClick={
|
||||
expandable
|
||||
? () => {
|
||||
userToggledRef.current = true;
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
userToggledRef.current = true;
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<StatusIcon status={tool.status} />
|
||||
<span className={styles.lineName}>{displayName}</span>
|
||||
{isTodo && hasTodoList && (
|
||||
<span className={styles.todoProgress}>
|
||||
{todoCompleted}/{todoItems!.length}
|
||||
</span>
|
||||
)}
|
||||
<ToolHeaderExtra
|
||||
info={{
|
||||
kind: getToolHeaderKind(tool),
|
||||
tool,
|
||||
displayName,
|
||||
// A todo row carries its checklist in the body below; a redundant
|
||||
// "Update Todos" description and the instant write duration would
|
||||
// only clutter the header next to the progress count.
|
||||
description: isTodo || relocateDescription ? '' : description,
|
||||
elapsed: isTodo ? '' : elapsed,
|
||||
workspaceCwd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className={`${styles.lineMain} ${expandable ? styles.lineExpandable : ''}`}
|
||||
title={
|
||||
expandable
|
||||
? expanded
|
||||
? t('tool.collapseHint')
|
||||
: t('tool.expand')
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
onClick={
|
||||
expandable
|
||||
? () => {
|
||||
userToggledRef.current = true;
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
userToggledRef.current = true;
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<StatusIcon status={tool.status} />
|
||||
<span className={styles.lineName}>{displayName}</span>
|
||||
{isTodo && hasTodoList && (
|
||||
<span className={styles.todoProgress}>
|
||||
{todoCompleted}/{todoItems!.length}
|
||||
</span>
|
||||
)}
|
||||
<ToolHeaderExtra
|
||||
info={{
|
||||
kind: getToolHeaderKind(tool),
|
||||
tool,
|
||||
displayName,
|
||||
// A todo row carries its checklist in the body below; a redundant
|
||||
// "Update Todos" description and the instant write duration would
|
||||
// only clutter the header next to the progress count.
|
||||
description: isTodo || hideDescriptionInHeader ? '' : description,
|
||||
elapsed: isTodo ? '' : elapsed,
|
||||
workspaceCwd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!summaryOnly || expanded) && isTodo && hasTodoList && (
|
||||
<TodoToolBody tool={tool} todos={todoItems!} expanded={expanded} />
|
||||
<TodoToolBody
|
||||
tool={tool}
|
||||
todos={todoItems!}
|
||||
expanded={expanded}
|
||||
title={displayName}
|
||||
/>
|
||||
)}
|
||||
{/* Todo tool whose payload couldn't be parsed (e.g. malformed args):
|
||||
fall back to the raw result summary so the row isn't blank. */}
|
||||
{(!summaryOnly || expanded) && isTodo && !hasTodoList && result && (
|
||||
<div className={styles.lineOutput}>{result}</div>
|
||||
)}
|
||||
{relocateDescription && (
|
||||
<div className={styles.lineFullArg}>{description}</div>
|
||||
{showExpandedSummaryPanel && (
|
||||
<ToolExpandedCard title={displayName} detail={expandedCardDetail}>
|
||||
{result && (
|
||||
<div
|
||||
className={`${styles.lineOutput} ${styles.expandedLineOutput}`}
|
||||
>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
</ToolExpandedCard>
|
||||
)}
|
||||
{!isTodo &&
|
||||
!hideCollapsedOutput &&
|
||||
result &&
|
||||
!showExpandedSummaryPanel &&
|
||||
(!expanded || !detailView) &&
|
||||
(!summaryOnly || expanded) && (
|
||||
<div className={styles.lineOutput}>{result}</div>
|
||||
<div
|
||||
className={
|
||||
expanded
|
||||
? `${styles.lineOutput} ${styles.expandedLineOutput}`
|
||||
: styles.lineOutput
|
||||
}
|
||||
>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
{!isTodo && expanded && detailView && (
|
||||
<div className={styles.lineDetail}>
|
||||
{isShellToolName(name) && (
|
||||
<ExpandedBashOutput tool={tool} maxLines={shellOutputMaxLines} />
|
||||
)}
|
||||
{(name === 'write_file' || name === 'writefile') && (
|
||||
<ExpandedEditContent tool={tool} />
|
||||
)}
|
||||
{(name === 'edit' || name === 'write' || name === 'editfile') && (
|
||||
<ExpandedEditContent tool={tool} />
|
||||
)}
|
||||
{(name === 'read' || name === 'read_file' || name === 'readfile') && (
|
||||
<div
|
||||
className={
|
||||
useMarkdownDetail
|
||||
? `${styles.lineDetail} ${styles.markdownLineDetail}`
|
||||
: styles.lineDetail
|
||||
}
|
||||
>
|
||||
{isRead ? (
|
||||
<ExpandedReadContent tool={tool} />
|
||||
)}
|
||||
{isAskUserQuestionToolName(tool.toolName) && (
|
||||
<ExpandedAskUserQuestionOutput tool={tool} />
|
||||
) : (
|
||||
<ToolExpandedCard title={displayName} detail={expandedCardDetail}>
|
||||
{isShellToolName(name) && <ExpandedBashOutput tool={tool} />}
|
||||
{(name === 'write_file' || name === 'writefile') && (
|
||||
<ExpandedEditContent tool={tool} />
|
||||
)}
|
||||
{(name === 'edit' || name === 'write' || name === 'editfile') && (
|
||||
<ExpandedEditContent tool={tool} />
|
||||
)}
|
||||
{isAskUserQuestionToolName(tool.toolName) && (
|
||||
<ExpandedAskUserQuestionOutput tool={tool} />
|
||||
)}
|
||||
</ToolExpandedCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1034,13 +1226,13 @@ export const ToolGroup = memo(function ToolGroup({
|
|||
tools,
|
||||
pendingApproval,
|
||||
workspaceCwd,
|
||||
shellOutputMaxLines,
|
||||
}: ToolGroupProps) {
|
||||
const { t } = useI18n();
|
||||
const compactMode = useContext(CompactModeContext);
|
||||
const [chatExpanded, setChatExpanded] = useState(false);
|
||||
const hasRunningTool = hasActiveTool(tools);
|
||||
const activeTool = tools.length > 0 ? getActiveTool(tools) : undefined;
|
||||
const singleTool = tools.length === 1 ? tools[0] : undefined;
|
||||
const summaryIconTool = tools[0] ?? activeTool;
|
||||
const liveStartedAtRef = useRef(Date.now());
|
||||
const summaryNow = useSharedNow(hasRunningTool);
|
||||
|
|
@ -1085,7 +1277,16 @@ export const ToolGroup = memo(function ToolGroup({
|
|||
: styles.chatSummaryText
|
||||
}
|
||||
>
|
||||
{formatToolGroupSummary(tools, t, runningDuration)}
|
||||
{singleTool
|
||||
? hasRunningTool
|
||||
? formatRunningSingleToolSummary(
|
||||
singleTool,
|
||||
t,
|
||||
runningDuration,
|
||||
workspaceCwd,
|
||||
)
|
||||
: formatSingleToolSummary(singleTool, t, workspaceCwd)
|
||||
: formatToolGroupSummary(tools, t, runningDuration)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
|
|
@ -1109,8 +1310,9 @@ export const ToolGroup = memo(function ToolGroup({
|
|||
tool={tool}
|
||||
approval={pendingApproval}
|
||||
workspaceCwd={workspaceCwd}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
summaryOnly
|
||||
summaryOnly={!singleTool}
|
||||
forceExpanded={!!singleTool}
|
||||
hideHeader={!!singleTool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1128,7 +1330,6 @@ export const ToolGroup = memo(function ToolGroup({
|
|||
tool={tool}
|
||||
approval={pendingApproval}
|
||||
workspaceCwd={workspaceCwd}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -305,11 +305,17 @@ describe('toolFormatting', () => {
|
|||
it('keeps proper tool names / acronyms in English', () => {
|
||||
const t = getTranslator('zh-CN');
|
||||
expect(localizeToolDisplayName('agent', t)).toBe('Agent');
|
||||
expect(localizeToolDisplayName('grep_search', t)).toBe('Grep');
|
||||
expect(localizeToolDisplayName('glob', t)).toBe('Glob');
|
||||
expect(localizeToolDisplayName('lsp', t)).toBe('LSP');
|
||||
});
|
||||
|
||||
it('localizes grep tool aliases in Chinese', () => {
|
||||
const t = getTranslator('zh-CN');
|
||||
expect(localizeToolDisplayName('grep', t)).toBe('搜索内容');
|
||||
expect(localizeToolDisplayName('grep_search', t)).toBe('搜索内容');
|
||||
expect(localizeToolDisplayName('search', t)).toBe('搜索内容');
|
||||
});
|
||||
|
||||
it('falls back to the English display name when the locale has no entry', () => {
|
||||
const t = getTranslator('en');
|
||||
expect(localizeToolDisplayName('todo_write', t)).toBe('TodoList');
|
||||
|
|
@ -325,7 +331,7 @@ describe('toolFormatting', () => {
|
|||
it('has a zh translation for every tool in the display-name map', () => {
|
||||
const tZh = getTranslator('zh-CN');
|
||||
// Tools intentionally shown in English (proper names / acronyms).
|
||||
const keepEnglish = new Set(['agent', 'grep_search', 'glob', 'search']);
|
||||
const keepEnglish = new Set(['agent', 'glob']);
|
||||
const untranslated = Object.keys(TOOL_DISPLAY_NAMES).filter(
|
||||
(wire) =>
|
||||
!keepEnglish.has(wire) &&
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
|||
edit: 'Edit',
|
||||
write_file: 'WriteFile',
|
||||
read_file: 'ReadFile',
|
||||
grep: 'Grep',
|
||||
grep_search: 'Grep',
|
||||
glob: 'Glob',
|
||||
run_shell_command: 'Shell',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
.view {
|
||||
margin-top: 6px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@
|
|||
}
|
||||
|
||||
.summaryToolIcon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
|
@ -99,15 +99,26 @@
|
|||
|
||||
.chevronRight,
|
||||
.chevronDown {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 1px solid currentColor;
|
||||
border-bottom: 1px solid currentColor;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.chevronRight::before,
|
||||
.chevronRight::after,
|
||||
.chevronDown::before,
|
||||
.chevronDown::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.summary:hover .chevronRight,
|
||||
.summary:hover .chevronDown,
|
||||
.summary:focus-visible .chevronRight,
|
||||
|
|
@ -116,17 +127,40 @@
|
|||
}
|
||||
|
||||
.chevronRight {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.chevronRight::before {
|
||||
top: calc(50% - 2px);
|
||||
left: calc(50% - 3px);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.chevronRight::after {
|
||||
top: calc(50% + 2px);
|
||||
left: calc(50% - 3px);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.chevronDown::before {
|
||||
top: 50%;
|
||||
left: calc(50% - 5px);
|
||||
transform: rotate(45deg);
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.chevronDown::after {
|
||||
top: 50%;
|
||||
left: calc(50% - 1px);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.group {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--secondary);
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
|
|
@ -157,8 +191,6 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
/* border-left: 2px solid var(--border); */
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
|
|
@ -199,6 +231,4 @@
|
|||
|
||||
.detail {
|
||||
margin-top: 4px;
|
||||
padding: 6px 0 2px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
.panel {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--secondary);
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
|
|
|
|||
|
|
@ -99,4 +99,50 @@ describe('SubAgentPanel sub-tool timestamps', () => {
|
|||
);
|
||||
expect(container.textContent).not.toContain(formatTimestamp(reference));
|
||||
});
|
||||
|
||||
it('keeps sub-tools expandable while hiding their collapsed output summary', () => {
|
||||
const container = renderPanel(
|
||||
makeAgentWithSubTool({
|
||||
callId: 'sub-1',
|
||||
toolName: 'Shell',
|
||||
status: 'completed',
|
||||
args: { command: 'npm test' },
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { text: 'first line\nsecond line' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const row = Array.from(container.querySelectorAll('[role="button"]')).find(
|
||||
(el) => el.textContent?.includes('Shell'),
|
||||
) as HTMLElement | undefined;
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.textContent).toContain('npm test');
|
||||
expect(container.textContent).not.toContain('first line');
|
||||
act(() => row!.click());
|
||||
expect(container.textContent).toContain('first line');
|
||||
expect(container.textContent).toContain('second line');
|
||||
});
|
||||
|
||||
it('hides non-standard sub-tool summaries until the row is expanded', () => {
|
||||
const container = renderPanel(
|
||||
makeAgentWithSubTool({
|
||||
callId: 'sub-1',
|
||||
toolName: 'list_directory',
|
||||
status: 'completed',
|
||||
rawOutput: 'src\npackage.json',
|
||||
}),
|
||||
);
|
||||
const row = Array.from(container.querySelectorAll('[role="button"]')).find(
|
||||
(el) => el.textContent?.includes('ListFiles'),
|
||||
) as HTMLElement | undefined;
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(container.textContent).not.toContain('2 item(s)');
|
||||
act(() => row!.click());
|
||||
expect(container.textContent).toContain('2 item(s)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,14 +104,13 @@ const SubToolLine = memo(function SubToolLine({ tool }: { tool: ACPToolCall }) {
|
|||
tool.subTools || tool.subContent ? (
|
||||
<SubAgentPanel tool={tool} />
|
||||
) : (
|
||||
<ToolLine tool={tool} />
|
||||
<ToolLine tool={tool} forceExpandable hideCollapsedOutput />
|
||||
);
|
||||
return <SubToolTime timestamp={tool.startTime}>{body}</SubToolTime>;
|
||||
});
|
||||
|
||||
function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {
|
||||
const { t } = useI18n();
|
||||
const desc = tc.description || '';
|
||||
return (
|
||||
<div className={chromeStyles.line}>
|
||||
<div className={chromeStyles.lineMain}>
|
||||
|
|
@ -119,9 +118,6 @@ function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {
|
|||
<span className={chromeStyles.lineName}>
|
||||
{localizeToolDisplayName(tc.name, t)}
|
||||
</span>
|
||||
{desc && (
|
||||
<span className={chromeStyles.lineArg}>{truncateText(desc, 70)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@
|
|||
}
|
||||
|
||||
.chatSummaryToolIcon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
|
@ -111,15 +111,26 @@
|
|||
|
||||
.chatChevronRight,
|
||||
.chatChevronDown {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 1px solid currentColor;
|
||||
border-bottom: 1px solid currentColor;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.chatChevronRight::before,
|
||||
.chatChevronRight::after,
|
||||
.chatChevronDown::before,
|
||||
.chatChevronDown::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.chatSummary:hover .chatChevronRight,
|
||||
.chatSummary:hover .chatChevronDown,
|
||||
.chatSummary:focus-visible .chatChevronRight,
|
||||
|
|
@ -128,15 +139,38 @@
|
|||
}
|
||||
|
||||
.chatChevronRight {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.chatChevronRight::before {
|
||||
top: calc(50% - 2px);
|
||||
left: calc(50% - 3px);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.chatChevronRight::after {
|
||||
top: calc(50% + 2px);
|
||||
left: calc(50% - 3px);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.chatChevronDown {
|
||||
transform: rotate(45deg);
|
||||
margin-top: -3px;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatChevronDown::before {
|
||||
top: 50%;
|
||||
left: calc(50% - 5px);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.chatChevronDown::after {
|
||||
top: 50%;
|
||||
left: calc(50% - 1px);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.chatSummaryContentClip {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
|
|
@ -208,7 +242,9 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lineExpandable:hover .lineName {
|
||||
.lineExpandable:hover .lineName,
|
||||
.lineExpandable:hover .lineArg,
|
||||
.lineExpandable:hover .lineElapsed {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
|
@ -221,22 +257,73 @@
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Full, wrapped tool argument shown below the header when the row is expanded
|
||||
(the header drops its single-line ellipsised copy). Aligns with the expanded
|
||||
output block below it. */
|
||||
.lineFullArg {
|
||||
margin-left: 16px;
|
||||
margin-top: 2px;
|
||||
.expandedCard {
|
||||
margin: 5px 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.expandedCardHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
gap: 3px;
|
||||
padding: 8px 12px 8px;
|
||||
}
|
||||
|
||||
.expandedCardTitle {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expandedCardDetail {
|
||||
min-width: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.expandedCardBody {
|
||||
padding: 6px 12px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expandedAgentCard {
|
||||
background: var(--secondary);
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandedLineOutput {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.lineDetail {
|
||||
margin: 5px 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.markdownLineDetail {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.expandedBash,
|
||||
|
|
@ -245,20 +332,21 @@
|
|||
}
|
||||
|
||||
.expandedOutput {
|
||||
padding: 0px 0px 0 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.expandedEdit {
|
||||
margin-top: 2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.expandedWrite {
|
||||
|
|
@ -271,25 +359,6 @@
|
|||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.expandBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 3px 10px;
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--subtle-bg);
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expandBtn:hover {
|
||||
color: var(--agent-blue-500);
|
||||
background: var(--agent-blue-100);
|
||||
}
|
||||
|
||||
.todoProgress {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
|
|
@ -301,6 +370,10 @@
|
|||
padding: 4px 0 2px 14px;
|
||||
}
|
||||
|
||||
.expandedCardBody .todoBody {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.compactGroup {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 14px;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export type MarkdownTableMode = 'basic' | 'advanced';
|
|||
|
||||
export type ToolHeaderKind =
|
||||
| 'agent'
|
||||
| 'ask'
|
||||
| 'edit'
|
||||
| 'fetch'
|
||||
| 'read'
|
||||
|
|
|
|||
|
|
@ -1507,13 +1507,22 @@ const EN: Messages = {
|
|||
'tool.expand': 'Expand',
|
||||
'tool.collapseHint': 'Collapse',
|
||||
'tool.status.failed': 'Failed',
|
||||
'tool.showAll': (v) => `▼ Show all (${v?.count ?? 0} lines)`,
|
||||
'tool.showLess': '▲ Show less',
|
||||
'tool.showFullLines': '▼ Show full lines',
|
||||
'tool.linesTotal': (v) => `▼ ${v?.count ?? 0} lines total`,
|
||||
'toolGroup.moreKinds': (v) => ` +${v?.count ?? 0}`,
|
||||
'toolGroup.summary': (v) =>
|
||||
`Ran ${v?.count ?? 0} tool${v?.count === 1 ? '' : 's'}`,
|
||||
'toolGroup.summary.editedFiles': (v) =>
|
||||
`Edited ${v?.count ?? 0} file${v?.count === 1 ? '' : 's'}`,
|
||||
'toolGroup.summary.ranCommands': (v) =>
|
||||
`Ran ${v?.count ?? 0} command${v?.count === 1 ? '' : 's'}`,
|
||||
'toolGroup.summary.readFiles': (v) =>
|
||||
`Read ${v?.count ?? 0} file${v?.count === 1 ? '' : 's'}`,
|
||||
'toolGroup.summary.searched': (v) =>
|
||||
`Searched ${v?.count ?? 0} time${v?.count === 1 ? '' : 's'}`,
|
||||
'toolGroup.summary.updatedTodos': (v) =>
|
||||
`Updated task list${v?.count === 1 ? '' : ` ${v?.count ?? 0} times`}`,
|
||||
'toolGroup.summary.askedUser': 'Asked user',
|
||||
'toolGroup.summary.otherTools': (v) =>
|
||||
`Called ${v?.count ?? 0} other tool${v?.count === 1 ? '' : 's'}`,
|
||||
'toolGroup.running': (v) =>
|
||||
`Running ${v?.name ?? 'tool'}${v?.duration ? ` ${v.duration}` : ''}${
|
||||
Number(v?.count ?? 0) > 1 ? ` · ${v?.count ?? 0} tools` : ''
|
||||
|
|
@ -1568,7 +1577,8 @@ const ZH: Messages = {
|
|||
'toolName.edit': '编辑',
|
||||
'toolName.write_file': '写入文件',
|
||||
'toolName.read_file': '读取文件',
|
||||
'toolName.grep_search': 'Grep',
|
||||
'toolName.grep': '搜索内容',
|
||||
'toolName.grep_search': '搜索内容',
|
||||
'toolName.glob': 'Glob',
|
||||
'toolName.run_shell_command': '运行命令',
|
||||
'toolName.todo_write': '任务清单',
|
||||
|
|
@ -1608,7 +1618,7 @@ const ZH: Messages = {
|
|||
'toolName.readfile': '读取文件',
|
||||
'toolName.write': '写入文件',
|
||||
'toolName.writefile': '写入文件',
|
||||
'toolName.search': 'Grep',
|
||||
'toolName.search': '搜索内容',
|
||||
'toolName.todowrite': '任务清单',
|
||||
'toolName.savememory': '保存记忆',
|
||||
'toolName.askuserquestion': '询问用户',
|
||||
|
|
@ -3009,12 +3019,18 @@ const ZH: Messages = {
|
|||
'tool.expand': '展开',
|
||||
'tool.collapseHint': '收起',
|
||||
'tool.status.failed': '执行失败',
|
||||
'tool.showAll': (v) => `▼ 显示全部(${v?.count ?? 0} 行)`,
|
||||
'tool.showLess': '▲ 显示更少',
|
||||
'tool.showFullLines': '▼ 显示完整行',
|
||||
'tool.linesTotal': (v) => `▼ 共 ${v?.count ?? 0} 行`,
|
||||
'toolGroup.moreKinds': (v) => ` +${v?.count ?? 0}`,
|
||||
'toolGroup.summary': (v) => `调用了 ${v?.count ?? 0} 个工具`,
|
||||
'toolGroup.summary.editedFiles': (v) => `已编辑 ${v?.count ?? 0} 个文件`,
|
||||
'toolGroup.summary.ranCommands': (v) => `已运行 ${v?.count ?? 0} 条命令`,
|
||||
'toolGroup.summary.readFiles': (v) => `已读取 ${v?.count ?? 0} 个文件`,
|
||||
'toolGroup.summary.searched': (v) => `已搜索 ${v?.count ?? 0} 次`,
|
||||
'toolGroup.summary.updatedTodos': (v) =>
|
||||
Number(v?.count ?? 0) > 1
|
||||
? `已更新任务清单 ${v?.count ?? 0} 次`
|
||||
: '已更新任务清单',
|
||||
'toolGroup.summary.askedUser': '已询问用户',
|
||||
'toolGroup.summary.otherTools': (v) => `调用了 ${v?.count ?? 0} 个工具`,
|
||||
'toolGroup.running': (v) =>
|
||||
`正在执行 ${v?.name ?? '工具'}${v?.duration ? ` ${v.duration}` : ''}${
|
||||
Number(v?.count ?? 0) > 1 ? ` · 共 ${v?.count ?? 0} 个工具` : ''
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue