Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 69 additions & 1 deletion packages/ai-chat/src/common/chat-request-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 51 additions & 6 deletions packages/ai-chat/src/common/chat-request-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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 {
Expand All @@ -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<ParsedChatRequest> {
Expand Down Expand Up @@ -185,6 +190,7 @@ export class ChatRequestParserImpl implements ChatRequestParser {
}

parts.push(newPart);
i = newPart.range.endExclusive - 1;
}
}

Expand Down Expand Up @@ -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);
Expand Down
31 changes: 3 additions & 28 deletions packages/terminal/src/browser/terminal-file-link-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -71,14 +68,6 @@ export class FileLinkProvider implements TerminalLinkProvider {
return false;
}

protected async isValidFileURI(uri: URI): Promise<boolean> {
try {
const stat = await this.fileService.resolve(uri);
return !stat.isDirectory;
} catch { }
return false;
}

protected async toURI(match: string, cwd: URI): Promise<URI | undefined> {
const path = await this.extractPath(match);
if (!path) {
Expand Down Expand Up @@ -120,20 +109,6 @@ export class FileLinkProvider implements TerminalLinkProvider {
return this.openURI(toOpen, position);
}

async openURI(toOpen: URI, position: Position): Promise<void> {
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<Position> {
const matches: string[] | null = (await this.createRegExp()).exec(link);
const info: Position = { line: 1, character: 1 };
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TerminalLink[]>;

protected async isValidFileURI(uri: URI): Promise<boolean> {
try {
const stat = await this.fileService.resolve(uri);
return !stat.isDirectory;
} catch { }
return false;
}

async openURI(uri: URI, position?: Position): Promise<void> {
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);
}
}
}
Loading
Loading