diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/Configurator.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/Configurator.java index 8e252fb5..bf503815 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/Configurator.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/Configurator.java @@ -107,7 +107,7 @@ public HarnessEngine agentRuntime(AgentProperties props) throws Exception { .maxTurns(props.getMaxTurns()) .autoRethink(props.isAutoRethink()) .toolsAdd(props.getTools()) - .disallowedToolsAdd(props.getTools()) + .disallowedToolsAdd(props.getDisallowedTools()) .sessionWindowSize(props.getSessionWindowSize()) .sessionProvider(sessionProvider) .compressionThreshold(props.getSummaryWindowSize(), props.getSummaryWindowToken()) @@ -398,4 +398,4 @@ private void addSystemLspServer(HarnessEngine engine, AgentSettings settings, St // 同步到 settings 以便前端展示 settings.getLspServers().put(name, lspServer); } -} \ No newline at end of file +} diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/command/ShellCommandSupport.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/command/ShellCommandSupport.java new file mode 100644 index 00000000..51d1d027 --- /dev/null +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/command/ShellCommandSupport.java @@ -0,0 +1,439 @@ +/* + * Copyright 2017-2026 noear.org and authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.noear.solon.codecli.command; + +import org.noear.solon.ai.agent.AgentSession; +import org.noear.solon.ai.chat.message.ChatMessage; +import org.noear.solon.core.util.JavaUtil; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * 系统命令上下文注入支持。 + */ +public class ShellCommandSupport { + private static final int MAX_OUTPUT_CHARS = 20_000; + private static final long TIMEOUT_SECONDS = 60; + + public static boolean isShellCommand(String input) { + return input != null && input.startsWith("!"); + } + + public static Result executeAndInject(AgentSession session, String workspace, String input) throws Exception { + String command = input.substring(1).trim(); + if (command.length() == 0) { + throw new IllegalArgumentException("系统命令不能为空"); + } + + Result result = execute(workspace, command); + session.addMessage(ChatMessage.ofUser(result.toContextText())); + return result; + } + + private static Result execute(String workspace, String command) throws Exception { + ProcessBuilder builder = new ProcessBuilder(buildShellArgs(resolveShell(), command)); + + if (workspace != null && workspace.length() > 0) { + builder.directory(new File(workspace)); + } + if (JavaUtil.IS_WINDOWS) { + enrichWindowsEnvironment(builder.environment()); + } + builder.redirectErrorStream(true); + + long startMs = System.currentTimeMillis(); + Process process = builder.start(); + StreamCollector collector = new StreamCollector(process.getInputStream()); + Thread collectorThread = new Thread(collector, "shell-command-output-collector"); + collectorThread.setDaemon(true); + collectorThread.start(); + + boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + } + + collectorThread.join(TimeUnit.SECONDS.toMillis(2)); + + int exitCode = finished ? process.exitValue() : -1; + long elapsedMs = System.currentTimeMillis() - startMs; + return new Result(command, workspace, exitCode, elapsedMs, collector.getOutput(), !finished); + } + + static ShellInfo resolveShell() { + if (JavaUtil.IS_WINDOWS) { + String shell = firstNotEmpty(System.getenv("SOLONCODE_SHELL"), System.getenv("SHELL")); + if (shell != null) { + return new ShellInfo(resolveShellType(shell), shell); + } + + String pwsh = findWindowsCommand("pwsh.exe"); + if (pwsh != null) { + return new ShellInfo(ShellType.POWERSHELL, pwsh); + } + + String powershell = findWindowsCommand("powershell.exe"); + if (powershell != null) { + return new ShellInfo(ShellType.POWERSHELL, powershell); + } + + String comSpec = System.getenv("ComSpec"); + if (comSpec != null && comSpec.trim().length() > 0) { + return new ShellInfo(resolveShellType(comSpec), comSpec); + } + + return new ShellInfo(ShellType.CMD, "cmd"); + } + + String shell = System.getenv("SHELL"); + if (shell != null && shell.trim().length() > 0) { + return new ShellInfo(resolveShellType(shell), shell); + } + + if (JavaUtil.IS_MAC && new File("/bin/zsh").exists()) { + return new ShellInfo(ShellType.ZSH, "/bin/zsh"); + } + + if (new File("/bin/bash").exists()) { + return new ShellInfo(ShellType.BASH, "/bin/bash"); + } + + return new ShellInfo(ShellType.SH, "/bin/sh"); + } + + static List buildShellArgs(ShellInfo shellInfo, String command) { + switch (shellInfo.type) { + case ZSH: + return Arrays.asList(shellInfo.path, "-lc", buildZshScript(command)); + case BASH: + return Arrays.asList(shellInfo.path, "-lc", buildBashScript(command)); + case FISH: + return Arrays.asList(shellInfo.path, "-lc", buildFishScript(command)); + case POWERSHELL: + return Arrays.asList(shellInfo.path, "-NoLogo", "-Command", buildPowerShellScript(command)); + case CMD: + return Arrays.asList(shellInfo.path, "/c", buildCmdScript(command)); + case SH: + default: + return Arrays.asList(shellInfo.path, "-lc", buildShScript(command)); + } + } + + private static String buildZshScript(String command) { + return "if [ -n \"${ZDOTDIR:-}\" ] && [ -r \"$ZDOTDIR/.zshrc\" ]; then\n" + + " . \"$ZDOTDIR/.zshrc\"\n" + + "elif [ -r \"$HOME/.zshrc\" ]; then\n" + + " . \"$HOME/.zshrc\"\n" + + "fi\n" + + command; + } + + private static String buildBashScript(String command) { + return "shopt -s expand_aliases\n" + + "if [ -r \"$HOME/.bashrc\" ]; then\n" + + " . \"$HOME/.bashrc\"\n" + + "fi\n" + + command; + } + + private static String buildShScript(String command) { + return "if [ -r \"$HOME/.profile\" ]; then\n" + + " . \"$HOME/.profile\"\n" + + "fi\n" + + command; + } + + private static String buildFishScript(String command) { + return "if test -r \"$HOME/.config/fish/config.fish\"\n" + + " source \"$HOME/.config/fish/config.fish\"\n" + + "end\n" + + command; + } + + private static String buildPowerShellScript(String command) { + return "if (Test-Path $PROFILE) { . $PROFILE }; " + command; + } + + private static String buildCmdScript(String command) { + return "if exist \"%USERPROFILE%\\cmdrc.cmd\" call \"%USERPROFILE%\\cmdrc.cmd\"\r\n" + command; + } + + static void mergeWindowsPath(Map env, String machinePath, String userPath) { + String pathKey = env.containsKey("Path") ? "Path" : "PATH"; + String currentPath = env.get(pathKey); + Set items = new LinkedHashSet<>(); + + addPathItems(items, currentPath); + addPathItems(items, machinePath); + addPathItems(items, userPath); + + if (items.isEmpty() == false) { + env.put(pathKey, joinPathItems(items)); + } + } + + private static void enrichWindowsEnvironment(Map env) { + try { + mergeWindowsPath(env, + readWindowsEnvironmentVariable("Path", "Machine"), + readWindowsEnvironmentVariable("Path", "User")); + } catch (Throwable ignored) { + } + } + + private static String readWindowsEnvironmentVariable(String name, String target) throws Exception { + String shell = firstNotEmpty(findWindowsCommand("pwsh.exe"), findWindowsCommand("powershell.exe")); + if (shell == null) { + return null; + } + + Process process = new ProcessBuilder(shell, "-NoProfile", "-Command", + "[Environment]::GetEnvironmentVariable('" + name + "','" + target + "')") + .redirectErrorStream(true) + .start(); + StreamCollector collector = new StreamCollector(process.getInputStream()); + Thread collectorThread = new Thread(collector, "windows-env-reader"); + collectorThread.setDaemon(true); + collectorThread.start(); + + if (process.waitFor(5, TimeUnit.SECONDS) == false) { + process.destroyForcibly(); + return null; + } + collectorThread.join(TimeUnit.SECONDS.toMillis(1)); + + if (process.exitValue() != 0) { + return null; + } + + String output = collector.getOutput().trim(); + return output.length() == 0 ? null : output; + } + + private static void addPathItems(Set items, String path) { + if (path == null || path.trim().length() == 0) { + return; + } + + for (String item : path.split(";")) { + String normalized = item.trim(); + if (normalized.length() > 0) { + addPathItem(items, normalized); + } + } + } + + private static void addPathItem(Set items, String item) { + for (String existing : items) { + if (existing.equalsIgnoreCase(item)) { + return; + } + } + items.add(item); + } + + private static String joinPathItems(Set items) { + StringBuilder buf = new StringBuilder(); + for (String item : items) { + if (buf.length() > 0) { + buf.append(";"); + } + buf.append(item); + } + return buf.toString(); + } + + private static String findWindowsCommand(String command) { + String path = System.getenv("Path"); + if (path == null || path.length() == 0) { + path = System.getenv("PATH"); + } + if (path == null || path.length() == 0) { + return null; + } + + for (String dir : path.split(";")) { + if (dir == null || dir.trim().length() == 0) { + continue; + } + File file = new File(dir.trim(), command); + if (file.exists()) { + return file.getAbsolutePath(); + } + } + return null; + } + + private static ShellType resolveShellType(String shell) { + String name = new File(shell).getName().toLowerCase(); + if (name.endsWith(".exe")) { + name = name.substring(0, name.length() - 4); + } + + if ("zsh".equals(name)) { + return ShellType.ZSH; + } + + if ("bash".equals(name)) { + return ShellType.BASH; + } + + if ("fish".equals(name)) { + return ShellType.FISH; + } + + if ("pwsh".equals(name) || "powershell".equals(name)) { + return ShellType.POWERSHELL; + } + + if ("cmd".equals(name)) { + return ShellType.CMD; + } + + if (JavaUtil.IS_WINDOWS) { + return ShellType.CMD; + } + + return ShellType.SH; + } + + private static String firstNotEmpty(String... values) { + for (String val : values) { + if (val != null && val.trim().length() > 0) { + return val; + } + } + return null; + } + + enum ShellType { + ZSH, BASH, FISH, SH, POWERSHELL, CMD + } + + static class ShellInfo { + private final ShellType type; + private final String path; + + ShellInfo(ShellType type, String path) { + this.type = type; + this.path = path; + } + } + + public static class Result { + private final String command; + private final String cwd; + private final int exitCode; + private final long elapsedMs; + private final String output; + private final boolean timeout; + + public Result(String command, String cwd, int exitCode, long elapsedMs, String output, boolean timeout) { + this.command = command; + this.cwd = cwd; + this.exitCode = exitCode; + this.elapsedMs = elapsedMs; + this.output = output == null ? "" : output; + this.timeout = timeout; + } + + public String toDisplayText() { + StringBuilder buf = new StringBuilder(); + buf.append("$ ").append(command).append("\n"); + if (output.length() > 0) { + buf.append(output); + if (output.endsWith("\n") == false) { + buf.append("\n"); + } + } + buf.append("[exit: ").append(exitCode); + if (timeout) { + buf.append(", timeout"); + } + buf.append(", ").append(elapsedMs).append("ms]"); + return buf.toString(); + } + + public String toContextText() { + StringBuilder buf = new StringBuilder(); + buf.append("系统命令执行结果:\n"); + buf.append("命令:").append(command).append("\n"); + if (cwd != null && cwd.length() > 0) { + buf.append("工作目录:").append(cwd).append("\n"); + } + buf.append("退出码:").append(exitCode); + if (timeout) { + buf.append("(超时)"); + } + buf.append("\n"); + buf.append("耗时:").append(elapsedMs).append("ms\n"); + buf.append("输出:\n"); + if (output.length() > 0) { + buf.append(output); + } else { + buf.append("(无输出)"); + } + return buf.toString(); + } + } + + private static class StreamCollector implements Runnable { + private final InputStream inputStream; + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + private volatile IOException exception; + + private StreamCollector(InputStream inputStream) { + this.inputStream = inputStream; + } + + @Override + public void run() { + byte[] buffer = new byte[4096]; + try { + int len; + while ((len = inputStream.read(buffer)) >= 0) { + if (outputStream.size() < MAX_OUTPUT_CHARS * 4) { + outputStream.write(buffer, 0, len); + } + } + } catch (IOException e) { + exception = e; + } + } + + private String getOutput() throws IOException { + if (exception != null) { + throw exception; + } + + String text = new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + if (text.length() > MAX_OUTPUT_CHARS) { + return text.substring(0, MAX_OUTPUT_CHARS) + "\n[输出已截断,最多注入 " + MAX_OUTPUT_CHARS + " 字符]"; + } + return text; + } + } +} diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliCompleter.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliCompleter.java index f1eedc4f..638ff2e9 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliCompleter.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliCompleter.java @@ -27,6 +27,7 @@ import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; /** @@ -38,7 +39,7 @@ public class CliCompleter implements Completer { private final HarnessEngine engine; - public CliCompleter(HarnessEngine engine) { + public CliCompleter(HarnessEngine engine, String workspace) { this.engine = engine; } @@ -48,63 +49,99 @@ public void complete(LineReader reader, ParsedLine line, List candida return; } - if (line.word().startsWith("/")) { - String prefix = line.word().substring(1).toLowerCase(); - for (String name : engine.getCommandRegistry().names()) { - if (name.startsWith(prefix)) { - Command cmd = engine.getCommandRegistry().find(name); - // 构建补全提示:description + argument-hint - candidates.add(new Candidate("/" + name, "/" + name + " " + cmd.description(), null, null, null, null, true)); - } + String word = line.word(); + + if (word.startsWith("/")) { + completeCommands(word, candidates); + completeModels(word, candidates); + return; + } + + if (word.startsWith("@")) { + completeAgents(word, candidates); + return; + } + + if (word.startsWith("$")) { + completeSkills(word, candidates); + return; + } + + if (word.startsWith("!")) { + candidates.add(new Candidate("!", "!", null, "进入本地命令模式", null, null, true)); + candidates.add(new Candidate("!pwd", "!pwd", null, "执行一次本地命令", null, null, true)); + candidates.add(new Candidate("!ls", "!ls", null, "列出当前工作区文件", null, null, true)); + } + } + + private void completeCommands(String word, List candidates) { + String prefix = normalize(word.substring(1)); + for (String name : engine.getCommandRegistry().names()) { + if (normalize(name).startsWith(prefix)) { + Command cmd = engine.getCommandRegistry().find(name); + candidates.add(new Candidate("/" + name, "/" + name, null, cmd.description(), null, null, true)); } } + } - if (line.word().startsWith("/m")) { - String prefix = line.word().substring(1).toLowerCase(); - for (ChatConfig c : engine.getModels()) { - if (("model " + c.getNameOrModel()).startsWith(prefix)) { - candidates.add(new Candidate("/model " + c.getNameOrModel(), "/model " + c.getNameOrModel(), null, null, null, null, true)); - } + private void completeModels(String word, List candidates) { + String prefix = normalize(word.substring(1)); + for (ChatConfig c : engine.getModels()) { + if (normalize("model " + c.getNameOrModel()).startsWith(prefix)) { + candidates.add(new Candidate("/model " + c.getNameOrModel(), "/model " + c.getNameOrModel(), null, null, null, null, true)); } } + } - if (line.word().startsWith("@")) { - String prefix = line.word().substring(1).toLowerCase(); - for (AgentDefinition definition : engine.getAgentManager().getAgents()) { - if (definition.getName().startsWith(prefix)) { - // 构建补全提示:description + argument-hint - candidates.add(new Candidate("@" + definition.getName(), "@" + definition.getName() + " " + definition.getDescription(), null, null, null, null, true)); + private void completeSkills(String word, List candidates) { + Set added = new HashSet<>(); + String prefix = normalize(word.substring(1)); + for (SkillDir skill : engine.getSkills()) { + if (normalize(skill.getName()).startsWith(prefix)) { + if (added.add(skill.getName()) == false) { + continue; } + + String desc = shorten(skill.getDescription(), 40); + candidates.add(new Candidate("$" + skill.getName(), "$" + skill.getName(), null, desc, null, null, true)); } } + } - if (line.word().startsWith("$")) { - Set added = new HashSet<>(); - String prefix = line.word().substring(1).toLowerCase(); - for (SkillDir skill : engine.getSkills()) { - if (skill.getName().startsWith(prefix)) { - if (added.contains(skill.getName())) { - continue; - } else { - added.add(skill.getName()); - } - - // 构建补全提示:description + argument-hint - String desc = skill.getDescription(); - if (desc != null) { - // 取第一行,并限制最大长度 - int newlineIdx = desc.indexOf('\n'); - if (newlineIdx > 0) { - desc = desc.substring(0, newlineIdx); - } - if (desc.length() > 30) { - desc = desc.substring(0, 30) + "..."; - } - } - - candidates.add(new Candidate("$" + skill.getName(), "$" + skill.getName() + " " + desc, null, null, null, null, true)); - } + private void completeAgents(String word, List candidates) { + String prefix = normalize(word.substring(1)); + for (AgentDefinition agent : engine.getAgentManager().getAgents()) { + if (agent.isHidden()) { + continue; } + + if (normalize(agent.getName()).startsWith(prefix)) { + String desc = formatDescription("子代理", shorten(agent.getDescription(), 40)); + candidates.add(new Candidate("@" + agent.getName(), "@" + agent.getName(), null, desc, null, null, true, 0)); + } + } + } + + private String shorten(String desc, int maxLength) { + if (desc == null) { + return ""; + } + + int newlineIdx = desc.indexOf('\n'); + if (newlineIdx > 0) { + desc = desc.substring(0, newlineIdx); + } + if (desc.length() > maxLength) { + desc = desc.substring(0, maxLength - 3) + "..."; } + return desc; + } + + private String formatDescription(String type, String desc) { + return desc.length() == 0 ? type : type + " " + desc; + } + + private String normalize(String text) { + return text.toLowerCase(Locale.ROOT); } } diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliShell.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliShell.java index 501ad8c9..1baf5248 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliShell.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliShell.java @@ -16,9 +16,14 @@ package org.noear.solon.codecli.portal.cli; import org.jline.reader.EndOfFileException; +import org.jline.reader.Candidate; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; +import org.jline.reader.ParsedLine; +import org.jline.reader.Parser; import org.jline.reader.UserInterruptException; +import org.jline.reader.Widget; +import org.jline.reader.impl.LineReaderImpl; import org.jline.terminal.Attributes; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; @@ -41,6 +46,7 @@ import org.noear.solon.ai.talents.memory.MemoryTalent; import org.noear.solon.ai.util.CmdUtil; import org.noear.solon.codecli.command.CliCommandContext; +import org.noear.solon.codecli.command.ShellCommandSupport; import org.noear.solon.codecli.config.AgentFlags; import org.noear.solon.codecli.config.AgentProperties; import org.noear.solon.codecli.command.builtin.LoopScheduler; @@ -53,6 +59,7 @@ import reactor.core.scheduler.Schedulers; import java.io.File; +import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalTime; @@ -67,14 +74,18 @@ @Preview("3.9.4") public class CliShell implements Runnable { private final static Logger LOG = LoggerFactory.getLogger(CliShell.class); + private static final Field JLINE_POST_FIELD = initJlinePostField(); private final static String SESSION_ID_CLI = "cli"; private Terminal terminal; private LineReader reader; private final HarnessEngine engine; + private final CliCompleter completer; private final AgentProperties agentProps; private final LoopScheduler loopScheduler; + private int cliHintStart = -1; + private boolean largeHintPrompted; // ANSI 颜色常量 private final static String @@ -93,6 +104,7 @@ public CliShell(HarnessEngine engine, AgentProperties agentProps, LoopScheduler this.engine = engine; this.agentProps = agentProps; this.loopScheduler = loopScheduler; + this.completer = new CliCompleter(engine, engine.getWorkspace()); try { this.terminal = TerminalBuilder.builder() @@ -101,9 +113,11 @@ public CliShell(HarnessEngine engine, AgentProperties agentProps, LoopScheduler .build(); this.reader = LineReaderBuilder.builder() + .appName("SolonCode") .terminal(terminal) - .completer(new CliCompleter(engine)) + .completer(completer) .build(); + setupInputHints(); } catch (Throwable e) { LOG.error("JLine initialization failed", e); } @@ -117,6 +131,193 @@ public LineReader getReader() { return reader; } + private static Field initJlinePostField() { + try { + Field field = LineReaderImpl.class.getDeclaredField("post"); + field.setAccessible(true); + return field; + } catch (Throwable e) { + return null; + } + } + + private void setupInputHints() { + reader.setOpt(LineReader.Option.AUTO_LIST); + reader.setOpt(LineReader.Option.AUTO_MENU_LIST); + reader.setOpt(LineReader.Option.CASE_INSENSITIVE); + reader.setVariable(LineReader.MENU_LIST_MAX, 12); + reader.setVariable(LineReader.COMPLETION_STYLE_LIST_BACKGROUND, "bg:default"); + reader.setVariable(LineReader.COMPLETION_STYLE_LIST_STARTING, "fg:default"); + + if (reader instanceof LineReaderImpl) { + LineReaderImpl impl = (LineReaderImpl) reader; + wrapSelfInsert(impl); + wrapDeleteWidget(impl, LineReader.BACKWARD_DELETE_CHAR); + wrapDeleteWidget(impl, LineReader.VI_BACKWARD_DELETE_CHAR); + wrapDeleteWidget(impl, LineReader.DELETE_CHAR); + wrapDeleteWidget(impl, LineReader.KILL_WORD); + wrapDeleteWidget(impl, LineReader.BACKWARD_KILL_WORD); + wrapDeleteWidget(impl, LineReader.KILL_LINE); + wrapDeleteWidget(impl, LineReader.KILL_WHOLE_LINE); + } + } + + private void wrapSelfInsert(LineReaderImpl impl) { + Widget selfInsert = impl.getWidgets().get(LineReader.SELF_INSERT); + if (selfInsert == null) { + return; + } + + impl.getWidgets().put(LineReader.SELF_INSERT, () -> { + int cursorBefore = impl.getBuffer().cursor(); + boolean activeBefore = hasActiveHint(impl); + boolean handled = selfInsert.apply(); + String binding = impl.getLastBinding(); + + if (handled && isTriggerBinding(binding) && isTokenStart(impl, cursorBefore)) { + cliHintStart = cursorBefore; + showInputChoices(impl); + } else if (handled && activeBefore) { + if (isCompletingActiveHint(impl)) { + showInputChoices(impl); + } else { + clearInputChoices(impl); + } + } + return handled; + }); + } + + private void wrapDeleteWidget(LineReaderImpl impl, String widgetName) { + Widget deleteWidget = impl.getWidgets().get(widgetName); + if (deleteWidget == null) { + return; + } + + impl.getWidgets().put(widgetName, () -> { + String before = impl.getBuffer().toString(); + int cursorBefore = impl.getBuffer().cursor(); + boolean activeBefore = hasActiveHint(impl); + boolean deletesTrigger = deletesTrigger(widgetName, before, cursorBefore); + boolean handled = deleteWidget.apply(); + + if (handled && activeBefore) { + if (deletesTrigger || isCompletingActiveHint(impl) == false) { + clearInputChoices(impl); + } else { + showInputChoices(impl); + } + } else if (handled && deletesTrigger) { + clearInputChoices(impl); + } + return handled; + }); + } + + private boolean isTriggerBinding(String binding) { + return "/".equals(binding) || "@".equals(binding) || "$".equals(binding) || "!".equals(binding); + } + + private boolean deletesTrigger(String widgetName, String buffer, int cursorBefore) { + if (LineReader.BACKWARD_DELETE_CHAR.equals(widgetName) || LineReader.VI_BACKWARD_DELETE_CHAR.equals(widgetName)) { + return isTriggerAt(buffer, cursorBefore - 1); + } + if (LineReader.DELETE_CHAR.equals(widgetName)) { + return isTriggerAt(buffer, cursorBefore); + } + return hasActiveHint(buffer); + } + + private boolean hasActiveHint(LineReaderImpl impl) { + return hasActiveHint(impl.getBuffer().toString()); + } + + private boolean hasActiveHint(String buffer) { + return cliHintStart >= 0 && isTriggerAt(buffer, cliHintStart); + } + + static boolean isTriggerAt(String buffer, int index) { + if (index < 0 || index >= buffer.length()) { + return false; + } + char c = buffer.charAt(index); + return (c == '/' || c == '@' || c == '$' || c == '!') && + (index == 0 || Character.isWhitespace(buffer.charAt(index - 1))); + } + + private boolean isCompletingActiveHint(LineReaderImpl impl) { + return isCompletingHintToken(impl.getBuffer().toString(), cliHintStart, impl.getBuffer().cursor()); + } + + static boolean isCompletingHintToken(String buffer, int hintStart, int cursor) { + if (isTriggerAt(buffer, hintStart) == false || cursor <= hintStart || cursor > buffer.length()) { + return false; + } + + for (int i = hintStart + 1; i < cursor; i++) { + if (Character.isWhitespace(buffer.charAt(i))) { + return false; + } + } + return true; + } + + private boolean isTokenStart(LineReaderImpl impl, int cursorBefore) { + if (cursorBefore == 0) { + return true; + } + return Character.isWhitespace(impl.getBuffer().atChar(cursorBefore - 1)); + } + + private void showInputChoices(LineReaderImpl impl) { + if (isLargeHint(impl)) { + if (largeHintPrompted) { + clearPost(impl); + impl.callWidget("." + LineReader.REDISPLAY); + return; + } + largeHintPrompted = true; + } + impl.callWidget("." + LineReader.LIST_CHOICES); + } + + private boolean isLargeHint(LineReaderImpl impl) { + return isLargeHintCandidateCount(countInputChoiceCandidates(impl), terminal.getSize().getRows()); + } + + static boolean isLargeHintCandidateCount(int candidateCount, int terminalRows) { + if (candidateCount <= 0 || terminalRows <= 0) { + return false; + } + return candidateCount >= Math.max(1, terminalRows - 1); + } + + private int countInputChoiceCandidates(LineReaderImpl impl) { + try { + ParsedLine line = reader.getParser().parse(impl.getBuffer().toString(), impl.getBuffer().cursor(), Parser.ParseContext.COMPLETE); + List candidates = new ArrayList<>(); + completer.complete(reader, line, candidates); + return candidates.size(); + } catch (Throwable e) { + return 0; + } + } + + private void clearInputChoices(LineReaderImpl impl) { + cliHintStart = -1; + clearPost(impl); + impl.callWidget("." + LineReader.REDISPLAY); + } + + private void clearPost(LineReaderImpl impl) { + if (JLINE_POST_FIELD != null) { + try { + JLINE_POST_FIELD.set(impl, null); + } catch (Throwable ignored) { + } + } + } + /** * 预备开始 */ @@ -151,7 +352,9 @@ public void call(String input) { AgentSession session = prepare(SESSION_ID_CLI); try { - if (!isCommand(session, input)) { + if (ShellCommandSupport.isShellCommand(input)) { + runShellCommand(session, input); + } else if (!isCommand(session, input)) { performAgentTask(session, input, null); } } catch (Throwable e) { @@ -182,30 +385,59 @@ public void run() { } // 2. 主循环 + boolean shellMode = false; while (true) { try { String input; try { terminal.writer().println(); - terminal.writer().print(BOLD + CYAN + "User" + RESET); + terminal.writer().print(BOLD + (shellMode ? YELLOW + "Shell" : CYAN + "User") + RESET); terminal.writer().println(); terminal.flush(); - input = reader.readLine(CYAN + "❯ " + RESET).trim(); + input = reader.readLine((shellMode ? YELLOW + "$ " : CYAN + "❯ ") + RESET).trim(); } catch (UserInterruptException e) { + if (shellMode) { + shellMode = false; + terminal.writer().println(DIM + "已退出本地命令模式" + RESET); + terminal.flush(); + } continue; } catch (EndOfFileException e) { + if (shellMode) { + shellMode = false; + terminal.writer().println(DIM + "已退出本地命令模式" + RESET); + terminal.flush(); + continue; + } terminal.writer().println("\nBye!"); terminal.flush(); break; // 直接跳出主循环,优雅退出 } + if (shellMode) { + if (Assert.isEmpty(input) || "exit".equals(input) || "/exit".equals(input)) { + shellMode = false; + terminal.writer().println(DIM + "已退出本地命令模式" + RESET); + terminal.flush(); + } else { + runShellCommand(session, "!" + input); + } + continue; + } + if (Assert.isEmpty(input)) { continue; } - if (!isCommand(session, input)) { + if ("!".equals(input)) { + shellMode = true; + terminal.writer().println(DIM + "已进入本地命令模式,输入空行或 exit 退出" + RESET); + terminal.flush(); + } else if (ShellCommandSupport.isShellCommand(input)) { + runShellCommand(session, input); + } else if (!isCommand(session, input)) { performAgentTask(session, input, null); } } catch (Throwable e) { @@ -228,6 +460,14 @@ private void safeChatInput(AgentSession session, String prompt) { } } + private void runShellCommand(AgentSession session, String input) throws Exception { + ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject(session, engine.getWorkspace(), input); + terminal.writer().println(); + terminal.writer().println(BOLD + "Shell" + RESET + DIM + " " + getTimeNow() + RESET); + terminal.writer().println(" " + result.toDisplayText().replace("\n", "\n ")); + terminal.flush(); + } + private boolean isCommand(AgentSession session, String input) throws Exception { if (!input.startsWith("/")) { return false; @@ -675,7 +915,8 @@ protected void printWelcome(AgentSession session) { RESET + "(esc)" + DIM + " interrupt | " + RESET + "/(tab)" + DIM + " command | " + RESET + "$(tab)" + DIM + " skill | " + - RESET + "@(tab)" + DIM + " agent" + RESET); + RESET + "@(tab)" + DIM + " agent | " + + RESET + "!(tab)" + DIM + " shell" + RESET); terminal.flush(); } @@ -698,4 +939,4 @@ public void printWelcome(String text) { System.err.println(text); System.err.flush(); } -} \ No newline at end of file +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/command/ShellCommandSupportTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/command/ShellCommandSupportTest.java new file mode 100644 index 00000000..32eb8cc4 --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/command/ShellCommandSupportTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2017-2026 noear.org and authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.noear.solon.codecli.command; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.noear.solon.ai.agent.session.FileAgentSession; +import org.noear.solon.ai.chat.ChatRole; +import org.noear.solon.ai.chat.message.ChatMessage; +import org.noear.solon.core.util.JavaUtil; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class ShellCommandSupportTest { + @TempDir + Path tempDir; + + @Test + public void isShellCommand() { + assertTrue(ShellCommandSupport.isShellCommand("!pwd")); + assertTrue(ShellCommandSupport.isShellCommand("!")); + assertFalse(ShellCommandSupport.isShellCommand("/model")); + assertFalse(ShellCommandSupport.isShellCommand("pwd")); + assertFalse(ShellCommandSupport.isShellCommand(null)); + } + + @Test + public void executeAndInject() throws Exception { + FileAgentSession session = new FileAgentSession("shell-test", tempDir.resolve("session").toString()); + String input = JavaUtil.IS_WINDOWS ? "!cd" : "!pwd"; + + ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject(session, tempDir.toString(), input); + + assertTrue(result.toDisplayText().startsWith("$ " + input.substring(1))); + assertTrue(result.toDisplayText().contains("[exit: 0")); + + assertEquals(1, session.getMessages().size()); + ChatMessage message = session.getMessages().get(0); + assertEquals(ChatRole.USER, message.getRole()); + assertTrue(message.getContent().contains("系统命令执行结果")); + assertTrue(message.getContent().contains("命令:" + input.substring(1))); + assertTrue(message.getContent().contains("工作目录:" + tempDir)); + assertTrue(message.getContent().contains(tempDir.toString())); + assertTrue(message.getContent().contains("退出码:0")); + } + + @Test + public void executeAndInjectEmptyCommand() { + FileAgentSession session = new FileAgentSession("shell-empty-test", tempDir.resolve("empty-session").toString()); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> ShellCommandSupport.executeAndInject(session, tempDir.toString(), "!")); + + assertEquals("系统命令不能为空", e.getMessage()); + assertTrue(session.getMessages().isEmpty()); + } + + @Test + public void zshCommandLoadsZshrc() { + List args = ShellCommandSupport.buildShellArgs( + new ShellCommandSupport.ShellInfo(ShellCommandSupport.ShellType.ZSH, "/bin/zsh"), + "hello_from_alias"); + + assertEquals("/bin/zsh", args.get(0)); + assertEquals("-lc", args.get(1)); + assertTrue(args.get(2).contains("ZDOTDIR")); + assertTrue(args.get(2).contains(".zshrc")); + assertTrue(args.get(2).endsWith("\nhello_from_alias")); + } + + @Test + public void bashCommandLoadsBashrcAndExpandsAliases() { + List args = ShellCommandSupport.buildShellArgs( + new ShellCommandSupport.ShellInfo(ShellCommandSupport.ShellType.BASH, "/bin/bash"), + "hello_from_alias"); + + assertEquals("/bin/bash", args.get(0)); + assertEquals("-lc", args.get(1)); + assertTrue(args.get(2).contains("shopt -s expand_aliases")); + assertTrue(args.get(2).contains("$HOME/.bashrc")); + assertTrue(args.get(2).endsWith("\nhello_from_alias")); + } + + @Test + public void powershellCommandLoadsProfile() { + List args = ShellCommandSupport.buildShellArgs( + new ShellCommandSupport.ShellInfo(ShellCommandSupport.ShellType.POWERSHELL, "pwsh"), + "Get-Location"); + + assertEquals("pwsh", args.get(0)); + assertFalse(args.contains("-NoProfile")); + assertTrue(args.contains("-Command")); + assertTrue(args.get(args.size() - 1).contains("$PROFILE")); + } + + @Test + public void cmdCommandLoadsOptionalCmdrcAndStillRunsCommand() { + List args = ShellCommandSupport.buildShellArgs( + new ShellCommandSupport.ShellInfo(ShellCommandSupport.ShellType.CMD, "cmd"), + "where java"); + + assertEquals("cmd", args.get(0)); + assertEquals("/c", args.get(1)); + assertTrue(args.get(2).contains("%USERPROFILE%\\cmdrc.cmd")); + assertTrue(args.get(2).endsWith("\r\nwhere java")); + } + + @Test + public void windowsPathMergeKeepsCurrentPathAndAddsMissingRegistryPaths() { + Map env = new LinkedHashMap<>(); + env.put("Path", "C:\\App\\bin;C:\\Windows\\System32"); + + ShellCommandSupport.mergeWindowsPath(env, "C:\\Windows\\System32;C:\\Program Files\\Git\\cmd", + "C:\\Users\\me\\AppData\\Local\\Programs\\Tool\\bin"); + + assertEquals("C:\\App\\bin;C:\\Windows\\System32;C:\\Program Files\\Git\\cmd;C:\\Users\\me\\AppData\\Local\\Programs\\Tool\\bin", + env.get("Path")); + } +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliCompleterTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliCompleterTest.java new file mode 100644 index 00000000..31baaadb --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliCompleterTest.java @@ -0,0 +1,211 @@ +/* + * Copyright 2017-2026 noear.org and authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.noear.solon.codecli.portal.cli; + +import org.jline.reader.Candidate; +import org.jline.reader.ParsedLine; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.noear.solon.ai.harness.HarnessEngine; +import org.noear.solon.ai.harness.agent.AgentDefinition; +import org.noear.solon.ai.harness.command.Command; +import org.noear.solon.ai.harness.command.CommandContext; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CliCompleterTest { + @TempDir + Path tempDir; + + @Test + public void atTriggerCompletesAgentsOnly() throws Exception { + Files.createDirectories(tempDir.resolve("src/main")); + Files.write(tempDir.resolve("src/main/App.java"), Collections.singletonList("class App {}")); + + HarnessEngine engine = HarnessEngine.of("test", tempDir.toString()).build(); + engine.getAgentManager().addAgent(createAgent("reviewer", "Code review specialist")); + + CliCompleter completer = new CliCompleter(engine, tempDir.toString()); + List candidates = new ArrayList<>(); + + completer.complete(null, new TestParsedLine("@"), candidates); + + assertTrue(containsValue(candidates, "@reviewer")); + assertEquals("子代理 Code review specialist", findCandidate(candidates, "@reviewer").descr()); + assertFalse(containsValue(candidates, "@src/")); + assertFalse(containsValue(candidates, "@src/main/App.java")); + } + + @Test + public void atTriggerFiltersAgentsByPrefix() throws Exception { + HarnessEngine engine = HarnessEngine.of("test", tempDir.toString()).build(); + engine.getAgentManager().addAgent(createAgent("reviewer", "Code review specialist")); + engine.getAgentManager().addAgent(createAgent("writer", "Writing specialist")); + + CliCompleter completer = new CliCompleter(engine, tempDir.toString()); + List candidates = new ArrayList<>(); + + completer.complete(null, new TestParsedLine("@rev"), candidates); + + assertTrue(containsValue(candidates, "@reviewer")); + assertFalse(containsValue(candidates, "@writer")); + } + + @Test + public void slashTriggerFiltersCommandsByPrefix() { + HarnessEngine engine = HarnessEngine.of("test", tempDir.toString()).build(); + engine.getCommandRegistry().register(createCommand("clear", "清空会话记录")); + engine.getCommandRegistry().register(createCommand("exit", "退出进程")); + + CliCompleter completer = new CliCompleter(engine, tempDir.toString()); + List candidates = new ArrayList<>(); + + completer.complete(null, new TestParsedLine("/c"), candidates); + + assertTrue(containsValue(candidates, "/clear")); + assertEquals("清空会话记录", findCandidate(candidates, "/clear").descr()); + assertFalse(containsValue(candidates, "/exit")); + } + + @Test + public void atTriggerDoesNotCollectFileHints() throws Exception { + for (int i = 0; i < 20; i++) { + Files.write(tempDir.resolve(String.format("file%02d.txt", i)), Collections.singletonList("text")); + } + + HarnessEngine engine = HarnessEngine.of("test", tempDir.toString()).build(); + CliCompleter completer = new CliCompleter(engine, tempDir.toString()); + List candidates = new ArrayList<>(); + + completer.complete(null, new TestParsedLine("@"), candidates); + + assertEquals(0, countValuePrefix(candidates, "@file")); + } + + @Test + public void slashTriggerKeepsAllMatchingCommandHints() { + HarnessEngine engine = HarnessEngine.of("test", tempDir.toString()).build(); + for (int i = 0; i < 20; i++) { + engine.getCommandRegistry().register(createCommand(String.format("zzcmd%02d", i), "command " + i)); + } + + CliCompleter completer = new CliCompleter(engine, tempDir.toString()); + List candidates = new ArrayList<>(); + + completer.complete(null, new TestParsedLine("/zzcmd"), candidates); + + assertEquals(20, candidates.size()); + } + + private AgentDefinition createAgent(String name, String description) { + AgentDefinition.Metadata metadata = new AgentDefinition.Metadata(); + metadata.setName(name); + metadata.setDescription(description); + + AgentDefinition definition = new AgentDefinition(); + definition.setMetadata(metadata); + definition.setSystemPrompt("You are " + name + "."); + return definition; + } + + private Command createCommand(String name, String description) { + return new Command() { + @Override + public String name() { + return name; + } + + @Override + public String description() { + return description; + } + + @Override + public boolean execute(CommandContext ctx) { + return true; + } + }; + } + + private boolean containsValue(List candidates, String value) { + return findCandidate(candidates, value) != null; + } + + private Candidate findCandidate(List candidates, String value) { + for (Candidate candidate : candidates) { + if (value.equals(candidate.value())) { + return candidate; + } + } + return null; + } + + private int countValuePrefix(List candidates, String prefix) { + int count = 0; + for (Candidate candidate : candidates) { + if (candidate.value().startsWith(prefix)) { + count++; + } + } + return count; + } + + private static class TestParsedLine implements ParsedLine { + private final String word; + + private TestParsedLine(String word) { + this.word = word; + } + + @Override + public String word() { + return word; + } + + @Override + public int wordCursor() { + return word.length(); + } + + @Override + public int wordIndex() { + return 0; + } + + @Override + public List words() { + return Collections.singletonList(word); + } + + @Override + public String line() { + return word; + } + + @Override + public int cursor() { + return word.length(); + } + } +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliShellTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliShellTest.java new file mode 100644 index 00000000..f19678fd --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliShellTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2017-2026 noear.org and authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.noear.solon.codecli.portal.cli; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CliShellTest { + @Test + public void completingHintTokenStaysActiveWhileTypingSameToken() { + assertTrue(CliShell.isCompletingHintToken("/", 0, 1)); + assertTrue(CliShell.isCompletingHintToken("/c", 0, 2)); + assertTrue(CliShell.isCompletingHintToken("@reviewer", 0, 9)); + assertTrue(CliShell.isCompletingHintToken("hello @rev", 6, 10)); + } + + @Test + public void completingHintTokenStopsAfterWhitespaceOrInvalidTrigger() { + assertFalse(CliShell.isCompletingHintToken("@reviewer ", 0, 10)); + assertFalse(CliShell.isCompletingHintToken("hello@rev", 5, 9)); + assertFalse(CliShell.isCompletingHintToken("/c", 0, 0)); + assertFalse(CliShell.isCompletingHintToken("/c", 0, 3)); + } + + @Test + public void largeHintCandidateCountTracksTerminalRows() { + assertTrue(CliShell.isLargeHintCandidateCount(84, 24)); + assertTrue(CliShell.isLargeHintCandidateCount(23, 24)); + assertFalse(CliShell.isLargeHintCandidateCount(22, 24)); + assertFalse(CliShell.isLargeHintCandidateCount(84, 0)); + } +}