From 4994a1aa3f0820bb1daa84a058e83c0e93824437 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 21 May 2026 14:46:11 +0800 Subject: [PATCH 1/4] improve terminal command execution across windows and linux --- .../scripts/copilot-bash-integration.sh | 33 ++ .../copilot-powershell-integration.ps1 | 46 +++ .../scripts/copilot-sh-integration.sh | 32 -- .../terminal/api/IRunInTerminalTool.java | 16 +- .../terminal/api/ShellIntegrationScripts.java | 53 +++- .../api/TerminalCommandProcessor.java | 292 ++++++++++++++++++ .../ui/terminal/tm/RunInTerminalTool.java | 222 +++++++------ .../ui/terminal/RunInTerminalTool.java | 224 ++++++++------ .../api/TerminalCommandProcessorTest.java | 164 ++++++++++ .../tools/RunInTerminalToolAdapterTest.java | 22 +- .../copilot/eclipse/ui/chat/ChatView.java | 15 + .../chat/tools/RunInTerminalToolAdapter.java | 100 +++++- 12 files changed, 965 insertions(+), 254 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh create mode 100644 com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 delete mode 100644 com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh create mode 100644 com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh new file mode 100644 index 00000000..855f2b4a --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh @@ -0,0 +1,33 @@ +# Copilot Shell Integration for Bash +# This script is loaded with bash --init-file when starting a terminal. + +__copilot_bash_integration_main() { + [ -n "${COPILOT_BASH_INTEGRATION:-}" ] && return + COPILOT_BASH_INTEGRATION=1 + + bind 'set enable-bracketed-paste on' 2>/dev/null || true + __copilot_prompt_initialized=0 + + __copilot_precmd() { + __copilot_status=$? + if [ "${__copilot_prompt_initialized:-0}" = "1" ]; then + printf '\033]7775;C;%s\007' "$__copilot_status" + else + __copilot_prompt_initialized=1 + fi + printf '\033]7775;A\007' + } + + __copilot_prompt_end() { + printf '\033]7775;B\007' + } + + if [ -z "${__copilot_original_ps1:-}" ]; then + __copilot_original_ps1=${PS1:-'\$ '} + fi + + PROMPT_COMMAND=__copilot_precmd + PS1="${__copilot_original_ps1}"'\[$(__copilot_prompt_end)\]' +} + +__copilot_bash_integration_main \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 new file mode 100644 index 00000000..18b097d0 --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 @@ -0,0 +1,46 @@ +# Copilot Shell Integration for Windows PowerShell +# This script is loaded when starting a Copilot terminal. + +try { + $global:OutputEncoding = New-Object System.Text.UTF8Encoding $false + [Console]::InputEncoding = $global:OutputEncoding + [Console]::OutputEncoding = $global:OutputEncoding + $env:PYTHONIOENCODING = "utf-8" + $env:PYTHONUTF8 = "1" +} catch { + # Some hosts do not expose console encodings during startup. +} + +if (-not $global:COPILOT_SHELL_INTEGRATION) { + $global:COPILOT_SHELL_INTEGRATION = $true + $global:__copilot_original_prompt = (Get-Command prompt -CommandType Function).ScriptBlock + $global:__copilot_last_history_id = -1 + + function global:prompt { + $esc = [char]27 + $bel = [char]7 + $lastHistoryEntry = Get-History -Count 1 + $result = "" + + if ($global:__copilot_last_history_id -ne -1) { + if ($lastHistoryEntry.Id -eq $global:__copilot_last_history_id) { + $result += "$esc]7775;C$bel" + } else { + $exitCode = [int](!$global:?) + $result += "$esc]7775;C;$exitCode$bel" + } + } + + $result += "$esc]7775;A$bel" + + if ($global:__copilot_original_prompt) { + $result += $global:__copilot_original_prompt.Invoke() + } else { + $result += "PS $($executionContext.SessionState.Path.CurrentLocation)> " + } + + $result += "$esc]7775;B$bel" + $global:__copilot_last_history_id = $lastHistoryEntry.Id + $result + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh deleted file mode 100644 index 4b365b14..00000000 --- a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh +++ /dev/null @@ -1,32 +0,0 @@ -# Copilot Shell Integration for POSIX sh -# This script is sourced via ENV when starting a terminal. - -__copilot_sh_integration_main() { - # Avoid multiple initialization - [ -n "$COPILOT_SHELL_INTEGRATION" ] && return - COPILOT_SHELL_INTEGRATION=1 - - # OSC escape sequence: ESC ] 7775 ; C BEL - # This is invisible in terminal but detectable programmatically - __COPILOT_MARKER=$(printf '\033]7775;C\007') - - # The function that prints the marker - __copilot_precmd() { - printf '%s' "$__COPILOT_MARKER" - } - - # Save original PS1 only if PS1 is already set and not empty - if [ -z "$__copilot_original_ps1" ] && [ -n "$PS1" ]; then - __copilot_original_ps1=$PS1 - fi - - # Ensure PS1 has a value (POSIX shells may start without PS1) - : "${__copilot_original_ps1:=$ }" - - # Assemble PS1: - # (invisible OSC sequence) - # - PS1="\$(__copilot_precmd)${__copilot_original_ps1}" -} - -__copilot_sh_integration_main \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java index b3773000..b91429e1 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java @@ -16,22 +16,25 @@ public interface IRunInTerminalTool { /** - * Executes a command in the terminal. + * Executes a command in the terminal with an initial working directory. * * @param command The command to execute. * @param isBackground Whether the command should run in the background. + * @param workingDirectory The terminal's initial working directory. * @return A CompletableFuture that resolves to the output of the command. */ - public CompletableFuture executeCommand(String command, boolean isBackground); + public CompletableFuture executeCommand(String command, boolean isBackground, String workingDirectory); /** - * Prepares terminal properties for the command execution. + * Prepares terminal properties for the command execution with an initial working directory. * * @param runInBackground Whether the command should run in the background. * @param executionId The unique identifier for the execution. + * @param workingDirectory The terminal's initial working directory. * @return A map containing terminal properties. */ - public Map prepareTerminalProperties(boolean runInBackground, String executionId); + public Map prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory); /** * Retrieves the output of a background command execution. @@ -41,6 +44,11 @@ public interface IRunInTerminalTool { */ public StringBuilder getBackgroundCommandOutput(String executionId); + /** + * Cancels the foreground terminal command if one is currently running. + */ + public void cancelCurrentCommand(); + /** * Sets the terminal icon descriptor for the tool. */ diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java index cd527c3a..6bab76f4 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java @@ -19,33 +19,62 @@ */ public final class ShellIntegrationScripts { - /** - * The marker string output by the shell integration script after each command completes. - * Uses OSC (Operating System Command) escape sequence format: ESC ] 7775 ; marker BEL - * This is invisible in terminal output but can be detected programmatically. - */ - public static final String SHELL_MARKER = "\u001b]7775;C\u0007"; + /** OSC namespace used by Copilot shell integration markers. */ + public static final String OSC_NAMESPACE = "7775"; + + /** Marker emitted when the prompt starts. */ + public static final String PROMPT_START_MARKER = "\u001b]" + OSC_NAMESPACE + ";A\u0007"; + + /** Marker emitted when the prompt ends. */ + public static final String PROMPT_END_MARKER = "\u001b]" + OSC_NAMESPACE + ";B\u0007"; + + /** Marker emitted when a command finishes without an exit code. */ + public static final String COMMAND_FINISH_MARKER = "\u001b]" + OSC_NAMESPACE + ";C\u0007"; + + /** Marker prefix emitted when a command finishes with an exit code. */ + public static final String COMMAND_FINISH_MARKER_PREFIX = "\u001b]" + OSC_NAMESPACE + ";C;"; + + /** Pattern matching Copilot OSC markers, including markers that lost ESC/BEL during terminal processing. */ + public static final String OSC_MARKER_PATTERN = buildOscMarkerPattern(); private static final String SCRIPTS_PATH = "scripts/"; - private static final String SH_SCRIPT = "copilot-sh-integration.sh"; + private static final String BASH_SCRIPT = "copilot-bash-integration.sh"; + private static final String POWERSHELL_SCRIPT = "copilot-powershell-integration.ps1"; private ShellIntegrationScripts() { // Utility class } + private static String buildOscMarkerPattern() { + return "(?:\u001B)?\\]?" + OSC_NAMESPACE + ";[ABC](?:;[-]?\\d+)?(?:\u0007|\u001B\\\\)?"; + } + + /** + * Gets the absolute file path to the Bash integration script. + * + * @return the absolute path to the Bash script, or null if not found + */ + public static String getBashScriptPath() { + return getScriptPath(BASH_SCRIPT); + } + /** - * Gets the absolute file path to the POSIX sh integration script. + * Gets the absolute file path to the PowerShell integration script. * - * @return the absolute path to the sh script, or null if not found + * @return the absolute path to the PowerShell script, or null if not found */ - public static String getShScriptPath() { + public static String getPowerShellScriptPath() { + return getScriptPath(POWERSHELL_SCRIPT); + } + + private static String getScriptPath(String scriptName) { try { Bundle bundle = FrameworkUtil.getBundle(ShellIntegrationScripts.class); if (bundle == null) { return null; } - URL scriptUrl = FileLocator.find(bundle, new Path(SCRIPTS_PATH + SH_SCRIPT)); + URL scriptUrl = FileLocator.find(bundle, new Path(SCRIPTS_PATH + scriptName)); if (scriptUrl == null) { return null; } @@ -60,7 +89,7 @@ public static String getShScriptPath() { return scriptFile.getAbsolutePath(); } } catch (IOException e) { - CopilotCore.LOGGER.error("Failed to locate shell integration script: " + SH_SCRIPT, e); + CopilotCore.LOGGER.error("Failed to locate shell integration script: " + scriptName, e); } return null; } diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java new file mode 100644 index 00000000..b1184770 --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.terminal.api; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Processes terminal command input and output buffers. + */ +public final class TerminalCommandProcessor { + private static final int MAX_OUTPUT_LINE_COUNT = 1000; + private static final String BRACKETED_PASTE_START = "\u001b[200~"; + private static final String BRACKETED_PASTE_END = "\u001b[201~"; + private static final String BELL = "\u0007"; + private static final String ANSI_CSI_SEQUENCE_PATTERN = "\u001B\\[(\\?)?[\\d;]*[a-zA-Z]"; + private static final String OSC_SEQUENCE_PATTERN = "\u001B\\][^\u0007\u001B]*(?:\u0007|\u001B\\\\)"; + private static final Pattern PROMPT_START_MARKER_PATTERN = buildMarkerPattern("A", false); + private static final Pattern PROMPT_END_MARKER_PATTERN = buildMarkerPattern("B", false); + private static final Pattern COMMAND_FINISH_MARKER_PATTERN = buildMarkerPattern("C", true); + + private TerminalCommandProcessor() { + // Utility class + } + + /** + * Formats a command for immediate terminal execution. + * + * @param command the command to send + * @return command text formatted for terminal input + */ + public static String formatForExecution(String command) { + String normalizedCommand = removeTrailingLineEndings(normalizeLineEndings(command)); + String terminalInput = isMultilineCommand(normalizedCommand) + ? BRACKETED_PASTE_START + normalizedCommand + BRACKETED_PASTE_END + : normalizedCommand; + terminalInput = terminalInput.replace('\n', '\r'); + if (!terminalInput.endsWith("\r")) { + terminalInput += "\r"; + } + return terminalInput; + } + + /** + * Truncates terminal output to the tail when it is too long for a tool result. + * + * @param output terminal output + * @return terminal output, truncated to the last lines when needed + */ + public static String truncateOutput(String output) { + if (output == null || output.isEmpty()) { + return output == null ? "" : output; + } + + String normalizedOutput = normalizeLineEndings(output); + boolean endsWithNewline = normalizedOutput.endsWith("\n"); + String[] lines = normalizedOutput.split("\n", -1); + int lineCount = endsWithNewline ? lines.length - 1 : lines.length; + if (lineCount <= MAX_OUTPUT_LINE_COUNT) { + return output; + } + + int firstLineToKeep = lineCount - MAX_OUTPUT_LINE_COUNT; + StringBuilder truncatedOutput = new StringBuilder(); + truncatedOutput.append("[Terminal output truncated: showing last ") + .append(MAX_OUTPUT_LINE_COUNT) + .append(" of ") + .append(lineCount) + .append(" lines.]\n"); + for (int lineIndex = firstLineToKeep; lineIndex < lineCount; lineIndex++) { + if (lineIndex > firstLineToKeep) { + truncatedOutput.append('\n'); + } + truncatedOutput.append(lines[lineIndex]); + } + if (endsWithNewline) { + truncatedOutput.append('\n'); + } + return truncatedOutput.toString(); + } + + /** + * Cleans terminal output for model-visible tool results. + * + * @param output terminal output + * @return output with terminal control sequences removed and long output truncated + */ + public static String prepareOutputForModel(String output) { + return truncateOutput(cleanTerminalControlSequences(output)); + } + + /** + * Attempts to complete a command using shell integration markers. + * + * @param output terminal output buffer + * @param activeCommand command currently being executed + * @param skipCompletion whether to skip the next completed command region + * @return the completion check result + */ + public static CompletionCheckResult tryCompleteWithMarker(StringBuilder output, String activeCommand, + boolean skipCompletion) { + MarkerRange commandFinishMarkerRange = findCommandFinishMarker(output); + if (commandFinishMarkerRange == null) { + // Startup or idle prompts can arrive before a command runs. Keep the visible prompt text, but remove marker + // bytes so later command output cleanup does not have to handle stale prompt boundaries. + removePromptMarkers(output); + return CompletionCheckResult.incomplete(); + } + + // The command-finished marker is emitted before the next prompt. Wait for prompt end so the returned output keeps + // the prompt line, which gives the language model the terminal's current working directory. + MarkerRange promptEndMarkerRange = findMarker(output, PROMPT_END_MARKER_PATTERN, commandFinishMarkerRange.endIndex); + if (promptEndMarkerRange == null) { + return CompletionCheckResult.incomplete(); + } + + if (skipCompletion) { + // This region belongs to a command that was interrupted by Ctrl+C. Drop it so it cannot complete the next + // foreground command that may already be listening on the same terminal output buffer. + output.delete(0, promptEndMarkerRange.endIndex); + return CompletionCheckResult.skipped(); + } + + // Keep command output plus the next prompt, but remove the command-finished marker itself, including exit code. + String completedOutput = output.substring(0, commandFinishMarkerRange.startIndex) + + output.substring(commandFinishMarkerRange.endIndex, promptEndMarkerRange.endIndex); + return CompletionCheckResult.completed(cleanCommandOutput(completedOutput, activeCommand)); + } + + /** + * Attempts to complete a command by detecting a shell prompt. + * + * @param output terminal output buffer + * @param skipCompletion whether to skip the next completed command region + * @return the completion check result + */ + public static CompletionCheckResult tryCompleteWithPrompt(StringBuilder output, boolean skipCompletion) { + String terminalOutput = output.toString().trim(); + int lastNewLineIndex = terminalOutput.lastIndexOf('\n'); + if (lastNewLineIndex <= 0) { + return CompletionCheckResult.incomplete(); + } + + String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); + if (lastLine.isBlank() || lastLine.length() == 1) { + return CompletionCheckResult.incomplete(); + } + + char lastChar = lastLine.charAt(lastLine.length() - 1); + boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; + if (!isPromptChar) { + return CompletionCheckResult.incomplete(); + } + + if (skipCompletion) { + output.setLength(0); + return CompletionCheckResult.skipped(); + } + + String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); + int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); + if (promptStartIndex == -1) { + promptStartIndex = 0; + } else { + promptStartIndex += lastLine.length(); + } + + if (contentWithoutLastPrompt.isBlank()) { + return CompletionCheckResult.incomplete(); + } + return CompletionCheckResult.completed(contentWithoutLastPrompt.substring(promptStartIndex).trim()); + } + + private static String removeBracketedPasteMarkers(String output) { + return output.replace(BRACKETED_PASTE_START, "").replace(BRACKETED_PASTE_END, ""); + } + + private static boolean isMultilineCommand(String command) { + int newlineIndex = command.indexOf('\n'); + while (newlineIndex >= 0) { + if (newlineIndex == 0 || command.charAt(newlineIndex - 1) != '\\') { + return true; + } + newlineIndex = command.indexOf('\n', newlineIndex + 1); + } + return false; + } + + private static String removeTrailingLineEndings(String value) { + int endIndex = value.length(); + while (endIndex > 0 && value.charAt(endIndex - 1) == '\n') { + endIndex--; + } + return value.substring(0, endIndex); + } + + private static MarkerRange findCommandFinishMarker(StringBuilder output) { + return findMarker(output, COMMAND_FINISH_MARKER_PATTERN, 0); + } + + private static MarkerRange findMarker(StringBuilder output, Pattern markerPattern, int startIndex) { + Matcher matcher = markerPattern.matcher(output); + if (!matcher.find(startIndex)) { + return null; + } + return new MarkerRange(matcher.start(), matcher.end()); + } + + private static void removePromptMarkers(StringBuilder output) { + removeAll(output, PROMPT_START_MARKER_PATTERN); + removeAll(output, PROMPT_END_MARKER_PATTERN); + } + + private static void removeAll(StringBuilder output, Pattern markerPattern) { + Matcher matcher = markerPattern.matcher(output); + while (matcher.find()) { + output.delete(matcher.start(), matcher.end()); + matcher.reset(output); + } + } + + private static Pattern buildMarkerPattern(String markerKind, boolean includeExitCode) { + String exitCodePattern = includeExitCode ? "(?:;[-]?\\d+)?" : ""; + return Pattern.compile("(?:\u001B)?\\]" + ShellIntegrationScripts.OSC_NAMESPACE + ";" + markerKind + + exitCodePattern + "(?:\u0007|\u001B\\\\)?"); + } + + private static String cleanCommandOutput(String output, String activeCommand) { + String normalizedOutput = normalizeLineEndings(output); + normalizedOutput = removeShellIntegrationMarkers(normalizedOutput); + normalizedOutput = removeBracketedPasteMarkers(normalizedOutput); + String normalizedCommand = normalizeLineEndings(activeCommand == null ? "" : activeCommand).trim(); + if (normalizedCommand.isBlank()) { + return normalizedOutput.trim(); + } + int commandIndex = normalizedOutput.lastIndexOf(normalizedCommand); + if (commandIndex >= 0) { + normalizedOutput = normalizedOutput.substring(commandIndex + normalizedCommand.length()); + } + return normalizedOutput.trim(); + } + + private static String removeShellIntegrationMarkers(String output) { + return output.replaceAll(ShellIntegrationScripts.OSC_MARKER_PATTERN, "") + .replace(ShellIntegrationScripts.PROMPT_START_MARKER, "") + .replace(ShellIntegrationScripts.PROMPT_END_MARKER, "") + .replace(ShellIntegrationScripts.COMMAND_FINISH_MARKER, ""); + } + + private static String cleanTerminalControlSequences(String output) { + if (output == null || output.isEmpty()) { + return output == null ? "" : output; + } + return removeShellIntegrationMarkers(output) + .replaceAll(ANSI_CSI_SEQUENCE_PATTERN, "") + .replaceAll(OSC_SEQUENCE_PATTERN, ""); + } + + private static String normalizeLineEndings(String value) { + return value.replace("\r\n", "\n").replace('\r', '\n'); + } + + private record MarkerRange(int startIndex, int endIndex) { + } + + /** + * Result of checking a terminal output buffer for command completion. + */ + public record CompletionCheckResult(CompletionCheckState state, String output) { + private static CompletionCheckResult incomplete() { + return new CompletionCheckResult(CompletionCheckState.INCOMPLETE, ""); + } + + private static CompletionCheckResult skipped() { + return new CompletionCheckResult(CompletionCheckState.SKIPPED, ""); + } + + private static CompletionCheckResult completed(String output) { + return new CompletionCheckResult(CompletionCheckState.COMPLETED, output); + } + } + + /** + * Terminal output completion states. + */ + public enum CompletionCheckState { + INCOMPLETE, + SKIPPED, + COMPLETED + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java index 78f12c39..394f1711 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.terminal.tm; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -36,6 +37,9 @@ import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.ShellIntegrationScripts; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckResult; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; /** * terminal tool implementation for older Eclipse versions. @@ -46,6 +50,10 @@ public class RunInTerminalTool implements IRunInTerminalTool { private static final Object lock = new Object(); private static final Map backgroundCommandOutputs = new HashMap<>(); private static final String BACKGROUND_TERMINAL_PREFIX = "Copilot-"; + private static final String POWERSHELL_SCRIPT_ENV = "COPILOT_POWERSHELL_INTEGRATION_SCRIPT"; + private static final char INTERRUPT_CHARACTER = '\u0003'; + private static final String COMMAND_CANCELLED_MESSAGE = "Terminal command cancelled."; + private static final String COMMAND_INTERRUPTED_MESSAGE = "Terminal command interrupted by a new command."; // Non-background terminal field private ITerminalViewControl persistentTerminalViewControl; @@ -60,8 +68,9 @@ public class RunInTerminalTool implements IRunInTerminalTool { // Output and command state private StringBuilder sb; private CompletableFuture resultFuture; + private volatile String activeCommand; private volatile boolean useMarker; - private volatile boolean isInitialMarkerHandled; + private volatile boolean skipNextCompletionAfterInterrupt; /** * Constructor for RunInTerminalTool. @@ -71,13 +80,21 @@ public RunInTerminalTool() { } @Override - public CompletableFuture executeCommand(String command, boolean isBackground) { + public CompletableFuture executeCommand(String command, boolean isBackground, String workingDirectory) { if (StringUtils.isBlank(command)) { return CompletableFuture.completedFuture("The command is null or empty."); } + if (!isBackground) { + // A new foreground command immediately installs a new future after Ctrl+C, so the interrupted command's + // next prompt-completion marker must be skipped if it arrives after the new command starts listening. + interruptCurrentCommand(COMMAND_INTERRUPTED_MESSAGE, true); + } + resultFuture = new CompletableFuture<>(); - useMarker = Platform.getOS().equals(Platform.OS_LINUX); + CompletableFuture commandFuture = resultFuture; + useMarker = hasShellIntegrationMarker(); + activeCommand = isBackground ? null : command; if (!useMarker) { // Retain only the last line (prompt) in the output buffer @@ -93,30 +110,30 @@ public CompletableFuture executeCommand(String command, boolean isBackgr } String executionId = UUID.randomUUID().toString(); - final String finalCommand = command + System.lineSeparator(); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { revealTerminal(); - this.persistentTerminalViewControl.pasteString(finalCommand); - return this.resultFuture; + sendCommand(this.persistentTerminalViewControl, finalCommand); + return commandFuture; } ITerminalService service = TerminalServiceFactory.getService(); if (service == null) { + activeCommand = null; return CompletableFuture.completedFuture("Failed to open terminal console due to terminal service is null."); } - // New non-background terminal will have an initial marker from shell startup; need to handle it - if (useMarker && !isBackground) { - isInitialMarkerHandled = false; - } - - service.openConsole(prepareTerminalProperties(isBackground, executionId), status -> { + service.openConsole(prepareTerminalProperties(isBackground, executionId, workingDirectory), status -> { if (status.isOK()) { + if (commandFuture.isDone()) { + return; + } ITerminalViewControl terminalViewControl = finalizeTerminalSetup(executionId, isBackground); if (terminalViewControl == null) { - resultFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); + activeCommand = null; + commandFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); return; } @@ -124,9 +141,10 @@ public CompletableFuture executeCommand(String command, boolean isBackgr this.persistentTerminalViewControl = terminalViewControl; revealTerminal(); } - terminalViewControl.pasteString(finalCommand); + sendCommand(terminalViewControl, finalCommand); } else { - resultFuture.complete("Failed to open terminal console: " + status.getException()); + activeCommand = null; + commandFuture.complete("Failed to open terminal console: " + status.getException()); } }); } @@ -135,25 +153,34 @@ public CompletableFuture executeCommand(String command, boolean isBackgr return CompletableFuture.completedFuture("Command is running in terminal with ID=" + executionId); } - return resultFuture; + return commandFuture; } @Override - public Map prepareTerminalProperties(boolean runInBackground, String executionId) { + public Map prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory) { Map properties = new HashMap<>(); - properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); + if (StringUtils.isNotBlank(workingDirectory)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); + } if (Platform.getOS().equals(Platform.OS_WIN32)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "cmd.exe"); - } else if (Platform.getOS().equals(Platform.OS_LINUX)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/sh"); - // Use ENV to load shell integration script at startup - String scriptPath = ShellIntegrationScripts.getShScriptPath(); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "powershell.exe"); + String scriptPath = ShellIntegrationScripts.getPowerShellScriptPath(); if (scriptPath != null) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, new String[] { "ENV=" + scriptPath }); + String[] environment = new String[] { POWERSHELL_SCRIPT_ENV + "=" + scriptPath }; + String args = "-NoExit -ExecutionPolicy Bypass -Command \". $env:" + POWERSHELL_SCRIPT_ENV + "\""; + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, environment); properties.put(ITerminalsConnectorConstants.PROP_PROCESS_MERGE_ENVIRONMENT, true); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, args); + } + } else if (Platform.getOS().equals(Platform.OS_LINUX)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/bash"); + String scriptPath = ShellIntegrationScripts.getBashScriptPath(); + if (scriptPath != null) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, "--init-file \"" + scriptPath + "\" -i"); } } else { // macOS or other Unix-like: keep existing behavior, only set args if empty @@ -187,6 +214,64 @@ public StringBuilder getBackgroundCommandOutput(String executionId) { return output; } + @Override + public void cancelCurrentCommand() { + // User cancel completes the current future without starting a replacement command. Do not reserve a skip here, + // otherwise a later command could incorrectly skip its own completion if the interrupted prompt was already idle. + interruptCurrentCommand(COMMAND_CANCELLED_MESSAGE, false); + } + + private void interruptCurrentCommand(String completionMessage, boolean skipInterruptedCompletion) { + ITerminalViewControl terminalViewControl = null; + CompletableFuture commandFuture = null; + synchronized (lock) { + if (!hasRunningForegroundCommand()) { + if (!skipInterruptedCompletion) { + skipNextCompletionAfterInterrupt = false; + } + return; + } + activeCommand = null; + skipNextCompletionAfterInterrupt = skipInterruptedCompletion; + terminalViewControl = persistentTerminalViewControl; + commandFuture = resultFuture; + } + + if (terminalViewControl != null) { + sendInterrupt(terminalViewControl); + } + if (commandFuture != null && !commandFuture.isDone()) { + commandFuture.complete(completionMessage); + } + } + + private boolean hasRunningForegroundCommand() { + return StringUtils.isNotBlank(activeCommand) && resultFuture != null && !resultFuture.isDone(); + } + + private void sendInterrupt(ITerminalViewControl terminalViewControl) { + Display display = terminalViewControl.getControl().getDisplay(); + display.syncExec(() -> { + if (!terminalViewControl.isDisposed()) { + terminalViewControl.sendKey(INTERRUPT_CHARACTER); + } + }); + } + + private void sendCommand(ITerminalViewControl terminalViewControl, String command) { + terminalViewControl.pasteString(command); + } + + private boolean hasShellIntegrationMarker() { + if (Platform.getOS().equals(Platform.OS_WIN32)) { + return ShellIntegrationScripts.getPowerShellScriptPath() != null; + } + if (Platform.getOS().equals(Platform.OS_LINUX)) { + return ShellIntegrationScripts.getBashScriptPath() != null; + } + return false; + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { @@ -246,12 +331,9 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList } return (byteBuffer, bytesRead) -> { - String content = new String(byteBuffer, 0, bytesRead); - // Remove ANSI escape sequences - // Sometimes it also removes the linebreaks. But we need the last prompt line to - // be a separate line later. So we - // add line separator back to the content. - content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", StringUtils.LF); + String content = new String(byteBuffer, 0, bytesRead, StandardCharsets.UTF_8); + // Remove ANSI escape sequences while preserving only real line breaks from the terminal output. + content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", ""); // Handle Windows terminal title sequences - using Platform instead of // PlatformUtils @@ -266,79 +348,29 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList // Detect completion based on platform strategy if (!isBackground && resultFuture != null && !resultFuture.isDone()) { - if (useMarker) { - tryCompleteWithMarker(output); - } else { - tryCompleteWithPrompt(output); - } + CompletionCheckResult completionResult; + do { + completionResult = useMarker + ? TerminalCommandProcessor.tryCompleteWithMarker(output, activeCommand, skipNextCompletionAfterInterrupt) + : TerminalCommandProcessor.tryCompleteWithPrompt(output, skipNextCompletionAfterInterrupt); + handleCompletionResult(completionResult); + } while (completionResult.state() == CompletionCheckState.SKIPPED + && resultFuture != null && !resultFuture.isDone()); } }; } - /** - * Attempts to complete the command by detecting the shell marker in output. - * Used on Linux where shell integration script outputs a marker after each command. - */ - private void tryCompleteWithMarker(StringBuilder output) { - int markerIndex = output.indexOf(ShellIntegrationScripts.SHELL_MARKER); - if (markerIndex < 0) { - return; - } - - // Remove marker from output - output.delete(markerIndex, markerIndex + ShellIntegrationScripts.SHELL_MARKER.length()); - - // Skip the initial marker that appears when terminal starts (before any command is run) - if (!isInitialMarkerHandled) { - isInitialMarkerHandled = true; - return; - } - - String cleaned = output.toString().trim(); - resultFuture.complete(cleaned); - } - - /** - * Attempts to complete the command by detecting a shell prompt in output. - * Used on Windows and macOS where prompt characters indicate command completion. - */ - private void tryCompleteWithPrompt(StringBuilder output) { - String terminalOutput = output.toString().trim(); - int lastNewLineIndex = terminalOutput.lastIndexOf(StringUtils.LF); - if (lastNewLineIndex <= 0) { + private void handleCompletionResult(CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { return; } - - String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); - - // Check if last line is a prompt line - // Mac always has single '%' as last line, that's not what we want. - if (StringUtils.isBlank(lastLine) || lastLine.length() == 1) { + if (completionResult.state() == CompletionCheckState.SKIPPED) { + skipNextCompletionAfterInterrupt = false; return; } - - char lastChar = lastLine.charAt(lastLine.length() - 1); - boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; - if (!isPromptChar) { - return; - } - - // Extract result text between prompts - String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); - int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); - // If the prompt line is not found, set start index to 0. Sometimes it starts - // with the commandResult. - if (promptStartIndex == -1) { - promptStartIndex = 0; - } else { - promptStartIndex += lastLine.length(); - } - - if (!contentWithoutLastPrompt.isBlank()) { - String commandResult = contentWithoutLastPrompt.substring(promptStartIndex).trim(); - if (resultFuture != null && !resultFuture.isDone()) { - resultFuture.complete(commandResult); - } + activeCommand = null; + if (resultFuture != null && !resultFuture.isDone()) { + resultFuture.complete(completionResult.output()); } } diff --git a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java index 721dc954..692bd957 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.terminal; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -36,6 +37,9 @@ import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.ShellIntegrationScripts; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckResult; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; /** * Modern terminal tool implementation for newer Eclipse versions. @@ -46,6 +50,10 @@ public class RunInTerminalTool implements IRunInTerminalTool { private static final Object lock = new Object(); private static final Map backgroundCommandOutputs = new HashMap<>(); private static final String BACKGROUND_TERMINAL_PREFIX = "Copilot-"; + private static final String POWERSHELL_SCRIPT_ENV = "COPILOT_POWERSHELL_INTEGRATION_SCRIPT"; + private static final char INTERRUPT_CHARACTER = '\u0003'; + private static final String COMMAND_CANCELLED_MESSAGE = "Terminal command cancelled."; + private static final String COMMAND_INTERRUPTED_MESSAGE = "Terminal command interrupted by a new command."; // Non-background terminal field private ITerminalViewControl persistentTerminalViewControl; @@ -60,8 +68,9 @@ public class RunInTerminalTool implements IRunInTerminalTool { // Output and command state private StringBuilder sb; private CompletableFuture resultFuture; + private volatile String activeCommand; private volatile boolean useMarker; - private volatile boolean isInitialMarkerHandled; + private volatile boolean skipNextCompletionAfterInterrupt; /** * Constructor for RunInTerminalTool. @@ -71,15 +80,22 @@ public RunInTerminalTool() { } @Override - public CompletableFuture executeCommand(String command, boolean isBackground) { + public CompletableFuture executeCommand(String command, boolean isBackground, String workingDirectory) { if (StringUtils.isBlank(command)) { return CompletableFuture.completedFuture("The command is null or empty."); } + if (!isBackground) { + // A new foreground command immediately installs a new future after Ctrl+C, so the interrupted command's + // next prompt-completion marker must be skipped if it arrives after the new command starts listening. + interruptCurrentCommand(COMMAND_INTERRUPTED_MESSAGE, true); + } + resultFuture = new CompletableFuture<>(); + CompletableFuture commandFuture = resultFuture; - // Linux uses shell-integration marker lines; others rely on prompt detection - useMarker = Platform.getOS().equals(Platform.OS_LINUX); + useMarker = hasShellIntegrationMarker(); + activeCommand = isBackground ? null : command; if (!useMarker) { // Retain only the last line (prompt) in the output buffer @@ -95,30 +111,30 @@ public CompletableFuture executeCommand(String command, boolean isBackgr } String executionId = UUID.randomUUID().toString(); - final String finalCommand = command + System.lineSeparator(); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { revealTerminal(); - this.persistentTerminalViewControl.pasteString(finalCommand); - return this.resultFuture; + sendCommand(this.persistentTerminalViewControl, finalCommand); + return commandFuture; } ITerminalService service = CoreBundleActivator.getTerminalService(); if (service == null) { + activeCommand = null; return CompletableFuture.completedFuture("Failed to open terminal console due to terminal service is null."); } - // New non-background terminal will have an initial marker from shell startup; need to handle it - if (useMarker && !isBackground) { - isInitialMarkerHandled = false; - } - - service.openConsole(prepareTerminalProperties(isBackground, executionId)).handle((o, e) -> { + service.openConsole(prepareTerminalProperties(isBackground, executionId, workingDirectory)).handle((o, e) -> { if (e == null) { + if (commandFuture.isDone()) { + return null; + } ITerminalViewControl terminalViewControl = finalizeTerminalSetup(executionId, isBackground); if (terminalViewControl == null) { - resultFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); + activeCommand = null; + commandFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); return null; } @@ -126,9 +142,10 @@ public CompletableFuture executeCommand(String command, boolean isBackgr this.persistentTerminalViewControl = terminalViewControl; revealTerminal(); } - terminalViewControl.pasteString(finalCommand); + sendCommand(terminalViewControl, finalCommand); } else { - resultFuture.complete("Failed to open terminal console: " + e.getMessage()); + activeCommand = null; + commandFuture.complete("Failed to open terminal console: " + e.getMessage()); } return null; }); @@ -138,25 +155,34 @@ public CompletableFuture executeCommand(String command, boolean isBackgr return CompletableFuture.completedFuture("Command is running in terminal with ID=" + executionId); } - return resultFuture; + return commandFuture; } @Override - public Map prepareTerminalProperties(boolean runInBackground, String executionId) { + public Map prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory) { Map properties = new HashMap<>(); - properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); + if (StringUtils.isNotBlank(workingDirectory)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); + } if (Platform.getOS().equals(Platform.OS_WIN32)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "cmd.exe"); - } else if (Platform.getOS().equals(Platform.OS_LINUX)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/sh"); - // Use ENV to load shell integration script at startup - String scriptPath = ShellIntegrationScripts.getShScriptPath(); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "powershell.exe"); + String scriptPath = ShellIntegrationScripts.getPowerShellScriptPath(); if (scriptPath != null) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, new String[] { "ENV=" + scriptPath }); + String[] environment = new String[] { POWERSHELL_SCRIPT_ENV + "=" + scriptPath }; + String args = "-NoExit -ExecutionPolicy Bypass -Command \". $env:" + POWERSHELL_SCRIPT_ENV + "\""; + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, environment); properties.put(ITerminalsConnectorConstants.PROP_PROCESS_MERGE_ENVIRONMENT, true); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, args); + } + } else if (Platform.getOS().equals(Platform.OS_LINUX)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/bash"); + String scriptPath = ShellIntegrationScripts.getBashScriptPath(); + if (scriptPath != null) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, "--init-file \"" + scriptPath + "\" -i"); } } else { // macOS or other Unix-like: keep existing behavior, only set args if empty @@ -190,6 +216,64 @@ public StringBuilder getBackgroundCommandOutput(String executionId) { return output; } + @Override + public void cancelCurrentCommand() { + // User cancel completes the current future without starting a replacement command. Do not reserve a skip here, + // otherwise a later command could incorrectly skip its own completion if the interrupted prompt was already idle. + interruptCurrentCommand(COMMAND_CANCELLED_MESSAGE, false); + } + + private void interruptCurrentCommand(String completionMessage, boolean skipInterruptedCompletion) { + ITerminalViewControl terminalViewControl = null; + CompletableFuture commandFuture = null; + synchronized (lock) { + if (!hasRunningForegroundCommand()) { + if (!skipInterruptedCompletion) { + skipNextCompletionAfterInterrupt = false; + } + return; + } + activeCommand = null; + skipNextCompletionAfterInterrupt = skipInterruptedCompletion; + terminalViewControl = persistentTerminalViewControl; + commandFuture = resultFuture; + } + + if (terminalViewControl != null) { + sendInterrupt(terminalViewControl); + } + if (commandFuture != null && !commandFuture.isDone()) { + commandFuture.complete(completionMessage); + } + } + + private boolean hasRunningForegroundCommand() { + return StringUtils.isNotBlank(activeCommand) && resultFuture != null && !resultFuture.isDone(); + } + + private void sendInterrupt(ITerminalViewControl terminalViewControl) { + Display display = terminalViewControl.getControl().getDisplay(); + display.syncExec(() -> { + if (!terminalViewControl.isDisposed()) { + terminalViewControl.sendKey(INTERRUPT_CHARACTER); + } + }); + } + + private void sendCommand(ITerminalViewControl terminalViewControl, String command) { + terminalViewControl.pasteString(command); + } + + private boolean hasShellIntegrationMarker() { + if (Platform.getOS().equals(Platform.OS_WIN32)) { + return ShellIntegrationScripts.getPowerShellScriptPath() != null; + } + if (Platform.getOS().equals(Platform.OS_LINUX)) { + return ShellIntegrationScripts.getBashScriptPath() != null; + } + return false; + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { @@ -234,7 +318,7 @@ private ITerminalControl getTerminalControl(String terminalTitle, boolean isBack } } } catch (PartInitException e) { - //skip exception + // Skip exception } }); @@ -249,11 +333,9 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList } return (byteBuffer, bytesRead) -> { - String content = new String(byteBuffer, 0, bytesRead); - // Remove ANSI escape sequences - // Sometimes it also removes the linebreaks. But we need the last prompt line to be a separate line later. So we - // add line separator back to the content. - content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", StringUtils.LF); + String content = new String(byteBuffer, 0, bytesRead, StandardCharsets.UTF_8); + // Remove ANSI escape sequences while preserving only real line breaks from the terminal output. + content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", ""); // Handle Windows terminal title sequences - using Platform instead of PlatformUtils if (Platform.getOS().equals(Platform.OS_WIN32)) { @@ -266,79 +348,29 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList // Detect completion based on platform strategy if (!isBackground && resultFuture != null && !resultFuture.isDone()) { - if (useMarker) { - tryCompleteWithMarker(output); - } else { - tryCompleteWithPrompt(output); - } + CompletionCheckResult completionResult; + do { + completionResult = useMarker + ? TerminalCommandProcessor.tryCompleteWithMarker(output, activeCommand, skipNextCompletionAfterInterrupt) + : TerminalCommandProcessor.tryCompleteWithPrompt(output, skipNextCompletionAfterInterrupt); + handleCompletionResult(completionResult); + } while (completionResult.state() == CompletionCheckState.SKIPPED + && resultFuture != null && !resultFuture.isDone()); } }; } - /** - * Attempts to complete the command by detecting the shell marker in output. - * Used on Linux where shell integration script outputs a marker after each command. - */ - private void tryCompleteWithMarker(StringBuilder output) { - int markerIndex = output.indexOf(ShellIntegrationScripts.SHELL_MARKER); - if (markerIndex < 0) { - return; - } - - // Remove marker from output - output.delete(markerIndex, markerIndex + ShellIntegrationScripts.SHELL_MARKER.length()); - - // Skip the initial marker that appears when terminal starts (before any command is run) - if (!isInitialMarkerHandled) { - isInitialMarkerHandled = true; - return; - } - - String cleaned = output.toString().trim(); - resultFuture.complete(cleaned); - } - - /** - * Attempts to complete the command by detecting a shell prompt in output. - * Used on Windows and macOS where prompt characters indicate command completion. - */ - private void tryCompleteWithPrompt(StringBuilder output) { - String terminalOutput = output.toString().trim(); - int lastNewLineIndex = terminalOutput.lastIndexOf(StringUtils.LF); - if (lastNewLineIndex <= 0) { + private void handleCompletionResult(CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { return; } - - String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); - - // Check if last line is a prompt line - // Mac always has single '%' as last line, that's not what we want. - if (StringUtils.isBlank(lastLine) || lastLine.length() == 1) { + if (completionResult.state() == CompletionCheckState.SKIPPED) { + skipNextCompletionAfterInterrupt = false; return; } - - char lastChar = lastLine.charAt(lastLine.length() - 1); - boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; - if (!isPromptChar) { - return; - } - - // Extract result text between prompts - String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); - int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); - // If the prompt line is not found, set start index to 0. Sometimes it starts - // with the commandResult. - if (promptStartIndex == -1) { - promptStartIndex = 0; - } else { - promptStartIndex += lastLine.length(); - } - - if (!contentWithoutLastPrompt.isBlank()) { - String commandResult = contentWithoutLastPrompt.substring(promptStartIndex).trim(); - if (resultFuture != null && !resultFuture.isDone()) { - resultFuture.complete(commandResult); - } + activeCommand = null; + if (resultFuture != null && !resultFuture.isDone()) { + resultFuture.complete(completionResult.output()); } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java new file mode 100644 index 00000000..fb425997 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.terminal.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; + +class TerminalCommandProcessorTest { + @Test + void testFormatForExecution_singleLine_appendsCarriageReturn() { + assertEquals("echo hello\r", TerminalCommandProcessor.formatForExecution("echo hello")); + } + + @Test + void testFormatForExecution_multiline_wrapsWithBracketedPaste() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second")); + } + + @Test + void testFormatForExecution_multilineWithCrlf_normalizesLineEndings() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\r\necho second")); + } + + @Test + void testFormatForExecution_multilineWithTrailingNewline_doesNotSubmitEmptyCommand() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second\n")); + } + + @Test + void testFormatForExecution_singleLineWithTrailingNewline_doesNotUseBracketedPaste() { + assertEquals("echo hello\r", TerminalCommandProcessor.formatForExecution("echo hello\n")); + } + + @Test + void testFormatForExecution_backslashContinuation_doesNotUseBracketedPaste() { + assertEquals("echo hello \\" + "\r world\r", + TerminalCommandProcessor.formatForExecution("echo hello \\\n world")); + } + + @Test + void testTryCompleteWithMarker_completed_keepsNextPrompt() { + StringBuilder output = new StringBuilder("echo hi\r\nhi\r\n") + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("0\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("PS C:\\projects\\copilot-eclipse> ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output, "echo hi", false); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("hi\nPS C:\\projects\\copilot-eclipse>", result.output()); + } + + @Test + void testTryCompleteWithMarker_completedWithBareMarker() { + StringBuilder output = new StringBuilder("echo hi\r\nhi\r\n]7775;C;0]7775;A$ ]7775;B"); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output, "echo hi", false); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("hi\n$", result.output()); + } + + @Test + void testTryCompleteWithMarker_incomplete_removesPromptMarkers() { + StringBuilder output = new StringBuilder() + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("PS C:\\projects\\copilot-eclipse> ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output, "echo hi", false); + + assertEquals(CompletionCheckState.INCOMPLETE, result.state()); + assertEquals("PS C:\\projects\\copilot-eclipse> ", output.toString()); + } + + @Test + void testTryCompleteWithMarker_skipCompletion_discardsInterruptedCommandRegion() { + StringBuilder output = new StringBuilder("old output\n") + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("1\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("PS C:\\projects\\copilot-eclipse> ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER) + .append("new output"); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output, "new command", true); + + assertEquals(CompletionCheckState.SKIPPED, result.state()); + assertEquals("new output", output.toString()); + } + + @Test + void testTryCompleteWithMarker_skipInterruptedMarkerThenCompletesCommandRegion() { + StringBuilder output = new StringBuilder() + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("0\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("$ ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER) + .append("echo hi\r\nhi\r\n") + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("0\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("$ ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var skipped = TerminalCommandProcessor.tryCompleteWithMarker(output, "echo hi", true); + var completed = TerminalCommandProcessor.tryCompleteWithMarker(output, "echo hi", false); + + assertEquals(CompletionCheckState.SKIPPED, skipped.state()); + assertEquals(CompletionCheckState.COMPLETED, completed.state()); + assertEquals("hi\n$", completed.output()); + } + + @Test + void testTryCompleteWithPrompt_completed_extractsOutputBetweenPrompts() { + StringBuilder output = new StringBuilder("PS C:\\repo> echo hi\nhi\nPS C:\\repo> "); + + var result = TerminalCommandProcessor.tryCompleteWithPrompt(output, false); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nhi", result.output()); + } + + @Test + void testTruncateOutput_shortOutput_returnsOriginalOutput() { + String output = "line 1\r\nline 2"; + + assertEquals(output, TerminalCommandProcessor.truncateOutput(output)); + } + + @Test + void testTruncateOutput_longOutput_keepsTailLines() { + StringBuilder output = new StringBuilder(); + for (int lineIndex = 1; lineIndex <= 1005; lineIndex++) { + output.append("line ").append(lineIndex).append('\n'); + } + + String result = TerminalCommandProcessor.truncateOutput(output.toString()); + + assertTrue(result.startsWith("[Terminal output truncated: showing last 1000 of 1005 lines.]\n")); + assertFalse(result.contains("line 5\n")); + assertTrue(result.contains("line 6\n")); + assertTrue(result.endsWith("line 1005\n")); + } + + @Test + void testPrepareOutputForModel_removesCopilotShellMarkers() { + String output = "start\n" + ShellIntegrationScripts.PROMPT_START_MARKER + + "]7775;B\nbody\n]7775;C;0\n" + + ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX + "1\u0007end"; + + String result = TerminalCommandProcessor.prepareOutputForModel(output); + + assertEquals("start\n\nbody\n\nend", result); + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java index 01ebcc41..b597ee88 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java @@ -6,12 +6,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.Path; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.AfterEach; @@ -77,7 +83,8 @@ void testInvokeWithEmptyCommandReturnsErrorStatus() throws InterruptedException, assertNotNull(results); assertEquals(1, results.length); assertEquals(ToolInvocationStatus.error, results[0].getStatus()); - assertTrue(results[0].getContent().get(0).getValue().equals("No terminal implementation available. Terminal service not yet loaded or failed to load.")); + assertEquals("No terminal implementation available. Terminal service not yet loaded or failed to load.", + results[0].getContent().get(0).getValue()); } @Test @@ -113,6 +120,19 @@ void testToolName() { assertEquals("run_in_terminal", runInTerminalToolAdapter.getToolName()); } + @Test + void testResolveWorkingDirectoryFromResources_usesFirstReferencedProjectLocation() { + IProject mockProject = mock(IProject.class); + when(mockProject.isAccessible()).thenReturn(true); + when(mockProject.getLocation()).thenReturn(Path.fromOSString("C:\\workspace\\project-a")); + IResource resource = mock(IResource.class); + when(resource.getProject()).thenReturn(mockProject); + + String result = RunInTerminalToolAdapter.resolveWorkingDirectoryFromResources(List.of(resource)); + + assertEquals("C:\\workspace\\project-a", result); + } + @Test void testGetTerminalOutputToolWithNoTerminalServiceReturnsErrorStatus() diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 49f5bb31..d7e5b2a7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -78,6 +78,8 @@ import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; +import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; @@ -1279,6 +1281,7 @@ public void onCancel() { this.subagentConversationId = null; this.lastRunSubagentToolCallId = null; + cancelCurrentTerminalCommand(); if (persistenceManager != null && StringUtils.isNotBlank(this.conversationId)) { persistenceManager.persistCachedConversation(this.conversationId); @@ -1294,6 +1297,18 @@ public void onCancel() { } } + private void cancelCurrentTerminalCommand() { + try { + TerminalServiceManager terminalManager = TerminalServiceManager.getInstance(); + IRunInTerminalTool terminalTool = terminalManager != null ? terminalManager.getCurrentService() : null; + if (terminalTool != null) { + terminalTool.cancelCurrentCommand(); + } + } catch (RuntimeException e) { + CopilotCore.LOGGER.error("Failed to cancel terminal command", e); + } + } + @Override public void onNewConversation() { clearCurrentConversation(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java index 085d4913..a8d03600 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java @@ -9,6 +9,9 @@ import java.util.concurrent.CompletableFuture; import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.IPath; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; @@ -18,8 +21,12 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.ChatView; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.ReferencedFileService; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** @@ -38,18 +45,22 @@ public RunInTerminalToolAdapter() { private String buildToolDescription() { if (PlatformUtils.isWindows()) { return """ - Shell: cmd.exe + Shell: powershell.exe - This tool allows you to execute Windows Command Prompt commands in a persistent terminal session, \ + This tool allows you to execute PowerShell commands in a persistent terminal session, \ preserving environment variables, working directory, and other context across multiple commands. Use this tool instead of printing a shell codeblock and asking the user to run it. Command Execution: - - Use & to chain commands on one line (or && for conditional execution on success) - - Never create a sub-shell (e.g., cmd /c "command") unless explicitly asked - - Use pipelines | for data flow + - Use ; to chain commands on one line + - Never create a sub-shell (e.g., powershell -c "command") unless explicitly asked + - Prefer pipelines | for object-based data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, \ + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it with command flags (e.g., `git --no-pager`) + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -58,19 +69,23 @@ private String buildToolDescription() { } if (PlatformUtils.isLinux()) { return """ - Shell: /bin/sh (POSIX shell) + Shell: /bin/bash - This tool allows you to execute POSIX shell commands in a persistent terminal session, \ + This tool allows you to execute Bash commands in a persistent terminal session, \ preserving environment variables, working directory, and other context across multiple commands. Use this tool instead of printing a shell codeblock and asking the user to run it. Command Execution: - - Use ; to chain commands on one line (or && for conditional execution on success) - - Never create a sub-shell (e.g., sh -c "command") unless explicitly asked + - Use && to chain commands on one line + - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, \ + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) - - Use POSIX-compliant syntax (avoid bash-specific features like arrays or [[ ]]) + - Bash syntax is supported, including arrays and [[ ]] + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -87,8 +102,12 @@ private String buildToolDescription() { - Use && to chain commands on one line - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, \ + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -178,13 +197,63 @@ public CompletableFuture invoke(Map i } impl.setTerminalIconDescriptor(UiUtils.buildImageDescriptorFromPngPath("/icons/github_copilot.png")); + String workingDirectory = resolveWorkingDirectory(); - return impl.executeCommand(command, isBackground).thenApply( - result -> new LanguageModelToolResult[] { new LanguageModelToolResult(result, ToolInvocationStatus.success) }) + return impl.executeCommand(command, isBackground, workingDirectory) + .thenApply(result -> new LanguageModelToolResult[] { new LanguageModelToolResult( + TerminalCommandProcessor.prepareOutputForModel(result), ToolInvocationStatus.success) }) .exceptionally(throwable -> new LanguageModelToolResult[] { new LanguageModelToolResult( "Terminal execution failed: " + throwable.getMessage(), ToolInvocationStatus.error) }); } + static String resolveWorkingDirectoryFromResources(List resources) { + if (resources == null) { + return ""; + } + for (IResource resource : resources) { + String location = resolveProjectLocation(resource); + if (StringUtils.isNotBlank(location)) { + return location; + } + } + return ""; + } + + private static String resolveWorkingDirectory() { + ChatServiceManager manager = CopilotUi.getPlugin() != null ? CopilotUi.getPlugin().getChatServiceManager() : null; + if (manager != null) { + ReferencedFileService fileService = manager.getReferencedFileService(); + if (fileService != null) { + String currentFileLocation = resolveProjectLocation(fileService.getCurrentFile()); + if (StringUtils.isNotBlank(currentFileLocation)) { + return currentFileLocation; + } + + String referencedLocation = resolveWorkingDirectoryFromResources(fileService.getReferencedFiles()); + if (StringUtils.isNotBlank(referencedLocation)) { + return referencedLocation; + } + } + } + + return ""; + } + + private static String resolveProjectLocation(IResource resource) { + if (resource == null) { + return ""; + } + return resolveProjectLocation(resource.getProject()); + } + + private static String resolveProjectLocation(IProject project) { + if (project == null || !project.isAccessible()) { + return ""; + } + IPath location = project.getLocation(); + return location != null ? location.toOSString() : ""; + } + /** * Tool to retrieve the output of a terminal command that was previously started with run_in_terminal. */ @@ -204,7 +273,10 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); - toolInfo.setDescription("Get the output of a terminal command previous started with run_in_terminal."); + toolInfo.setDescription(""" + Get the output of a terminal command previous started with run_in_terminal. + Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow. + """); // Define the input schema for the tool InputSchema inputSchema = new InputSchema(); @@ -247,7 +319,7 @@ public CompletableFuture invoke(Map i toolResult.addContent("Invalid terminal ID " + id); } else { toolResult.setStatus(ToolInvocationStatus.success); - toolResult.addContent(output.toString()); + toolResult.addContent(TerminalCommandProcessor.prepareOutputForModel(output.toString())); } } resultFuture.complete(new LanguageModelToolResult[] { toolResult }); From 95f9b0e878cc4de566083b16ea15bbd23c4737da Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 21 May 2026 15:24:50 +0800 Subject: [PATCH 2/4] remove test --- .../tools/RunInTerminalToolAdapterTest.java | 173 ------------------ 1 file changed, 173 deletions(-) delete mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java deleted file mode 100644 index b597ee88..00000000 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -package com.microsoft.copilot.eclipse.ui.chat.tools; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; - -import org.eclipse.core.resources.IProject; -import org.eclipse.core.resources.IResource; -import org.eclipse.core.runtime.Path; -import org.eclipse.swt.widgets.Display; -import org.eclipse.swt.widgets.Shell; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; -import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; -import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; -import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; - -class RunInTerminalToolAdapterTest { - - private RunInTerminalToolAdapter runInTerminalToolAdapter; - private Shell shell; - - @BeforeEach - void setUp() { - // Setup SWT components - SwtUtils.invokeOnDisplayThread(() -> { - shell = new Shell(Display.getDefault()); - }); - - runInTerminalToolAdapter = new RunInTerminalToolAdapter(); - } - - @AfterEach - void tearDown() { - SwtUtils.invokeOnDisplayThread(() -> { - if (shell != null && !shell.isDisposed()) { - shell.dispose(); - } - }); - } - - @Test - void testGetToolInformation() { - // Act - LanguageModelToolInformation toolInfo = runInTerminalToolAdapter.getToolInformation(); - - // Assert - assertNotNull(toolInfo); - assertEquals("run_in_terminal", toolInfo.getName()); - assertNotNull(toolInfo.getDescription()); - assertNotNull(toolInfo.getInputSchema()); - assertTrue(toolInfo.getInputSchema().getRequired().contains("command")); - } - - @Test - void testInvokeWithEmptyCommandReturnsErrorStatus() throws InterruptedException, ExecutionException { - // Arrange - Map input = new HashMap<>(); - input.put("command", ""); - input.put("isBackground", false); - - // Act - CompletableFuture future = runInTerminalToolAdapter.invoke(input, null); - LanguageModelToolResult[] results = future.get(); - - // Assert - // In test environment with no terminal service, the "no terminal" error is returned - // before command validation. This is the actual behavior of the implementation. - assertNotNull(results); - assertEquals(1, results.length); - assertEquals(ToolInvocationStatus.error, results[0].getStatus()); - assertEquals("No terminal implementation available. Terminal service not yet loaded or failed to load.", - results[0].getContent().get(0).getValue()); - } - - @Test - void testInvokeWithNoTerminalServiceReturnsErrorStatus() throws InterruptedException, ExecutionException { - // Arrange - Since we can't mock the singleton, this test verifies error handling - // when no terminal service is available (which is the typical case in unit tests) - Map input = new HashMap<>(); - input.put("command", "echo test"); - input.put("isBackground", false); - - // Act - CompletableFuture future = runInTerminalToolAdapter.invoke(input, null); - LanguageModelToolResult[] results = future.get(); - - // Assert - When no terminal service is available, we expect an error - assertNotNull(results); - assertEquals(1, results.length); - // The result will be either error (no terminal service) or success (if service is available) - // We just verify the result is properly formed - assertNotNull(results[0].getStatus()); - assertNotNull(results[0].getContent()); - } - - @Test - void testNeedConfirmation() { - // Act & Assert - assertEquals(true, runInTerminalToolAdapter.needConfirmation()); - } - - @Test - void testToolName() { - // Act & Assert - assertEquals("run_in_terminal", runInTerminalToolAdapter.getToolName()); - } - - @Test - void testResolveWorkingDirectoryFromResources_usesFirstReferencedProjectLocation() { - IProject mockProject = mock(IProject.class); - when(mockProject.isAccessible()).thenReturn(true); - when(mockProject.getLocation()).thenReturn(Path.fromOSString("C:\\workspace\\project-a")); - IResource resource = mock(IResource.class); - when(resource.getProject()).thenReturn(mockProject); - - String result = RunInTerminalToolAdapter.resolveWorkingDirectoryFromResources(List.of(resource)); - - assertEquals("C:\\workspace\\project-a", result); - } - - - @Test - void testGetTerminalOutputToolWithNoTerminalServiceReturnsErrorStatus() - throws InterruptedException, ExecutionException { - // Arrange - RunInTerminalToolAdapter.GetTerminalOutputTool getTool = new RunInTerminalToolAdapter.GetTerminalOutputTool(); - Map input = new HashMap<>(); - input.put("id", "terminal-123"); - - // Act - CompletableFuture future = getTool.invoke(input, null); - LanguageModelToolResult[] results = future.get(); - - // Assert - When no terminal service is available, we expect an error - assertNotNull(results); - assertEquals(1, results.length); - // The result will be either error (no terminal service or invalid ID) - // We just verify the result is properly formed - assertNotNull(results[0].getStatus()); - assertNotNull(results[0].getContent()); - } - - @Test - void testGetTerminalOutputToolGetToolInformation() { - // Arrange - RunInTerminalToolAdapter.GetTerminalOutputTool getTool = new RunInTerminalToolAdapter.GetTerminalOutputTool(); - - // Act - LanguageModelToolInformation toolInfo = getTool.getToolInformation(); - - // Assert - assertNotNull(toolInfo); - assertEquals("get_terminal_output", toolInfo.getName()); - assertNotNull(toolInfo.getDescription()); - assertNotNull(toolInfo.getInputSchema()); - assertTrue(toolInfo.getInputSchema().getRequired().contains("id")); - } -} From d36ebf60177b6757ca55349127be6c2ca2fcc6c4 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 21 May 2026 15:36:29 +0800 Subject: [PATCH 3/4] recover test file --- .../tools/RunInTerminalToolAdapterTest.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java new file mode 100644 index 00000000..5f6aa92c --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +class RunInTerminalToolAdapterTest { + + private RunInTerminalToolAdapter runInTerminalToolAdapter; + private Shell shell; + + @BeforeEach + void setUp() { + // Setup SWT components + SwtUtils.invokeOnDisplayThread(() -> { + shell = new Shell(Display.getDefault()); + }); + + runInTerminalToolAdapter = new RunInTerminalToolAdapter(); + } + + @AfterEach + void tearDown() { + SwtUtils.invokeOnDisplayThread(() -> { + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Test + void testGetToolInformation() { + // Act + LanguageModelToolInformation toolInfo = runInTerminalToolAdapter.getToolInformation(); + + // Assert + assertNotNull(toolInfo); + assertEquals("run_in_terminal", toolInfo.getName()); + assertNotNull(toolInfo.getDescription()); + assertNotNull(toolInfo.getInputSchema()); + assertTrue(toolInfo.getInputSchema().getRequired().contains("command")); + } + + @Test + void testInvokeWithEmptyCommandReturnsErrorStatus() throws InterruptedException, ExecutionException { + // Arrange + Map input = new HashMap<>(); + input.put("command", ""); + input.put("isBackground", false); + + // Act + CompletableFuture future = runInTerminalToolAdapter.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + // Assert + // In test environment with no terminal service, the "no terminal" error is returned + // before command validation. This is the actual behavior of the implementation. + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.error, results[0].getStatus()); + assertEquals("No terminal implementation available. Terminal service not yet loaded or failed to load.", + results[0].getContent().get(0).getValue()); + } + + @Test + void testInvokeWithNoTerminalServiceReturnsErrorStatus() throws InterruptedException, ExecutionException { + // Arrange - Since we can't mock the singleton, this test verifies error handling + // when no terminal service is available (which is the typical case in unit tests) + Map input = new HashMap<>(); + input.put("command", "echo test"); + input.put("isBackground", false); + + // Act + CompletableFuture future = runInTerminalToolAdapter.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + // Assert - When no terminal service is available, we expect an error + assertNotNull(results); + assertEquals(1, results.length); + // The result will be either error (no terminal service) or success (if service is available) + // We just verify the result is properly formed + assertNotNull(results[0].getStatus()); + assertNotNull(results[0].getContent()); + } + + @Test + void testNeedConfirmation() { + // Act & Assert + assertEquals(true, runInTerminalToolAdapter.needConfirmation()); + } + + @Test + void testToolName() { + // Act & Assert + assertEquals("run_in_terminal", runInTerminalToolAdapter.getToolName()); + } + + @Test + void testGetTerminalOutputToolWithNoTerminalServiceReturnsErrorStatus() + throws InterruptedException, ExecutionException { + // Arrange + RunInTerminalToolAdapter.GetTerminalOutputTool getTool = new RunInTerminalToolAdapter.GetTerminalOutputTool(); + Map input = new HashMap<>(); + input.put("id", "terminal-123"); + + // Act + CompletableFuture future = getTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + // Assert - When no terminal service is available, we expect an error + assertNotNull(results); + assertEquals(1, results.length); + // The result will be either error (no terminal service or invalid ID) + // We just verify the result is properly formed + assertNotNull(results[0].getStatus()); + assertNotNull(results[0].getContent()); + } + + @Test + void testGetTerminalOutputToolGetToolInformation() { + // Arrange + RunInTerminalToolAdapter.GetTerminalOutputTool getTool = new RunInTerminalToolAdapter.GetTerminalOutputTool(); + + // Act + LanguageModelToolInformation toolInfo = getTool.getToolInformation(); + + // Assert + assertNotNull(toolInfo); + assertEquals("get_terminal_output", toolInfo.getName()); + assertNotNull(toolInfo.getDescription()); + assertNotNull(toolInfo.getInputSchema()); + assertTrue(toolInfo.getInputSchema().getRequired().contains("id")); + } +} From 7552464cdf1e62886cd718c4e370fa0886ea8834 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Thu, 21 May 2026 18:05:58 +0800 Subject: [PATCH 4/4] resolve comments --- .../scripts/copilot-bash-integration.sh | 14 ++++++++++++- .../copilot-powershell-integration.ps1 | 12 ++++++++--- .../terminal/api/ShellIntegrationScripts.java | 2 +- .../api/TerminalCommandProcessor.java | 14 +++++++++++-- .../ui/terminal/tm/RunInTerminalTool.java | 8 +++++++- .../ui/terminal/RunInTerminalTool.java | 8 +++++++- .../api/TerminalCommandProcessorTest.java | 10 ++++++++-- .../chat/tools/RunInTerminalToolAdapter.java | 20 +++++++++---------- 8 files changed, 67 insertions(+), 21 deletions(-) diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh index 855f2b4a..b36076c7 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh @@ -16,6 +16,7 @@ __copilot_bash_integration_main() { __copilot_prompt_initialized=1 fi printf '\033]7775;A\007' + return "$__copilot_status" } __copilot_prompt_end() { @@ -26,7 +27,18 @@ __copilot_bash_integration_main() { __copilot_original_ps1=${PS1:-'\$ '} fi - PROMPT_COMMAND=__copilot_precmd + case "$(declare -p PROMPT_COMMAND 2>/dev/null)" in + declare\ -a*|declare\ -A*) + PROMPT_COMMAND=(__copilot_precmd "${PROMPT_COMMAND[@]}") + ;; + *) + if [ -n "${PROMPT_COMMAND:-}" ]; then + PROMPT_COMMAND="__copilot_precmd; ${PROMPT_COMMAND}" + else + PROMPT_COMMAND=__copilot_precmd + fi + ;; + esac PS1="${__copilot_original_ps1}"'\[$(__copilot_prompt_end)\]' } diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 index 18b097d0..f19cb1d3 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 @@ -5,8 +5,6 @@ try { $global:OutputEncoding = New-Object System.Text.UTF8Encoding $false [Console]::InputEncoding = $global:OutputEncoding [Console]::OutputEncoding = $global:OutputEncoding - $env:PYTHONIOENCODING = "utf-8" - $env:PYTHONUTF8 = "1" } catch { # Some hosts do not expose console encodings during startup. } @@ -17,6 +15,8 @@ if (-not $global:COPILOT_SHELL_INTEGRATION) { $global:__copilot_last_history_id = -1 function global:prompt { + $lastSuccess = $? + $lastExitCode = $LASTEXITCODE $esc = [char]27 $bel = [char]7 $lastHistoryEntry = Get-History -Count 1 @@ -26,7 +26,13 @@ if (-not $global:COPILOT_SHELL_INTEGRATION) { if ($lastHistoryEntry.Id -eq $global:__copilot_last_history_id) { $result += "$esc]7775;C$bel" } else { - $exitCode = [int](!$global:?) + if ($lastSuccess) { + $exitCode = 0 + } elseif ($null -ne $lastExitCode -and $lastExitCode -ne 0) { + $exitCode = $lastExitCode + } else { + $exitCode = 1 + } $result += "$esc]7775;C;$exitCode$bel" } } diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java index 6bab76f4..c2015f2c 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java @@ -46,7 +46,7 @@ private ShellIntegrationScripts() { } private static String buildOscMarkerPattern() { - return "(?:\u001B)?\\]?" + OSC_NAMESPACE + ";[ABC](?:;[-]?\\d+)?(?:\u0007|\u001B\\\\)?"; + return "(?:\u001B)?\\]" + OSC_NAMESPACE + ";[ABC](?:;[-]?\\d+)?(?:\u0007|\u001B\\\\)?"; } /** diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java index b1184770..5d13f8c4 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java @@ -13,7 +13,6 @@ public final class TerminalCommandProcessor { private static final int MAX_OUTPUT_LINE_COUNT = 1000; private static final String BRACKETED_PASTE_START = "\u001b[200~"; private static final String BRACKETED_PASTE_END = "\u001b[201~"; - private static final String BELL = "\u0007"; private static final String ANSI_CSI_SEQUENCE_PATTERN = "\u001B\\[(\\?)?[\\d;]*[a-zA-Z]"; private static final String OSC_SEQUENCE_PATTERN = "\u001B\\][^\u0007\u001B]*(?:\u0007|\u001B\\\\)"; private static final Pattern PROMPT_START_MARKER_PATTERN = buildMarkerPattern("A", false); @@ -31,8 +30,19 @@ private TerminalCommandProcessor() { * @return command text formatted for terminal input */ public static String formatForExecution(String command) { + return formatForExecution(command, true); + } + + /** + * Formats a command for immediate terminal execution. + * + * @param command the command to send + * @param useBracketedPaste whether multiline commands should be sent using bracketed paste + * @return command text formatted for terminal input + */ + public static String formatForExecution(String command, boolean useBracketedPaste) { String normalizedCommand = removeTrailingLineEndings(normalizeLineEndings(command)); - String terminalInput = isMultilineCommand(normalizedCommand) + String terminalInput = useBracketedPaste && isMultilineCommand(normalizedCommand) ? BRACKETED_PASTE_START + normalizedCommand + BRACKETED_PASTE_END : normalizedCommand; terminalInput = terminalInput.replace('\n', '\r'); diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java index 394f1711..d751b3d0 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java @@ -110,7 +110,7 @@ public CompletableFuture executeCommand(String command, boolean isBackgr } String executionId = UUID.randomUUID().toString(); - final String finalCommand = TerminalCommandProcessor.formatForExecution(command); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command, useBracketedPaste()); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { @@ -161,6 +161,7 @@ public Map prepareTerminalProperties(boolean runInBackground, St String workingDirectory) { Map properties = new HashMap<>(); + properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); if (StringUtils.isNotBlank(workingDirectory)) { properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); @@ -272,6 +273,11 @@ private boolean hasShellIntegrationMarker() { return false; } + private boolean useBracketedPaste() { + // macOS terminal multiline handling differs from PowerShell/Bash integration, so keep its existing plain input. + return Platform.getOS().equals(Platform.OS_WIN32) || Platform.getOS().equals(Platform.OS_LINUX); + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { diff --git a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java index 692bd957..c0d9102c 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java @@ -111,7 +111,7 @@ public CompletableFuture executeCommand(String command, boolean isBackgr } String executionId = UUID.randomUUID().toString(); - final String finalCommand = TerminalCommandProcessor.formatForExecution(command); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command, useBracketedPaste()); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { @@ -163,6 +163,7 @@ public Map prepareTerminalProperties(boolean runInBackground, St String workingDirectory) { Map properties = new HashMap<>(); + properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); if (StringUtils.isNotBlank(workingDirectory)) { properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); @@ -274,6 +275,11 @@ private boolean hasShellIntegrationMarker() { return false; } + private boolean useBracketedPaste() { + // macOS terminal multiline handling differs from PowerShell/Bash integration, so keep its existing plain input. + return Platform.getOS().equals(Platform.OS_WIN32) || Platform.getOS().equals(Platform.OS_LINUX); + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java index fb425997..399d2c07 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java @@ -35,6 +35,12 @@ void testFormatForExecution_multilineWithTrailingNewline_doesNotSubmitEmptyComma TerminalCommandProcessor.formatForExecution("echo first\necho second\n")); } + @Test + void testFormatForExecution_multilineWithoutBracketedPaste_submitsPlainLines() { + assertEquals("echo first\recho second\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second", false)); + } + @Test void testFormatForExecution_singleLineWithTrailingNewline_doesNotUseBracketedPaste() { assertEquals("echo hello\r", TerminalCommandProcessor.formatForExecution("echo hello\n")); @@ -154,11 +160,11 @@ void testTruncateOutput_longOutput_keepsTailLines() { @Test void testPrepareOutputForModel_removesCopilotShellMarkers() { String output = "start\n" + ShellIntegrationScripts.PROMPT_START_MARKER - + "]7775;B\nbody\n]7775;C;0\n" + + "]7775;B\nbody\n]7775;C;0\nliteral 7775;A remains\n" + ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX + "1\u0007end"; String result = TerminalCommandProcessor.prepareOutputForModel(output); - assertEquals("start\n\nbody\n\nend", result); + assertEquals("start\n\nbody\n\nliteral 7775;A remains\nend", result); } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java index a8d03600..ae7cf6ee 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java @@ -55,9 +55,9 @@ private String buildToolDescription() { - Use ; to chain commands on one line - Never create a sub-shell (e.g., powershell -c "command") unless explicitly asked - Prefer pipelines | for object-based data flow - - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, \ - unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, - then run it + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it with command flags (e.g., `git --no-pager`) - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow @@ -79,9 +79,9 @@ private String buildToolDescription() { - Use && to chain commands on one line - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow - - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, \ - unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, - then run it + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) - Bash syntax is supported, including arrays and [[ ]] @@ -102,9 +102,9 @@ private String buildToolDescription() { - Use && to chain commands on one line - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow - - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, \ - unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, - then run it + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow @@ -274,7 +274,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - Get the output of a terminal command previous started with run_in_terminal. + Get the output of a terminal command previously started with run_in_terminal. Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow. """);