@theia/ai-terminal 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 (34) hide show
  1. package/lib/browser/abstract-shell-execution-tool.d.ts +39 -0
  2. package/lib/browser/abstract-shell-execution-tool.d.ts.map +1 -0
  3. package/lib/browser/abstract-shell-execution-tool.js +103 -0
  4. package/lib/browser/abstract-shell-execution-tool.js.map +1 -0
  5. package/lib/browser/index.d.ts +1 -0
  6. package/lib/browser/index.d.ts.map +1 -1
  7. package/lib/browser/index.js +1 -0
  8. package/lib/browser/index.js.map +1 -1
  9. package/lib/browser/predefined-shell-tool.d.ts +51 -0
  10. package/lib/browser/predefined-shell-tool.d.ts.map +1 -0
  11. package/lib/browser/predefined-shell-tool.js +77 -0
  12. package/lib/browser/predefined-shell-tool.js.map +1 -0
  13. package/lib/browser/predefined-shell-tool.spec.d.ts +2 -0
  14. package/lib/browser/predefined-shell-tool.spec.d.ts.map +1 -0
  15. package/lib/browser/predefined-shell-tool.spec.js +197 -0
  16. package/lib/browser/predefined-shell-tool.spec.js.map +1 -0
  17. package/lib/browser/shell-execution-tool.d.ts +4 -13
  18. package/lib/browser/shell-execution-tool.d.ts.map +1 -1
  19. package/lib/browser/shell-execution-tool.js +7 -91
  20. package/lib/browser/shell-execution-tool.js.map +1 -1
  21. package/lib/common/shell-execution-input-parser.spec.js +3 -3
  22. package/lib/common/shell-execution-input-parser.spec.js.map +1 -1
  23. package/lib/common/shell-execution-server.d.ts +18 -2
  24. package/lib/common/shell-execution-server.d.ts.map +1 -1
  25. package/lib/common/shell-execution-server.js +13 -9
  26. package/lib/common/shell-execution-server.js.map +1 -1
  27. package/package.json +9 -9
  28. package/src/browser/abstract-shell-execution-tool.ts +127 -0
  29. package/src/browser/index.ts +1 -0
  30. package/src/browser/predefined-shell-tool.spec.ts +222 -0
  31. package/src/browser/predefined-shell-tool.ts +100 -0
  32. package/src/browser/shell-execution-tool.ts +10 -103
  33. package/src/common/shell-execution-input-parser.spec.ts +3 -3
  34. package/src/common/shell-execution-server.ts +31 -9
