@theia/ai-ide 1.76.0-next.7 → 1.76.0

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 (174) hide show
  1. package/lib/browser/ai-allow-all-mode-chat-banner.js +1 -1
  2. package/lib/browser/ai-allow-all-mode-chat-banner.js.map +1 -1
  3. package/lib/browser/ai-aware-scm-commit-widget.d.ts +27 -0
  4. package/lib/browser/ai-aware-scm-commit-widget.d.ts.map +1 -0
  5. package/lib/browser/ai-aware-scm-commit-widget.js +142 -0
  6. package/lib/browser/ai-aware-scm-commit-widget.js.map +1 -0
  7. package/lib/browser/ai-configuration/ai-configuration-detail-widget.d.ts +10 -4
  8. package/lib/browser/ai-configuration/ai-configuration-detail-widget.d.ts.map +1 -1
  9. package/lib/browser/ai-configuration/ai-configuration-detail-widget.js +12 -7
  10. package/lib/browser/ai-configuration/ai-configuration-detail-widget.js.map +1 -1
  11. package/lib/browser/ai-configuration/ai-configuration-detail-widget.spec.js +14 -14
  12. package/lib/browser/ai-configuration/ai-configuration-detail-widget.spec.js.map +1 -1
  13. package/lib/browser/ai-configuration/ai-configuration-tree-widget.d.ts +6 -0
  14. package/lib/browser/ai-configuration/ai-configuration-tree-widget.d.ts.map +1 -1
  15. package/lib/browser/ai-configuration/ai-configuration-tree-widget.js +9 -0
  16. package/lib/browser/ai-configuration/ai-configuration-tree-widget.js.map +1 -1
  17. package/lib/browser/ai-configuration/categories/agent-capabilities-settings.d.ts.map +1 -1
  18. package/lib/browser/ai-configuration/categories/agent-capabilities-settings.js +2 -1
  19. package/lib/browser/ai-configuration/categories/agent-capabilities-settings.js.map +1 -1
  20. package/lib/browser/ai-configuration/categories/agent-chips.js +1 -1
  21. package/lib/browser/ai-configuration/categories/agent-chips.js.map +1 -1
  22. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.d.ts +8 -0
  23. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.d.ts.map +1 -1
  24. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.js +27 -8
  25. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.js.map +1 -1
  26. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.spec.js +38 -2
  27. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.spec.js.map +1 -1
  28. package/lib/browser/ai-configuration/categories/models-configuration-category.d.ts +13 -2
  29. package/lib/browser/ai-configuration/categories/models-configuration-category.d.ts.map +1 -1
  30. package/lib/browser/ai-configuration/categories/models-configuration-category.js +41 -2
  31. package/lib/browser/ai-configuration/categories/models-configuration-category.js.map +1 -1
  32. package/lib/browser/ai-configuration/categories/models-configuration-category.spec.js +32 -2
  33. package/lib/browser/ai-configuration/categories/models-configuration-category.spec.js.map +1 -1
  34. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.d.ts +26 -0
  35. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.d.ts.map +1 -0
  36. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.js +212 -0
  37. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.js.map +1 -0
  38. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.d.ts +2 -0
  39. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.d.ts.map +1 -0
  40. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.js +423 -0
  41. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.js.map +1 -0
  42. package/lib/browser/ai-configuration/language-model-renderer.d.ts.map +1 -1
  43. package/lib/browser/ai-configuration/language-model-renderer.js +10 -3
  44. package/lib/browser/ai-configuration/language-model-renderer.js.map +1 -1
  45. package/lib/browser/ai-getting-started-commands.d.ts +10 -0
  46. package/lib/browser/ai-getting-started-commands.d.ts.map +1 -0
  47. package/lib/browser/ai-getting-started-commands.js +34 -0
  48. package/lib/browser/ai-getting-started-commands.js.map +1 -0
  49. package/lib/browser/ai-getting-started-contribution.d.ts +20 -0
  50. package/lib/browser/ai-getting-started-contribution.d.ts.map +1 -0
  51. package/lib/browser/ai-getting-started-contribution.js +70 -0
  52. package/lib/browser/ai-getting-started-contribution.js.map +1 -0
  53. package/lib/browser/ai-getting-started-walkthrough.d.ts +41 -0
  54. package/lib/browser/ai-getting-started-walkthrough.d.ts.map +1 -0
  55. package/lib/browser/ai-getting-started-walkthrough.js +225 -0
  56. package/lib/browser/ai-getting-started-walkthrough.js.map +1 -0
  57. package/lib/browser/ai-getting-started-walkthrough.spec.d.ts +2 -0
  58. package/lib/browser/ai-getting-started-walkthrough.spec.d.ts.map +1 -0
  59. package/lib/browser/ai-getting-started-walkthrough.spec.js +124 -0
  60. package/lib/browser/ai-getting-started-walkthrough.spec.js.map +1 -0
  61. package/lib/browser/ai-walkthrough-context-keys.d.ts +55 -0
  62. package/lib/browser/ai-walkthrough-context-keys.d.ts.map +1 -0
  63. package/lib/browser/ai-walkthrough-context-keys.js +145 -0
  64. package/lib/browser/ai-walkthrough-context-keys.js.map +1 -0
  65. package/lib/browser/ai-walkthrough-context-keys.spec.d.ts +2 -0
  66. package/lib/browser/ai-walkthrough-context-keys.spec.d.ts.map +1 -0
  67. package/lib/browser/ai-walkthrough-context-keys.spec.js +166 -0
  68. package/lib/browser/ai-walkthrough-context-keys.spec.js.map +1 -0
  69. package/lib/browser/chat-session-item.js +2 -2
  70. package/lib/browser/chat-session-item.js.map +1 -1
  71. package/lib/browser/commit-message-agent.d.ts +31 -0
  72. package/lib/browser/commit-message-agent.d.ts.map +1 -0
  73. package/lib/browser/commit-message-agent.js +99 -0
  74. package/lib/browser/commit-message-agent.js.map +1 -0
  75. package/lib/browser/commit-message-agent.spec.d.ts +2 -0
  76. package/lib/browser/commit-message-agent.spec.d.ts.map +1 -0
  77. package/lib/browser/commit-message-agent.spec.js +82 -0
  78. package/lib/browser/commit-message-agent.spec.js.map +1 -0
  79. package/lib/browser/commit-message-command-contribution.d.ts +15 -0
  80. package/lib/browser/commit-message-command-contribution.d.ts.map +1 -0
  81. package/lib/browser/commit-message-command-contribution.js +73 -0
  82. package/lib/browser/commit-message-command-contribution.js.map +1 -0
  83. package/lib/browser/commit-message-commands.d.ts +5 -0
  84. package/lib/browser/commit-message-commands.d.ts.map +1 -0
  85. package/lib/browser/commit-message-commands.js +28 -0
  86. package/lib/browser/commit-message-commands.js.map +1 -0
  87. package/lib/browser/commit-message-prompt-template.d.ts +11 -0
  88. package/lib/browser/commit-message-prompt-template.d.ts.map +1 -0
  89. package/lib/browser/commit-message-prompt-template.js +63 -0
  90. package/lib/browser/commit-message-prompt-template.js.map +1 -0
  91. package/lib/browser/commit-message-runner.d.ts +41 -0
  92. package/lib/browser/commit-message-runner.d.ts.map +1 -0
  93. package/lib/browser/commit-message-runner.js +143 -0
  94. package/lib/browser/commit-message-runner.js.map +1 -0
  95. package/lib/browser/commit-message-runner.spec.d.ts +2 -0
  96. package/lib/browser/commit-message-runner.spec.d.ts.map +1 -0
  97. package/lib/browser/commit-message-runner.spec.js +206 -0
  98. package/lib/browser/commit-message-runner.spec.js.map +1 -0
  99. package/lib/browser/frontend-module.d.ts.map +1 -1
  100. package/lib/browser/frontend-module.js +25 -0
  101. package/lib/browser/frontend-module.js.map +1 -1
  102. package/lib/browser/git-changes-tool.d.ts +73 -0
  103. package/lib/browser/git-changes-tool.d.ts.map +1 -0
  104. package/lib/browser/git-changes-tool.js +217 -0
  105. package/lib/browser/git-changes-tool.js.map +1 -0
  106. package/lib/browser/git-changes-tool.spec.d.ts +2 -0
  107. package/lib/browser/git-changes-tool.spec.d.ts.map +1 -0
  108. package/lib/browser/git-changes-tool.spec.js +167 -0
  109. package/lib/browser/git-changes-tool.spec.js.map +1 -0
  110. package/lib/browser/ide-chat-welcome-message-provider.d.ts +26 -28
  111. package/lib/browser/ide-chat-welcome-message-provider.d.ts.map +1 -1
  112. package/lib/browser/ide-chat-welcome-message-provider.js +61 -151
  113. package/lib/browser/ide-chat-welcome-message-provider.js.map +1 -1
  114. package/lib/browser/review/pr-review-capability-contribution.js +1 -1
  115. package/lib/browser/review/pr-review-capability-contribution.js.map +1 -1
  116. package/lib/browser/user-interaction-tool-renderer.d.ts +6 -0
  117. package/lib/browser/user-interaction-tool-renderer.d.ts.map +1 -1
  118. package/lib/browser/user-interaction-tool-renderer.js +36 -7
  119. package/lib/browser/user-interaction-tool-renderer.js.map +1 -1
  120. package/lib/browser/user-interaction-tool.d.ts +23 -1
  121. package/lib/browser/user-interaction-tool.d.ts.map +1 -1
  122. package/lib/browser/user-interaction-tool.js +56 -17
  123. package/lib/browser/user-interaction-tool.js.map +1 -1
  124. package/lib/browser/user-interaction-tool.spec.js +151 -2
  125. package/lib/browser/user-interaction-tool.spec.js.map +1 -1
  126. package/lib/common/command-prompt-template.js +1 -1
  127. package/lib/common/user-interaction-tool.d.ts +19 -1
  128. package/lib/common/user-interaction-tool.d.ts.map +1 -1
  129. package/lib/common/user-interaction-tool.js +100 -29
  130. package/lib/common/user-interaction-tool.js.map +1 -1
  131. package/lib/common/user-interaction-tool.spec.js +215 -174
  132. package/lib/common/user-interaction-tool.spec.js.map +1 -1
  133. package/package.json +26 -25
  134. package/src/browser/ai-allow-all-mode-chat-banner.tsx +2 -2
  135. package/src/browser/ai-aware-scm-commit-widget.tsx +153 -0
  136. package/src/browser/ai-configuration/ai-configuration-detail-widget.spec.ts +14 -14
  137. package/src/browser/ai-configuration/ai-configuration-detail-widget.tsx +12 -7
  138. package/src/browser/ai-configuration/ai-configuration-tree-widget.tsx +8 -0
  139. package/src/browser/ai-configuration/categories/agent-capabilities-settings.tsx +7 -8
  140. package/src/browser/ai-configuration/categories/agent-chips.tsx +1 -1
  141. package/src/browser/ai-configuration/categories/model-aliases-configuration-category.spec.ts +40 -2
  142. package/src/browser/ai-configuration/categories/model-aliases-configuration-category.tsx +31 -10
  143. package/src/browser/ai-configuration/categories/models-configuration-category.spec.ts +47 -2
  144. package/src/browser/ai-configuration/categories/models-configuration-category.tsx +52 -5
  145. package/src/browser/ai-configuration/categories/provider-model-discovery-section.spec.tsx +451 -0
  146. package/src/browser/ai-configuration/categories/provider-model-discovery-section.tsx +341 -0
  147. package/src/browser/ai-configuration/language-model-renderer.tsx +11 -3
  148. package/src/browser/ai-getting-started-commands.ts +33 -0
  149. package/src/browser/ai-getting-started-contribution.ts +64 -0
  150. package/src/browser/ai-getting-started-walkthrough.spec.ts +146 -0
  151. package/src/browser/ai-getting-started-walkthrough.ts +249 -0
  152. package/src/browser/ai-walkthrough-context-keys.spec.ts +209 -0
  153. package/src/browser/ai-walkthrough-context-keys.ts +147 -0
  154. package/src/browser/chat-session-item.tsx +2 -2
  155. package/src/browser/commit-message-agent.spec.ts +97 -0
  156. package/src/browser/commit-message-agent.ts +113 -0
  157. package/src/browser/commit-message-command-contribution.ts +67 -0
  158. package/src/browser/commit-message-commands.ts +25 -0
  159. package/src/browser/commit-message-prompt-template.ts +65 -0
  160. package/src/browser/commit-message-runner.spec.ts +266 -0
  161. package/src/browser/commit-message-runner.ts +163 -0
  162. package/src/browser/frontend-module.ts +27 -0
  163. package/src/browser/git-changes-tool.spec.ts +200 -0
  164. package/src/browser/git-changes-tool.ts +239 -0
  165. package/src/browser/ide-chat-welcome-message-provider.tsx +93 -230
  166. package/src/browser/review/pr-review-capability-contribution.ts +1 -1
  167. package/src/browser/style/commit-message-overlay.css +64 -0
  168. package/src/browser/style/index.css +4 -42
  169. package/src/browser/user-interaction-tool-renderer.tsx +38 -8
  170. package/src/browser/user-interaction-tool.spec.ts +201 -2
  171. package/src/browser/user-interaction-tool.ts +69 -18
  172. package/src/common/command-prompt-template.ts +1 -1
  173. package/src/common/user-interaction-tool.spec.ts +217 -161
  174. package/src/common/user-interaction-tool.ts +127 -32
