From 7d448953f0320a88f3ba0bc4661ee8a33e88319a Mon Sep 17 00:00:00 2001 From: Dmitrij Rozdestvensky <124160656+dr14-make@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:05:06 +0200 Subject: [PATCH 1/3] feat(terminal): linkify file:// URLs printed in the terminal (#17783) * feat(terminal): linkify file:// URLs printed in the terminal A printed file:// URL was inert: UrlLinkProvider only matches http(s), and FileLinkProvider's path regex excludes ':' so the URL survives only as a scheme-less remainder its guard now skips. Add a dedicated FileUriLinkProvider that matches file:// URLs, parses them as whole URIs (preserving authority for UNC/localhost forms), validates via FileService, and opens the file in the editor via OpenerService. * feat(terminal): open file:// links at :line:column * refactor(terminal): dedupe file link providers and fix regex re-entrancy Collect regex matches synchronously before awaiting so concurrent provideLinks calls cannot corrupt the shared g-flag lastIndex. Extract the shared file-open helpers (isValidFileURI, openURI) into AbstractFileOpeningLinkProvider, which both FileLinkProvider and FileUriLinkProvider extend. Signed-off-by: Dmitrij Rozdestvensky --- CHANGELOG.md | 1 + .../browser/terminal-file-link-provider.ts | 31 +-- .../terminal-file-opening-link-provider.ts | 57 +++++ .../terminal-file-uri-link-provider.spec.ts | 203 ++++++++++++++++++ .../terminal-file-uri-link-provider.ts | 91 ++++++++ .../src/browser/terminal-frontend-module.ts | 3 + 6 files changed, 358 insertions(+), 28 deletions(-) create mode 100644 packages/terminal/src/browser/terminal-file-opening-link-provider.ts create mode 100644 packages/terminal/src/browser/terminal-file-uri-link-provider.spec.ts create mode 100644 packages/terminal/src/browser/terminal-file-uri-link-provider.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a0f040e1d2b..9db525361525d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## 1.74.0 - tbd - [terminal] fixed the file link provider logging a `UriError` when a printed URL (e.g. `https://example.com/path`) was matched as a local file candidate; such `//host/path` candidates are now skipped and left to the URL link provider [#17767](https://github.com/eclipse-theia/theia/pull/17767) +- [terminal] added linkification of `file://` URLs printed in the terminal, opening the referenced local file in the editor, including an optional `:line:column` suffix [#17783](https://github.com/eclipse-theia/theia/pull/17783) - [ai-anthropic, ai-chat, ai-chat-ui, ai-core, ai-openai] added provider-native server-side compaction for chat sessions, modeled as a model capability (`LanguageModelMetaData.serverSideCompactionSupport`) plus layered activation: a global preference (`ai-features.chat.serverSideCompaction`, default on), a per-provider override, and a per-session override (in the session settings dialog) that wins over both. Honored by the Anthropic Messages API (beta) and the OpenAI Responses API; the compaction marker is persisted in the session, replayed on subsequent requests, surfaced as an inline chat marker, and reflected in the token-usage tooltip (cumulative usage and a "compacted Nx" count). Providers/models without the capability ignore it [#17636](https://github.com/eclipse-theia/theia/issues/17636) - [ai-chat-ui] replaced scroll-direction heuristics in `ChatViewTreeWidget` with Virtuoso's `atBottomStateChange` for reliable auto-scroll during streaming [#17728](https://github.com/eclipse-theia/theia/pull/17728) - [ai-vercel-ai] deprecated `@theia/ai-vercel-ai` package [#TBD](https://github.com/eclipse-theia/theia/pull/TBD) diff --git a/packages/terminal/src/browser/terminal-file-link-provider.ts b/packages/terminal/src/browser/terminal-file-link-provider.ts index 4f2e04ebda947..229416b66a4b9 100644 --- a/packages/terminal/src/browser/terminal-file-link-provider.ts +++ b/packages/terminal/src/browser/terminal-file-link-provider.ts @@ -15,22 +15,19 @@ // ***************************************************************************** import { OS, Path, QuickInputService, ILogger } from '@theia/core'; -import { OpenerService } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import { Position } from '@theia/editor/lib/browser'; -import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { AbstractFileOpeningLinkProvider } from './terminal-file-opening-link-provider'; import { TerminalWidget } from './base/terminal-widget'; -import { TerminalLink, TerminalLinkProvider } from './terminal-link-provider'; +import { TerminalLink } from './terminal-link-provider'; import { TerminalWidgetImpl } from './terminal-widget-impl'; import { FileSearchService } from '@theia/file-search/lib/common/file-search-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; @injectable() -export class FileLinkProvider implements TerminalLinkProvider { +export class FileLinkProvider extends AbstractFileOpeningLinkProvider { - @inject(OpenerService) protected readonly openerService: OpenerService; @inject(QuickInputService) protected readonly quickInputService: QuickInputService; - @inject(FileService) protected fileService: FileService; @inject(FileSearchService) protected searchService: FileSearchService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(ILogger) @named('terminal:FileLinkProvider') @@ -71,14 +68,6 @@ export class FileLinkProvider implements TerminalLinkProvider { return false; } - protected async isValidFileURI(uri: URI): Promise { - try { - const stat = await this.fileService.resolve(uri); - return !stat.isDirectory; - } catch { } - return false; - } - protected async toURI(match: string, cwd: URI): Promise { const path = await this.extractPath(match); if (!path) { @@ -120,20 +109,6 @@ export class FileLinkProvider implements TerminalLinkProvider { return this.openURI(toOpen, position); } - async openURI(toOpen: URI, position: Position): Promise { - let options = {}; - if (position) { - options = { selection: { start: position } }; - } - - try { - const opener = await this.openerService.getOpener(toOpen, options); - opener.open(toOpen, options); - } catch (err) { - this.logger.error('Cannot open link ' + toOpen, err); - } - } - protected async extractPosition(link: string): Promise { const matches: string[] | null = (await this.createRegExp()).exec(link); const info: Position = { line: 1, character: 1 }; diff --git a/packages/terminal/src/browser/terminal-file-opening-link-provider.ts b/packages/terminal/src/browser/terminal-file-opening-link-provider.ts new file mode 100644 index 0000000000000..100a2aad23b15 --- /dev/null +++ b/packages/terminal/src/browser/terminal-file-opening-link-provider.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +// Copyright (C) 2026 JuliaHub, Inc. and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ILogger } from '@theia/core'; +import { OpenerService } from '@theia/core/lib/browser'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { Position } from '@theia/editor/lib/browser'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { TerminalWidget } from './base/terminal-widget'; +import { TerminalLink, TerminalLinkProvider } from './terminal-link-provider'; + +/** + * Base for terminal link providers that resolve a matched string to a local file URI and open it in + * the editor. Subclasses contribute their own matching in {@link provideLinks} and a named logger. + */ +@injectable() +export abstract class AbstractFileOpeningLinkProvider implements TerminalLinkProvider { + + @inject(OpenerService) protected readonly openerService: OpenerService; + @inject(FileService) protected readonly fileService: FileService; + + protected abstract readonly logger: ILogger; + + abstract provideLinks(line: string, terminal: TerminalWidget): Promise; + + protected async isValidFileURI(uri: URI): Promise { + try { + const stat = await this.fileService.resolve(uri); + return !stat.isDirectory; + } catch { } + return false; + } + + async openURI(uri: URI, position?: Position): Promise { + const options = position ? { selection: { start: position } } : {}; + try { + const opener = await this.openerService.getOpener(uri, options); + await opener.open(uri, options); + } catch (err) { + this.logger.error('Cannot open link ' + uri, err); + } + } +} diff --git a/packages/terminal/src/browser/terminal-file-uri-link-provider.spec.ts b/packages/terminal/src/browser/terminal-file-uri-link-provider.spec.ts new file mode 100644 index 0000000000000..970482df71287 --- /dev/null +++ b/packages/terminal/src/browser/terminal-file-uri-link-provider.spec.ts @@ -0,0 +1,203 @@ +// ***************************************************************************** +// Copyright (C) 2026 JuliaHub, Inc. and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +const disableJSDOM = enableJSDOM(); +// Importing the provider pulls in xterm, which probes a canvas 2d context on load; JSDOM has no +// canvas backend, so stub it out to keep this spec runnable without the native `canvas` package. +(HTMLCanvasElement.prototype as unknown as { getContext: () => unknown }).getContext = () => undefined; + +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; +FrontendApplicationConfigProvider.set({}); + +import * as chai from 'chai'; +import { ILogger } from '@theia/core'; +import { OpenerService, OpenHandler } from '@theia/core/lib/browser'; +import URI from '@theia/core/lib/common/uri'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { TerminalWidget } from './base/terminal-widget'; +import { FileUriLinkProvider } from './terminal-file-uri-link-provider'; + +disableJSDOM(); + +const expect = chai.expect; + +class TestFileUriLinkProvider extends FileUriLinkProvider { + readonly loggedErrors: string[] = []; + readonly opened: string[] = []; + readonly openedOptions: unknown[] = []; + + constructor(existingFiles: string[], directories: string[] = []) { + super(); + const files = new Set(existingFiles); + const dirs = new Set(directories.map(uri => new URI(uri).toString())); + (this as unknown as { logger: ILogger }).logger = { + error: (message: unknown) => { this.loggedErrors.push(String(message)); } + } as unknown as ILogger; + (this as unknown as { fileService: FileService }).fileService = { + resolve: async (uri: URI) => { + if (dirs.has(uri.toString())) { + return { isDirectory: true }; + } + if (files.has(uri.toString())) { + return { isDirectory: false }; + } + throw new Error('does not exist'); + } + } as unknown as FileService; + const opener: OpenHandler = { + id: 'test', + canHandle: () => 1, + open: async (uri: URI, options?: unknown) => { + this.opened.push(uri.toString()); + this.openedOptions.push(options); + return undefined; + } + }; + (this as unknown as { openerService: OpenerService }).openerService = { + getOpener: async () => opener, + getOpeners: async () => [opener] + } as unknown as OpenerService; + } +} + +const terminal = {} as unknown as TerminalWidget; + +describe('FileUriLinkProvider', () => { + + it('should linkify and open a POSIX file:// URL that resolves to a file', async () => { + const uri = 'file:///home/user/project/file.ts'; + const provider = new TestFileUriLinkProvider([uri]); + const links = await provider.provideLinks(`open ${uri} now`, terminal); + expect(links).to.have.lengthOf(1); + expect(links[0].startIndex).to.equal('open '.length); + expect(links[0].length).to.equal(uri.length); + expect(provider.loggedErrors).to.deep.equal([]); + await links[0].handle(); + expect(provider.opened).to.deep.equal([new URI(uri).toString()]); + }); + + it('should linkify a Windows drive file:// URL', async () => { + const uri = 'file:///C:/Users/dev/file.ts'; + const provider = new TestFileUriLinkProvider([new URI(uri).toString()]); + const links = await provider.provideLinks(`see ${uri}`, terminal); + expect(links).to.have.lengthOf(1); + expect(provider.loggedErrors).to.deep.equal([]); + }); + + it('should linkify a UNC file:// URL with an authority', async () => { + const uri = 'file://server/share/file.ts'; + const provider = new TestFileUriLinkProvider([new URI(uri).toString()]); + const links = await provider.provideLinks(uri, terminal); + expect(links).to.have.lengthOf(1); + expect(provider.loggedErrors).to.deep.equal([]); + }); + + it('should not produce a link for a file:// URL that does not resolve, without throwing or logging', async () => { + const provider = new TestFileUriLinkProvider([]); + const links = await provider.provideLinks('open file:///home/user/missing.ts now', terminal); + expect(links).to.deep.equal([]); + expect(provider.loggedErrors).to.deep.equal([]); + }); + + it('should ignore http(s) URLs', async () => { + const provider = new TestFileUriLinkProvider([]); + const links = await provider.provideLinks('see https://example.com/path for details', terminal); + expect(links).to.deep.equal([]); + }); + + it('should trim trailing sentence punctuation from the matched URL', async () => { + const uri = 'file:///home/user/project/file.ts'; + const provider = new TestFileUriLinkProvider([uri]); + const links = await provider.provideLinks(`the file is ${uri}.`, terminal); + expect(links).to.have.lengthOf(1); + expect(links[0].length).to.equal(uri.length); + }); + + it('should open at the given line and column for a file://…:line:column suffix', async () => { + const uri = 'file:///home/user/project/file.ts'; + const provider = new TestFileUriLinkProvider([uri]); + const match = `${uri}:10:5`; + const links = await provider.provideLinks(`error at ${match}`, terminal); + expect(links).to.have.lengthOf(1); + // The whole `file://…:10:5` is clickable, but the URI drops the position suffix. + expect(links[0].length).to.equal(match.length); + await links[0].handle(); + expect(provider.opened).to.deep.equal([new URI(uri).toString()]); + // one-based `10:5` becomes a zero-based editor position. + expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 9, character: 4 } } }]); + }); + + it('should open at the given line with a file://…:line suffix (no column)', async () => { + const uri = 'file:///home/user/project/file.ts'; + const provider = new TestFileUriLinkProvider([uri]); + const links = await provider.provideLinks(`${uri}:42`, terminal); + expect(links).to.have.lengthOf(1); + await links[0].handle(); + expect(provider.opened).to.deep.equal([new URI(uri).toString()]); + expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 41, character: 0 } } }]); + }); + + it('should keep the Windows drive colon and still apply a trailing position', async () => { + const uri = 'file:///C:/Users/dev/file.ts'; + const provider = new TestFileUriLinkProvider([new URI(uri).toString()]); + const links = await provider.provideLinks(`${uri}:7:3`, terminal); + expect(links).to.have.lengthOf(1); + await links[0].handle(); + expect(provider.opened).to.deep.equal([new URI(uri).toString()]); + expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 6, character: 2 } } }]); + }); + + it('should clamp a zero line/column to the first position', async () => { + const uri = 'file:///home/user/project/file.ts'; + const provider = new TestFileUriLinkProvider([uri]); + const links = await provider.provideLinks(`${uri}:0`, terminal); + expect(links).to.have.lengthOf(1); + await links[0].handle(); + expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 0, character: 0 } } }]); + }); + + it('should not produce a link for a file:// URL that resolves to a directory', async () => { + const uri = 'file:///home/user/project'; + const provider = new TestFileUriLinkProvider([], [uri]); + const links = await provider.provideLinks(`open ${uri} now`, terminal); + expect(links).to.deep.equal([]); + expect(provider.loggedErrors).to.deep.equal([]); + }); + + it('should linkify every file:// URL on a line at the correct offsets', async () => { + const a = 'file:///home/user/a.ts'; + const b = 'file:///home/user/b.ts'; + const provider = new TestFileUriLinkProvider([a, b]); + const line = `first ${a} then ${b}`; + const links = await provider.provideLinks(line, terminal); + expect(links).to.have.lengthOf(2); + expect(links.map(link => link.startIndex)).to.deep.equal([line.indexOf(a), line.indexOf(b)]); + expect(links.map(link => line.substr(link.startIndex, link.length))).to.deep.equal([a, b]); + }); + + it('should not leak regex state across concurrent invocations', async () => { + const a = 'file:///home/user/a.ts'; + const b = 'file:///home/user/b.ts'; + const provider = new TestFileUriLinkProvider([a, b]); + const [linksA, linksB] = await Promise.all([ + provider.provideLinks(`x ${a}`, terminal), + provider.provideLinks(b, terminal) + ]); + expect(linksA.map(link => link.length)).to.deep.equal([a.length]); + expect(linksB.map(link => link.length)).to.deep.equal([b.length]); + }); +}); diff --git a/packages/terminal/src/browser/terminal-file-uri-link-provider.ts b/packages/terminal/src/browser/terminal-file-uri-link-provider.ts new file mode 100644 index 0000000000000..c13bbf0710f3b --- /dev/null +++ b/packages/terminal/src/browser/terminal-file-uri-link-provider.ts @@ -0,0 +1,91 @@ +// ***************************************************************************** +// Copyright (C) 2026 JuliaHub, Inc. and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ILogger } from '@theia/core'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { Position } from '@theia/editor/lib/browser'; +import { AbstractFileOpeningLinkProvider } from './terminal-file-opening-link-provider'; +import { TerminalWidget } from './base/terminal-widget'; +import { TerminalLink } from './terminal-link-provider'; + +/** + * Linkifies `file://` URLs printed in the terminal and opens the referenced local file in the editor. + * + * A dedicated provider is needed because `UrlLinkProvider` only matches `http(s)` and + * `FileLinkProvider`'s path regex excludes `:`, so neither recognizes the `file:` scheme. + */ +@injectable() +export class FileUriLinkProvider extends AbstractFileOpeningLinkProvider { + + @inject(ILogger) @named('terminal:FileUriLinkProvider') + protected readonly logger: ILogger; + + // The optional third slash denotes an empty authority (`file:///path`); the match runs to the first + // whitespace or character that commonly delimits a URL in prose. + protected readonly fileUriRegExp = /file:\/\/\/?[^\s'"`<>()[\]]+/g; + + async provideLinks(line: string, terminal: TerminalWidget): Promise { + // Collect all matches synchronously before awaiting: the `g`-flag regex is instance-shared, so + // yielding mid-iteration would let a concurrent call corrupt its `lastIndex`. + const candidates: Array<{ match: string, startIndex: number }> = []; + this.fileUriRegExp.lastIndex = 0; + let regExpResult: RegExpExecArray | null; + while (regExpResult = this.fileUriRegExp.exec(line)) { + candidates.push({ match: this.trimTrailingPunctuation(regExpResult[0]), startIndex: regExpResult.index }); + } + const links = await Promise.all(candidates.map(async ({ match, startIndex }) => { + const { fileUri, position } = this.splitPosition(match); + const uri = this.toURI(fileUri); + if (uri && await this.isValidFileURI(uri)) { + return { startIndex, length: match.length, handle: () => this.openURI(uri, position) }; + } + return undefined; + })); + return links.filter((link): link is TerminalLink => link !== undefined); + } + + /** + * Splits a trailing `:line` or `:line:column` suffix (as in `file:///x.ts:10:5`) off the matched + * URL into a one-based editor position. The drive colon of `file:///C:/x.ts` is not affected, as + * it is not followed by digits at the end of the string. + */ + protected splitPosition(match: string): { fileUri: string, position?: Position } { + const positionMatch = /:(\d+)(?::(\d+))?$/.exec(match); + if (!positionMatch) { + return { fileUri: match }; + } + const line = Math.max(0, parseInt(positionMatch[1], 10) - 1); + const character = positionMatch[2] !== undefined ? Math.max(0, parseInt(positionMatch[2], 10) - 1) : 0; + return { + fileUri: match.slice(0, positionMatch.index), + position: { line, character } + }; + } + + protected toURI(match: string): URI | undefined { + try { + const uri = new URI(match); + return uri.scheme === 'file' ? uri : undefined; + } catch { + return undefined; + } + } + + protected trimTrailingPunctuation(match: string): string { + return match.replace(/[.,;:]+$/, ''); + } +} diff --git a/packages/terminal/src/browser/terminal-frontend-module.ts b/packages/terminal/src/browser/terminal-frontend-module.ts index 871e2d5a66409..5e747c657afa2 100644 --- a/packages/terminal/src/browser/terminal-frontend-module.ts +++ b/packages/terminal/src/browser/terminal-frontend-module.ts @@ -43,6 +43,7 @@ import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/qui import { createXtermLinkFactory, TerminalLinkProvider, TerminalLinkProviderContribution, XtermLinkFactory } from './terminal-link-provider'; import { UrlLinkProvider } from './terminal-url-link-provider'; import { FileDiffPostLinkProvider, FileDiffPreLinkProvider, FileLinkProvider, LocalFileLinkProvider } from './terminal-file-link-provider'; +import { FileUriLinkProvider } from './terminal-file-uri-link-provider'; import { ContributedTerminalProfileStore, DefaultProfileStore, DefaultTerminalProfileService, TerminalProfileService, TerminalProfileStore, UserTerminalProfileStore @@ -120,6 +121,8 @@ export default new ContainerModule(bind => { // default terminal link provider bind(UrlLinkProvider).toSelf().inSingletonScope(); bind(TerminalLinkProvider).toService(UrlLinkProvider); + bind(FileUriLinkProvider).toSelf().inSingletonScope(); + bind(TerminalLinkProvider).toService(FileUriLinkProvider); bind(FileLinkProvider).toSelf().inSingletonScope(); bind(TerminalLinkProvider).toService(FileLinkProvider); bind(FileDiffPreLinkProvider).toSelf().inSingletonScope(); From 1321fbca9addb502017a04359f6850b854aa7e3c Mon Sep 17 00:00:00 2001 From: Cedrik Ewers Date: Fri, 17 Jul 2026 12:17:55 +0200 Subject: [PATCH 2/3] Fix button order in untitled workspace exit dialog (#17787) Align the button order in the untitled workspace exit dialog to match the layout of most other dialogs ("Cancel" -> "Don't Save" -> "Save"). See `ShouldSaveDialog` in packages/core/src/browser/saveable.ts for reference. --- .../workspace/src/browser/untitled-workspace-exit-dialog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workspace/src/browser/untitled-workspace-exit-dialog.ts b/packages/workspace/src/browser/untitled-workspace-exit-dialog.ts index cca1e15ec6b8d..233e7947d77c0 100644 --- a/packages/workspace/src/browser/untitled-workspace-exit-dialog.ts +++ b/packages/workspace/src/browser/untitled-workspace-exit-dialog.ts @@ -35,8 +35,8 @@ export class UntitledWorkspaceExitDialog extends AbstractDialog Date: Fri, 17 Jul 2026 20:12:30 +0700 Subject: [PATCH 3/3] feat(ai-chat): enhance command parsing and integrate PromptService for command disambiguation (#17661) Co-authored-by: Nina Doschek --- .../src/common/chat-request-parser.spec.ts | 70 ++++++++++++++++++- .../ai-chat/src/common/chat-request-parser.ts | 57 +++++++++++++-- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/packages/ai-chat/src/common/chat-request-parser.spec.ts b/packages/ai-chat/src/common/chat-request-parser.spec.ts index f1ef4e76a6bd0..6b25ca60c0dec 100644 --- a/packages/ai-chat/src/common/chat-request-parser.spec.ts +++ b/packages/ai-chat/src/common/chat-request-parser.spec.ts @@ -20,7 +20,7 @@ import { ChatRequestParserImpl } from './chat-request-parser'; import { ChatAgent, ChatAgentLocation } from './chat-agents'; import { ChatContext, ChatRequest } from './chat-model'; import { expect } from 'chai'; -import { AIVariable, DefaultAIVariableService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core'; +import { AIVariable, DefaultAIVariableService, PromptService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core'; import { ILogger, Logger } from '@theia/core'; import { ParsedChatRequestAgentPart, ParsedChatRequestFunctionPart, ParsedChatRequestTextPart, ParsedChatRequestVariablePart } from './parsed-chat-request'; import { AgentDelegationTool } from '../browser/agent-delegation-tool'; @@ -276,6 +276,74 @@ describe('ChatRequestParserImpl', () => { expect(varPart.variableArg).to.equal('cmd|"arg with \\"quote\\"" other'); }); + it('parses multiple commands in one message', async () => { + const req: ChatRequest = { + text: '/skill-one /skill-two' + }; + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); + + expect(result.parts.length).to.equal(3); + const firstCommand = result.parts[0] as ParsedChatRequestVariablePart; + const separator = result.parts[1] as ParsedChatRequestTextPart; + const secondCommand = result.parts[2] as ParsedChatRequestVariablePart; + expect(firstCommand.variableName).to.equal('prompt'); + expect(firstCommand.variableArg).to.equal('skill-one'); + expect(separator.text).to.equal(' '); + expect(secondCommand.variableName).to.equal('prompt'); + expect(secondCommand.variableArg).to.equal('skill-two'); + }); + + it('inserts a separator between two argument-taking commands', async () => { + const req: ChatRequest = { + text: '/summarize foo /hello bar' + }; + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); + + expect(result.parts.length).to.equal(3); + const firstCommand = result.parts[0] as ParsedChatRequestVariablePart; + const separator = result.parts[1] as ParsedChatRequestTextPart; + const secondCommand = result.parts[2] as ParsedChatRequestVariablePart; + expect(firstCommand.variableArg).to.equal('summarize|foo'); + expect(separator.kind).to.equal('text'); + expect(separator.text).to.equal(' '); + expect(secondCommand.variableArg).to.equal('hello|bar'); + }); + + it('keeps path-like slash arguments as command arguments', async () => { + const req: ChatRequest = { + text: '/explain /path/to/file' + }; + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); + + expect(result.parts.length).to.equal(1); + const varPart = result.parts[0] as ParsedChatRequestVariablePart; + expect(varPart.variableName).to.equal('prompt'); + expect(varPart.variableArg).to.equal('explain|/path/to/file'); + }); + + it('uses known command names to disambiguate slash command arguments', async () => { + const promptService = { + getCommands: () => [ + { id: 'command-skill-one', template: '', isCommand: true, commandName: 'skill-one' }, + { id: 'command-skill-two', template: '', isCommand: true, commandName: 'skill-two' }, + ] + } as unknown as PromptService; + const parserWithPromptService = new ChatRequestParserImpl(chatAgentService, variableService, toolInvocationRegistry, logger); + (parserWithPromptService as unknown as { promptService: PromptService }).promptService = promptService; + const context: ChatContext = { variables: [] }; + + const multipleCommands = await parserWithPromptService.parseChatRequest({ text: '/skill-one /skill-two' }, ChatAgentLocation.Panel, context); + expect((multipleCommands.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-one'); + expect((multipleCommands.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-two'); + + const pathArgument = await parserWithPromptService.parseChatRequest({ text: '/skill-one /tmp' }, ChatAgentLocation.Panel, context); + expect(pathArgument.parts.length).to.equal(1); + expect((pathArgument.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-one|/tmp'); + }); + it('treats the first @agent mention as the selector and does not allow later mentions to override it', async () => { const createAgent = (id: string): ChatAgent => ({ id, diff --git a/packages/ai-chat/src/common/chat-request-parser.ts b/packages/ai-chat/src/common/chat-request-parser.ts index 494aa79f3c68a..c09750f29d81f 100644 --- a/packages/ai-chat/src/common/chat-request-parser.ts +++ b/packages/ai-chat/src/common/chat-request-parser.ts @@ -19,7 +19,7 @@ *--------------------------------------------------------------------------------------------*/ // Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatRequestParser.ts -import { inject, injectable } from '@theia/core/shared/inversify'; +import { inject, injectable, optional } from '@theia/core/shared/inversify'; import { ChatAgentService } from './chat-agent-service'; import { ChatAgentLocation } from './chat-agents'; import { ChatContext, ChatRequest } from './chat-model'; @@ -37,7 +37,7 @@ import { ParsedChatRequestPart, } from './parsed-chat-request'; import { - AIVariable, AIVariableService, createAIResolveVariableCache, getAllResolvedAIVariables, parseFunctionReference, ToolInvocationRegistry, ToolRequest + AIVariable, AIVariableService, createAIResolveVariableCache, getAllResolvedAIVariables, parseFunctionReference, PromptService, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core'; import { ILogger } from '@theia/core'; @@ -47,7 +47,8 @@ const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent const functionReg = /^~(\??[\w_\-\.]+)(?=(\s|$|\b))/i; const functionPromptFormatReg = /^\~\{\s*(.*?)\s*\}/i; const variableReg = /^#([\w_\-]+)(?::([\w_\-_\/\\.:]+))?(?=(\s|$|\b))/i; // A #-variable with an optional : arg (#file:workspace/path/name.ext) -const commandReg = /^\/([\w_\-]+)(?:\s+(.+?))?(?=\s*$)/; // A /-command with optional arguments (/commandname arg1 arg2) +const commandReg = /^\/([\w_\-]+)/; // A /-command (/commandname) with optional arguments parsed separately +const nextCommandReg = /\s+\/([\w_\-]+)(?=\s|$)/g; export const ChatRequestParser = Symbol('ChatRequestParser'); export interface ChatRequestParser { @@ -62,11 +63,15 @@ function offsetRange(start: number, endExclusive: number): OffsetRange { } @injectable() export class ChatRequestParserImpl implements ChatRequestParser { + + @inject(PromptService) @optional() + protected readonly promptService?: PromptService; + constructor( @inject(ChatAgentService) private readonly agentService: ChatAgentService, @inject(AIVariableService) private readonly variableService: AIVariableService, @inject(ToolInvocationRegistry) private readonly toolInvocationRegistry: ToolInvocationRegistry, - @inject(ILogger) private readonly logger: ILogger + @inject(ILogger) private readonly logger: ILogger, ) { } async parseChatRequest(request: ChatRequest, location: ChatAgentLocation, context: ChatContext): Promise { @@ -185,6 +190,7 @@ export class ChatRequestParserImpl implements ChatRequestParser { } parts.push(newPart); + i = newPart.range.endExclusive - 1; } } @@ -292,13 +298,52 @@ export class ChatRequestParserImpl implements ChatRequestParser { return; } - const [full, commandName, commandArgs] = nextCommandMatch; - const commandRange = offsetRange(offset, offset + full.length); + const [commandText, commandName] = nextCommandMatch; + let commandEnd = commandText.length; + let commandArgs: string | undefined; + + const nextCommandOffset = this.findNextCommandOffset(message, commandEnd); + const argsEnd = nextCommandOffset ?? message.length; + const rawArgs = message.slice(commandEnd, argsEnd); + const args = rawArgs.trim(); + if (args) { + commandArgs = args; + // Advance the consumed range only up to the end of the trimmed argument, not up to the + // next command. This keeps the whitespace between the argument and a following command + // out of this part's range, so parseParts emits it as a separator text part. Otherwise + // the two resolved prompt fragments would run into each other with no space between them. + commandEnd = argsEnd - (rawArgs.length - rawArgs.trimEnd().length); + } + + const commandRange = offsetRange(offset, offset + commandEnd); const variableArg = commandArgs ? `${commandName}|${commandArgs}` : commandName; return new ParsedChatRequestVariablePart(commandRange, 'prompt', variableArg); } + private findNextCommandOffset(message: string, startOffset: number): number | undefined { + nextCommandReg.lastIndex = startOffset; + let match = nextCommandReg.exec(message); + while (match) { + const commandName = match[1]; + // Deliberate behavior difference: without a PromptService we cannot tell commands from + // path-like arguments, so any `/word` token is treated as a command boundary. With a + // PromptService we only break on known command names and keep unknown `/word` tokens + // (e.g. `/tmp`, `/path/to/file`) as part of the current command's arguments. + if (!this.promptService || this.isKnownCommand(commandName)) { + return match.index + match[0].indexOf(chatSubcommandLeader); + } + match = nextCommandReg.exec(message); + } + return undefined; + } + + private isKnownCommand(commandName: string): boolean { + return this.promptService?.getCommands().some(command => + (command.commandName ?? command.id) === commandName + ) ?? false; + } + private tryToParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined { // Support both the chat and prompt formats for functions const nextFunctionMatch = message.match(functionPromptFormatReg) || message.match(functionReg);