@@ -0,0 +1,222 @@
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 { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
26
+ import URI from '@theia/core/lib/common/uri';
27
+ import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
28
+ import { ShellExecutionRequest, ShellExecutionResult, ShellExecutionServer } from '../common/shell-execution-server';
29
+ import { PREDEFINED_SHELL_TOOL_PROVIDER, PredefinedShellTool } from './predefined-shell-tool';
30
+
31
+ disableJSDOM();
32
+
33
+ class TestPredefinedShellTool extends PredefinedShellTool {
34
+ readonly id = 'test-predefined';
35
+ readonly description = 'A test predefined shell tool that returns hello';
36
+
37
+ protected buildCommand(args: Record<string, unknown>): string {
38
+ const upper = args.upper === true;
39
+ return upper ? 'echo HELLO' : 'echo hello';
40
+ }
41
+
42
+ protected resolveWorkspaceRoot(args: Record<string, unknown>): string | undefined {
43
+ return typeof args.cwd === 'string' ? args.cwd : this.firstWorkspaceRoot();
44
+ }
45
+ }
46
+
47
+ function createResult(overrides: Partial<ShellExecutionResult> = {}): ShellExecutionResult {
48
+ return {
49
+ success: true,
50
+ exitCode: 0,
51
+ stdout: 'hello\n',
52
+ stderr: '',
53
+ duration: 5,
54
+ ...overrides
55
+ };
56
+ }
57
+
58
+ describe('PredefinedShellTool', () => {
59
+
60
+ let container: Container;
61
+ let shellServer: { execute: sinon.SinonStub; cancel: sinon.SinonStub };
62
+ let tool: TestPredefinedShellTool;
63
+
64
+ before(() => {
65
+ disableJSDOM = enableJSDOM();
66
+ });
67
+
68
+ after(() => {
69
+ disableJSDOM();
70
+ });
71
+
72
+ beforeEach(() => {
73
+ container = new Container();
74
+ shellServer = {
75
+ execute: sinon.stub().resolves(createResult()),
76
+ cancel: sinon.stub().resolves(true)
77
+ };
78
+ const workspaceService: Partial<WorkspaceService> = {
79
+ getWorkspaceRootUri: () => new URI('file:///workspace')
80
+ };
81
+ container.bind(ShellExecutionServer).toConstantValue(shellServer as unknown as ShellExecutionServer);
82
+ container.bind(WorkspaceService).toConstantValue(workspaceService as WorkspaceService);
83
+ container.bind(TestPredefinedShellTool).toSelf();
84
+ tool = container.get(TestPredefinedShellTool);
85
+ });
86
+
87
+ it('exposes the subclass id and description on the tool', () => {
88
+ const request = tool.getTool();
89
+ expect(request.id).to.equal('test-predefined');
90
+ expect(request.name).to.equal('test-predefined');
91
+ expect(request.description).to.equal('A test predefined shell tool that returns hello');
92
+ expect(request.providerName).to.equal(PREDEFINED_SHELL_TOOL_PROVIDER);
93
+ });
94
+
95
+ it('does not declare checkAutoAction so confirmation flows through the normal ToolConfirmationManager', () => {
96
+ const request = tool.getTool();
97
+ expect(request.checkAutoAction).to.be.undefined;
98
+ });
99
+
100
+ it('passes the hardcoded command, workspace root and a generated execution id to ShellExecutionServer', async () => {
101
+ const request = tool.getTool();
102
+ await request.handler('{}');
103
+ expect(shellServer.execute.calledOnce).to.be.true;
104
+ const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
105
+ expect(arg.command).to.equal('echo hello');
106
+ // URI.fsPath() uses the platform-native separator, so the cwd comes back as
107
+ // `\workspace` on Windows. Normalize before asserting to keep the test platform-agnostic.
108
+ expect(arg.cwd?.replace(/\\/g, '/')).to.equal('/workspace');
109
+ expect(arg.timeout).to.equal(30_000);
110
+ expect(typeof arg.executionId).to.equal('string');
111
+ expect(arg.executionId!.length).to.be.greaterThan(0);
112
+ });
113
+
114
+ it('forwards typed args to buildCommand', async () => {
115
+ const request = tool.getTool();
116
+ await request.handler(JSON.stringify({ upper: true }));
117
+ const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
118
+ expect(arg.command).to.equal('echo HELLO');
119
+ });
120
+
121
+ it('forwards the same typed args to resolveWorkspaceRoot, so the cwd can depend on them', async () => {
122
+ await tool.getTool().handler(JSON.stringify({ cwd: '/elsewhere' }));
123
+ const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
124
+ expect(arg.cwd).to.equal('/elsewhere');
125
+ });
126
+
127
+ it('handles an empty argument string as an empty args object', async () => {
128
+ const request = tool.getTool();
129
+ await request.handler('');
130
+ const arg = shellServer.execute.firstCall.args[0] as ShellExecutionRequest;
131
+ expect(arg.command).to.equal('echo hello');
132
+ });
133
+
134
+ it('returns a formatted result with combined output', async () => {
135
+ shellServer.execute.resolves(createResult({ stdout: 'out\n', stderr: 'err\n' }));
136
+ const request = tool.getTool();
137
+ const raw = await request.handler('{}');
138
+ expect(raw).to.deep.include({
139
+ success: true,
140
+ exitCode: 0
141
+ });
142
+ const result = raw as { output: string };
143
+ expect(result.output).to.contain('out');
144
+ expect(result.output).to.contain('err');
145
+ });
146
+
147
+ it('returns a canceled result when the underlying execution is canceled', async () => {
148
+ shellServer.execute.resolves(createResult({ canceled: true, stdout: 'partial\n', stderr: '' }));
149
+ const request = tool.getTool();
150
+ const result = await request.handler('{}') as { canceled: true; output?: string };
151
+ expect(result.canceled).to.be.true;
152
+ expect(result.output).to.contain('partial');
153
+ });
154
+
155
+ it('wires the invocation cancellation token to ShellExecutionServer.cancel', async () => {
156
+ const cts = new CancellationTokenSource();
157
+ const request = tool.getTool();
158
+
159
+ let capturedExecutionId: string | undefined;
160
+ shellServer.execute.callsFake(async (req: ShellExecutionRequest) => {
161
+ capturedExecutionId = req.executionId;
162
+ return new Promise<ShellExecutionResult>(resolve => {
163
+ cts.token.onCancellationRequested(() => resolve(createResult({ canceled: true, stdout: '', stderr: '' })));
164
+ });
165
+ });
166
+
167
+ const handlerPromise = request.handler('{}', { toolCallId: 'tc-1', cancellationToken: cts.token });
168
+ cts.cancel();
169
+ await handlerPromise;
170
+
171
+ expect(shellServer.cancel.calledOnce).to.be.true;
172
+ expect(shellServer.cancel.firstCall.args[0]).to.equal(capturedExecutionId);
173
+ });
174
+
175
+ it('tracks the execution by tool call id so it can be cancelled by id', async () => {
176
+ let capturedExecutionId: string | undefined;
177
+ let release: (() => void) | undefined;
178
+ shellServer.execute.callsFake(async (req: ShellExecutionRequest) => {
179
+ capturedExecutionId = req.executionId;
180
+ return new Promise<ShellExecutionResult>(resolve => {
181
+ release = () => resolve(createResult());
182
+ });
183
+ });
184
+
185
+ const handlerPromise = tool.getTool().handler('{}', { toolCallId: 'tc-1' });
186
+ expect(tool.isExecutionRunning('tc-1')).to.be.true;
187
+
188
+ expect(await tool.cancelExecution('tc-1')).to.be.true;
189
+ expect(shellServer.cancel.firstCall.args[0]).to.equal(capturedExecutionId);
190
+
191
+ release!();
192
+ await handlerPromise;
193
+ expect(tool.isExecutionRunning('tc-1')).to.be.false;
194
+ });
195
+
196
+ it('applies the default truncation budget, keeping only the first and last 50 lines', async () => {
197
+ const stdout = Array.from({ length: 500 }, (_unused, index) => `line ${index}`).join('\n');
198
+ shellServer.execute.resolves(createResult({ stdout }));
199
+
200
+ const result = await tool.getTool().handler('{}') as { output: string };
201
+
202
+ expect(result.output).to.contain('line 0');
203
+ expect(result.output).to.contain('line 499');
204
+ expect(result.output).to.not.contain('line 250');
205
+ expect(result.output).to.contain('400 lines omitted');
206
+ });
207
+
208
+ it('honours a raised truncation budget declared by the subclass', async () => {
209
+ class GenerousTool extends TestPredefinedShellTool {
210
+ protected override readonly truncation = { headLines: 400, tailLines: 100 };
211
+ }
212
+ container.bind(GenerousTool).toSelf();
213
+ const generous = container.get(GenerousTool);
214
+ const stdout = Array.from({ length: 500 }, (_unused, index) => `line ${index}`).join('\n');
215
+ shellServer.execute.resolves(createResult({ stdout }));
216
+
217
+ const result = await generous.getTool().handler('{}') as { output: string };
218
+
219
+ expect(result.output).to.contain('line 250');
220
+ expect(result.output).to.not.contain('lines omitted');
221
+ });
222
+ });
@@ -0,0 +1,100 @@
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 { injectable } from '@theia/core/shared/inversify';
18
+ import { ToolInvocationContext, ToolRequest, ToolRequestParameters } from '@theia/ai-core';
19
+ import { AbstractShellExecutionTool } from './abstract-shell-execution-tool';
20
+ import { OutputTruncationOptions, ShellExecutionCanceledResult, ShellExecutionToolResult } from '../common/shell-execution-server';
21
+
22
+ /** `providerName` reported by tools that derive from {@link PredefinedShellTool}. */
23
+ export const PREDEFINED_SHELL_TOOL_PROVIDER = 'ai-predefined-shell';
24
+
25
+ /**
26
+ * Base class for tools that execute a fixed, hardcoded shell command. A subclass declares its
27
+ * tool `id`, its parameters and a `buildCommand(args)` method that assembles the command from
28
+ * typed arguments. The LLM only supplies the typed arguments, so the subclass stays in control
29
+ * of the command.
30
+ *
31
+ * Unlike the general-purpose `shellExecute` tool, a {@link PredefinedShellTool} does not consult
32
+ * `ShellCommandPermissionService` and does not appear on the user's shell allow/deny lists. The
33
+ * safety boundary is the subclass `buildCommand`, which must not be coercible into running
34
+ * arbitrary commands.
35
+ */
36
+ @injectable()
37
+ export abstract class PredefinedShellTool extends AbstractShellExecutionTool {
38
+
39
+ /** The unique identifier of this tool. Must not be `shellExecute`. */
40
+ abstract readonly id: string;
41
+
42
+ /** Human-readable description of this tool, shown to the LLM. */
43
+ abstract readonly description: string;
44
+
45
+ protected readonly parameters: ToolRequestParameters = { type: 'object', properties: {} };
46
+
47
+ /** Timeout for the underlying shell execution, in milliseconds. */
48
+ protected readonly timeout: number = 30_000;
49
+
50
+ /** Output truncation budget. `undefined` keeps the `shellExecute` defaults (first/last 50 lines). */
51
+ protected readonly truncation: OutputTruncationOptions | undefined = undefined;
52
+
53
+ /** Build the exact shell command to execute from the parsed arguments. */
54
+ protected abstract buildCommand(args: Record<string, unknown>): string;
55
+
56
+ /**
57
+ * Resolves the working directory for the shell command, given the same parsed arguments that
58
+ * {@link buildCommand} receives, so a tool can let the caller pick the folder it runs in.
59
+ *
60
+ * Deliberately abstract: the cwd materially affects most predefined commands (anything
61
+ * talking to a specific repository, build script, or per-folder tool), and silently
62
+ * defaulting to an arbitrary workspace root produces results that look plausible but describe
63
+ * the wrong folder.
64
+ *
65
+ * Subclasses that genuinely do not care about the cwd can return
66
+ * {@link firstWorkspaceRoot} or `undefined`.
67
+ */
68
+ protected abstract resolveWorkspaceRoot(args: Record<string, unknown>): string | undefined;
69
+
70
+ /**
71
+ * The **first** workspace root, or `undefined` when no workspace is open. Only a sensible
72
+ * `resolveWorkspaceRoot` implementation for commands that produce the same output regardless
73
+ * of cwd. In a multi-root workspace the first root is arbitrary.
74
+ */
75
+ protected firstWorkspaceRoot(): string | undefined {
76
+ return this.workspaceService.getWorkspaceRootUri(undefined)?.path.fsPath();
77
+ }
78
+
79
+ getTool(): ToolRequest {
80
+ return {
81
+ id: this.id,
82
+ name: this.id,
83
+ providerName: PREDEFINED_SHELL_TOOL_PROVIDER,
84
+ description: this.description,
85
+ parameters: this.parameters,
86
+ handler: (argString: string, ctx?: ToolInvocationContext) => this.execute(argString, ctx)
87
+ };
88
+ }
89
+
90
+ protected async execute(argString: string, ctx?: ToolInvocationContext): Promise<ShellExecutionToolResult | ShellExecutionCanceledResult> {
91
+ const args: Record<string, unknown> = argString ? JSON.parse(argString) : {};
92
+ return this.runShellCommand({
93
+ command: this.buildCommand(args),
94
+ cwd: this.resolveWorkspaceRoot(args),
95
+ timeout: this.timeout,
96
+ ctx,
97
+ truncation: this.truncation
98
+ });
99
+ }
100
+ }
@@ -15,48 +15,22 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { injectable, inject } from '@theia/core/shared/inversify';
18
- import { ToolProvider, ToolRequest, AutoActionResult } from '@theia/ai-core';
18
+ import { ToolRequest, AutoActionResult } from '@theia/ai-core';
19
19
  import { ShellCommandPermissionService } from './shell-command-permission-service';
20
- import { WorkspaceService } from '@theia/workspace/lib/browser';
20
+ import { AbstractShellExecutionTool } from './abstract-shell-execution-tool';
21
21
  import {
22
22
  SHELL_EXECUTION_FUNCTION_ID,
23
- ShellExecutionServer,
24
23
  ShellExecutionToolResult,
25
- ShellExecutionCanceledResult,
26
- combineAndTruncate
24
+ ShellExecutionCanceledResult
27
25
  } from '../common/shell-execution-server';
28
- import { CancellationToken, generateUuid, Path } from '@theia/core';
26
+ import { Path } from '@theia/core';
29
27
 
30
28
  @injectable()
31
- export class ShellExecutionTool implements ToolProvider {
32
-
33
- @inject(ShellExecutionServer)
34
- protected readonly shellServer: ShellExecutionServer;
35
-
36
- @inject(WorkspaceService)
37
- protected readonly workspaceService: WorkspaceService;
29
+ export class ShellExecutionTool extends AbstractShellExecutionTool {
38
30
 
39
31
  @inject(ShellCommandPermissionService)
40
32
  protected readonly shellCommandPermissionService: ShellCommandPermissionService;
41
33
 
42
- protected readonly runningExecutions = new Map<string, string>();
43
-
44
- async cancelExecution(toolCallId: string): Promise<boolean> {
45
- const executionId = this.runningExecutions.get(toolCallId);
46
- if (executionId) {
47
- const canceled = await this.shellServer.cancel(executionId);
48
- if (canceled) {
49
- this.runningExecutions.delete(toolCallId);
50
- }
51
- return canceled;
52
- }
53
- return false;
54
- }
55
-
56
- isExecutionRunning(toolCallId: string): boolean {
57
- return this.runningExecutions.has(toolCallId);
58
- }
59
-
60
34
  getTool(): ToolRequest {
61
35
  return {
62
36
  id: SHELL_EXECUTION_FUNCTION_ID,
@@ -171,56 +145,12 @@ TIMEOUT: Default 2 minutes, max 10 minutes. Specify higher timeout for longer co
171
145
  timeout?: number;
172
146
  } = JSON.parse(argString);
173
147
 
174
- const resolvedCwd = this.resolveCwd(args.cwd);
175
-
176
- // Generate execution ID and get tool call ID from context
177
- const executionId = generateUuid();
178
- const toolCallId = this.extractToolCallId(ctx);
179
- const cancellationToken = this.extractCancellationToken(ctx);
180
-
181
- // Track this execution
182
- if (toolCallId) {
183
- this.runningExecutions.set(toolCallId, executionId);
184
- }
185
-
186
- const cancellationListener = cancellationToken?.onCancellationRequested(() => {
187
- this.shellServer.cancel(executionId);
148
+ return this.runShellCommand({
149
+ command: args.command,
150
+ cwd: this.resolveCwd(args.cwd),
151
+ timeout: args.timeout,
152
+ ctx
188
153
  });
189
-
190
- try {
191
- const result = await this.shellServer.execute({
192
- command: args.command,
193
- cwd: resolvedCwd,
194
- timeout: args.timeout,
195
- executionId,
196
- });
197
-
198
- if (result.canceled) {
199
- return {
200
- canceled: true,
201
- output: this.combineAndTruncate(result.stdout, result.stderr) || undefined,
202
- duration: result.duration,
203
- };
204
- }
205
-
206
- // Combine stdout and stderr, apply truncation
207
- const combinedOutput = this.combineAndTruncate(result.stdout, result.stderr);
208
-
209
- return {
210
- success: result.success,
211
- exitCode: result.exitCode,
212
- output: combinedOutput,
213
- error: result.error,
214
- duration: result.duration,
215
- cwd: result.resolvedCwd,
216
- };
217
- } finally {
218
- // Clean up
219
- cancellationListener?.dispose();
220
- if (toolCallId) {
221
- this.runningExecutions.delete(toolCallId);
222
- }
223
- }
224
154
  }
225
155
 
226
156
  /**
@@ -269,27 +199,4 @@ TIMEOUT: Default 2 minutes, max 10 minutes. Specify higher timeout for longer co
269
199
  );
270
200
  }
271
201
 
272
- protected extractToolCallId(ctx?: unknown): string | undefined {
273
- if (ctx && typeof ctx === 'object' && 'toolCallId' in ctx) {
274
- return (ctx as { toolCallId?: string }).toolCallId;
275
- }
276
- return undefined;
277
- }
278
-
279
- protected extractCancellationToken(ctx: unknown): CancellationToken | undefined {
280
- if (ctx && typeof ctx === 'object') {
281
- // Check for MutableChatRequestModel structure (response.cancellationToken)
282
- if ('response' in ctx) {
283
- const response = (ctx as { response?: { cancellationToken?: CancellationToken } }).response;
284
- if (response?.cancellationToken) {
285
- return response.cancellationToken;
286
- }
287
- }
288
- }
289
- return undefined;
290
- }
291
-
292
- protected combineAndTruncate(stdout: string, stderr: string): string {
293
- return combineAndTruncate(stdout, stderr);
294
- }
295
202
  }
@@ -105,10 +105,10 @@ describe('parseShellExecutionInput', () => {
105
105
  expect(result.command).to.equal('echo "hello"');
106
106
  });
107
107
 
108
- it('should handle incomplete command with backslash', () => {
109
- // During streaming, we get partial content - the regex stops at first unescaped quote
108
+ it('should unescape a quote in an incomplete command', () => {
109
+ // During streaming, we get partial content - the escaped quote is part of the command, not its end
110
110
  const result = parseShellExecutionInput('{"command": "echo \\"hello');
111
- expect(result.command).to.equal('echo \\');
111
+ expect(result.command).to.equal('echo "hello');
112
112
  });
113
113
 
114
114
  it('should handle command with pipes and redirects', () => {
@@ -82,16 +82,38 @@ export const TAIL_LINES = 50;
82
82
  export const GRACE_LINES = 10;
83
83
  export const MAX_LINE_LENGTH = 1000;
84
84
 
85
- export function truncateLine(line: string): string {
86
- if (line.length <= MAX_LINE_LENGTH) {
85
+ /**
86
+ * Line and line-length budget applied to combined command output. The defaults suit the
87
+ * general-purpose `shellExecute` tool, where output is often a long build or test log and only
88
+ * the beginning and the end carry information. Tools whose output is meaningful as a whole
89
+ * (e.g. a diff) should raise these limits.
90
+ */
91
+ export interface OutputTruncationOptions {
92
+ /** Number of leading lines to keep. Default {@link HEAD_LINES}. */
93
+ headLines?: number;
94
+ /** Number of trailing lines to keep. Default {@link TAIL_LINES}. */
95
+ tailLines?: number;
96
+ /** Extra lines tolerated before truncation kicks in at all. Default {@link GRACE_LINES}. */
97
+ graceLines?: number;
98
+ /** Maximum length of a single line before its middle is elided. Default {@link MAX_LINE_LENGTH}. */
99
+ maxLineLength?: number;
100
+ }
101
+
102
+ export function truncateLine(line: string, maxLineLength: number = MAX_LINE_LENGTH): string {
103
+ if (line.length <= maxLineLength) {
87
104
  return line;
88
105
  }
89
- const halfLength = Math.floor((MAX_LINE_LENGTH - 30) / 2);
106
+ const halfLength = Math.floor((maxLineLength - 30) / 2);
90
107
  const omittedCount = line.length - halfLength * 2;
91
108
  return `${line.slice(0, halfLength)} ... [${omittedCount} chars omitted] ... ${line.slice(-halfLength)}`;
92
109
  }
93
110
 
94
- export function combineAndTruncate(stdout: string, stderr: string): string {
111
+ export function combineAndTruncate(stdout: string, stderr: string, options?: OutputTruncationOptions): string {
112
+ const head = options?.headLines ?? HEAD_LINES;
113
+ const tail = options?.tailLines ?? TAIL_LINES;
114
+ const grace = options?.graceLines ?? GRACE_LINES;
115
+ const maxLineLength = options?.maxLineLength ?? MAX_LINE_LENGTH;
116
+
95
117
  const trimmedStdout = stdout.trim();
96
118
  const trimmedStderr = stderr.trim();
97
119
 
@@ -108,13 +130,13 @@ export function combineAndTruncate(stdout: string, stderr: string): string {
108
130
 
109
131
  const lines = output.split('\n');
110
132
 
111
- if (lines.length <= HEAD_LINES + TAIL_LINES + GRACE_LINES) {
112
- return lines.map(truncateLine).join('\n');
133
+ if (lines.length <= head + tail + grace) {
134
+ return lines.map(line => truncateLine(line, maxLineLength)).join('\n');
113
135
  }
114
136
 
115
- const headLines = lines.slice(0, HEAD_LINES).map(truncateLine);
116
- const tailLines = lines.slice(-TAIL_LINES).map(truncateLine);
117
- const omittedCount = lines.length - HEAD_LINES - TAIL_LINES;
137
+ const headLines = lines.slice(0, head).map(line => truncateLine(line, maxLineLength));
138
+ const tailLines = lines.slice(-tail).map(line => truncateLine(line, maxLineLength));
139
+ const omittedCount = lines.length - head - tail;
118
140
 
119
141
  return [...headLines, `\n... [${omittedCount} lines omitted] ...\n`, ...tailLines].join('\n');
120
142
  }