@@ -0,0 +1,163 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { Emitter, Event, ILogger, MessageService, nls } from '@theia/core';
18
+ import { ConfirmDialog, Dialog } from '@theia/core/lib/browser/dialogs';
19
+ import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
20
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
21
+ import { ScmRepository } from '@theia/scm/lib/browser/scm-repository';
22
+ import { ScmService } from '@theia/scm/lib/browser/scm-service';
23
+ import { CommitMessageAgent } from './commit-message-agent';
24
+ import { GetGitChangesTool } from './git-changes-tool';
25
+
26
+ /**
27
+ * Drives the {@link CommitMessageAgent}: reads the staged git diff of the selected repository,
28
+ * asks the agent to turn it into a commit message and writes the result into that repository's
29
+ * SCM commit-message input.
30
+ *
31
+ * The diff is read by calling {@link GetGitChangesTool.getStagedChanges} directly rather than by
32
+ * letting the model call the tool: the agent is a plain (non-chat) agent with a single, fixed
33
+ * input, so there is no chat session, no tool-confirmation prompt and no chat-model bookkeeping.
34
+ * `git diff --cached` also renders staged additions of new files as complete addition diffs, so
35
+ * untracked files need no separate handling.
36
+ */
37
+ @injectable()
38
+ export class CommitMessageRunner {
39
+
40
+ @inject(CommitMessageAgent)
41
+ protected readonly commitMessageAgent: CommitMessageAgent;
42
+
43
+ @inject(GetGitChangesTool)
44
+ protected readonly gitChangesTool: GetGitChangesTool;
45
+
46
+ @inject(ScmService)
47
+ protected readonly scmService: ScmService;
48
+
49
+ @inject(MessageService)
50
+ protected readonly messageService: MessageService;
51
+
52
+ @inject(ILogger) @named('ai-ide:CommitMessageRunner')
53
+ protected readonly logger: ILogger;
54
+
55
+ /**
56
+ * Cancellation source of the in-flight generation, or `undefined` when idle. It is fired by
57
+ * {@link cancel} so a click on the spinning button cancels the run even while we are still in
58
+ * the pre-request phase (resolving the repository, fetching the diff, awaiting the overwrite
59
+ * confirmation). Once the LLM request is under way, the same token cancels it via
60
+ * {@link CommitMessageAgent.generateCommitMessage}.
61
+ */
62
+ protected current: CancellationTokenSource | undefined;
63
+
64
+ protected readonly onDidChangeEmitter = new Emitter<void>();
65
+ readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
66
+
67
+ isRunning(): boolean {
68
+ return !!this.current;
69
+ }
70
+
71
+ cancel(): void {
72
+ this.current?.cancel();
73
+ }
74
+
75
+ async run(): Promise<void> {
76
+ if (this.current) {
77
+ return;
78
+ }
79
+ const cts = new CancellationTokenSource();
80
+ this.current = cts;
81
+ this.onDidChangeEmitter.fire();
82
+
83
+ try {
84
+ const repository = this.scmService.selectedRepository;
85
+ if (!repository) {
86
+ // Deliberately not awaited: a dismissed toast never resolves, which would leave
87
+ // the button spinning forever.
88
+ this.messageService.warn(
89
+ nls.localize('theia/ai-ide/commit-message/no-repository', 'No source-control repository is selected.')
90
+ );
91
+ return;
92
+ }
93
+
94
+ const changes = await this.gitChangesTool.getStagedChanges(repository, cts.token);
95
+ if (cts.token.isCancellationRequested) {
96
+ return;
97
+ }
98
+ if (!changes.trim()) {
99
+ this.messageService.warn(
100
+ nls.localize('theia/ai-ide/commit-message/no-changes', 'There are no staged changes to generate a commit message from.')
101
+ );
102
+ return;
103
+ }
104
+
105
+ if (!(await this.confirmOverwrite(repository))) {
106
+ return;
107
+ }
108
+ if (cts.token.isCancellationRequested) {
109
+ return;
110
+ }
111
+
112
+ const message = await this.commitMessageAgent.generateCommitMessage(changes, cts.token);
113
+ if (cts.token.isCancellationRequested) {
114
+ return;
115
+ }
116
+ if (!message) {
117
+ this.messageService.warn(
118
+ nls.localize('theia/ai-ide/commit-message/empty', 'The model returned an empty commit message.')
119
+ );
120
+ return;
121
+ }
122
+
123
+ repository.input.value = message;
124
+ repository.input.focus();
125
+ } catch (error) {
126
+ if (cts.token.isCancellationRequested) {
127
+ return;
128
+ }
129
+ this.logger.error('Failed to run commit-message agent', error);
130
+ this.notifyFailure(error instanceof Error ? error.message : String(error));
131
+ } finally {
132
+ this.current = undefined;
133
+ this.onDidChangeEmitter.fire();
134
+ cts.dispose();
135
+ }
136
+ }
137
+
138
+ protected notifyFailure(reason: string): void {
139
+ this.messageService.error(
140
+ nls.localize(
141
+ 'theia/ai-ide/commit-message/failed',
142
+ 'Failed to generate commit message: {0}',
143
+ reason
144
+ )
145
+ );
146
+ }
147
+
148
+ /** Returns `true` if the operation should proceed. */
149
+ protected async confirmOverwrite(repository: ScmRepository): Promise<boolean> {
150
+ if (!repository.input.value.trim()) {
151
+ return true;
152
+ }
153
+ return !!await new ConfirmDialog({
154
+ title: nls.localize('theia/ai-ide/commit-message/replace-title', 'Replace Commit Message'),
155
+ msg: nls.localize(
156
+ 'theia/ai-ide/commit-message/replace-prompt',
157
+ 'The commit message field is not empty. Replace its content with the generated message?'
158
+ ),
159
+ ok: nls.localizeByDefault('Replace'),
160
+ cancel: Dialog.CANCEL
161
+ }).open();
162
+ }
163
+ }
@@ -95,7 +95,11 @@ import { ContextFilesVariableContribution } from '../common/context-files-variab
95
95
  import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
