@theia/ai-chat-ui 1.72.1 → 1.72.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/lib/browser/ai-chat-ui-frontend-module.d.ts.map +1 -1
  2. package/lib/browser/ai-chat-ui-frontend-module.js +4 -0
  3. package/lib/browser/ai-chat-ui-frontend-module.js.map +1 -1
  4. package/lib/browser/chat-response-renderer/delegation-tool-renderer.d.ts +8 -1
  5. package/lib/browser/chat-response-renderer/delegation-tool-renderer.d.ts.map +1 -1
  6. package/lib/browser/chat-response-renderer/delegation-tool-renderer.js +30 -1
  7. package/lib/browser/chat-response-renderer/delegation-tool-renderer.js.map +1 -1
  8. package/lib/browser/chat-response-renderer/tool-confirmation.d.ts +15 -1
  9. package/lib/browser/chat-response-renderer/tool-confirmation.d.ts.map +1 -1
  10. package/lib/browser/chat-response-renderer/tool-confirmation.js +60 -17
  11. package/lib/browser/chat-response-renderer/tool-confirmation.js.map +1 -1
  12. package/lib/browser/chat-response-renderer/toolcall-part-renderer.d.ts +8 -1
  13. package/lib/browser/chat-response-renderer/toolcall-part-renderer.d.ts.map +1 -1
  14. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js +31 -5
  15. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js.map +1 -1
  16. package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js +102 -0
  17. package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js.map +1 -1
  18. package/lib/browser/chat-view-widget.d.ts +1 -0
  19. package/lib/browser/chat-view-widget.d.ts.map +1 -1
  20. package/lib/browser/chat-view-widget.js +3 -0
  21. package/lib/browser/chat-view-widget.js.map +1 -1
  22. package/lib/browser/tool-confirmation-keybinding-contribution.d.ts +25 -0
  23. package/lib/browser/tool-confirmation-keybinding-contribution.d.ts.map +1 -0
  24. package/lib/browser/tool-confirmation-keybinding-contribution.js +106 -0
  25. package/lib/browser/tool-confirmation-keybinding-contribution.js.map +1 -0
  26. package/package.json +12 -12
  27. package/src/browser/ai-chat-ui-frontend-module.ts +5 -0
  28. package/src/browser/chat-response-renderer/delegation-tool-renderer.tsx +34 -3
  29. package/src/browser/chat-response-renderer/tool-confirmation.tsx +122 -30
  30. package/src/browser/chat-response-renderer/toolcall-part-renderer.spec.ts +127 -0
  31. package/src/browser/chat-response-renderer/toolcall-part-renderer.tsx +51 -6
  32. package/src/browser/chat-view-widget.tsx +4 -0
  33. package/src/browser/style/index.css +9 -0
  34. package/src/browser/tool-confirmation-keybinding-contribution.ts +104 -0
@@ -18,15 +18,20 @@ import { inject, injectable, named } from '@theia/core/shared/inversify';
18
18
  import { ChatRequestInvocation, ChatResponseContent, ChatResponseModel, InteractiveContent, ToolCallChatResponseContent } from '@theia/ai-chat';
19
19
  import { ChatAgentService } from '@theia/ai-chat/lib/common/chat-agent-service';
20
20
  import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
21
+ import { PendingToolConfirmationTracker } from '@theia/ai-chat/lib/browser/pending-tool-confirmation-tracker';
21
22
  import { AGENT_DELEGATION_FUNCTION_ID } from '@theia/ai-core/lib/common/tool-constants';
22
23
  import { ToolInvocationRegistry } from '@theia/ai-core';
23
24
  import { AgentDelegationTool } from '@theia/ai-chat/lib/browser/agent-delegation-tool';
24
25
  import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
25
26
  import { ResponseNode } from '../chat-tree-view';
26
27
  import { SubChatWidgetFactory } from '../chat-tree-view/sub-chat-widget';
27
- import { withToolCallConfirmation } from './tool-confirmation';
28
+ import { ToolConfirmationKeybindingHints, withToolCallConfirmation } from './tool-confirmation';
29
+ import {
30
+ APPROVE_LATEST_TOOL_CONFIRMATION_COMMAND,
31
+ DENY_LATEST_TOOL_CONFIRMATION_COMMAND
32
+ } from '../tool-confirmation-keybinding-contribution';
28
33
  import { extractJsonStringField } from './toolcall-utils';
