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..b36076c7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh @@ -0,0 +1,45 @@ +# 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' + return "$__copilot_status" + } + + __copilot_prompt_end() { + printf '\033]7775;B\007' + } + + if [ -z "${__copilot_original_ps1:-}" ]; then + __copilot_original_ps1=${PS1:-'\$ '} + fi + + 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)\]' +} + +__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..f19cb1d3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 @@ -0,0 +1,52 @@ +# 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 +} 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 { + $lastSuccess = $? + $lastExitCode = $LASTEXITCODE + $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 { + if ($lastSuccess) { + $exitCode = 0 + } elseif ($null -ne $lastExitCode -and $lastExitCode -ne 0) { + $exitCode = $lastExitCode + } else { + $exitCode = 1 + } + $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..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 @@ -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..5d13f8c4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java @@ -0,0 +1,302 @@ +// 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 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) { + 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 = useBracketedPaste && 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..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 @@ -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, useBracketedPaste()); 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,35 @@ 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 +215,69 @@ 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 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) { @@ -246,12 +337,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 +354,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) { + private void handleCompletionResult(CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { 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; + if (completionResult.state() == CompletionCheckState.SKIPPED) { + skipNextCompletionAfterInterrupt = false; 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) { - 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) { - 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..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 @@ -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, useBracketedPaste()); 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,35 @@ 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 +217,69 @@ 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 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) { @@ -234,7 +324,7 @@ private ITerminalControl getTerminalControl(String terminalTitle, boolean isBack } } } catch (PartInitException e) { - //skip exception + // Skip exception } }); @@ -249,11 +339,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 +354,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) { + private void handleCompletionResult(CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { 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; + if (completionResult.state() == CompletionCheckState.SKIPPED) { + skipNextCompletionAfterInterrupt = false; 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) { - 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) { - 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..399d2c07 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java @@ -0,0 +1,170 @@ +// 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_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")); + } + + @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\nliteral 7775;A remains\n" + + ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX + "1\u0007end"; + + String result = TerminalCommandProcessor.prepareOutputForModel(output); + + 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.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..5f6aa92c 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 @@ -77,7 +77,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,7 +114,6 @@ void testToolName() { assertEquals("run_in_terminal", runInTerminalToolAdapter.getToolName()); } - @Test void testGetTerminalOutputToolWithNoTerminalServiceReturnsErrorStatus() throws InterruptedException, ExecutionException { 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..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 @@ -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 previously 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 });