96
96
  import { TemplatePreferenceContribution } from './template-preference-contribution';
97
97
  import { ChatWelcomeMessageProvider } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
98
+ import { WalkthroughProvider } from '@theia/getting-started/lib/common/walkthrough-provider';
98
99
  import { IdeChatWelcomeMessageProvider } from './ide-chat-welcome-message-provider';
100
+ import { AiGettingStartedWalkthroughProvider } from './ai-getting-started-walkthrough';
101
+ import { AiGettingStartedContribution } from './ai-getting-started-contribution';
102
+ import { AiWalkthroughContextKeys } from './ai-walkthrough-context-keys';
99
103
  import { ChatSessionsWelcomeMessageProvider } from './chat-sessions-welcome-message-provider';
100
104
  import { ChatSessionItemActionContribution, DefaultChatSessionItemActionContribution } from './chat-session-item-action-contribution';
101
105
  import { AiAllowAllModeChatBanner } from './ai-allow-all-mode-chat-banner';
@@ -148,6 +152,12 @@ import { AIFirstPerspectiveContribution } from './ai-first-perspective-contribut
148
152
  import { ChatSessionListService } from './chat-session-list-service';
149
153
  import { AISessionsWidget } from './ai-sessions-widget';
150
154
  import { AISessionsViewContribution } from './ai-sessions-view-contribution';
