Skip to content

Commit b968eb9

Browse files
eneufeldndoschek
andauthored
fix: improve tool call hover behavior and argument rendering (#16990)
* fix: improve tool call hover behavior and argument rendering - Narrow hover trigger from <summary> to args label <span> to prevent cascading hovers when scrolling through chat - Make hover interactive (sticky) so users can move into the tooltip, scroll, and select text - Format tooltip content smartly: short string values as plain text, long strings as code blocks with real line breaks, non-strings always as code blocks - Add scrollable hover styling scoped via cssClasses - Add getArgumentsShortLabel for all file changeset tools to produce short single-line labels (e.g., "Ran writeFileContent(test.ts …)") fixes: #16988 * treat strings and non string values the same * more review comments * fix: improve tool call argument tooltip rendering - Render values by type: inline code for short strings/primitives, code blocks for long/multiline strings, JSON blocks for objects and arrays - Expand array-of-object entries (e.g. replacements) into individual key-value sections with code blocks - Constrain hover dimensions and add scroll support for large code blocks - Add horizontal rule separators between top-level argument entries - Fix and extend tests * fix: anchor tooltip to entire summary element --------- Co-authored-by: Nina Doschek <ndoschek@eclipsesource.com>
1 parent 58f56e0 commit b968eb9

File tree

5 files changed

+378
-44
lines changed

5 files changed

+378
-44
lines changed

packages/ai-chat-ui/src/browser/chat-response-renderer/toolcall-part-renderer.spec.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// *****************************************************************************
1616

1717
import { expect } from 'chai';
18-
import { condenseArguments } from './toolcall-utils';
18+
import { condenseArguments, formatArgsForTooltip } from './toolcall-utils';
1919

2020
describe('condenseArguments', () => {
2121

@@ -130,3 +130,147 @@ describe('condenseArguments', () => {
130130
});
131131

132132
});
133+
134+
describe('formatArgsForTooltip', () => {
135+
136+
it('renders short single-line args as inline code', () => {
137+
const args = JSON.stringify({ path: 'test.ts' });
138+
const result = formatArgsForTooltip(args);
139+
expect(result.value).to.contain('**path:** `test.ts`');
140+
expect(result.value).to.not.contain('```');
141+
});
142+
143+
it('renders long single-line string as inline code (no newlines)', () => {
144+
const longPath = 'src/browser/chat-response-renderer/toolcall-utils.ts';
145+
const args = JSON.stringify({ path: longPath });
146+
const result = formatArgsForTooltip(args);
147+
expect(result.value).to.contain(`**path:** \`${longPath}\``);
148+
expect(result.value).to.not.contain('```');
149+
});
150+
151+
it('renders multi-line string as code block', () => {
152+
const multiLine = 'line one\nline two\nline three';
153+
const args = JSON.stringify({ content: multiLine });
154+
const result = formatArgsForTooltip(args);
155+
expect(result.value).to.contain('**content:**');
156+
expect(result.value).to.contain('```\n');
157+
expect(result.value).to.contain(multiLine);
158+
});
159+
160+
it('renders JSON object value as code block (serialization produces newlines)', () => {
161+
const args = JSON.stringify({ config: { nested: true, key: 'value' } });
162+
const result = formatArgsForTooltip(args);
163+
expect(result.value).to.contain('**config:**');
164+
expect(result.value).to.contain('```');
165+
});
166+
167+
it('renders array value as code block (serialization produces newlines)', () => {
168+
const bigArray = Array.from({ length: 5 }, (_, i) => i);
169+
const args = JSON.stringify({ data: bigArray });
170+
const result = formatArgsForTooltip(args);
171+
expect(result.value).to.contain('**data:**');
172+
expect(result.value).to.contain('```');
173+
});
174+
175+
it('renders simple non-string values as inline code (no code blocks)', () => {
176+
const args = '{"count": 42, "enabled": true, "value": null}';
177+
const result = formatArgsForTooltip(args);
178+
expect(result.value).to.contain('**count:** `42`');
179+
expect(result.value).to.contain('**enabled:** `true`');
180+
expect(result.value).to.contain('**value:** `null`');
181+
expect(result.value).to.not.contain('```');
182+
});
183+
184+
it('renders mixed single-line and multi-line values correctly', () => {
185+
const args = JSON.stringify({ path: 'test.ts', content: 'line1\nline2' });
186+
const result = formatArgsForTooltip(args);
187+
expect(result.value).to.contain('**path:** `test.ts`');
188+
expect(result.value).to.contain('**content:**');
189+
expect(result.value).to.contain('```');
190+
});
191+
192+
it('falls back to code block for short unparseable JSON', () => {
193+
const invalidJson = 'not json at all';
194+
const result = formatArgsForTooltip(invalidJson);
195+
expect(result.value).to.contain('```');
196+
expect(result.value).to.contain('not json at all');
197+
});
198+
199+
it('falls back to code block for multi-line unparseable JSON', () => {
200+
const invalidJson = 'not json\nat all';
201+
const result = formatArgsForTooltip(invalidJson);
202+
expect(result.value).to.contain('```');
203+
expect(result.value).to.contain(invalidJson);
204+
});
205+
206+
it('handles top-level non-object parsed value as code block', () => {
207+
const longString = '"a string that is longer than fifty characters total and keeps going"';
208+
expect(longString.length).to.be.greaterThan(50);
209+
const result = formatArgsForTooltip(longString);
210+
expect(result.value).to.contain('```');
211+
expect(result.value).to.contain('a string that is longer than fifty characters total and keeps going');
212+
});
213+
214+
it('handles top-level array as code block (serialization produces newlines)', () => {
215+
const args = JSON.stringify([1, 2, 3]);
216+
const result = formatArgsForTooltip(args);
217+
expect(result.value).to.contain('```');
218+
});
219+
220+
it('separates top-level entries with horizontal rules', () => {
221+
const args = JSON.stringify({ path: 'test.ts', enabled: true });
222+
const result = formatArgsForTooltip(args);
223+
expect(result.value).to.contain('---');
224+
});
225+
226+
it('renders primitive array as JSON code block', () => {
227+
const args = JSON.stringify({ tags: ['a', 'b', 'c'] });
228+
const result = formatArgsForTooltip(args);
229+
expect(result.value).to.contain('**tags:**');
230+
expect(result.value).to.contain('```');
231+
expect(result.value).to.contain('"a"');
232+
expect(result.value).to.contain('"b"');
233+
});
234+
235+
it('expands array of objects with string values into sections', () => {
236+
const args = JSON.stringify({
237+
replacements: [
238+
{ oldContent: 'line one\nline two', newContent: 'line three\nline four' }
239+
]
240+
});
241+
const result = formatArgsForTooltip(args);
242+
expect(result.value).to.contain('**oldContent:**');
243+
expect(result.value).to.contain('**newContent:**');
244+
expect(result.value).to.contain('line one\nline two');
245+
expect(result.value).to.contain('line three\nline four');
246+
});
247+
248+
it('renders short strings as code blocks inside array items', () => {
249+
const args = JSON.stringify({
250+
replacements: [
251+
{ oldContent: '// 2024', newContent: '// 2025' }
252+
]
253+
});
254+
const result = formatArgsForTooltip(args);
255+
expect(result.value).to.contain('**oldContent:**');
256+
expect(result.value).to.contain('**newContent:**');
257+
expect(result.value).to.contain('// 2024');
258+
expect(result.value).to.contain('// 2025');
259+
// Both values should be in code blocks, not inline code
260+
const codeBlockCount = (result.value.match(/```/g) || []).length;
261+
expect(codeBlockCount).to.be.greaterThanOrEqual(4); // 2 code blocks = 4 fences
262+
});
263+
264+
it('numbers array items when there are multiple', () => {
265+
const args = JSON.stringify({
266+
replacements: [
267+
{ old: 'aaa\nbbb', new: 'ccc\nddd' },
268+
{ old: 'eee\nfff', new: 'ggg\nhhh' }
269+
]
270+
});
271+
const result = formatArgsForTooltip(args);
272+
expect(result.value).to.contain('\\[0\\]');
273+
expect(result.value).to.contain('\\[1\\]');
274+
});
275+
276+
});

packages/ai-chat-ui/src/browser/chat-response-renderer/toolcall-part-renderer.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ import { ResponseNode } from '../chat-tree-view';
2727
import { useMarkdownRendering } from './markdown-part-renderer';
2828
import { ToolCallResult, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
2929
import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
30-
import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
31-
import { condenseArguments } from './toolcall-utils';
30+
import { condenseArguments, formatArgsForTooltip } from './toolcall-utils';
3231

3332
@injectable()
3433
export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@@ -143,23 +142,15 @@ export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallCh
143142
if (!target || !response.arguments || !response.arguments.trim() || response.arguments.trim() === '{}') {
144143
return;
145144
}
146-
const prettyArgs = this.prettyPrintArgs(response.arguments);
147-
const markdownString = new MarkdownStringImpl(`**${response.name}**\n`).appendCodeblock('json', prettyArgs);
145+
const markdownString = formatArgsForTooltip(response.arguments);
148146
this.hoverService.requestHover({
149147
content: markdownString,
150148
target,
151-
position: 'right'
149+
position: 'right',
150+
interactive: true,
151+
cssClasses: ['toolcall-args-hover']
152152
});
153153
}
154-
155-
private prettyPrintArgs(args: string): string {
156-
try {
157-
return JSON.stringify(JSON.parse(args), undefined, 2);
158-
} catch (e) {
159-
// fall through
160-
return args;
161-
}
162-
}
163154
}
164155

165156
const Spinner = () => (
@@ -196,8 +187,7 @@ const ToolCallContent: React.FC<ToolCallContentProps> = ({
196187
}) => {
197188
const [confirmationState, setConfirmationState] = React.useState<ToolConfirmationState>('waiting');
198189
const [rejectionReason, setRejectionReason] = React.useState<unknown>(undefined);
199-
// eslint-disable-next-line no-null/no-null
200-
const summaryRef = React.useRef<HTMLElement>(null);
190+
const summaryRef = React.useRef<HTMLElement | undefined>(undefined);
201191

202192
const formatReason = (reason: unknown): string => {
203193
if (!reason) {
@@ -278,8 +268,8 @@ const ToolCallContent: React.FC<ToolCallContentProps> = ({
278268
) : response.finished ? (
279269
<details className='theia-toolCall-finished'>
280270
<summary
281-
ref={summaryRef}
282-
onMouseEnter={() => showArgsTooltip(response, summaryRef.current ?? undefined)}
271+
ref={(el: HTMLElement | null) => { summaryRef.current = el ?? undefined; }}
272+
onMouseEnter={() => showArgsTooltip(response, summaryRef.current)}
283273
>
284274
{nls.localize('theia/ai/chat-ui/toolcall-part-renderer/finished', 'Ran')} {response.name}
285275
(<span className='theia-toolCall-args-label'>{getArgumentsLabel(response.name, response.arguments)}</span>)

0 commit comments

Comments
 (0)