29
- import { CompositeTreeNode, ContextMenuRenderer, OpenerService } from '@theia/core/lib/browser';
34
+ import { CompositeTreeNode, ContextMenuRenderer, KeybindingRegistry, MarkdownRenderer, OpenerService } from '@theia/core/lib/browser';
30
35
  import { ContributionProvider, DisposableCollection, nls } from '@theia/core';
31
36
  import * as React from '@theia/core/shared/react';
32
37
 
@@ -54,6 +59,15 @@ export class DelegationToolRenderer implements ChatResponsePartRenderer<ToolCall
54
59
  @inject(OpenerService)
55
60
  protected openerService: OpenerService;
56
61
 
62
+ @inject(PendingToolConfirmationTracker)
63
+ protected pendingToolConfirmationTracker: PendingToolConfirmationTracker;
64
+
65
+ @inject(KeybindingRegistry)
66
+ protected keybindingRegistry: KeybindingRegistry;
67
+
68
+ @inject(MarkdownRenderer)
69
+ protected markdownRenderer: MarkdownRenderer;
70
+
57
71
  @inject(ContributionProvider) @named(ChatResponsePartRenderer)
58
72
  protected chatResponsePartRenderers: ContributionProvider<ChatResponsePartRenderer<ChatResponseContent>>;
59
73
 
@@ -111,10 +125,27 @@ export class DelegationToolRenderer implements ChatResponsePartRenderer<ToolCall
111
125
  chatId,
112
126
  requestCanceled: parentNode.response.isCanceled,
113
127
  contextMenuRenderer: this.contextMenuRenderer,
114
- openerService: this.openerService
128
+ openerService: this.openerService,
129
+ pendingTracker: this.pendingToolConfirmationTracker,
130
+ keybindingHints: this.getKeybindingHints(),
131
+ markdownRenderer: this.markdownRenderer
115
132
  }}
116
133
  />;
117
134
  }
135
+
136
+ protected getKeybindingHints(): ToolConfirmationKeybindingHints {
137
+ const allow = this.formatKeybinding(APPROVE_LATEST_TOOL_CONFIRMATION_COMMAND.id);
138
+ const deny = this.formatKeybinding(DENY_LATEST_TOOL_CONFIRMATION_COMMAND.id);
139
+ return { allow, deny };
140
+ }
141
+
142
+ protected formatKeybinding(commandId: string): string | undefined {
143
+ const bindings = this.keybindingRegistry.getKeybindingsForCommand(commandId);
144
+ if (!bindings.length) {
145
+ return undefined;
146
+ }
147
+ return this.keybindingRegistry.acceleratorFor(bindings[0], '+').join('+');
148
+ }
118
149
  }
119
150
 