155
+ import { CommitMessageAgent } from './commit-message-agent';
156
+ import { CommitMessageRunner } from './commit-message-runner';
157
+ import { CommitMessageCommandContribution } from './commit-message-command-contribution';
158
+ import { GetGitChangesTool } from './git-changes-tool';
159
+ import { AiAwareScmCommitWidget } from './ai-aware-scm-commit-widget';
160
+ import { ScmCommitWidget } from '@theia/scm/lib/browser/scm-commit-widget';
151
161
 
152
162
  export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
153
163
  bind(PreferenceContribution).toConstantValue({ schema: aiIdePreferenceSchema });
@@ -217,6 +227,11 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
217
227
 
218
228
  bind(ChatSessionListService).toSelf().inSingletonScope();
219
229
 
230
+ bind(WalkthroughProvider).to(AiGettingStartedWalkthroughProvider).inSingletonScope();
231
+ bind(CommandContribution).to(AiGettingStartedContribution).inSingletonScope();
232
+ bind(AiWalkthroughContextKeys).toSelf().inSingletonScope();
233
+ bind(FrontendApplicationContribution).toService(AiWalkthroughContextKeys);
234
+
220
235
  bind(ChatWelcomeMessageProvider).to(IdeChatWelcomeMessageProvider).inSingletonScope();
