@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.
- package/lib/browser/ai-chat-ui-frontend-module.d.ts.map +1 -1
- package/lib/browser/ai-chat-ui-frontend-module.js +4 -0
- package/lib/browser/ai-chat-ui-frontend-module.js.map +1 -1
- package/lib/browser/chat-response-renderer/delegation-tool-renderer.d.ts +8 -1
- package/lib/browser/chat-response-renderer/delegation-tool-renderer.d.ts.map +1 -1
- package/lib/browser/chat-response-renderer/delegation-tool-renderer.js +30 -1
- package/lib/browser/chat-response-renderer/delegation-tool-renderer.js.map +1 -1
- package/lib/browser/chat-response-renderer/tool-confirmation.d.ts +15 -1
- package/lib/browser/chat-response-renderer/tool-confirmation.d.ts.map +1 -1
- package/lib/browser/chat-response-renderer/tool-confirmation.js +60 -17
- package/lib/browser/chat-response-renderer/tool-confirmation.js.map +1 -1
- package/lib/browser/chat-response-renderer/toolcall-part-renderer.d.ts +8 -1
- package/lib/browser/chat-response-renderer/toolcall-part-renderer.d.ts.map +1 -1
- package/lib/browser/chat-response-renderer/toolcall-part-renderer.js +31 -5
- package/lib/browser/chat-response-renderer/toolcall-part-renderer.js.map +1 -1
- package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js +102 -0
- package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js.map +1 -1
- package/lib/browser/chat-view-widget.d.ts +1 -0
- package/lib/browser/chat-view-widget.d.ts.map +1 -1
- package/lib/browser/chat-view-widget.js +3 -0
- package/lib/browser/chat-view-widget.js.map +1 -1
- package/lib/browser/tool-confirmation-keybinding-contribution.d.ts +25 -0
- package/lib/browser/tool-confirmation-keybinding-contribution.d.ts.map +1 -0
- package/lib/browser/tool-confirmation-keybinding-contribution.js +106 -0
- package/lib/browser/tool-confirmation-keybinding-contribution.js.map +1 -0
- package/package.json +12 -12
- package/src/browser/ai-chat-ui-frontend-module.ts +5 -0
- package/src/browser/chat-response-renderer/delegation-tool-renderer.tsx +34 -3
- package/src/browser/chat-response-renderer/tool-confirmation.tsx +122 -30
- package/src/browser/chat-response-renderer/toolcall-part-renderer.spec.ts +127 -0
- package/src/browser/chat-response-renderer/toolcall-part-renderer.tsx +51 -6
- package/src/browser/chat-view-widget.tsx +4 -0
- package/src/browser/style/index.css +9 -0
- 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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
<
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
<
|
|
484
|
-
{
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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 (
|
|
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;
|