120
151
  interface PendingInteraction {
@@ -16,13 +16,14 @@
16
16
 
17
17
  import * as React from '@theia/core/shared/react';
18
18
  import { nls } from '@theia/core/lib/common/nls';
19
- import { codicon, ContextMenuRenderer, OpenerService } from '@theia/core/lib/browser';
19
+ import { codicon, ContextMenuRenderer, LocalizedMarkdown, MarkdownRenderer, OpenerService } from '@theia/core/lib/browser';
20
20
  import { ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
21
21
  import { ToolRequest } from '@theia/ai-core';
22
22
  import { CommandMenu, ContextExpressionMatcher, MenuPath } from '@theia/core/lib/common/menu';
23
23
  import { GroupImpl } from '@theia/core/lib/browser/menu/composite-menu-node';
24
24
  import { ToolConfirmationMode as ToolConfirmationPreferenceMode } from '@theia/ai-chat/lib/common/chat-tool-preferences';
25
25
  import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
26
+ import { PendingToolConfirmationTracker } from '@theia/ai-chat/lib/browser/pending-tool-confirmation-tracker';
26
27
  import { MarkdownRender } from './markdown-part-renderer';
27
28
  import { condenseArguments, formatArgsForTooltip } from './toolcall-utils';
28
29
 
@@ -160,9 +161,16 @@ export interface ToolConfirmationCallbacks {
160
161
  onDeny: (scope: ConfirmationScope, reason?: string) => void;
161
162
  }
162
163
 
164
+ export interface ToolConfirmationKeybindingHints {
165
+ allow?: string;
166
+ deny?: string;
167
+ }
168
+
163
169
  export interface ToolConfirmationActionsProps extends ToolConfirmationCallbacks {
164
170
  toolName: string;
165
171
  contextMenuRenderer: ContextMenuRenderer;
172
+ autoFocus?: boolean;
173
+ keybindingHints?: ToolConfirmationKeybindingHints;
166
174
  }
167
175
 
168
176
  export class InlineActionMenuNode implements CommandMenu {
@@ -196,7 +204,9 @@ export const ToolConfirmationActions: React.FC<ToolConfirmationActionsProps> = (
196
204
  toolRequest,
197
205
  onAllow,
198
206
  onDeny,
199
- contextMenuRenderer
207
+ contextMenuRenderer,
208
+ autoFocus,
209
+ keybindingHints
200
210
  }) => {
201
211
  const [allowScope, setAllowScope] = React.useState<ConfirmationScope>('once');
202
212
  const [denyScope, setDenyScope] = React.useState<ConfirmationScope>('once');
@@ -205,6 +215,14 @@ export const ToolConfirmationActions: React.FC<ToolConfirmationActionsProps> = (
205
215
  const [denyReason, setDenyReason] = React.useState('');
206
216
  // eslint-disable-next-line no-null/no-null
207
217
  const denyReasonInputRef = React.useRef<HTMLInputElement>(null);
218
+ // eslint-disable-next-line no-null/no-null
219
+ const allowButtonRef = React.useRef<HTMLButtonElement>(null);
220
+
221
+ React.useEffect(() => {
222
+ if (autoFocus && allowButtonRef.current) {
223
+ allowButtonRef.current.focus();
224
+ }
225
+ }, [autoFocus]);
208
226
 
209
227
  const handleAllow = React.useCallback(() => {
210
228
  if ((allowScope === 'forever' || allowScope === 'session') && toolRequest?.confirmAlwaysAllow) {
@@ -334,6 +352,9 @@ export const ToolConfirmationActions: React.FC<ToolConfirmationActionsProps> = (
334
352
  const selectedScope = type === 'allow' ? allowScope : denyScope;
335
353
  const setScope = type === 'allow' ? setAllowScope : setDenyScope;
336
354
  const handleMain = type === 'allow' ? handleAllow : handleDeny;
355
+ const keybindingHint = type === 'allow' ? keybindingHints?.allow : keybindingHints?.deny;
356
+ const mainLabel = scopeLabel(type, selectedScope);
357
+ const mainTitle = keybindingHint && selectedScope === 'once' ? `${mainLabel} (${keybindingHint})` : undefined;
337
358
 
338
359
  return (
339
360
  <div
@@ -341,10 +362,12 @@ export const ToolConfirmationActions: React.FC<ToolConfirmationActionsProps> = (
341
362
  style={{ display: 'inline-flex', position: 'relative' }}
342
363
  >
343
364
  <button
365
+ ref={type === 'allow' ? allowButtonRef : undefined}
344
366
  className={`theia-button ${type === 'allow' ? 'main' : 'secondary'} theia-tool-confirmation-main-btn`}
345
367
  onClick={handleMain}
368
+ title={mainTitle}
346
369
  >
347
- {scopeLabel(type, selectedScope)}
370
+ {mainLabel}
348
371
  </button>
349
372
  <button
350
373
  className={`theia-button ${type === 'allow' ? 'main' : 'secondary'} theia-tool-confirmation-chevron-btn`}
@@ -433,10 +456,45 @@ export interface ToolConfirmationProps extends Pick<ToolConfirmationCallbacks, '
433
456
  onDeny: (scope?: ConfirmationScope, reason?: string) => void;
434
457
  contextMenuRenderer: ContextMenuRenderer;
435
458
  openerService: OpenerService;
459
+ pendingTracker?: PendingToolConfirmationTracker;
460
+ keybindingHints?: ToolConfirmationKeybindingHints;
461
+ chatId?: string;
462
+ markdownRenderer?: MarkdownRenderer;
436
463
  }
437
464
 
438
- export const ToolConfirmation: React.FC<ToolConfirmationProps> = ({ response, toolRequest, onAllow, onDeny, contextMenuRenderer, openerService }) => {
465
+ /**
466
+ * Command that opens the AI Configuration view on the Tools tab; its id is duplicated here as a
467
+ * string constant to keep ai-chat-ui free of an ai-ide dependency.
468
+ */
469
+ const OPEN_TOOLS_CONFIGURATION_COMMAND_ID = 'aiConfiguration:openTools';
470
+
471
+ const ToolConfirmationIntro: React.FC<{ markdownRenderer: MarkdownRenderer }> = ({ markdownRenderer }) => (
472
+ <div className="theia-tool-confirmation-intro">
473
+ <LocalizedMarkdown
474
+ localizationKey="theia/ai/chat-ui/toolconfirmation/intro"
475
+ defaultMarkdown={
476
+ 'AI agents may want to use tools to act on your workspace. ' +
477
+ 'By default each tool call needs your confirmation. ' +
478
+ 'You can change this default or pre-approve individual tools in the [Tools configuration view]({0}).'
479
+ }
480
+ args={[`command:${OPEN_TOOLS_CONFIGURATION_COMMAND_ID}`]}
481
+ markdownRenderer={markdownRenderer}
482
+ markdownOptions={{
483
+ isTrusted: { enabledCommands: [OPEN_TOOLS_CONFIGURATION_COMMAND_ID] }
484
+ }}
485
+ />
486
+ </div>
487
+ );
488
+
489
+ export const ToolConfirmation: React.FC<ToolConfirmationProps> = ({
490
+ response, toolRequest, onAllow, onDeny, contextMenuRenderer, openerService, pendingTracker, keybindingHints, chatId, markdownRenderer
491
+ }) => {
439
492
  const [state, setState] = React.useState<ToolConfirmationState>('waiting');
493
+ // Pure initializer (no side effects): decide whether the intro should render. Marking the chat
494
+ // as "intro shown" happens in the effect below to keep the initializer pure for StrictMode.
495
+ const [showIntro] = React.useState<boolean>(
496
+ () => !!(pendingTracker && chatId && markdownRenderer && !pendingTracker.hasShownIntro(chatId))
497
+ );
440
498
 
441
499
  const handleAllow = React.useCallback((scope: ConfirmationScope) => {
442
500
  setState('allowed');
@@ -448,6 +506,25 @@ export const ToolConfirmation: React.FC<ToolConfirmationProps> = ({ response, to
448
506
  onDeny(scope, reason);
449
507
  }, [onDeny]);
450
508
 
509
+ React.useEffect(() => {
510
+ if (showIntro && pendingTracker && chatId) {
511
+ pendingTracker.markIntroShown(chatId);
512
+ }
513
+ }, []);
514
+
515
+ React.useEffect(() => {
516
+ if (!pendingTracker || !chatId || state !== 'waiting') {
517
+ return;
518
+ }
519
+ const disposable = pendingTracker.register({
520
+ chatId,
521
+ response,
522
+ allow: () => handleAllow('once'),
523
+ deny: () => handleDeny('once')
524
+ });
525
+ return () => disposable.dispose();
526
+ }, [pendingTracker, chatId, response, handleAllow, handleDeny, state]);
527
+
451
528
  if (state === 'allowed') {
452
529
  return (
453
530
  <div className="theia-tool-confirmation-status allowed">
@@ -472,32 +549,37 @@ export const ToolConfirmation: React.FC<ToolConfirmationProps> = ({ response, to
472
549
  );
473
550
 
474
551
  return (
475
- <div className="theia-tool-confirmation">
476
- <div className="theia-tool-confirmation-header">
477
- <span className={codicon('shield')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/header', 'Confirm Tool Execution')}
478
- </div>
479
- <div className="theia-tool-confirmation-info">
480
- {toolRequest?.description ? (
481
- <details className="theia-tool-confirmation-name">
482
- <summary>{toolNameContent}</summary>
483
- <div className="theia-tool-confirmation-description">
484
- {toolRequest.description}
485
- </div>
486
- </details>
487
- ) : (
488
- <div className="theia-tool-confirmation-name">{toolNameContent}</div>
489
- )}
490
- <ToolArgsDisplay args={response.arguments} openerService={openerService} />
552
+ <>
553
+ {showIntro && markdownRenderer && <ToolConfirmationIntro markdownRenderer={markdownRenderer} />}
554
+ <div className="theia-tool-confirmation">
555
+ <div className="theia-tool-confirmation-header">
556
+ <span className={codicon('shield')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/header', 'Confirm Tool Execution')}
557
+ </div>
558
+ <div className="theia-tool-confirmation-info">
559
+ {toolRequest?.description ? (
560
+ <details className="theia-tool-confirmation-name">
561
+ <summary>{toolNameContent}</summary>
562
+ <div className="theia-tool-confirmation-description">
563
+ {toolRequest.description}
564
+ </div>
565
+ </details>
566
+ ) : (
567
+ <div className="theia-tool-confirmation-name">{toolNameContent}</div>
568
+ )}
569
+ <ToolArgsDisplay args={response.arguments} openerService={openerService} />
570
+ </div>
571
+ <ToolConfirmationActions
572
+ toolName={response.name ?? 'unknown'}
573
+ toolRequest={toolRequest}
574
+ onAllow={handleAllow}
575
+ onDeny={handleDeny}
576
+ contextMenuRenderer={contextMenuRenderer}
577
+ autoFocus={true}
578
+ keybindingHints={keybindingHints}
579
+ />
580
+ <CountdownTimer response={response} />
491
581
  </div>
492
- <ToolConfirmationActions
493
- toolName={response.name ?? 'unknown'}
494
- toolRequest={toolRequest}
495
- onAllow={handleAllow}
496
- onDeny={handleDeny}
497
- contextMenuRenderer={contextMenuRenderer}
498
- />
499
- <CountdownTimer response={response} />
500
- </div>
582
+ </>
501
583
  );
502
584
  };
503
585
 
@@ -539,6 +621,9 @@ export interface WithToolCallConfirmationProps {
539
621
  requestCanceled: boolean;
540
622
  contextMenuRenderer: ContextMenuRenderer;
541
623
  openerService: OpenerService;
624
+ pendingTracker?: PendingToolConfirmationTracker;
625
+ keybindingHints?: ToolConfirmationKeybindingHints;
626
+ markdownRenderer?: MarkdownRenderer;
542
627
  }
543
628
 
544
629
  export function withToolCallConfirmation<P extends object>(
@@ -559,7 +644,10 @@ export function withToolCallConfirmation<P extends object>(
559
644
  showArgsTooltip,
560
645
  requestCanceled,
561
646
  contextMenuRenderer,
562
- openerService
647
+ openerService,
648
+ pendingTracker,
649
+ keybindingHints,
650
+ markdownRenderer
563
651
  } = toolConfirmation;
564
652
 
565
653
  const { confirmationState } = useToolConfirmationState(response, confirmationMode);
@@ -622,6 +710,10 @@ export function withToolCallConfirmation<P extends object>(
622
710
  onDeny={handleDeny}
623
711
  contextMenuRenderer={contextMenuRenderer}
624
712
  openerService={openerService}
713
+ pendingTracker={pendingTracker}
714
+ keybindingHints={keybindingHints}
715
+ chatId={chatId}
716
+ markdownRenderer={markdownRenderer}
625
717
  />
626
718
  );
627
719
  }
@@ -15,6 +15,10 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
+ import { ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
19
+ import { OpenerService } from '@theia/core/lib/browser';
20
+ import { ReactNode } from '@theia/core/shared/react';
21
+ import { ToolCallPartRenderer } from './toolcall-part-renderer';
18
22
  import { condenseArguments, formatArgsForTooltip } from './toolcall-utils';
19
23
 
20
24
  describe('condenseArguments', () => {
@@ -274,3 +278,126 @@ describe('formatArgsForTooltip', () => {
274
278
  });
275
279
 
276
280
  });
281
+
282
+ /**
283
+ * Test-only subclass that exposes the protected `renderResult` method and
284
+ * accepts a minimal `OpenerService` stub. `renderResult` only references the
285
+ * opener service indirectly through `<MarkdownRender>`, and the React tree is
286
+ * never mounted in these unit tests, so an empty stub is sufficient.
287
+ */
288
+ class TestableToolCallPartRenderer extends ToolCallPartRenderer {
289
+ constructor() {
290
+ super();
291
+ this.openerService = {} as OpenerService;
292
+ }
293
+ callRenderResult(response: ToolCallChatResponseContent): ReactNode {
294
+ return this.renderResult(response);
295
+ }
296
+ }
297
+
298
+ interface RenderedNode {
299
+ type: string;
300
+ props: { children?: unknown; className?: string };
301
+ }
302
+
303
+ describe('ToolCallPartRenderer.renderResult', () => {
304
+
305
+ function renderResult(result: unknown): RenderedNode | undefined {
306
+ const renderer = new TestableToolCallPartRenderer();
307
+ const response = { kind: 'toolCall', result } as unknown as ToolCallChatResponseContent;
308
+ return renderer.callRenderResult(response) as RenderedNode | undefined;
309
+ }
310
+
311
+ it('returns undefined for an undefined result', () => {
312
+ expect(renderResult(undefined)).to.be.undefined;
313
+ });
314
+
315
+ it('returns undefined for an empty-string result (falsy after tryParse)', () => {
316
+ expect(renderResult('')).to.be.undefined;
317
+ });
318
+
319
+ it('renders primitive numeric result via <pre>{String(result)}</pre>', () => {
320
+ const node = renderResult(42)!;
321
+ expect(node.type).to.equal('pre');
322
+ expect(node.props.children).to.equal('42');
323
+ });
324
+
325
+ it('renders unparseable string result via <pre>{String(result)}</pre>', () => {
326
+ const node = renderResult('not json at all')!;
327
+ expect(node.type).to.equal('pre');
328
+ expect(node.props.children).to.equal('not json at all');
329
+ });
330
+
331
+ it('renders proper ToolCallContent (array) via the response-content branch', () => {
332
+ const node = renderResult({ content: [{ type: 'text', text: 'hello' }] })!;
333
+ expect(node.type).to.equal('div');
334
+ expect(node.props.className).to.equal('theia-toolCall-response-content');
335
+ });
336
+
337
+ it('renders ToolCallContent that arrived as a JSON string via the response-content branch', () => {
338
+ const node = renderResult(JSON.stringify({ content: [{ type: 'text', text: 'hello' }] }))!;
339
+ expect(node.type).to.equal('div');
340
+ expect(node.props.className).to.equal('theia-toolCall-response-content');
341
+ });
342
+
343
+ // Regression: an object whose `content` field is not an array must not crash.
344
+ // This is the exact shape produced by `getWorkspaceFileList` (after multi-root
345
+ // support landed in v1.72.0) when a workspace entry happens to be literally
346
+ // named "content" (e.g. Hugo sites).
347
+ it('falls back to <pre>JSON</pre> when result has a non-array "content" key (regression)', () => {
348
+ const result = {
349
+ '.devcontainer': 'directory',
350
+ 'config.yaml': 'file',
351
+ 'content': 'directory',
352
+ 'go.mod': 'file'
353
+ };
354
+ const node = renderResult(result)!;
355
+ expect(node.type).to.equal('pre');
356
+ expect(node.props.children).to.equal(JSON.stringify(result, undefined, 2));
357
+ });
358
+
359
+ it('falls back to <pre>JSON</pre> when content is a string (regression)', () => {
360
+ const result = { content: 'directory' };
361
+ const node = renderResult(result)!;
362
+ expect(node.type).to.equal('pre');
363
+ expect(node.props.children).to.equal(JSON.stringify(result, undefined, 2));
364
+ });
365
+
366
+ it('falls back to <pre>JSON</pre> when content is an object (regression)', () => {
367
+ const result = { content: { nested: true } };
368
+ const node = renderResult(result)!;
369
+ expect(node.type).to.equal('pre');
370
+ expect(node.props.children).to.equal(JSON.stringify(result, undefined, 2));
371
+ });
372
+
373
+ it('falls back to <pre>JSON</pre> when content is a number (regression)', () => {
374
+ const result = { content: 42 };
375
+ const node = renderResult(result)!;
376
+ expect(node.type).to.equal('pre');
377
+ expect(node.props.children).to.equal(JSON.stringify(result, undefined, 2));
378
+ });
379
+
380
+ it('falls back to <pre>JSON</pre> for JSON-string result whose parsed object has a non-array content key (regression)', () => {
381
+ // Byte-for-byte shape persisted by getWorkspaceFileList for a Hugo workspace.
382
+ const raw = '{".devcontainer":"directory","config.yaml":"file","content":"directory","go.mod":"file"}';
383
+ const node = renderResult(raw)!;
384
+ expect(node.type).to.equal('pre');
385
+ // The fallback stringifies the *parsed* object, not the original string.
386
+ expect(node.props.children).to.equal(JSON.stringify(JSON.parse(raw), undefined, 2));
387
+ });
388
+
389
+ it('falls back to <pre>JSON</pre> for plain objects without a content key', () => {
390
+ const result = { files: [] };
391
+ const node = renderResult(result)!;
392
+ expect(node.type).to.equal('pre');
393
+ expect(node.props.children).to.equal(JSON.stringify(result, undefined, 2));
394
+ });
395
+
396
+ it('falls back to <pre>JSON</pre> for top-level array results', () => {
397
+ const result = [{ file: 'a', matches: [] }];
398
+ const node = renderResult(result)!;
399
+ expect(node.type).to.equal('pre');
400
+ expect(node.props.children).to.equal(JSON.stringify(result, undefined, 2));
401
+ });
402
+
403
+ });
@@ -19,14 +19,19 @@ import { inject, injectable } from '@theia/core/shared/inversify';
19
19
  import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
20
20
  import { ReactNode } from '@theia/core/shared/react';
21
21
  import { nls } from '@theia/core/lib/common/nls';
22
- import { codicon, ContextMenuRenderer, HoverService, OpenerService } from '@theia/core/lib/browser';
22
+ import { codicon, ContextMenuRenderer, HoverService, KeybindingRegistry, MarkdownRenderer, OpenerService } from '@theia/core/lib/browser';
23
23
  import * as React from '@theia/core/shared/react';
24
- import { createConfirmationHandlers, ToolConfirmation, useToolConfirmationState } from './tool-confirmation';
24
+ import { createConfirmationHandlers, ToolConfirmation, ToolConfirmationKeybindingHints, useToolConfirmationState } from './tool-confirmation';
25
25
  import { ToolConfirmationMode } from '@theia/ai-chat/lib/common/chat-tool-preferences';
26
26
  import { ResponseNode } from '../chat-tree-view';
27
27
  import { MarkdownRender } from './markdown-part-renderer';
28
- import { ToolCallResult, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
28
+ import { isToolCallContent, ToolCallResult, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
29
29
  import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
30
+ import { PendingToolConfirmationTracker } from '@theia/ai-chat/lib/browser/pending-tool-confirmation-tracker';
31
+ import {
32
+ APPROVE_LATEST_TOOL_CONFIRMATION_COMMAND,
33
+ DENY_LATEST_TOOL_CONFIRMATION_COMMAND
34
+ } from '../tool-confirmation-keybinding-contribution';
30
35
  import { condenseArguments, formatArgsForTooltip } from './toolcall-utils';
31
36
 
32
37
  @injectable()
@@ -47,6 +52,15 @@ export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallCh
47
52
  @inject(ContextMenuRenderer)
48
53
  protected contextMenuRenderer: ContextMenuRenderer;
49
54
 
55
+ @inject(PendingToolConfirmationTracker)
56
+ protected pendingToolConfirmationTracker: PendingToolConfirmationTracker;
57
+
58
+ @inject(KeybindingRegistry)
59
+ protected keybindingRegistry: KeybindingRegistry;
60
+
61
+ @inject(MarkdownRenderer)
62
+ protected markdownRenderer: MarkdownRenderer;
63
+
50
64
  canHandle(response: ChatResponseContent): number {
51
65
  if (ToolCallChatResponseContent.is(response)) {
52
66
  return 10;
@@ -68,6 +82,10 @@ export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallCh
68
82
  onDeny={handleDeny}
69
83
  contextMenuRenderer={this.contextMenuRenderer}
70
84
  openerService={this.openerService}
85
+ pendingTracker={this.pendingToolConfirmationTracker}
86
+ keybindingHints={this.getKeybindingHints()}
87
+ chatId={chatId}
88
+ markdownRenderer={this.markdownRenderer}
71
89
  />;
72
90
  }
73
91
 
@@ -86,7 +104,24 @@ export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallCh
86
104
  responseRenderer={this.renderResult.bind(this)}
87
105
  requestCanceled={parentNode.response.isCanceled}
88
106
  contextMenuRenderer={this.contextMenuRenderer}
89
- openerService={this.openerService} />;
107
+ openerService={this.openerService}
108
+ pendingTracker={this.pendingToolConfirmationTracker}
109
+ keybindingHints={this.getKeybindingHints()}
110
+ markdownRenderer={this.markdownRenderer} />;
111
+ }
112
+
113
+ protected getKeybindingHints(): ToolConfirmationKeybindingHints {
114
+ const allow = this.formatKeybinding(APPROVE_LATEST_TOOL_CONFIRMATION_COMMAND.id);
115
+ const deny = this.formatKeybinding(DENY_LATEST_TOOL_CONFIRMATION_COMMAND.id);
116
+ return { allow, deny };
117
+ }
118
+
119
+ protected formatKeybinding(commandId: string): string | undefined {
120
+ const bindings = this.keybindingRegistry.getKeybindingsForCommand(commandId);
121
+ if (!bindings.length) {
122
+ return undefined;
123
+ }
124
+ return this.keybindingRegistry.acceleratorFor(bindings[0], '+').join('+');
90
125
  }
91
126
 
92
127
  protected renderResult(response: ToolCallChatResponseContent): ReactNode {
@@ -98,7 +133,7 @@ export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallCh
98
133
  if (typeof result !== 'object' || result === null) {
99
134
  return <pre>{String(result)}</pre>;
100
135
  }
101
- if ('content' in result) {
136
+ if (isToolCallContent(result)) {
102
137
  return <div className='theia-toolCall-response-content'>
103
138
  {result.content.map((content, idx) => {
104
139
  switch (content.type) {
@@ -190,6 +225,9 @@ interface ToolCallContentProps {
190
225
  requestCanceled: boolean;
191
226
  contextMenuRenderer: ContextMenuRenderer;
192
227
  openerService: OpenerService;
228
+ pendingTracker?: PendingToolConfirmationTracker;
229
+ keybindingHints?: ToolConfirmationKeybindingHints;
230
+ markdownRenderer?: MarkdownRenderer;
193
231
  }
194
232
 
195
233
  /**
@@ -206,7 +244,10 @@ const ToolCallContent: React.FC<ToolCallContentProps> = ({
206
244
  requestCanceled,
207
245
  showArgsTooltip,
208
246
  contextMenuRenderer,
209
- openerService
247
+ openerService,
248
+ pendingTracker,
249
+ keybindingHints,
250
+ markdownRenderer
210
251
  }) => {
211
252
  const { confirmationState, rejectionReason } = useToolConfirmationState(response, confirmationMode);
212
253
  const summaryRef = React.useRef<HTMLElement | undefined>(undefined);
@@ -297,6 +338,10 @@ const ToolCallContent: React.FC<ToolCallContentProps> = ({
297
338
  onDeny={handleDeny}
298
339
  contextMenuRenderer={contextMenuRenderer}
299
340
  openerService={openerService}
341
+ pendingTracker={pendingTracker}
342
+ keybindingHints={keybindingHints}
343
+ chatId={chatId}
344
+ markdownRenderer={markdownRenderer}
300
345
  />
301
346
  </span>
302
347
  )}
@@ -341,6 +341,10 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta
341
341
  getSettings(): ChatSessionSettings | undefined {
342
342
  return this.chatSession.model.settings;
343
343
  }
344
+
345
+ get sessionId(): string {
346
+ return this.chatSession.id;
347
+ }
344
348
  }
345
349
 
346
350
  export namespace ChatViewWidget {
@@ -991,6 +991,15 @@ div:last-child>.theia-ChatNode {
991
991
  cursor: default;
992
992
  }
993
993
 
994
+ .theia-tool-confirmation-intro {
995
+ margin: calc(var(--theia-ui-padding) * 2) 0;
996
+ line-height: var(--theia-content-line-height);
997
+ }
998
+
999
+ .theia-tool-confirmation-intro p {
1000
+ margin: 0;
1001
+ }
1002
+
994
1003
  .theia-tool-confirmation-header {
995
1004
  font-weight: bold;
996
1005
  margin-bottom: 8px;