221
236
  bind(ChatWelcomeMessageProvider).to(ChatSessionsWelcomeMessageProvider).inSingletonScope();
222
237
  bind(ChatBannerProvider).to(AiAllowAllModeChatBanner).inSingletonScope();
@@ -386,4 +401,16 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
386
401
  .inSingletonScope();
387
402
  bindViewContribution(bind, AISessionsViewContribution);
388
403
  bind(TabBarToolbarContribution).toService(AISessionsViewContribution);
404
+
405
+ // CommitMessageAgent is a plain (non-chat) `Agent`: it is driven exclusively from the SCM
406
+ // commit widget via `CommitMessageRunner`, so it does not appear in chat `@`-mention
407
+ // completion or the chat agent list. The `Agent` binding registers its prompt fragments
408
+ // with the prompt service.
409
+ bind(CommitMessageAgent).toSelf().inSingletonScope();
410
+ bind(Agent).toService(CommitMessageAgent);
411
+ bindToolProvider(GetGitChangesTool, bind);
412
+ bind(CommitMessageRunner).toSelf().inSingletonScope();
413
+ bind(CommitMessageCommandContribution).toSelf().inSingletonScope();
414
+ bind(CommandContribution).toService(CommitMessageCommandContribution);
415
+ rebind(ScmCommitWidget).to(AiAwareScmCommitWidget);
389
416
  });
@@ -0,0 +1,200 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
20
+ FrontendApplicationConfigProvider.set({});
21
+
22
+ import { expect } from 'chai';
23
+ import * as sinon from 'sinon';
24
+ import { Container } from '@theia/core/shared/inversify';
25
+ import URI from '@theia/core/lib/common/uri';
26
+ import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
27
+ import { ScmRepository } from '@theia/scm/lib/browser/scm-repository';
28
+ import { ScmService } from '@theia/scm/lib/browser/scm-service';
29
+ import { ShellExecutionRequest, ShellExecutionResult, ShellExecutionServer } from '@theia/ai-terminal/lib/common/shell-execution-server';
30
+ import { GET_GIT_CHANGES_FUNCTION_ID, GetGitChangesTool, GitChangesRepositoryError } from './git-changes-tool';
31
+
32
+ disableJSDOM();
33
+
34
+ function shellResult(overrides: Partial<ShellExecutionResult> = {}): ShellExecutionResult {
35
+ return { success: true, exitCode: 0, stdout: 'staged diff', stderr: '', duration: 1, ...overrides };
36
+ }
37
+
38
+ function makeRepo(rootUri: string): ScmRepository {
39
+ return { provider: { rootUri } } as unknown as ScmRepository;
40
+ }
41
+
42
+ describe('GetGitChangesTool', () => {
43
+
44
+ let container: Container;
45
+ let shellServer: { execute: sinon.SinonStub; cancel: sinon.SinonStub };
46
+ let scmService: { repositories: ScmRepository[]; selectedRepository: ScmRepository | undefined };
47
+ let workspaceRoots: URI[];
48
+ let tool: GetGitChangesTool;
49
+
50
+ const backend = makeRepo('file:///work/backend');
51
+ const frontend = makeRepo('file:///work/frontend');
52
+ const nested = makeRepo('file:///work/backend/vendor/lib');
53
+
54
+ function cwdOfCall(index = 0): string | undefined {
55
+ return (shellServer.execute.getCall(index).args[0] as ShellExecutionRequest).cwd?.replace(/\\/g, '/');
56
+ }
57
+
58
+ before(() => {
59
+ disableJSDOM = enableJSDOM();
60
+ });
61
+
62
+ after(() => {
63
+ disableJSDOM();
64
+ });
65
+
66
+ beforeEach(() => {
67
+ container = new Container();
68
+ shellServer = { execute: sinon.stub().resolves(shellResult()), cancel: sinon.stub().resolves(true) };
69
+ workspaceRoots = [new URI('file:///work/backend'), new URI('file:///work/frontend')];
70
+ scmService = { repositories: [backend, frontend, nested], selectedRepository: frontend };
71
+ const workspaceService: Partial<WorkspaceService> = {
72
+ tryGetRoots: () => workspaceRoots.map(resource => ({ resource } as never))
73
+ };
74
+ container.bind(ShellExecutionServer).toConstantValue(shellServer as unknown as ShellExecutionServer);
75
+ container.bind(ScmService).toConstantValue(scmService as unknown as ScmService);
76
+ container.bind(WorkspaceService).toConstantValue(workspaceService as WorkspaceService);
77
+ container.bind(GetGitChangesTool).toSelf();
78
+ tool = container.get(GetGitChangesTool);
79
+ });
80
+
81
+ afterEach(() => sinon.restore());
82
+
83
+ it('exposes the well-known tool id getGitChanges with a repository parameter', () => {
84
+ const request = tool.getTool();
85
+ expect(request.id).to.equal(GET_GIT_CHANGES_FUNCTION_ID);
86
+ expect((request.parameters.properties.repository as { type: string }).type).to.equal('string');
87
+ expect(request.parameters.required ?? []).to.not.contain('repository');
88
+ });
89
+
90
+ it('runs a cross-platform "git diff --cached" with no POSIX-only shell operators', async () => {
91
+ await tool.getTool().handler('{}');
92
+ const command = (shellServer.execute.firstCall.args[0] as ShellExecutionRequest).command;
93
+ expect(command).to.equal('git diff --cached --no-color');
94
+ expect(command).to.not.match(/;|\||xargs|sh -c|\/dev\/null/);
95
+ });
96
+
97
+ it('falls back to the selected repository when no repository argument is given', async () => {
98
+ await tool.getTool().handler('{}');
99
+ expect(cwdOfCall()).to.equal('/work/frontend');
100
+ });
101
+
102
+ it('runs in the named repository rather than the selected one', async () => {
103
+ const result = await tool.getTool().handler(JSON.stringify({ repository: 'backend' })) as { output: string };
104
+ expect(result.output).to.equal('staged diff');
105
+ expect(cwdOfCall()).to.equal('/work/backend');
106
+ });
107
+
108
+ it('identifies a repository nested inside a workspace root by its relative path', async () => {
109
+ await tool.getTool().handler(JSON.stringify({ repository: 'backend/vendor/lib' }));
110
+ expect(cwdOfCall()).to.equal('/work/backend/vendor/lib');
111
+ });
112
+
113
+ it('labels the argument with the repository for the tool-call summary', () => {
114
+ const label = tool.getTool().getArgumentsShortLabel!(JSON.stringify({ repository: 'backend' }));
115
+ expect(label).to.deep.equal({ label: 'backend', hasMore: false });
116
+ });
117
+
118
+ it('accepts an absolute path and ignores separator and case differences', async () => {
119
+ await tool.getTool().handler(JSON.stringify({ repository: '/work/backend' }));
120
+ expect(cwdOfCall()).to.equal('/work/backend');
121
+
122
+ await tool.getTool().handler(JSON.stringify({ repository: 'BackEnd/' }));
123
+ expect(cwdOfCall(1)).to.equal('/work/backend');
124
+ });
125
+
126
+ it('reports the known repositories instead of running an unknown one', async () => {
127
+ const result = await tool.getTool().handler(JSON.stringify({ repository: '../../etc' })) as GitChangesRepositoryError;
128
+ expect(result.error).to.contain('../../etc');
129
+ expect(result.availableRepositories).to.deep.equal(['backend', 'frontend', 'backend/vendor/lib']);
130
+ expect(shellServer.execute.called).to.be.false;
131
+ });
132
+
133
+ it('reports an ambiguous folder name instead of picking the first matching repository', async () => {
134
+ const otherLib = makeRepo('file:///work/frontend/vendor/lib');
135
+ scmService.repositories = [backend, frontend, nested, otherLib];
136
+
137
+ const result = await tool.getTool().handler(JSON.stringify({ repository: 'lib' })) as GitChangesRepositoryError;
138
+ expect(result.error).to.contain('ambiguous');
139
+ expect(shellServer.execute.called).to.be.false;
140
+
141
+ await tool.getTool().handler(JSON.stringify({ repository: 'frontend/vendor/lib' }));
142
+ expect(cwdOfCall()).to.equal('/work/frontend/vendor/lib');
143
+ });
144
+
145
+ it('prefers an exact label match over a folder name shared with a nested repository', async () => {
146
+ const nestedBackend = makeRepo('file:///work/frontend/backend');
147
+ scmService.repositories = [backend, frontend, nestedBackend];
148
+
149
+ await tool.getTool().handler(JSON.stringify({ repository: 'backend' }));
150
+ expect(cwdOfCall()).to.equal('/work/backend');
151
+ });
152
+
153
+ it('never derives the working directory from the argument, only from the matched repository', async () => {
154
+ await tool.getTool().handler(JSON.stringify({ repository: 'backend; rm -rf /' }));
155
+ expect(shellServer.execute.called).to.be.false;
156
+ });
157
+
158
+ it('asks for a repository when none is named and none is selected', async () => {
159
+ scmService.selectedRepository = undefined;
160
+ const result = await tool.getTool().handler('{}') as GitChangesRepositoryError;
161
+ expect(result.error).to.contain('repository argument');
162
+ expect(shellServer.execute.called).to.be.false;
163
+ });
164
+
165
+ it('throws instead of returning the error output when the git command fails', async () => {
166
+ shellServer.execute.resolves(shellResult({
167
+ success: false,
168
+ exitCode: 128,
169
+ stdout: '',
170
+ stderr: 'fatal: not a git repository'
171
+ }));
172
+
173
+ let caught: Error | undefined;
174
+ try {
175
+ await tool.getStagedChanges(backend);
176
+ } catch (error) {
177
+ caught = error as Error;
178
+ }
179
+ expect(caught?.message).to.match(/fatal: not a git repository/);
180
+ });
181
+
182
+ it('returns an empty diff when the execution was canceled', async () => {
183
+ shellServer.execute.resolves(shellResult({ canceled: true, stdout: 'partial' }));
184
+ expect(await tool.getStagedChanges(backend)).to.equal('');
185
+ });
186
+
187
+ it('keeps far more diff lines than the default shellExecute budget', async () => {
188
+ const stdout = Array.from({ length: 600 }, (_unused, index) => `+line ${index}`).join('\n');
189
+ shellServer.execute.resolves(shellResult({ stdout }));
190
+
191
+ const diff = await tool.getStagedChanges(backend);
192
+
193
+ expect(diff).to.contain('+line 300');
194
+ expect(diff).to.not.contain('lines omitted');
195
+ });
196
+
197
+ it('does not declare checkAutoAction so confirmation flows through the normal ToolConfirmationManager', () => {
198
+ expect(tool.getTool().checkAutoAction).to.be.undefined;
199
+ });
200
+ });
@@ -0,0 +1,239 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { CancellationToken } from '@theia/core';
18
+ import URI from '@theia/core/lib/common/uri';
19
+ import { inject, injectable } from '@theia/core/shared/inversify';
20
+ import { ToolInvocationContext, ToolRequest, ToolRequestParameters } from '@theia/ai-core';
21
+ import { ScmRepository } from '@theia/scm/lib/browser/scm-repository';
22
+ import { ScmService } from '@theia/scm/lib/browser/scm-service';
23
+ import { PredefinedShellTool } from '@theia/ai-terminal/lib/browser/predefined-shell-tool';
24
+ import {
25
+ OutputTruncationOptions,
26
+ ShellExecutionCanceledResult,
27
+ ShellExecutionToolResult
28
+ } from '@theia/ai-terminal/lib/common/shell-execution-server';
29
+
30
+ export const GET_GIT_CHANGES_FUNCTION_ID = 'getGitChanges';
31
+
32
+ /** Returned instead of a diff when the arguments do not identify a known repository. */
33
+ export interface GitChangesRepositoryError {
34
+ error: string;
35
+ availableRepositories: string[];
36
+ }
37
+
38
+ export namespace GitChangesRepositoryError {
39
+ export function is(result: unknown): result is GitChangesRepositoryError {
40
+ return !!result && typeof result === 'object' && 'availableRepositories' in result;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Returns `git diff --cached` for one of the workspace's git repositories.
46
+ *
47
+ * `git diff --cached` is a plain, operator-free command that behaves the same on POSIX shells and
48
+ * Windows `cmd.exe`, and it renders staged additions of new files as complete addition diffs, so
49
+ * untracked files need no separate handling.
50
+ *
51
+ * The `repository` argument is matched against the repositories the SCM service actually knows
52
+ * about, and the command then runs in *that* repository's root. The argument never reaches the
53
+ * shell, so a repository name cannot inject a command. An unknown or ambiguous name is answered
54
+ * with the list of valid ones rather than a silent fallback, so the model cannot mistake one
55
+ * repository's diff for another's.
56
+ */
57
+ @injectable()
58
+ export class GetGitChangesTool extends PredefinedShellTool {
59
+
60
+ @inject(ScmService)
61
+ protected readonly scmService: ScmService;
62
+
63
+ readonly id = GET_GIT_CHANGES_FUNCTION_ID;
64
+
65
+ readonly description =
66
+ 'Returns the staged changes (`git diff --cached`) of a git repository in the workspace, ' +
67
+ 'including the full content of newly added files. Unstaged and untracked files are not ' +
68
+ 'included. Returns an empty diff when nothing is staged, and an error listing the known ' +
69
+ 'repositories when the requested one does not exist.';
70
+
71
+ protected override readonly parameters: ToolRequestParameters = {
72
+ type: 'object',
73
+ properties: {
74
+ repository: {
75
+ type: 'string',
76
+ description:
77
+ 'Which repository to read. Use the path of the repository root relative to the ' +
78
+ 'workspace (e.g. "backend" or "backend/tools"), or its absolute path. Omit it to ' +
79
+ 'use the repository currently selected in the Source Control view; in a ' +
80
+ 'single-repository workspace that is always the right one, but in a workspace ' +
81
+ 'with several repositories pass the name explicitly so the diff is not taken ' +
82
+ 'from whichever repository the user happens to have selected.'
83
+ }
84
+ }
85
+ };
86
+
87
+ /**
88
+ * A diff is only useful as a whole, so the `shellExecute` budget of 50 head plus 50 tail
89
+ * lines is far too tight. The head is favoured over the tail because the leading hunks
90
+ * describe the change; the backend caps stdout at 1MB regardless.
91
+ */
92
+ protected override readonly truncation: OutputTruncationOptions = {
93
+ headLines: 2000,
94
+ tailLines: 500,
95
+ maxLineLength: 2000
96
+ };
97
+
98
+ override getTool(): ToolRequest {
99
+ return {
100
+ ...super.getTool(),
101
+ // The base class runs the command unconditionally; an unknown repository has to be
102
+ // answered before that, so the handler is wrapped rather than `execute` overridden.
103
+ handler: (argString: string, ctx?: ToolInvocationContext) => this.handle(argString, ctx),
104
+ getArgumentsShortLabel: (args: string) => {
105
+ try {
106
+ const repository = JSON.parse(args)?.repository;
107
+ return typeof repository === 'string' && repository ? { label: repository, hasMore: false } : undefined;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Runs the diff for the given repository and returns it. Used directly by the commit-message
117
+ * generator, which already knows its repository and must not go through tool confirmation.
118
+ *
119
+ * Returns an empty string when the execution was canceled.
120
+ *
121
+ * @throws if the git command fails, so a `fatal: ...` message is never mistaken for a diff.
122
+ */
123
+ async getStagedChanges(repository: ScmRepository, cancellationToken?: CancellationToken): Promise<string> {
124
+ const result = await this.runShellCommand({
125
+ command: this.buildCommand(),
126
+ cwd: new URI(repository.provider.rootUri).path.fsPath(),
127
+ timeout: this.timeout,
128
+ ctx: { cancellationToken },
129
+ truncation: this.truncation
130
+ });
131
+ if (ShellExecutionCanceledResult.is(result)) {
132
+ return '';
133
+ }
134
+ if (!result.success) {
135
+ const detail = result.error || result.output || `exit code ${result.exitCode}`;
136
+ throw new Error(`Failed to read the staged changes: ${detail}`);
137
+ }
138
+ return result.output;
139
+ }
140
+
141
+ protected buildCommand(): string {
142
+ return 'git diff --cached --no-color';
143
+ }
144
+
145
+ protected async handle(
146
+ argString: string,
147
+ ctx?: ToolInvocationContext
148
+ ): Promise<ShellExecutionToolResult | ShellExecutionCanceledResult | GitChangesRepositoryError> {
149
+ const args: Record<string, unknown> = argString ? JSON.parse(argString) : {};
150
+ const requested = this.requestedRepository(args);
151
+ const candidates = this.findRepositories(args);
152
+ if (candidates.length > 1) {
153
+ return {
154
+ error: `Repository '${requested}' is ambiguous. Pass the relative path shown in availableRepositories or the absolute path of the repository root.`,
155
+ availableRepositories: this.availableRepositories()
156
+ };
157
+ }
158
+ if (candidates.length === 0) {
159
+ return {
160
+ error: requested
161
+ ? `Unknown repository '${requested}'.`
162
+ : 'No repository is selected in the Source Control view. Pass the repository argument.',
163
+ availableRepositories: this.availableRepositories()
164
+ };
165
+ }
166
+ return this.execute(argString, ctx);
167
+ }
168
+
169
+ protected resolveWorkspaceRoot(args: Record<string, unknown>): string {
170
+ const repository = this.findRepository(args);
171
+ if (!repository) {
172
+ // Guarded by `handle`; failing loudly here keeps an unresolved repository from
173
+ // silently running the diff in the backend process' own working directory.
174
+ throw new Error(`Unknown repository '${this.requestedRepository(args) ?? ''}'.`);
175
+ }
176
+ return new URI(repository.provider.rootUri).path.fsPath();
177
+ }
178
+
179
+ protected requestedRepository(args: Record<string, unknown>): string | undefined {
180
+ return typeof args.repository === 'string' && args.repository.trim() ? args.repository.trim() : undefined;
181
+ }
182
+
183
+ /** The repository identified by the `repository` argument, or `undefined` if it is unknown or ambiguous. */
184
+ protected findRepository(args: Record<string, unknown>): ScmRepository | undefined {
185
+ const candidates = this.findRepositories(args);
186
+ return candidates.length === 1 ? candidates[0] : undefined;
187
+ }
188
+
189
+ /**
190
+ * Matches the `repository` argument against the known repositories by label or path. The
191
+ * folder name alone is only used when nothing matches by label or path. Falls back to the
192
+ * selected repository when the argument is absent. More than one result means the argument
193
+ * is ambiguous.
194
+ */
195
+ protected findRepositories(args: Record<string, unknown>): ScmRepository[] {
196
+ const requested = this.requestedRepository(args);
197
+ if (!requested) {
198
+ return this.scmService.selectedRepository ? [this.scmService.selectedRepository] : [];
199
+ }
200
+ const normalized = this.normalize(requested);
201
+ const repositories = this.scmService.repositories;
202
+ const byLabelOrPath = repositories.filter(repository => {
203
+ const root = new URI(repository.provider.rootUri);
204
+ return this.normalize(this.repositoryLabel(repository)) === normalized
205
+ || this.normalize(root.path.fsPath()) === normalized
206
+ || this.normalize(root.toString()) === normalized;
207
+ });
208
+ if (byLabelOrPath.length > 0) {
209
+ return byLabelOrPath;
210
+ }
211
+ return repositories.filter(repository => this.normalize(new URI(repository.provider.rootUri).path.base) === normalized);
212
+ }
213
+
214
+ protected availableRepositories(): string[] {
215
+ return this.scmService.repositories.map(repository => this.repositoryLabel(repository));
216
+ }
217
+
218
+ /**
219
+ * Identifies a repository by the path of its root relative to the workspace, so that the label
220
+ * is usable as the `repository` argument of a follow-up call. Workspace roots sharing a folder
221
+ * name produce identical labels, which {@link findRepositories} reports as ambiguous.
222
+ */
223
+ protected repositoryLabel(repository: ScmRepository): string {
224
+ const root = new URI(repository.provider.rootUri);
225
+ for (const workspaceRoot of this.workspaceService.tryGetRoots()) {
226
+ if (!workspaceRoot.resource.isEqualOrParent(root)) {
227
+ continue;
228
+ }
229
+ const relative = workspaceRoot.resource.relative(root)?.toString();
230
+ return relative ? `${workspaceRoot.resource.path.base}/${relative}` : workspaceRoot.resource.path.base;
231
+ }
232
+ return root.path.base;
233
+ }
234
+
235
+ /** Path separators and case differ across platforms and between user phrasings. */
236
+ protected normalize(value: string): string {
237
+ return value.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
238
+ }
239
+ }